Files
ten31-database/backend/test_contacts_grid_signals.py
T
Keysat 42c169559c Mobile Phase 8d: sort controls across Grid, Pipeline, Contacts
Add a shared SortPill + SortSheet (label+hint option rows) and per-surface sort
tables:
- Grid: Name / Pipeline stage / Committed / Last contact / Priority, applied in
  the displayed memo (name is the tiebreak; staleness ranks longest-since-contact
  first, no-activity treated as most stale; committed uses the fund rollup).
- Pipeline: Name / Amount / Last activity / Priority, sorted within each stage.
  "Last activity" uses opp.updated_at as a recency proxy until the Pipeline card
  wires true last-contact recency (8f).
- Contacts: drop the investor/prospect type tabs (the prospect type is unused);
  add a Priority sort alongside Name A-Z/Z-A and Last contact.

contact_grid_signals() now also surfaces the linked investor's priority flag,
injected on both contact read paths (same derive-on-read contract as committed /
pipeline_stage), powering the Contacts Priority sort. Extended
test_contacts_grid_signals.py covers it; 39/39 backend green.
2026-06-19 22:06:14 -05:00

210 lines
10 KiB
Python

#!/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()