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