93ac0c240f
Grid detail (G6): replace the single row.notes blob with a NoteTimeline fed by a new investor-level read, GET /api/communications?source_row_id=<grid row id> (filter maps the grid row -> fundraising_investors.source_row_id -> fundraising_contacts.contact_id -> communications, soft-delete-respecting; cancelled-flag fetch + commsReload after a log). Note-logging now uses the shared LogCommunicationSheet, retiring the bespoke noteForm select sheet and the dead .fs-note-log style. New MobileQuickLog: a shell mobile-top-bar pencil reachable from every tab — two-step sheet (pick investor via search + recent-first pool -> inline log form) writing through the one-row /api/fundraising/log-communication path. source_row_id and contact_id are kept mutually exclusive in handle_list_communications so a future caller passing both can't get the empty intersection. Guarded by test_grid_comm_timeline.py (cross-contact aggregation, investor isolation, soft-delete); 39/39 backend green.
165 lines
7.0 KiB
Python
165 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Regression test for the Grid-detail communications timeline filter (Phase 8c, G6).
|
|
|
|
The mobile Grid detail's notes timeline pulls an investor-level communication stream
|
|
via GET /api/communications?source_row_id=<grid row id>. That filter (added to
|
|
handle_list_communications) maps the grid JSON row id → fundraising_investors.source_row_id
|
|
→ fundraising_contacts.contact_id → communications, so it must:
|
|
- return every communication across ALL the investor's contacts,
|
|
- stay isolated (one investor's row id never returns another's comms),
|
|
- respect soft-delete (cm.deleted_at IS NULL) through the join.
|
|
|
|
Boots the REAL server, seeds investors by driving the one-row log path (which creates the
|
|
grid row + contact + communication AND syncs the relational mirror the filter joins on),
|
|
then drives the live read path with a real token. Synthetic only (guardrail #9).
|
|
|
|
Run: cd backend && python3 test_grid_comm_timeline.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 = []
|
|
DEL = "2026-06-01T00:00:00"
|
|
|
|
|
|
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(method, port, path, token, body=None):
|
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
|
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
|
|
|
|
|
|
def _log_comm(port, token, investor_name, contact, subject, create=False):
|
|
"""Drive the one-row log path; returns (status, grid_row_id)."""
|
|
st, data = _req("POST", port, "/api/fundraising/log-communication", token, {
|
|
"investor_name": investor_name,
|
|
"create_investor_if_missing": create,
|
|
"contact": contact,
|
|
"type": "note",
|
|
"subject": subject,
|
|
"body": subject,
|
|
"append_note": True,
|
|
})
|
|
row_id = ((data or {}).get("data", {}).get("row") or {}).get("id")
|
|
return st, row_id
|
|
|
|
|
|
def main():
|
|
server.init_db()
|
|
conn = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
|
# password_hash is intentionally a non-bcrypt placeholder — we mint the token directly via
|
|
# create_token(), so the password-verify path is never exercised.
|
|
conn.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
|
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
|
conn.commit()
|
|
conn.close()
|
|
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:
|
|
# Investor A: two contacts, one communication per contact. Create seeds the row with
|
|
# Jane + logs "Intro call"; update-row adds John as a second pill (so the relational mirror
|
|
# links BOTH contacts to A's row); then a comm is logged against John. The timeline must
|
|
# aggregate across both contacts — the point of the source_row_id join over a single contact.
|
|
st, rowA = _log_comm(port, token, "Acme Capital",
|
|
{"name": "Jane Doe", "email": "jane@acme.example"}, "Intro call", create=True)
|
|
check(st == 201 and bool(rowA), f"create investor A via log path -> 201 + row id (got {st}, {rowA})")
|
|
st, _ = _req("POST", port, "/api/fundraising/update-row", token, {
|
|
"row_id": rowA, "investor_name": "Acme Capital",
|
|
"contacts": [{"name": "Jane Doe", "email": "jane@acme.example"},
|
|
{"name": "John Roe", "email": "john@acme.example"}],
|
|
})
|
|
check(st == 200, f"add John as a second contact on A via update-row (got {st})")
|
|
st, _ = _log_comm(port, token, "Acme Capital",
|
|
{"name": "John Roe", "email": "john@acme.example"}, "Follow-up email")
|
|
check(st == 201, f"second contact's comm logged on A (got {st})")
|
|
|
|
# Investor B: a separate investor, one communication (isolation control).
|
|
st, rowB = _log_comm(port, token, "Beacon Ventures",
|
|
{"name": "Sam Poe", "email": "sam@beacon.example"}, "Beacon note", create=True)
|
|
check(st == 201 and bool(rowB), f"create investor B via log path -> 201 + row id (got {st}, {rowB})")
|
|
|
|
# ── source_row_id returns the whole investor (across contacts) ──
|
|
print("\n[source_row_id timeline]")
|
|
st, data = _req("GET", port, f"/api/communications?source_row_id={rowA}", token)
|
|
subsA = {c.get("subject") for c in (data or {}).get("data", [])}
|
|
check(st == 200, f"GET timeline for A -> 200 (got {st})")
|
|
check(subsA == {"Intro call", "Follow-up email"},
|
|
f"A's timeline spans both contacts' comms (got {subsA})")
|
|
|
|
# ── isolation: A's row id never returns B's comms ──
|
|
print("\n[isolation]")
|
|
check("Beacon note" not in subsA, "A's timeline excludes investor B's comm")
|
|
st, dataB = _req("GET", port, f"/api/communications?source_row_id={rowB}", token)
|
|
subsB = {c.get("subject") for c in (dataB or {}).get("data", [])}
|
|
check(subsB == {"Beacon note"}, f"B's timeline is its own comm only (got {subsB})")
|
|
|
|
# ── soft-delete respected through the join ──
|
|
print("\n[soft-delete]")
|
|
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
|
c.execute("UPDATE communications SET deleted_at=? WHERE subject='Intro call'", (DEL,))
|
|
c.commit()
|
|
c.close()
|
|
st, data2 = _req("GET", port, f"/api/communications?source_row_id={rowA}", token)
|
|
subsA2 = {c.get("subject") for c in (data2 or {}).get("data", [])}
|
|
check(subsA2 == {"Follow-up email"},
|
|
f"soft-deleted comm filtered from A's timeline (got {subsA2})")
|
|
|
|
# ── unknown row id returns empty, not an error ──
|
|
st, data3 = _req("GET", port, "/api/communications?source_row_id=does-not-exist", token)
|
|
check(st == 200 and (data3 or {}).get("data") == [],
|
|
f"unknown source_row_id -> 200 + empty (got {st}, {(data3 or {}).get('data')})")
|
|
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 comm timeline source_row_id filter)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|