lawn-estimator-dev / tests /test_db.py
TempuraML's picture
feat(admin): DB-backed tenant + key issuance — one-click client onboarding (#141)
a2a8f31 unverified
Raw
History Blame Contribute Delete
17.3 kB
"""Run ledger (db.RunLedger) — the observability columns + real durability (P1a)."""
from __future__ import annotations
from lawn_estimator.db import (
ApiKeyStore,
AuditLog,
ConfigStore,
JobStore,
LeadStore,
MeasurementEditStore,
RunLedger,
TenantStore,
UserStore,
connect,
)
from lawn_estimator.metering import SINGLE
def test_record_stores_the_observability_columns():
conn = connect(":memory:")
RunLedger(conn).record(
"acme", "5534 Mayberry St", SINGLE,
zip="68106", method="lidar+rgb", confidence="high", duration_s=21.4, result_id="abc123",
)
row = conn.execute("SELECT * FROM runs").fetchone()
assert row["tenant_id"] == "acme"
assert row["surface"] == SINGLE
assert row["zip"] == "68106"
assert row["method"] == "lidar+rgb"
assert row["confidence"] == "high"
assert row["duration_s"] == 21.4
assert row["result_id"] == "abc123"
assert row["billable"] == 1
assert row["outcome"] == "ok" # set explicitly (not via the column default)
assert row["created_at"] # server-stamped
def test_record_sets_outcome_ok_without_a_column_default():
# Reproduce the migrated-Neon condition: `outcome` exists but has NO default (added via
# ADD COLUMN). record() must write 'ok' explicitly, else `ok` stays 0 on prod forever.
db = connect(":memory:")
db.execute("DROP TABLE runs")
db.execute(
"CREATE TABLE runs (id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, surface TEXT, "
"address TEXT, address_norm TEXT, zip TEXT, billable INTEGER, method TEXT, confidence TEXT, "
"lawn_sqft REAL, duration_s REAL, result_id TEXT, outcome TEXT, error_class TEXT, created_at TEXT)"
)
db.commit()
RunLedger(db).record("acme", "1 A St", "single", result_id="r1")
row = db.execute("SELECT outcome FROM runs WHERE result_id = 'r1'").fetchone()
assert row["outcome"] == "ok" # explicit — there is no default to fall back on
def test_ensure_columns_backfills_legacy_null_outcome_as_ok():
# A pre-observability row (outcome NULL) counts in total but is invisible to ok — the
# `total=2, ok=0` gap. Re-running the migration backfills it to 'ok'.
db = connect(":memory:")
led = RunLedger(db)
# Recreate runs with a NULLABLE outcome (the migrated Neon column has no NOT NULL / default),
# then insert a pre-observability completed quote as outcome=NULL.
db.execute("DROP TABLE runs")
db.execute(
"CREATE TABLE runs (id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, surface TEXT, "
"address TEXT, address_norm TEXT, zip TEXT, billable INTEGER, method TEXT, confidence TEXT, "
"lawn_sqft REAL, duration_s REAL, result_id TEXT, outcome TEXT, error_class TEXT, "
"created_at TEXT DEFAULT '2020-01-01T00:00:00Z')"
)
db.execute("INSERT INTO runs (tenant_id, surface, address, address_norm, billable, outcome) "
"VALUES ('acme', 'batch', '1 A St', '1 a st', 1, NULL)")
db.commit()
assert led.stats("acme")["total"] == 1 and led.stats("acme")["ok"] == 0 # the gap
db._ensure_columns() # re-running the migration backfills
s = led.stats("acme")
assert s["total"] == 1 and s["ok"] == 1 # now counted as ok
def test_usage_survives_a_new_connection_to_the_same_file(tmp_path):
db = str(tmp_path / "app.db")
RunLedger(connect(db)).record("acme", "123 A St", SINGLE)
# A brand-new ledger over the same file (i.e. a "redeploy") still sees the count.
assert RunLedger(connect(db)).usage("acme") == 1
def test_run_ledger_records_failures_and_stats():
led = RunLedger(connect(":memory:"))
led.record("acme", "1 A St", "single", duration_s=20.0) # ok
led.record("acme", "2 B St", "batch", duration_s=30.0) # ok
led.record_failure("acme", "bad addr", "single", error_class="ValueError", outcome="rejected")
led.record_failure("acme", "3 C St", "batch", error_class="RuntimeError", outcome="error")
s = led.stats("acme")
assert s["total"] == 4 and s["ok"] == 2 and s["rejected"] == 1 and s["errors"] == 1
assert s["failure_rate"] == 0.25 # errors / total
assert s["avg_duration_s"] == 25.0 # avg over the two ok runs (failures excluded)
assert RunLedger(connect(":memory:")).stats("nobody")["total"] == 0
def test_stats_by_tenant_is_cross_tenant():
led = RunLedger(connect(":memory:"))
led.record("acme", "1", "batch", duration_s=20.0)
led.record("acme", "2", "single", duration_s=30.0)
led.record_failure("acme", "x", "single", error_class="ValueError", outcome="rejected")
led.record("beta", "9", "batch", duration_s=10.0)
rows = led.stats_by_tenant()
by = {r["tenant"]: r for r in rows}
assert by["acme"]["total"] == 3 and by["acme"]["ok"] == 2 and by["acme"]["rejected"] == 1
assert by["acme"]["billable"] == 2 # 2 billable ok runs; the rejected one isn't
assert by["beta"]["total"] == 1
assert rows[0]["tenant"] == "acme" # most active first
def test_run_ledger_stores_lawn_sqft_and_fetches_by_result_id():
led = RunLedger(connect(":memory:"))
led.record("acme", "1 A St", "batch", lawn_sqft=4200.0, result_id="r1", zip="68106")
row = led.get_by_result_id("acme", "r1")
assert row["lawn_sqft"] == 4200.0 and row["surface"] == "batch" and row["zip"] == "68106"
assert led.get_by_result_id("acme", "nope") is None
assert led.get_by_result_id("beta", "r1") is None # tenant-scoped
def test_measurement_edit_store():
st = MeasurementEditStore(connect(":memory:"))
st.record("acme", "r1", 4500.0, original_sqft=4000.0, address="1 A St", user_id=1)
got = st.recent("acme")
assert got[0]["edited_sqft"] == 4500.0 and got[0]["original_sqft"] == 4000.0
assert st.recent("beta") == []
def test_lead_store_records_and_lists_newest_first():
store = LeadStore(connect(":memory:"))
store.record("acme", "widget", "1 A St", name="Sam", email="s@x.com", phone="402-555-0100")
store.record("acme", "quote", "2 B St", email="b@x.com", zip="68106",
lawn_sqft=4200.0, price_total=90.0, confidence="high", result_id="r1")
store.record("beta", "widget", "9 Z St", email="z@x.com") # other tenant, filtered out
leads = store.recent("acme")
assert [lead["address"] for lead in leads] == ["2 B St", "1 A St"] # newest first
quote_lead = leads[0]
assert quote_lead["source"] == "quote"
assert quote_lead["lawn_sqft"] == 4200.0
assert quote_lead["price_total"] == 90.0
assert quote_lead["result_id"] == "r1"
widget_lead = leads[1]
assert widget_lead["phone"] == "402-555-0100"
assert widget_lead["lawn_sqft"] is None # captured before measurement
assert store.recent("gamma") == [] # unknown tenant
def test_tenant_store_roundtrips_and_upserts():
store = TenantStore(connect(":memory:"))
assert store.get("acme") is None # never stored
store.put("acme", {"company": "Acme", "currency": "USD"})
assert store.get("acme") == {"company": "Acme", "currency": "USD"}
store.put("acme", {"company": "Acme Renamed"}) # upsert replaces
assert store.get("acme") == {"company": "Acme Renamed"}
assert store.all_ids() == ["acme"]
def test_user_store_maps_clerk_user_to_tenant_and_role():
users = UserStore(connect(":memory:"))
uid = users.create("acme", "clerk_abc", "owner@acme.com", "owner", status="active")
got = users.by_clerk_id("clerk_abc")
assert got["tenant_id"] == "acme" and got["role"] == "owner" and got["email"] == "owner@acme.com"
assert users.by_clerk_id("clerk_nope") is None # unprovisioned Clerk user → denied
users.set_role(uid, "staff")
assert users.by_clerk_id("clerk_abc")["role"] == "staff"
users.create("acme", "clerk_def", "staff@acme.com", "staff")
assert {u["email"] for u in users.by_tenant("acme")} == {"owner@acme.com", "staff@acme.com"}
def test_audit_log_is_append_only_scoped_and_newest_first():
audit = AuditLog(connect(":memory:"))
audit.record("config.publish", tenant_id="acme", user_id=1,
before={"rate": 5}, after={"rate": 6}, ip="1.2.3.4")
audit.record("user.invite", tenant_id="acme", user_id=1, target_type="user", target_id="7")
audit.record("config.publish", tenant_id="beta", user_id=9) # other tenant
rows = audit.recent("acme")
assert [r["action"] for r in rows] == ["user.invite", "config.publish"] # newest first
assert '"rate": 6' in rows[1]["after"] and rows[1]["ip"] == "1.2.3.4"
assert audit.recent("beta")[0]["action"] == "config.publish" # scoped
def test_config_store_drafts_and_versions():
cs = ConfigStore(connect(":memory:"))
assert cs.get_draft("acme") is None
cs.save_draft("acme", {"company": "Acme", "rate": 5}, user_id=1)
assert cs.get_draft("acme") == {"company": "Acme", "rate": 5}
cs.save_draft("acme", {"company": "Acme", "rate": 6}) # upsert
assert cs.get_draft("acme")["rate"] == 6
cs.add_version("acme", {"v": 1})
cs.add_version("acme", {"v": 2})
versions = cs.versions("acme")
assert [v["config"]["v"] for v in versions] == [2, 1] # newest first
cs.clear_draft("acme")
assert cs.get_draft("acme") is None
class _Dropped(Exception):
"""Stand-in for psycopg.OperationalError/InterfaceError (psycopg isn't installed locally)."""
def _fake_pg_retry(db):
"""Retrofit a real SQLite Database into the postgres retry path with fake connections."""
db.is_postgres = True
db._conn_errors = (_Dropped,)
def test_execute_reconnects_once_on_a_dropped_connection():
# Neon drops idle connections; a dropped conn must reconnect + retry, not surface a 500.
db = connect(":memory:")
_fake_pg_retry(db)
calls = {"n": 0}
class DeadConn:
def execute(self, sql, params):
calls["n"] += 1
raise _Dropped("server closed the connection unexpectedly")
class LiveConn:
def execute(self, sql, params):
calls["n"] += 1
return "ok-cursor"
db._conn = DeadConn()
db._connect_pg = lambda: setattr(db, "_conn", LiveConn()) # "reconnect" swaps in a live conn
assert db.execute("SELECT 1") == "ok-cursor" # retried on the reconnected connection
assert calls["n"] == 2 # exactly one failed attempt + one retry
def test_execute_retries_only_once_then_propagates():
# A real outage (still dead after reconnect) must raise, not loop forever.
import pytest
db = connect(":memory:")
_fake_pg_retry(db)
calls = {"n": 0}
class DeadConn:
def execute(self, sql, params):
calls["n"] += 1
raise _Dropped("still down")
db._conn = DeadConn()
db._connect_pg = lambda: setattr(db, "_conn", DeadConn()) # reconnect, but still dead
with pytest.raises(_Dropped):
db.execute("SELECT 1")
assert calls["n"] == 2 # original + exactly one retry, then propagate
def test_user_store_invite_activate_and_caps():
users = UserStore(connect(":memory:"))
users.create("acme", "clerk_owner", "owner@acme.com", "owner", status="active")
# A pending invite has no Clerk id and is found by email until it's accepted.
iid = users.invite("acme", "Staff@Acme.com", "staff", invited_by=1)
assert users.by_clerk_id("clerk_new") is None
pending = users.pending_by_email("staff@acme.com") # case-insensitive
assert pending["id"] == iid and pending["role"] == "staff" and pending["clerk_user_id"] is None
# First sign-in binds the Clerk id + activates it.
users.activate(iid, "clerk_new")
assert users.pending_by_email("staff@acme.com") is None # no longer pending
assert users.by_clerk_id("clerk_new")["status"] == "active"
# Seats count active + pending (not disabled); owners are counted for the last-owner guard.
assert users.seat_count("acme") == 2 and users.active_owner_count("acme") == 1
users.set_status(iid, "disabled")
assert users.seat_count("acme") == 1
def test_job_store_persist_resume_scope_and_evict():
jobs = JobStore(connect(":memory:"))
jobs.create("j1", client="acme", imagery="google", addresses=["1 A St", "2 B St", "3 C St"])
# Freshly created -> queued, worker sees it with completed=0 and decoded addresses.
assert jobs.pending_ids() == ["j1"]
w = jobs.for_worker("j1")
assert w["client"] == "acme" and w["imagery"] == "google" and w["completed"] == 0
assert w["addresses"] == ["1 A St", "2 B St", "3 C St"]
# Worker runs the first address, then the app "restarts" mid-batch (nothing lost).
jobs.mark_running("j1")
jobs.append_result("j1", 1, {"address": "1 A St", "status": "ok", "result_id": "r1"})
assert jobs.pending_ids() == ["j1"] # still resumable after a restart
assert jobs.for_worker("j1")["completed"] == 1 # resumes from index 1, not 0
# Finish the batch; the poll view is scoped to the owning client.
jobs.append_result("j1", 2, {"address": "2 B St", "status": "ok", "result_id": "r2"})
jobs.append_result("j1", 3, {"address": "3 C St", "status": "error"})
jobs.mark_done("j1")
assert jobs.pending_ids() == []
view = jobs.get("j1", "acme")
assert view["status"] == "done" and view["total"] == 3 and view["completed"] == 3
assert [r.get("result_id") for r in view["results"]] == ["r1", "r2", None]
assert jobs.get("j1", "someone-else") is None # a job never leaks across clients
# Idempotent replay of an index overwrites rather than duplicating.
jobs.append_result("j1", 2, {"address": "2 B St", "status": "ok", "result_id": "r2b"})
assert jobs.get("j1", "acme")["results"][1]["result_id"] == "r2b"
# Eviction returns the result_ids to forget and only removes DONE jobs beyond `keep`.
jobs.create("j2", client="acme", imagery="google", addresses=["x"]) # queued, must survive
forget = jobs.evict_old(keep=0)
assert set(forget) == {"r1", "r2b"} # None result_ids are skipped
assert jobs.get("j1", "acme") is None # the done job is gone
assert jobs.pending_ids() == ["j2"] # the queued job is untouched
def test_run_ledger_overview_aggregates():
led = RunLedger(connect(":memory:"))
led.record("acme", "1 A St", "single", zip="68106", duration_s=20.0, lawn_sqft=4000.0)
led.record("acme", "2 B St", "batch", zip="68106", duration_s=40.0, lawn_sqft=6000.0)
led.record_failure("acme", "x", "single", error_class="ValueError", outcome="rejected", zip="68164")
led.record("beta", "9 Z", "batch", zip="68007") # other tenant — excluded
ov = led.overview("acme", days=30)
assert ov["quotes"] == 3 and ov["ok"] == 2 and ov["rejected"] == 1 and ov["errors"] == 0
assert ov["by_surface"] == {"single": 2, "batch": 1}
assert ov["avg_duration_s"] == 30.0 and ov["avg_lawn_sqft"] == 5000 # ok runs only
assert ov["top_zips"][0] == {"zip": "68106", "count": 2}
assert len(ov["volume_series"]) == 30
assert ov["volume_series"][-1]["count"] == 3 and sum(p["count"] for p in ov["volume_series"]) == 3
assert RunLedger(connect(":memory:")).overview("nobody")["quotes"] == 0
def test_api_key_store_issue_and_lookup():
store = ApiKeyStore(connect(":memory:"))
store.issue("acme", "pub-abc", kind="publishable", label="acme")
store.issue("acme", "sec-xyz", kind="secret")
rows = store.all()
assert {"pub-abc", "sec-xyz"} <= {r["api_key"] for r in rows}
assert store.key_for_tenant("acme", "publishable") == "pub-abc"
assert store.key_for_tenant("acme", "secret") == "sec-xyz"
assert store.key_for_tenant("nobody") is None
def test_lead_store_summary_conversion():
store = LeadStore(connect(":memory:"))
store.record("acme", "widget", "1 A", email="a@x")
store.record("acme", "booking", "1 A", price_total=90.0, result_id="r1")
store.record("acme", "booking", "2 B", price_total=110.0, result_id="r2")
store.record("acme", "batch", "3 C", price_total=200.0) # operator quote — NOT a customer lead
store.record("beta", "widget", "9 Z") # other tenant
s = store.summary("acme", days=30)
assert s["leads"] == 3 and s["bookings"] == 2 and s["conversion"] == round(2 / 3, 3) # batch excluded
assert s["by_source"] == {"widget": 1, "booking": 2} and "batch" not in s["by_source"]
assert s["avg_price"] == 100.0 # avg of BOOKED prices (90, 110); the batch 200 is ignored
def test_lead_store_summary_top_packages():
import json
store = LeadStore(connect(":memory:"))
spring = json.dumps({"bundle": {"id": "spring", "name": "Spring Package", "cadence": "seasonal"}})
store.record("acme", "booking", "1 A", price_total=90.0, result_id="r1", detail=spring)
store.record("acme", "booking", "2 B", price_total=90.0, result_id="r2", detail=spring)
store.record("acme", "booking", "3 C", price_total=50.0, result_id="r3") # à-la-carte, no bundle
top = store.summary("acme", days=30)["top_packages"]
assert top and top[0] == {"name": "Spring Package", "count": 2}