refactor(tests): overhaul visual tests — single server, mock emails, fix init_db
- Consolidate 3 duplicate server processes into 1 session-scoped live_server fixture in conftest.py (port 5111, shared across all visual test modules). Reduces startup overhead from ~3× to 1×. - Fix init_db mock: patch padelnomics.app.init_db (where it's used) instead of core.init_db (where it's defined). The before_serving hook imported init_db locally — patching core alone didn't prevent the real init_db from replacing the in-memory test DB. - Keep patches active through app.run_task() so before_serving hooks can't replace the test DB during the server's lifetime. - Force RESEND_API_KEY="" in the visual test server subprocess to prevent real email sends (dev mode: prints to stdout, returns "dev"). - Remove 4 screenshot-only no-op tests, replace with single test_capture_screenshots that grabs all pages in one pass. - Fix test_planner_tab_switching: remove nonexistent "metrics" tab. - Delete ~200 lines of duplicated boilerplate from 3 test files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,20 +7,10 @@ and validation error handling.
|
||||
Run explicitly with:
|
||||
uv run pytest -m visual tests/test_quote_wizard.py -v
|
||||
"""
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from padelnomics.app import create_app
|
||||
from padelnomics.migrations.migrate import migrate
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from padelnomics import core
|
||||
from playwright.sync_api import expect
|
||||
|
||||
pytestmark = pytest.mark.visual
|
||||
|
||||
@@ -28,69 +18,9 @@ SCREENSHOTS_DIR = Path(__file__).parent / "screenshots"
|
||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def _run_server(ready_event):
|
||||
"""Run the Quart dev server in a separate process."""
|
||||
import aiosqlite
|
||||
|
||||
async def _serve():
|
||||
# Build schema DDL by replaying migrations against a temp DB
|
||||
tmp_db = str(Path(tempfile.mkdtemp()) / "schema.db")
|
||||
migrate(tmp_db)
|
||||
tmp_conn = sqlite3.connect(tmp_db)
|
||||
rows = tmp_conn.execute(
|
||||
"SELECT sql FROM sqlite_master"
|
||||
" WHERE sql IS NOT NULL"
|
||||
" AND name NOT LIKE 'sqlite_%'"
|
||||
" AND name NOT LIKE '%_fts_%'"
|
||||
" AND name != '_migrations'"
|
||||
" ORDER BY rowid"
|
||||
).fetchall()
|
||||
tmp_conn.close()
|
||||
schema_ddl = ";\n".join(r[0] for r in rows) + ";"
|
||||
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
await conn.execute("PRAGMA foreign_keys=ON")
|
||||
await conn.executescript(schema_ddl)
|
||||
await conn.commit()
|
||||
core._db = conn
|
||||
|
||||
with patch.object(core, "init_db", new_callable=AsyncMock), \
|
||||
patch.object(core, "close_db", new_callable=AsyncMock):
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
|
||||
ready_event.set()
|
||||
await app.run_task(host="127.0.0.1", port=5112)
|
||||
|
||||
asyncio.run(_serve())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def live_server():
|
||||
"""Start a live Quart server on port 5112 for quote wizard tests."""
|
||||
ready = multiprocessing.Event()
|
||||
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
|
||||
proc.start()
|
||||
ready.wait(timeout=10)
|
||||
time.sleep(1)
|
||||
yield "http://127.0.0.1:5112"
|
||||
proc.terminate()
|
||||
proc.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def browser():
|
||||
"""Launch a headless Chromium browser."""
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
yield b
|
||||
b.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(browser):
|
||||
"""Create a page for the quote wizard tests."""
|
||||
"""Desktop page for quote wizard tests."""
|
||||
pg = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
yield pg
|
||||
pg.close()
|
||||
@@ -102,38 +32,31 @@ def _check_radio(page, name, value):
|
||||
|
||||
|
||||
def _fill_step_1(page):
|
||||
"""Fill step 1: facility type = indoor."""
|
||||
_check_radio(page, "facility_type", "indoor")
|
||||
|
||||
|
||||
def _fill_step_2(page):
|
||||
"""Fill step 2: country = Germany."""
|
||||
page.select_option("select[name='country']", "DE")
|
||||
|
||||
|
||||
def _fill_step_5(page):
|
||||
"""Fill step 5: timeline = 3-6mo."""
|
||||
_check_radio(page, "timeline", "3-6mo")
|
||||
|
||||
|
||||
def _fill_step_6(page):
|
||||
"""Fill step 6: financing status + decision process (both required)."""
|
||||
_check_radio(page, "financing_status", "self_funded")
|
||||
_check_radio(page, "decision_process", "solo")
|
||||
|
||||
|
||||
def _fill_step_7(page):
|
||||
"""Fill step 7: stakeholder type = entrepreneur."""
|
||||
_check_radio(page, "stakeholder_type", "entrepreneur")
|
||||
|
||||
|
||||
def _fill_step_8(page):
|
||||
"""Fill step 8: select at least one service (required, checkbox pill)."""
|
||||
_check_radio(page, "services_needed", "installation")
|
||||
|
||||
|
||||
def _fill_step_9(page):
|
||||
"""Fill step 9: contact details (name, email, phone, consent)."""
|
||||
page.fill("input[name='contact_name']", "Test User")
|
||||
page.fill("input[name='contact_email']", "test@example.com")
|
||||
page.fill("input[name='contact_phone']", "+49 123 456789")
|
||||
@@ -141,13 +64,11 @@ def _fill_step_9(page):
|
||||
|
||||
|
||||
def _click_next(page):
|
||||
"""Click the Next button and wait for HTMX swap."""
|
||||
page.locator("button.q-btn-next").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
|
||||
def _click_back(page):
|
||||
"""Click the Back button and wait for HTMX swap."""
|
||||
page.locator("button.q-btn-back").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
@@ -167,7 +88,7 @@ def test_quote_wizard_full_flow(live_server, page):
|
||||
_fill_step_2(page)
|
||||
_click_next(page)
|
||||
|
||||
# Step 3: Build Context (optional — just click next)
|
||||
# Step 3: Build Context (optional)
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
|
||||
_click_next(page)
|
||||
|
||||
@@ -180,7 +101,7 @@ def test_quote_wizard_full_flow(live_server, page):
|
||||
_fill_step_5(page)
|
||||
_click_next(page)
|
||||
|
||||
# Step 6: Financing (has required fields)
|
||||
# Step 6: Financing
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Financing")
|
||||
_fill_step_6(page)
|
||||
_click_next(page)
|
||||
@@ -190,7 +111,7 @@ def test_quote_wizard_full_flow(live_server, page):
|
||||
_fill_step_7(page)
|
||||
_click_next(page)
|
||||
|
||||
# Step 8: Services Needed (at least one required)
|
||||
# Step 8: Services Needed
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Services Needed")
|
||||
_fill_step_8(page)
|
||||
_click_next(page)
|
||||
@@ -199,14 +120,16 @@ def test_quote_wizard_full_flow(live_server, page):
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Contact Details")
|
||||
_fill_step_9(page)
|
||||
|
||||
# Submit the form
|
||||
# Submit
|
||||
page.locator("button.q-btn-submit").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Should see either success page or verification sent page (both acceptable)
|
||||
body_text = page.locator("body").inner_text()
|
||||
assert "matched" in body_text.lower() or "check your email" in body_text.lower() or "verify" in body_text.lower(), \
|
||||
f"Expected success or verification page, got: {body_text[:200]}"
|
||||
assert (
|
||||
"matched" in body_text.lower()
|
||||
or "check your email" in body_text.lower()
|
||||
or "verify" in body_text.lower()
|
||||
), f"Expected success or verification page, got: {body_text[:200]}"
|
||||
|
||||
page.screenshot(path=str(SCREENSHOTS_DIR / "quote_wizard_submitted.png"), full_page=True)
|
||||
|
||||
@@ -216,29 +139,24 @@ def test_quote_wizard_back_navigation(live_server, page):
|
||||
page.goto(f"{live_server}/en/leads/quote")
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Step 1: select Indoor
|
||||
_fill_step_1(page)
|
||||
_click_next(page)
|
||||
|
||||
# Step 2: select Germany
|
||||
_fill_step_2(page)
|
||||
page.fill("input[name='city']", "Berlin")
|
||||
_click_next(page)
|
||||
|
||||
# Step 3: select a build context (radio is display:none, click label instead)
|
||||
_check_radio(page, "build_context", "new_standalone")
|
||||
_click_next(page)
|
||||
|
||||
# Step 4: now go back to step 3
|
||||
# Step 4: go back to step 3
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase")
|
||||
_click_back(page)
|
||||
|
||||
# Verify we're on step 3 and build_context is preserved
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
|
||||
checked = page.locator("input[name='build_context'][value='new_standalone']").is_checked()
|
||||
assert checked, "Build context 'new_standalone' should still be checked after going back"
|
||||
|
||||
# Go back to step 2 and verify data preserved
|
||||
_click_back(page)
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
|
||||
|
||||
@@ -254,22 +172,19 @@ def test_quote_wizard_validation_errors(live_server, page):
|
||||
page.goto(f"{live_server}/en/leads/quote")
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Step 1: DON'T select facility_type, just click Next
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
|
||||
_click_next(page)
|
||||
|
||||
# Should still be on step 1 with an error hint
|
||||
# Should still be on step 1 with error hint
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
|
||||
error_hint = page.locator(".q-error-hint")
|
||||
expect(error_hint).to_be_visible()
|
||||
expect(page.locator(".q-error-hint")).to_be_visible()
|
||||
|
||||
# Now fill the field and proceed — should work
|
||||
# Fix and proceed
|
||||
_fill_step_1(page)
|
||||
_click_next(page)
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
|
||||
|
||||
# Skip country (required on step 2) — should stay on step 2
|
||||
# Skip country (required)
|
||||
_click_next(page)
|
||||
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
|
||||
error_hint = page.locator(".q-error-hint")
|
||||
expect(error_hint).to_be_visible()
|
||||
expect(page.locator(".q-error-hint")).to_be_visible()
|
||||
|
||||
Reference in New Issue
Block a user