Files
ten31-database/backend/test_purge_soft_deleted.py
T
Keysat 1564c087bf Remove Instructions/Feedback + lp_profiles; sync retry, purge, mobile fixes (v0.1.0:104)
Removals (net -570 lines):
- Delete the Instructions and Feedback (feature_requests) pages + backend.
- Retire lp_profiles + investor_type across server, ingest, and seeds; migration
  0008 drops both empty tables (a sanctioned one-off exception to
  never-hard-delete). 0001's lp_profiles ALTER is removed so a fresh DB doesn't
  break the migration chain (live DBs already applied it).

Fixes:
- Email sync: a transient timeout no longer terminally parks a mailbox; the
  scheduler retries 'retrying' each cycle and re-includes errored accounts on an
  hourly backoff, so stuck mailboxes self-heal.
- Mobile Contacts: page through the full directory (server caps 500/page) -- one
  fetch silently truncated at 720, hiding people from the list and from search.
- Mobile email review: clock icon to set a reminder inline; approval cards show
  date/time.

New:
- Admin-only purge of soft-deleted rows (Settings -> Admin; type-to-confirm,
  refuses any row still linked to live data).

Tests: 45/45 (adds test_sync_ready + test_purge_soft_deleted). Reviewer pass
applied (NULL reminders.contact_id on contact purge). Bumped to v0.1.0:104.
2026-06-20 20:06:11 -05:00

158 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""Test for the admin soft-deleted purge (v0.1.0:104).
The purge is a deliberate, admin-only, type-to-confirm exception to never-hard-delete, for
clearing dummy/test data. It must be SAFE: only ever touch a soft-deleted row, and never
remove or mutate LIVE data via a cascade/SET-NULL. This boots the real server, seeds live +
soft-deleted graphs, and drives /api/admin/soft-deleted[/purge] over HTTP. Synthetic only.
Run: cd backend && python3 test_purge_soft_deleted.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 _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()
body = resp.read().decode("utf-8", "replace")
conn.close()
try:
return resp.status, (json.loads(body) if body else None)
except ValueError:
return resp.status, None
def _get(port, path, token):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request("GET", path, headers={"Authorization": "Bearer " + token})
resp = conn.getresponse()
body = resp.read().decode("utf-8", "replace")
conn.close()
try:
return resp.status, (json.loads(body) if body else None)
except ValueError:
return resp.status, None
def exists(table, rid):
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
n = c.execute(f"SELECT COUNT(*) FROM {table} WHERE id = ?", (rid,)).fetchone()[0]
c.close()
return n > 0
def seed():
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
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)")
# Soft-deleted contact with ONLY soft-deleted children -> purgeable; cascade should remove them.
c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) VALUES ('cClean','Dummy','Clean',?)", (DEL,))
c.execute("INSERT INTO opportunities (id,name,contact_id,owner_id,deleted_at) VALUES ('opC','Opp','cClean','u1',?)", (DEL,))
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmC','cClean','2026-05-01','u1','note',?)", (DEL,))
# A reminder pointing at the purge target (reminders.contact_id is a bare logical FK, no ON DELETE):
# the purge must NULL it, not leave it dangling and not delete the reminder.
c.execute("INSERT INTO reminders (id,contact_id,investor_id,title) VALUES ('remC','cClean','inv-x','Follow up dummy')")
# Soft-deleted contact WITH a live child -> must refuse (cascade would kill live data).
c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) VALUES ('cLiveKid','Has','Livekid',?)", (DEL,))
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLiveKid','2026-05-02','u1','live note')")
# A live contact -> must refuse (not soft-deleted).
c.execute("INSERT INTO contacts (id,first_name,last_name) VALUES ('cLive','Real','Person')")
# Soft-deleted org with no live refs -> purgeable.
c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgClean','Dead Org',?)", (DEL,))
# Soft-deleted org referenced by a LIVE contact -> must refuse (SET NULL would mutate live data).
c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgRef','Ref Org',?)", (DEL,))
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cRef','Org','Member','orgRef')")
c.commit()
c.close()
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:
print("\n[list soft-deleted]")
st, body = _get(port, "/api/admin/soft-deleted", token)
groups = (body or {}).get("groups", {})
cids = {x["id"] for x in groups.get("contacts", [])}
oids = {x["id"] for x in groups.get("organizations", [])}
check(st == 200, f"GET soft-deleted -> 200 (got {st})")
check({"cClean", "cLiveKid"} <= cids and "cLive" not in cids, f"lists soft-deleted contacts only (got {cids})")
check({"orgClean", "orgRef"} <= oids, f"lists soft-deleted orgs (got {oids})")
check("opC" in {x["id"] for x in groups.get("opportunities", [])}, "lists the soft-deleted opportunity")
print("\n[purge guards]")
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cLive"})
check(st == 400, f"purge a LIVE contact -> 400 (got {st})")
check(exists("contacts", "cLive"), "live contact still present after refused purge")
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cLiveKid"})
check(st == 409, f"purge contact with a LIVE child -> 409 (got {st})")
check(exists("contacts", "cLiveKid") and exists("communications", "cmLive"), "contact + its live child preserved")
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "organizations", "id": "orgRef"})
check(st == 409, f"purge org referenced by a LIVE contact -> 409 (got {st})")
check(exists("organizations", "orgRef") and exists("contacts", "cRef"), "org + its live referencing contact preserved")
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "bogus", "id": "x"})
check(st == 400, f"unknown table -> 400 (got {st})")
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "nope"})
check(st == 404, f"missing id -> 404 (got {st})")
print("\n[purge happy path + cascade]")
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cClean"})
check(st == 200, f"purge a clean soft-deleted contact -> 200 (got {st})")
check(not exists("contacts", "cClean"), "purged contact is gone")
check(not exists("opportunities", "opC") and not exists("communications", "cmC"),
"its soft-deleted children were cascade-removed")
_rc = sqlite3.connect(os.environ["CRM_DB_PATH"])
_rem = _rc.execute("SELECT contact_id FROM reminders WHERE id = 'remC'").fetchone()
_rc.close()
check(_rem is not None and _rem[0] is None,
"a reminder on the purged contact is KEPT but its contact_id is NULL'd (no dangling ref)")
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "organizations", "id": "orgClean"})
check(st == 200, f"purge a clean soft-deleted org -> 200 (got {st})")
check(not exists("organizations", "orgClean"), "purged org is gone")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"{len(FAILS)} FAILED")
sys.exit(1)
print("ALL PASS (soft-deleted purge)")
if __name__ == "__main__":
main()