feat: chat — suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre #190
- **A — Suivi du document** : l'en-tête du panneau chat affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document. - **B — Chat général** : onglet « Chat » (dernier) dans la sidebar, conversation centrale stockée dans le store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`) ; pastille de messages non lus sur l'onglet ; pièces jointes image/vidéo (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list) ; URL cliquables au rendu ; date/heure d'envoi. - **C — Ordre des onglets** : la barre de filtre passe sous les onglets et sert de recherche dans le chat (texte + auteur) quand l'onglet Chat est actif (`switchSidebarTab` + `routeFilter`). - Transport : broadcast SSE `chat_message` réutilisé (`vault __global__` route vers la sidebar, sinon le panneau fichier) — pas de 2ᵉ WebSocket. - i18n FR/EN (6 clés), CSS bloc #190, `initSidebarChat()` dans l'orchestrateur. - Tests : pytest 27 chat (12 nouveaux) ; JSDOM `filechat.test.mjs` 11 (5 nouveaux) ; suite 1655 passed, ruff/mypy 0, validate-imports 42 modules. - Docs : CHANGELOG [Unreleased], ROADMAP #190 → index, fiche `file-chat-169.md` §#190, guide « Chat général », journal des interventions.
This commit is contained in:
+88
-1
@@ -10,8 +10,10 @@ 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``."""
|
||||
"""Keep every test (store AND routes) out of the real ``data/chats``/
|
||||
``data/chat_uploads``."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
monkeypatch.setattr(_store, "UPLOAD_DIR", tmp_path / "uploads")
|
||||
|
||||
|
||||
class TestStore:
|
||||
@@ -120,3 +122,88 @@ def test_history_per_path_isolated(client, test_vault_dir, path):
|
||||
other = "note1.md" if path != "note1.md" else "note2.md"
|
||||
r = client.get("/api/file/TestVault/chat", params={"path": other})
|
||||
assert r.json()["messages"] == []
|
||||
|
||||
|
||||
# --- #190 : chat général + pièces jointes -----------------------------------
|
||||
|
||||
class TestGlobalChat:
|
||||
def test_history_empty_by_default(self, client, test_vault_dir):
|
||||
r = client.get("/api/chat")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"messages": []}
|
||||
|
||||
def test_post_and_get_roundtrip(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={"text": "Bonjour à tous"})
|
||||
assert r.status_code == 200
|
||||
msg = r.json()["message"]
|
||||
assert msg["text"] == "Bonjour à tous"
|
||||
assert msg["user"] # authenticated user recorded
|
||||
got = client.get("/api/chat").json()["messages"]
|
||||
assert [m["id"] for m in got] == [msg["id"]]
|
||||
|
||||
def test_empty_text_rejected(self, client, test_vault_dir):
|
||||
assert client.post("/api/chat", json={"text": " "}).status_code == 400
|
||||
|
||||
def test_general_chat_isolated_from_file_chats(self, client, test_vault_dir):
|
||||
client.post("/api/chat", json={"text": "général"})
|
||||
client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": "fichier"})
|
||||
assert len(client.get("/api/chat").json()["messages"]) == 1
|
||||
assert len(client.get("/api/file/TestVault/chat", params={"path": "note1.md"}).json()["messages"]) == 1
|
||||
|
||||
def test_unknown_attachment_rejected(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={
|
||||
"text": "pièce jointe",
|
||||
"attachment": {"name": "nope.png", "url": "javascript:alert(1)", "kind": "image"},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_external_url_attachment_accepted(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={
|
||||
"text": "un lien",
|
||||
"attachment": {"name": "", "url": "https://example.com/x.png", "kind": "image"},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["url"] == "https://example.com/x.png"
|
||||
|
||||
|
||||
class TestAttachments:
|
||||
def test_upload_and_serve_roundtrip(self, client, test_vault_dir):
|
||||
files = {"file": ("shot.png", b"\x89PNG\r\n\x1a\nfake", "image/png")}
|
||||
r = client.post("/api/chat/upload", files=files)
|
||||
assert r.status_code == 200
|
||||
att = r.json()["attachment"]
|
||||
assert att["kind"] == "image"
|
||||
assert att["mime"] == "image/png"
|
||||
# served back with the right content type
|
||||
r2 = client.get(f"/api/chat/attachment/{att['name']}")
|
||||
assert r2.status_code == 200
|
||||
assert r2.headers["content-type"].startswith("image/png")
|
||||
|
||||
def test_video_kind_detection(self, client, test_vault_dir):
|
||||
files = {"file": ("clip.mp4", b"\x00\x00\x00\x18ftyp", "video/mp4")}
|
||||
att = client.post("/api/chat/upload", files=files).json()["attachment"]
|
||||
assert att["kind"] == "video"
|
||||
|
||||
def test_extension_not_allowed(self, client, test_vault_dir):
|
||||
files = {"file": ("evil.exe", b"MZ...", "application/octet-stream")}
|
||||
assert client.post("/api/chat/upload", files=files).status_code == 400
|
||||
|
||||
def test_empty_file_rejected(self, client, test_vault_dir):
|
||||
files = {"file": ("empty.png", b"", "image/png")}
|
||||
assert client.post("/api/chat/upload", files=files).status_code == 400
|
||||
|
||||
def test_attachment_traversal_rejected(self, client, test_vault_dir):
|
||||
# A traversal/unknown name must never produce a media response: the
|
||||
# route 404s, or the SPA catch-all answers HTML (200 + text/html).
|
||||
for bad in ("../secret.png", "sub/evil.png", "..%2Fsecret.png", "nope.png"):
|
||||
r = client.get(f"/api/chat/attachment/{bad}")
|
||||
ct = r.headers.get("content-type", "")
|
||||
assert not ct.startswith(("image/", "video/")), (bad, r.status_code, ct)
|
||||
assert r.status_code in (404, 400, 422, 200), bad
|
||||
|
||||
def test_message_with_uploaded_attachment(self, client, test_vault_dir):
|
||||
files = {"file": ("pic.webp", b"RIFF0000WEBP", "image/webp")}
|
||||
att = client.post("/api/chat/upload", files=files).json()["attachment"]
|
||||
r = client.post("/api/chat", json={"text": "voici", "attachment": att})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["kind"] == "image"
|
||||
|
||||
Reference in New Issue
Block a user