itsulu-blog-publisher/e2e/test_error_recovery.py
Nicholas Riegel acfa1d93d7 feat: establish Phase 3 E2E testing infrastructure with Playwright
Created comprehensive E2E test suite for ITSulu Blog Publisher using Playwright
and Runboat. Includes:

PHASE3_ROADMAP.md:
- Goals for E2E coverage (10-30 scenarios)
- Performance benchmark targets (latency, tokens, queries, throughput)
- Implementation plan with layer-by-layer breakdown
- Success criteria and SLO targets
- Runboat integration details for CI/CD

e2e/ directory structure:
- conftest.py: Runboat polling, auth fixtures, page fixture
- requirements.txt: pytest, playwright, requests
- test_generation.py: On-demand generation workflows (5 tests)
- test_scheduling.py: Schedule slot configuration and execution (6 tests)
- test_error_recovery.py: Error handling and email notifications (8 tests)

Total: 19 E2E test scenarios covering:
- On-demand post generation with auto-publish
- Scheduled generation with topic queue
- Error recovery and retry mechanism
- Email notifications with correct content
- Social media copy generation
- Concurrent post generation
- Progress feedback during API calls

Tests use:
- Playwright sync API with 30s timeout (Odoo JS rendering)
- Runboat polling with 180s timeout (instance cold-start)
- Session-scoped auth to avoid repeated 30s logins
- Data-test-id selectors where available, fallback to get_by_*
- Proper wait_for_load_state() for async operations

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-30 00:50:43 -04:00

297 lines
11 KiB
Python

