7f9a15ebf3
The fundraising grid (canonical) now drives the classic opportunities Pipeline board, instead of the board being a disconnected second data-entry surface. An "Add to Pipeline" row action creates a durably-linked opportunity via the new opportunities.fundraising_investor_id (migration 0005, additive + reversible), reusing the grid's already-synced contact — retiring the POST /api/contacts side-door — and mapping the grid lead to the opp owner. Ownership is split so the two stay reconciled: the grid owns whether the link exists and the seed; the board owns stage/probability/owner. The link endpoint is idempotent (one live opp per investor; a re-link never reseeds funnel fields). "Is in pipeline?"/"what stage?" are derived from a live opp join and injected as read-only grid columns on read, stripped on write, so they never persist or dirty the autosave. Remove-from-pipeline soft-deletes the opp and leaves the grid row fully intact; deleting an investor from the grid archives its orphaned opp. Also fixes the standing soft-delete leak in handle_pipeline_report and the dashboard pipeline aggregates, which counted tombstoned opportunities. Tests: backend/test_grid_pipeline_link.py (link/idempotent/round-trip/guards/ unlink-intact/re-link/orphan/aggregates); 28/28 suite green, render-smoke green.
271 lines
14 KiB
Python
271 lines
14 KiB
Python
#!/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()
|