We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/tbrennem-source/sf-permits-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
"""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