itsulu-blog-publisher/addons/itsulu_blog_publisher/tests/test_performance.py
Nicholas Riegel c039b5f0cb release: v0.4.8 — CI pipeline green + Odoo 17 fixes (squash of !1)
Squash-merge of fix/ci-pipeline-corrections. Drives the full test suite
to 69/69 green on the ITSulu K8s cluster and fixes two production bugs.

Production fixes:
- Email template migrated from dead Odoo Mako (${}/% for) to Odoo 17
  inline_template ({{ }}) + qweb body (type="html", t-out/t-foreach/t-if).
  Notification emails previously rendered raw code in the subject/body.
- _create_blog_post now writes 'content': llm_response.body_html — every
  auto-generated post was publishing empty.
- Removed duplicate itsulu_social_id field (startup warning).

Testing & infra:
- CI pipeline corrected (stage order, DB auth, junit artifact, addons path).
- E2E moved to ephemeral jobs in the itsulu-testing K8s namespace.
- Test code brought up to Odoo 17 (mail rendering, blog.post.content,
  pytest-bdd env fixture, _render_field).

Versioning:
- Introduce MAJOR.MINOR.PATCH scheme, VERSION file, scripts/bump-version.sh,
  CHANGELOG.md; first release v0.4.8. CLAUDE.md §15 documents the process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:58:57 -04:00

297 lines
12 KiB
Python
Raw Permalink 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."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
cls.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."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
cls.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."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.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."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
cls.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')