itsulu-blog-publisher/addons/itsulu_blog_publisher/tests/test_llm_router.py

198 lines
8.2 KiB
Python

# 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": "<h1>Test</h1><p>Content here.</p>",
"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)