Kansho/backend/tests/test_debug_persist.py
2026-08-29 11:04:34 +02:00

310 lines
16 KiB
Python

"""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("<h2>Verlauf</h2>" 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()