diff --git a/addons/itsulu_blog_publisher/features/blog_generation.feature b/addons/itsulu_blog_publisher/features/blog_generation.feature index bbf89dc..015f610 100644 --- a/addons/itsulu_blog_publisher/features/blog_generation.feature +++ b/addons/itsulu_blog_publisher/features/blog_generation.feature @@ -1,5 +1,5 @@ # ================================================================= -# addons/itsulu_blog_publisher/features/blog_generation.feature +# On-demand AI blog post generation # ================================================================= Feature: On-demand AI blog post generation As an ITSulu content admin @@ -39,129 +39,3 @@ Feature: On-demand AI blog post generation And a generation log entry exists with state "error" And the log contains a human-readable error message And a "Retry" button is visible on the log record - - -# ================================================================= -# addons/itsulu_blog_publisher/features/blog_scheduling.feature -# ================================================================= -Feature: Scheduled blog post generation - As an ITSulu content admin - I want to schedule three blog posts per day automatically - So that content is published without manual intervention - - Background: - Given the Anthropic API key is configured in Settings - And the blog "ITSulu Insights" exists in Odoo - - Scenario: Active morning slot creates a blog post when run - Given I am on the Blog Publisher backend form - And I set auto-publish to True - And I enter topic "Morning AI Topic" - When I click "Generate Now" - Then a blog.post record is created with a non-empty title - And a generation log entry exists with state "success" - - Scenario: Generate a blog post and leave it as draft - Given I am on the Blog Publisher backend form - And I enter topic "Scheduled Draft Topic" - And I set auto-publish to False - When I click "Generate Now" - Then a blog.post record is created with a non-empty title - And the blog.post is_published is False - - -# ================================================================= -# addons/itsulu_blog_publisher/features/llm_provider_selection.feature -# ================================================================= -Feature: Multi-provider LLM routing - As an ITSulu admin - I want to choose which LLM provider generates each blog post - So that I can control cost, quality, and availability - - Background: - Given the Anthropic API key is configured in Settings - And the blog "ITSulu Insights" exists in Odoo - - Scenario: Anthropic provider generates blog content - Given provider is "anthropic" and model is "claude-sonnet-4-20250514" - When the LLM router is called with a prompt - Then the router calls the Anthropic /v1/messages endpoint - And returns a non-empty string response - - Scenario: OpenAI provider generates blog content - Given provider is "openai" and model is "gpt-4o" - When the LLM router is called with a prompt - Then the router calls the OpenAI /v1/chat/completions endpoint - And returns a non-empty string response - - Scenario: Gemini provider generates blog content - Given provider is "gemini" and model is "gemini-2.0-flash" - When the LLM router is called with a prompt - Then the router calls the Google Gemini API endpoint - And returns a non-empty string response - - Scenario: Ollama provider generates blog content using local model - Given provider is "ollama" and model is "mistral" - And the Ollama base URL is "http://localhost:11434" - When the LLM router is called with a prompt - Then the router calls http://localhost:11434/api/chat - And returns a non-empty string response - - 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" - - Scenario: Token usage is recorded in generation log - Given provider is "anthropic" and model is "claude-sonnet-4-20250514" - When the LLM router is called with a prompt - Then the generation log record contains tokens_used > 0 - - -# ================================================================= -# addons/itsulu_blog_publisher/features/seo_population.feature -# ================================================================= -Feature: SEO fields populated on every generated blog post - As an ITSulu content admin - I want all SEO fields filled automatically - So that every post is search-engine ready at publication - - Background: - Given the Anthropic API key is configured in Settings - And the blog "ITSulu Insights" exists in Odoo - - Scenario: All SEO fields are populated after generation - Given I am on the Blog Publisher backend form - And I enter topic "AI Governance Enterprise" - And I set auto-publish to True - When I click "Generate Now" - Then the SEO fields website_meta_title and website_meta_description are populated - And a generation log entry exists with state "success" - - -# ================================================================= -# addons/itsulu_blog_publisher/features/notification_email.feature -# ================================================================= -Feature: Post-generation notification email - As an ITSulu content admin - I want to receive one email after each successful blog post publication - So that I have the social media copy ready to paste - - Background: - Given the notification email recipient is "nicholasr@itsulu.com" - - Scenario: Notification email is sent after successful auto-publish - Given a blog post was generated and auto-published - When the generation completes - Then exactly one email is sent to "nicholasr@itsulu.com" - And the email subject matches "[ITSulu Insights] Blog Post Published: {title} - {date}" - - Scenario: Notification email is NOT sent for draft posts - Given the Anthropic API key is configured in Settings - And the blog "ITSulu Insights" exists in Odoo - And I am on the Blog Publisher backend form - And I enter topic "Draft Topic No Email" - And I set auto-publish to False - When I click "Generate Now" - Then a blog.post record is created with a non-empty title - And the blog.post is_published is False diff --git a/addons/itsulu_blog_publisher/features/blog_scheduling.feature b/addons/itsulu_blog_publisher/features/blog_scheduling.feature new file mode 100644 index 0000000..ac022c5 --- /dev/null +++ b/addons/itsulu_blog_publisher/features/blog_scheduling.feature @@ -0,0 +1,27 @@ +# ================================================================= +# Scheduled blog post generation +# ================================================================= +Feature: Scheduled blog post generation + As an ITSulu content admin + I want to schedule three blog posts per day automatically + So that content is published without manual intervention + + Background: + Given the Anthropic API key is configured in Settings + And the blog "ITSulu Insights" exists in Odoo + + Scenario: Active morning slot creates a blog post when run + Given I am on the Blog Publisher backend form + And I set auto-publish to True + And I enter topic "Morning AI Topic" + When I click "Generate Now" + Then a blog.post record is created with a non-empty title + And a generation log entry exists with state "success" + + Scenario: Generate a blog post and leave it as draft + Given I am on the Blog Publisher backend form + And I enter topic "Scheduled Draft Topic" + And I set auto-publish to False + When I click "Generate Now" + Then a blog.post record is created with a non-empty title + And the blog.post is_published is False diff --git a/addons/itsulu_blog_publisher/features/llm_provider_selection.feature b/addons/itsulu_blog_publisher/features/llm_provider_selection.feature new file mode 100644 index 0000000..709891b --- /dev/null +++ b/addons/itsulu_blog_publisher/features/llm_provider_selection.feature @@ -0,0 +1,46 @@ +# ================================================================= +# Multi-provider LLM routing +# ================================================================= +Feature: Multi-provider LLM routing + As an ITSulu admin + I want to choose which LLM provider generates each blog post + So that I can control cost, quality, and availability + + Background: + Given the Anthropic API key is configured in Settings + And the blog "ITSulu Insights" exists in Odoo + + Scenario: Anthropic provider generates blog content + Given provider is "anthropic" and model is "claude-sonnet-4-20250514" + When the LLM router is called with a prompt + Then the router calls the Anthropic /v1/messages endpoint + And returns a non-empty string response + + Scenario: OpenAI provider generates blog content + Given provider is "openai" and model is "gpt-4o" + When the LLM router is called with a prompt + Then the router calls the OpenAI /v1/chat/completions endpoint + And returns a non-empty string response + + Scenario: Gemini provider generates blog content + Given provider is "gemini" and model is "gemini-2.0-flash" + When the LLM router is called with a prompt + Then the router calls the Google Gemini API endpoint + And returns a non-empty string response + + Scenario: Ollama provider generates blog content using local model + Given provider is "ollama" and model is "mistral" + And the Ollama base URL is "http://localhost:11434" + When the LLM router is called with a prompt + Then the router calls http://localhost:11434/api/chat + And returns a non-empty string response + + 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" + + Scenario: Token usage is recorded in generation log + Given provider is "anthropic" and model is "claude-sonnet-4-20250514" + When the LLM router is called with a prompt + Then the generation log record contains tokens_used > 0 diff --git a/addons/itsulu_blog_publisher/features/notification_email.feature b/addons/itsulu_blog_publisher/features/notification_email.feature new file mode 100644 index 0000000..2d40807 --- /dev/null +++ b/addons/itsulu_blog_publisher/features/notification_email.feature @@ -0,0 +1,27 @@ +# ================================================================= +# Post-generation notification email +# ================================================================= +Feature: Post-generation notification email + As an ITSulu content admin + I want to receive one email after each successful blog post publication + So that I have the social media copy ready to paste + + Background: + Given the notification email recipient is "nicholasr@itsulu.com" + + Scenario: Notification email is sent after successful auto-publish + Given a blog post was generated and auto-published + When the generation completes + Then exactly one email is sent to "nicholasr@itsulu.com" + And the email subject matches "[ITSulu Insights] Blog Post Published: {title} - {date}" + + Scenario: Notification email is NOT sent for draft posts + Given the Anthropic API key is configured in Settings + And the blog "ITSulu Insights" exists in Odoo + And I am on the Blog Publisher backend form + And I enter topic "Draft Topic No Email" + And I set auto-publish to False + When I click "Generate Now" + Then a blog.post record is created with a non-empty title + And the blog.post is_published is False + And no email is sent to "nicholasr@itsulu.com" diff --git a/addons/itsulu_blog_publisher/features/seo_population.feature b/addons/itsulu_blog_publisher/features/seo_population.feature new file mode 100644 index 0000000..3f3563b --- /dev/null +++ b/addons/itsulu_blog_publisher/features/seo_population.feature @@ -0,0 +1,19 @@ +# ================================================================= +# SEO fields populated on every generated blog post +# ================================================================= +Feature: SEO fields populated on every generated blog post + As an ITSulu content admin + I want all SEO fields filled automatically + So that every post is search-engine ready at publication + + Background: + Given the Anthropic API key is configured in Settings + And the blog "ITSulu Insights" exists in Odoo + + Scenario: All SEO fields are populated after generation + Given I am on the Blog Publisher backend form + And I enter topic "AI Governance Enterprise" + And I set auto-publish to True + When I click "Generate Now" + Then 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/tests/test_bdd_steps.py b/addons/itsulu_blog_publisher/tests/test_bdd_steps.py index 94ae04b..2758eb3 100644 --- a/addons/itsulu_blog_publisher/tests/test_bdd_steps.py +++ b/addons/itsulu_blog_publisher/tests/test_bdd_steps.py @@ -360,3 +360,113 @@ def then_email_subject_matches(odoo_env, subject_pattern): assert core in (mail.subject or ''), ( f"Subject '{mail.subject}' does not start with '{core}'" ) + + +@then(parsers.parse('no email is sent to "{email}"')) +def then_no_email_sent(odoo_env, email): + mails = odoo_env['mail.mail'].search([('email_to', 'ilike', email)]) + assert len(mails) == 0, f"Expected no email to {email}, but found {len(mails)} email(s)" + + +@then('the email body contains the blog post title') +def then_email_contains_title(odoo_env, ctx): + mail = odoo_env['mail.mail'].search([], order='id desc', limit=1) + post = ctx.get('post') + if post: + assert post.name in (mail.body_html or ''), ( + f"Email body does not contain post title '{post.name}'" + ) + + +@then('the email body contains social media copy for all enabled platforms') +def then_email_contains_social_copy(odoo_env, ctx): + mail = odoo_env['mail.mail'].search([], order='id desc', limit=1) + post = ctx.get('post') + if post and post.social_ids: + social = post.social_ids[0] + body = mail.body_html or '' + # Verify at least one social platform is mentioned + platforms_found = 0 + if social.twitter_enabled and social.twitter_post_a: + platforms_found += 1 + if social.bluesky_enabled and social.bluesky_post_a: + platforms_found += 1 + if social.mastodon_enabled and social.mastodon_post: + platforms_found += 1 + if social.linkedin_enabled and social.linkedin_post: + platforms_found += 1 + assert platforms_found > 0, "Email must contain at least one enabled social platform copy" + + +# ================================================================= # +# Additional verification steps for robustness # +# ================================================================= # + +@then('the blog.post has tags assigned') +def then_blog_post_has_tags(odoo_env, ctx): + post = ctx.get('post') or odoo_env['blog.post'].search([], order='id desc', limit=1) + assert post.tag_ids, "blog.post must have at least one tag assigned" + + +@then(parsers.parse('the blog.post tags include "{tag_name}"')) +def then_blog_post_has_tag(odoo_env, tag_name, ctx): + post = ctx.get('post') or odoo_env['blog.post'].search([], order='id desc', limit=1) + tag_names = [tag.name for tag in post.tag_ids] + assert tag_name in tag_names, ( + f"Tag '{tag_name}' not found. Available tags: {tag_names}" + ) + + +@then('the blog.post has social media copy assigned') +def then_blog_post_has_social_copy(odoo_env, ctx): + post = ctx.get('post') or odoo_env['blog.post'].search([], order='id desc', limit=1) + social = odoo_env['itsulu.blog.post.social'].search([('blog_post_id', '=', post.id)]) + assert social, "blog.post must have associated social media copy" + ctx['social'] = social + + +@then('at least one social platform is enabled') +def then_at_least_one_platform_enabled(odoo_env, ctx): + social = ctx.get('social') or odoo_env['itsulu.blog.post.social'].search([], order='id desc', limit=1) + assert ( + social.twitter_enabled or social.bluesky_enabled + or social.mastodon_enabled or social.linkedin_enabled + ), "At least one social platform must be enabled" + + +@then('the generation log records the correct LLM provider') +def then_log_has_correct_provider(odoo_env, ctx): + log = odoo_env['itsulu.blog.generation.log'].search( + [('state', '=', 'success')], order='id desc', limit=1 + ) + provider = ctx.get('provider', 'anthropic') + assert log.llm_provider == provider, ( + f"Expected provider '{provider}' in log, got '{log.llm_provider}'" + ) + + +@then('the generation log records the correct LLM model') +def then_log_has_correct_model(odoo_env, ctx): + log = odoo_env['itsulu.blog.generation.log'].search( + [('state', '=', 'success')], order='id desc', limit=1 + ) + model = ctx.get('model', '') + assert log.llm_model == model, ( + f"Expected model '{model}' in log, got '{log.llm_model}'" + ) + + +@then(parsers.parse('the generation log trigger_source is "{source}"')) +def then_log_trigger_source(odoo_env, source): + log = odoo_env['itsulu.blog.generation.log'].search([], order='id desc', limit=1) + assert log.trigger_source == source, ( + f"Expected trigger_source '{source}', got '{log.trigger_source}'" + ) + + +@then('the generation duration is recorded') +def then_generation_duration_recorded(odoo_env): + log = odoo_env['itsulu.blog.generation.log'].search( + [('state', '=', 'success')], order='id desc', limit=1 + ) + assert log.duration_seconds > 0, "duration_seconds must be > 0"