mirror of
https://gitlab.com/itsulu-odoo/itsulu-blog-publisher.git
synced 2026-05-30 23:41:23 +00:00
- views/blog_schedule_views.xml: web_ribbon invisible="active" (Odoo 17
bare-expr syntax) -> attrs="{'invisible': [('active','=',True)]}".
This was the view-validation error blocking module install on Odoo 14.
- tests: Odoo 14 TransactionCase exposes self.env in setUp(), not cls.env
in setUpClass() (that pattern is Odoo 15+). Converted all 13 setUpClass
blocks across 6 test files to setUp(self) + self.env/self.factory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
196 lines
8.2 KiB
Python
196 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."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.factory = BlogPublisherFactory(self.env)
|
|
# Store a known-valid stub API key in ir.config_parameter
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
|
|
)
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.openai_api_key', 'sk-openai-test-key'
|
|
)
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.gemini_api_key', 'gemini-test-key'
|
|
)
|
|
self.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."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.factory = BlogPublisherFactory(self.env)
|
|
self.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)
|