feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
This commit is contained in:
@@ -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