itsulu-blog-publisher/addons/itsulu_blog_publisher/tests/test_blog_generation_log.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

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")