Files
ten31-database/backend/test_contacts_grid_signals.py
T
Keysat 707a270922 Mobile Phase 8h: Grid detail stage/reminder cards + Open-in-Grid deep-link
Grid full-screen investor detail, conformed to the dc anatomy:
- G4: pipeline stage as a single tappable .detail-tap-card (chip + Change/Add)
- G5: dedicated Reminder card fed by the soonest active reminder; tri-state
  (loading → disabled "Checking…" so a pre-load tap can't POST a duplicate;
  none → "No reminder set"; object → edit). Edits PATCH in place, else POST.
- G6 (notes timeline) was already in place.

Open-in-Grid deep-link, now on all three mobile detail surfaces (Contacts,
Pipeline, Reminders): a shared shell openInvestorInGrid(rowId) sets a one-shot
gridUiAction object the mobile grid consumes on mount to open that investor's
detail; the desktop grid drains the unrecognized object so it can't linger.
Each surface gets its grid row id from a server-injected source_row_id:
contacts via contact_grid_signals, opportunities via the durable
fundraising_investor_id join, reminders via the investor_id join. All are
read-only/GET-only or field-allowlist writes, so none need a strip point.

Tests: source_row_id injection assertions for contacts, opportunities, and
reminders; full suite 40/40. Client surfaces jsdom-verified.
2026-06-20 07:08:29 -05:00

225 lines
11 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).
- `source_row_id` -> that investor's grid row id (the "Open investor in Grid" deep-link target, 8h),
present for ANY grid-linked contact (even a zero-commit prospect), null otherwise.
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null / priority false
/ source_row_id null.
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})")
# ── source_row_id signal: the "Open investor in Grid" deep-link target (8h) ──
print("\n[source_row_id: Open-in-Grid deep-link target = the linked investor's grid row id]")
check((jane or {}).get("source_row_id") == "rowAcme",
f"Jane.source_row_id == 'rowAcme' (got {(jane or {}).get('source_row_id')!r})")
check((pat or {}).get("source_row_id") == "rowBeta",
f"Pat.source_row_id == 'rowBeta' (present for a zero-commit linked contact) (got {(pat or {}).get('source_row_id')!r})")
check((vince or {}).get("source_row_id") is None,
f"Vince.source_row_id is None (no grid link) (got {(vince or {}).get('source_row_id')!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})")
# The deep-link target also follows the winning link → Mega's grid row (rowMega), not rowAcme.
check(jd.get("source_row_id") == "rowMega",
f"multi-linked contact's source_row_id follows the higher-committed investor (rowMega) (got {jd.get('source_row_id')!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()