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:
parent
6c51b11b27
commit
8469ef8b33
6 changed files with 230 additions and 127 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
19
addons/itsulu_blog_publisher/features/seo_population.feature
Normal file
19
addons/itsulu_blog_publisher/features/seo_population.feature
Normal 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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue