feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
This commit is contained in:
@@ -69,10 +69,15 @@ globalThis.fetch = (url) => {
|
||||
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;
|
||||
@@ -211,6 +216,68 @@ test("#190 — URL rendered as a link, surrounding text stays plain", async () =
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,13 @@ import pytest
|
||||
from backend import file_chat as _store
|
||||
|
||||
|
||||
def _login(client, username="admin", password="chab30") -> dict:
|
||||
"""Log in against the auth-enabled ``admin_client`` fixture (#191)."""
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_chat_dir(tmp_path, monkeypatch):
|
||||
"""Keep every test (store AND routes) out of the real ``data/chats``/
|
||||
@@ -207,3 +214,153 @@ class TestAttachments:
|
||||
r = client.post("/api/chat", json={"text": "voici", "attachment": att})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["kind"] == "image"
|
||||
|
||||
|
||||
# --- #191 : suppression, messages privés, link preview ----------------------
|
||||
|
||||
class TestDelete:
|
||||
def test_author_deletes_own_post(self, client, test_vault_dir):
|
||||
msg = client.post("/api/chat", json={"text": "a supprimer"}).json()["message"]
|
||||
r = client.delete(f"/api/chat/{msg['id']}")
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/chat").json()["messages"] == []
|
||||
|
||||
def test_delete_unknown_404(self, client, test_vault_dir):
|
||||
assert client.delete("/api/chat/nope").status_code == 404
|
||||
|
||||
def test_removed_message_cannot_be_deleted_twice(self, client, test_vault_dir):
|
||||
msg = client.post("/api/chat", json={"text": "x"}).json()["message"]
|
||||
client.delete(f"/api/chat/{msg['id']}")
|
||||
assert client.delete(f"/api/chat/{msg['id']}").status_code == 404
|
||||
|
||||
|
||||
class TestPrivateChat:
|
||||
"""DM flows need a real logged-in identity (auth enabled)."""
|
||||
|
||||
def test_dm_roundtrip(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/normaluser", json={"text": "salut"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["text"] == "salut"
|
||||
|
||||
def test_dm_unknown_user_404(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/ghost", json={"text": "hi"}, headers=h)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_dm_to_self_400(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/admin", json={"text": "hi"}, headers=h)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_dm_isolated_from_general(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat", json={"text": "public"}, headers=h)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "prive"}, headers=h)
|
||||
gen = admin_client.get("/api/chat", headers=h).json()["messages"]
|
||||
assert [m["text"] for m in gen] == ["public"]
|
||||
dm = admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]
|
||||
assert [m["text"] for m in dm] == ["prive"]
|
||||
|
||||
def test_dm_history(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "hystorique"}, headers=h)
|
||||
r = admin_client.get("/api/chat/dm/normaluser", headers=h)
|
||||
assert [m["text"] for m in r.json()["messages"]] == ["hystorique"]
|
||||
|
||||
def test_dm_pair_is_shared_not_duplicated(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "a"}, headers=h)
|
||||
assert _store.dm_path("admin", "normaluser") == _store.dm_path("normaluser", "admin")
|
||||
assert len(admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]) == 1
|
||||
|
||||
def test_dm_delete(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
msg = admin_client.post(
|
||||
"/api/chat/dm/normaluser", json={"text": "adieu"}, headers=h
|
||||
).json()["message"]
|
||||
r = admin_client.delete(f"/api/chat/dm/normaluser/{msg['id']}", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"] == []
|
||||
|
||||
def test_requires_auth(self, admin_client, test_vault_dir):
|
||||
assert admin_client.get("/api/chat/dm/normaluser").status_code == 401
|
||||
assert admin_client.get("/api/chat/users").status_code == 401
|
||||
|
||||
def test_user_list_for_picker(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.get("/api/chat/users", headers=h)
|
||||
assert r.status_code == 200
|
||||
names = [u["username"] for u in r.json()]
|
||||
assert "normaluser" in names
|
||||
assert "admin" not in names # oneself is not a DM target
|
||||
assert all("password" not in u and "hash" not in str(u) for u in r.json())
|
||||
|
||||
|
||||
class TestLinkPreview:
|
||||
def test_no_url_no_preview(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(_store, "build_preview", lambda text: None)
|
||||
msg = client.post("/api/chat", json={"text": "pas d url"}).json()["message"]
|
||||
# the schema always serialises the key (null) — it must stay falsy
|
||||
assert not msg.get("preview")
|
||||
|
||||
def test_preview_attached_when_fetched(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
_store, "build_preview",
|
||||
lambda text: {"url": "https://x.test", "title": "Titre", "description": "", "image": "", "site": "x.test"},
|
||||
)
|
||||
msg = client.post("/api/chat", json={"text": "voir https://x.test"}).json()["message"]
|
||||
assert msg["preview"]["title"] == "Titre"
|
||||
|
||||
def test_preview_persisted_in_history(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
_store, "build_preview",
|
||||
lambda text: {"url": "https://y.test", "title": "Y", "description": "", "image": "", "site": "y.test"},
|
||||
)
|
||||
client.post("/api/chat", json={"text": "https://y.test"})
|
||||
got = client.get("/api/chat").json()["messages"]
|
||||
assert got[-1]["preview"]["site"] == "y.test"
|
||||
|
||||
def test_build_preview_rejects_non_http(self):
|
||||
assert _store.build_preview("javascript:alert(1)") is None
|
||||
assert _store.build_preview("aucune URL") is None
|
||||
|
||||
def test_build_preview_blocks_private_hosts(self, monkeypatch):
|
||||
# SSRF guard rejects loopback before any network round-trip
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
assert _store.build_preview("http://127.0.0.1/x") is None
|
||||
|
||||
def test_build_preview_happy_path_parses_og(self, monkeypatch):
|
||||
"""The fetch itself (a bad kwarg here used to fail silently)."""
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
# the SSRF guard resolves DNS for real — out of scope for this test
|
||||
from backend.tools import web as _web
|
||||
|
||||
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = (
|
||||
"<html><head><title>Fallback</title>"
|
||||
'<meta property="og:title" content="Titre OG">'
|
||||
'<meta property="og:description" content="Desc">'
|
||||
'<meta property="og:site_name" content="Site">'
|
||||
"</head></html>"
|
||||
)
|
||||
|
||||
called = {}
|
||||
|
||||
def _fake_get(url, **kw):
|
||||
called["url"] = url
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(_store.httpx, "get", _fake_get)
|
||||
preview = _store.build_preview("voir https://example.test/page")
|
||||
assert called["url"] == "https://example.test/page"
|
||||
assert preview == {
|
||||
"url": "https://example.test/page",
|
||||
"title": "Titre OG",
|
||||
"description": "Desc",
|
||||
"image": "",
|
||||
"site": "Site",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user