1564c087bf
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.
158 lines
7.6 KiB
Python
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()
|