""" 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 = '

Test

' + ('Content. ' * 100) + '

' 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 = '

Mock response

' 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 = '

Content

' 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 = '

Raw

' 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 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 = '

' + ('This is content about Kubernetes. ' * 100) + '

' 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 = '

Raw response

' 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 = '

Body

' 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 = '

Raw

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