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
+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__"