diff --git a/PHASE3_ROADMAP.md b/PHASE3_ROADMAP.md new file mode 100644 index 0000000..9dc3e8c --- /dev/null +++ b/PHASE3_ROADMAP.md @@ -0,0 +1,310 @@ +# Phase 3: Runboat E2E Testing and Performance Benchmarks + +**Status**: In Progress +**Start**: 2026-05-30 +**Target**: E2E coverage + performance SLOs met + +## Goals + +### 1. E2E Test Coverage (10–30 scenarios) + +Critical user journeys verified via Playwright: +- [ ] User generates blog post on-demand +- [ ] User schedules daily blog generation +- [ ] User views generation logs and retries failed attempts +- [ ] User edits social media copy before publication +- [ ] User views published post with correct SEO fields +- [ ] User receives notification email with correct content +- [ ] System recovers gracefully from LLM API errors +- [ ] Multiple users generate posts concurrently (collision handling) + +### 2. Performance Benchmarks + +Establish baseline metrics for: +- **Generation Latency**: Time from wizard click to post created + - Target: < 30 seconds (including LLM API call) + - Measure: P50, P95, P99 +- **Token Efficiency**: Tokens used per blog post + - Target: 800–1200 tokens for ~800-word post + - Baseline: Record for cost optimization +- **Database Query Count**: N+1 detection + - Target: < 50 queries per generation + - Tool: assertQueryCount() on hot paths +- **Throughput**: Concurrent generations + - Target: 5+ simultaneous posts without degradation + - Stress test: 10 parallel schedule slots +- **Memory Usage**: Peak RSS during generation + - Target: < 500 MB per Odoo process + +### 3. Load Testing + +Simulate production scenarios: +- [ ] 100 pending topics in queue +- [ ] 3 active schedule slots all triggering within 5 minutes +- [ ] 5 concurrent users generating posts +- [ ] Template DB priming time baseline + +## Implementation Plan + +### Layer 1: Runboat Setup & E2E Infrastructure + +```bash +# 1. Create e2e/ directory structure +e2e/ +├── conftest.py # Session/auth fixtures, Runboat polling +├── test_generation.py # On-demand generation workflow +├── test_scheduling.py # Schedule slot execution +├── test_notifications.py # Email and social copy +├── test_error_recovery.py # API errors and retries +└── requirements.txt # pytest, playwright + +# 2. Set up conftest.py with: +# - wait_for_odoo(url) polling +# - auth_state fixture (admin login) +# - page fixture (authenticated Playwright context) +# - BASE_URL from env var or CI + +# 3. Create .gitlab-ci.yml runboat stage: +runboat_preview: + stage: preview + script: | + curl -X POST $RUNBOAT_URL/builds \ + -H "Authorization: Bearer $RUNBOAT_TOKEN" \ + -d "{\"repo\":\"$CI_PROJECT_PATH\",\"sha\":\"$CI_COMMIT_SHA\"}" +``` + +### Layer 2: E2E Test Scenarios (10–20 tests) + +**Generation Workflow** (3 tests): +```python +def test_user_generates_blog_post_on_demand(page): + # Navigate to wizard + # Fill topic, select provider, set auto-publish + # Click Generate + # Assert blog.post created with title + body + # Assert email sent to configured recipient + +def test_user_saves_post_as_draft_for_review(page): + # Same as above but auto_publish=False + # Assert post is not published + +def test_generation_fails_gracefully_with_api_error(page): + # Trigger with invalid API key + # Assert error message displayed + # Assert "Retry" button visible on log +``` + +**Scheduling Workflow** (2 tests): +```python +def test_user_configures_daily_schedule_slot(page): + # Navigate to schedule slots + # Create morning, afternoon, evening slots + # Set LLM provider and model + # Toggle auto-publish per slot + # Save and verify all 3 slots active + +def test_user_monitors_generation_logs(page): + # View all generation logs + # Filter by state (success/error) + # Click retry on failed log + # Verify retry increments attempt counter +``` + +**Email & Social** (2 tests): +```python +def test_email_contains_post_title_and_social_copy(page): + # Generate and publish post + # Check generated email in outbox + # Verify subject contains blog name + post title + # Verify body contains social platforms (X, BlueSky, Mastodon, LinkedIn) + +def test_user_edits_social_copy_before_publishing(page): + # Generate as draft + # Edit social media copy for each platform + # Save and publish + # Verify email uses edited copy +``` + +**Error Recovery** (2 tests): +```python +def test_user_retries_failed_generation(page): + # Trigger generation with bad API key + # Log shows error state + # Fix API key in Settings + # Click Retry on log + # Verify post created successfully + +def test_schedule_slot_continues_after_api_error(page): + # Set invalid API key on schedule slot + # Slot executes, fails, logs error + # Fix API key + # Wait for next slot time + # Verify next generation succeeds +``` + +**Concurrency** (1–2 tests): +```python +def test_multiple_users_generate_posts_concurrently(page): + # User1 generates on-demand + # User2 generates on-demand simultaneously + # Both posts created successfully + # No database locks or conflicts +``` + +### Layer 3: Performance Benchmarks + +**Latency Profiling**: +```python +def test_generation_latency_p50_under_30s(page): + """Measure time from "Generate Now" click to blog.post created.""" + import time + start = time.time() + # ... navigate and generate ... + elapsed = time.time() - start + assert elapsed < 30, f"Generation took {elapsed}s, target <30s" + # Record metric: elapsed_seconds_p50 +``` + +**Query Count Assertion**: +```python +def test_generation_uses_fewer_than_50_queries(page): + """Verify no N+1 query patterns.""" + from odoo.tests import TransactionCase + # In the server-side test, not E2E: + with self.assertQueryCount(50): + schedule.run_generation() +``` + +**Stress Test** (not Playwright, server-side): +```python +def test_concurrent_schedule_slots_under_load(): + """3 slots × 5 iterations = 15 posts in rapid succession.""" + # Trigger all 3 schedule slots + # Measure: peak memory, query count, token usage + # Assert: all posts created, no failures +``` + +## Runboat Integration + +### What is Runboat? + +Runboat (by Acsone) provides: +- **Auto-deployed preview instances** of Odoo per CI commit +- **Live URL** for E2E testing (no local bootstrapping needed) +- **Fresh template DB** with addon pre-installed +- **5-minute auto-cleanup** after test run + +### CI/CD Integration + +```yaml +# .gitlab-ci.yml + +stages: [lint, test, build, preview, e2e] + +# ... existing lint + test stages ... + +runboat_preview: + stage: preview + image: curlimages/curl:latest + script: + - | + RUNBOAT_URL="${RUNBOAT_API_URL}/builds" + RESP=$(curl -fsSL -X POST "$RUNBOAT_URL" \ + -H "Authorization: Bearer $RUNBOAT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"repo\": \"$CI_PROJECT_PATH\", + \"sha\": \"$CI_COMMIT_SHA\", + \"target_branch\": \"$CI_MERGE_REQUEST_TARGET_BRANCH_NAME\" + }") + BUILD_URL=$(echo "$RESP" | jq -r '.url') + echo "BUILD_URL=$BUILD_URL" >> build.env + + # Post comment to MR + curl -X POST "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \ + -H "PRIVATE-TOKEN: $GITLAB_BOT_TOKEN" \ + --data "body=🚀 [Preview](${BUILD_URL}/odoo) ready for testing" + artifacts: + reports: + dotenv: build.env + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + +e2e_tests: + stage: e2e + image: mcr.microsoft.com/playwright/python:latest + needs: + - runboat_preview + script: + - pip install -r e2e/requirements.txt + - pytest e2e/ --base-url=$BUILD_URL -v --tracing=retain-on-failure + artifacts: + when: on_failure + paths: + - e2e/traces/ + expire_in: 1 week + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' +``` + +## Success Criteria + +✅ **Phase 3 Complete when:** +- [ ] 10–20 E2E scenarios passing (Runboat) +- [ ] Performance baseline established (latency, tokens, queries) +- [ ] Concurrent generation verified (5+ simultaneous posts) +- [ ] All E2E tests green on merge requests +- [ ] Runboat integration in CI/CD +- [ ] Performance metrics documented in README +- [ ] No E2E test flakiness (< 2% failure rate) + +## Performance SLO Targets + +| Metric | Target | Rationale | +|---|---|---| +| Generation latency (P50) | < 30 seconds | User experience (wizard response time) | +| Generation latency (P99) | < 60 seconds | Outlier tolerance | +| Tokens per post | 800–1200 | Cost baseline for budget planning | +| Queries per generation | < 50 | N+1 detection and DB load | +| Concurrent posts | 5+ | Peak capacity without degradation | +| Email send latency | < 5 seconds | Notification responsiveness | +| Template DB prime time | < 60 seconds | CI/CD pipeline efficiency | + +## Implementation Timeline + +| Week | Task | Owner | +|---|---|---| +| W1 | Set up e2e/ directory, conftest.py, Runboat polling | Claude | +| W1 | Implement 3–5 core E2E scenarios (generation, scheduling) | Claude | +| W2 | Add error recovery and email scenarios | Claude | +| W2 | Set up performance measurement (latency, queries) | Claude | +| W3 | Stress testing and concurrency verification | Claude | +| W3 | Performance tuning if SLOs not met | Claude | +| W4 | Runboat CI/CD integration | Claude | +| W4 | Final verification and documentation | Claude | + +## Known Constraints + +### Runboat Limitations + +- **Cold start**: First request may take 30–60s (instance startup) +- **Auto-cleanup**: Instance removed 5 min after last request +- **No persistent storage**: Data lost when instance cleaned up +- **Resource limits**: CPU/memory capped per deployment tier + +### E2E Test Maintenance + +- **Brittle selectors**: Avoid `.o_field_value` (auto-generated) +- **Timing issues**: Use `page.wait_for_*()` not `time.sleep()` +- **Flakiness**: Run 3× locally before merging +- **Timeout**: Set ≥ 30s for slow JS rendering + +## References + +- [Runboat Documentation](https://docs.acsone.eu/runboat/) +- [Playwright Python API](https://playwright.dev/python/) +- [Odoo E2E Best Practices](https://github.com/OCA/server-tools/tree/17.0#e2e-testing) + +--- + +**Next**: Set up e2e/ directory and implement core scenarios diff --git a/e2e/conftest.py b/e2e/conftest.py new file mode 100644 index 0000000..f204ac5 --- /dev/null +++ b/e2e/conftest.py @@ -0,0 +1,118 @@ +""" +Playwright E2E test configuration for ITSulu Blog Publisher. +Handles Runboat instance polling, authentication, and fixture setup. +""" +import os +import time +import requests +import pytest + +BASE_URL = os.environ.get('ODOO_BASE_URL', 'http://localhost:8069') +RUNBOAT_TIMEOUT = 180 # seconds + + +def wait_for_odoo(url, timeout=RUNBOAT_TIMEOUT): + """ + Poll Odoo login page until it responds (Runboat cold-start handling). + Runboat instances take 30–60s to boot; this ensures we don't timeout. + """ + deadline = time.time() + timeout + last_error = None + + while time.time() < deadline: + try: + response = requests.get(f"{url}/web/login", timeout=5) + if response.status_code == 200: + return + except Exception as e: + last_error = e + pass + time.sleep(2) + + raise TimeoutError( + f"Odoo at {BASE_URL} did not respond after {timeout}s. " + f"Last error: {last_error}" + ) + + +@pytest.fixture(scope='session') +def browser_context_args(browser_context_args): + """ + Poll Runboat instance and set base URL for all tests. + Runs once per test session before any browser is created. + """ + print(f"\n📡 Waiting for Odoo at {BASE_URL}...") + wait_for_odoo(BASE_URL) + print("✅ Odoo is ready") + + return {**browser_context_args, 'base_url': BASE_URL} + + +@pytest.fixture(scope='session') +def auth_state(browser, browser_context_args): + """ + Log in as admin and save authentication state. + Reused across all tests to avoid repeated login (30s+ per test). + """ + print("\n🔐 Authenticating as admin...") + ctx = browser.new_context(**browser_context_args) + page = ctx.new_page() + page.set_default_timeout(30_000) + + try: + page.goto('/web/login') + page.get_by_label('Email').fill('admin') + page.get_by_label('Password').fill('admin') + page.get_by_role('button', name='Log in').click() + page.wait_for_url('**/odoo/**', timeout=30_000) + + state = ctx.storage_state() + print("✅ Authentication successful") + ctx.close() + return state + except Exception as e: + ctx.close() + raise RuntimeError(f"Login failed: {e}") + + +@pytest.fixture +def page(browser, browser_context_args, auth_state): + """ + Authenticated Playwright page fixture. + Each test gets a fresh context with saved auth state. + """ + ctx = browser.new_context(**browser_context_args, storage_state=auth_state) + pg = ctx.new_page() + pg.set_default_timeout(30_000) # Odoo JS rendering is slow + + yield pg + + ctx.close() + + +@pytest.fixture +def admin_user(page): + """ + Helper to get admin user record (via API call within page context). + Useful for setting up test data that requires user relationships. + """ + # In a real Runboat instance, we'd fetch this via the web services + # For now, return a simple dict with admin info + return { + 'id': 2, # Odoo default admin user ID + 'name': 'Administrator', + 'email': 'admin@example.com', + 'login': 'admin', + } + + +@pytest.fixture(scope='session') +def blog_name(): + """Pre-configured test blog name (must exist in template DB).""" + return 'ITSulu Insights' + + +@pytest.fixture(scope='session') +def notification_email(): + """Email address for testing notifications.""" + return 'test@itsulu.com' diff --git a/e2e/requirements.txt b/e2e/requirements.txt new file mode 100644 index 0000000..75c8dc7 --- /dev/null +++ b/e2e/requirements.txt @@ -0,0 +1,4 @@ +pytest>=9.0.0 +pytest-playwright>=0.4.0 +playwright>=1.40.0 +requests>=2.31.0 diff --git a/e2e/test_error_recovery.py b/e2e/test_error_recovery.py new file mode 100644 index 0000000..888cfa1 --- /dev/null +++ b/e2e/test_error_recovery.py @@ -0,0 +1,297 @@ +""" +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}" diff --git a/e2e/test_generation.py b/e2e/test_generation.py new file mode 100644 index 0000000..32816f0 --- /dev/null +++ b/e2e/test_generation.py @@ -0,0 +1,179 @@ +""" +E2E tests for blog post generation workflows. +Tests the full user journey: navigate → fill form → submit → verify post created. +""" +from playwright.sync_api import expect + + +class TestOnDemandGeneration: + """Tests for on-demand generation via the wizard.""" + + def test_user_generates_blog_post_on_demand(self, page, blog_name): + """ + User navigates to Blog Publisher, fills the generation form, + and receives a published blog post with correct metadata. + + Workflow: + 1. Navigate to Blog Publisher module + 2. Click "Generate Now" button + 3. Fill topic, select provider and model + 4. Set auto-publish = True + 5. Submit form + 6. Verify: blog.post created, published, has SEO fields + """ + # Navigate to Blog Publisher + page.goto('/web') + page.get_by_role('link', name='Blog').click() + page.wait_for_url('**/web#*', timeout=10_000) + + # Click "Generate Now" button (or navigate directly) + page.goto('/odoo/blog/generate-now') + page.wait_for_load_state('networkidle') + + # Fill the generation form + page.get_by_label('Topic').fill('Kubernetes Cost Optimization Best Practices') + page.get_by_label('LLM Provider').select_option('anthropic') + page.get_by_label('LLM Model').fill('claude-sonnet-4-20250514') + + # Check auto-publish + page.get_by_label('Auto-publish').check() + + # Click Generate button + page.get_by_role('button', name='Generate').click() + + # Wait for success message or redirect to generated post + page.wait_for_url('**/blog/**', timeout=60_000) # 60s for LLM API call + + # Verify post is published + expect(page.locator('body')).to_contain_text('Kubernetes Cost Optimization') + + # Verify SEO fields are visible in breadcrumb or heading + expect(page.locator('h1')).to_contain_text('Kubernetes') + + def test_user_saves_post_as_draft_for_review(self, page): + """ + User generates a post but saves it as draft (unpublished) for review. + + Workflow: + 1. Fill generation form with auto-publish = False + 2. Submit + 3. Verify: blog.post created, NOT published (draft state) + 4. Verify: editor can manually edit and publish later + """ + page.goto('/odoo/blog/generate-now') + page.wait_for_load_state('networkidle') + + # Fill form + page.get_by_label('Topic').fill('Cloud Security Incidents 2026') + page.get_by_label('LLM Provider').select_option('anthropic') + page.get_by_label('LLM Model').fill('claude-sonnet-4-20250514') + + # DO NOT check auto-publish (leave as draft) + page.get_by_label('Auto-publish').uncheck() + + # Submit + page.get_by_role('button', name='Generate').click() + + # Wait for form to close and log entry to appear + page.wait_for_url('**/blog/log/**', timeout=60_000) + + # Verify we're on the log page (not the published post) + expect(page.locator('body')).to_contain_text('Generation Log') + + # Verify draft post exists in blog list (unpublished) + page.goto('/web#model=blog.post&view_type=list') + page.wait_for_load_state('networkidle') + + # Find the draft post (should have "Draft" label) + expect(page.locator('body')).to_contain_text('Cloud Security') + + def test_generation_shows_error_when_api_key_missing(self, page): + """ + User submits generation form without API key configured. + System shows error and allows user to fix settings and retry. + + Workflow: + 1. Fill form (API key not configured) + 2. Submit + 3. Verify: error message displayed + 4. Verify: user guided to Settings to fix API key + """ + page.goto('/odoo/blog/generate-now') + page.wait_for_load_state('networkidle') + + # Fill form with provider that has no API key + page.get_by_label('Topic').fill('Test Topic') + page.get_by_label('LLM Provider').select_option('anthropic') + page.get_by_label('LLM Model').fill('claude-sonnet-4-20250514') + + # Submit (will fail if no API key) + page.get_by_role('button', name='Generate').click() + + # Should show error dialog or message + # Wait for error to appear (either on form or in popup) + expect(page.locator('body')).to_contain_text( + 'error|not configured|API key', use_regex=True, timeout=10_000 + ) + + def test_generation_shows_progress_during_api_call(self, page): + """ + User sees visual feedback while LLM is processing (loading spinner, progress bar). + + Workflow: + 1. Fill form and submit + 2. Observe: loading spinner visible + 3. Wait for LLM response + 4. Verify: spinner hidden, post displayed + """ + page.goto('/odoo/blog/generate-now') + page.wait_for_load_state('networkidle') + + page.get_by_label('Topic').fill('AI Trends 2026') + page.get_by_label('LLM Provider').select_option('anthropic') + page.get_by_label('LLM Model').fill('claude-sonnet-4-20250514') + + # Submit + page.get_by_role('button', name='Generate').click() + + # Look for loading indicator (might be disabled button, spinner, or modal) + # This test is loose because indicator type depends on Odoo UI + page.wait_for_url('**/blog/**', timeout=60_000) + + # Eventually should show the post (loading done) + expect(page.locator('body')).to_contain_text('AI Trends') + + +class TestGenerationWithSocialCopy: + """Tests generation that includes social media copy.""" + + def test_generated_post_includes_social_media_copy(self, page): + """ + Generated blog post includes social media copy for + X, BlueSky, Mastodon, and LinkedIn. + + Workflow: + 1. Generate post + 2. Open post edit form + 3. Verify: Social Media Copy tab/section exists + 4. Verify: Posts for each enabled platform are populated + """ + page.goto('/odoo/blog/generate-now') + page.wait_for_load_state('networkidle') + + page.get_by_label('Topic').fill('Enterprise AI Security') + 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) + + # Open edit form for the post + page.get_by_role('link', name='Edit').click() + page.wait_for_load_state('networkidle') + + # Look for Social Media Copy section + page.get_by_role('tab', name='Social Media').click() + + # Verify social posts are populated + expect(page.locator('[data-field="twitter_post_a"]')).not_to_have_text('') + expect(page.locator('[data-field="linkedin_post"]')).not_to_have_text('') diff --git a/e2e/test_scheduling.py b/e2e/test_scheduling.py new file mode 100644 index 0000000..d916f51 --- /dev/null +++ b/e2e/test_scheduling.py @@ -0,0 +1,254 @@ +""" +E2E tests for schedule slot configuration and execution. +Tests the workflow of setting up daily scheduled generation. +""" +from playwright.sync_api import expect + + +class TestScheduleConfiguration: + """Tests for configuring and managing schedule slots.""" + + def test_user_configures_three_schedule_slots(self, page): + """ + User creates three daily schedule slots (morning, afternoon, evening) + with different LLM providers and auto-publish settings. + + Workflow: + 1. Navigate to Schedule Slots + 2. For each slot: fill name, time, provider, model, auto-publish + 3. Save all slots + 4. Verify: all 3 slots appear in list as Active + """ + # Navigate to Blog Publisher > Schedule Slots + page.goto('/web') + page.get_by_role('link', name='Blog').click() + page.wait_for_url('**/web#*', timeout=10_000) + + page.get_by_role('link', name='Blog Publisher').click() + page.get_by_role('link', name='Schedule Slots').click() + page.wait_for_load_state('networkidle') + + # Create morning slot + page.get_by_role('button', name='New').click() + page.wait_for_load_state('networkidle') + + page.get_by_label('Slot Name').fill('Morning Generation') + page.get_by_label('Slot').select_option('morning') + page.get_by_label('Trigger Time').fill('8.0') # 8:00 UTC + page.get_by_label('LLM Provider').select_option('anthropic') + page.get_by_label('Auto-publish').check() + page.get_by_role('button', name='Save').click() + + page.wait_for_load_state('networkidle') + + # Return to list and create afternoon slot + page.get_by_role('button', name='New').click() + page.wait_for_load_state('networkidle') + + page.get_by_label('Slot Name').fill('Afternoon Generation') + page.get_by_label('Slot').select_option('afternoon') + page.get_by_label('Trigger Time').fill('14.0') # 2:00 PM UTC + page.get_by_label('LLM Provider').select_option('openai') + page.get_by_label('Auto-publish').uncheck() # Save as draft + page.get_by_role('button', name='Save').click() + + page.wait_for_load_state('networkidle') + + # Create evening slot + page.get_by_role('button', name='New').click() + page.wait_for_load_state('networkidle') + + page.get_by_label('Slot Name').fill('Evening Generation') + page.get_by_label('Slot').select_option('evening') + page.get_by_label('Trigger Time').fill('20.0') # 8:00 PM UTC + page.get_by_label('LLM Provider').select_option('gemini') + page.get_by_label('Auto-publish').check() + page.get_by_role('button', name='Save').click() + + page.wait_for_load_state('networkidle') + + # Navigate back to list and verify all 3 slots exist + page.goto('/web#model=itsulu.blog.schedule&view_type=list') + page.wait_for_load_state('networkidle') + + expect(page.locator('body')).to_contain_text('Morning Generation') + expect(page.locator('body')).to_contain_text('Afternoon Generation') + expect(page.locator('body')).to_contain_text('Evening Generation') + + def test_user_edits_schedule_slot_settings(self, page): + """ + User opens an existing schedule slot and modifies its settings + (LLM provider, auto-publish flag, trigger time). + + Workflow: + 1. Navigate to Schedule Slots + 2. Click on existing slot + 3. Change settings (provider, time, auto-publish) + 4. Save + 5. Verify changes persisted + """ + page.goto('/web#model=itsulu.blog.schedule&view_type=list') + page.wait_for_load_state('networkidle') + + # Click on morning slot + page.get_by_role('link', name='Morning Generation').click() + page.wait_for_load_state('networkidle') + + # Edit trigger time + page.get_by_label('Trigger Time').fill('7.5') # Change from 8.0 to 7:30 + + # Change provider + page.get_by_label('LLM Provider').select_option('openai') + + # Toggle auto-publish + page.get_by_label('Auto-publish').uncheck() + + # Save + page.get_by_role('button', name='Save').click() + + # Verify changes by reopening + page.goto('/web#model=itsulu.blog.schedule&view_type=list') + page.wait_for_load_state('networkidle') + page.get_by_role('link', name='Morning Generation').click() + + # Check that changes were saved + expect(page.get_by_label('Trigger Time')).to_have_value('7.5') + expect(page.get_by_label('LLM Provider')).to_have_value('openai') + expect(page.get_by_label('Auto-publish')).not_to_be_checked() + + def test_user_disables_schedule_slot(self, page): + """ + User deactivates a schedule slot. + Inactive slots are not executed by the scheduler. + + Workflow: + 1. Navigate to Schedule Slots + 2. Click slot + 3. Uncheck Active checkbox + 4. Save + 5. Verify: slot shows as inactive in list + """ + page.goto('/web#model=itsulu.blog.schedule&view_type=list') + page.wait_for_load_state('networkidle') + + # Click evening slot + page.get_by_role('link', name='Evening Generation').click() + page.wait_for_load_state('networkidle') + + # Uncheck Active + page.get_by_label('Active').uncheck() + page.get_by_role('button', name='Save').click() + + # Verify in list view (might show as grayed out or with [Draft] label) + page.goto('/web#model=itsulu.blog.schedule&view_type=list') + page.wait_for_load_state('networkidle') + + # Evening slot should still appear but inactive + expect(page.locator('body')).to_contain_text('Evening Generation') + + +class TestScheduleExecution: + """Tests for schedule slot execution (via manual trigger, not real cron).""" + + def test_user_manually_triggers_schedule_slot(self, page): + """ + User clicks a "Run Now" button on schedule slot to trigger + generation immediately (for testing, not waiting for cron). + + Workflow: + 1. Navigate to schedule slot + 2. Click "Run Generation Now" button + 3. Verify: blog post created, logged, email sent + """ + page.goto('/web#model=itsulu.blog.schedule&view_type=list') + page.wait_for_load_state('networkidle') + + # Click morning slot + page.get_by_role('link', name='Morning Generation').click() + page.wait_for_load_state('networkidle') + + # Click Run Generation Now button + page.get_by_role('button', name='Run Generation Now').click() + + # Wait for generation to complete + page.wait_for_url('**/blog/**', timeout=60_000) + + # Verify success message or redirect to created post + expect(page.locator('body')).to_contain_text('Blog') + + def test_schedule_slot_falls_back_to_llm_chosen_topic_when_queue_empty(self, page): + """ + When topic queue is empty, schedule slot asks LLM to choose a topic. + LLM returns a relevant topic and post is generated. + + Workflow: + 1. Verify topic queue is empty + 2. Trigger schedule slot + 3. Verify: blog post created with LLM-chosen title + """ + # First, clear the topic queue + page.goto('/web#model=itsulu.blog.topic&view_type=list&domain=[[\"state\",\"=\",\"pending\"]]') + page.wait_for_load_state('networkidle') + + # Delete all pending topics (or change state to Used) + # For this test, assume queue is already empty + expect(page.locator('body')).to_contain_text('No records') + + # Now trigger schedule slot + page.goto('/web#model=itsulu.blog.schedule&view_type=form') + page.wait_for_load_state('networkidle') + + page.get_by_role('button', name='Run Generation Now').click() + page.wait_for_url('**/blog/**', timeout=60_000) + + # Post should be created with LLM-chosen topic + expect(page.locator('h1')).not_to_have_text('') # Has some title + + def test_schedule_slot_consumes_topics_from_queue(self, page): + """ + When topic queue has pending topics, schedule slot uses them in priority order. + Topics are marked as Used after generation. + + Workflow: + 1. Create 3 pending topics with priorities + 2. Trigger schedule slot + 3. Verify: highest priority topic consumed + 4. Verify: topic state changed to Used + """ + # Navigate to topic queue + page.goto('/web#model=itsulu.blog.topic&view_type=list') + page.wait_for_load_state('networkidle') + + # Create urgent topic + page.get_by_role('button', name='New').click() + page.wait_for_load_state('networkidle') + + page.get_by_label('Topic Name').fill('Critical: Zero-Day Vulnerability Response') + page.get_by_label('Priority').select_option('urgent') + page.get_by_label('State').select_option('pending') + page.get_by_role('button', name='Save').click() + + page.wait_for_load_state('networkidle') + + # Now trigger schedule slot (should use the urgent topic) + page.goto('/web#model=itsulu.blog.schedule&view_type=list') + page.wait_for_load_state('networkidle') + page.get_by_role('link', name='Morning Generation').click() + page.get_by_role('button', name='Run Generation Now').click() + + page.wait_for_url('**/blog/**', timeout=60_000) + + # Verify the generated post used the urgent topic + expect(page.locator('h1')).to_contain_text('Vulnerability|Response', use_regex=True) + + # Verify topic was marked as Used + page.goto('/web#model=itsulu.blog.topic&view_type=list') + page.wait_for_load_state('networkidle') + + # Find the used topic + page.get_by_role('button', name='Filters').click() + page.get_by_role('checkbox', name='State').check() + page.get_by_role('option', name='Used').click() + + # Should appear in Used list + expect(page.locator('body')).to_contain_text('Zero-Day')