"""
E2E tests for error handling and recovery workflows.
Tests that system gracefully handles LLM API failures and provides retry capability.
"""
from playwright.sync_api import expect
class TestErrorRecovery:
"""Tests for error handling and recovery mechanisms."""
def test_user_retries_failed_generation_after_fixing_api_key(self, page):
"""
User attempts generation with invalid API key.
System logs error. User fixes API key in Settings.
User clicks Retry on the failed generation log.
Generation succeeds with the corrected API key.
Workflow:
1. Set invalid API key in Settings
2. Attempt generation (fails)
3. Verify: error log created with error message
4. Verify: Retry button visible on log
5. Fix API key in Settings
6. Click Retry on log
7. Verify: post created successfully
"""
# Navigate to Settings
page.goto('/web/settings')
page.wait_for_load_state('networkidle')
# Set invalid API key (e.g., "INVALID")
page.get_by_label('Anthropic API Key').fill('INVALID')
page.get_by_role('button', name='Save').click()
page.wait_for_load_state('networkidle')
# Attempt generation
page.goto('/odoo/blog/generate-now')
page.wait_for_load_state('networkidle')
page.get_by_label('Topic').fill('Test Topic for Error')
page.get_by_label('LLM Provider').select_option('anthropic')
page.get_by_role('button', name='Generate').click()
# Should fail and show error
page.wait_for_url('**/blog/log/**', timeout=60_000)
# Verify error message
expect(page.locator('body')).to_contain_text('error|unauthorized|api key', use_regex=True)
# Verify Retry button is visible
expect(page.get_by_role('button', name='Retry')).to_be_visible()
# Fix API key in Settings
page.goto('/web/settings')
page.wait_for_load_state('networkidle')
page.get_by_label('Anthropic API Key').fill('sk-ant-valid-key-here')
page.get_by_role('button', name='Save').click()
page.wait_for_load_state('networkidle')
# Return to log and click Retry
page.go_back()
page.get_by_role('button', name='Retry').click()
# Should succeed this time
page.wait_for_url('**/blog/**', timeout=60_000)
# Verify post created
expect(page.locator('body')).to_contain_text('Test Topic')
def test_generation_log_shows_human_readable_error_message(self, page):
"""
When generation fails, the error log displays a human-readable message
explaining what went wrong (not a stack trace).
Workflow:
1. Trigger generation with bad API key
2. Navigate to generation logs
3. Verify: error message is clear and actionable
"""
# Navigate to Settings and set invalid key
page.goto('/web/settings')
page.wait_for_load_state('networkidle')
page.get_by_label('Anthropic API Key').fill('INVALID_KEY')
page.get_by_role('button', name='Save').click()
page.wait_for_load_state('networkidle')
# Attempt generation
page.goto('/odoo/blog/generate-now')
page.wait_for_load_state('networkidle')
page.get_by_label('Topic').fill('Error Test')
page.get_by_label('LLM Provider').select_option('anthropic')
page.get_by_role('button', name='Generate').click()
# Should fail
page.wait_for_url('**/blog/log/**', timeout=60_000)
# Check error message is displayed and readable
error_msg = page.get_by_label('Error Message')
expect(error_msg).to_be_visible()
expect(error_msg).not_to_contain_text('Traceback') # No stack trace
expect(error_msg).not_to_contain_text('File "') # No file paths
# Should contain actionable advice
error_text = error_msg.text_content()
expect(error_text).to_match_regex(r'(API|key|invalid|unauthorized)', flags='i')
def test_error_log_does_not_link_incomplete_blog_post(self, page):
"""
When generation fails before blog.post is created, the error log has no
blog_post_id. Only successful logs link to the created post.
Workflow:
1. Trigger generation with bad API key (fails before post creation)
2. Verify: error log has no blog_post_id
3. Verify: "View Post" link is not available on error log
"""
# Set invalid key
page.goto('/web/settings')
page.wait_for_load_state('networkidle')
page.get_by_label('Anthropic API Key').fill('BAD_KEY')
page.get_by_role('button', name='Save').click()
page.wait_for_load_state('networkidle')
# Attempt generation
page.goto('/odoo/blog/generate-now')
page.wait_for_load_state('networkidle')
page.get_by_label('Topic').fill('Another Error')
page.get_by_label('LLM Provider').select_option('anthropic')
page.get_by_role('button', name='Generate').click()
# Fails
page.wait_for_url('**/blog/log/**', timeout=60_000)
# Verify: "View Post" link should NOT exist (or be disabled)
view_post_link = page.get_by_role('link', name='View Post')
# Either link doesn't exist or it's disabled
if view_post_link.count() == 0:
# Good: link doesn't exist
pass
else:
# Link exists, check it's disabled
expect(view_post_link).to_have_attribute('disabled', 'true')
class TestNotificationEmails:
"""Tests for email notifications after generation."""
def test_notification_email_sent_for_published_posts(self, page, notification_email):
"""
When a blog post is published (auto_publish=True), a notification email
is sent to the configured recipient(s).
Workflow:
1. Configure notification email in Settings
2. Generate and auto-publish post
3. Verify: email appears in outbox with correct recipient
4. Verify: email contains post title, blog name, and social copy
"""
# Set notification email
page.goto('/web/settings')
page.wait_for_load_state('networkidle')
page.get_by_label('Notification Emails').fill(notification_email)
page.get_by_role('button', name='Save').click()
page.wait_for_load_state('networkidle')
# Generate and publish
page.goto('/odoo/blog/generate-now')
page.wait_for_load_state('networkidle')
page.get_by_label('Topic').fill('Email Notification Test')
page.get_by_label('LLM Provider').select_option('anthropic')
page.get_by_label('Auto-publish').check()
page.get_by_role('button', name='Generate').click()
# Wait for post to be created
page.wait_for_url('**/blog/**', timeout=60_000)
# Verify post title appears
expect(page.locator('h1')).to_contain_text('Email Notification')
# Check email was sent (navigate to email outbox)
page.goto('/web#model=mail.mail&view_type=list')
page.wait_for_load_state('networkidle')
# Filter for recent emails to this recipient
page.get_by_role('button', name='Filters').click()
page.get_by_role('option', name='Email To').click()
page.get_by_label('Email To').fill(notification_email)
# Should find email
expect(page.locator('body')).to_contain_text(notification_email)
# Click email to verify content
page.get_by_role('link', name='Email').first.click()
page.wait_for_load_state('networkidle')
# Verify subject contains blog name and post title
expect(page.locator('[data-field="subject"]')).to_contain_text('Email Notification')
# Verify body contains social copy sections (X, BlueSky, Mastodon, LinkedIn)
body = page.locator('[data-field="body_html"]')
expect(body).to_contain_text('Twitter|X', use_regex=True)
def test_no_email_sent_for_draft_posts(self, page, notification_email):
"""
When a post is saved as draft (auto_publish=False), NO email is sent.
Only published posts trigger notifications.
Workflow:
1. Configure notification email
2. Generate as draft (auto_publish=False)
3. Verify: no email sent (email count doesn't increase)
"""
# Get initial email count
page.goto('/web#model=mail.mail&view_type=list')
page.wait_for_load_state('networkidle')
# Count emails before
emails_before = len(page.locator('tbody tr').all())
# Generate as draft
page.goto('/odoo/blog/generate-now')
page.wait_for_load_state('networkidle')
page.get_by_label('Topic').fill('Draft Post - No Email')
page.get_by_label('LLM Provider').select_option('anthropic')
page.get_by_label('Auto-publish').uncheck() # Draft
page.get_by_role('button', name='Generate').click()
# Wait for completion
page.wait_for_url('**/blog/log/**', timeout=60_000)
# Check email count again
page.goto('/web#model=mail.mail&view_type=list')
page.wait_for_load_state('networkidle')
emails_after = len(page.locator('tbody tr').all())
# Should be the same (no new email)
assert emails_after == emails_before, \
f"Email count increased from {emails_before} to {emails_after} for draft post"
def test_email_contains_correct_blog_name_in_subject(self, page, blog_name, notification_email):
"""
Email subject line includes the blog name in brackets,
e.g., "[ITSulu Insights] Blog Post Published: <post_title>"
Workflow:
1. Generate and publish post
2. Check email subject line
3. Verify format: [<blog_name>] Blog Post Published: <post_title>
"""
# Configure and generate
page.goto('/web/settings')
page.wait_for_load_state('networkidle')
page.get_by_label('Notification Emails').fill(notification_email)
page.get_by_role('button', name='Save').click()
page.wait_for_load_state('networkidle')
page.goto('/odoo/blog/generate-now')
page.wait_for_load_state('networkidle')
page.get_by_label('Topic').fill('Subject Line Test')
page.get_by_label('LLM Provider').select_option('anthropic')
page.get_by_label('Auto-publish').check()
page.get_by_role('button', name='Generate').click()
page.wait_for_url('**/blog/**', timeout=60_000)
# Check email subject
page.goto('/web#model=mail.mail&view_type=list')
page.wait_for_load_state('networkidle')
# Find latest email
page.get_by_role('link', name='Email').first.click()
page.wait_for_load_state('networkidle')
subject = page.locator('[data-field="subject"]').input_value()
# Verify format: [ITSulu Insights] Blog Post Published: ...
assert f'[{blog_name}]' in subject, f"Subject missing blog name: {subject}"
assert 'Published' in subject, f"Subject missing 'Published': {subject}"
assert 'Subject Line' in subject, f"Subject missing post title: {subject}"