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:
@@ -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()
|
||||
Reference in New Issue
Block a user