#!/usr/bin/env python3 """Regression: GET /api/fundraising/state heals blank grid-pill emails from the relational mirror. The grid blob is canonical for the mobile "Edit investor" sheet, but an email can reach a linked classic contact (email capture / a contact edit) without ever being written back into the blob pill — so the edit form showed an empty email for a contact the directory clearly had (Grant, 2026-06-20). The state handler now fills a blank pill email from fundraising_contacts.email, else the linked contacts.email, matched by pill order then name. This asserts: - a blank pill whose linked contact has an email is HEALED on read; - a blank pill whose linked contact is also blank stays blank; - a pill that already carries an email in the blob is NEVER overwritten (fill-only). Synthetic data only. Run: cd backend && python3 test_grid_email_heal.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_state(port, token): conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) conn.request("GET", "/api/fundraising/state", headers={"Authorization": "Bearer " + token}) resp = conn.getresponse() raw = resp.read().decode("utf-8", "replace") conn.close() return resp.status, (json.loads(raw) if raw else None) GRID = { "columns": [{"id": "investor_name", "label": "Investor", "type": "text"}, {"id": "contacts", "label": "Contacts", "type": "contacts"}], "rows": [ {"id": "rowW", "investor_name": "Wyoming", "notes": "", "contacts": [{"name": "Philip Treick", "email": "", "title": ""}, {"name": "Jose Briones", "email": "", "title": ""}]}, {"id": "rowA", "investor_name": "Acme Capital", "notes": "", "contacts": [{"name": "Jane Doe", "email": "keep@acme.com", "title": ""}]}, {"id": "rowO", "investor_name": "Orphan LP", "notes": "", "contacts": [{"name": "No Link", "email": "", "title": ""}]}, ], } 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)") c.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version) " "VALUES ('main', ?, '[]', 1) " "ON CONFLICT(id) DO UPDATE SET grid_json = excluded.grid_json", (json.dumps(GRID),)) # Classic contacts directory: Jose has the captured email the blob never got; Philip is blank. c.execute("INSERT INTO contacts (id,first_name,last_name,email) VALUES " "('c-phil','Philip','Treick','')," "('c-jose','Jose','Briones','jbriones@uwyo.edu')," "('c-jane','Jane','Doe','other@acme.com')") # differs from the blob's keep@acme.com # Relational mirror (what sync_fundraising_relational would build): blank fc.email, linked contact_id. c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested) VALUES " "('inv-w','Wyoming','rowW',0),('inv-a','Acme Capital','rowA',0),('inv-o','Orphan LP','rowO',0)") # fc-orphan has contact_id NULL (pre-0004 orphan) and blank email — nothing to heal from. c.execute("INSERT INTO fundraising_contacts (id,investor_id,full_name,email,sort_order,contact_id) VALUES " "('fc-phil','inv-w','Philip Treick','',0,'c-phil')," "('fc-jose','inv-w','Jose Briones','',1,'c-jose')," "('fc-jane','inv-a','Jane Doe','',0,'c-jane')," "('fc-orphan','inv-o','No Link','',0,NULL)") 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: st, d = _get_state(port, token) rows = ((d or {}).get("data", {}).get("grid", {}) or {}).get("rows", []) by_id = {r.get("id"): r for r in rows} w = by_id.get("rowW", {}) a = by_id.get("rowA", {}) wc = w.get("contacts", []) ac = a.get("contacts", []) print("\n[heal: blank pill email filled from the linked contact (Jose)]") jose = next((c for c in wc if c.get("name") == "Jose Briones"), {}) check(st == 200 and jose.get("email") == "jbriones@uwyo.edu", f"Jose pill healed to jbriones@uwyo.edu (got {jose.get('email')!r})") print("\n[heal: blank pill whose contact is also blank stays blank (Philip)]") phil = next((c for c in wc if c.get("name") == "Philip Treick"), {}) check(phil.get("email", "") == "", f"Philip pill stays blank (got {phil.get('email')!r})") print("\n[heal: a pill that already has an email is never overwritten (Jane)]") jane = next((c for c in ac if c.get("name") == "Jane Doe"), {}) check(jane.get("email") == "keep@acme.com", f"Jane pill keeps its blob email, not the contact's (got {jane.get('email')!r})") print("\n[heal: a pill whose fundraising_contacts row has contact_id NULL stays blank (orphan)]") o = by_id.get("rowO", {}) orphan = next((c for c in o.get("contacts", []) if c.get("name") == "No Link"), {}) check(orphan.get("email", "") == "", f"orphan pill (no contact_id, no email source) stays blank (got {orphan.get('email')!r})") finally: httpd.shutdown() print() if FAILS: print(f"FAILED ({len(FAILS)}):") for f in FAILS: print(f" - {f}") sys.exit(1) print("ALL PASS (grid email heal)") if __name__ == "__main__": main()