Files
ObsiGate/tests/frontend/filechat.test.mjs
T
bruno f00a8bea8f
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 17m11s
feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
2026-10-08 22:33:29 -04:00

293 lines
14 KiB
JavaScript

#!/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>
<div class="sidebar-tab-panel active" id="sidebar-panel-chat">
<div id="sidebar-panel-chat-list" class="file-chat-list"></div>
<div id="sidebar-panel-chat-pending"></div>
<form id="sidebar-panel-chat-form"><input id="sidebar-panel-chat-input" /></form>
</div>
<button id="sidebar-tab-chat"></button>
</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();
// #191 — the delete right needs a logged-in identity
const { AuthManager } = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href);
AuthManager.getUser = () => ({ username: "bruno", role: "user" });
const mod = await import("../../frontend/js/filechat.js");
const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
onChatDeleted,
} = 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, "&lt;b&gt;x&lt;/b&gt;");
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();
});
test("#190 A — followFileChat re-targets the open panel", async () => {
await openFileChat("V", "one.md", "Doc Un");
const panel = document.getElementById("file-chat-panel");
assert.strictEqual(panel.classList.contains("hidden"), false);
followFileChat("V", "two.md", "Doc Deux");
await new Promise((r) => setTimeout(r, 20));
const target = panel.querySelector(".file-chat-target");
assert.strictEqual(target.textContent, "Doc Deux");
assert.strictEqual(target.title, "two.md");
closeFileChat();
followFileChat("V", "three.md", "Doc Trois");
await new Promise((r) => setTimeout(r, 20));
assert.strictEqual(panel.querySelector(".file-chat-target").textContent, "Doc Deux");
});
test("#190 B — openSidebarChat renders the general history", async () => {
await openSidebarChat();
const list = document.getElementById("sidebar-panel-chat-list");
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
});
test("#190 B — onChatMessage routes __global__ to the sidebar list", async () => {
const list = document.getElementById("sidebar-panel-chat-list");
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "salut tout le monde");
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
});
test("#190 C — filterChatMessages narrows by text/user", async () => {
onChatMessage({ vault: "__global__", path: "general", message: { id: "f1", user: "alice", text: "recette de pizza", ts: 10 } });
onChatMessage({ vault: "__global__", path: "general", message: { id: "f2", user: "bob", text: "deploiement docker", ts: 11 } });
const list = document.getElementById("sidebar-panel-chat-list");
filterChatMessages("pizza");
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "recette de pizza");
filterChatMessages("bob"); // la recherche couvre aussi l'auteur
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
filterChatMessages("");
assert.ok(list.querySelectorAll(".file-chat-msg").length >= 3);
});
test("#190 — URL rendered as a link, surrounding text stays plain", async () => {
const list = document.getElementById("sidebar-panel-chat-list");
onChatMessage({ vault: "__global__", path: "general", message: { id: "u1", user: "alice", text: "voici https://example.com/doc et du texte", ts: 20 } });
const msg = list.querySelector('[data-id="u1"]');
const a = msg.querySelector("a.file-chat-link");
assert.strictEqual(a.href, "https://example.com/doc");
assert.strictEqual(a.target, "_blank");
assert.ok(msg.textContent.includes("du texte"));
});
test("#191 — preview card rendered with site/title/description", () => {
const list = document.createElement("div");
renderChatMessages(list, [{
id: "p1", user: "alice", text: "voir https://example.com", ts: 40,
preview: { url: "https://example.com", title: "Mon site", description: "Le desc", image: "", site: "example.com" },
}], "bruno");
const card = list.querySelector("a.file-chat-preview");
assert.ok(card, "carte preview présente");
assert.strictEqual(card.href, "https://example.com/");
assert.strictEqual(card.target, "_blank");
assert.strictEqual(card.querySelector(".file-chat-preview-title").textContent, "Mon site");
assert.strictEqual(card.querySelector(".file-chat-preview-desc").textContent, "Le desc");
assert.strictEqual(card.querySelector(".file-chat-preview-site").textContent, "example.com");
renderChatMessages(list, [{ id: "p2", user: "alice", text: "https://x.test", ts: 41 }], "bruno");
assert.strictEqual(list.querySelector("a.file-chat-preview"), null);
});
test("#191 — delete button hidden without delUrl, shown for own post", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno");
assert.strictEqual(list.querySelector(".file-chat-del"), null);
renderChatMessages(list, [{ id: "d2", user: "bruno", text: "x", ts: 51 }], "bruno", "/api/chat");
assert.ok(list.querySelector(".file-chat-del"), "bouton suppression visible");
renderChatMessages(list, [{ id: "d3", user: "alice", text: "x", ts: 52 }], "bruno", "/api/chat");
assert.strictEqual(list.querySelector(".file-chat-del"), null);
});
test("#191 — onChatDeleted removes the message from any list", async () => {
await openSidebarChat();
const list = document.getElementById("sidebar-panel-chat-list");
onChatMessage({ vault: "__global__", path: "general", message: { id: "del1", user: "alice", text: "a bientot", ts: 60 } });
assert.strictEqual(list.querySelector('[data-id="del1"]') !== null, true);
onChatDeleted({ vault: "__global__", path: "general", id: "del1" });
assert.strictEqual(list.querySelector('[data-id="del1"]'), null);
});
test("#191 — unread counters accumulate then reset on open", async () => {
localStorage.removeItem("obsigate-chat-unread");
const panel = document.getElementById("sidebar-panel-chat");
panel.classList.remove("active");
onChatMessage({ vault: "__global__", path: "general", message: { id: "u-1", user: "bob", text: "1", ts: 70 } });
onChatMessage({ vault: "__global__", path: "general", message: { id: "u-2", user: "bob", text: "2", ts: 71 } });
let u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
assert.strictEqual(u.general, 2);
refreshUnreadBadge();
assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge").textContent, "2");
panel.classList.add("active");
await openSidebarChat();
u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
assert.strictEqual(u.general, 0);
assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge"), null);
});
test("#191 — DM routed by pair path, isolated from general", async () => {
const list = document.getElementById("sidebar-panel-chat-list");
const before = list.querySelectorAll(".file-chat-msg").length;
onChatMessage({ vault: "__dm__", path: "alice|bruno", message: { id: "dm-1", user: "alice", text: "prive", ts: 80 } });
const u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
assert.strictEqual(u["dm:alice"], 1);
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before);
});
// 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`);