#!/usr/bin/env python3 """Tests for the grid → Pipeline link ("Adopt the Pipeline", v0.1.0:87). Boots the REAL server against a temp DB and exercises the new endpoints end-to-end: - POST /api/fundraising/pipeline/link creates exactly ONE opportunity, linked via opportunities.fundraising_investor_id, reusing the grid's synced contact (no POST /api/contacts side-door) and mapping the grid 'lead' -> owner; - the link is idempotent: a re-link returns the existing opp and NEVER reseeds its Pipeline-owned funnel fields (stage/probability) — the board owns those; - GET /api/fundraising/state injects read-only pipeline / pipeline_stage row values derived from the live opp; - linking a contactless row, or an unknown row, is refused; - POST .../unlink soft-deletes the opp (off the board, recoverable) while leaving the grid investor row fully intact; - deleting an investor from the grid archives its orphaned opp on the next save; - the pipeline report + dashboard aggregates exclude archived (soft-deleted) opps. Synthetic data only. Run: cd backend && python3 test_grid_pipeline_link.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 _req(port, method, path, token=None, body=None): conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) headers = {} if token: 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 _put_grid(port, token, rows): return _req(port, "PUT", "/api/fundraising/state", token, {"grid": {"columns": [], "rows": rows}, "views": []}) ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", "lead": "Grant", "contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]} ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital LLC", "notes": "", "lead": "", "contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]} ROW_EMPTY = {"id": "rowEmpty", "investor_name": "Empty LP", "notes": "", "contacts": []} def _db(): return sqlite3.connect(os.environ["CRM_DB_PATH"]) def seed(): c = _db() 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.commit() c.close() def _opp_count_live(fr_investor_id=None): c = _db() if fr_investor_id: n = c.execute("SELECT COUNT(*) FROM opportunities WHERE fundraising_investor_id = ? " "AND deleted_at IS NULL", (fr_investor_id,)).fetchone()[0] else: n = c.execute("SELECT COUNT(*) FROM opportunities WHERE deleted_at IS NULL").fetchone()[0] c.close() return n 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, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA, ROW_EMPTY]) check(st == 200, f"seed grid via PUT /state (got {st})") # ── link creates one linked opp with the seeds + resolved contact + mapped owner ── print("\n[link: creates one linked opportunity with seeds]") st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, { "source_row_id": "rowAcme", "fund_name": "Fund III", "expected_amount": 250000, "probability": 40, "stage": "outreach", }) opp = (d or {}).get("data") or {} check(st == 201 and (d or {}).get("already_linked") is False, f"link -> 201 new (got {st}, {d})") check(opp.get("stage") == "outreach" and opp.get("expected_amount") == 250000 and opp.get("probability") == 40 and opp.get("fund_name") == "Fund III", f"seeds applied (got {{stage:{opp.get('stage')}, amt:{opp.get('expected_amount')}, " f"prob:{opp.get('probability')}, fund:{opp.get('fund_name')}}})") check(opp.get("first_name") == "Jane", f"reused synced contact Jane Doe (got {opp.get('first_name')})") check(opp.get("owner_name") == "Grant", f"grid lead 'Grant' -> owner Grant (got {opp.get('owner_name')})") fr_id = opp.get("fundraising_investor_id") check(bool(fr_id), f"opportunity carries fundraising_investor_id (got {fr_id})") check(_opp_count_live(fr_id) == 1, "exactly one live opp linked to the investor") opp_id = opp.get("id") # ── idempotent re-link: returns existing, board-owned stage NOT reseeded ── print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]") st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "meeting"}) check(st == 200, f"advance stage on the board -> meeting (got {st})") st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, { "source_row_id": "rowAcme", "stage": "lead", "expected_amount": 999, "probability": 5, }) opp2 = (d or {}).get("data") or {} check(st == 200 and (d or {}).get("already_linked") is True, f"re-link -> already_linked (got {st}, {d})") check(opp2.get("stage") == "meeting" and opp2.get("expected_amount") == 250000, f"funnel fields preserved, not reseeded (got stage={opp2.get('stage')}, amt={opp2.get('expected_amount')})") check(_opp_count_live(fr_id) == 1, "still exactly one live opp (no duplicate)") # ── read-injection: GET state shows pipeline flag + stage, derived live ── print("\n[read-injection: GET /state exposes read-only pipeline + pipeline_stage]") st, d = _req(port, "GET", "/api/fundraising/state", token) rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])} check(rows.get("rowAcme", {}).get("pipeline") is True and rows.get("rowAcme", {}).get("pipeline_stage") == "meeting", f"rowAcme pipeline true @meeting (got {rows.get('rowAcme', {}).get('pipeline')}, " f"{rows.get('rowAcme', {}).get('pipeline_stage')})") check(rows.get("rowBeta", {}).get("pipeline") is False and rows.get("rowBeta", {}).get("pipeline_stage") == "", f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')})") # ── round-trip: a save echoing the injected read-only values is lossless ── print("\n[round-trip: PUT carrying injected pipeline values strips them, link intact]") st, d = _req(port, "GET", "/api/fundraising/state", token) echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", []) st, _ = _put_grid(port, token, echoed) # as the frontend autosave would, rows still carry pipeline* check(st == 200, f"echo-back save -> 200 (got {st})") check(_opp_count_live(fr_id) == 1, "link survives the round-trip (no dup, not archived)") c = _db() blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0]) c.close() stored_acme = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {}) check("pipeline" not in stored_acme and "pipeline_stage" not in stored_acme, "computed keys are NOT persisted into the grid blob") st, d = _req(port, "GET", "/api/fundraising/state", token) rt = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}.get("rowAcme", {}) check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "meeting", f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')})") # ── guards ── print("\n[guard: a contactless row cannot be added to the pipeline]") st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "rowEmpty"}) check(st == 400, f"no contact -> 400 (got {st}, {d})") check(_opp_count_live() == 1, "no stray opp created for the contactless row") print("\n[guard: unknown grid row -> 404]") st, _ = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "nope"}) check(st == 404, f"unknown row -> 404 (got {st})") print("\n[guard: unauthenticated -> 401]") st, _ = _req(port, "POST", "/api/fundraising/pipeline/link", None, {"source_row_id": "rowAcme"}) check(st == 401, f"no token -> 401 (got {st})") # ── the opp loads on the board + counts in the dashboard while live ── print("\n[board + dashboard count the live opp]") st, d = _req(port, "GET", "/api/opportunities?limit=1000", token) ids = [o["id"] for o in (d or {}).get("data", [])] check(opp_id in ids, "linked opp appears on the board") st, d = _req(port, "GET", "/api/reports/dashboard", token) active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities") check(active == 1, f"dashboard active_opportunities == 1 (got {active})") # ── unlink soft-deletes the opp; the GRID ROW stays fully intact ── print("\n[unlink: archives the opp, leaves the grid investor intact]") st, d = _req(port, "POST", "/api/fundraising/pipeline/unlink", token, {"source_row_id": "rowAcme"}) check(st == 200 and (d or {}).get("data", {}).get("archived") == 1, f"unlink -> archived 1 (got {st}, {d})") check(_opp_count_live(fr_id) == 0, "opp is no longer live (soft-deleted)") c = _db() gone = c.execute("SELECT deleted_at FROM opportunities WHERE id = ?", (opp_id,)).fetchone()[0] inv_still = c.execute("SELECT investor_name FROM fundraising_investors WHERE source_row_id = 'rowAcme'").fetchone() contact_still = c.execute("SELECT COUNT(*) FROM fundraising_contacts WHERE investor_id = ?", (fr_id,)).fetchone()[0] c.close() check(gone is not None, "opp row tombstoned (deleted_at set), not hard-deleted") check(inv_still and inv_still[0] == "Acme Capital", "grid investor row untouched by unlink") check(contact_still >= 1, "grid investor's contacts untouched by unlink") st, d = _req(port, "GET", "/api/opportunities?limit=1000", token) check(opp_id not in [o["id"] for o in (d or {}).get("data", [])], "archived opp left the board") st, d = _req(port, "GET", "/api/fundraising/state", token) rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])} check(rows.get("rowAcme", {}).get("pipeline") is False, "grid no longer flags rowAcme as in-pipeline") # ── aggregates exclude the archived opp ── print("\n[aggregates exclude archived opps]") st, d = _req(port, "GET", "/api/reports/dashboard", token) active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities") check(active == 0, f"dashboard active_opportunities back to 0 (got {active})") st, d = _req(port, "GET", "/api/reports/pipeline", token) by_stage = (d or {}).get("data", {}).get("by_stage", []) total = sum(s.get("count", 0) for s in by_stage) check(total == 0, f"pipeline report by_stage excludes archived (got total {total})") # ── re-link after unlink: a fresh opp is created (the archived one stays archived) ── print("\n[re-link after unlink: creates a new opp, flag reappears]") st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, { "source_row_id": "rowAcme", "stage": "outreach", "expected_amount": 50000, }) relinked = (d or {}).get("data") or {} check(st == 201 and (d or {}).get("already_linked") is False and relinked.get("id") != opp_id, f"re-link -> a NEW opp distinct from the archived one (got {st}, {relinked.get('id')} vs {opp_id})") check(_opp_count_live(fr_id) == 1, "exactly one live opp again after re-link") st, _ = _req(port, "POST", "/api/fundraising/pipeline/unlink", token, {"source_row_id": "rowAcme"}) check(st == 200, "reset: unlink the re-linked opp") # ── orphan reconciler: deleting the investor from the grid archives its opp ── print("\n[orphan: deleting the grid investor archives its linked opp on next save]") st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, { "source_row_id": "rowBeta", "stage": "lead", "expected_amount": 100000, }) beta = (d or {}).get("data") or {} beta_opp_id, beta_fr = beta.get("id"), beta.get("fundraising_investor_id") check(st == 201 and _opp_count_live(beta_fr) == 1, f"beta linked (got {st})") # drop rowBeta from the grid (keep the others) st, _ = _put_grid(port, token, [ROW_ACME, ROW_EMPTY]) check(st == 200, f"save grid without rowBeta (got {st})") check(_opp_count_live(beta_fr) == 0, "beta's orphaned opp archived by the reconciler") st, d = _req(port, "GET", "/api/opportunities?limit=1000", token) check(beta_opp_id not in [o["id"] for o in (d or {}).get("data", [])], "orphaned opp left the board") finally: httpd.shutdown() print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):")) for f in FAILS: print(" - " + f) sys.exit(1 if FAILS else 0) if __name__ == "__main__": main()