"""Opt-in debug persist for the admin test spur. Run from backend/: python tests/test_debug_persist.py""" from __future__ import annotations import json import os import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] REPO = ROOT.parent sys.path.insert(0, str(ROOT)) os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-debug-persist-test.sqlite") os.environ["KANSHO_MEDIA_ROOT"] = str(Path(tempfile.gettempdir()) / "kansho-debug-persist-media") os.environ["KANSHO_FAKE_PROVIDER"] = "1" os.environ["KANSHO_FAKE_DETECT"] = "1" Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True) Path(os.environ["KANSHO_MEDIA_ROOT"]).mkdir(parents=True, exist_ok=True) from fastapi.testclient import TestClient from identity_store import remember_mapping from main import app from privacy_gateway import reset_debug def expect(ok: bool, message: str) -> None: if not ok: raise SystemExit(f"FAIL: {message}") print(f"OK {message}") def header(token: str) -> dict: return {"X-Auth-Token": token} def open_space_day(client, headers, title="Alltag", date="2026-08-28"): space = client.post("/api/journal/spaces", headers=headers, json={"title": title}) expect(space.status_code == 200, f"create space {space.status_code}") day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": date}, ) expect(day.status_code == 200, f"open day {day.status_code}") return space.json(), day.json() def blob(data) -> str: return json.dumps(data, ensure_ascii=False) def main() -> None: reset_debug() ui = (REPO / "frontend" / "src" / "pages" / "AdminDebugPage.jsx").read_text(encoding="utf-8") expect("Debug persistieren" in ui, "admin page has the persist toggle") expect("Space / Tag / Gespräche" in ui, "admin page uses space/day/conversation structure") expect("Gespräch-JSON" in ui, "admin page can download one conversation") expect("JSON herunterladen" not in ui, "admin page no longer uses a flat download label as primary") nav = (REPO / "frontend" / "src" / "config" / "adminNav.js").read_text(encoding="utf-8") expect("/admin/debug" in nav, "admin nav lists debug") dialogue_ui = (REPO / "frontend" / "src" / "pages" / "DialoguePage.jsx").read_text(encoding="utf-8") expect("CallTrace" not in dialogue_ui, "dialogue page no longer shows the live test spur") expect("Debug herunterladen" in dialogue_ui, "dialogue page can download the conversation debug log") day_ui = (REPO / "frontend" / "src" / "pages" / "JournalDayPage.jsx").read_text(encoding="utf-8") expect("import CallTrace" not in day_ui, "journal day does not render CallTrace under the thread") expect("Debug herunterladen" in day_ui, "journal day can download the conversation debug log") editor_ui = (REPO / "frontend" / "src" / "pages" / "JournalEditorPage.jsx").read_text(encoding="utf-8") expect("import CallTrace" in editor_ui, "journal editor still shows the generate trace") expect("

Verlauf

" in editor_ui, "journal editor names the generate trace") expect("Debug herunterladen" in editor_ui, "journal editor can download the generate debug log") expect("purpose=journal_generate" in editor_ui, "journal editor downloads the generate scope") with TestClient(app) as client: setup = client.post( "/api/auth/setup", json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"}, ) expect(setup.status_code == 200, f"setup {setup.status_code}") headers = header(setup.json()["token"]) profile_id = setup.json()["profile_id"] other = client.post( "/api/users", headers=headers, json={"email": "ute@example.test", "name": "Ute", "password": "user-pass", "role": "user"}, ) expect(other.status_code == 200, "second profile") other_headers = header( client.post("/api/auth/login", json={"email": "ute@example.test", "password": "user-pass"}).json()["token"] ) settings = client.get("/api/admin/debug", headers=headers) expect(settings.status_code == 200, "admin can read debug settings") expect(settings.json()["persist_enabled"] is False, "persist is off by default") expect(settings.json()["run_count"] == 0, "no runs at start") denied = client.get("/api/admin/debug", headers=other_headers) expect(denied.status_code == 403, "non-admin cannot read debug settings") space, day = open_space_day(client, headers) day_id = day["day"]["id"] conv = client.post( f"/api/journal/days/{day_id}/conversations", headers=headers, json={"title": "Gespräch"}, ) expect(conv.status_code == 200, f"start conversation {conv.status_code}") conv_id = conv.json()["id"] reset_debug() turn = client.post( f"/api/journal/conversations/{conv_id}/turn", headers=headers, json={"body": "Heute war der Markt voll. Ich habe Kirschen gekauft."}, ) expect(turn.status_code == 200, f"turn while persist off {turn.status_code}") listed = client.get("/api/admin/debug/runs", headers=headers) expect(listed.json()["runs"] == [], "off means no persisted steps") enabled = client.put("/api/admin/debug", headers=headers, json={"persist_enabled": True}) expect(enabled.status_code == 200, "enable persist") expect(enabled.json()["persist_enabled"] is True, "persist flag stored") remember_mapping(profile_id, "Anna") reset_debug() first = client.post( f"/api/journal/conversations/{conv_id}/turn", headers=headers, json={"body": "Anna war am Stand. Die Kirschen waren reif."}, ) expect(first.status_code == 200, f"first persisted turn {first.status_code}") reset_debug() second = client.post( f"/api/journal/conversations/{conv_id}/turn", headers=headers, json={"body": "Später noch der Hafen, ungefähr 18:00 Uhr."}, ) expect(second.status_code == 200, f"second persisted turn {second.status_code}") listed = client.get("/api/admin/debug/runs", headers=headers) expect(listed.status_code == 200, "list runs") runs = listed.json()["runs"] expect(len(runs) >= 2, "each turn is stored") expect(all(item.get("purpose") == "dialogue_turn" for item in runs[:2]), "listed steps are dialogue turns") newest = client.get(f"/api/admin/debug/runs/{runs[0]['id']}", headers=headers) expect(newest.status_code == 200, "read run detail") payload = newest.json().get("payload") or {} stored = blob(payload) expect("local_label" not in stored, "mapping labels are not persisted") expect("mapping_table" not in stored, "mapping table is not persisted") expect("replacements" not in stored, "replacement objects are not persisted") details = [ client.get(f"/api/admin/debug/runs/{item['id']}", headers=headers).json() for item in runs[:2] ] expect( any("Hafen" in blob(item.get("payload") or {}) for item in details), "intern keeps the step text", ) hafen = next(item.get("payload") or {} for item in details if "Hafen" in blob(item.get("payload") or {})) expect((hafen.get("trace") or {}).get("egress") or (hafen.get("call_traces")), "egress of the step is stored") expect((hafen.get("user") or {}).get("body"), "user line of the step is stored") expect((hafen.get("assistant") or {}).get("body"), "assistant line of the step is stored") expect(hafen.get("call_traces") or (hafen.get("trace") or {}).get("egress"), "gateway calls of the step are stored") user_space = client.post("/api/journal/spaces", headers=other_headers, json={"title": "Utes Tag"}) user_day = client.post( f"/api/journal/spaces/{user_space.json()['id']}/days", headers=other_headers, json={"calendar_date": "2026-08-28"}, ) user_conv = client.post( f"/api/journal/days/{user_day.json()['day']['id']}/conversations", headers=other_headers, json={"title": "Gespräch"}, ) user_turn = client.post( f"/api/journal/conversations/{user_conv.json()['id']}/turn", headers=other_headers, json={"body": "Utes privater Satz bleibt ohne Debug-Speicher."}, ) expect(user_turn.status_code == 200, "non-admin can still talk") after_user = client.get("/api/admin/debug/runs", headers=headers).json()["runs"] expect(len(after_user) == len(runs), "non-admin turns are not persisted") expect( not any("Utes privater Satz" in blob(item) for item in after_user), "admin list does not contain the other profile", ) reset_debug() gen = client.post( f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv_id]}, ) expect(gen.status_code == 200, f"generate {gen.status_code}") after_gen = client.get("/api/admin/debug/runs", headers=headers).json()["runs"] expect(any(item.get("purpose") == "journal_generate" for item in after_gen), "journal generate is a stored step") exported = client.get("/api/admin/debug/export?format=json", headers=headers) expect(exported.status_code == 200, "json export") expect("attachment" in (exported.headers.get("content-disposition") or ""), "json is a download") document = exported.json() expect(document.get("kind") == "kansho.debug_export", "export names the contract") expect(document.get("version") == 2, "export version is nested tree") expect(document.get("scope") == "all", "full export is unscoped") expect(document.get("run_count") == len(document.get("runs") or []), "run_count matches runs") expect(len(document.get("runs") or []) >= 3, "export contains the dialogue and generate steps") expect((document.get("spaces") or []), "full export has space tree") space = (document.get("spaces") or [{}])[0] expect(space.get("title") == "Alltag", "tree names the space") day_node = (space.get("days") or [{}])[0] expect(day_node.get("calendar_date") == "2026-08-28", "tree names the day") conv_node = (day_node.get("conversations") or [{}])[0] expect(len(conv_node.get("steps") or []) >= 2, "tree keeps dialogue steps on the conversation") expect(day_node.get("generates"), "journal generate sits on the day") expect("local_label" not in blob(document), "export has no mapping labels") created = [item.get("created") for item in document["runs"]] expect(created == sorted(created), "export is chronological") tree = client.get("/api/admin/debug/tree", headers=headers) expect(tree.status_code == 200, "tree endpoint") tree_space = (tree.json().get("spaces") or [{}])[0] expect(tree_space.get("title") == "Alltag", "live tree names the space") tree_conv = ((tree_space.get("days") or [{}])[0].get("conversations") or [{}])[0] expect(tree_conv.get("id") == conv_id, "live tree points at the conversation") expect(tree_conv.get("step_count") >= 2, "live tree counts steps") conv_export = client.get( f"/api/admin/debug/export?format=json&conversation_id={conv_id}", headers=headers, ) expect(conv_export.status_code == 200, "conversation export") conv_doc = conv_export.json() expect(conv_doc.get("scope") == "conversation", "conversation export is scoped") expect(conv_doc.get("conversation", {}).get("id") == conv_id, "conversation export names the dialogue") expect( all(item.get("purpose") == "dialogue_turn" for item in conv_doc.get("runs") or []), "conversation export is the dialogue steps, not the day generate", ) expect(len(conv_doc.get("runs") or []) >= 2, "conversation export has both turns") expect("conversation" in (conv_export.headers.get("content-disposition") or ""), "conversation filename is distinct") latest = client.get( f"/api/admin/debug/latest?journal_day_id={day_id}&purpose=journal_generate", headers=headers, ) expect(latest.status_code == 200, "latest generate endpoint") latest_run = latest.json().get("run") or {} expect(latest_run.get("purpose") == "journal_generate", "latest run is the journal generate") expect(latest_run.get("journal_day_id") == day_id, "latest generate belongs to the day") expect((latest_run.get("trace") or {}).get("purpose") == "journal_generate" or (latest_run.get("trace") or {}).get("stages"), "latest generate keeps the trace") gen_export = client.get( f"/api/admin/debug/export?format=json&journal_day_id={day_id}&purpose=journal_generate", headers=headers, ) expect(gen_export.status_code == 200, "generate export") gen_doc = gen_export.json() expect(gen_doc.get("scope") == "journal_generate", "generate export is scoped") expect( all(item.get("purpose") == "journal_generate" for item in gen_doc.get("runs") or []), "generate export is only the journal draft steps", ) expect(len(gen_doc.get("runs") or []) >= 1, "generate export has the draft run") expect("journal" in (gen_export.headers.get("content-disposition") or ""), "generate filename is distinct") markdown = client.get("/api/admin/debug/export?format=markdown", headers=headers) expect(markdown.status_code == 200, "markdown export") text = markdown.text expect("Kanshō Debug-Export" in text, "markdown has a title") expect("Intern (Klartext-Vorlage, nicht gesendet)" in text, "markdown has intern section") expect("Egress (maskiert, gesendet)" in text, "markdown has egress section") expect("dialogue_turn" in text, "markdown lists dialogue steps") expect("journal_generate" in text, "markdown lists generate steps") bad = client.get("/api/admin/debug/export?format=csv", headers=headers) expect(bad.status_code == 400, "unknown export format is rejected") disabled = client.put("/api/admin/debug", headers=headers, json={"persist_enabled": False}) expect(disabled.json()["persist_enabled"] is False, "persist can be turned off") count_before = len(client.get("/api/admin/debug/runs", headers=headers).json()["runs"]) reset_debug() later = client.post( f"/api/journal/conversations/{conv_id}/turn", headers=headers, json={"body": "Noch ein Satz ohne Persistenz."}, ) expect(later.status_code == 200, "turn after disable") count_after = len(client.get("/api/admin/debug/runs", headers=headers).json()["runs"]) expect(count_after == count_before, "disable stops new writes and keeps history") run_id = client.get("/api/admin/debug/runs", headers=headers).json()["runs"][0]["id"] removed = client.delete(f"/api/admin/debug/runs/{run_id}", headers=headers) expect(removed.status_code == 200, "delete one run") cleared = client.post("/api/admin/debug/runs/clear", headers=headers) expect(cleared.status_code == 200, "clear remaining runs") expect(client.get("/api/admin/debug/runs", headers=headers).json()["runs"] == [], "history empty after clear") health = client.get("/api/admin/health", headers=headers) expect(health.json()["inventory"]["debug_persist_enabled"] is False, "health shows persist off") expect(health.json()["inventory"]["debug_runs"] == 0, "health shows empty debug count") print("All debug persist tests passed.") if __name__ == "__main__": main()