f181525926
First-class reminders tied to the fundraising grid — foundation of the agreed reminders -> NL-search -> bot-mutations plan (keep LP data off third-party LLMs). - reminders table (migration 0006; logical FK to fundraising_investors.id + denormalized name), CRUD at /api/reminders (soft-delete; open/done/snoozed/ cancelled; assignee; source; source_row_id resolution) - read-only derived reminder_status grid column (overdue/due_soon/open), filterable; orphan reconciler cancels reminders when an investor leaves the grid - Reminders page, Dashboard "Reminders Due" card, daily-digest reminders section - per-investor last_activity_at recency rollup (shared block for the W2 NL query) - tests: test_reminders.py + digest reminders test (31/31 green, render-smoke green)
297 lines
15 KiB
Python
297 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for reminders / follow-ups (W1).
|
|
|
|
Boots the REAL server against a temp DB and exercises the new endpoints end-to-end:
|
|
- POST /api/reminders creates an open reminder tied to a grid investor (denormalized
|
|
investor_name resolved from the grid), or a standalone task (no investor_id);
|
|
- GET /api/reminders lists + filters by status (active/open/done/...), overdue, investor_id,
|
|
assignee=me; every read is soft-delete filtered;
|
|
- PATCH completes (stamps completed_at) / snoozes / edits a reminder; status is validated;
|
|
- DELETE soft-deletes (gone from every list, never hard-deleted);
|
|
- GET /api/fundraising/state injects a read-only reminder_status (overdue/due_soon/open/'')
|
|
derived live from open reminders, and strips it on save (never persisted to the blob);
|
|
- deleting an investor from the grid cancels its orphaned reminders (reconcile twin),
|
|
while a standalone reminder is untouched;
|
|
- the usual guards (missing title -> 400, bad status -> 400, unknown investor -> 404,
|
|
unauthenticated -> 401).
|
|
Synthetic data only.
|
|
|
|
Run: cd backend && python3 test_reminders.py
|
|
"""
|
|
import http.client
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from datetime import datetime, timedelta
|
|
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 = []
|
|
|
|
TODAY = datetime.utcnow().date()
|
|
TOMORROW = (TODAY + timedelta(days=1)).isoformat()
|
|
YESTERDAY = (TODAY - timedelta(days=1)).isoformat()
|
|
FAR = (TODAY + timedelta(days=30)).isoformat()
|
|
|
|
|
|
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", "notes": "",
|
|
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com"}]}
|
|
ROW_GAMMA = {"id": "rowGamma", "investor_name": "Gamma Partners", "notes": "",
|
|
"contacts": [{"name": "Sam Lee", "email": "sam@gamma.com"}]}
|
|
|
|
|
|
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 _investor_id(source_row_id):
|
|
c = _db()
|
|
r = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id = ?", (source_row_id,)).fetchone()
|
|
c.close()
|
|
return r[0] if r else None
|
|
|
|
|
|
def _grid_reminder_status(port, token):
|
|
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
|
rows = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
|
|
return {r["id"]: r.get("reminder_status") for r in rows}
|
|
|
|
|
|
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_GAMMA])
|
|
check(st == 200, f"seed grid via PUT /state (got {st})")
|
|
acme_id = _investor_id("rowAcme")
|
|
beta_id = _investor_id("rowBeta")
|
|
gamma_id = _investor_id("rowGamma")
|
|
check(bool(acme_id and beta_id and gamma_id), "investor ids resolved from the grid")
|
|
|
|
# ── create: investor-linked + denormalized name resolved from the grid ──
|
|
print("\n[create: investor-linked reminder resolves the denormalized name]")
|
|
st, d = _req(port, "POST", "/api/reminders", token,
|
|
{"investor_id": acme_id, "title": "Send Fund III deck", "due_date": TOMORROW})
|
|
rem = (d or {}).get("data") or {}
|
|
acme_rem_id = rem.get("id")
|
|
check(st == 201 and rem.get("status") == "open", f"create -> 201 open (got {st}, {d})")
|
|
check(rem.get("investor_name") == "Acme Capital", f"name denormalized from grid (got {rem.get('investor_name')})")
|
|
|
|
# overdue reminder on Beta; far-future + standalone for filter coverage
|
|
st, d = _req(port, "POST", "/api/reminders", token,
|
|
{"investor_id": beta_id, "title": "Call Pat", "due_date": YESTERDAY})
|
|
beta_rem_id = (d or {}).get("data", {}).get("id")
|
|
check(st == 201, f"create overdue beta reminder (got {st})")
|
|
st, d = _req(port, "POST", "/api/reminders", token,
|
|
{"investor_id": gamma_id, "title": "Quarterly touch", "due_date": FAR})
|
|
gamma_rem_id = (d or {}).get("data", {}).get("id")
|
|
st, d = _req(port, "POST", "/api/reminders", token, {"title": "Team: refresh pipeline view"})
|
|
standalone_id = (d or {}).get("data", {}).get("id")
|
|
check(st == 201 and (d or {}).get("data", {}).get("investor_id") in (None, ""),
|
|
f"standalone reminder (no investor) created (got {st})")
|
|
|
|
# ── list + filters ──
|
|
print("\n[list + filters]")
|
|
st, d = _req(port, "GET", "/api/reminders", token)
|
|
items = (d or {}).get("data", [])
|
|
check(st == 200 and len(items) == 4, f"list returns all 4 open (got {st}, {len(items)})")
|
|
check(all("last_activity_at" in it for it in items), "each row carries last_activity_at")
|
|
# dated reminders sort before undated, soonest first -> YESTERDAY (beta) leads
|
|
check(items and items[0].get("id") == beta_rem_id, f"overdue sorts first (got {items[0].get('id') if items else None})")
|
|
|
|
st, d = _req(port, "GET", "/api/reminders?overdue=1", token)
|
|
ids = [it["id"] for it in (d or {}).get("data", [])]
|
|
check(ids == [beta_rem_id], f"overdue=1 -> only the past-due one (got {ids})")
|
|
# overdue owns the status constraint: a conflicting status= must not silently zero out
|
|
st, d = _req(port, "GET", "/api/reminders?overdue=1&status=done", token)
|
|
ids = [it["id"] for it in (d or {}).get("data", [])]
|
|
check(ids == [beta_rem_id], f"overdue wins over a conflicting status= (got {ids})")
|
|
|
|
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
|
|
ids = [it["id"] for it in (d or {}).get("data", [])]
|
|
check(ids == [acme_rem_id], f"investor_id filter (got {ids})")
|
|
|
|
st, d = _req(port, "GET", "/api/reminders?assignee=me", token)
|
|
check(len(d.get("data", [])) == 0, "assignee=me -> none (reminders created unassigned)")
|
|
|
|
# ── grid injection: reminder_status derived live, never persisted ──
|
|
print("\n[grid injection: read-only reminder_status]")
|
|
s = _grid_reminder_status(port, token)
|
|
check(s.get("rowAcme") == "due_soon", f"due-tomorrow -> due_soon (got {s.get('rowAcme')})")
|
|
check(s.get("rowBeta") == "overdue", f"past-due -> overdue (got {s.get('rowBeta')})")
|
|
check(s.get("rowGamma") == "open", f"far-future -> open (got {s.get('rowGamma')})")
|
|
# echo the injected value back on save; it must NOT persist into the blob
|
|
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
|
echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
|
|
st, _ = _put_grid(port, token, echoed)
|
|
c = _db()
|
|
blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
|
|
c.close()
|
|
acme_stored = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
|
|
check("reminder_status" not in acme_stored, "reminder_status not persisted into the grid blob")
|
|
|
|
# ── complete / reopen stamps completed_at ──
|
|
print("\n[complete + reopen]")
|
|
st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "done"})
|
|
check(st == 200 and (d or {}).get("data", {}).get("status") == "done"
|
|
and (d or {}).get("data", {}).get("completed_at"), f"done stamps completed_at (got {d})")
|
|
st, d = _req(port, "GET", "/api/reminders?status=open", token)
|
|
check(acme_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "done reminder drops from status=open")
|
|
st, d = _req(port, "GET", "/api/reminders?status=done", token)
|
|
check(acme_rem_id in [it["id"] for it in (d or {}).get("data", [])], "done reminder shows under status=done")
|
|
check(_grid_reminder_status(port, token).get("rowAcme") in (None, ""), "completed reminder clears the grid chip")
|
|
st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "open"})
|
|
check((d or {}).get("data", {}).get("completed_at") in (None, ""), "reopen clears completed_at")
|
|
|
|
# ── snooze: out of 'open', still in 'active' ──
|
|
print("\n[snooze]")
|
|
st, d = _req(port, "PATCH", f"/api/reminders/{beta_rem_id}", token,
|
|
{"status": "snoozed", "snoozed_until": FAR})
|
|
check(st == 200 and (d or {}).get("data", {}).get("status") == "snoozed", f"snooze (got {st})")
|
|
st, d = _req(port, "GET", "/api/reminders?status=open", token)
|
|
check(beta_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "snoozed drops from status=open")
|
|
st, d = _req(port, "GET", "/api/reminders?status=active", token)
|
|
check(beta_rem_id in [it["id"] for it in (d or {}).get("data", [])], "snoozed stays in status=active")
|
|
|
|
# ── edit title + due date ──
|
|
print("\n[edit]")
|
|
st, d = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token,
|
|
{"title": "Quarterly touch — Q3", "due_date": TOMORROW})
|
|
check((d or {}).get("data", {}).get("title") == "Quarterly touch — Q3"
|
|
and (d or {}).get("data", {}).get("due_date") == TOMORROW, f"title+due edited (got {d})")
|
|
|
|
# ── soft-delete: gone from every list, tombstoned not hard-deleted ──
|
|
print("\n[soft-delete]")
|
|
st, d = _req(port, "DELETE", f"/api/reminders/{standalone_id}", token)
|
|
check(st == 200 and (d or {}).get("data", {}).get("deleted") is True, f"delete -> 200 (got {st})")
|
|
st, d = _req(port, "GET", "/api/reminders", token)
|
|
check(standalone_id not in [it["id"] for it in (d or {}).get("data", [])], "deleted reminder hidden from list")
|
|
c = _db()
|
|
gone = c.execute("SELECT deleted_at FROM reminders WHERE id = ?", (standalone_id,)).fetchone()[0]
|
|
c.close()
|
|
check(gone is not None, "deleted reminder tombstoned (deleted_at set), not hard-deleted")
|
|
|
|
# ── orphan reconcile: drop the investor from the grid -> its reminders cancelled ──
|
|
print("\n[orphan reconcile: deleting the grid investor cancels its reminders]")
|
|
# create a fresh standalone task to confirm it is NOT cancelled by the reconciler
|
|
st, d = _req(port, "POST", "/api/reminders", token, {"title": "Standalone keeps living"})
|
|
keep_id = (d or {}).get("data", {}).get("id")
|
|
st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA]) # drop rowGamma
|
|
c = _db()
|
|
gamma_status = c.execute("SELECT status FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
|
|
keep_status = c.execute("SELECT status FROM reminders WHERE id = ?", (keep_id,)).fetchone()[0]
|
|
gamma_name = c.execute("SELECT investor_name FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
|
|
c.close()
|
|
check(gamma_status == "cancelled", f"orphaned investor's reminder cancelled (got {gamma_status})")
|
|
check(gamma_name == "Gamma Partners", "cancelled reminder keeps denormalized investor_name for history")
|
|
check(keep_status == "open", f"standalone reminder untouched by reconcile (got {keep_status})")
|
|
|
|
# ── recency rollup: a tombstoned email sighting must not inflate last_activity_at ──
|
|
print("\n[recency: soft-deleted email sighting excluded from last_activity_at]")
|
|
c = _db()
|
|
c.execute("INSERT INTO emails (id, rfc_message_id, from_email, sent_at, is_matched, match_status) "
|
|
"VALUES ('em1','<em1@x>','lp@acme.com','2026-06-10T00:00:00Z',1,'matched')")
|
|
c.execute("INSERT INTO email_investor_links (id, email_id, fundraising_investor_id, match_kind, match_confidence, matched_address) "
|
|
"VALUES ('eil1','em1',?, 'exact_email',1.0,'lp@acme.com')", (acme_id,))
|
|
c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
|
|
"VALUES ('eam1','em1','acct1','g1','t1','2026-06-11T00:00:00Z')") # tombstoned sighting only
|
|
c.commit(); c.close()
|
|
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
|
|
items = (d or {}).get("data", [])
|
|
la = items[0].get("last_activity_at") if items else "MISSING"
|
|
check(bool(items) and la is None, f"tombstoned-only email -> no last_activity (got {la})")
|
|
c = _db()
|
|
c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
|
|
"VALUES ('eam2','em1','acct2','g2','t2',NULL)")
|
|
c.commit(); c.close()
|
|
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
|
|
items = (d or {}).get("data", [])
|
|
la = items[0].get("last_activity_at") if items else "MISSING"
|
|
check(bool(items) and la == '2026-06-10T00:00:00Z', f"live sighting -> last_activity set (got {la})")
|
|
|
|
# ── guards ──
|
|
print("\n[guards]")
|
|
st, _ = _req(port, "POST", "/api/reminders", token, {"investor_id": acme_id})
|
|
check(st == 400, f"missing title -> 400 (got {st})")
|
|
st, _ = _req(port, "POST", "/api/reminders", token, {"title": "x", "investor_id": "nope"})
|
|
check(st == 404, f"unknown investor_id -> 404 (got {st})")
|
|
st, _ = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token, {"status": "bogus"})
|
|
check(st == 400, f"invalid status -> 400 (got {st})")
|
|
st, _ = _req(port, "DELETE", "/api/reminders/doesnotexist", token)
|
|
check(st == 404, f"delete unknown -> 404 (got {st})")
|
|
st, _ = _req(port, "GET", "/api/reminders", None)
|
|
check(st == 401, f"unauthenticated list -> 401 (got {st})")
|
|
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()
|