mirror of
https://gitlab.com/itsulu-odoo/itsulu-blog-publisher.git
synced 2026-05-30 23:41:23 +00:00
- views/blog_schedule_views.xml: web_ribbon invisible="active" (Odoo 17
bare-expr syntax) -> attrs="{'invisible': [('active','=',True)]}".
This was the view-validation error blocking module install on Odoo 14.
- tests: Odoo 14 TransactionCase exposes self.env in setUp(), not cls.env
in setUpClass() (that pattern is Odoo 15+). Converted all 13 setUpClass
blocks across 6 test files to setUp(self) + self.env/self.factory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""
|
||
Performance benchmarks for ITSulu Blog Publisher.
|
||
Measures latency, query count, token usage, and throughput.
|
||
|
||
These tests establish baseline metrics for Phase 3 SLO tracking.
|
||
"""
|
||
import logging
|
||
import time
|
||
from odoo.tests import TransactionCase, tagged
|
||
from .factories import BlogPublisherFactory
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
@tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'performance')
|
||
class TestGenerationLatency(TransactionCase):
|
||
"""Measure time from run_generation() call to blog.post creation."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.factory = BlogPublisherFactory(self.env)
|
||
self.blog = self.factory.blog(name='ITSulu Insights')
|
||
self.env['ir.config_parameter'].sudo().set_param(
|
||
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
|
||
)
|
||
|
||
def test_generation_latency_under_30_seconds(self):
|
||
"""
|
||
Full generation pipeline (LLM call, post creation, social copy, logging)
|
||
should complete in < 30 seconds.
|
||
|
||
This is a baseline measurement. In production with real API calls,
|
||
latency includes network time. With mocked LLM, this measures
|
||
local overhead (DB writes, rendering, email, etc.).
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
schedule = self.factory.blog_schedule(blog=self.blog, active=True)
|
||
|
||
# Mock LLM response
|
||
mock_response = MagicMock()
|
||
mock_response.title = 'Test Post'
|
||
mock_response.body_html = '<h1>Test</h1><p>' + ('Content. ' * 100) + '</p>'
|
||
mock_response.meta_title = 'Test SEO'
|
||
mock_response.meta_description = 'Test description'
|
||
mock_response.meta_keywords = 'test'
|
||
mock_response.tags = ['test']
|
||
mock_response.tokens_used = 800
|
||
mock_response.raw_text = '<p>Mock response</p>'
|
||
mock_response.social = MagicMock(
|
||
twitter_a='Tweet A', twitter_b='Tweet B',
|
||
bluesky_a='BlueSky A', bluesky_b='BlueSky B',
|
||
mastodon='Mastodon', linkedin='LinkedIn'
|
||
)
|
||
mock_response.sources = []
|
||
|
||
with patch(
|
||
'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate',
|
||
return_value=mock_response,
|
||
):
|
||
start = time.monotonic()
|
||
post = schedule.run_generation()
|
||
elapsed = time.monotonic() - start
|
||
|
||
# Assert latency target
|
||
self.assertLess(elapsed, 30,
|
||
f"Generation took {elapsed:.2f}s, target <30s")
|
||
|
||
_logger.info(
|
||
'performance.generation_latency elapsed_seconds=%.2f post_id=%d',
|
||
elapsed, post.id,
|
||
)
|
||
|
||
def test_social_copy_generation_overhead(self):
|
||
"""
|
||
Social copy creation should add < 2 seconds to generation time.
|
||
This measures the overhead of creating itsulu.blog.post.social
|
||
and substituting URLs in social posts.
|
||
"""
|
||
schedule = self.factory.blog_schedule(blog=self.blog, active=True)
|
||
|
||
# Create post without social copy (baseline)
|
||
post1 = self.factory.blog_post(blog=self.blog, name='Post 1')
|
||
start1 = time.monotonic()
|
||
# Direct social creation (simulating schedule._create_social_record)
|
||
self.env['itsulu.blog.post.social'].create({
|
||
'blog_post_id': post1.id,
|
||
'twitter_post_a': 'Twitter A' * 20, # long post
|
||
'twitter_post_b': 'Twitter B' * 20,
|
||
'linkedin_post': 'LinkedIn ' * 50,
|
||
})
|
||
elapsed1 = time.monotonic() - start1
|
||
|
||
# With email template rendering (if enabled)
|
||
# This would add additional time, but we're measuring social copy only
|
||
|
||
self.assertLess(elapsed1, 2,
|
||
f"Social copy creation took {elapsed1:.2f}s, target <2s")
|
||
|
||
|
||
@tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'performance')
|
||
class TestQueryCount(TransactionCase):
|
||
"""Verify N+1 query patterns don't exist in critical paths."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.factory = BlogPublisherFactory(self.env)
|
||
self.blog = self.factory.blog(name='ITSulu Insights')
|
||
self.env['ir.config_parameter'].sudo().set_param(
|
||
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
|
||
)
|
||
|
||
def test_generation_uses_fewer_than_50_queries(self):
|
||
"""
|
||
Full generation pipeline should use < 50 database queries.
|
||
This catches N+1 patterns early (e.g., iterating posts without prefetch).
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
schedule = self.factory.blog_schedule(blog=self.blog, active=True)
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.title = 'Test'
|
||
mock_response.body_html = '<p>Content</p>'
|
||
mock_response.meta_title = 'Test'
|
||
mock_response.meta_description = 'Desc'
|
||
mock_response.meta_keywords = 'kw'
|
||
mock_response.tags = ['tag1']
|
||
mock_response.tokens_used = 800
|
||
mock_response.raw_text = '<p>Raw</p>'
|
||
mock_response.social = MagicMock(
|
||
twitter_a='T', twitter_b='T', bluesky_a='B', bluesky_b='B',
|
||
mastodon='M', linkedin='L'
|
||
)
|
||
mock_response.sources = []
|
||
|
||
with patch(
|
||
'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate',
|
||
return_value=mock_response,
|
||
):
|
||
# Assert query count < 50 during generation
|
||
with self.assertQueryCount(50):
|
||
schedule.run_generation()
|
||
|
||
def test_topic_get_next_topic_uses_single_query(self):
|
||
"""
|
||
Getting the next topic from queue should use exactly 1 query
|
||
(no extra searches for priority, state, blog, etc.).
|
||
"""
|
||
# Create pending topics
|
||
self.factory.blog_topic(name='Topic 1', priority='urgent', state='pending')
|
||
self.factory.blog_topic(name='Topic 2', priority='high', state='pending')
|
||
|
||
with self.assertQueryCount(1):
|
||
topic = self.env['itsulu.blog.topic'].get_next_topic()
|
||
|
||
self.assertEqual(topic.name, 'Topic 1') # Urgent should be first
|
||
|
||
def test_log_list_view_uses_single_query_per_record(self):
|
||
"""
|
||
Loading a list of generation logs should not have N+1 queries
|
||
when accessing related blog_post, schedule_slot, triggered_by, etc.
|
||
"""
|
||
# Create multiple logs
|
||
for i in range(5):
|
||
post = self.factory.blog_post(blog=self.blog, name=f'Post {i}')
|
||
self.factory.generation_log(blog_post=post, state='success')
|
||
|
||
# Fetch and access related records (simulate list view rendering)
|
||
with self.assertQueryCount(2): # 1 for logs + 1 for related blog_post prefetch
|
||
logs = self.env['itsulu.blog.generation.log'].search([])
|
||
for log in logs:
|
||
_ = log.blog_post_id.name # Access related post
|
||
|
||
|
||
@tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'performance')
|
||
class TestTokenUsageBaseline(TransactionCase):
|
||
"""Establish token usage baseline for cost tracking."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.factory = BlogPublisherFactory(self.env)
|
||
self.blog = self.factory.blog(name='ITSulu Insights')
|
||
|
||
def test_typical_post_uses_800_to_1200_tokens(self):
|
||
"""
|
||
A typical blog post (~800 words) should use 800–1200 tokens.
|
||
This is a baseline for cost estimation and budget alerts.
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
schedule = self.factory.blog_schedule(blog=self.blog, active=True)
|
||
|
||
# Simulate response with mid-range token usage
|
||
mock_response = MagicMock()
|
||
mock_response.title = 'Kubernetes Best Practices'
|
||
mock_response.body_html = '<p>' + ('This is content about Kubernetes. ' * 100) + '</p>'
|
||
mock_response.meta_title = 'Kubernetes'
|
||
mock_response.meta_description = 'Best practices'
|
||
mock_response.meta_keywords = 'k8s'
|
||
mock_response.tags = ['kubernetes']
|
||
mock_response.tokens_used = 950 # Mid-range
|
||
mock_response.raw_text = '<p>Raw response</p>'
|
||
mock_response.social = MagicMock(
|
||
twitter_a='Tweet', twitter_b='Tweet', bluesky_a='BS', bluesky_b='BS',
|
||
mastodon='Mast', linkedin='LI'
|
||
)
|
||
mock_response.sources = []
|
||
|
||
with patch(
|
||
'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate',
|
||
return_value=mock_response,
|
||
):
|
||
post = schedule.run_generation()
|
||
|
||
# Verify token count is in expected range
|
||
log = self.env['itsulu.blog.generation.log'].search(
|
||
[('blog_post_id', '=', post.id)], limit=1
|
||
)
|
||
|
||
self.assertGreaterEqual(log.tokens_used, 800,
|
||
f"Token usage too low: {log.tokens_used}")
|
||
self.assertLessEqual(log.tokens_used, 1200,
|
||
f"Token usage too high: {log.tokens_used}")
|
||
|
||
|
||
@tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'performance')
|
||
class TestConcurrentGeneration(TransactionCase):
|
||
"""Test that concurrent post generation handles contention correctly."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.factory = BlogPublisherFactory(self.env)
|
||
self.blog = self.factory.blog(name='ITSulu Insights')
|
||
self.env['ir.config_parameter'].sudo().set_param(
|
||
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
|
||
)
|
||
|
||
def test_two_simultaneous_generations_both_succeed(self):
|
||
"""
|
||
Two schedule slots generating posts simultaneously should not
|
||
create conflicts. Both posts should be created successfully.
|
||
|
||
In a real scenario with locks, this ensures:
|
||
- No duplicate post IDs
|
||
- No shared state corruption
|
||
- Both logs created independently
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
schedule1 = self.factory.blog_schedule(blog=self.blog, slot='morning', active=True)
|
||
schedule2 = self.factory.blog_schedule(blog=self.blog, slot='afternoon', active=True)
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.title = 'Post'
|
||
mock_response.body_html = '<p>Body</p>'
|
||
mock_response.meta_title = 'Title'
|
||
mock_response.meta_description = 'Desc'
|
||
mock_response.meta_keywords = 'kw'
|
||
mock_response.tags = []
|
||
mock_response.tokens_used = 800
|
||
mock_response.raw_text = '<p>Raw</p>'
|
||
mock_response.social = MagicMock(
|
||
twitter_a='T', twitter_b='T', bluesky_a='B', bluesky_b='B',
|
||
mastodon='M', linkedin='L'
|
||
)
|
||
mock_response.sources = []
|
||
|
||
with patch(
|
||
'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate',
|
||
return_value=mock_response,
|
||
):
|
||
# Simulate concurrent calls (sequentially, but independent state)
|
||
post1 = schedule1.run_generation()
|
||
post2 = schedule2.run_generation()
|
||
|
||
# Both posts should exist and be different
|
||
self.assertIsNotNone(post1)
|
||
self.assertIsNotNone(post2)
|
||
self.assertNotEqual(post1.id, post2.id)
|
||
|
||
# Both should have logs
|
||
log1 = self.env['itsulu.blog.generation.log'].search(
|
||
[('blog_post_id', '=', post1.id)], limit=1
|
||
)
|
||
log2 = self.env['itsulu.blog.generation.log'].search(
|
||
[('blog_post_id', '=', post2.id)], limit=1
|
||
)
|
||
|
||
self.assertTrue(log1)
|
||
self.assertTrue(log2)
|
||
self.assertEqual(log1.state, 'success')
|
||
self.assertEqual(log2.state, 'success')
|