itsulu-blog-publisher/addons/itsulu_blog_publisher/tests/test_blog_generation_log.py
Nicholas Riegel 6635313f58 port(14.0): fix web_ribbon view attr + convert tests to setUp/self.env
- 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>
2026-05-30 11:44:24 -04:00

148 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."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.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."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.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")