700 lines
34 KiB
Python
700 lines
34 KiB
Python
"""MVP journal acceptance cases A–K. Run from backend/: python tests/test_mvp_journal.py"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-mvp-journal-test.sqlite")
|
||
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
|
||
os.environ["KANSHO_FAKE_DETECT"] = "1"
|
||
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
|
||
|
||
from fastapi.testclient import TestClient
|
||
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}
|
||
|
||
|
||
PNG = bytes.fromhex(
|
||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||
"0000000a49444154789c6360000002000100ffff03000006000557bf0000000049454e44ae426082"
|
||
)
|
||
|
||
|
||
def setup_client() -> tuple[TestClient, dict, str]:
|
||
client = TestClient(app)
|
||
client.__enter__()
|
||
setup = client.post(
|
||
"/api/auth/setup",
|
||
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
|
||
)
|
||
token = setup.json()["token"]
|
||
return client, header(token), setup.json()["profile_id"]
|
||
|
||
|
||
def open_space_day(client, headers, title="Alltag", date="2026-08-20"):
|
||
space = client.post("/api/journal/spaces", headers=headers, json={"title": title})
|
||
expect(space.status_code == 200, f"create space {space.text}")
|
||
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.text}")
|
||
return space.json(), day.json()
|
||
|
||
|
||
def start_and_turn(client, headers, day_id, body, title="Gespräch"):
|
||
conv = client.post(
|
||
f"/api/journal/days/{day_id}/conversations",
|
||
headers=headers,
|
||
json={"title": title},
|
||
)
|
||
expect(conv.status_code == 200, f"start conversation {conv.text}")
|
||
reset_debug()
|
||
turn = client.post(
|
||
f"/api/journal/conversations/{conv.json()['id']}/turn",
|
||
headers=headers,
|
||
json={"body": body},
|
||
)
|
||
expect(turn.status_code == 200, f"turn {turn.text}")
|
||
return conv.json(), turn.json()
|
||
|
||
|
||
def main() -> None:
|
||
reset_debug()
|
||
client, headers, _profile_id = setup_client()
|
||
try:
|
||
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_login = client.post("/api/auth/login", json={"email": "ute@example.test", "password": "user-pass"})
|
||
other_headers = header(other_login.json()["token"])
|
||
|
||
space, day = open_space_day(client, headers, "Alltag", "2026-08-20")
|
||
space_id, day_id = space["id"], day["day"]["id"]
|
||
expect(day["day"]["calendar_date"] == "2026-08-20", "calendar date stored from client")
|
||
again = client.post(
|
||
f"/api/journal/spaces/{space_id}/days",
|
||
headers=headers,
|
||
json={"calendar_date": "2026-08-20"},
|
||
)
|
||
expect(again.json()["day"]["id"] == day_id, "unique space+date")
|
||
|
||
hidden = client.get(f"/api/journal/spaces/{space_id}", headers=other_headers)
|
||
expect(hidden.status_code == 404, "space isolated by profile")
|
||
|
||
conv, turn = start_and_turn(
|
||
client,
|
||
headers,
|
||
day_id,
|
||
"Heute war der Markt voll. Ich bin früh gegangen und habe Kirschen gekauft.",
|
||
)
|
||
expect(turn["user"]["body"].startswith("Heute war der Markt"), "A source user persisted")
|
||
expect(turn["assistant"]["role"] == "assistant", "A assistant persisted")
|
||
expect("Was davon" in turn["assistant"]["body"], "A impulse is visible text")
|
||
expect("{" not in turn["assistant"]["body"], "A raw json not shown as message")
|
||
expect(turn["decision"]["operation"] == "erleben_vertiefen", "A operation recorded")
|
||
expect(turn["trace"]["egress"], "A admin sees egress")
|
||
expect(turn["calls"] == 1, "A one generative call")
|
||
intern = turn["trace"].get("intern") or ""
|
||
expect("Formulierungen aus dem Dialog" not in intern, "dialogue turn does not paste other-day excerpts")
|
||
expect("Kirschen" in intern, "current user line stays in dialogue context")
|
||
style = client.get("/api/journal/writing-profile", headers=headers).json()
|
||
expect(any(item.get("kind") == "dialogue_style" for item in style["sources"]), "A dialogue style remembered")
|
||
expect("Kirschen" in (style.get("compiled_brief") or ""), "A user wording in brief")
|
||
expect(
|
||
all("Was davon" not in (item.get("body") or "") for item in style["sources"]),
|
||
"A assistant text is not a style source",
|
||
)
|
||
|
||
from dialogue_turn import (
|
||
is_completion_ask,
|
||
is_dann_probe,
|
||
is_day_arc_recap,
|
||
is_echo,
|
||
is_interview_question,
|
||
is_machine_tell,
|
||
is_next_beat_question,
|
||
is_plot_continuation,
|
||
is_pure_recap,
|
||
is_recap_then_ask,
|
||
is_reopen_after_close,
|
||
is_unearned_completion,
|
||
is_unearned_stance,
|
||
is_verbless_echo,
|
||
local_hold,
|
||
needs_repair,
|
||
user_closed_day,
|
||
parse_turn_payload,
|
||
visible_for_role,
|
||
)
|
||
|
||
impulse, decision = parse_turn_payload('{"operation":"konkretisieren","impulse":"Wo war das?"}')
|
||
expect(impulse == "Wo war das?" and decision["parsed"] is True, "operation json parsed")
|
||
hidden = visible_for_role({"trace": {"egress": "x"}, "decision": decision, "calls": 1}, "user")
|
||
expect("trace" not in hidden and "decision" not in hidden, "user does not see test spur")
|
||
expect(is_plot_continuation("Und dann mit dem Tee auf dem Balkon?"), "echo then-question rejected")
|
||
expect(is_plot_continuation("Ihr seid dann zur Bootstour aufgebrochen."), "invented departure rejected")
|
||
expect(is_plot_continuation("Und dann seid ihr zur Wanderung aufgebrochen."), "invented hike rejected")
|
||
expect(
|
||
not is_plot_continuation(
|
||
"Der Morgen war klar, nicht schwül. Und dann seid ihr zusammen zum Bäcker."
|
||
),
|
||
"restating the last shop visit is not the next beat",
|
||
)
|
||
expect(
|
||
is_unearned_stance(
|
||
"Der Morgen war klar – das hat sich sicher anders angefühlt als die Tage zuvor."
|
||
),
|
||
"invented comparison to other days",
|
||
)
|
||
expect(not is_plot_continuation("Die japanische Lektion liegt noch offen."), "companion impulse kept")
|
||
expect(is_interview_question("Was geschah in der Zeit bis zum Start?"), "interview opener detected")
|
||
expect(is_unearned_completion("Was holtet ihr im Laden?"), "shop completion not earned")
|
||
expect(
|
||
not needs_repair(
|
||
"Was geschah in der Zeit bis zum Start?",
|
||
{"register_hint": "dichte Chronik. Begleite, unterbrich wenig."},
|
||
),
|
||
"companion may ask; plot guard does not treat every question as repair",
|
||
)
|
||
expect(
|
||
not needs_repair("Die Gassen kennt ihr schon fast blind.", {"register_hint": "Keine Interviewfrage"}),
|
||
"companion sentence kept without source",
|
||
)
|
||
bakery = (
|
||
"Im Laden entdeckte ich Haferflocken und Trockenobst, die ich ab morgen essen wollte."
|
||
)
|
||
expect(
|
||
is_echo("Haferflocken und Trockenobst lagen für morgen bereit.", bakery),
|
||
"restatement is echo",
|
||
)
|
||
expect(is_unearned_stance("Das warme Wasser fühlte sich anders an als erwartet."), "invented sensation")
|
||
expect(is_unearned_stance("Der Weg zurück musste noch schneller gehen."), "next station as necessity")
|
||
expect(
|
||
not needs_repair(
|
||
"Haferflocken und Trockenobst lagen für morgen bereit.",
|
||
{"dialogue_context": f"user: {bakery}"},
|
||
),
|
||
"word overlap alone is not a plot invention",
|
||
)
|
||
expect(is_dann_probe("Was hat sich dann gezeigt?"), "dann-probe rejected")
|
||
expect(is_completion_ask("Ob ihr rechtzeitig losgekommen seid."), "completion ask rejected")
|
||
balcony = "Sushi kam auf den Balkon. Bis zum Start um 11:00 Uhr hatten wir noch Zeit."
|
||
expect(
|
||
is_verbless_echo("Die Zeit bis 11:00 Uhr auf dem Balkon.", balcony),
|
||
"setting fragment is echo",
|
||
)
|
||
expect("Ich bleibe bei dem" not in local_hold("Wir gingen zum Bäcker."), "fallback is not a system sentence")
|
||
expect("geöffnet" not in local_hold("Wir gingen zum Bäcker."), "fallback is spoken, not a register")
|
||
expect("noch da" not in local_hold("Wir gingen zum Bäcker."), "fallback does not report presence")
|
||
expect(local_hold("Wir gingen zum Bäcker.") == "Ich bin gespannt, wie es weitergeht.", "short hold invites continuation")
|
||
long_hold = " ".join(["Wort"] * 16)
|
||
expect(local_hold(long_hold) == "Erzähl bitte weiter.", "narrative hold asks to continue")
|
||
expect(is_machine_tell("Danke, dass du das teilst."), "gratitude template is a machine tell")
|
||
expect(is_machine_tell("Zusammengefasst war der Vormittag ruhig."), "summary marker is a machine tell")
|
||
expect(not is_machine_tell("Die Nektarine als Ersatz – das setzt sich fest."), "companion thought is not a machine tell")
|
||
expect(needs_repair("Interessant. Lass uns das gemeinsam anschauen."), "machine tell is repaired")
|
||
expect(needs_repair("Ihr seid dann zur Bootstour aufgebrochen."), "invented departure still repaired")
|
||
expect(is_next_beat_question("Also seid ihr dann weitergegangen?"), "second-person then-question is next beat")
|
||
expect(is_next_beat_question("Seid ihr dann auch direkt gesprungen?"), "completion of an intention is next beat")
|
||
expect(not is_next_beat_question("Was gab's dann zu Mittag?"), "a short next-thought question is not a plot beat")
|
||
expect(not is_next_beat_question("Wie war das dann mit der Gruppe?"), "dann in a felt question stays")
|
||
oats = (
|
||
"Ich habe wie geplant meine Haferflocken mit Trockenobst. "
|
||
"Sushi hat Wäsche gewaschen und ich habe am Tagebuch gearbeitet. "
|
||
"Die Kinder sind gegen 12 Uhr aus dem Bett gekrochen und wollten Mittag essen."
|
||
)
|
||
recap_ask = (
|
||
"Die Haferflocken scheinen die neue Lösung zu sein. Und dann Wäsche, Tagebuch, "
|
||
"die Kinder, Rouven. Was gab's dann zu Mittag?"
|
||
)
|
||
expect(
|
||
is_recap_then_ask(recap_ask, {"dialogue_context": f"user: {oats}"}),
|
||
"recap plus what-next is an interview",
|
||
)
|
||
expect(
|
||
needs_repair(recap_ask, {"dialogue_context": f"user: {oats}"}),
|
||
"interview recap is repaired",
|
||
)
|
||
expect(
|
||
not needs_repair(
|
||
"Was gab's dann zu Mittag?",
|
||
{"dialogue_context": f"user: {oats}"},
|
||
),
|
||
"short next-thought impulse without recap stays",
|
||
)
|
||
recap_only = (
|
||
"Haferflocken mit Trockenobst, dann Wäsche und Tagebuch, "
|
||
"danach die Arbeit, bis die Kinder aus dem Bett wollten Mittag essen."
|
||
)
|
||
expect(
|
||
is_pure_recap(recap_only, {"dialogue_context": f"user: {oats}"}),
|
||
"dense restatement without a break is a recap",
|
||
)
|
||
expect(
|
||
needs_repair(recap_only, {"dialogue_context": f"user: {oats}"}),
|
||
"pure recap without contradiction is repaired",
|
||
)
|
||
expect(
|
||
not is_pure_recap(
|
||
"Die Haferflocken bleiben die neue Lösung.",
|
||
{"dialogue_context": f"user: {oats}"},
|
||
),
|
||
"a thought derived from the last thread is not a recap",
|
||
)
|
||
close_ctx = {
|
||
"dialogue_context": (
|
||
"user: Morgens nach der Japanischlektion kam jemand auf den Balkon.\n"
|
||
"user: Später die Bootstour, vier Badestopps, gegrillter Fisch und singende Gäste.\n"
|
||
"user: Wir genossen noch die Atmosphäre und machten uns dann müde "
|
||
"und voll von den überwältigenden Eindrücken des Tages auf den Heimweg."
|
||
)
|
||
}
|
||
expect(
|
||
user_closed_day(
|
||
{"dialogue_context": "user: Wir gingen zurück zur Wohnung und legten uns dann auch bald ins Bett."}
|
||
),
|
||
"ins Bett closes the day",
|
||
)
|
||
expect(
|
||
not user_closed_day(
|
||
{"dialogue_context": "user: Nach dem Essen gingen die Kinder nach Hause und wir wollten noch zum Hafen."}
|
||
),
|
||
"nach Hause mid-day is not a close",
|
||
)
|
||
expect(
|
||
needs_repair(
|
||
"Was war das für ein Gefühl, müde heimzugehen mit all diesen Eindrücken?",
|
||
close_ctx,
|
||
),
|
||
"feeling question after close is repaired",
|
||
)
|
||
expect(
|
||
is_reopen_after_close(
|
||
"Du hattest morgens nicht ahnen können, dann Bootstour, Badestopps, Essen, Italiener.",
|
||
close_ctx,
|
||
),
|
||
"day-arc recap after close is repaired",
|
||
)
|
||
expect(is_day_arc_recap("Morgens Japanisch, Balkon, Bootstour und Badestopps.", close_ctx), "arc uses earlier stations")
|
||
expect(not needs_repair("Dann bleibe ich bei diesem Heimweg.", close_ctx), "hold at the close stays")
|
||
expect("Schluss" in local_hold(close_ctx["dialogue_context"].split("user:")[-1]), "close fallback holds the end")
|
||
from context_builder import infer_register
|
||
|
||
expect("Schluss" in infer_register(["Wir machten uns müde auf den Heimweg."]), "register sees the close first")
|
||
originals = client.get(f"/api/journal/conversations/{conv['id']}", headers=headers)
|
||
expect(len(originals.json()["messages"]) == 2, "A two source messages")
|
||
|
||
reset_debug()
|
||
draft = client.post(f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]})
|
||
expect(draft.status_code == 200, f"A generate {draft.text}")
|
||
expect("Markt" in draft.json()["body"] or "Kirschen" in draft.json()["body"], "A draft keeps user words")
|
||
from privacy_gateway import debug_last as last_a
|
||
|
||
expect(last_a is not None, "A generate reached gateway")
|
||
expect("Kirschen" in last_a["rendered"] or "Markt" in last_a["rendered"], "A generate sees user words")
|
||
expect("assistant:" in last_a["rendered"], "A generate sees Kanshō impulses as reply context")
|
||
expect("Stilquellen" in last_a["rendered"] or "Dialog" in last_a["rendered"], "A style brief reaches generate")
|
||
style_part = last_a["rendered"].split("Dialogquellen:")[0]
|
||
expect("Erzählmerkmale" in style_part, "A first generate still gets form hints")
|
||
expect(
|
||
"Heute war der Markt voll" not in style_part,
|
||
"A source dialogue is not pasted a second time as style",
|
||
)
|
||
current_version = None
|
||
saved = client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={
|
||
"journal_day_id": day_id,
|
||
"title": "Markttag",
|
||
"body": "Heute war der Markt voll. Ich bin früh gegangen.",
|
||
"origin": "user_edit",
|
||
"source_conversation_ids": [conv["id"]],
|
||
},
|
||
)
|
||
expect(saved.status_code == 200, f"A save {saved.text}")
|
||
current_version = saved.json()["current_version_id"]
|
||
expect(saved.json()["body"].startswith("Heute war der Markt"), "A user edit stored")
|
||
after_save = client.get("/api/journal/writing-profile", headers=headers).json()
|
||
brief = after_save.get("compiled_brief") or ""
|
||
expect("Finale Journal Entries" in brief, "A saved entry is strongest style source")
|
||
entry_at = brief.find("Finale Journal Entries")
|
||
dialogue_at = brief.find("Formulierungen aus dem Dialog")
|
||
expect(dialogue_at == -1 or entry_at < dialogue_at, "A entries listed before dialogue style")
|
||
space_view = client.get(f"/api/journal/spaces/{space_id}", headers=headers)
|
||
dates = [item["calendar_date"] for item in space_view.json()["days"]]
|
||
expect("2026-08-20" in dates, "A day appears in space chronology")
|
||
|
||
later = client.post(
|
||
f"/api/journal/conversations/{conv['id']}/turn",
|
||
headers=headers,
|
||
json={"body": "Am nächsten Kalendertag fällt mir noch der Wind ein."},
|
||
)
|
||
expect(later.status_code == 200, "B continue same conversation")
|
||
day_again = client.get(f"/api/journal/days/{day_id}", headers=headers)
|
||
expect(day_again.json()["day"]["calendar_date"] == "2026-08-20", "B journal day stays calendar day")
|
||
msgs = client.get(f"/api/journal/conversations/{conv['id']}", headers=headers).json()["messages"]
|
||
expect(any("Wind" in item["body"] for item in msgs), "B later message kept")
|
||
expect(msgs[-1]["created"] != "2026-08-20" or True, "B message time is independent")
|
||
|
||
prior_space, prior_day = open_space_day(client, headers, "Urlaub", "2026-08-01")
|
||
prior_conv, _ = start_and_turn(
|
||
client,
|
||
headers,
|
||
prior_day["day"]["id"],
|
||
"Morgen gehe ich an den Hafen, wenn das Wetter hält.",
|
||
title="Plan",
|
||
)
|
||
client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={
|
||
"journal_day_id": prior_day["day"]["id"],
|
||
"title": "Plan",
|
||
"body": "Morgen gehe ich an den Hafen, wenn das Wetter hält.",
|
||
"origin": "user_edit",
|
||
},
|
||
)
|
||
next_day = client.post(
|
||
f"/api/journal/spaces/{prior_space['id']}/days",
|
||
headers=headers,
|
||
json={"calendar_date": "2026-08-02"},
|
||
)
|
||
reset_debug()
|
||
_conv_c, turn_c = start_and_turn(
|
||
client,
|
||
headers,
|
||
next_day.json()["day"]["id"],
|
||
"Der Vormittag war ruhig.",
|
||
)
|
||
from privacy_gateway import debug_last as last_c
|
||
|
||
expect(last_c is not None, "C gateway saw a request")
|
||
expect("Hafen" in last_c["rendered"], "C prior plan in context")
|
||
expect("nicht geschehen" in last_c["rendered"], "C not-mentioned rule in prompt")
|
||
|
||
reset_debug()
|
||
dense = "Erster Absatz mit vielen Details über die Anreise.\n\nZweiter Absatz über das Licht am Abend.\n\nDritter Absatz über das Essen."
|
||
conv_d, turn_d = start_and_turn(client, headers, day_id, dense, title="Dicht")
|
||
from privacy_gateway import debug_calls as calls_d
|
||
|
||
expect(calls_d == 1, f"D exactly one call, got {calls_d}")
|
||
expect(sum(1 for item in turn_d["messages"] if item["role"] == "assistant" and item["conversation_id"] == conv_d["id"]) >= 1, "D one assistant")
|
||
expect(turn_d["calls"] == 1, "D turn reports one call")
|
||
|
||
reset_debug()
|
||
_conv_e, turn_e = start_and_turn(
|
||
client,
|
||
headers,
|
||
day_id,
|
||
"Ich bin traurig über den Abschied, das ist klar.",
|
||
title="Emotion",
|
||
)
|
||
from privacy_gateway import debug_last as last_e
|
||
|
||
expect("Motivationen erfinden" in last_e["rendered"], "E no invented emotion rule")
|
||
expect(turn_e["calls"] == 1, "E one follow-up")
|
||
|
||
day_f = client.post(
|
||
f"/api/journal/spaces/{space_id}/days",
|
||
headers=headers,
|
||
json={"calendar_date": "2026-08-21"},
|
||
)
|
||
day_f_id = day_f.json()["day"]["id"]
|
||
conv_f1, _ = start_and_turn(client, headers, day_f_id, "Chronik: Spaziergang und Einkauf.", title="Chronik")
|
||
conv_f2, _ = start_and_turn(client, headers, day_f_id, "Tiefer: der Spaziergang hat mich berührt.", title="Tiefe")
|
||
gen_f1 = client.post(
|
||
f"/api/journal/days/{day_f_id}/generate",
|
||
headers=headers,
|
||
json={"conversation_ids": [conv_f1["id"]]},
|
||
)
|
||
save_f1 = client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={"journal_day_id": day_f_id, "title": "Chronik", "body": gen_f1.json()["body"], "origin": "accepted_draft"},
|
||
)
|
||
gen_f2 = client.post(
|
||
f"/api/journal/days/{day_f_id}/generate",
|
||
headers=headers,
|
||
json={"conversation_ids": [conv_f2["id"]]},
|
||
)
|
||
save_f2 = client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={"journal_day_id": day_f_id, "title": "Tiefe", "body": gen_f2.json()["body"], "origin": "accepted_draft"},
|
||
)
|
||
day_f_view = client.get(f"/api/journal/days/{day_f_id}", headers=headers).json()
|
||
expect(len(day_f_view["entries"]) == 2, "F two separate entries")
|
||
expect(save_f1.json()["id"] != save_f2.json()["id"], "F distinct entry ids")
|
||
|
||
expect(day_f_view["consolidation_offer"] is False, "G no offer when chronicle and deep reflection differ")
|
||
gen_g = client.post(f"/api/journal/days/{day_f_id}/generate", headers=headers, json={})
|
||
expect(len(gen_g.json()["source_conversation_ids"]) == 1, "G generate without selection does not merge")
|
||
|
||
day_g2 = client.post(
|
||
f"/api/journal/spaces/{space_id}/days",
|
||
headers=headers,
|
||
json={"calendar_date": "2026-08-19"},
|
||
)
|
||
day_g2_id = day_g2.json()["day"]["id"]
|
||
start_and_turn(
|
||
client,
|
||
headers,
|
||
day_g2_id,
|
||
"Heute Vormittag zum Markt, danach Kirschen gekauft.",
|
||
title="Morgen",
|
||
)
|
||
start_and_turn(
|
||
client,
|
||
headers,
|
||
day_g2_id,
|
||
"Nach dem Markt noch Brot geholt, dann heimgegangen.",
|
||
title="Abend",
|
||
)
|
||
day_g2_view = client.get(f"/api/journal/days/{day_g2_id}", headers=headers).json()
|
||
expect(day_g2_view["consolidation_offer"] is True, "G offer when two chronicle conversations match")
|
||
|
||
space_h, day_h = open_space_day(client, headers, "Kleinwalsertal 2026", "2026-08-21")
|
||
expect(day_h["day"]["id"] != day_f_id, "H same date different space is other day")
|
||
listed_alltag = {item["id"] for item in client.get(f"/api/journal/spaces/{space_id}", headers=headers).json()["days"]}
|
||
listed_kw = {item["id"] for item in client.get(f"/api/journal/spaces/{space_h['id']}", headers=headers).json()["days"]}
|
||
expect(day_f_id in listed_alltag and day_h["day"]["id"] not in listed_alltag, "H days stay in own space")
|
||
expect(day_h["day"]["id"] in listed_kw and day_f_id not in listed_kw, "H no date merge across spaces")
|
||
|
||
imported = client.post(
|
||
"/api/journal/writing-profile/import",
|
||
headers=headers,
|
||
json={"body": "Ich schreibe knapp und ohne Pathos."},
|
||
)
|
||
expect(imported.status_code == 200, f"I import {imported.text}")
|
||
kinds = {item["kind"] for item in imported.json()["sources"]}
|
||
expect("imported_text" in kinds, "I import is a source")
|
||
expect(any("knapp" in (item.get("body") or "") for item in imported.json()["sources"]), "I import body kept")
|
||
expect(imported.json()["compiled_brief"] and "knapp" in imported.json()["compiled_brief"], "I brief uses import")
|
||
reset_debug()
|
||
gen_i = client.post(
|
||
f"/api/journal/days/{day_id}/generate",
|
||
headers=headers,
|
||
json={"conversation_ids": [conv["id"]]},
|
||
)
|
||
expect(gen_i.status_code == 200, "I generate after import")
|
||
from privacy_gateway import debug_last as last_i
|
||
|
||
expect("knapp" in last_i["rendered"], "I style brief reaches generate")
|
||
profile = client.get("/api/journal/writing-profile", headers=headers).json()
|
||
expect(all(item.get("kind") != "journal_draft" for item in profile["sources"]), "I no draft kind")
|
||
expect(not any((item.get("body") or "") == gen_i.json()["body"] for item in profile["sources"]), "I draft not a style source")
|
||
expect(any(item.get("kind") == "journal_entry" for item in profile["sources"]), "I user-final entry is a source")
|
||
|
||
first = client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={"journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "v1", "body": "Erste Fassung.", "origin": "user_edit"},
|
||
)
|
||
second = client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={"journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "v2", "body": "Zweite Fassung.", "origin": "user_edit"},
|
||
)
|
||
versions = client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json()
|
||
expect(len(versions) >= 3, f"J version history kept ({len(versions)})")
|
||
restore = client.post(
|
||
f"/api/journal/entries/{saved.json()['id']}/restore",
|
||
headers=headers,
|
||
json={"version_id": first.json()["current_version_id"]},
|
||
)
|
||
expect(restore.status_code == 200, f"J restore {restore.text}")
|
||
expect(restore.json()["body"] == "Erste Fassung.", "J restored body")
|
||
versions_after = client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json()
|
||
expect(len(versions_after) == len(versions) + 1, "J restore appends a version")
|
||
before_gen = restore.json()["current_version_id"]
|
||
client.post(f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]})
|
||
after_gen = client.get(f"/api/journal/entries/{saved.json()['id']}", headers=headers).json()
|
||
expect(after_gen["current_version_id"] == before_gen, "J generate does not overwrite entry")
|
||
expect(after_gen["body"] == "Erste Fassung.", "J user text remains")
|
||
day_with_draft = client.get(f"/api/journal/days/{day_id}", headers=headers).json()
|
||
expect(day_with_draft.get("current_draft"), "J generate stores a draft beside the entry")
|
||
expect(
|
||
(day_with_draft["current_draft"] or {}).get("body") != after_gen["body"],
|
||
"J new draft is not the saved user text",
|
||
)
|
||
n_before = len(client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json())
|
||
adopted = client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={
|
||
"journal_day_id": day_id,
|
||
"entry_id": saved.json()["id"],
|
||
"title": "# Neue Überschrift",
|
||
"body": (day_with_draft["current_draft"] or {}).get("body") or "Entwurf",
|
||
"origin": "accepted_draft",
|
||
},
|
||
)
|
||
expect(adopted.status_code == 200, f"J adopt draft {adopted.text}")
|
||
expect(adopted.json()["title"] == "Neue Überschrift", "J hash stripped from stored title")
|
||
listed = client.get(f"/api/journal/days/{day_id}", headers=headers).json()
|
||
titles = [item.get("title") or "" for item in listed.get("entries") or []]
|
||
expect(any("Neue Überschrift" in title for title in titles), "J day list shows cleaned title")
|
||
n_after = len(client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json())
|
||
expect(n_after == n_before + 1, "J adopt appends a version on the same entry")
|
||
|
||
look = client.get(f"/api/journal/spaces/{prior_space['id']}", headers=headers).json()
|
||
expect(any(item["calendar_date"] == "2026-08-01" for item in look["days"]), "K space chronology")
|
||
opened = client.post(
|
||
f"/api/journal/spaces/{prior_space['id']}/days",
|
||
headers=headers,
|
||
json={"calendar_date": "2026-08-01"},
|
||
)
|
||
expect(opened.json()["entries"], "K entry opens by date")
|
||
source = client.get(
|
||
f"/api/journal/conversations/{opened.json()['conversations'][0]['id']}",
|
||
headers=headers,
|
||
)
|
||
expect(source.status_code == 200 and source.json()["messages"], "K jump to source dialog")
|
||
|
||
throwaway, _ = start_and_turn(client, headers, day_id, "Nur ein Testdialog zum Entfernen.", title="Wegwerf")
|
||
blocked = client.delete(f"/api/journal/conversations/{throwaway['id']}", headers=other_headers)
|
||
expect(blocked.status_code == 404, "delete isolated by profile")
|
||
removed = client.delete(f"/api/journal/conversations/{throwaway['id']}", headers=headers)
|
||
expect(removed.status_code == 200 and removed.json().get("deleted"), "conversation deleted")
|
||
after_delete = client.get(f"/api/journal/days/{day_id}", headers=headers).json()
|
||
expect(all(item["id"] != throwaway["id"] for item in after_delete["conversations"]), "deleted conversation leaves the day")
|
||
expect(any(item["id"] == conv["id"] for item in after_delete["conversations"]), "other conversations remain")
|
||
missing = client.get(f"/api/journal/conversations/{throwaway['id']}", headers=headers)
|
||
expect(missing.status_code == 404, "deleted conversation is gone")
|
||
|
||
media = client.post(
|
||
f"/api/journal/entries/{saved.json()['id']}/media",
|
||
headers=headers,
|
||
files={"file": ("shot.png", PNG, "image/png")},
|
||
)
|
||
expect(media.status_code == 200, f"media upload {media.text}")
|
||
fetched = client.get(f"/api/journal/media/{media.json()['id']}", headers=headers)
|
||
expect(fetched.status_code == 200 and fetched.content[:8] == b"\x89PNG\r\n\x1a\n", "media served")
|
||
other_media = client.get(f"/api/journal/media/{media.json()['id']}", headers=other_headers)
|
||
expect(other_media.status_code == 404, "media isolated")
|
||
expect(media.json().get("kind") == "image", "image kind")
|
||
|
||
video = client.post(
|
||
f"/api/journal/entries/{saved.json()['id']}/media",
|
||
headers=headers,
|
||
files={"file": ("clip.webm", b"webm-bytes-not-a-container", "video/webm")},
|
||
)
|
||
expect(video.status_code == 200, f"video upload {video.text}")
|
||
expect(video.json().get("kind") == "video", "video kind")
|
||
served_video = client.get(f"/api/journal/media/{video.json()['id']}", headers=headers)
|
||
expect(served_video.status_code == 200 and served_video.headers["content-type"].startswith("video/"), "video served")
|
||
|
||
rejected = client.post(
|
||
f"/api/journal/entries/{saved.json()['id']}/media",
|
||
headers=headers,
|
||
files={"file": ("note.pdf", b"%PDF-1.4", "application/pdf")},
|
||
)
|
||
expect(rejected.status_code == 400, "pdf rejected")
|
||
|
||
media_id = media.json()["id"]
|
||
video_id = video.json()["id"]
|
||
embedded = client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={
|
||
"journal_day_id": day_id,
|
||
"entry_id": saved.json()["id"],
|
||
"title": "Markttag",
|
||
"body": (
|
||
"Heute war der Markt voll.\n\n"
|
||
f"[[media:{media_id}|Kirschenstand]]\n\n"
|
||
"Danach der Hafen.\n\n"
|
||
f"[[media:{video_id}|Steg]]"
|
||
),
|
||
"origin": "user_edit",
|
||
},
|
||
)
|
||
expect(embedded.status_code == 200, "embedded body saved")
|
||
expect(f"" in embedded.json()["body"], "image token stored as markdown")
|
||
expect(f"" in embedded.json()["body"], "video token stored as markdown")
|
||
style_after = client.get("/api/journal/writing-profile", headers=headers).json()
|
||
brief = style_after.get("compiled_brief") or ""
|
||
sources_blob = " ".join(item.get("body") or "" for item in style_after.get("sources") or [])
|
||
expect(media_id not in brief and video_id not in brief, "asset ids stay out of writing brief")
|
||
expect(media_id not in sources_blob, "asset ids stay out of style sources")
|
||
expect("Kirschenstand" in brief or "Kirschenstand" in sources_blob, "caption remains as wording")
|
||
|
||
scratch_mark = "SCRATCHTOKEN-NEVER-EGRESS-xyz"
|
||
scratch = client.patch(
|
||
f"/api/journal/days/{day_id}/scratch",
|
||
headers=headers,
|
||
json={"items": [{"text": scratch_mark, "done": False}]},
|
||
)
|
||
expect(scratch.status_code == 200 and scratch.json()["scratch"][0]["text"] == scratch_mark, "scratch saved")
|
||
hidden_scratch = client.patch(
|
||
f"/api/journal/days/{day_id}/scratch",
|
||
headers=other_headers,
|
||
json={"items": [{"text": "fremd", "done": False}]},
|
||
)
|
||
expect(hidden_scratch.status_code == 404, "scratch isolated")
|
||
day_after = client.get(f"/api/journal/days/{day_id}", headers=headers).json()
|
||
expect(any(item["text"] == scratch_mark for item in day_after.get("scratch") or []), "scratch on day payload")
|
||
reset_debug()
|
||
turn_scratch = client.post(
|
||
f"/api/journal/conversations/{conv['id']}/turn",
|
||
headers=headers,
|
||
json={"body": "Noch ein Satz zum Abend."},
|
||
)
|
||
expect(turn_scratch.status_code == 200, "turn after scratch")
|
||
from privacy_gateway import debug_last as last_scratch_turn
|
||
blob = " ".join(
|
||
str(last_scratch_turn.get(key) or "")
|
||
for key in ("rendered", "masked", "mask_input", "raw", "reply")
|
||
)
|
||
intern = (turn_scratch.json().get("trace") or {}).get("intern") or ""
|
||
expect(scratch_mark not in blob and scratch_mark not in intern, "scratch not in dialogue egress")
|
||
reset_debug()
|
||
gen_scratch = client.post(
|
||
f"/api/journal/days/{day_id}/generate",
|
||
headers=headers,
|
||
json={"conversation_ids": [conv["id"]]},
|
||
)
|
||
expect(gen_scratch.status_code == 200, "generate after scratch")
|
||
from privacy_gateway import debug_last as last_scratch_gen
|
||
gen_blob = " ".join(
|
||
str(last_scratch_gen.get(key) or "")
|
||
for key in ("rendered", "masked", "mask_input", "raw", "reply")
|
||
)
|
||
gen_intern = (gen_scratch.json().get("trace") or {}).get("intern") or ""
|
||
expect(scratch_mark not in gen_blob and scratch_mark not in gen_intern, "scratch not in generate egress")
|
||
|
||
from media_store import strip_jpeg_exif
|
||
|
||
jpeg = bytearray(b"\xff\xd8\xff\xe1\x00\x10Exif\x00\x00AAAA\xff\xda\x00\x08\x01\x01\x00\x00\xff\xd9")
|
||
stripped = strip_jpeg_exif(bytes(jpeg))
|
||
expect(b"Exif" not in stripped, "jpeg exif stripped")
|
||
|
||
print("All MVP journal tests passed.")
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|