From 2fd8d7fa3b781e71b5a8a1e7dd9902de6f3ebe3b Mon Sep 17 00:00:00 2001 From: Nicholas Riegel Date: Sat, 30 May 2026 02:57:10 -0400 Subject: [PATCH] fix: migrate email template to Odoo 17 syntax; fix BDD LLM mock JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRODUCTION BUG: email template used pre-Odoo-14 Mako syntax (${} and % for/% if), which Odoo 17 does not render — real notification emails would show literal '${object.blog_post_id.name}' in the subject. - subject/email_from: ${...} → {{ ... }} (inline_template engine) - body_html: add type="qweb"; ${x} → ; % for → ; % if → BDD: when_llm_router_called mocked provider.generate() returning raw HTML in .text, but LLMRouter._parse_response expects JSON and raised UserError before ctx['mock_generate'] was set. Now returns valid JSON with all required fields, and records the mock before generate() runs. Co-Authored-By: Claude Sonnet 4.6 --- .../data/mail_template_data.xml | 52 +++++++++---------- .../tests/test_bdd_steps.py | 30 +++++++++-- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/addons/itsulu_blog_publisher/data/mail_template_data.xml b/addons/itsulu_blog_publisher/data/mail_template_data.xml index ddb6148..23ab502 100644 --- a/addons/itsulu_blog_publisher/data/mail_template_data.xml +++ b/addons/itsulu_blog_publisher/data/mail_template_data.xml @@ -5,42 +5,42 @@ Blog Publisher — Post Published Notification - [ITSulu Insights] Blog Post Published: ${object.blog_post_id.name} - ${user.email_formatted} + [ITSulu Insights] Blog Post Published: {{ object.blog_post_id.name }} + {{ user.email_formatted }} True -

Blog Post Published

-

Title: ${object.blog_post_id.name}

-

Blog: ${object.blog_post_id.blog_id.name}

-

URL: https://itsulu.com${object.blog_post_id.website_url}

+

Title:

+

Blog:

+

URL: https://itsulu.com

Social Media Posts — Ready to Post

-% for social in object.blog_post_id.itsulu_social_id: -% if social.twitter_post_a: + +

Twitter

-

${social.twitter_post_a}

-% endif -% if social.twitter_post_b: +

+
+

Twitter

-

${social.twitter_post_b}

-% endif -% if social.bluesky_post_a: +

+
+

BlueSky

-

${social.bluesky_post_a}

-% endif -% if social.bluesky_post_b: +

+
+

BlueSky

-

${social.bluesky_post_b}

-% endif -% if social.mastodon_post: +

+
+

Mastodon

-

${social.mastodon_post}

-% endif -% if social.linkedin_post: +

+
+

LinkedIn

-

${social.linkedin_post}

-% endif -% endfor +

+
+
]]>
diff --git a/addons/itsulu_blog_publisher/tests/test_bdd_steps.py b/addons/itsulu_blog_publisher/tests/test_bdd_steps.py index c82b24c..d3356e1 100644 --- a/addons/itsulu_blog_publisher/tests/test_bdd_steps.py +++ b/addons/itsulu_blog_publisher/tests/test_bdd_steps.py @@ -226,10 +226,31 @@ def given_ollama_base_url(odoo_env, url): ) +def _valid_llm_json(): + """Return a JSON string with all fields LLMRouter._parse_response requires.""" + import json + return json.dumps({ + 'title': 'Generated Blog Post', + 'body_html': '

' + ('Generated content. ' * 30) + '

', + 'meta_title': 'Generated SEO Title', + 'meta_description': 'Generated meta description for the post.', + 'meta_keywords': 'ai, testing, blog', + 'tags': ['AI', 'Testing'], + 'social': { + 'twitter_a': 'Tweet A', 'twitter_b': 'Tweet B', + 'bluesky_a': 'BlueSky A', 'bluesky_b': 'BlueSky B', + 'mastodon': 'Mastodon post', 'linkedin': 'LinkedIn post', + }, + 'sources': [], + }) + + @when('the LLM router is called with a prompt') def when_llm_router_called(odoo_env, ctx): from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter - mock_resp = MagicMock(text='

Generated

', tokens_used=500) + # The router parses provider_response.text as JSON, so the mock must + # return a valid JSON blob with all required fields — not raw HTML. + mock_resp = MagicMock(text=_valid_llm_json(), tokens_used=500) provider_map = { 'anthropic': 'odoo.addons.itsulu_blog_publisher.services.anthropic_provider.AnthropicProvider.generate', @@ -243,10 +264,11 @@ def when_llm_router_called(odoo_env, ctx): try: if patch_target: with patch(patch_target, return_value=mock_resp) as mock_gen: - router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', '')) - result = router.generate(topic='Write a blog post') - ctx['result'] = result + # Record the mock before generate() so assertions can see it + # even if generate() raises. ctx['mock_generate'] = mock_gen + router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', '')) + ctx['result'] = router.generate(topic='Write a blog post') else: router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', '')) ctx['result'] = router.generate(topic='Write a blog post')