- Store backend `backend/file_chat.py` : messages JSON par (vault, path)
sous `data/chats/` (nom hashé SHA-256 → traversal impossible), plafond
500 messages, texte tronqué à 4000 caractères, écriture atomique.
- Routes `GET/POST /api/file/{vault}/chat` : auth + accès vault +
`resolve_safe_path`, schémas Pydantic (`response_model`), broadcast SSE
`chat_message` sur le transport existant (#62) — pas de second WebSocket.
- Panneau latéral `frontend/js/filechat.js` : bouton 💬 dans la toolbar
fichier, historique chronologique, envoi optimiste + dédoublonnage par id,
toast « Nouveau message » si le panneau est fermé/autre fichier.
- Relais SSE dans `sync.js` (import dynamique), CSS bloc #169 (plein écran
≤ 768 px, input 16 px anti-zoom), i18n FR/EN (10 clés `chat.*`).
- Tests : `tests/test_file_chat.py` (15) + `tests/frontend/filechat.test.mjs`
(6, ajouté au pipeline CI), regex toolbar-order mise à jour.
- Docs : CHANGELOG [Unreleased], ROADMAP #169 → livré + index, fiche
`docs/features/file-chat-169.md`, guide « Discuter d'un fichier ».
123 lines
5.1 KiB
Python
123 lines
5.1 KiB
Python
# 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"] == []
|