Mobile P3b: investor name + contact-pill editing (update-row)

Adds the editable half of BRIEF §3a's mobile grid set: rename an investor
and add/edit/remove its contact pills from the mobile detail sheet.

New POST /api/fundraising/update-row is the one-row read-fresh-modify-write
twin of log-communication: it mutates only the target row's name/contacts in
the canonical grid blob server-side, then bumps the version + re-syncs the
relational tables. It never accepts a whole-grid payload, so a stale mobile
client can't clobber concurrent edits to other rows (the reason mobile avoids
the whole-grid PUT). _sanitize_fundraising_contacts whitelists the known pill
fields as the trust boundary; removing a pill is soft on the classic contacts
directory (only the grid pill + fundraising_contacts row drop).

Frontend: MobileFundraisingGrid gains an Edit bottom-sheet (name input + pill
editor with client-side dedup); money stays desktop-only. New CSS is
theme-var-only so it flips in light mode.

Verified: test_fundraising_update_row.py (24 assertions, real HTTP), full
suite 37/37, render-smoke + a 375px jsdom interaction harness green.
This commit is contained in:
Keysat
2026-06-19 17:07:29 -05:00
parent 099d87dad2
commit 3f93daf28e
4 changed files with 436 additions and 27 deletions
+227
View File
@@ -0,0 +1,227 @@
#!/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()