feat: chat intégré par fichier — panneau latéral, historique, temps réel SSE #169
- 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 ».
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the per-file chat panel (#169):
|
||||
* `frontend/js/filechat.js`.
|
||||
*
|
||||
* Covers:
|
||||
* - `renderChatMessages()`: chronological render + empty state + escaping
|
||||
* - `onChatMessage()`: live append when open on the same file, dedupe by id,
|
||||
* no duplicate append when the panel shows another file
|
||||
*
|
||||
* Usage: node tests/frontend/filechat.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html><html><body><div id="toast-container"></div></body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.CSS = w.CSS || { escape: (s) => s };
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.requestAnimationFrame = w.requestAnimationFrame || ((cb) => setTimeout(cb, 16));
|
||||
globalThis.cancelAnimationFrame = w.cancelAnimationFrame || clearTimeout;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales");
|
||||
|
||||
// Route fetch: locales from disk, auth 401, API chat endpoints from a stub map.
|
||||
const apiStubs = new Map();
|
||||
globalThis.fetch = (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/static/locales/")) {
|
||||
const name = u.split("/").pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(LOCALES_DIR, name), "utf-8");
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(JSON.parse(body)) });
|
||||
} catch (_) {
|
||||
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
||||
}
|
||||
}
|
||||
if (u.includes("/api/auth/me")) {
|
||||
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({}) });
|
||||
}
|
||||
if (apiStubs.has(u)) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(apiStubs.get(u)) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ messages: [] }) });
|
||||
};
|
||||
|
||||
const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
|
||||
await initI18n();
|
||||
|
||||
const mod = await import("../../frontend/js/filechat.js");
|
||||
const { renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat } = mod;
|
||||
|
||||
let pass = 0;
|
||||
const cases = [];
|
||||
function test(name, fn) {
|
||||
cases.push([name, fn]);
|
||||
}
|
||||
|
||||
function makeList() {
|
||||
const list = document.createElement("div");
|
||||
list.className = "file-chat-list";
|
||||
return list;
|
||||
}
|
||||
|
||||
test("renderChatMessages: empty/missing shows the empty state", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(list, [], "bruno");
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0);
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
renderChatMessages(list, undefined, "bruno");
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
});
|
||||
|
||||
test("renderChatMessages: renders chronologically with mine class", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(
|
||||
list,
|
||||
[
|
||||
{ id: "1", user: "alice", text: "bonjour", ts: 1700000000 },
|
||||
{ id: "2", user: "bruno", text: "salut !", ts: 1700000060 },
|
||||
],
|
||||
"bruno",
|
||||
);
|
||||
const msgs = list.querySelectorAll(".file-chat-msg");
|
||||
assert.strictEqual(msgs.length, 2);
|
||||
assert.strictEqual(msgs[0].querySelector(".file-chat-user").textContent, "alice");
|
||||
assert.strictEqual(msgs[0].classList.contains("mine"), false);
|
||||
assert.strictEqual(msgs[1].classList.contains("mine"), true);
|
||||
assert.strictEqual(msgs[1].querySelector(".file-chat-body").textContent, "salut !");
|
||||
});
|
||||
|
||||
test("renderChatMessages: message text is text (no HTML injection)", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(list, [{ id: "1", user: "<img>", text: "<b>x</b>", ts: 1 }], "u");
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").innerHTML, "<b>x</b>");
|
||||
assert.strictEqual(list.querySelectorAll("b").length, 0);
|
||||
});
|
||||
|
||||
test("onChatMessage: appends live message to the open panel", async () => {
|
||||
await openFileChat("V", "note.md");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
// Seed an empty state so we can assert its removal
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
renderChatMessages(list, [], "bruno");
|
||||
onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty"), null);
|
||||
// Same id again → deduped
|
||||
onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
closeFileChat();
|
||||
assert.strictEqual(document.getElementById("file-chat-panel").classList.contains("hidden"), true);
|
||||
});
|
||||
|
||||
test("onChatMessage: ignores events for another file (no cross-file append)", async () => {
|
||||
await openFileChat("V", "note.md");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
renderChatMessages(list, [], "bruno");
|
||||
onChatMessage({ vault: "V", path: "OTHER.md", message: { id: "x", user: "alice", text: "no", ts: 3 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0);
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
test("toggleFileChat: opens then closes on the same file", async () => {
|
||||
await toggleFileChat("V", "t.md");
|
||||
let panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
await toggleFileChat("V", "t.md");
|
||||
panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), true);
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
// Sequential execution: each case depends on panel state left by the previous.
|
||||
for (const [name, fn] of cases) {
|
||||
try {
|
||||
await fn();
|
||||
pass++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
console.log(`\n${pass} passed`);
|
||||
@@ -39,7 +39,7 @@ test("viewer.js — group nav: TOC markdown-only, then pop-out, bookmark", () =>
|
||||
const m = viewer.match(/const navBtns = \[\];([\s\S]*?)const editBtns/);
|
||||
assert.ok(m, "navBtns block not found");
|
||||
assert.match(m[1], /if \(data\.is_markdown\) navBtns\.push\(tocBtn\);/);
|
||||
assert.match(m[1], /navBtns\.push\(openNewWindowBtn,\s*bookmarkBtn\);/);
|
||||
assert.ok(m[1].includes("navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);"), "navBtns group order");
|
||||
});
|
||||
|
||||
test("viewer.js — group edit = [edit, source, .ext, forge?, pretty?] in order", () => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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"] == []
|
||||
Reference in New Issue
Block a user