# addons/itsulu_blog_publisher/tests/test_llm_router.py """ Tests for services/llm_router.py Behaviour: route LLM calls to the correct provider, record token usage, raise UserError on unknown or misconfigured providers. RED PHASE — all tests here FAIL until llm_router and provider services exist. """ import json from unittest.mock import patch, MagicMock from odoo.tests import TransactionCase, tagged from odoo.exceptions import UserError from .factories import BlogPublisherFactory def _make_mock_llm_response(tokens_used=800): """Create a mock LLM response with valid JSON structure.""" response_json = { "title": "Test Blog Post", "body_html": "
Content here.
", "meta_title": "Test Meta Title", "meta_description": "Test meta description", "meta_keywords": "test, blog", "tags": ["test", "blog"], "social": { "twitter_a": "Tweet A {{URL}}", "twitter_b": "Tweet B {{URL}}", "bluesky_a": "BlueSky A {{URL}}", "bluesky_b": "BlueSky B {{URL}}", "mastodon": "Mastodon {{URL}}", "linkedin": "LinkedIn {{URL}}" }, "sources": [] } mock_response = MagicMock() mock_response.text = json.dumps(response_json) mock_response.tokens_used = tokens_used return mock_response @tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'llm_router') class TestLLMRouterProviderDispatch(TransactionCase): """Verify that the LLM router dispatches to the correct backend provider.""" @classmethod def setUpClass(cls): super().setUpClass() cls.factory = BlogPublisherFactory(cls.env) # Store a known-valid stub API key in ir.config_parameter cls.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key' ) cls.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.openai_api_key', 'sk-openai-test-key' ) cls.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.gemini_api_key', 'gemini-test-key' ) cls.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.ollama_base_url', 'http://localhost:11434' ) # --- Provider dispatch --- def test_anthropic_provider_calls_anthropic_endpoint(self): """ 1. Behaviour: router with provider='anthropic' calls AnthropicProvider.generate() 2. ARRANGE: router instantiated with provider='anthropic' 3. ACT: router.generate(prompt='Write a blog post about AI') 4. ASSERT: AnthropicProvider.generate was called once; result is non-empty string 5. FAIL reason: LLMRouter class does not exist yet """ # ARRANGE from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter mock_response = _make_mock_llm_response(tokens_used=800) with patch( 'odoo.addons.itsulu_blog_publisher.services.anthropic_provider' '.AnthropicProvider.generate', return_value=mock_response, ) as mock_generate: # ACT router = LLMRouter(self.env, provider='anthropic', model='claude-sonnet-4-20250514') result = router.generate(topic='Write a blog post about AI trends') # ASSERT mock_generate.assert_called_once() self.assertTrue(result.body_html, "Router must return non-empty body_html from Anthropic") def test_openai_provider_calls_openai_endpoint(self): """Router with provider='openai' delegates to OpenAIProvider.generate().""" from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter mock_response = _make_mock_llm_response(tokens_used=600) with patch( 'odoo.addons.itsulu_blog_publisher.services.openai_provider' '.OpenAIProvider.generate', return_value=mock_response, ) as mock_generate: router = LLMRouter(self.env, provider='openai', model='gpt-4o') result = router.generate(topic='Write a blog post about cloud computing') mock_generate.assert_called_once() self.assertTrue(result.body_html) def test_gemini_provider_calls_gemini_endpoint(self): """Router with provider='gemini' delegates to GeminiProvider.generate().""" from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter mock_response = _make_mock_llm_response(tokens_used=700) with patch( 'odoo.addons.itsulu_blog_publisher.services.gemini_provider' '.GeminiProvider.generate', return_value=mock_response, ) as mock_generate: router = LLMRouter(self.env, provider='gemini', model='gemini-2.0-flash') result = router.generate(topic='Write a blog post about automation') mock_generate.assert_called_once() self.assertTrue(result.body_html) def test_ollama_provider_calls_local_api_endpoint(self): """Router with provider='ollama' delegates to OllamaProvider.generate().""" from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter mock_response = _make_mock_llm_response(tokens_used=500) with patch( 'odoo.addons.itsulu_blog_publisher.services.ollama_provider' '.OllamaProvider.generate', return_value=mock_response, ) as mock_generate: router = LLMRouter(self.env, provider='ollama', model='mistral') result = router.generate(topic='Write a blog post about open source AI') mock_generate.assert_called_once() self.assertTrue(result.body_html) def test_unknown_provider_raises_user_error(self): """ Router with unrecognised provider must raise UserError immediately, before any network call is made. """ from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter with self.assertRaises(UserError) as ctx: router = LLMRouter(self.env, provider='unknown_provider', model='some-model') router.generate(topic='This should fail') self.assertIn('not configured', str(ctx.exception).lower()) def test_missing_api_key_raises_user_error(self): """Router raises UserError when required API key param is absent.""" from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter # Remove the key self.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.anthropic_api_key', '' ) with self.assertRaises(UserError) as ctx: router = LLMRouter(self.env, provider='anthropic', model='claude-sonnet-4-20250514') router.generate(topic='Fail fast') self.assertIn('api key', str(ctx.exception).lower()) # Restore for other tests self.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key' ) @tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'llm_router') class TestLLMRouterTokenLogging(TransactionCase): """Verify that token usage is captured from provider responses.""" @classmethod def setUpClass(cls): super().setUpClass() cls.factory = BlogPublisherFactory(cls.env) cls.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key' ) def test_router_response_includes_tokens_used(self): """ The LLMResponse object returned by router.generate() must expose tokens_used as a positive integer when the API returns usage data. """ from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter mock_response = _make_mock_llm_response(tokens_used=1234) with patch( 'odoo.addons.itsulu_blog_publisher.services.anthropic_provider' '.AnthropicProvider.generate', return_value=mock_response, ): router = LLMRouter(self.env, provider='anthropic', model='claude-sonnet-4-20250514') result = router.generate(topic='Token test') self.assertGreater(result.tokens_used, 0) self.assertEqual(result.tokens_used, 1234)