itsulu-blog-publisher/addons/itsulu_blog_publisher/tests/test_llm_router.py
Nicholas Riegel 0fc4febabf Reorganize codebase into Odoo addon structure per ARCHITECTURE.md
Restructure project files to follow the addon layout:
- Move models to addons/itsulu_blog_publisher/models/
- Move services (LLM providers, routers) to addons/itsulu_blog_publisher/services/
- Move wizards to addons/itsulu_blog_publisher/wizards/
- Move views (XML templates) to addons/itsulu_blog_publisher/views/
- Move data (cron, mail templates) to addons/itsulu_blog_publisher/data/
- Move security (ACL) to addons/itsulu_blog_publisher/security/
- Move tests and factories to addons/itsulu_blog_publisher/tests/
- Move BDD features to addons/itsulu_blog_publisher/features/
- Create __init__.py files for all Python packages

This enables proper Odoo module discovery and import structure.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 12:11:42 -04:00

182 lines
7.7 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.
"""
from unittest.mock import patch, MagicMock
from odoo.tests import TransactionCase, tagged
from odoo.exceptions import UserError
from .factories import BlogPublisherFactory
@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 = MagicMock()
mock_response.text = '<h1>AI Blog Post</h1><p>Content here...</p>'
mock_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(prompt='Write a blog post about AI trends')
# ASSERT
mock_generate.assert_called_once()
self.assertTrue(result.text, "Router must return non-empty text 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 = MagicMock()
mock_response.text = '<p>OpenAI generated content</p>'
mock_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(prompt='Write a blog post about cloud computing')
mock_generate.assert_called_once()
self.assertTrue(result.text)
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 = MagicMock()
mock_response.text = '<p>Gemini generated content</p>'
mock_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(prompt='Write a blog post about automation')
mock_generate.assert_called_once()
self.assertTrue(result.text)
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 = MagicMock()
mock_response.text = '<p>Mistral generated content</p>'
mock_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(prompt='Write a blog post about open source AI')
mock_generate.assert_called_once()
self.assertTrue(result.text)
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(prompt='This should fail')
self.assertIn('provider 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(prompt='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 = MagicMock()
mock_response.text = '<p>Content</p>'
mock_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(prompt='Token test')
self.assertGreater(result.tokens_used, 0)
self.assertEqual(result.tokens_used, 1234)