#!/usr/bin/env python3 """Integration test for the version-safe single-row update endpoint (P3b). POST /api/fundraising/update-row edits ONE investor row's name and/or contact pills by reading the canonical grid blob fresh server-side and mutating only the target row — never accepting a whole-grid payload (BRIEF §3a), so it can't clobber concurrent edits to other rows. This boots the REAL server against a throwaway DB, seeds a two-row grid, then drives the live HTTP endpoint to assert: * rename + pill add/edit persist into the blob and bump the version; * removing a pill drops it from the row + fundraising_contacts, but the classic contacts directory entry is NOT hard-deleted (soft-delete-only convention); * preserved pill fields (title/city/linkedin) survive an edit that only touches the name; * the OTHER grid row is untouched (no whole-grid clobber); * guards: missing row_id -> 400, unknown row_id -> 404, blank name -> 400, no-op body (neither field) -> 400. Synthetic data only (guardrail #9). Run: cd backend && python3 test_fundraising_update_row.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 _post(port, path, token, payload): conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) conn.request("POST", path, body=json.dumps(payload), headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"}) resp = conn.getresponse() raw = resp.read().decode("utf-8", "replace") conn.close() data = json.loads(raw) if raw else None return resp.status, data GRID = { "columns": [ {"id": "investor_name", "label": "Investor Name", "type": "text"}, {"id": "contacts", "label": "Contacts", "type": "contacts"}, {"id": "notes", "label": "Notes", "type": "text"}, ], "rows": [ {"id": "row-1", "investor_name": "Acme Capital", "notes": "", "contacts": [ {"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner", "city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin", "linkedin_url": "https://linkedin.com/in/janedoe"}, {"name": "Bob Roe", "email": "bob@acme.com", "title": ""}, ]}, {"id": "row-2", "investor_name": "Beacon Fund", "notes": "untouched", "contacts": [{"name": "Carl Vane", "email": "carl@beacon.com"}]}, ], } def seed(): c = sqlite3.connect(os.environ["CRM_DB_PATH"]) c.row_factory = sqlite3.Row 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, updated_by) " "VALUES ('main', ?, '[]', 1, 'u1') " "ON CONFLICT(id) DO UPDATE SET grid_json=excluded.grid_json, views_json='[]', version=1", (json.dumps(GRID),)) server.sync_fundraising_relational(c, server.sanitize_fundraising_grid(GRID), [], actor_user_id="u1") c.commit() c.close() def blob_rows(): c = sqlite3.connect(os.environ["CRM_DB_PATH"]) c.row_factory = sqlite3.Row row = c.execute("SELECT grid_json, version FROM fundraising_state WHERE id='main'").fetchone() c.close() grid = json.loads(row["grid_json"]) by_id = {r["id"]: r for r in grid["rows"]} return by_id, int(row["version"]) 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: # ── rename + add a third contact, edit Bob's title, keep Jane unchanged ── print("\n[rename + pill add/edit]") new_contacts = [ # Jane: only her name is "re-sent"; the client preserves her other fields by spread. {"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner", "city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin", "linkedin_url": "https://linkedin.com/in/janedoe"}, {"name": "Bob Roe", "email": "bob@acme.com", "title": "Principal"}, # edited title {"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"}, # added ] st, data = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": "Acme Capital LLC", "contacts": new_contacts}) check(st == 200, f"update-row -> 200 (got {st})") by_id, version = blob_rows() check(version == 2, f"version bumped 1 -> 2 (got {version})") r1 = by_id.get("row-1", {}) check(r1.get("investor_name") == "Acme Capital LLC", f"name renamed in blob (got {r1.get('investor_name')!r})") names = [c.get("name") for c in r1.get("contacts", [])] check(names == ["Jane Doe", "Bob Roe", "Dana Fox"], f"three pills in order (got {names})") bob = next((c for c in r1["contacts"] if c["name"] == "Bob Roe"), {}) check(bob.get("title") == "Principal", f"Bob's title edited (got {bob.get('title')!r})") jane = next((c for c in r1["contacts"] if c["name"] == "Jane Doe"), {}) check(jane.get("linkedin_url") == "https://linkedin.com/in/janedoe" and jane.get("city") == "Austin", f"Jane's preserved fields survived (got {jane})") # ── the OTHER row is byte-for-byte untouched (no whole-grid clobber) ── print("\n[other row untouched]") r2 = by_id.get("row-2", {}) check(r2.get("investor_name") == "Beacon Fund" and r2.get("notes") == "untouched" and [c.get("name") for c in r2.get("contacts", [])] == ["Carl Vane"], f"row-2 unchanged (got {r2})") # ── relational sync: classic contacts directory now has Dana; Dana also in fundraising_contacts ── print("\n[relational + classic-contacts sync]") c = sqlite3.connect(os.environ["CRM_DB_PATH"]) c.row_factory = sqlite3.Row dana = c.execute("SELECT id, deleted_at FROM contacts WHERE lower(email)='dana@acme.com'").fetchone() check(bool(dana), "added contact Dana propagated to classic contacts directory") inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone() fc_names = {r["full_name"] for r in c.execute( "SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()} check(fc_names == {"Jane Doe", "Bob Roe", "Dana Fox"}, f"fundraising_contacts mirrors the three pills (got {fc_names})") c.close() # ── remove Bob: pill drops + fundraising_contacts drops, but classic contact NOT hard-deleted ── print("\n[remove pill is soft on the classic directory]") st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [ {"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner"}, {"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"}, ]}) check(st == 200, f"remove-pill update -> 200 (got {st})") by_id, version = blob_rows() names = [c.get("name") for c in by_id["row-1"].get("contacts", [])] check(names == ["Jane Doe", "Dana Fox"], f"Bob removed from the row (got {names})") check(version == 3, f"version bumped 2 -> 3 (got {version})") c = sqlite3.connect(os.environ["CRM_DB_PATH"]) c.row_factory = sqlite3.Row inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone() fc_names = {r["full_name"] for r in c.execute( "SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()} check("Bob Roe" not in fc_names, f"Bob dropped from fundraising_contacts (got {fc_names})") bob_classic = c.execute("SELECT id FROM contacts WHERE lower(email)='bob@acme.com'").fetchone() check(bool(bob_classic), "removing a pill does NOT hard-delete the classic contacts row (soft-delete only)") c.close() # ── name-only update (no contacts key) leaves the pill list intact ── print("\n[name-only update preserves contacts]") st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": "Acme Capital"}) check(st == 200, f"name-only update -> 200 (got {st})") by_id, _ = blob_rows() names = [c.get("name") for c in by_id["row-1"].get("contacts", [])] check(by_id["row-1"].get("investor_name") == "Acme Capital", "name updated again") check(names == ["Jane Doe", "Dana Fox"], f"contacts untouched by a name-only edit (got {names})") # ── a name-only pill (no email) is KEPT; a fully-blank pill is dropped ── # (locks _sanitize_fundraising_contacts's emptiness rule = name OR email, not AND.) print("\n[name-only pill kept, blank pill dropped]") st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [ {"name": "Jane Doe", "email": "jane@acme.com"}, {"name": "Erin Pope", "email": ""}, # name only -> kept {"name": "", "email": ""}, # fully blank -> dropped {"name": " ", "title": "Ghost"}, # whitespace-only name, no email -> dropped ]}) check(st == 200, f"name-only-pill update -> 200 (got {st})") by_id, _ = blob_rows() names = [c.get("name") for c in by_id["row-1"].get("contacts", [])] check(names == ["Jane Doe", "Erin Pope"], f"name-only pill kept, blank/whitespace pills dropped (got {names})") # ── guards ── print("\n[validation guards]") st, _ = _post(port, "/api/fundraising/update-row", token, {"investor_name": "No Id"}) check(st == 400, f"missing row_id -> 400 (got {st})") st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-nope", "investor_name": "Ghost"}) check(st == 404, f"unknown row_id -> 404 (got {st})") st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": " "}) check(st == 400, f"blank name -> 400 (got {st})") st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1"}) check(st == 400, f"no-op body (neither name nor contacts) -> 400 (got {st})") st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": "nope"}) check(st == 400, f"contacts wrong type -> 400 (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 (fundraising update-row: version-safe single-row name/pill edit)") if __name__ == "__main__": main()