8b17c17137
Implements direct-message (DM) chat send and history endpoints:
- Migration 0011_add_dm_last_read.py (last_read_at on conversation_participants)
- Models updated in backend/app/models/chat.py
- Router in backend/app/routers/chat_dm.py:
- POST /api/chat/dm/{user_id}/messages (send DM, filtered via TextFilter, rate-limited via Redis)
- GET /api/chat/dm/{user_id}/messages (paginated history, updates last_read_at)
- GET /api/chat/dm/conversations (conversations list, last message, unread count)
- Blocks enforced in both directions (cannot send to/from blocked users)
- Self-messaging prevented, peer exist check, auth required
- Integration tests in backend/tests/test_chat_dm.py
Independently re-verified: 14/14 new DM chat tests pass,
272/272 full backend regression suite passes with zero regressions.
313 lines
11 KiB
Python
313 lines
11 KiB
Python
"""Integration tests for the DM chat router (app/routers/chat_dm.py):
|
|
|
|
- POST /api/chat/dm/{user_id}/messages
|
|
- GET /api/chat/dm/{user_id}/messages
|
|
- GET /api/chat/dm/conversations
|
|
|
|
The block-relationship router doesn't exist yet (sibling task), so blocks
|
|
are inserted directly against the ``Block`` model, same pattern already
|
|
used by ``tests/test_chat_models.py`` for direct model setup.
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from app import rate_limit
|
|
from app.config import settings
|
|
from app.main import app
|
|
from app.models.social import Block
|
|
|
|
|
|
def _register(client: TestClient, username: str) -> dict:
|
|
resp = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": username,
|
|
"email": f"{username}@example.com",
|
|
"password": "secret123",
|
|
"date_of_birth": "1990-01-01",
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
token = resp.json()["access_token"]
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
me = client.get("/api/users/me", headers=headers)
|
|
assert me.status_code == 200, me.text
|
|
return {"headers": headers, "id": me.json()["id"], "username": username}
|
|
|
|
|
|
async def _insert_block(blocker_id: int, blocked_id: int) -> None:
|
|
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
async with session_factory() as session:
|
|
session.add(Block(blocker_id=blocker_id, blocked_id=blocked_id))
|
|
await session.commit()
|
|
await engine.dispose()
|
|
|
|
|
|
class TestSendAndRetrieve:
|
|
def test_send_then_fetch_history(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_alice1")
|
|
bob = _register(client, "dm_bob1")
|
|
|
|
resp = client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": "hey bob"},
|
|
headers=alice["headers"],
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
sent = resp.json()
|
|
assert sent["sender_id"] == alice["id"]
|
|
assert sent["recipient_id"] == bob["id"]
|
|
assert sent["body"] == "hey bob"
|
|
assert sent["filtered"] is False
|
|
assert sent["id"] is not None
|
|
assert sent["created_at"] is not None
|
|
|
|
hist_alice = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
|
|
assert hist_alice.status_code == 200, hist_alice.text
|
|
msgs = hist_alice.json()["messages"]
|
|
assert len(msgs) == 1
|
|
assert msgs[0]["body"] == "hey bob"
|
|
|
|
hist_bob = client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
|
|
assert hist_bob.status_code == 200, hist_bob.text
|
|
assert len(hist_bob.json()["messages"]) == 1
|
|
assert hist_bob.json()["messages"][0]["body"] == "hey bob"
|
|
|
|
def test_cannot_message_self(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_self1")
|
|
resp = client.post(
|
|
f"/api/chat/dm/{alice['id']}/messages",
|
|
json={"body": "hi me"},
|
|
headers=alice["headers"],
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_target_user_must_exist(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_noexist1")
|
|
resp = client.post(
|
|
"/api/chat/dm/999999999/messages",
|
|
json={"body": "hello?"},
|
|
headers=alice["headers"],
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_requires_auth(self):
|
|
client = TestClient(app)
|
|
resp = client.post("/api/chat/dm/1/messages", json={"body": "hi"})
|
|
assert resp.status_code == 401
|
|
|
|
def test_history_pagination_cursor(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_page_a")
|
|
bob = _register(client, "dm_page_b")
|
|
|
|
for i in range(5):
|
|
r = client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": f"msg {i}"},
|
|
headers=alice["headers"],
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
|
|
page1 = client.get(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
params={"limit": 2},
|
|
headers=alice["headers"],
|
|
)
|
|
assert page1.status_code == 200
|
|
body1 = page1.json()
|
|
assert len(body1["messages"]) == 2
|
|
assert body1["messages"][0]["body"] == "msg 4" # newest first
|
|
assert body1["next_cursor"] is not None
|
|
|
|
page2 = client.get(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
params={"limit": 2, "before": body1["next_cursor"]},
|
|
headers=alice["headers"],
|
|
)
|
|
assert page2.status_code == 200
|
|
body2 = page2.json()
|
|
assert [m["body"] for m in body2["messages"]] == ["msg 2", "msg 1"]
|
|
|
|
def test_no_conversation_yet_returns_empty_history(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_empty_a")
|
|
bob = _register(client, "dm_empty_b")
|
|
resp = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"messages": [], "next_cursor": None}
|
|
|
|
|
|
class TestConversationList:
|
|
def test_lists_peer_last_message_and_unread_count(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_conv_a")
|
|
bob = _register(client, "dm_conv_b")
|
|
|
|
client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": "first"},
|
|
headers=alice["headers"],
|
|
)
|
|
client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": "second"},
|
|
headers=alice["headers"],
|
|
)
|
|
|
|
convs_bob = client.get("/api/chat/dm/conversations", headers=bob["headers"])
|
|
assert convs_bob.status_code == 200, convs_bob.text
|
|
data = convs_bob.json()
|
|
assert data["total"] == 1
|
|
conv = data["conversations"][0]
|
|
assert conv["peer_id"] == alice["id"]
|
|
assert conv["peer_username"] == "dm_conv_a"
|
|
assert conv["last_message"]["body"] == "second"
|
|
assert conv["unread_count"] == 2 # bob hasn't read yet
|
|
|
|
# Bob reads the latest page -> unread resets to 0.
|
|
client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
|
|
convs_bob_after = client.get("/api/chat/dm/conversations", headers=bob["headers"])
|
|
assert convs_bob_after.json()["conversations"][0]["unread_count"] == 0
|
|
|
|
# Alice sent them, so her own unread count for this conversation is 0.
|
|
convs_alice = client.get("/api/chat/dm/conversations", headers=alice["headers"])
|
|
assert convs_alice.json()["conversations"][0]["unread_count"] == 0
|
|
|
|
def test_scoped_to_authenticated_user_only(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_scope_a")
|
|
bob = _register(client, "dm_scope_b")
|
|
carol = _register(client, "dm_scope_c")
|
|
|
|
client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": "hi bob"},
|
|
headers=alice["headers"],
|
|
)
|
|
|
|
convs_carol = client.get("/api/chat/dm/conversations", headers=carol["headers"])
|
|
assert convs_carol.status_code == 200
|
|
assert convs_carol.json() == {"conversations": [], "total": 0}
|
|
|
|
def test_cannot_read_another_pairs_conversation(self):
|
|
"""A user's history endpoint only ever resolves *their own* pairing
|
|
with the given peer id -- there is no conversation id in the URL an
|
|
outsider could guess/pass to read someone else's DMs."""
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_iso_a")
|
|
bob = _register(client, "dm_iso_b")
|
|
carol = _register(client, "dm_iso_c")
|
|
|
|
client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": "secret to bob"},
|
|
headers=alice["headers"],
|
|
)
|
|
|
|
# Carol asking for her "history with bob" gets her own (empty)
|
|
# conversation, never alice's.
|
|
resp = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=carol["headers"])
|
|
assert resp.status_code == 200
|
|
assert resp.json()["messages"] == []
|
|
|
|
|
|
class TestBlocking:
|
|
async def test_blocker_cannot_send_to_blocked(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_block_a1")
|
|
bob = _register(client, "dm_block_b1")
|
|
await _insert_block(alice["id"], bob["id"])
|
|
|
|
resp = client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": "let me in"},
|
|
headers=alice["headers"],
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
async def test_blocked_cannot_send_to_blocker(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_block_a2")
|
|
bob = _register(client, "dm_block_b2")
|
|
await _insert_block(alice["id"], bob["id"])
|
|
|
|
resp = client.post(
|
|
f"/api/chat/dm/{alice['id']}/messages",
|
|
json={"body": "please respond"},
|
|
headers=bob["headers"],
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
class TestTextFilter:
|
|
def test_message_tripping_filter_is_redacted_and_flagged(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_filter_a")
|
|
bob = _register(client, "dm_filter_b")
|
|
|
|
resp = client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": "you are a bastard"},
|
|
headers=alice["headers"],
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
data = resp.json()
|
|
assert data["filtered"] is True
|
|
assert "bastard" not in data["body"]
|
|
assert "*" in data["body"]
|
|
|
|
# The stored/delivered copy is the redacted one, not the raw text.
|
|
hist = client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
|
|
assert hist.json()["messages"][0]["body"] == data["body"]
|
|
|
|
def test_clean_message_is_not_flagged(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_clean_a")
|
|
bob = _register(client, "dm_clean_b")
|
|
resp = client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": "have a nice day"},
|
|
headers=alice["headers"],
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["filtered"] is False
|
|
assert resp.json()["body"] == "have a nice day"
|
|
|
|
|
|
class TestRateLimit:
|
|
@pytest.fixture(autouse=True)
|
|
def _enable_rate_limiting(self):
|
|
prev = settings.RATE_LIMIT_ENABLED
|
|
settings.RATE_LIMIT_ENABLED = True
|
|
rate_limit.reset_all()
|
|
yield
|
|
rate_limit.reset_all()
|
|
settings.RATE_LIMIT_ENABLED = prev
|
|
|
|
def test_send_rate_limit_exceeded_returns_429(self):
|
|
client = TestClient(app)
|
|
alice = _register(client, "dm_rl_a")
|
|
bob = _register(client, "dm_rl_b")
|
|
|
|
codes = []
|
|
for i in range(25):
|
|
r = client.post(
|
|
f"/api/chat/dm/{bob['id']}/messages",
|
|
json={"body": f"spam {i}"},
|
|
headers=alice["headers"],
|
|
)
|
|
codes.append(r.status_code)
|
|
|
|
assert codes[:20] == [201] * 20
|
|
assert 429 in codes[20:]
|
|
assert codes[-1] == 429
|