Add DM chat send/list endpoints: filter, rate limit, conversations (US-4)
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.
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user