Complete BDD feature file organization and add comprehensive step definitions

- Split monolithic blog_generation.feature into separate files per feature:
  * blog_generation.feature: On-demand AI blog generation (3 scenarios)
  * blog_scheduling.feature: Scheduled posts (2 scenarios)
  * llm_provider_selection.feature: Multi-provider routing (6 scenarios)
  * seo_population.feature: SEO field population (1 scenario)
  * notification_email.feature: Post-generation emails (2 scenarios)
  Total: 14 BDD scenarios covering all major workflows

- Extended test_bdd_steps.py from 363 to 472 lines with new step definitions:
  * Added no_email_sent() for draft post email suppression verification
  * Added email_contains_title() for email content validation
  * Added email_contains_social_copy() for platform copy verification
  * Added blog_post_has_tags(), blog_post_has_tag() for tag verification
  * Added blog_post_has_social_copy(), at_least_one_platform_enabled()
  * Added log_has_correct_provider(), log_has_correct_model()
  * Added log_trigger_source(), generation_duration_recorded()

Follows pytest-bdd best practices: one feature per file, each with dedicated
scenarios and step definitions. All 14 scenarios now have complete step coverage.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicholas Riegel 2026-05-29 12:41:24 -04:00
parent 6c51b11b27
commit 8469ef8b33
6 changed files with 230 additions and 127 deletions

View file

@ -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 Feature: On-demand AI blog post generation
As an ITSulu content admin 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 a generation log entry exists with state "error"
And the log contains a human-readable error message And the log contains a human-readable error message
And a "Retry" button is visible on the log record 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

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

@ -360,3 +360,113 @@ def then_email_subject_matches(odoo_env, subject_pattern):
assert core in (mail.subject or ''), ( assert core in (mail.subject or ''), (
f"Subject '{mail.subject}' does not start with '{core}'" 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"