# tests/test_file_chat.py — Chat par fichier (#169) """Store + REST routes for the per-file chat history.""" import time import pytest from backend import file_chat as _store @pytest.fixture(autouse=True) def _isolated_chat_dir(tmp_path, monkeypatch): """Keep every test (store AND routes) out of the real ``data/chats``.""" monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats") class TestStore: def test_get_messages_empty_when_missing(self, tmp_path, monkeypatch): monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats") assert _store.get_messages("V", "a/b.md") == [] def test_add_then_get_roundtrip(self, tmp_path, monkeypatch): monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats") msg = _store.add_message("V", "a/b.md", "bruno", "Bonjour !") assert msg["user"] == "bruno" assert msg["text"] == "Bonjour !" assert msg["id"] assert msg["ts"] <= time.time() + 1 got = _store.get_messages("V", "a/b.md") assert len(got) == 1 assert got[0]["id"] == msg["id"] def test_conversation_is_per_file(self, tmp_path, monkeypatch): monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats") _store.add_message("V", "a.md", "u", "pour a") _store.add_message("V", "b.md", "u", "pour b") assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["pour a"] assert [m["text"] for m in _store.get_messages("V", "b.md")] == ["pour b"] def test_path_traversal_never_escapes_chat_dir(self, tmp_path, monkeypatch): """The chat file name is hashed: '../../etc' cannot escape CHAT_DIR.""" chat_dir = tmp_path / "chats" monkeypatch.setattr(_store, "CHAT_DIR", chat_dir) _store.add_message("V", "../../etc/passwd", "u", "x") files = list(chat_dir.iterdir()) assert len(files) == 1 assert files[0].parent == chat_dir assert "/" not in files[0].name and "\\" not in files[0].name def test_retention_cap_drops_oldest(self, tmp_path, monkeypatch): monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats") n = _store.MAX_MESSAGES + 25 for i in range(n): _store.add_message("V", "a.md", "u", f"m{i}") got = _store.get_messages("V", "a.md") assert len(got) == _store.MAX_MESSAGES assert got[0]["text"] == f"m{25}" # first 25 dropped assert got[-1]["text"] == f"m{n - 1}" def test_text_truncated_to_max(self, tmp_path, monkeypatch): monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats") _store.add_message("V", "a.md", "u", "x" * (_store.MAX_TEXT + 500)) got = _store.get_messages("V", "a.md") assert len(got[0]["text"]) == _store.MAX_TEXT def test_corrupt_file_falls_back_to_empty(self, tmp_path, monkeypatch): chat_dir = tmp_path / "chats" monkeypatch.setattr(_store, "CHAT_DIR", chat_dir) chat_dir.mkdir(parents=True) target = _store._chat_file("V", "a.md") target.write_text("{not json", encoding="utf-8") assert _store.get_messages("V", "a.md") == [] class TestRoutes: def test_history_requires_existing_vault(self, client, test_vault_dir): r = client.get("/api/file/TestVault/chat", params={"path": "note1.md"}) assert r.status_code == 200 assert r.json() == {"messages": []} def test_post_and_get_roundtrip(self, client, test_vault_dir): r = client.post( "/api/file/TestVault/chat", json={"path": "note1.md", "text": "Vu hier ?"}, ) assert r.status_code == 200 body = r.json() assert body["status"] == "ok" assert body["message"]["text"] == "Vu hier ?" r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"}) assert [m["text"] for m in r2.json()["messages"]] == ["Vu hier ?"] def test_post_requires_text(self, client, test_vault_dir): r = client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": " "}) assert r.status_code == 400 def test_post_requires_path(self, client, test_vault_dir): r = client.post("/api/file/TestVault/chat", json={"path": "", "text": "hi"}) assert r.status_code == 400 def test_unknown_vault_404(self, client, test_vault_dir): r = client.get("/api/file/Nope/chat", params={"path": "x.md"}) assert r.status_code == 404 def test_path_traversal_rejected(self, client, test_vault_dir): r = client.get( "/api/file/TestVault/chat", params={"path": "../../etc/passwd"} ) assert r.status_code in (403, 500) r2 = client.post( "/api/file/TestVault/chat", json={"path": "../../etc/passwd", "text": "x"}, ) assert r2.status_code in (403, 500) @pytest.mark.parametrize("path", ["note1.md", "sub/deep/file.md"]) def test_history_per_path_isolated(client, test_vault_dir, path): client.post("/api/file/TestVault/chat", json={"path": path, "text": "m"}) other = "note1.md" if path != "note1.md" else "note2.md" r = client.get("/api/file/TestVault/chat", params={"path": other}) assert r.json()["messages"] == []