"""Playwright E2E tests for severity scoring + property health flows.
Sprint 77-1: Tests core pages used in the severity/property-health user
journey — property reports, search results, portfolio, morning brief, and
the anonymous demo page.
Each test cites the corresponding scenario ID(s) from scenario-design-guide.md
and captures a screenshot to qa-results/screenshots/e2e/.
Run (standalone — recommended):
pytest tests/e2e/test_severity_scenarios.py -v --timeout=120
NOTE: Playwright tests are skipped in the full test suite to avoid asyncio
event loop conflicts. Run them standalone or set E2E_PLAYWRIGHT=1.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
# Ensure project root importable
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))
# ---------------------------------------------------------------------------
# Skip Playwright tests in full suite (same guard as test_scenarios.py)
# ---------------------------------------------------------------------------
_playwright_targeted = any(
"test_severity" in arg or "e2e" == os.path.basename(arg.rstrip("/"))
for arg in sys.argv
)
_playwright_enabled = (
os.environ.get("E2E_PLAYWRIGHT", "")
or os.environ.get("E2E_BASE_URL", "")
or _playwright_targeted
)
pytestmark = pytest.mark.skipif(
not _playwright_enabled,
reason="Playwright tests skipped in full suite (asyncio conflict). "
"Run: pytest tests/e2e/test_severity_scenarios.py -v",
)
SCREENSHOT_DIR = Path("qa-results/screenshots/e2e")
# Well-known SF parcel used in demo data — 1455 Market St (Block 3507, Lot 004)
DEMO_BLOCK = "3507"
DEMO_LOT = "004"
def _screenshot(page, name: str) -> None:
"""Capture a screenshot (best-effort, never fails the test)."""
try:
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(SCREENSHOT_DIR / f"{name}.png"))
except Exception:
pass
# ===========================================================================
# Test 77-1-1: Property report page loads for a known parcel
# SCENARIO 1, 2a, 2b, 3 (severity/health shown on property report)
# ===========================================================================
class TestPropertyReport:
"""Property report loads and exposes health/severity information.
NOTE: These tests require the local DuckDB to be populated via
``python -m src.ingest``. When the ``permits`` table is absent (fresh
checkout, CI without ingest), the report route returns 500 and the
tests skip gracefully rather than fail.
"""
def test_property_report_loads_for_known_parcel(self, page, live_server):
"""SCENARIO-1,2a,2b,3: Property report loads for a real parcel.
Navigates to /report/<block>/<lot> and confirms the page returns
a successful response with permit data. No auth required — reports
are publicly accessible.
Skips when DuckDB permits table is absent (local dev without ingest).
"""
resp = page.goto(f"{live_server}/report/{DEMO_BLOCK}/{DEMO_LOT}")
_screenshot(page, "report-known-parcel")
assert resp is not None
# 500 in local dev = DuckDB not populated (permits table missing).
# This is a pre-existing local-dev condition, not a regression.
if resp.status == 500:
pytest.skip(
"Property report returned 500 — DuckDB permits table likely absent. "
"Run: python -m src.ingest to populate local DB."
)
assert resp.status in (200, 302, 301), (
f"Property report returned unexpected status {resp.status}"
)
if resp.status == 200:
body = page.text_content("body") or ""
lower = body.lower()
# Page should reference permit(s) or the address
assert any(kw in lower for kw in ["permit", "block", "market", "report"]), (
"Property report should contain permit data or block/lot reference"
)
def test_property_report_contains_sections(self, page, live_server):
"""SCENARIO-1,3: Loaded property report has structured sections.
Checks that the report template renders its key sections
(permit list, complaints, risk cards) when data is available.
"""
resp = page.goto(f"{live_server}/report/{DEMO_BLOCK}/{DEMO_LOT}")
_screenshot(page, "report-sections")
if resp is None or resp.status in (500,):
pytest.skip(
"Property report did not return 200 — DuckDB permits table likely absent"
)
if resp.status != 200:
pytest.skip(f"Property report returned {resp.status} — skipping structure check")
body = page.text_content("body") or ""
lower = body.lower()
# Report should contain at least one of: permits table, section headers,
# or risk-assessment content. Resilient — data may vary in test env.
has_content = any(kw in lower for kw in [
"permit", "complaint", "violation", "inspection",
"risk", "section", "health",
])
assert has_content, (
"Property report should have permit/complaint/health content"
)
def test_property_report_invalid_parcel_handled(self, page, live_server):
"""Edge case: Invalid block/lot returns graceful error (not 500 from app bug).
NOTE: In local dev without ingest, this route returns 500 due to missing
DuckDB permits table. That pre-existing condition causes a skip, not a fail.
When the DB is populated, invalid parcels should return 404 or a graceful
error page (never a raw Python traceback).
"""
resp = page.goto(f"{live_server}/report/0000/999X")
_screenshot(page, "report-invalid-parcel")
assert resp is not None
# 500 from local dev (no ingest) — skip, not fail
if resp.status == 500:
body = page.content()
# If the 500 mentions CatalogException/DuckDB it's the known missing-table
# issue. Accept that as a skip. A real app bug would show a different trace.
if "CatalogException" in body or "Table with name" in body or "permits" in body:
pytest.skip(
"Report 500 caused by absent DuckDB permits table — pre-existing "
"local dev condition. Run: python -m src.ingest"
)
# Any other 500 IS a real failure
pytest.fail(
"Invalid parcel returned 500 with unexpected error body — possible bug"
)
# ===========================================================================
# Test 77-1-2: Search results display for address query
# SCENARIO 16 (address matching), SCENARIO 38
# ===========================================================================
class TestSearchResultsSeverity:
"""Search results surface permits for severity assessment."""
def test_search_results_for_market_st(self, page, live_server):
"""SCENARIO-16,38: Address search for 'market' returns permit results.
Confirms the search infrastructure is working and that results
render permit cards or a results section (needed to navigate to
the property report for severity review).
"""
resp = page.goto(f"{live_server}/search?q=market")
page.wait_for_load_state("networkidle")
_screenshot(page, "search-market-results")
assert resp is not None
assert resp.status in (200, 302), (
f"Search returned unexpected status {resp.status}"
)
body = page.text_content("body") or ""
lower = body.lower()
# Results page should mention permits, results, or the search term
assert any(kw in lower for kw in ["permit", "result", "market", "search"]), (
"Search page should contain permit data or search context"
)
def test_search_results_for_specific_address(self, page, live_server):
"""SCENARIO-38: Searching a specific address returns targeted results."""
resp = page.goto(f"{live_server}/search?q=1455+Market+St")
page.wait_for_load_state("networkidle")
_screenshot(page, "search-1455-market")
assert resp is not None
assert resp.status in (200, 302)
body = page.text_content("body") or ""
lower = body.lower()
# Market St permits or address reference should appear
assert any(kw in lower for kw in ["permit", "market", "1455", "result"]), (
"Search for specific address should return results"
)
def test_search_empty_query_handled(self, page, live_server):
"""Edge case: Empty search query returns graceful response."""
resp = page.goto(f"{live_server}/search?q=")
_screenshot(page, "search-empty")
assert resp is not None
assert resp.status in (200, 302), (
f"Empty search returned unexpected status {resp.status}"
)
# ===========================================================================
# Test 77-1-3: Portfolio page loads for authenticated user
# SCENARIO-40 (auth gating), portfolio watch list
# ===========================================================================
class TestPortfolioPageAuth:
"""Portfolio page is accessible to authenticated users."""
def test_portfolio_loads_for_expediter(self, auth_page):
"""SCENARIO-40: Portfolio page renders for authenticated expediter.
Verifies that an authenticated user lands on the portfolio page
(not a login redirect) and sees portfolio-related content.
"""
pg = auth_page("expediter")
pg.goto(f"{pg._base_url}/portfolio")
pg.wait_for_load_state("networkidle")
_screenshot(pg, "portfolio-expediter")
# Must be on the portfolio page (not redirected to login)
url = pg.url
assert "/auth/login" not in url and "/login" not in url, (
"Authenticated user should not be redirected to login"
)
body = pg.text_content("body") or ""
lower = body.lower()
assert any(kw in lower for kw in ["portfolio", "project", "watch", "permit"]), (
"Portfolio page should contain project/permit tracking content"
)
def test_portfolio_anonymous_redirected(self, page, live_server):
"""SCENARIO-40: Anonymous users are redirected away from /portfolio."""
resp = page.goto(f"{live_server}/portfolio")
url = page.url
_screenshot(page, "portfolio-anon-redirect")
# Anonymous visit should redirect to login or return 302
redirected = "/auth/login" in url or "/login" in url
assert redirected or resp.status in (302, 301), (
"Anonymous user should be redirected away from /portfolio"
)
# ===========================================================================
# Test 77-1-4: Brief page renders sections for authenticated user
# SCENARIO-1 (morning brief shows health), SCENARIO-39
# ===========================================================================
class TestMorningBriefAuth:
"""Morning brief renders health/severity sections for authenticated users."""
def test_brief_loads_for_authenticated_user(self, auth_page):
"""SCENARIO-1,39: Morning brief page loads for an authenticated expediter.
Verifies the brief renders — confirming the data pipeline is
functional and the template renders. Resilient: no specific
section text is asserted beyond general page structure.
"""
pg = auth_page("expediter")
pg.goto(f"{pg._base_url}/brief")
pg.wait_for_load_state("networkidle")
_screenshot(pg, "brief-expediter")
# Must not be redirected to login
url = pg.url
assert "/auth/login" not in url and "/login" not in url, (
"Authenticated user should not be redirected to login on /brief"
)
body = pg.text_content("body") or ""
lower = body.lower()
# Brief should have at least a lookback toggle or a section heading
assert any(kw in lower for kw in [
"brief", "permit", "today", "last 7 days", "lookback", "change"
]), "Morning brief page should contain brief/permit content"
def test_brief_lookback_parameter_accepted(self, auth_page):
"""Brief page accepts lookback parameter without error."""
pg = auth_page("expediter")
resp = pg.goto(f"{pg._base_url}/brief?lookback=7")
pg.wait_for_load_state("networkidle")
_screenshot(pg, "brief-lookback-7")
assert resp is not None
assert resp.status == 200, (
f"Brief with lookback=7 returned unexpected status {resp.status}"
)
def test_brief_anonymous_redirected(self, page, live_server):
"""SCENARIO-40: Anonymous users cannot access /brief."""
resp = page.goto(f"{live_server}/brief")
url = page.url
_screenshot(page, "brief-anon-redirect")
redirected = "/auth/login" in url or "/login" in url
assert redirected or resp.status in (302, 301), (
"Anonymous user should be redirected away from /brief"
)
# ===========================================================================
# Test 77-1-5: Demo page shows property intelligence without auth
# SCENARIO-27 (fresh permit GREEN), demo route is public
# ===========================================================================
class TestDemoPageAnonymous:
"""Demo page renders property intelligence for anonymous users."""
def test_demo_page_loads_without_auth(self, page, live_server):
"""Demo page is publicly accessible and renders permit data.
The /demo route shows 1455 Market St pre-loaded data to give
visitors an unauthenticated preview of property intelligence.
"""
resp = page.goto(f"{live_server}/demo")
page.wait_for_load_state("networkidle")
_screenshot(page, "demo-anonymous")
assert resp is not None
assert resp.status == 200, (
f"Demo page returned unexpected status {resp.status}"
)
body = page.text_content("body") or ""
lower = body.lower()
assert any(kw in lower for kw in ["permit", "demo", "market", "property"]), (
"Demo page should contain permit/property data"
)
def test_demo_page_has_property_content(self, page, live_server):
"""Demo page renders a property section, not a blank page."""
page.goto(f"{live_server}/demo")
page.wait_for_load_state("networkidle")
_screenshot(page, "demo-content")
# Page should have some structured content (headings, sections)
try:
headings = page.locator("h1, h2, h3, h4")
assert headings.count() > 0, "Demo page should have structured headings"
except Exception:
# Fallback: body has substantial text
body = page.text_content("body") or ""
assert len(body.strip()) > 100, "Demo page should have substantial content"
def test_demo_page_density_param_handled(self, page, live_server):
"""Edge case: Demo page accepts density=max parameter without error."""
resp = page.goto(f"{live_server}/demo?density=max")
_screenshot(page, "demo-density-max")
assert resp is not None
assert resp.status == 200, (
f"Demo page with density=max returned status {resp.status}"
)