"""Tests for the sfpermits.ai web UI."""
import pytest
import sys
import os
import web.app as _app_mod
from web.helpers import md_to_html, _rate_buckets
@pytest.fixture
def client():
_app = _app_mod.app # Always get the current app from the module
_app.config["TESTING"] = True
_rate_buckets.clear() # Reset rate limits between tests
with _app.test_client() as client:
yield client
_rate_buckets.clear()
def test_index_loads_landing_page(client):
"""Homepage renders landing page for unauthenticated users."""
rv = client.get("/")
assert rv.status_code == 200
html = rv.data.decode()
assert "sfpermits.ai" in html
assert "Building Permit Intelligence" in html
assert "/search" in html # search form action
def test_index_loads_app_when_logged_in(client):
"""Homepage renders full app for authenticated users."""
import src.db as db_mod
if db_mod.BACKEND == "duckdb":
db_mod.init_user_schema()
from web.auth import get_or_create_user, create_magic_token
user = get_or_create_user("indextest@test.com")
token = create_magic_token(user["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.get("/")
assert rv.status_code == 200
html = rv.data.decode()
assert "sfpermits.ai" in html
assert "Analyze Project" in html
assert "Kitchen Remodel" in html # preset chip
def test_index_has_neighborhoods(client):
"""Neighborhood dropdown is populated for authenticated users."""
import src.db as db_mod
if db_mod.BACKEND == "duckdb":
db_mod.init_user_schema()
from web.auth import get_or_create_user, create_magic_token
user = get_or_create_user("hoodtest@test.com")
token = create_magic_token(user["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.get("/")
html = rv.data.decode()
assert "Noe Valley" in html
assert "Mission" in html
assert "Pacific Heights" in html
def test_analyze_empty_description(client):
"""POST with empty description returns 400."""
rv = client.post("/analyze", data={"description": ""})
assert rv.status_code == 400
assert b"Please enter a project description" in rv.data
def test_analyze_basic(client):
"""POST with minimal input returns 5 result panels."""
rv = client.post("/analyze", data={
"description": "Kitchen remodel removing wall",
"cost": "85000",
"neighborhood": "Noe Valley",
})
assert rv.status_code == 200
html = rv.data.decode()
# All 5 tab panels present
assert 'id="panel-predict"' in html
assert 'id="panel-fees"' in html
assert 'id="panel-timeline"' in html
assert 'id="panel-docs"' in html
assert 'id="panel-risk"' in html
def test_analyze_no_cost(client):
"""POST without cost still runs predict/timeline/docs/risk but fees shows info message."""
rv = client.post("/analyze", data={
"description": "Small bathroom refresh",
})
assert rv.status_code == 200
html = rv.data.decode()
assert 'id="panel-predict"' in html
assert "Enter an estimated cost" in html # fees info message
def test_analyze_restaurant(client):
"""Restaurant project triggers DPH/fire routing."""
rv = client.post("/analyze", data={
"description": "Convert retail to restaurant with Type I hood, grease interceptor, 49 seats",
"cost": "250000",
"neighborhood": "Mission",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "restaurant" in html.lower() or "food" in html.lower()
def test_analyze_adu(client):
"""ADU project gets proper routing."""
rv = client.post("/analyze", data={
"description": "Convert garage to ADU with kitchenette and bathroom, 450 sq ft",
"cost": "180000",
"sqft": "450",
"neighborhood": "Sunset/Parkside",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "adu" in html.lower() or "accessory" in html.lower()
def test_md_to_html_basic():
"""md_to_html converts markdown tables and bold."""
result = md_to_html("**bold text**\n\n| A | B |\n|---|---|\n| 1 | 2 |")
assert "<strong>" in result
assert "<table>" in result
def test_md_to_html_links():
"""md_to_html preserves links."""
result = md_to_html("[sf.gov](https://sf.gov)")
assert 'href="https://sf.gov"' in result
# --- Plan Set Validator web tests ---
def _make_simple_pdf():
"""Create a minimal valid PDF for upload tests."""
from pypdf import PdfWriter
from pypdf.generic import RectangleObject
writer = PdfWriter()
writer.add_blank_page(width=22 * 72, height=34 * 72)
import io
buf = io.BytesIO()
writer.write(buf)
return buf.getvalue()
def test_index_has_validator(client):
"""Homepage includes the plan analysis (AI Vision) section for logged-in users."""
import src.db as db_mod
if db_mod.BACKEND == "duckdb":
db_mod.init_user_schema()
from web.auth import get_or_create_user, create_magic_token
user = get_or_create_user("validatortest@test.com")
token = create_magic_token(user["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.get("/")
html = rv.data.decode()
# Section was renamed from 'Plan Set Validator' to 'Analyze Plans (AI Vision)'
assert "Analyze Plans" in html or "analyze-plans" in html
assert "planfile" in html # file input
def test_validate_no_file(client):
"""POST to /validate with no file returns 400."""
rv = client.post("/validate", data={})
assert rv.status_code == 400
assert b"Please select a PDF" in rv.data
def test_validate_non_pdf(client):
"""POST to /validate with non-PDF returns 400."""
from io import BytesIO
rv = client.post("/validate", data={
"planfile": (BytesIO(b"not a pdf"), "readme.txt"),
}, content_type="multipart/form-data")
assert rv.status_code == 400
assert b"Only PDF files" in rv.data
def test_validate_empty_pdf(client):
"""POST to /validate with empty file returns 400."""
from io import BytesIO
rv = client.post("/validate", data={
"planfile": (BytesIO(b""), "plans.pdf"),
}, content_type="multipart/form-data")
assert rv.status_code == 400
assert b"empty" in rv.data
def test_validate_success(client):
"""POST to /validate with valid PDF returns EPR report."""
from io import BytesIO
pdf_data = _make_simple_pdf()
rv = client.post("/validate", data={
"planfile": (BytesIO(pdf_data), "A-PLAN-R0 123 Main St.pdf"),
}, content_type="multipart/form-data")
assert rv.status_code == 200
html = rv.data.decode()
assert "EPR Compliance Report" in html
assert "A-PLAN-R0 123 Main St.pdf" in html
def test_validate_with_addendum(client):
"""POST with addendum checkbox uses higher file limit."""
from io import BytesIO
pdf_data = _make_simple_pdf()
rv = client.post("/validate", data={
"planfile": (BytesIO(pdf_data), "plans.pdf"),
"is_addendum": "on",
}, content_type="multipart/form-data")
assert rv.status_code == 200
html = rv.data.decode()
assert "EPR Compliance Report" in html
# --- Security & bot protection tests ---
def test_robots_txt(client):
"""robots.txt blocks all crawlers during beta."""
rv = client.get("/robots.txt")
assert rv.status_code == 200
body = rv.data.decode()
assert "User-agent: *" in body
assert "Disallow: /" in body
assert rv.content_type.startswith("text/plain")
def test_blocked_scanner_paths(client):
"""Vulnerability scanner probe paths return 404."""
for path in ["/wp-admin", "/wp-login.php", "/.env", "/.git", "/phpmyadmin"]:
rv = client.get(path)
assert rv.status_code == 404, f"{path} should return 404"
def test_rate_limit_analyze(client):
"""Rate limit triggers after 10 /analyze POSTs in a window."""
_rate_buckets.clear()
for i in range(10):
rv = client.post("/analyze", data={"description": f"test project {i}"})
assert rv.status_code == 200
# 11th should be rate limited
rv = client.post("/analyze", data={"description": "one more"})
assert rv.status_code == 429
assert b"Rate limit" in rv.data
_rate_buckets.clear()
def test_rate_limit_validate(client):
"""Rate limit triggers after 5 /validate POSTs in a window."""
from io import BytesIO
_rate_buckets.clear()
pdf_data = _make_simple_pdf()
for i in range(5):
rv = client.post("/validate", data={
"planfile": (BytesIO(pdf_data), "plans.pdf"),
}, content_type="multipart/form-data")
assert rv.status_code == 200
# 6th should be rate limited
rv = client.post("/validate", data={
"planfile": (BytesIO(pdf_data), "plans.pdf"),
}, content_type="multipart/form-data")
assert rv.status_code == 429
_rate_buckets.clear()
def test_noindex_meta_tag(client):
"""Beta page has noindex meta tag for authenticated users."""
import src.db as db_mod
if db_mod.BACKEND == "duckdb":
db_mod.init_user_schema()
from web.auth import get_or_create_user, create_magic_token
user = get_or_create_user("noindextest@test.com")
token = create_magic_token(user["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.get("/")
html = rv.data.decode()
assert 'name="robots"' in html
assert "noindex" in html
# --- Enhanced Input Form tests ---
def test_index_has_personalization(client):
"""Homepage includes the collapsible personalization section for logged-in users."""
import src.db as db_mod
if db_mod.BACKEND == "duckdb":
db_mod.init_user_schema()
from web.auth import get_or_create_user, create_magic_token
user = get_or_create_user("personalizetest@test.com")
token = create_magic_token(user["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.get("/")
html = rv.data.decode()
assert "personalize-section" in html
assert "priority-chip" in html
assert "contractor_name" in html
assert "architect_name" in html
assert "consultant_name" in html
assert "experience_level" in html
assert "additional_context" in html
assert "target_date" in html
def test_analyze_with_priorities(client):
"""POST with priority chips reorders result tabs."""
rv = client.post("/analyze", data={
"description": "Kitchen remodel with budget concerns",
"cost": "85000",
"priorities": "cost,timeline",
})
assert rv.status_code == 200
html = rv.data.decode()
# Fees tab should appear (cost priority)
assert "panel-fees" in html
assert "panel-timeline" in html
def test_analyze_with_additional_context(client):
"""Additional context triggers are picked up."""
rv = client.post("/analyze", data={
"description": "Office renovation",
"cost": "200000",
"additional_context": "This is a historic landmark building with seismic concerns",
})
assert rv.status_code == 200
html = rv.data.decode()
# The enriched description should trigger historic and seismic pathways
assert "panel-predict" in html
def test_analyze_with_experience_level(client):
"""Experience level parameter is accepted."""
rv = client.post("/analyze", data={
"description": "Simple bathroom refresh",
"experience_level": "first_time",
})
assert rv.status_code == 200
def test_analyze_with_team_names_no_match(client):
"""Team names that don't match still return 200 with results."""
rv = client.post("/analyze", data={
"description": "Kitchen remodel in Noe Valley",
"cost": "85000",
"contractor_name": "Nonexistent Contractor XYZ12345",
})
assert rv.status_code == 200
html = rv.data.decode()
# Should have team tab since a name was provided
assert "panel-predict" in html
def test_analyze_with_target_date(client):
"""Target date parameter is accepted."""
rv = client.post("/analyze", data={
"description": "Kitchen remodel",
"cost": "85000",
"target_date": "2027-01-15",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "panel-timeline" in html
# ---------------------------------------------------------------------------
# Report share: field name fix + personal message
# ---------------------------------------------------------------------------
def test_report_share_requires_login(client):
"""Report share endpoint requires authentication."""
rv = client.post("/report/0001/001/share", data={
"email": "friend@example.com",
})
# Should redirect to login or return 302/401
assert rv.status_code in (302, 401, 403)
def test_report_share_rejects_bad_email(client):
"""Report share rejects invalid email."""
# Login first (need auth helper from test_auth pattern)
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "web"))
from web.auth import get_or_create_user, create_magic_token
# Force duckdb for this test
import src.db as db_mod
if db_mod.BACKEND == "duckdb":
db_mod.init_user_schema()
user = get_or_create_user("sharer@test.com")
token = create_magic_token(user["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.post("/report/0001/001/share", data={
"email": "notanemail",
})
assert rv.status_code == 400
assert b"valid email" in rv.data
def test_report_share_form_uses_correct_field_name():
"""Report share form sends field name 'email' matching the route."""
import os
template_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"web", "templates", "report.html"
)
with open(template_path) as f:
content = f.read()
# The form should use name="email" to match the route
assert 'name="email"' in content
# Should NOT use the old mismatched field name
assert 'name="recipient_email"' not in content
def test_report_share_form_has_message_field():
"""Report share form includes a personal message textarea."""
import os
template_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"web", "templates", "report.html"
)
with open(template_path) as f:
content = f.read()
assert 'name="message"' in content
assert "personal note" in content.lower()
def test_report_has_prominent_share_button():
"""Report page has a prominent share button in the report body (not just header)."""
import os
template_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"web", "templates", "report.html"
)
with open(template_path) as f:
content = f.read()
# Should have a share button that opens the share modal
assert "openShareModal()" in content
# The button should contain share text
assert "Share" in content
def test_report_email_template_supports_personal_message():
"""Report email template renders personal message block."""
from flask import Flask
test_app = Flask(__name__, template_folder=os.path.join(
os.path.dirname(os.path.dirname(__file__)), "web", "templates"
))
test_app.config["TESTING"] = True
# Mock ReportLinks
class MockLinks:
@staticmethod
def permit(n): return f"http://test/{n}"
@staticmethod
def complaint(n): return f"http://test/{n}"
@staticmethod
def parcel(b, l): return f"http://test/{b}/{l}"
@staticmethod
def planning_code(s): return f"http://test/{s}"
@staticmethod
def entity(n): return f"http://test/{n}"
@staticmethod
def ethics_registry(): return "http://test/ethics"
with test_app.app_context():
from flask import render_template
html = render_template(
"report_email.html",
report={
"address": "123 Test St",
"block": "0001",
"lot": "001",
"links": {"parcel": "http://test/parcel"},
},
report_url="http://test/report/0001/001",
is_owner=False,
links=MockLinks,
sender_name="Tim",
personal_message="Check out this property!",
)
assert "Tim" in html
assert "Check out this property!" in html
assert "shared this with you" in html
def test_report_email_template_hides_message_when_empty():
"""Report email template hides message block when no message."""
from flask import Flask
test_app = Flask(__name__, template_folder=os.path.join(
os.path.dirname(os.path.dirname(__file__)), "web", "templates"
))
test_app.config["TESTING"] = True
class MockLinks:
@staticmethod
def permit(n): return f"http://test/{n}"
@staticmethod
def complaint(n): return f"http://test/{n}"
@staticmethod
def parcel(b, l): return f"http://test/{b}/{l}"
@staticmethod
def planning_code(s): return f"http://test/{s}"
@staticmethod
def entity(n): return f"http://test/{n}"
@staticmethod
def ethics_registry(): return "http://test/ethics"
with test_app.app_context():
from flask import render_template
html = render_template(
"report_email.html",
report={
"address": "456 Other St",
"block": "0002",
"lot": "002",
"links": {"parcel": "http://test/parcel"},
},
report_url="http://test/report/0002/002",
is_owner=False,
links=MockLinks,
)
assert "shared this with you" not in html