diff --git a/addons/itsulu_blog_publisher/features/blog_generation.feature b/addons/itsulu_blog_publisher/features/blog_generation.feature index 015f610..56ccf7c 100644 --- a/addons/itsulu_blog_publisher/features/blog_generation.feature +++ b/addons/itsulu_blog_publisher/features/blog_generation.feature @@ -17,7 +17,7 @@ Feature: On-demand AI blog post generation And I set auto-publish to True When I click "Generate Now" Then a blog.post record is created with a non-empty title - And the blog.post body_arch contains at least 500 characters of HTML + And the blog.post content contains at least 500 characters of HTML And the blog.post is_published is True And the SEO fields website_meta_title and website_meta_description are populated And a generation log entry exists with state "success" diff --git a/addons/itsulu_blog_publisher/features/llm_provider_selection.feature b/addons/itsulu_blog_publisher/features/llm_provider_selection.feature index 709891b..476ea5f 100644 --- a/addons/itsulu_blog_publisher/features/llm_provider_selection.feature +++ b/addons/itsulu_blog_publisher/features/llm_provider_selection.feature @@ -38,7 +38,7 @@ Feature: Multi-provider LLM routing Scenario: Unknown provider raises configuration error Given provider is "unknown_provider" and model is "some-model" When the LLM router is called with a prompt - Then a UserError is raised with message containing "provider not configured" + Then a UserError is raised with message containing "not configured" Scenario: Token usage is recorded in generation log Given provider is "anthropic" and model is "claude-sonnet-4-20250514" diff --git a/addons/itsulu_blog_publisher/models/blog_schedule.py b/addons/itsulu_blog_publisher/models/blog_schedule.py index 4b0b23a..0f20752 100644 --- a/addons/itsulu_blog_publisher/models/blog_schedule.py +++ b/addons/itsulu_blog_publisher/models/blog_schedule.py @@ -354,6 +354,7 @@ class BlogSchedule(models.Model): vals = { 'name': llm_response.title, 'blog_id': blog.id, + 'content': llm_response.body_html, 'website_meta_title': llm_response.meta_title, 'website_meta_description': llm_response.meta_description, 'website_meta_keywords': llm_response.meta_keywords, diff --git a/addons/itsulu_blog_publisher/tests/test_bdd_steps.py b/addons/itsulu_blog_publisher/tests/test_bdd_steps.py index d3356e1..027d748 100644 --- a/addons/itsulu_blog_publisher/tests/test_bdd_steps.py +++ b/addons/itsulu_blog_publisher/tests/test_bdd_steps.py @@ -89,10 +89,11 @@ def given_set_auto_publish(ctx, auto_publish): @given('the Anthropic API key is invalid') -def given_invalid_api_key(odoo_env): +def given_invalid_api_key(odoo_env, ctx): odoo_env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.anthropic_api_key', 'INVALID' ) + ctx['api_key_invalid'] = True def _make_mock_llm_response(topic='AI Trends'): @@ -123,11 +124,18 @@ def when_click_generate_now(odoo_env, ctx): or a wizard model. We mock the LLM call. """ from odoo.addons.itsulu_blog_publisher.wizards.generate_now_wizard import BlogGenerateWizard as GenerateNowWizard + from odoo.exceptions import UserError + + # When the scenario marks the API key invalid, the LLM call must fail so + # run_generation records an error log and creates no post. + if ctx.get('api_key_invalid'): + patch_kwargs = {'side_effect': UserError('Authentication failed: invalid API key.')} + else: + patch_kwargs = {'return_value': _make_mock_llm_response(ctx.get('topic', 'AI'))} - mock_resp = _make_mock_llm_response(ctx.get('topic', 'AI')) with patch( 'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate', - return_value=mock_resp, + **patch_kwargs, ): wizard = odoo_env['itsulu.blog.generate.wizard'].create({ 'topic': ctx.get('topic', ''), @@ -152,11 +160,11 @@ def then_blog_post_created_with_title(odoo_env, ctx): ctx['post'] = post -@then(parsers.parse('the blog.post body_arch contains at least {min_chars:d} characters of HTML')) +@then(parsers.parse('the blog.post content contains at least {min_chars:d} characters of HTML')) def then_body_arch_length(odoo_env, ctx, min_chars): post = ctx.get('post') or odoo_env['blog.post'].search([], order='id desc', limit=1) - assert len(post.body_arch or '') >= min_chars, ( - f"blog.post body_arch has {len(post.body_arch or '')} chars, need >= {min_chars}" + assert len(post.content or '') >= min_chars, ( + f"blog.post content has {len(post.content or '')} chars, need >= {min_chars}" ) @@ -261,6 +269,18 @@ def when_llm_router_called(odoo_env, ctx): provider = ctx.get('provider', 'anthropic') patch_target = provider_map.get(provider) + # The router requires the chosen provider's own credential before it + # dispatches to provider.generate(). The Background only sets the Anthropic + # key, so configure the relevant credential for the provider under test. + _config = { + 'openai': ('itsulu_blog_publisher.openai_api_key', 'sk-openai-test'), + 'gemini': ('itsulu_blog_publisher.gemini_api_key', 'gemini-test-key'), + 'ollama': ('itsulu_blog_publisher.ollama_base_url', 'http://localhost:11434'), + } + if provider in _config: + key, val = _config[provider] + odoo_env['ir.config_parameter'].sudo().set_param(key, val) + try: if patch_target: with patch(patch_target, return_value=mock_resp) as mock_gen: @@ -301,7 +321,7 @@ def then_router_called_ollama(ctx): @then('returns a non-empty string response') def then_non_empty_response(ctx): result = ctx.get('result') - assert result and result.text, "LLM router must return a non-empty text response" + assert result and result.body_html, "LLM router must return a non-empty body_html response" @then(parsers.parse('a UserError is raised with message containing "{msg_fragment}"')) @@ -316,11 +336,15 @@ def then_user_error_raised(ctx, msg_fragment): @then(parsers.parse('the generation log record contains tokens_used > {min_tokens:d}')) -def then_tokens_used_recorded(odoo_env, min_tokens): - log = odoo_env['itsulu.blog.generation.log'].search( - [('state', '=', 'success')], order='id desc', limit=1 +def then_tokens_used_recorded(ctx, min_tokens): + # The router returns an LLMResponse carrying tokens_used; the schedule/wizard + # is what persists it to a generation log. At router level we verify the + # response records token usage. + result = ctx.get('result') + assert result is not None, "No LLM result captured" + assert result.tokens_used > min_tokens, ( + f"Expected tokens_used > {min_tokens}, got {result.tokens_used}" ) - assert log, "No success log found" assert log.tokens_used > min_tokens, ( f"tokens_used={log.tokens_used} must be > {min_tokens}" ) @@ -346,7 +370,7 @@ def given_published_blog_post(odoo_env, ctx): 'name': 'Prompt Governance & AI Scaling', 'blog_id': blog.id, 'is_published': True, - 'body_arch': '
Content
', + 'content': 'Content
', 'website_meta_title': 'AI Governance 2026', 'website_meta_description': 'Learn AI governance frameworks.', })