Spaces:
Running
Running
| """API contract — FastAPI TestClient with the pipeline mocked (no model, no network). | |
| conftest sets ALLOWED_API_KEYS / WARM_MODEL_ON_STARTUP / rate limits before this | |
| module imports `api`. `run` is monkeypatched to a canned result dict so we exercise | |
| auth, the response schema, error mapping, and the batch job flow only. | |
| """ | |
| from __future__ import annotations | |
| import time | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from lawn_estimator import api | |
| from lawn_estimator.db import ( | |
| ApiKeyStore, | |
| AuditLog, | |
| ConfigStore, | |
| LeadStore, | |
| MeasurementEditStore, | |
| RunLedger, | |
| TenantStore, | |
| UserStore, | |
| connect, | |
| ) | |
| from lawn_estimator.pricing import PricingConfig, Service | |
| from lawn_estimator.tenants import ServiceArea, Tenant | |
| KEY = {"X-API-Key": "test-key"} | |
| CANNED_RESULT = { | |
| "rgb_veg_sqft": 1200.0, | |
| "lidar_lawn_sqft": 4669.8, | |
| "lawn_sqft": 4669.8, | |
| "parcel_area_sqft": 6504.6, | |
| "estimation_area_sqft": 7104.8, | |
| "ground_sampled_sqft": 5346.3, | |
| "row_buffer_ft": 12.0, | |
| "formatted_address": "5534 Mayberry St, Omaha, NE 68106", | |
| "zip_code": "68106", | |
| "visualization_path": "/does/not/exist.png", | |
| "imagery_source": "naip", | |
| "region": "Douglas County, NE", | |
| "method": "lidar+rgb", | |
| "confidence": "high", | |
| "extensions": [{"street": "Mayberry St", "area_sqft": 600.2, | |
| "centroid": [-96.0, 41.26], "street_facing": True}], | |
| "extension_total_sqft": 600.2, | |
| "warning": None, | |
| } | |
| def client(): | |
| with TestClient(api.app) as c: | |
| yield c | |
| class _FakeGeocode: | |
| """Minimal stand-in for GeocodeResult — the API only reads .zip_code for gating.""" | |
| def __init__(self, zip_code="68106"): | |
| self.zip_code = zip_code | |
| self.formatted_address = "5534 Mayberry St, Omaha, NE 68106" | |
| self.latitude, self.longitude = 41.26, -96.0 | |
| def _patch_resolve(monkeypatch, zip_code="68106"): | |
| monkeypatch.setattr(api, "resolve", lambda address: (object(), _FakeGeocode(zip_code))) | |
| def mock_run(monkeypatch): | |
| def _fake_run(address, imagery="auto", **kwargs): | |
| return dict(CANNED_RESULT) | |
| monkeypatch.setattr(api, "run", _fake_run) | |
| _patch_resolve(monkeypatch, "68106") # geocode-only pre-pipeline gate | |
| return _fake_run | |
| def _default_resolve(monkeypatch): | |
| # _run_one now geocodes (api.resolve) before the pipeline; keep every test off the | |
| # network with an in-Coverage default. Tests needing a specific ZIP re-patch it. | |
| _patch_resolve(monkeypatch, "68106") | |
| def _reset_email_throttle(): | |
| # The per-recipient send throttle is a module-level dict; clear it between | |
| # tests so the email cases don't consume each other's quota. | |
| api._EMAIL_SEND_LOG.clear() | |
| yield | |
| # --------------------------------------------------------------------------- | |
| # Auth + health | |
| # --------------------------------------------------------------------------- | |
| def test_health_needs_no_auth(client): | |
| r = client.get("/health") | |
| assert r.status_code == 200 | |
| assert r.json() == {"status": "ok", "service": "lawn-estimator"} | |
| # --------------------------------------------------------------------------- | |
| # Dashboard auth (P2a) — Clerk identity + our tenant-scoped authorization | |
| # --------------------------------------------------------------------------- | |
| def test_dashboard_me_gate(client, monkeypatch): | |
| monkeypatch.setattr(api, "USERS", UserStore(connect(":memory:"))) | |
| api.USERS.create("acme", "clerk_owner", "o@acme.com", "owner", status="active") | |
| api.USERS.create("acme", "clerk_invited", "p@acme.com", "staff", status="invited") | |
| # No Clerk session (fail-closed: CLERK_SECRET_KEY unset) → 401. | |
| assert client.get("/dashboard/me").status_code == 401 | |
| # A verified Clerk session resolves to OUR user (tenant + role). | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_owner") | |
| body = client.get("/dashboard/me", headers={"Authorization": "Bearer x"}).json() | |
| assert body["tenant_id"] == "acme" and body["role"] == "owner" and body["email"] == "o@acme.com" | |
| # Verified Clerk user with NO provisioned row → 403 (invite-only, no open signup). | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_ghost") | |
| assert client.get("/dashboard/me", headers={"Authorization": "Bearer x"}).status_code == 403 | |
| # Provisioned but not yet active (invited) → 403. | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_invited") | |
| assert client.get("/dashboard/me", headers={"Authorization": "Bearer x"}).status_code == 403 | |
| def test_require_owner_gates_staff(): | |
| import pytest | |
| from fastapi import HTTPException | |
| owner = api.DashboardUser(id=1, tenant_id="acme", role="owner", email="o@x.com") | |
| assert api.require_owner(user=owner) is owner | |
| staff = api.DashboardUser(id=2, tenant_id="acme", role="staff", email="s@x.com") | |
| with pytest.raises(HTTPException) as e: | |
| api.require_owner(user=staff) | |
| assert e.value.status_code == 403 | |
| def test_dashboard_dev_token_shim(monkeypatch): | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", None) # pre-Clerk | |
| monkeypatch.setattr(api, "DASHBOARD_DEV_TOKEN", "devtok") | |
| monkeypatch.setattr(api, "DASHBOARD_DEV_CLERK_ID", "dev_user") | |
| assert api.verify_clerk_session("devtok") == "dev_user" | |
| assert api.verify_clerk_session("wrong") is None | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", "sk_live") # once Clerk is on, dev token is dead | |
| assert api.verify_clerk_session("devtok") is None | |
| def test_dashboard_config_editor_flow(client, monkeypatch): | |
| db = connect(":memory:") # one DB for all the dashboard stores | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "TENANT_STORE", TenantStore(db)) | |
| monkeypatch.setattr(api, "CONFIG_STORE", ConfigStore(db)) | |
| monkeypatch.setattr(api, "AUDIT", AuditLog(db)) | |
| monkeypatch.setattr(api, "TENANTS", {}) | |
| api.USERS.create("acme", "clerk_owner", "o@acme.com", "owner", status="active") | |
| api.USERS.create("acme", "clerk_staff", "s@acme.com", "staff", status="active") | |
| hdr = {"Authorization": "Bearer x"} | |
| valid = {"company": "Acme", "currency": "USD", "presentation": "firm", | |
| "services": [{"id": "mowing", "label": "Mowing", "rate_per_1000_sqft": 5, "min_charge": 45}]} | |
| # --- as OWNER --- | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_owner") | |
| got = client.get("/dashboard/config", headers=hdr).json() | |
| assert got["live"] is None and got["draft"] is None | |
| assert client.put("/dashboard/config/draft", headers=hdr, json={"config": valid}).status_code == 200 | |
| assert client.get("/dashboard/config", headers=hdr).json()["draft"]["company"] == "Acme" | |
| pub = client.post("/dashboard/config/publish", headers=hdr, json={"config": valid}).json() | |
| assert pub["ok"] is True | |
| assert api.TENANT_STORE.get("acme")["company"] == "Acme" # live in the DB | |
| assert "acme" in api.TENANTS # widget hot-reloaded, no redeploy | |
| bad = {**valid, "branding": {"cta_url": "javascript:alert(1)"}} # XSS attempt | |
| pubbad = client.post("/dashboard/config/publish", headers=hdr, json={"config": bad}).json() | |
| assert pubbad["ok"] is False and any("cta_url" in e for e in pubbad["errors"]) | |
| actions = {e["action"] for e in client.get("/dashboard/audit", headers=hdr).json()["entries"]} | |
| assert {"config.publish", "config.draft.save"} <= actions | |
| # --- as STAFF: may view, may not edit (owner-only) --- | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_staff") | |
| assert client.get("/dashboard/config", headers=hdr).status_code == 200 | |
| assert client.put("/dashboard/config/draft", headers=hdr, json={"config": valid}).status_code == 403 | |
| assert client.post("/dashboard/config/publish", headers=hdr, json={"config": valid}).status_code == 403 | |
| def test_dashboard_page_is_served(client): | |
| r = client.get("/dashboard") | |
| assert r.status_code == 200 and "Lawn Dashboard" in r.text | |
| def test_dashboard_batch_runs_and_is_tenant_scoped(client, mock_run, monkeypatch): | |
| monkeypatch.setattr(api, "USERS", UserStore(connect(":memory:"))) | |
| api.USERS.create("acme", "clerk_acme", "a@acme.com", "staff", status="active") | |
| api.USERS.create("beta", "clerk_beta", "b@beta.com", "owner", status="active") | |
| hdr = {"Authorization": "Bearer x"} | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_acme") | |
| r = client.post("/dashboard/batch", headers=hdr, | |
| json={"addresses": ["5534 Mayberry St, Omaha, NE 68106", "5819 Hickory St"]}) | |
| assert r.status_code == 200 and r.json()["total"] == 2 | |
| job_id = r.json()["job_id"] | |
| status = {} | |
| deadline = time.time() + 5 | |
| while time.time() < deadline: | |
| status = client.get(f"/dashboard/jobs/{job_id}", headers=hdr).json() | |
| if status["status"] == "done": | |
| break | |
| time.sleep(0.05) | |
| assert status["status"] == "done" and status["completed"] == 2 | |
| assert all(res["status"] == "ok" for res in status["results"]) | |
| # Another tenant can't see the job (404, not 403 — no existence leak across tenants). | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_beta") | |
| assert client.get(f"/dashboard/jobs/{job_id}", headers=hdr).status_code == 404 | |
| def test_admin_page_is_served(client): | |
| r = client.get("/admin") | |
| assert r.status_code == 200 and "Platform Admin" in r.text | |
| def test_admin_overview_gate_and_cross_tenant(client, monkeypatch): | |
| monkeypatch.setattr(api, "METER", RunLedger(connect(":memory:"))) | |
| api.METER.record("acme", "1", api.BATCH, duration_s=20.0) | |
| api.METER.record_failure("acme", "x", api.SINGLE, error_class="ValueError", outcome="error") | |
| api.METER.record("beta", "9", api.BATCH, duration_s=10.0) | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", None) # pre-Clerk dev path | |
| monkeypatch.setattr(api, "ADMIN_DEV_TOKEN", "admtok") | |
| monkeypatch.setattr(api, "PLATFORM_ADMIN_EMAILS", {"me@platform.com"}) | |
| assert client.get("/admin/overview").status_code == 403 # no token | |
| assert client.get("/admin/overview", headers={"Authorization": "Bearer nope"}).status_code == 403 | |
| d = client.get("/admin/overview", headers={"Authorization": "Bearer admtok"}).json() # dev admin | |
| assert d["totals"]["tenants"] == 2 and d["totals"]["total"] == 3 and d["totals"]["errors"] == 1 | |
| assert d["tenants"][0]["tenant"] == "acme" # most active first, cross-tenant | |
| def test_admin_provision_tenant_end_to_end(client, monkeypatch, mock_run): | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", None) | |
| monkeypatch.setattr(api, "ADMIN_DEV_TOKEN", "admtok") | |
| monkeypatch.setattr(api, "CLERK", None) | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "AUDIT", AuditLog(db)) | |
| monkeypatch.setattr(api, "KEYS", ApiKeyStore(db)) | |
| monkeypatch.setattr(api, "TENANT_STORE", TenantStore(db)) | |
| monkeypatch.setattr(api, "TENANTS", {}) | |
| monkeypatch.setattr(api, "WIDGET_KEYS", {}) | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {}) | |
| hdr = {"Authorization": "Bearer admtok"} | |
| r = client.post("/admin/tenants", headers=hdr, json={ | |
| "tenant_id": "acme-lawn", "company": "Acme Lawn", "owner_email": "o@acme.com", | |
| "origin": "https://acme.com"}) | |
| assert r.status_code == 200 | |
| body = r.json() | |
| key = body["publishable_key"] | |
| assert body["tenant_id"] == "acme-lawn" and body["invited"] == "o@acme.com" and key | |
| # Registered live — no restart: in the registry, key map, DB key store, and config store. | |
| assert "acme-lawn" in api.TENANTS and api.WIDGET_KEYS[key] == "acme-lawn" | |
| assert api.KEYS.key_for_tenant("acme-lawn") == key | |
| assert api.TENANT_STORE.get("acme-lawn")["company"] == "Acme Lawn" | |
| assert any(m["email"] == "o@acme.com" and m["role"] == "owner" for m in api.USERS.by_tenant("acme-lawn")) | |
| # Appears in the admin clients list. | |
| lst = client.get("/admin/tenants", headers=hdr).json()["tenants"] | |
| assert any(t["tenant_id"] == "acme-lawn" and t["has_key"] and t["members"] >= 1 for t in lst) | |
| # The minted key authenticates a widget /quote — domain-locked to the tenant's origin. | |
| ok = client.post("/quote", json={"address": "x"}, headers={"X-API-Key": key, "Origin": "https://acme.com"}) | |
| assert ok.status_code == 200 | |
| bad = client.post("/quote", json={"address": "x"}, headers={"X-API-Key": key, "Origin": "https://evil.com"}) | |
| assert bad.status_code == 403 | |
| # Duplicate id → 409; bad slug / missing fields → 422. | |
| assert client.post("/admin/tenants", headers=hdr, json={ | |
| "tenant_id": "acme-lawn", "company": "X", "owner_email": "a@b.com"}).status_code == 409 | |
| assert client.post("/admin/tenants", headers=hdr, json={ | |
| "tenant_id": "Bad Slug!", "company": "X", "owner_email": "a@b.com"}).status_code == 422 | |
| assert client.post("/admin/tenants", headers=hdr, json={ | |
| "tenant_id": "ok-slug", "company": "X", "owner_email": "not-an-email"}).status_code == 422 | |
| def test_admin_is_a_separate_realm_from_a_tenant_session(client, monkeypatch): | |
| # A valid TENANT (operator) session must NOT reach /admin — the realms are distinct. | |
| monkeypatch.setattr(api, "USERS", UserStore(connect(":memory:"))) | |
| api.USERS.create("acme", "clerk_owner", "o@acme.com", "owner", status="active") | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_owner") # valid tenant session | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", None) | |
| monkeypatch.setattr(api, "ADMIN_DEV_TOKEN", None) # no admin credential | |
| assert client.get("/admin/overview", headers={"Authorization": "Bearer x"}).status_code == 403 | |
| def test_quote_failure_records_a_rejected_run(client, monkeypatch): | |
| _register_tenant(monkeypatch, ACME, key="acmekey") | |
| monkeypatch.setattr(api, "METER", RunLedger(connect(":memory:"))) | |
| def boom(_address): | |
| raise ValueError("outside our service area") | |
| monkeypatch.setattr(api, "resolve", boom) | |
| r = client.post("/quote", json={"address": "x"}, headers={"X-API-Key": "acmekey"}) | |
| assert r.status_code == 422 | |
| s = api.METER.stats("acme") | |
| assert s["rejected"] == 1 and s["errors"] == 0 and s["ok"] == 0 # failure logged, not billable | |
| def test_dashboard_measurement_edit_batch_only(client, monkeypatch): | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "METER", RunLedger(db)) | |
| monkeypatch.setattr(api, "MEDITS", MeasurementEditStore(db)) | |
| monkeypatch.setattr(api, "AUDIT", AuditLog(db)) | |
| api.USERS.create("acme", "clerk_acme", "a@acme.com", "owner", status="active") | |
| api.METER.record("acme", "1 Batch St", api.BATCH, lawn_sqft=4000.0, result_id="rbatch", zip="68106") | |
| api.METER.record("acme", "2 Cust St", api.SINGLE, lawn_sqft=3000.0, result_id="rcust", zip="68106") | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_acme") | |
| hdr = {"Authorization": "Bearer x"} | |
| # A batch measurement can be corrected → stored (original vs edited) + audited + re-priced. | |
| r = client.post("/dashboard/measurement/edit", headers=hdr, | |
| json={"result_id": "rbatch", "edited_sqft": 4500}) | |
| assert r.status_code == 200 and r.json()["edited_sqft"] == 4500 | |
| assert api.MEDITS.recent("acme")[0]["original_sqft"] == 4000.0 | |
| assert any(e["action"] == "measurement.edit" for e in api.AUDIT.recent("acme")) | |
| # A customer/widget quote is LOCKED — never editable (it's what the homeowner agreed to). | |
| assert client.post("/dashboard/measurement/edit", headers=hdr, | |
| json={"result_id": "rcust", "edited_sqft": 3500}).status_code == 403 | |
| # Unknown result id → 404; non-positive area → 422. | |
| assert client.post("/dashboard/measurement/edit", headers=hdr, | |
| json={"result_id": "nope", "edited_sqft": 100}).status_code == 404 | |
| assert client.post("/dashboard/measurement/edit", headers=hdr, | |
| json={"result_id": "rbatch", "edited_sqft": 0}).status_code == 422 | |
| def test_dashboard_snippet_returns_the_tenants_widget_key(client, monkeypatch): | |
| monkeypatch.setattr(api, "USERS", UserStore(connect(":memory:"))) | |
| api.USERS.create("acme", "clerk_acme", "a@acme.com", "owner", status="active") | |
| api.USERS.create("beta", "clerk_beta", "b@beta.com", "owner", status="active") | |
| monkeypatch.setattr(api, "WIDGET_KEYS", {"pub-acme": "acme"}) # acme issued a key; beta not | |
| hdr = {"Authorization": "Bearer x"} | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_acme") | |
| body = client.get("/dashboard/snippet", headers=hdr).json() | |
| assert body["widget_key"] == "pub-acme" and body["host"] | |
| # Behind a TLS proxy the embed host must be https (else mixed-content-blocked on the tenant's | |
| # https page): X-Forwarded-Proto: https upgrades the http base_url TestClient reports. | |
| fwd = client.get("/dashboard/snippet", headers={**hdr, "X-Forwarded-Proto": "https"}).json() | |
| assert fwd["host"].startswith("https://") | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_beta") # scoped: not acme's key | |
| assert client.get("/dashboard/snippet", headers=hdr).json()["widget_key"] is None | |
| def test_dashboard_usage_and_leads_are_session_scoped(client, monkeypatch): | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "METER", RunLedger(db)) | |
| monkeypatch.setattr(api, "LEADS", LeadStore(db)) | |
| api.USERS.create("acme", "clerk_owner", "o@acme.com", "owner", status="active") | |
| api.LEADS.record("acme", "widget", "1 A St", email="a@x.com") | |
| api.LEADS.record("beta", "widget", "9 Z St", email="z@x.com") # other tenant | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_owner") | |
| hdr = {"Authorization": "Bearer x"} | |
| leads = client.get("/dashboard/leads", headers=hdr).json()["leads"] | |
| assert {lead["address"] for lead in leads} == {"1 A St"} # never beta's (tenant-scoped) | |
| assert client.get("/dashboard/usage", headers=hdr).json()["tenant"] == "acme" | |
| def test_widget_page_is_served(client): | |
| # The embeddable widget is a public, self-contained page (its /quote calls need a key). | |
| r = client.get("/widget") | |
| assert r.status_code == 200 | |
| assert 'id="address"' in r.text and "Get my price" in r.text | |
| def test_quote_missing_key_is_rejected(client): | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}) | |
| assert r.status_code == 422 # required X-API-Key header absent | |
| def test_quote_bad_key_is_unauthorized(client): | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, | |
| headers={"X-API-Key": "not-the-key"}) | |
| assert r.status_code == 401 | |
| def test_key_parsing_supports_labels_and_bare_keys(monkeypatch): | |
| monkeypatch.setenv("ALLOWED_API_KEYS", "website:G1Bvsecret, ownerbarekey ,, partner : p91dsecret ") | |
| keys = api._load_api_keys() | |
| assert keys["G1Bvsecret"] == "website" | |
| assert keys["ownerbarekey"] == "owne…" # bare key -> prefix label | |
| assert keys["p91dsecret"] == "partner" # whitespace around label/key is stripped | |
| def test_labeled_key_authenticates_with_bare_key_and_logs_label(client, mock_run, monkeypatch, caplog): | |
| # clients send ONLY the key — the label is server-side, for the logs | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {"G1Bvsecret": "website"}) | |
| with caplog.at_level("INFO"): | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, | |
| headers={"X-API-Key": "G1Bvsecret"}) | |
| assert r.status_code == 200 | |
| assert any("client=website" in rec.message for rec in caplog.records) | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, | |
| headers={"X-API-Key": "website:G1Bvsecret"}) | |
| assert r.status_code == 401 # the label is not part of the credential | |
| # --------------------------------------------------------------------------- | |
| # /quote contract | |
| # --------------------------------------------------------------------------- | |
| def test_quote_success_returns_full_contract(client, mock_run): | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, headers=KEY) | |
| assert r.status_code == 200 | |
| body = r.json() | |
| assert body["address"] == "5534 Mayberry St, Omaha, NE 68106" | |
| assert body["formatted_address"] == "5534 Mayberry St, Omaha, NE 68106" | |
| assert body["lawn_sqft"] == 4669.8 | |
| assert body["parcel_sqft"] == 6504.6 | |
| assert body["estimation_area_sqft"] == 7104.8 | |
| assert body["ground_sampled_sqft"] == 5346.3 | |
| assert body["row_buffer_ft"] == 12.0 | |
| assert body["pct_of_estimation"] == pytest.approx(65.7) | |
| assert body["imagery_source"] == "naip" | |
| assert body["region"] == "Douglas County, NE" | |
| assert body["method"] == "lidar+rgb" | |
| assert body["confidence"] == "high" | |
| assert body["extension_total_sqft"] == 600.2 | |
| assert body["extensions"][0]["street"] == "Mayberry St" | |
| # The viz is internal-only, addressed via result_id (/viz/{id}.png); the | |
| # explicit visualization_url field was dropped from the web-dev contract. | |
| assert "visualization_url" not in body | |
| assert body["result_id"] | |
| assert body["email_sent"] is None # no email requested | |
| assert body["warning"] is None | |
| assert body["status"] == "ok" | |
| # Pricing is now computed from lawn_sqft (4669.8 → mowing hits its $45 minimum). | |
| pricing = body["pricing"] | |
| assert pricing is not None | |
| assert pricing["currency"] == "USD" | |
| assert pricing["presentation"] == "estimate" | |
| assert len(pricing["line_items"]) == 4 | |
| mowing = next(i for i in pricing["line_items"] if i["service"] == "mowing") | |
| assert mowing["price"] == 45.0 and mowing["min_applied"] is True | |
| # Default tenant has no service area → serves everywhere, ZIP echoed. | |
| assert body["zip_code"] == "68106" | |
| def _register_tenant(monkeypatch, tenant, key="acmekey"): | |
| """Point auth + registry at a single test tenant reachable by `key`.""" | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {key: tenant.id}) | |
| monkeypatch.setattr(api, "TENANTS", {tenant.id: tenant}) | |
| # A tenant that only serves ZIP 68022, with a doubled ($/sqft × travel factor) package there. | |
| ACME = Tenant( | |
| id="acme", | |
| pricing=PricingConfig("Acme", "USD", "firm", (Service("mowing", "Mowing", 5, 45),)), | |
| areas=( | |
| ServiceArea( | |
| name="Outer", | |
| zips=frozenset({"68022"}), | |
| pricing=PricingConfig("Acme", "USD", "firm", (Service("mowing", "Mowing", 5, 0),)), | |
| travel_time_factor=2.0, | |
| ), | |
| ), | |
| ) | |
| def test_quote_in_area_uses_per_area_pricing(client, monkeypatch, mock_run): | |
| _register_tenant(monkeypatch, ACME) | |
| _patch_resolve(monkeypatch, "68022") # inside ACME's service area | |
| r = client.post("/quote", json={"address": "x, Elkhorn, NE 68022"}, headers={"X-API-Key": "acmekey"}) | |
| assert r.status_code == 200 | |
| mowing = next(i for i in r.json()["pricing"]["line_items"] if i["service"] == "mowing") | |
| # 4669.8 × $5/1k × 2.0 travel factor, min $0 → factor is visible. | |
| assert mowing["price"] == pytest.approx(round(4669.8 * 5 / 1000 * 2.0, 2)) | |
| def test_usage_counts_billable_quotes_and_dedups_customer_repeats(client, monkeypatch, mock_run): | |
| monkeypatch.setattr(api, "METER", RunLedger(connect(":memory:"))) # fresh, deterministic | |
| addr = {"address": "5534 Mayberry St, Omaha, NE 68106"} | |
| assert client.post("/quote", json=addr, headers=KEY).status_code == 200 | |
| assert client.post("/quote", json=addr, headers=KEY).status_code == 200 # same address | |
| body = client.get("/usage", headers=KEY).json() | |
| assert body["billable_quotes"] == 1 # the customer duplicate isn't charged | |
| def test_leads_endpoint_is_tenant_scoped_and_secret_only(client, monkeypatch): | |
| monkeypatch.setattr(api, "LEADS", LeadStore(connect(":memory:"))) | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {"acme-secret": "acme", "beta-secret": "beta"}) | |
| api.LEADS.record("acme", "widget", "1 A St", email="a@x.com") | |
| api.LEADS.record("acme", "quote", "2 B St", email="b@x.com", lawn_sqft=4200.0, price_total=90.0) | |
| api.LEADS.record("beta", "widget", "9 Z St", email="z@x.com") # another tenant | |
| body = client.get("/leads", headers={"X-API-Key": "acme-secret"}).json() | |
| assert body["tenant"] == "acme" and body["count"] == 2 | |
| assert body["leads"][0]["address"] == "2 B St" # newest first | |
| assert body["leads"][0]["lawn_sqft"] == 4200.0 | |
| assert {lead["address"] for lead in body["leads"]} == {"1 A St", "2 B St"} # never beta's | |
| # Business data: a publishable widget key (not in ALLOWED_API_KEYS) can't read it. | |
| assert client.get("/leads", headers={"X-API-Key": "pub-k"}).status_code == 401 | |
| assert client.get("/leads").status_code == 422 # required key header absent | |
| def test_quote_out_of_area_is_rejected_without_measuring(client, monkeypatch): | |
| _register_tenant(monkeypatch, ACME) | |
| _patch_resolve(monkeypatch, "68106") # in Coverage, outside ACME's service area | |
| def _run_must_not_run(*a, **k): | |
| raise AssertionError("pipeline must not run for an out-of-area address") | |
| monkeypatch.setattr(api, "run", _run_must_not_run) | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, headers={"X-API-Key": "acmekey"}) | |
| assert r.status_code == 422 | |
| assert "don't currently serve" in r.json()["detail"] | |
| _CENTRAL_POLY = { | |
| "type": "Polygon", | |
| "coordinates": [[[-96.02, 41.22], [-95.95, 41.22], [-95.95, 41.28], [-96.02, 41.28], [-96.02, 41.22]]], | |
| } | |
| def test_service_area_zips_endpoint(client, monkeypatch): | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| api.USERS.create("acme", "clerk_owner", "o@acme.com", "owner", status="active") | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_owner") | |
| r = client.post("/dashboard/service-area/zips", headers={"Authorization": "Bearer x"}, | |
| json={"polygon": _CENTRAL_POLY}) | |
| assert r.status_code == 200 | |
| zips = r.json()["zips"] | |
| assert "68106" in zips and "68022" not in zips # Elkhorn is outside the drawn box | |
| def test_quote_polygon_gate_rejects_outside(client, monkeypatch): | |
| t = Tenant(id="acme", pricing=PricingConfig("Acme", "USD", "firm", (Service("mowing", "Mowing", 5, 45),)), | |
| service_area_polygon=_CENTRAL_POLY) | |
| _register_tenant(monkeypatch, t) | |
| g = _FakeGeocode("68106") | |
| g.latitude, g.longitude = 41.00, -96.50 # inside Coverage, outside the drawn polygon | |
| monkeypatch.setattr(api, "resolve", lambda a: (object(), g)) | |
| def _must_not_run(*a, **k): | |
| raise AssertionError("pipeline must not run for an out-of-polygon address") | |
| monkeypatch.setattr(api, "run", _must_not_run) | |
| r = client.post("/quote", json={"address": "x"}, headers={"X-API-Key": "acmekey"}) | |
| assert r.status_code == 422 and "don't currently serve" in r.json()["detail"] | |
| def test_quote_polygon_gate_allows_inside(client, monkeypatch, mock_run): | |
| # mock_run patches resolve to the default in-box point (41.26, -96.0) + a canned result. | |
| t = Tenant(id="acme", pricing=PricingConfig("Acme", "USD", "firm", (Service("mowing", "Mowing", 5, 45),)), | |
| service_area_polygon=_CENTRAL_POLY) | |
| _register_tenant(monkeypatch, t) | |
| r = client.post("/quote", json={"address": "x"}, headers={"X-API-Key": "acmekey"}) | |
| assert r.status_code == 200 | |
| def test_geocode_failure_is_sanitized_for_the_homeowner(client, monkeypatch): | |
| # The raw geocoder error names county internals — must never reach the homeowner. | |
| def _raise(address): | |
| raise ValueError("Address not found in Sarpy County address points: 'asdf'") | |
| monkeypatch.setattr(api, "resolve", _raise) | |
| r = client.post("/quote", json={"address": "asdf nonsense"}, headers=KEY) | |
| assert r.status_code == 422 | |
| detail = r.json()["detail"] | |
| assert "County" not in detail and "address points" not in detail | |
| assert "double-check" in detail.lower() | |
| # --- Publishable widget key + domain-lock (W2) ------------------------------ | |
| WIDGET_TENANT = Tenant( | |
| id="wco", | |
| pricing=PricingConfig("WCo", "USD", "firm", (Service("mowing", "Mowing", 5, 45),)), | |
| allowed_origins=frozenset({"https://wco.com"}), | |
| ) | |
| def _register_widget(monkeypatch): | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {"secret-k": "wco"}) | |
| monkeypatch.setattr(api, "WIDGET_KEYS", {"pub-k": "wco"}) | |
| monkeypatch.setattr(api, "TENANTS", {"wco": WIDGET_TENANT}) | |
| def test_publishable_key_quotes_from_allowed_origin(client, monkeypatch, mock_run): | |
| _register_widget(monkeypatch) | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, | |
| headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}) | |
| assert r.status_code == 200 | |
| def test_publishable_key_rejected_from_other_origin(client, monkeypatch, mock_run): | |
| _register_widget(monkeypatch) | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, | |
| headers={"X-API-Key": "pub-k", "Origin": "https://evil.com"}) | |
| assert r.status_code == 403 | |
| def test_publishable_key_rejected_without_origin(client, monkeypatch, mock_run): | |
| _register_widget(monkeypatch) | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, | |
| headers={"X-API-Key": "pub-k"}) | |
| assert r.status_code == 403 | |
| def test_publishable_key_denied_on_batch(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| r = client.post("/quote/batch", json={"addresses": ["a"]}, | |
| headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}) | |
| assert r.status_code == 401 # batch is secret-key only (require_api_key) | |
| def test_publishable_key_denied_on_usage(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| r = client.get("/usage", headers={"X-API-Key": "pub-k"}) | |
| assert r.status_code == 401 | |
| def test_secret_key_quotes_without_origin(client, monkeypatch, mock_run): | |
| _register_widget(monkeypatch) | |
| r = client.post("/quote", json={"address": "5534 Mayberry St, Omaha, NE 68106"}, | |
| headers={"X-API-Key": "secret-k"}) # secret key: no origin check | |
| assert r.status_code == 200 | |
| # --- W2b CAPTCHA + W3b config ----------------------------------------------- | |
| def test_widget_config_exposes_public_config(client, monkeypatch): | |
| monkeypatch.setattr(api, "TURNSTILE_SITE_KEY", "site-abc") | |
| monkeypatch.setattr(api, "GOOGLE_PLACES_SERVER_KEY", "server-key") # server-side; NOT exposed | |
| body = client.get("/widget/config").json() # no key → no branding | |
| # The Google key is NOT in the response — only whether autocomplete is available. | |
| assert body == {"turnstile_site_key": "site-abc", "autocomplete": True, "branding": None, | |
| "questions": []} | |
| def test_autocomplete_proxies_google_and_is_domain_locked(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "GOOGLE_PLACES_SERVER_KEY", "server-key") | |
| class FakeResp: | |
| def json(self): | |
| return {"status": "OK", "predictions": [ | |
| {"description": "5534 Mayberry St, Omaha, NE, USA", "place_id": "p1"}, | |
| {"description": "5534 Maple Ave, Omaha, NE, USA", "place_id": "p2"}, | |
| ]} | |
| import requests | |
| monkeypatch.setattr(requests, "get", lambda *a, **k: FakeResp()) | |
| ok = client.get("/autocomplete?q=5534 May", | |
| headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}) | |
| assert ok.status_code == 200 | |
| preds = ok.json()["predictions"] | |
| assert preds[0] == {"description": "5534 Mayberry St, Omaha, NE, USA", "place_id": "p1"} | |
| # Domain-locked (no CAPTCHA, but origin still enforced), and missing key is rejected. | |
| assert client.get("/autocomplete?q=5534 May", | |
| headers={"X-API-Key": "pub-k", "Origin": "https://evil.com"}).status_code == 403 | |
| assert client.get("/autocomplete?q=5534 May").status_code == 422 # required key header absent | |
| def test_autocomplete_degrades_to_empty(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| # Authorized, but no server key configured and a too-short query → graceful []. | |
| monkeypatch.setattr(api, "GOOGLE_PLACES_SERVER_KEY", None) | |
| r = client.get("/autocomplete?q=5534 May", | |
| headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}) | |
| assert r.status_code == 200 and r.json() == {"predictions": []} | |
| def test_autocomplete_logs_google_denial_and_still_degrades(client, monkeypatch, caplog): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "GOOGLE_PLACES_SERVER_KEY", "server-key") | |
| class DeniedResp: | |
| def json(self): | |
| return {"status": "REQUEST_DENIED", "error_message": "The provided API key is invalid."} | |
| import requests | |
| monkeypatch.setattr(requests, "get", lambda *a, **k: DeniedResp()) | |
| with caplog.at_level("WARNING"): | |
| r = client.get("/autocomplete?q=5534 May", | |
| headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}) | |
| # Still fails safe for the user (empty list), but the WHY is now in the logs — and the | |
| # message carries Google's reason, never our key. | |
| assert r.status_code == 200 and r.json() == {"predictions": []} | |
| assert "REQUEST_DENIED" in caplog.text and "provided API key is invalid" in caplog.text | |
| assert "server-key" not in caplog.text | |
| def test_widget_config_returns_tenant_branding_for_a_valid_widget_key(client, monkeypatch): | |
| branded = Tenant(id="wco", pricing=None, allowed_origins=frozenset({"https://wco.com"}), | |
| branding={"name": "WCo Lawns", "accent": "#123456", "cta_url": "https://wco.com"}) | |
| monkeypatch.setattr(api, "WIDGET_KEYS", {"pub-k": "wco"}) | |
| monkeypatch.setattr(api, "TENANTS", {"wco": branded}) | |
| ok = client.get("/widget/config", headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}) | |
| assert ok.json()["branding"]["name"] == "WCo Lawns" | |
| bad_origin = client.get("/widget/config", headers={"X-API-Key": "pub-k", "Origin": "https://evil.com"}) | |
| assert bad_origin.json()["branding"] is None # domain-locked | |
| assert client.get("/widget/config").json()["branding"] is None # no key | |
| def test_captcha_enforced_for_widget_key_when_secret_set(client, monkeypatch, mock_run): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "TURNSTILE_SECRET_KEY", "sekret") | |
| monkeypatch.setattr(api, "verify_turnstile", lambda token, ip=None: token == "good") | |
| hdr = {"X-API-Key": "pub-k", "Origin": "https://wco.com"} | |
| assert client.post("/quote", json={"address": ADDRESS}, headers=hdr).status_code == 403 # no token | |
| assert client.post("/quote", json={"address": ADDRESS}, | |
| headers={**hdr, "CF-Turnstile-Response": "nope"}).status_code == 403 # bad token | |
| r = client.post("/quote", json={"address": ADDRESS}, | |
| headers={**hdr, "CF-Turnstile-Response": "good"}) # valid token | |
| assert r.status_code == 200 | |
| def test_captcha_skipped_for_secret_key(client, monkeypatch, mock_run): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "TURNSTILE_SECRET_KEY", "sekret") | |
| r = client.post("/quote", json={"address": ADDRESS}, headers={"X-API-Key": "secret-k"}) # operator skips CAPTCHA | |
| assert r.status_code == 200 | |
| # --- Lead capture (W3) ------------------------------------------------------ | |
| def test_lead_captured_with_contact(client): | |
| r = client.post("/lead", headers=KEY, json={ | |
| "address": "5534 Mayberry St, Omaha, NE 68106", | |
| "name": "Sam", "email": "sam@example.com", "phone": "402-555-1212", | |
| }) | |
| assert r.status_code == 200 | |
| assert r.json()["captured"] is True # notified depends on an ESP being configured | |
| def test_lead_requires_email_or_phone(client): | |
| r = client.post("/lead", headers=KEY, json={"address": "5534 Mayberry St"}) | |
| assert r.status_code == 422 | |
| def test_lead_missing_key_is_rejected(client): | |
| r = client.post("/lead", json={"address": "x", "email": "a@b.com"}) | |
| assert r.status_code == 422 # required X-API-Key header absent | |
| def test_lead_publishable_key_is_domain_locked(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| ok = client.post("/lead", headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}, | |
| json={"address": "x", "email": "a@b.com"}) | |
| assert ok.status_code == 200 | |
| bad = client.post("/lead", headers={"X-API-Key": "pub-k", "Origin": "https://evil.com"}, | |
| json={"address": "x", "email": "a@b.com"}) | |
| assert bad.status_code == 403 | |
| def test_lead_endpoint_persists_a_durable_row(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "LEADS", LeadStore(connect(":memory:"))) | |
| r = client.post("/lead", headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}, | |
| json={"address": "5534 Mayberry St", "name": "Sam", | |
| "email": "sam@example.com", "phone": "402-555-1212"}) | |
| assert r.status_code == 200 | |
| leads = api.LEADS.recent("wco") | |
| assert len(leads) == 1 | |
| assert leads[0]["source"] == "widget" | |
| assert leads[0]["email"] == "sam@example.com" | |
| assert leads[0]["name"] == "Sam" | |
| assert leads[0]["lawn_sqft"] is None # captured before measurement runs | |
| def test_quote_with_email_persists_a_lead_with_the_measurement(client, mock_run, monkeypatch): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "LEADS", LeadStore(connect(":memory:"))) | |
| monkeypatch.setattr(api, "send_quote_emails", lambda *a, **k: True) | |
| r = client.post("/quote", headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}, | |
| json={"address": ADDRESS, "email": "cust@example.com", "name": "Sam"}) | |
| assert r.status_code == 200 | |
| leads = api.LEADS.recent("wco") | |
| assert len(leads) == 1 | |
| assert leads[0]["source"] == "quote" | |
| assert leads[0]["email"] == "cust@example.com" | |
| assert leads[0]["lawn_sqft"] == 4669.8 # measurement filled in | |
| def test_out_of_coverage_message_passes_through(client, monkeypatch): | |
| # The already-clean out-of-Coverage message is kept verbatim. | |
| def _raise(address): | |
| raise ValueError("'X' is outside our service area. We currently cover Douglas County and Sarpy County only.") | |
| monkeypatch.setattr(api, "resolve", _raise) | |
| r = client.post("/quote", json={"address": "1600 Pennsylvania Ave NW, Washington, DC 20500"}, headers=KEY) | |
| assert r.status_code == 422 | |
| assert "outside our service area" in r.json()["detail"] | |
| def test_quote_pipeline_error_maps_to_500_without_leaking_internals(client, monkeypatch): | |
| # A non-ValueError failure returns a generic message — internals (which can | |
| # embed request URLs carrying the Google key) must never reach the client. | |
| def _boom(address, imagery="auto", **kwargs): | |
| raise RuntimeError("parcel lookup failed at http://internal/secret?key=AIzaLEAK") | |
| monkeypatch.setattr(api, "run", _boom) | |
| r = client.post("/quote", json={"address": "bad"}, headers=KEY) | |
| assert r.status_code == 500 | |
| detail = r.json()["detail"] | |
| assert detail == api.GENERIC_PIPELINE_ERROR | |
| assert "secret" not in detail and "AIzaLEAK" not in detail and "parcel lookup" not in detail | |
| def test_quote_value_error_maps_to_422_with_message(client, monkeypatch): | |
| # Curated, address-facing failures keep their (safe) message and are the | |
| # caller's fault to fix → 422, not 500. | |
| def _reject(address, imagery="auto", **kwargs): | |
| raise ValueError("'X, Faketown' is outside our service area.") | |
| monkeypatch.setattr(api, "run", _reject) | |
| r = client.post("/quote", json={"address": "X, Faketown"}, headers=KEY) | |
| assert r.status_code == 422 | |
| assert "outside our service area" in r.json()["detail"] | |
| def test_quote_email_is_throttled_after_limit(client, mock_run, monkeypatch): | |
| monkeypatch.setattr(api, "send_quote_emails", lambda *a, **k: True) | |
| body = {"address": ADDRESS, "email": "spammy@example.com"} | |
| for _ in range(api._EMAIL_MAX_PER_HOUR): | |
| assert client.post("/quote", json=body, headers=KEY).json()["email_sent"] is True | |
| # Over the limit: still a full 200 quote, but no send. | |
| r = client.post("/quote", json=body, headers=KEY) | |
| assert r.status_code == 200 | |
| assert r.json()["email_sent"] is False | |
| assert r.json()["lawn_sqft"] == 4669.8 | |
| # --------------------------------------------------------------------------- | |
| # Customer email / lead capture on /quote | |
| # --------------------------------------------------------------------------- | |
| ADDRESS = "5534 Mayberry St, Omaha, NE 68106" | |
| def test_quote_with_email_sends_and_still_returns_full_quote(client, mock_run, monkeypatch): | |
| calls = {} | |
| def fake_send(payload, email, name=None, company=None, **kwargs): | |
| calls.update(payload=payload, email=email, name=name, company=company, **kwargs) | |
| return True | |
| monkeypatch.setattr(api, "send_quote_emails", fake_send) | |
| r = client.post("/quote", json={"address": ADDRESS, "email": "cust@example.com", "name": "Sam"}, | |
| headers=KEY) | |
| assert r.status_code == 200 | |
| body = r.json() | |
| # the website still gets everything it displays | |
| assert body["lawn_sqft"] == 4669.8 | |
| assert body["pricing"] is not None | |
| assert body["email_sent"] is True | |
| # and the emailer got the same quote payload + the customer's contact | |
| assert calls["email"] == "cust@example.com" | |
| assert calls["name"] == "Sam" | |
| assert calls["company"] == "Partner Lawn Care" | |
| assert calls["payload"]["lawn_sqft"] == 4669.8 | |
| def test_quote_email_failure_never_fails_the_quote(client, mock_run, monkeypatch): | |
| monkeypatch.setattr(api, "send_quote_emails", lambda *a, **k: False) | |
| r = client.post("/quote", json={"address": ADDRESS, "email": "cust@example.com"}, headers=KEY) | |
| assert r.status_code == 200 | |
| assert r.json()["email_sent"] is False | |
| assert r.json()["lawn_sqft"] == 4669.8 | |
| def test_quote_email_crash_never_fails_the_quote(client, mock_run, monkeypatch): | |
| def _boom(*a, **k): | |
| raise RuntimeError("renderer bug") | |
| monkeypatch.setattr(api, "send_quote_emails", _boom) | |
| r = client.post("/quote", json={"address": ADDRESS, "email": "cust@example.com"}, headers=KEY) | |
| assert r.status_code == 200 | |
| assert r.json()["email_sent"] is False | |
| assert r.json()["lawn_sqft"] == 4669.8 | |
| def test_quote_without_email_never_touches_the_emailer(client, mock_run, monkeypatch): | |
| def _fail(*a, **k): | |
| raise AssertionError("emailer must not be called without an email") | |
| monkeypatch.setattr(api, "send_quote_emails", _fail) | |
| r = client.post("/quote", json={"address": ADDRESS}, headers=KEY) | |
| assert r.status_code == 200 | |
| assert r.json()["email_sent"] is None | |
| def test_quote_invalid_email_is_rejected_before_the_pipeline(client, monkeypatch): | |
| def _boom(address, imagery="auto", **kwargs): | |
| raise AssertionError("pipeline must not run for an invalid email") | |
| monkeypatch.setattr(api, "run", _boom) | |
| r = client.post("/quote", json={"address": ADDRESS, "email": "not-an-email"}, headers=KEY) | |
| assert r.status_code == 422 | |
| # --------------------------------------------------------------------------- | |
| # /quote/batch job flow | |
| # --------------------------------------------------------------------------- | |
| def test_batch_rejects_empty_address_list(client): | |
| r = client.post("/quote/batch", json={"addresses": []}, headers=KEY) | |
| assert r.status_code == 422 | |
| def test_batch_submits_job_and_completes(client, mock_run): | |
| r = client.post("/quote/batch", | |
| json={"addresses": ["5534 Mayberry St, Omaha, NE 68106", | |
| "5819 Hickory St, Omaha, NE 68106"]}, | |
| headers=KEY) | |
| assert r.status_code == 200 | |
| job_id = r.json()["job_id"] | |
| assert r.json()["total"] == 2 | |
| status = {} | |
| deadline = time.time() + 5 | |
| while time.time() < deadline: | |
| status = client.get(f"/jobs/{job_id}", headers=KEY).json() | |
| if status["status"] == "done": | |
| break | |
| time.sleep(0.05) | |
| assert status["status"] == "done" | |
| assert status["completed"] == 2 | |
| assert all(res["status"] == "ok" for res in status["results"]) | |
| # batch results follow the same contract: viz via result_id only | |
| assert all(res["result_id"] and "visualization_url" not in res for res in status["results"]) | |
| # --------------------------------------------------------------------------- | |
| # Backpressure (M-1): a single quote can't wait on the lock forever | |
| # --------------------------------------------------------------------------- | |
| def test_quote_returns_503_when_pipeline_is_busy(client, mock_run, monkeypatch): | |
| monkeypatch.setattr(api, "QUOTE_LOCK_TIMEOUT_S", 0.05) | |
| api._PIPELINE_LOCK.acquire() # simulate a batch holding the pipeline | |
| try: | |
| r = client.post("/quote", json={"address": ADDRESS}, headers=KEY) | |
| finally: | |
| api._PIPELINE_LOCK.release() | |
| assert r.status_code == 503 | |
| assert r.headers.get("Retry-After") == "60" | |
| # --------------------------------------------------------------------------- | |
| # Per-client ownership (M-3): jobs and visualizations aren't cross-readable | |
| # --------------------------------------------------------------------------- | |
| def test_job_not_readable_by_another_client(client, mock_run, monkeypatch): | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {"akey": "alpha", "bkey": "beta"}) | |
| job_id = client.post("/quote/batch", json={"addresses": ["5534 Mayberry St, Omaha, NE 68106"]}, | |
| headers={"X-API-Key": "akey"}).json()["job_id"] | |
| # beta cannot see alpha's job (404, not 403 — existence must not leak) | |
| assert client.get(f"/jobs/{job_id}", headers={"X-API-Key": "bkey"}).status_code == 404 | |
| # alpha can | |
| assert client.get(f"/jobs/{job_id}", headers={"X-API-Key": "akey"}).status_code == 200 | |
| def test_viz_requires_a_key(client): | |
| assert client.get("/viz/anything.png").status_code == 422 # X-API-Key header absent | |
| def test_viz_unknown_id_is_404(client): | |
| assert client.get("/viz/nope123.png", headers=KEY).status_code == 404 | |
| def test_viz_served_to_owner_only(client, tmp_path, monkeypatch): | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {"akey": "alpha", "bkey": "beta"}) | |
| png = tmp_path / "v.png" | |
| png.write_bytes(b"\x89PNG\r\n\x1a\n") | |
| overlay = tmp_path / "o.png" | |
| overlay.write_bytes(b"\x89PNG\r\n\x1a\n") | |
| monkeypatch.setitem(api.VIZ_PATHS, "rid123", (png, overlay, "alpha")) | |
| assert client.get("/viz/rid123.png", headers={"X-API-Key": "akey"}).status_code == 200 | |
| assert client.get("/viz/rid123.png", headers={"X-API-Key": "bkey"}).status_code == 404 | |
| def test_overlay_served_to_owner_only(client, tmp_path, monkeypatch): | |
| # The homeowner overlay is widget-callable (secret OR domain-locked publishable key), | |
| # but still scoped to the owning client. | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {"akey": "alpha", "bkey": "beta"}) | |
| png, overlay = tmp_path / "v.png", tmp_path / "o.png" | |
| png.write_bytes(b"\x89PNG\r\n\x1a\n") | |
| overlay.write_bytes(b"\x89PNG\r\n\x1a\n") | |
| monkeypatch.setitem(api.VIZ_PATHS, "rid123", (png, overlay, "alpha")) | |
| assert client.get("/overlay/rid123.png", headers={"X-API-Key": "akey"}).status_code == 200 | |
| assert client.get("/overlay/rid123.png", headers={"X-API-Key": "bkey"}).status_code == 404 | |
| def test_overlay_404_when_absent(client, tmp_path, monkeypatch): | |
| monkeypatch.setattr(api, "ALLOWED_API_KEYS", {"akey": "alpha"}) | |
| png = tmp_path / "v.png" | |
| png.write_bytes(b"\x89PNG\r\n\x1a\n") | |
| monkeypatch.setitem(api.VIZ_PATHS, "rid456", (png, None, "alpha")) # viz but no overlay | |
| assert client.get("/overlay/rid456.png", headers={"X-API-Key": "akey"}).status_code == 404 | |
| # --------------------------------------------------------------------------- | |
| # Job/viz lifecycle (M-9 + M-4): evict only finished jobs; clean up their viz | |
| # --------------------------------------------------------------------------- | |
| def test_evict_only_removes_done_jobs(monkeypatch): | |
| from lawn_estimator.db import JobStore | |
| store = JobStore(connect(":memory:")) | |
| monkeypatch.setattr(api, "JOBS_STORE", store) | |
| monkeypatch.setattr(api, "MAX_JOBS_KEPT", 1) | |
| # Explicit created_at so "oldest done first" is deterministic (no timestamp ties). | |
| for jid, status, ts in [("d1", "done", "2020-01-01"), ("q1", "queued", "2020-01-02"), | |
| ("d2", "done", "2020-01-03")]: | |
| store._db.execute( | |
| "INSERT INTO jobs (id, client, imagery, addresses, status, total, completed, created_at) " | |
| "VALUES (?, 'c', 'auto', '[]', ?, 0, 0, ?)", (jid, status, ts)) | |
| store._db.commit() | |
| api._evict_old_jobs() | |
| ids = {r["id"] for r in store._db.execute("SELECT id FROM jobs").fetchall()} | |
| assert "q1" in ids # a queued job is never evicted (would 404 the poller) | |
| assert "d1" not in ids # finished jobs go, oldest first | |
| def test_evict_removes_viz_entry_and_unlinks_png(monkeypatch, tmp_path): | |
| from lawn_estimator.db import JobStore | |
| store = JobStore(connect(":memory:")) | |
| monkeypatch.setattr(api, "JOBS_STORE", store) | |
| monkeypatch.setattr(api, "OUTPUT_DIR", tmp_path) | |
| monkeypatch.setattr(api, "MAX_JOBS_KEPT", 0) | |
| api.VIZ_PATHS.clear() | |
| try: | |
| png = tmp_path / "e.png" | |
| png.write_bytes(b"x") | |
| api.VIZ_PATHS["r1"] = (png, None, "c") | |
| store.create("j1", client="c", imagery="auto", addresses=["a"]) | |
| store.append_result("j1", 1, {"result_id": "r1", "status": "ok"}) | |
| store.mark_done("j1") | |
| api._evict_old_jobs() | |
| assert "r1" not in api.VIZ_PATHS | |
| assert not png.exists() # PNG unlinked, not orphaned on the ephemeral disk | |
| finally: | |
| api.VIZ_PATHS.clear() | |
| # --------------------------------------------------------------------------- | |
| # Clerk auth: /auth/config + DB-backed team invitations (Option B) | |
| # --------------------------------------------------------------------------- | |
| def test_pending_invite_activates_on_first_signin(client, monkeypatch): | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "AUDIT", AuditLog(db)) | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", "sk_test") | |
| api.USERS.invite("thelawnstandard", "op@x.com", "owner") # a pending invite exists | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_op1") | |
| monkeypatch.setattr(api, "verify_clerk_email", lambda t: "op@x.com") | |
| body = client.get("/dashboard/me", headers={"Authorization": "Bearer x"}).json() | |
| assert body["tenant_id"] == "thelawnstandard" and body["role"] == "owner" and body["email"] == "op@x.com" | |
| assert api.USERS.by_clerk_id("clerk_op1")["status"] == "active" # bound on first sign-in | |
| def test_signin_without_an_invite_is_denied(client, monkeypatch): | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", "sk_test") | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_rando") | |
| monkeypatch.setattr(api, "verify_clerk_email", lambda t: "stranger@nope.com") | |
| r = client.get("/dashboard/me", headers={"Authorization": "Bearer x"}) | |
| assert r.status_code == 403 # invite-only | |
| assert api.USERS.by_clerk_id("clerk_rando") is None | |
| def test_auth_config_exposes_publishable_key_only(client, monkeypatch): | |
| monkeypatch.setattr(api, "CLERK_PUBLISHABLE_KEY", | |
| "pk_test_c3VubnktZG9ua2V5LTU1LmNsZXJrLmFjY291bnRzLmRldiQ") | |
| body = client.get("/auth/config").json() | |
| assert body["clerk_publishable_key"].startswith("pk_test_") | |
| assert body["clerk_frontend_api"] == "sunny-donkey-55.clerk.accounts.dev" | |
| assert "secret" not in str(body).lower() # no secret ever leaks here | |
| monkeypatch.setattr(api, "CLERK_PUBLISHABLE_KEY", None) | |
| assert client.get("/auth/config").json()["clerk_publishable_key"] is None # -> dev-token mode | |
| def _owner_session(monkeypatch, db, tenant="thelawnstandard", email="owner@x.com", clerk="clerk_owner"): | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "AUDIT", AuditLog(db)) | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", "sk_test") | |
| monkeypatch.setattr(api, "CLERK", None) # skip the best-effort Clerk invite call in tests | |
| uid = api.USERS.create(tenant, clerk, email, "owner", status="active") | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: clerk) | |
| monkeypatch.setattr(api, "verify_clerk_email", lambda t: email) | |
| return uid | |
| def test_owner_invites_teammate_dedupes_and_caps(client, monkeypatch): | |
| db = connect(":memory:") | |
| _owner_session(monkeypatch, db) | |
| hdr = {"Authorization": "Bearer x"} | |
| assert client.post("/dashboard/members/invite", headers=hdr, | |
| json={"email": "staff@x.com", "role": "staff"}).json()["invited"] == "staff@x.com" | |
| members = client.get("/dashboard/members", headers=hdr).json()["members"] | |
| assert any(m["email"] == "staff@x.com" and m["status"] == "invited" for m in members) | |
| assert client.post("/dashboard/members/invite", headers=hdr, | |
| json={"email": "staff@x.com"}).status_code == 409 # dedupe | |
| for i in range(3): # owner + staff = 2 seats; fill to 5, then reject the 6th | |
| assert client.post("/dashboard/members/invite", headers=hdr, | |
| json={"email": f"m{i}@x.com"}).status_code == 200 | |
| assert client.post("/dashboard/members/invite", headers=hdr, | |
| json={"email": "over@x.com"}).status_code == 422 # cap of 5 | |
| def test_staff_cannot_invite(client, monkeypatch): | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", "sk_test") | |
| api.USERS.create("acme", "clerk_staff", "staff@x.com", "staff", status="active") | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_staff") | |
| monkeypatch.setattr(api, "verify_clerk_email", lambda t: "staff@x.com") | |
| assert client.post("/dashboard/members/invite", headers={"Authorization": "Bearer x"}, | |
| json={"email": "x@y.com"}).status_code == 403 # require_owner | |
| def test_cannot_remove_or_demote_last_owner(client, monkeypatch): | |
| db = connect(":memory:") | |
| uid = _owner_session(monkeypatch, db) | |
| hdr = {"Authorization": "Bearer x"} | |
| assert client.post(f"/dashboard/members/{uid}/remove", headers=hdr).status_code == 422 # self | |
| assert client.post(f"/dashboard/members/{uid}/role", headers=hdr, | |
| json={"role": "staff"}).status_code == 422 # last owner | |
| def test_admin_invite_owner_bootstraps_a_tenant(client, monkeypatch): | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "AUDIT", AuditLog(db)) | |
| monkeypatch.setattr(api, "CLERK", None) | |
| monkeypatch.setattr(api, "CLERK_SECRET_KEY", None) # dev-token admin path | |
| monkeypatch.setattr(api, "ADMIN_DEV_TOKEN", "admintok") | |
| monkeypatch.setattr(api, "PLATFORM_ADMIN_EMAILS", {"me@x.com"}) | |
| r = client.post("/admin/invite-owner", headers={"Authorization": "Bearer admintok"}, | |
| json={"tenant_id": "newco", "email": "owner@newco.com"}) | |
| assert r.status_code == 200 and r.json()["tenant"] == "newco" | |
| assert api.USERS.pending_by_email("owner@newco.com")["role"] == "owner" | |
| # --------------------------------------------------------------------------- | |
| # W5 — in-widget booking (/book) | |
| # --------------------------------------------------------------------------- | |
| def test_book_records_booking_lead_and_notifies(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "LEADS", LeadStore(connect(":memory:"))) | |
| sent = {} | |
| monkeypatch.setattr(api, "notify_booking", lambda **kw: (sent.update(kw), True)[1]) | |
| wco = {"X-API-Key": "pub-k", "Origin": "https://wco.com"} | |
| ok = client.post("/book", headers=wco, json={ | |
| "address": "1 A St", "email": "a@x.com", "result_id": "r1", | |
| "services": ["Mowing", "Fertilizer"], "total": 105.0}) | |
| assert ok.status_code == 200 and ok.json()["booked"] is True | |
| row = api.LEADS.recent("wco")[0] | |
| assert row["source"] == "booking" and row["price_total"] == 105.0 and row["result_id"] == "r1" | |
| assert sent["services"] == ["Mowing", "Fertilizer"] and sent["total"] == 105.0 | |
| # Validation: no contact, or no service selected -> 422. | |
| assert client.post("/book", headers=wco, | |
| json={"address": "x", "services": ["Mowing"]}).status_code == 422 | |
| assert client.post("/book", headers=wco, | |
| json={"address": "x", "email": "a@x.com", "services": []}).status_code == 422 | |
| # Domain-locked like /quote and /lead. | |
| assert client.post("/book", headers={"X-API-Key": "pub-k", "Origin": "https://evil.com"}, | |
| json={"address": "x", "email": "a@x.com", "services": ["Mowing"]}).status_code == 403 | |
| def test_book_captures_bundle(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "LEADS", LeadStore(connect(":memory:"))) | |
| monkeypatch.setattr(api, "notify_booking", lambda **kw: True) | |
| wco = {"X-API-Key": "pub-k", "Origin": "https://wco.com"} | |
| r = client.post("/book", headers=wco, json={ | |
| "address": "1 A St", "email": "a@x.com", "result_id": "r1", "total": 120.0, | |
| "services": ["Mowing", "Aeration"], "bundle_id": "spring", "bundle_name": "Spring Package", | |
| "cadence": "seasonal"}) | |
| assert r.status_code == 200 | |
| import json as _json | |
| detail = _json.loads(api.LEADS.recent("wco")[0]["detail"]) | |
| assert detail["bundle"] == {"id": "spring", "name": "Spring Package", "cadence": "seasonal"} | |
| def test_widget_config_includes_qualifying_questions(client, monkeypatch): | |
| q = [{"label": "Locked gate?", "type": "boolean", "surcharge": 10}] | |
| t = Tenant(id="wco", pricing=None, allowed_origins=frozenset({"https://wco.com"}), questions=tuple(q)) | |
| monkeypatch.setattr(api, "WIDGET_KEYS", {"pub-k": "wco"}) | |
| monkeypatch.setattr(api, "TENANTS", {"wco": t}) | |
| body = client.get("/widget/config", headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}).json() | |
| assert body["questions"] == q | |
| def test_book_captures_answers(client, monkeypatch): | |
| _register_widget(monkeypatch) | |
| monkeypatch.setattr(api, "LEADS", LeadStore(connect(":memory:"))) | |
| sent = {} | |
| monkeypatch.setattr(api, "notify_booking", lambda **kw: (sent.update(kw), True)[1]) | |
| r = client.post("/book", headers={"X-API-Key": "pub-k", "Origin": "https://wco.com"}, json={ | |
| "address": "1 A St", "email": "a@x.com", "services": ["Mowing"], "total": 55.0, | |
| "answers": {"Locked gate?": "Yes", "Frequency": "One-time"}}) | |
| assert r.status_code == 200 | |
| assert sent["answers"] == {"Locked gate?": "Yes", "Frequency": "One-time"} | |
| def test_priority_lock_interactive_quote_jumps_the_batch_queue(): | |
| # A2: while an interactive (high) quote is waiting, a batch (low) address must not grab | |
| # the pipeline — the homeowner never waits behind more than the one in-flight quote. | |
| import threading | |
| lock = api.PriorityLock() | |
| order = [] | |
| assert lock.acquire(priority="low") # a batch address is mid-run, holding the pipeline | |
| def interactive(): | |
| assert lock.acquire(priority="high") | |
| order.append("interactive") | |
| lock.release() | |
| def next_batch_address(): | |
| assert lock.acquire(priority="low") | |
| order.append("batch") | |
| lock.release() | |
| hi = threading.Thread(target=interactive) | |
| lo = threading.Thread(target=next_batch_address) | |
| hi.start() | |
| while lock._waiting_hi == 0: # ensure the interactive quote is queued first | |
| time.sleep(0.001) | |
| lo.start() | |
| time.sleep(0.02) # let the next batch address reach its wait | |
| lock.release() # the in-flight batch address finishes | |
| hi.join(2) | |
| lo.join(2) | |
| assert order == ["interactive", "batch"] # interactive jumped ahead of the queued batch | |
| def test_priority_lock_times_out_to_503_signal(): | |
| # A single /quote caps its wait: if the pipeline stays busy, acquire returns False | |
| # (the caller raises PipelineBusy -> 503) rather than blocking forever. | |
| lock = api.PriorityLock() | |
| assert lock.acquire(priority="low") # something else holds it | |
| assert lock.acquire(timeout=0.05, priority="high") is False | |
| lock.release() | |
| assert lock.acquire(timeout=0.05, priority="high") is True # free now | |
| # --------------------------------------------------------------------------- | |
| # B3 — Cloud Tasks async batch (routing + the /tasks/quote handler) | |
| # --------------------------------------------------------------------------- | |
| class _FakeDispatcher: | |
| """Stand-in for tasks.TASKS. `enabled` toggles the batch routing; enqueues are recorded.""" | |
| def __init__(self, enabled): | |
| self.enabled = enabled | |
| self.enqueued = [] | |
| def enqueue_address(self, *, job_id, idx, address, imagery, client, submitted_by=""): | |
| self.enqueued.append({"job_id": job_id, "idx": idx, "address": address, | |
| "imagery": imagery, "client": client, "submitted_by": submitted_by}) | |
| def verify_request(self, token): | |
| return token == "tasktoken" | |
| def test_batch_enqueues_cloud_tasks_when_enabled(client, mock_run, monkeypatch): | |
| # With Cloud Tasks on, /quote/batch enqueues one task per address and does NOT touch the | |
| # in-process queue (which wouldn't survive on serverless). | |
| fake = _FakeDispatcher(enabled=True) | |
| monkeypatch.setattr(api, "TASKS", fake) | |
| puts = [] | |
| monkeypatch.setattr(api._JOB_QUEUE, "put", lambda x: puts.append(x)) | |
| r = client.post("/quote/batch", json={"addresses": ["1 A St", "2 B St", "3 C St"]}, headers=KEY) | |
| assert r.status_code == 200 | |
| jid = r.json()["job_id"] | |
| assert [e["idx"] for e in fake.enqueued] == [1, 2, 3] | |
| assert all(e["job_id"] == jid for e in fake.enqueued) | |
| assert puts == [] # the in-process queue is bypassed under Cloud Tasks | |
| def test_dispatch_job_falls_back_to_in_process_when_disabled(monkeypatch): | |
| fake = _FakeDispatcher(enabled=False) | |
| monkeypatch.setattr(api, "TASKS", fake) | |
| puts = [] | |
| monkeypatch.setattr(api._JOB_QUEUE, "put", lambda x: puts.append(x)) | |
| api._dispatch_job("jX", imagery="auto", client="c", addresses=["a", "b"]) | |
| assert puts == ["jX"] and fake.enqueued == [] # in-process path, no tasks | |
| def test_tasks_quote_rejects_missing_or_bad_token(client, monkeypatch): | |
| monkeypatch.setattr(api, "TASKS", _FakeDispatcher(enabled=True)) | |
| body = {"job_id": "x", "idx": 1, "address": "1 A St"} | |
| assert client.post("/tasks/quote", json=body).status_code == 403 # no header | |
| assert client.post("/tasks/quote", json=body, | |
| headers={"X-Tasks-Token": "wrong"}).status_code == 403 # bad token | |
| def test_tasks_quote_runs_addresses_and_marks_done_on_last(client, mock_run, monkeypatch): | |
| monkeypatch.setattr(api, "TASKS", _FakeDispatcher(enabled=True)) | |
| jid = "b3job" | |
| api.JOBS_STORE.create(jid, client="tenantX", imagery="auto", addresses=["1 A St", "2 B St"]) | |
| hdr = {"X-Tasks-Token": "tasktoken"} | |
| r1 = client.post("/tasks/quote", | |
| json={"job_id": jid, "idx": 1, "address": "1 A St", "client": "tenantX"}, headers=hdr) | |
| assert r1.status_code == 200 | |
| mid = api.JOBS_STORE.get(jid, "tenantX") | |
| assert mid["completed"] == 1 and mid["status"] != "done" # not done until the last address | |
| r2 = client.post("/tasks/quote", | |
| json={"job_id": jid, "idx": 2, "address": "2 B St", "client": "tenantX"}, headers=hdr) | |
| assert r2.status_code == 200 | |
| fin = api.JOBS_STORE.get(jid, "tenantX") | |
| assert fin["completed"] == 2 and fin["status"] == "done" | |
| assert [res["status"] for res in fin["results"]] == ["ok", "ok"] | |
| def test_tasks_quote_is_idempotent_on_retry(client, mock_run, monkeypatch): | |
| # Cloud Tasks may redeliver a task; re-running the same idx overwrites, never duplicates. | |
| monkeypatch.setattr(api, "TASKS", _FakeDispatcher(enabled=True)) | |
| jid = "b3retry" | |
| api.JOBS_STORE.create(jid, client="t", imagery="auto", addresses=["1 A St"]) | |
| hdr = {"X-Tasks-Token": "tasktoken"} | |
| body = {"job_id": jid, "idx": 1, "address": "1 A St", "client": "t"} | |
| assert client.post("/tasks/quote", json=body, headers=hdr).status_code == 200 | |
| assert client.post("/tasks/quote", json=body, headers=hdr).status_code == 200 # redelivery | |
| fin = api.JOBS_STORE.get(jid, "t") | |
| assert fin["completed"] == 1 and len(fin["results"]) == 1 and fin["status"] == "done" | |
| def test_record_batch_lead_files_and_dedups(monkeypatch): | |
| from lawn_estimator.db import LeadStore, connect | |
| monkeypatch.setattr(api, "LEADS", LeadStore(connect(":memory:"))) | |
| result = { | |
| "status": "ok", "result_id": "r1", "formatted_address": "1 A St", "zip_code": "68106", | |
| "lawn_sqft": 4200.0, "confidence": "high", | |
| "pricing": {"line_items": [{"service": "mow", "price": 48.0}, {"service": "fert", "price": 32.0}]}, | |
| "lawn_polygon_px": [[0, 0], [10, 0], [10, 10]], "pixel_area_sqft": 0.5, "image_size_px": [1280, 1280], | |
| } | |
| api._record_batch_lead("acme", "runner@acme.com", result) | |
| leads = api.LEADS.recent("acme") | |
| assert len(leads) == 1 | |
| lead = leads[0] | |
| assert lead["source"] == "batch" and lead["email"] == "runner@acme.com" and lead["result_id"] == "r1" | |
| assert lead["price_total"] == 80.0 # summed from the line items | |
| import json as _json | |
| detail = _json.loads(lead["detail"]) | |
| assert detail["pixel_area_sqft"] == 0.5 and detail["image_size_px"] == [1280, 1280] | |
| assert detail["pricing"]["line_items"] and detail["lawn_polygon_px"] # geometry for the popup | |
| api._record_batch_lead("acme", "runner@acme.com", result) # same result_id -> deduped | |
| assert len(api.LEADS.recent("acme")) == 1 | |
| api._record_batch_lead("acme", "x", {"status": "error", "address": "2 B St"}) # non-ok skipped | |
| assert len(api.LEADS.recent("acme")) == 1 | |
| def test_measurement_edit_persists_to_lead_and_keeps_history(client, mock_run, monkeypatch): | |
| import json as _json | |
| from lawn_estimator.db import AuditLog, LeadStore, MeasurementEditStore, RunLedger, UserStore, connect | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "METER", RunLedger(db)) | |
| monkeypatch.setattr(api, "MEDITS", MeasurementEditStore(db)) | |
| monkeypatch.setattr(api, "LEADS", LeadStore(db)) | |
| monkeypatch.setattr(api, "AUDIT", AuditLog(db)) | |
| api.USERS.create("acme", "clerk_acme", "a@acme.com", "owner", status="active") | |
| api.METER.record("acme", "1 Batch St", api.BATCH, lawn_sqft=4000.0, result_id="rb", zip="68106") | |
| api.LEADS.record("acme", "batch", "1 Batch St", email="runner@acme.com", lawn_sqft=4000.0, | |
| result_id="rb", detail=_json.dumps({"original_sqft": 4000.0})) | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_acme") | |
| hdr = {"Authorization": "Bearer x"} | |
| # An edit persists onto the lead (no lost work) and is recorded as a tenant edit. | |
| assert client.post("/dashboard/measurement/edit", headers=hdr, | |
| json={"result_id": "rb", "edited_sqft": 4500}).status_code == 200 | |
| assert api.LEADS.get_by_result("acme", "rb")["lawn_sqft"] == 4500.0 | |
| h = client.get("/dashboard/measurement/history/rb", headers=hdr).json() | |
| assert [s["kind"] for s in h["states"]] == ["original", "tenant"] | |
| assert h["states"][0]["sqft"] == 4000.0 and h["current_sqft"] == 4500.0 | |
| # Revert = re-apply the original; it persists and the history keeps every state. | |
| assert client.post("/dashboard/measurement/edit", headers=hdr, | |
| json={"result_id": "rb", "edited_sqft": 4000}).status_code == 200 | |
| assert api.LEADS.get_by_result("acme", "rb")["lawn_sqft"] == 4000.0 | |
| h2 = client.get("/dashboard/measurement/history/rb", headers=hdr).json() | |
| assert h2["current_sqft"] == 4000.0 and len(h2["states"]) == 3 # original + two edits | |
| def test_book_records_customer_measurement_edit(client, monkeypatch): | |
| from lawn_estimator.db import LeadStore, MeasurementEditStore, RunLedger, connect | |
| _register_widget(monkeypatch) | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "LEADS", LeadStore(db)) | |
| monkeypatch.setattr(api, "METER", RunLedger(db)) | |
| monkeypatch.setattr(api, "MEDITS", MeasurementEditStore(db)) | |
| monkeypatch.setattr(api, "notify_booking", lambda **kw: True) | |
| api.METER.record("wco", "1 A St", api.SINGLE, lawn_sqft=4000.0, result_id="r1", zip="68106") | |
| wco = {"X-API-Key": "pub-k", "Origin": "https://wco.com"} | |
| # Booking with an adjusted outline records a 'customer' edit + persists the booked area. | |
| r = client.post("/book", headers=wco, json={"address": "1 A St", "email": "a@x.com", | |
| "result_id": "r1", "services": ["Mowing"], "total": 90.0, "edited_sqft": 4600}) | |
| assert r.status_code == 200 | |
| edits = api.MEDITS.history("wco", "r1") | |
| assert len(edits) == 1 and edits[0]["kind"] == "customer" | |
| assert edits[0]["edited_sqft"] == 4600.0 and edits[0]["original_sqft"] == 4000.0 | |
| lead = api.LEADS.get_by_result("wco", "r1") | |
| assert lead["source"] == "booking" and lead["lawn_sqft"] == 4600.0 | |
| # Booking without a real change (edited == original) records no edit. | |
| api.METER.record("wco", "2 B St", api.SINGLE, lawn_sqft=3000.0, result_id="r2", zip="68106") | |
| client.post("/book", headers=wco, json={"address": "2 B St", "email": "b@x.com", | |
| "result_id": "r2", "services": ["Mowing"], "total": 70.0, "edited_sqft": 3000}) | |
| assert api.MEDITS.history("wco", "r2") == [] | |
| def test_book_stores_line_items_long_format_with_purchased_flag(client, monkeypatch): | |
| import json as _json | |
| from lawn_estimator.db import BookingItemStore, LeadStore, connect | |
| _register_widget(monkeypatch) | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "LEADS", LeadStore(db)) | |
| monkeypatch.setattr(api, "BOOKING_ITEMS", BookingItemStore(db)) | |
| monkeypatch.setattr(api, "notify_booking", lambda **kw: True) | |
| wco = {"X-API-Key": "pub-k", "Origin": "https://wco.com"} | |
| items = [{"service": "mow", "label": "Mowing", "price": 48.0, "purchased": True}, | |
| {"service": "fert", "label": "Fertilizer", "price": 32.0, "purchased": False}] | |
| r = client.post("/book", headers=wco, json={"address": "1 A St", "email": "a@x.com", | |
| "result_id": "r1", "services": ["Mowing"], "total": 48.0, "line_items": items}) | |
| assert r.status_code == 200 | |
| stored = api.BOOKING_ITEMS.for_result("wco", "r1") # one row per offered item, long format | |
| assert len(stored) == 2 | |
| by = {s["service"]: s for s in stored} | |
| assert by["mow"]["purchased"] == 1 and by["mow"]["price"] == 48.0 and by["mow"]["label"] == "Mowing" | |
| assert by["fert"]["purchased"] == 0 # offered but not bought | |
| lead = api.LEADS.get_by_result("wco", "r1") # popup breakdown = the purchased items | |
| detail = _json.loads(lead["detail"]) | |
| assert [li["service"] for li in detail["pricing"]["line_items"]] == ["mow"] | |
| def test_dashboard_overview(client, mock_run, monkeypatch): | |
| from lawn_estimator.db import LeadStore, RunLedger, UserStore, connect | |
| db = connect(":memory:") | |
| monkeypatch.setattr(api, "USERS", UserStore(db)) | |
| monkeypatch.setattr(api, "METER", RunLedger(db)) | |
| monkeypatch.setattr(api, "LEADS", LeadStore(db)) | |
| api.USERS.create("acme", "clerk_a", "a@acme.com", "owner", status="active") | |
| api.METER.record("acme", "1 A St", api.BATCH, zip="68106", lawn_sqft=4200.0, duration_s=22.0, result_id="r1") | |
| api.LEADS.record("acme", "widget", "1 A St", email="a@x") | |
| api.LEADS.record("acme", "booking", "1 A St", price_total=90.0, result_id="r1") | |
| monkeypatch.setattr(api, "verify_clerk_session", lambda t: "clerk_a") | |
| d = client.get("/dashboard/overview", headers={"Authorization": "Bearer x"}).json() | |
| assert d["tenant"] == "acme" and d["billable_quotes"] == 1 and d["quotes"] == 1 | |
| assert d["by_surface"]["batch"] == 1 and len(d["volume_series"]) == 30 | |
| assert d["leads_summary"]["leads"] == 2 and d["leads_summary"]["bookings"] == 1 | |