#!/usr/bin/env python3 """Tests for the mobile Contacts card's grid-derived signals (Phase 8a). GET /api/contacts enriches each classic contact with two read-only, live-derived fields sourced from the fundraising grid (the canonical investor model), for the mobile card: - `committed` -> the linked investor's total_invested (>0 drives the existing-LP avatar ring), mirroring existing_investor_by_source_row (committed capital, not graveyard); - `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill), or null when the investor isn't in the pipeline. - `priority` -> that investor's priority flag (drives the mobile Contacts Priority sort, 8d). A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null / priority false. Signals are derived fresh on read and never stored on the contact. Synthetic data only. Run: cd backend && python3 test_contacts_grid_signals.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 _req(port, method, path, token=None, body=None): conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) headers = {} if token: headers["Authorization"] = "Bearer " + token payload = None if body is not None: payload = json.dumps(body) headers["Content-Type"] = "application/json" conn.request(method, path, body=payload, headers=headers) resp = conn.getresponse() raw = resp.read().decode("utf-8", "replace") conn.close() data = None if raw: try: data = json.loads(raw) except ValueError: pass return resp.status, data # One fund column so a non-zero cell rolls up into total_invested (the "existing LP" signal). COLUMNS = [{"id": "fund1", "label": "Fund III", "isFund": True}] ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "priority": True, "fund1": 250000, "contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]} ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital", "fund1": 0, "contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]} def _db(): return sqlite3.connect(os.environ["CRM_DB_PATH"]) def seed(): c = _db() 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)") # A pure classic contact with NO fundraising-grid link (not an investor). c.execute("INSERT INTO contacts (id,first_name,last_name,email,contact_type,status) " "VALUES ('cLegacy','Vendor','Vince','vince@vendor.com','other','active')") c.commit() c.close() def _by_email(contacts, email): return next((c for c in contacts if (c.get("email") or "").lower() == email), None) 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: st, _ = _req(port, "PUT", "/api/fundraising/state", token, {"grid": {"columns": COLUMNS, "rows": [ROW_ACME, ROW_BETA]}, "views": []}) check(st == 200, f"seed grid via PUT /state (got {st})") # Put Acme into the pipeline at 'engaged' so its contact's card shows a stage pill. st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "rowAcme", "stage": "engaged"}) check(st in (200, 201), f"link Acme to pipeline @engaged (got {st}, {d})") st, d = _req(port, "GET", "/api/contacts?limit=500", token) contacts = (d or {}).get("data") or [] check(st == 200 and contacts, f"GET /api/contacts (got {st}, {len(contacts)} contacts)") jane = _by_email(contacts, "jane@acme.com") pat = _by_email(contacts, "pat@beta.com") vince = _by_email(contacts, "vince@vendor.com") check(jane is not None, "Acme's synced contact Jane Doe is in the directory") check(pat is not None, "Beta's synced contact Pat Roe is in the directory") check(vince is not None, "the pure classic contact Vince is in the directory") # ── existing-LP ring signal: committed reflects the linked investor's rollup ── print("\n[committed: existing-LP ring driven by the linked investor's total_invested]") check((jane or {}).get("committed") == 250000, f"Jane.committed == 250000 (existing LP) (got {(jane or {}).get('committed')})") check((pat or {}).get("committed") == 0, f"Pat.committed == 0 (zero-commit prospect, no ring) (got {(pat or {}).get('committed')})") check((vince or {}).get("committed") == 0, f"Vince.committed == 0 (no grid link) (got {(vince or {}).get('committed')})") # ── stage-pill signal: pipeline_stage is the investor's live derived stage ── print("\n[pipeline_stage: stage pill driven by the investor's live opp stage]") check((jane or {}).get("pipeline_stage") == "engaged", f"Jane.pipeline_stage == 'engaged' (got {(jane or {}).get('pipeline_stage')!r})") check((pat or {}).get("pipeline_stage") is None, f"Pat.pipeline_stage is None (not in pipeline) (got {(pat or {}).get('pipeline_stage')!r})") check((vince or {}).get("pipeline_stage") is None, f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!r})") # ── priority signal: flagged investor → contact's Priority-sort key (8d) ── print("\n[priority: Contacts Priority sort driven by the investor's priority flag]") check((jane or {}).get("priority") is True, f"Jane.priority is True (Acme flagged) (got {(jane or {}).get('priority')!r})") check((pat or {}).get("priority") is False, f"Pat.priority is False (Beta not flagged) (got {(pat or {}).get('priority')!r})") check((vince or {}).get("priority") is False, f"Vince.priority is False (no grid link) (got {(vince or {}).get('priority')!r})") # ── the get-by-id endpoint carries the same signals (mobile detail sheet, 8b) ── print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]") st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token) detail = (d or {}).get("data") or {} check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged" and detail.get("priority") is True, f"detail carries committed/pipeline_stage/priority (got committed={detail.get('committed')}, stage={detail.get('pipeline_stage')!r}, priority={detail.get('priority')!r})") st, d = _req(port, "GET", f"/api/contacts/{vince['id']}", token) vdetail = (d or {}).get("data") or {} check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None and vdetail.get("priority") is False, f"unlinked contact detail has committed 0 / stage None / priority False (got {vdetail.get('committed')}, {vdetail.get('pipeline_stage')!r}, {vdetail.get('priority')!r})") # ── stage tracks the board: advancing the opp re-derives the contact's stage ── print("\n[derived-live: advancing the board stage re-derives the contact's pill]") opp_id = None st, d = _req(port, "GET", "/api/fundraising/state", token) for r in (d or {}).get("data", {}).get("grid", {}).get("rows", []): if r.get("id") == "rowAcme": opp_id = r.get("opportunity_id") st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "diligence"}) check(st == 200, f"advance Acme's opp -> diligence (got {st})") st, d = _req(port, "GET", "/api/contacts?limit=500", token) jane2 = _by_email((d or {}).get("data") or [], "jane@acme.com") check((jane2 or {}).get("pipeline_stage") == "diligence", f"Jane.pipeline_stage re-derives to 'diligence' (got {(jane2 or {}).get('pipeline_stage')!r})") # ── dedup: a contact linked to two investors exposes the highest-committed one ── print("\n[dedup: highest-committed linked investor wins for a multi-linked contact]") c = _db() # Link Jane's classic contact to a SECOND, richer investor (direct rows — the grid sync # makes one link per pill; this exercises the multi-link branch in contact_grid_signals). c.execute("INSERT INTO fundraising_investors (id, investor_name, source_row_id, total_invested) " "VALUES ('inv2','Mega Fund LP','rowMega',500000)") c.execute("INSERT INTO fundraising_contacts (id, investor_id, full_name, contact_id) " "VALUES ('fc2','inv2','Jane Doe',?)", (jane['id'],)) c.commit() c.close() st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token) jd = (d or {}).get("data") or {} check(jd.get("committed") == 500000, f"multi-linked contact exposes the higher committed (500000 > 250000) (got {jd.get('committed')})") # The winning (higher-committed) link is Mega Fund LP, which is not flagged → priority follows it. check(jd.get("priority") is False, f"multi-linked contact's priority follows the higher-committed investor (Mega, unflagged) (got {jd.get('priority')!r})") finally: httpd.shutdown() print() if FAILS: print(f"FAILED ({len(FAILS)}):") for m in FAILS: print(" - " + m) sys.exit(1) print("All contacts-grid-signals tests passed.") if __name__ == "__main__": main()