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>
150 lines
5.4 KiB
Python
150 lines
5.4 KiB
Python
# addons/itsulu_blog_publisher/tests/test_blog_generation_log.py
|
|
"""
|
|
Tests for models/blog_generation_log.py
|
|
Behaviour: every generation attempt (success or failure) is recorded with
|
|
full metadata; failed logs expose a Retry action.
|
|
|
|
RED PHASE — all tests FAIL until itsulu.blog.generation.log model exists.
|
|
"""
|
|
from odoo.tests import TransactionCase, tagged
|
|
from .factories import BlogPublisherFactory
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'generation_log')
|
|
class TestBlogGenerationLogCreation(TransactionCase):
|
|
"""Verify that generation log records capture the correct metadata."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.factory = BlogPublisherFactory(cls.env)
|
|
cls.blog = cls.factory.blog(name='ITSulu Insights')
|
|
|
|
def test_successful_log_record_is_created_with_correct_fields(self):
|
|
"""
|
|
1. Behaviour: a success log captures provider, model, tokens, duration, post link
|
|
2. ARRANGE: a blog post and a log factory call
|
|
3. ACT: create the log
|
|
4. ASSERT: all fields populated, state='success'
|
|
5. FAIL reason: model does not exist
|
|
"""
|
|
# ARRANGE
|
|
blog_post = self.factory.blog_post(blog=self.blog)
|
|
|
|
# ACT
|
|
log = self.factory.generation_log(
|
|
blog_post=blog_post,
|
|
state='success',
|
|
llm_provider='anthropic',
|
|
llm_model='claude-sonnet-4-20250514',
|
|
tokens_used=1500,
|
|
duration_seconds=11.7,
|
|
trigger_source='manual',
|
|
)
|
|
|
|
# ASSERT
|
|
self.assertEqual(log.state, 'success')
|
|
self.assertEqual(log.llm_provider, 'anthropic')
|
|
self.assertEqual(log.llm_model, 'claude-sonnet-4-20250514')
|
|
self.assertEqual(log.tokens_used, 1500)
|
|
self.assertAlmostEqual(log.duration_seconds, 11.7, places=1)
|
|
self.assertEqual(log.blog_post_id.id, blog_post.id)
|
|
self.assertEqual(log.trigger_source, 'manual')
|
|
|
|
def test_error_log_record_stores_human_readable_error_message(self):
|
|
"""Error logs must store the full error message for operator diagnosis."""
|
|
# ARRANGE + ACT
|
|
log = self.factory.generation_log(
|
|
state='error',
|
|
error_message='Anthropic API returned 401 Unauthorized — check API key.',
|
|
blog_post_id=False,
|
|
)
|
|
|
|
# ASSERT
|
|
self.assertEqual(log.state, 'error')
|
|
self.assertIn('401', log.error_message)
|
|
|
|
def test_error_log_has_no_linked_blog_post(self):
|
|
"""When generation fails, no blog post is created, so log.blog_post_id is False."""
|
|
# ACT
|
|
log = self.factory.generation_log(state='error', blog_post_id=False)
|
|
|
|
# ASSERT
|
|
self.assertFalse(log.blog_post_id)
|
|
|
|
def test_log_trigger_source_can_be_scheduled(self):
|
|
"""Logs triggered by a cron job record trigger_source='scheduled'."""
|
|
# ACT
|
|
log = self.factory.generation_log(trigger_source='scheduled', state='success')
|
|
|
|
# ASSERT
|
|
self.assertEqual(log.trigger_source, 'scheduled')
|
|
|
|
def test_log_trigger_source_records_slot_name_for_scheduled_runs(self):
|
|
"""Scheduled logs record which slot triggered them (morning/afternoon/evening)."""
|
|
# ACT
|
|
log = self.factory.generation_log(
|
|
trigger_source='scheduled',
|
|
schedule_slot='morning',
|
|
state='success',
|
|
)
|
|
|
|
# ASSERT
|
|
self.assertEqual(log.schedule_slot, 'morning')
|
|
|
|
def test_tokens_used_defaults_to_zero_for_error_logs(self):
|
|
"""Error logs that never reached the LLM should have tokens_used=0."""
|
|
# ACT
|
|
log = self.factory.generation_log(state='error', tokens_used=0)
|
|
|
|
# ASSERT
|
|
self.assertEqual(log.tokens_used, 0)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'generation_log')
|
|
class TestBlogGenerationLogRetry(TransactionCase):
|
|
"""Verify that failed logs expose a working Retry action."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.factory = BlogPublisherFactory(cls.env)
|
|
|
|
def test_error_log_action_retry_returns_wizard_action(self):
|
|
"""
|
|
Calling action_retry() on an error log must return an ir.actions.act_window
|
|
or similar action dict so the UI can open the Generate Now wizard.
|
|
"""
|
|
# ARRANGE
|
|
log = self.factory.generation_log(
|
|
state='error',
|
|
error_message='Timeout',
|
|
)
|
|
|
|
# ACT
|
|
result = log.action_retry()
|
|
|
|
# ASSERT
|
|
self.assertIsInstance(result, dict,
|
|
"action_retry() must return an action dict")
|
|
self.assertIn(result.get('type', ''), [
|
|
'ir.actions.act_window',
|
|
'ir.actions.client',
|
|
'ir.actions.server',
|
|
])
|
|
|
|
def test_success_log_does_not_expose_retry_button(self):
|
|
"""
|
|
A success log should NOT have action_retry() callable
|
|
(or it should raise, or return False — not open a wizard).
|
|
This prevents accidental duplicate generation.
|
|
"""
|
|
# ARRANGE
|
|
blog_post = self.factory.blog_post()
|
|
log = self.factory.generation_log(state='success', blog_post=blog_post)
|
|
|
|
# ASSERT — retry on success log should either not exist or be a no-op
|
|
if hasattr(log, 'action_retry'):
|
|
result = log.action_retry()
|
|
self.assertFalse(result,
|
|
"action_retry() on a success log must return False or no-op")
|