itsulu-blog-publisher/addons/itsulu_blog_publisher/tests/test_performance.py
Nicholas Riegel 6635313f58 port(14.0): fix web_ribbon view attr + convert tests to setUp/self.env
- 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>
2026-05-30 11:44:24 -04:00

293 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 8001200 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')