""" 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: " Workflow: 1. Generate and publish post 2. Check email subject line 3. Verify format: [] Blog Post Published: """ # 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}"