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
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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 ''), (
|
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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue