05f15b9197
The Investors/Prospects distinction is now derived live from the canonical grid (contact_grid_signals -> committed/pipeline_stage), not the mechanically set contact_type column: - Desktop Contacts: drop the Investors/Prospects tabs + TYPE badge; show a derived Status (existing-LP badge + pipeline stage chip). - Dashboard: repoint Total LPs / Prospects onto fundraising_investors entities (committed>0 vs $0, graveyard + blank-row placeholder excluded); fix a total_contacts soft-delete leak. - Stop reading/writing contact_type across the create/update/import/sync paths. The column is left inert in place; a physical drop is deferred to a later signed-off table-rebuild migration (SQLite no-drop-column; contacts is FK-referenced) -- same retire-then-drop path lp_profiles took.
136 lines
5.8 KiB
Python
136 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Regression test for the dashboard KPI repoint + lp_profiles retirement (2026-06-16).
|
|
|
|
"Total Committed" used to SUM lp_profiles.commitment_amount — an orphaned table with no
|
|
reachable input path, so the dashboard read ~$0 while the real commitments lived in the
|
|
fundraising grid. It now sums fundraising_investors.total_invested (the canonical grid
|
|
rollup) with graveyarded (written-off) investors excluded, "Total Funded" is dropped
|
|
(the grid has no funded-vs-committed concept), and the /api/lp-profiles* + lp-breakdown
|
|
endpoints are gone.
|
|
|
|
v0.1.0:106 repointed "Total LPs" / "Prospects" off the retired contacts.contact_type onto
|
|
the canonical grid (investor entities): an LP = a grid investor with total_invested > 0
|
|
(graveyard excluded); a prospect = a live grid row with $0 committed (graveyard + the
|
|
'Untitled Investor' blank-row placeholder excluded).
|
|
|
|
This boots the REAL server against a temp DB, seeds grid investors (live LP, graveyarded,
|
|
live prospect, blank placeholder), and asserts: total_committed reflects the live grid
|
|
rollup only, total_lps / total_prospects use the grid-entity definitions, the metrics no
|
|
longer carry a total_funded key, and the retired routes 404. Synthetic only.
|
|
|
|
Run: cd backend && python3 test_dashboard_report.py
|
|
"""
|
|
import http.client
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from http.server import ThreadingHTTPServer
|
|
|
|
_DATA = tempfile.mkdtemp()
|
|
os.environ["CRM_DATA_DIR"] = _DATA
|
|
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import server # noqa: E402
|
|
|
|
FAILS = []
|
|
|
|
|
|
def check(cond, msg):
|
|
print((" PASS " if cond else " FAIL ") + msg)
|
|
if not cond:
|
|
FAILS.append(msg)
|
|
|
|
|
|
class _Quiet(server.CRMHandler):
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
|
|
def _get(port, path, token):
|
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
|
conn.request("GET", path, headers={"Authorization": "Bearer " + token})
|
|
resp = conn.getresponse()
|
|
body = resp.read().decode("utf-8", "replace")
|
|
conn.close()
|
|
data = None
|
|
if body:
|
|
try:
|
|
data = json.loads(body)
|
|
except ValueError:
|
|
pass
|
|
return resp.status, data
|
|
|
|
|
|
def seed():
|
|
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
|
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
|
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
|
# live investor committed 3,000,000; graveyarded investor committed 500,000 (must be excluded)
|
|
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
|
"VALUES ('fiLive','Harbor LP','rowLive',3000000,0)")
|
|
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
|
"VALUES ('fiDead','Passed LP','rowDead',500000,1)")
|
|
# a live prospect (in the grid, $0 committed) and a blank placeholder row — the prospect
|
|
# count includes the former and excludes the latter ('Untitled Investor' = a blank grid row)
|
|
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
|
"VALUES ('fiProspect','Prospect Co','rowProspect',0,0)")
|
|
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
|
"VALUES ('fiBlank','Untitled Investor','rowBlank',0,0)")
|
|
# one live + one soft-deleted contact: total_contacts must count only the live one
|
|
# (guards the deleted_at filter added alongside the contact_type repoint)
|
|
c.execute("INSERT INTO contacts (id,first_name,last_name) VALUES ('ctLive','Ann','Live')")
|
|
c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) "
|
|
"VALUES ('ctGone','Bob','Gone','2026-06-01T00:00:00Z')")
|
|
c.commit()
|
|
c.close()
|
|
|
|
|
|
def main():
|
|
server.init_db()
|
|
seed()
|
|
token = server.create_token("u1", "grant", "admin")
|
|
|
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
|
port = httpd.server_address[1]
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
try:
|
|
print("\n[dashboard total_committed comes from the grid, graveyard excluded]")
|
|
st, dash = _get(port, "/api/reports/dashboard", token)
|
|
check(st == 200, f"GET dashboard -> 200 (got {st})")
|
|
metrics = (dash or {}).get("data", {}).get("metrics", {})
|
|
check(metrics.get("total_committed") == 3000000,
|
|
f"total_committed = live grid rollup only (3,000,000; got {metrics.get('total_committed')})")
|
|
check("total_funded" not in metrics,
|
|
f"total_funded key dropped from metrics (got keys {sorted(metrics)})")
|
|
|
|
print("\n[Total LPs / Prospects derived from the grid, not the retired contacts.contact_type]")
|
|
check(metrics.get("total_lps") == 1,
|
|
f"total_lps = grid investors committed>0, graveyard excluded (1; got {metrics.get('total_lps')})")
|
|
check(metrics.get("total_prospects") == 1,
|
|
f"total_prospects = grid rows with $0 committed; graveyard + 'Untitled Investor' excluded (1; got {metrics.get('total_prospects')})")
|
|
check(metrics.get("total_contacts") == 1,
|
|
f"total_contacts excludes soft-deleted contacts (1; got {metrics.get('total_contacts')})")
|
|
|
|
print("\n[retired lp_profiles endpoints 404]")
|
|
for path in ("/api/lp-profiles", "/api/lp-profiles/anything", "/api/reports/lp-breakdown"):
|
|
st, _ = _get(port, path, token)
|
|
check(st == 404, f"GET {path} -> 404 (got {st})")
|
|
finally:
|
|
httpd.shutdown()
|
|
|
|
print()
|
|
if FAILS:
|
|
print(f"FAILED ({len(FAILS)}):")
|
|
for f in FAILS:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
print("ALL PASS (dashboard KPI repoint + lp_profiles retirement)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|