"""Journal trash, undelete, and hard delete. Run from backend/: python tests/test_journal_trash.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_MEDIA_ROOT"] = str(Path(tempfile.gettempdir()) / "kansho-trash-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 main import app from media_store import media_root 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 main() -> None: with TestClient(app) as client: setup = client.post("/api/auth/setup", json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"}) headers = header(setup.json()["token"]) 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"]) space = client.post("/api/journal/spaces", headers=headers, json={"title": "Alltag"}) space_id = space.json()["id"] day = client.post(f"/api/journal/spaces/{space_id}/days", headers=headers, json={"calendar_date": "2026-08-20"}) day_id = day.json()["day"]["id"] conv = client.post(f"/api/journal/days/{day_id}/conversations", headers=headers, json={"title": "Gespräch"}) client.post(f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": "Heute war der Markt voll."}) saved = client.post( "/api/journal/entries", headers=headers, json={"journal_day_id": day_id, "title": "Markt", "body": "Heute war der Markt voll.", "origin": "user_edit"}, ) entry_id = saved.json()["id"] second = client.post( "/api/journal/entries", headers=headers, json={"journal_day_id": day_id, "title": "Abend", "body": "Später am Hafen.", "origin": "user_edit"}, ) second_id = second.json()["id"] media = client.post( f"/api/journal/entries/{entry_id}/media", headers=headers, files={"file": ("shot.png", PNG, "image/png")}, ) expect(media.status_code == 200, "media attached") media_id = media.json()["id"] rel = media.json()["rel_path"] expect((media_root() / rel).is_file(), "media file exists") listed = client.get(f"/api/journal/spaces/{space_id}/entries", headers=headers).json() expect(len(listed) == 2, "two active entries") expect(listed[0]["calendar_date"] == "2026-08-20", "same day") expect(listed[0]["id"] != listed[1]["id"], "same-day entries are distinct") expect(all(item["id"] in {entry_id, second_id} for item in listed), "list is the two entries") hidden_list = client.get(f"/api/journal/spaces/{space_id}/entries", headers=other_headers) expect(hidden_list.status_code == 404, "entry list isolated") hidden_trash = client.get(f"/api/journal/spaces/{space_id}/trash", headers=other_headers) expect(hidden_trash.status_code == 404, "trash list isolated") deleted = client.delete(f"/api/journal/entries/{entry_id}", headers=headers) expect(deleted.status_code == 200, "soft delete") after_soft = client.get(f"/api/journal/spaces/{space_id}/entries", headers=headers).json() expect(all(item["id"] != entry_id for item in after_soft), "soft-deleted entry leaves chronology") expect(any(item["id"] == second_id for item in after_soft), "other entry remains") trash = client.get(f"/api/journal/spaces/{space_id}/trash", headers=headers).json() expect(any(item["id"] == entry_id for item in trash), "soft-deleted appears in trash") missing = client.get(f"/api/journal/entries/{entry_id}", headers=headers) expect(missing.status_code == 404, "deleted entry is not a normal GET") stolen_delete = client.delete(f"/api/journal/entries/{second_id}", headers=other_headers) expect(stolen_delete.status_code == 404, "foreign profile cannot soft-delete") stolen_undelete = client.post(f"/api/journal/entries/{entry_id}/undelete", headers=other_headers, json={}) expect(stolen_undelete.status_code == 404, "foreign profile cannot undelete") stolen_purge = client.post(f"/api/journal/entries/{entry_id}/purge", headers=other_headers, json={"confirm": True}) expect(stolen_purge.status_code == 404, "foreign profile cannot purge") version_restore = client.post( f"/api/journal/entries/{entry_id}/restore", headers=headers, json={"version_id": saved.json()["current_version_id"]}, ) expect(version_restore.status_code == 404, "version restore is not trash undelete") back = client.post(f"/api/journal/entries/{entry_id}/undelete", headers=headers, json={}) expect(back.status_code == 200, f"undelete {back.text}") expect(back.json().get("deleted_at") in (None, ""), "undelete clears deleted_at") restored_list = client.get(f"/api/journal/spaces/{space_id}/entries", headers=headers).json() expect(any(item["id"] == entry_id for item in restored_list), "undeleted entry returns to chronology") expect(client.get(f"/api/journal/spaces/{space_id}/trash", headers=headers).json() == [], "trash empty after undelete") versions = client.get(f"/api/journal/entries/{entry_id}/versions", headers=headers).json() expect(len(versions) == 1, "undelete does not append a version") client.delete(f"/api/journal/entries/{entry_id}", headers=headers) unconfirmed = client.post(f"/api/journal/entries/{entry_id}/purge", headers=headers, json={"confirm": False}) expect(unconfirmed.status_code == 400, "purge without confirm fails closed") still = client.get(f"/api/journal/spaces/{space_id}/trash", headers=headers).json() expect(any(item["id"] == entry_id for item in still), "unconfirmed purge leaves the entry") active_purge = client.post(f"/api/journal/entries/{second_id}/purge", headers=headers, json={"confirm": True}) expect(active_purge.status_code == 400, "purge of active entry is rejected") conv_before = client.get(f"/api/journal/conversations/{conv.json()['id']}", headers=headers) expect(conv_before.status_code == 200, "source dialogue still there before purge") purged = client.post(f"/api/journal/entries/{entry_id}/purge", headers=headers, json={"confirm": True}) expect(purged.status_code == 200 and purged.json().get("purged"), f"purge {purged.text}") expect(purged.json().get("conversations_untouched") is True, "source dialogues are not deleted") expect(client.get(f"/api/journal/conversations/{conv.json()['id']}", headers=headers).status_code == 200, "dialogue remains") expect(client.get(f"/api/journal/entries/{entry_id}", headers=headers).status_code == 404, "purged entry gone") expect(client.get(f"/api/journal/spaces/{space_id}/trash", headers=headers).json() == [], "purged entry leaves trash") expect(not (media_root() / rel).exists(), "unreferenced media file removed") expect(client.get(f"/api/journal/media/{media_id}", headers=headers).status_code == 404, "media row gone") again = client.post(f"/api/journal/entries/{entry_id}/purge", headers=headers, json={"confirm": True}) expect(again.status_code == 404, "second purge of same id is not found") again_undelete = client.post(f"/api/journal/entries/{entry_id}/undelete", headers=headers, json={}) expect(again_undelete.status_code == 404, "cannot undelete a purged entry") leftover = client.get(f"/api/journal/spaces/{space_id}/entries", headers=headers).json() expect(all(item["id"] != entry_id for item in leftover), "purged id absent from chronology") expect(any(item["id"] == second_id for item in leftover), "untouched entry remains") if __name__ == "__main__": main()