feat: chat — saisie auto-agrandissante, accusé de réception ✓✓, vignette de tuile #192 (BUG-109)
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 17m1s

This commit is contained in:
2026-10-08 23:28:08 -04:00
parent f00a8bea8f
commit 54caa48d1f
22 changed files with 645 additions and 52 deletions
+62 -2
View File
@@ -25,7 +25,7 @@ const dom = new JSDOM(
<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>
<form id="sidebar-panel-chat-form"><textarea id="sidebar-panel-chat-input"></textarea></form>
</div>
<button id="sidebar-tab-chat"></button>
</body></html>`,
@@ -77,7 +77,7 @@ const mod = await import("../../frontend/js/filechat.js");
const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
onChatDeleted,
onChatDeleted, onChatRead,
} = mod;
let pass = 0;
@@ -278,6 +278,66 @@ test("#191 — DM routed by pair path, isolated from general", async () => {
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before);
});
// --- #192 : accusé de réception + boîte de saisie auto-agrandie --------------
test("#192 — receipt shows ✓ then ✓✓ once another participant read it", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "r1", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, {});
let st = list.querySelector(".file-chat-status");
assert.strictEqual(st.textContent, "✓");
assert.strictEqual(st.classList.contains("read"), false);
// MY OWN read timestamp must never count as "the peer read it"
renderChatMessages(list, [{ id: "r2", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { bruno: 500 });
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
renderChatMessages(list, [{ id: "r3", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { alice: 150 });
st = list.querySelector(".file-chat-status");
assert.strictEqual(st.textContent, "✓✓");
assert.strictEqual(st.classList.contains("read"), true);
// messages of others carry no receipt
renderChatMessages(list, [{ id: "r4", user: "alice", text: "ok", ts: 100 }], "bruno", null, {});
assert.strictEqual(list.querySelector(".file-chat-status"), null);
});
test("#192 — onChatRead flips ✓ → ✓✓ live and notifies the sender", async () => {
await openSidebarChat();
const list = document.getElementById("sidebar-panel-chat-list");
renderChatMessages(list, [{ id: "s1", user: "bruno", text: "msg", ts: 200 }], "bruno", "/api/chat", {});
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
const container = document.getElementById("toast-container");
const before = container.children.length;
onChatRead({ vault: "__global__", path: "general", user: "alice", ts: 300, read: { bruno: 250, alice: 300 } });
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓✓");
assert.ok(container.children.length > before, "notification de lecture affichée");
// notre propre écho ne notifie pas
const same = container.children.length;
onChatRead({ vault: "__global__", path: "general", user: "bruno", ts: 310, read: { bruno: 310 } });
assert.strictEqual(container.children.length, same);
});
test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => {
assert.strictEqual(document.getElementById("sidebar-panel-chat-input").tagName, "TEXTAREA");
await openFileChat("V", "grow.md");
const panel = document.getElementById("file-chat-panel");
const input = panel.querySelector(".file-chat-input");
assert.strictEqual(input.tagName, "TEXTAREA");
assert.strictEqual(input.rows, 1);
input.value = "ligne1\nligne2";
input.dispatchEvent(new w.Event("input", { bubbles: true }));
assert.ok(input.style.height, "hauteur pilotée en JS");
let prevented = false;
const ev = new w.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
Object.defineProperty(ev, "preventDefault", { value: () => { prevented = true; } });
input.value = ""; // rien à envoyer : on ne teste que le geste
input.dispatchEvent(ev);
assert.strictEqual(prevented, true, "Enter (sans Shift) est intercepté");
const nl = new w.KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true });
let nlPrevented = false;
Object.defineProperty(nl, "preventDefault", { value: () => { nlPrevented = true; } });
input.dispatchEvent(nl);
assert.strictEqual(nlPrevented, false, "Shift+Enter garde le retour à la ligne");
closeFileChat();
});
// Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) {
try {
+117 -2
View File
@@ -85,7 +85,7 @@ 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": []}
assert r.json() == {"messages": [], "read": {}}
def test_post_and_get_roundtrip(self, client, test_vault_dir):
r = client.post(
@@ -137,7 +137,7 @@ 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": []}
assert r.json() == {"messages": [], "read": {}}
def test_post_and_get_roundtrip(self, client, test_vault_dir):
r = client.post("/api/chat", json={"text": "Bonjour à tous"})
@@ -364,3 +364,118 @@ class TestLinkPreview:
"image": "",
"site": "Site",
}
def test_preview_image_is_proxied_same_origin(self, monkeypatch):
"""BUG-109: a remote og:image is blocked by ``img-src 'self'``.
A *relative* og:image must be resolved against the page and downloaded
into ``chat_uploads`` so the browser can load it same-origin.
"""
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
from backend.tools import web as _web
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
class _Page:
status_code = 200
text = (
'<meta property="og:title" content="Titre">'
'<meta property="og:image" content="/img/pic.png">'
)
class _Img:
status_code = 200
content = b"\x89PNG-fake-bytes"
headers = {"content-type": "image/png"}
def _fake_get(url, **kw):
return _Img() if url.endswith(".png") else _Page()
monkeypatch.setattr(_store.httpx, "get", _fake_get)
preview = _store.build_preview("voir https://site.test/a")
assert preview["title"] == "Titre"
assert preview["image"].startswith("/api/chat/attachment/")
assert preview["image"].endswith(".png")
stored = _store.UPLOAD_DIR / preview["image"].rsplit("/", 1)[1]
assert stored.exists()
assert stored.read_bytes() == b"\x89PNG-fake-bytes"
def test_preview_image_failure_keeps_the_card(self, monkeypatch):
"""A dead thumbnail degrades to an empty image, never a lost card."""
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
from backend.tools import web as _web
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
class _Page:
status_code = 200
text = (
'<meta property="og:title" content="Titre">'
'<meta property="og:image" content="https://cdn.test/broken.jpg">'
)
class _Dead:
status_code = 404
content = b""
headers = {}
def _fake_get(url, **kw):
return _Dead() if url.endswith(".jpg") else _Page()
monkeypatch.setattr(_store.httpx, "get", _fake_get)
preview = _store.build_preview("voir https://site.test/b")
assert preview["title"] == "Titre"
assert preview["image"] == ""
# --- #192 : accusé de réception ---------------------------------------------
class TestReadReceipts:
def test_mark_read_roundtrip(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
assert _store.get_read("V", "a.md") == {}
read = _store.mark_read("V", "a.md", "bruno")
assert read["bruno"] > 0
assert _store.get_read("V", "a.md") == read
def test_mark_read_preserves_messages(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
_store.add_message("V", "a.md", "alice", "salut")
_store.mark_read("V", "a.md", "bruno")
assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["salut"]
assert "bruno" in _store.get_read("V", "a.md")
def test_post_read_records_for_the_caller(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
assert r.status_code == 200, r.text
read = r.json()["read"]
assert read["anonymous"] > 0 # auth disabled in tests → fake user
# the history endpoint exposes the same map (#192)
assert client.get("/api/chat").json()["read"] == read
def test_post_read_refuses_a_dm_we_are_not_in(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "__dm__", "path": "alice|bob"})
assert r.status_code == 403
def test_post_read_requires_vault_and_path(self, client, test_vault_dir):
assert client.post("/api/chat/read", json={}).status_code == 400
def test_post_read_follows_vault_acl(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "TestVault", "path": "note1.md"})
assert r.status_code == 200, r.text
assert "anonymous" in r.json()["read"]
def test_post_read_broadcasts_chat_read(self, client, test_vault_dir, monkeypatch):
from backend.routers import file_chat as _router
events = []
class _Spy:
async def broadcast(self, event, data):
events.append((event, data))
monkeypatch.setattr(_router, "sse_manager", _Spy())
client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
assert [e for e, _ in events] == ["chat_read"]
assert events[0][1]["user"] == "anonymous"
assert events[0][1]["vault"] == "__global__"