312 lines
16 KiB
Python
312 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))
|
|
|
|
from tests.harness import configure_test_engine
|
|
|
|
configure_test_engine()
|
|
|
|
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_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()
|