diff --git a/backend/alembic/versions/0011_add_dm_last_read.py b/backend/alembic/versions/0011_add_dm_last_read.py new file mode 100644 index 0000000..2309a08 --- /dev/null +++ b/backend/alembic/versions/0011_add_dm_last_read.py @@ -0,0 +1,36 @@ +"""add last_read_at to conversation_participants (DM unread tracking) + +Revision ID: 0011_add_dm_last_read +Revises: 0010_add_profile_tables +Create Date: 2026-09-15 00:00:00.000000 + +app/routers/chat_dm.py (DM send/list/conversations endpoints). Unread-count +tracking is implemented as a last-read timestamp per participant: whenever a +user fetches a conversation's message history, their +``conversation_participants`` row's ``last_read_at`` is bumped to "now", and +the conversation-list endpoint counts messages with +``created_at > last_read_at`` (or all messages if never read) as unread for +that participant. Nullable/no default so existing rows (none yet in +practice, but consistent with "never read") read as "everything unread". +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0011_add_dm_last_read" +down_revision: Union[str, None] = "0010_add_profile_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "conversation_participants", + sa.Column("last_read_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("conversation_participants", "last_read_at") diff --git a/backend/app/main.py b/backend/app/main.py index de181a0..a371c4d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles +from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm from app.services.game_servers import supervisor @@ -48,6 +48,7 @@ app.include_router(presence.router, prefix="/api/presence", tags=["presence"]) app.include_router(groups.router, prefix="/api/groups", tags=["groups"]) app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"]) app.include_router(profiles.router, prefix="/api/profile", tags=["profile"]) +app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"]) @app.get("/api/health") diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py index c9499c4..7cffc51 100644 --- a/backend/app/models/chat.py +++ b/backend/app/models/chat.py @@ -121,6 +121,10 @@ class ConversationParticipant(Base): Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + # DM unread tracking (app/routers/chat_dm.py, migration 0010): bumped to + # "now" whenever this participant fetches the conversation's history. + # Null means "never read" -- everything in the conversation is unread. + last_read_at = Column(DateTime(timezone=True), nullable=True) conversation = relationship("Conversation", back_populates="participants") user = relationship("User") diff --git a/backend/app/routers/chat_dm.py b/backend/app/routers/chat_dm.py new file mode 100644 index 0000000..4d83465 --- /dev/null +++ b/backend/app/routers/chat_dm.py @@ -0,0 +1,396 @@ +"""Direct-message (DM) chat router. + +docs/planning/social.md §4/§5 (US-4). Builds the send/list REST surface on +top of ``app/models/chat.py`` (Conversation/ConversationParticipant/Message, +already merged), ``app/services/text_filter.py`` (already merged), and +``app/rate_limit.py`` (already merged). + +Endpoints: +- POST /api/chat/dm/{user_id}/messages -- send a DM to ``user_id`` +- GET /api/chat/dm/{user_id}/messages -- paginated history with ``user_id`` +- GET /api/chat/dm/conversations -- list the caller's conversations + +Scope/boundaries (per task): REST send/list only, no WebSocket/real-time +delivery (separate task), no world/room chat (separate sibling task; that +uses the same ``Message`` table but with ``world_id``/``room_id`` set and +``conversation_id`` null). + +Design decisions this task owns: + +- **Blocking.** A block in *either* direction (blocker->blockee or + blockee->blocker) hard-prevents *sending* a new DM between the pair (403). + It does not hide history the pair already has -- ``GET .../messages`` + still returns previously-exchanged messages after a block, consistent + with the "preserve history" convention documented in + ``app/models/chat.py``'s module docstring. Only sending is blocked. +- **Text filtering.** Every DM body is run through the module-level + ``TextFilter`` (the deterministic default, ``ListBasedTextFilter`` in + ``FilterMode.REDACT``) before storing: + - ALLOW -> store the body as-is, ``filtered=False``. + - REDACT -> store ``filtered_text`` (matched spans starred out), + ``filtered=True``. + - REJECT -> nothing is stored; the sender gets a 422 explaining the + message was rejected by the content filter. (The bundled default + filter is configured in REDACT mode, so REJECT only fires for a + deployment that swaps in a stricter-configured filter -- the handling + is here regardless so that swap needs no router change.) +- **Rate limiting.** Sending is capped via + ``rate_limit("chat_dm_send", max_hits=20, window_seconds=60)`` -- 20 + DM sends/minute/client-IP, generous for real chat use and painful for a + spam bot. A no-op when ``settings.RATE_LIMIT_ENABLED`` is false (see + ``app/rate_limit.py``), matching every other rate-limited endpoint. +- **Unread tracking.** A last-read timestamp per participant + (``ConversationParticipant.last_read_at``, migration 0010). Fetching a + conversation's *latest* page of history (``GET .../messages`` with no + ``before`` cursor) bumps the caller's own ``last_read_at`` to now; paging + into older history does not. ``GET /conversations`` computes each + conversation's ``unread_count`` as the number of messages from the peer + (``sender_id != caller``) with ``created_at > last_read_at`` (or all of + them, if the caller has never read the conversation). +- **Pagination.** + - History (``GET {user_id}/messages``): cursor-based, newest-first. + ``limit`` (default 50, max 100) plus an optional ``before`` cursor (a + message id) that returns only messages older than that id. Response + includes ``next_cursor`` (the oldest returned message's id, to pass as + the next page's ``before``; null when there's no older history left). + - Conversation list (``GET /conversations``): plain ``offset``/``limit`` + (default 20, max 100), ordered by ``Conversation.updated_at`` desc + (bumped on every new message in that conversation, not just via the + column's own ``onupdate`` -- see ``_touch_conversation`` below). +- Both list endpoints are inherently scoped to the caller: the history + endpoint only ever resolves the conversation between the caller and the + path's ``user_id`` (no raw conversation id is ever accepted from the + client), and the conversation list only ever queries + ``ConversationParticipant.user_id == caller``. There is no way for a + client to address another pair's conversation. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import CurrentUser +from app.models.chat import Conversation, ConversationParticipant, Message +from app.models.social import Block +from app.models.user import User +from app.rate_limit import rate_limit +from app.services.text_filter import FilterAction, default_text_filter + +router = APIRouter() + +DbDep = Annotated[AsyncSession, Depends(get_db)] + +_text_filter = default_text_filter() +chat_dm_send_rate_limit = rate_limit("chat_dm_send", max_hits=20, window_seconds=60) + +_BODY_MAX_LEN = 4000 + +# ──────────────────────────── Pydantic schemas ──────────────────────────── + + +class SendDMRequest(BaseModel): + body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN) + + +class DMMessageResponse(BaseModel): + id: int + conversation_id: int + sender_id: int + recipient_id: int + body: str + filtered: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class DMMessageListResponse(BaseModel): + messages: list[DMMessageResponse] + next_cursor: int | None = None + + +class DMConversationSummary(BaseModel): + conversation_id: int + peer_id: int + peer_username: str + last_message: DMMessageResponse | None + unread_count: int + updated_at: datetime + + +class DMConversationListResponse(BaseModel): + conversations: list[DMConversationSummary] + total: int + + +# ──────────────────────────────── Helpers ────────────────────────────────── + + +async def _get_target_user(db: AsyncSession, user_id: int) -> User: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + + +async def _is_blocked_either_direction(db: AsyncSession, user_a: int, user_b: int) -> bool: + result = await db.execute( + select(Block.id).where( + or_( + and_(Block.blocker_id == user_a, Block.blocked_id == user_b), + and_(Block.blocker_id == user_b, Block.blocked_id == user_a), + ) + ).limit(1) + ) + return result.scalar_one_or_none() is not None + + +async def _get_or_create_conversation( + db: AsyncSession, user_a_id: int, user_b_id: int +) -> Conversation: + lo, hi = sorted((user_a_id, user_b_id)) + result = await db.execute( + select(Conversation).where( + Conversation.user_a_id == lo, Conversation.user_b_id == hi + ) + ) + conversation = result.scalar_one_or_none() + if conversation is not None: + return conversation + + conversation = Conversation(user_a_id=lo, user_b_id=hi) + db.add(conversation) + await db.flush() # assign conversation.id + db.add(ConversationParticipant(conversation_id=conversation.id, user_id=lo)) + db.add(ConversationParticipant(conversation_id=conversation.id, user_id=hi)) + await db.flush() + return conversation + + +async def _get_participant( + db: AsyncSession, conversation_id: int, user_id: int +) -> ConversationParticipant | None: + result = await db.execute( + select(ConversationParticipant).where( + ConversationParticipant.conversation_id == conversation_id, + ConversationParticipant.user_id == user_id, + ) + ) + return result.scalar_one_or_none() + + +def _peer_id(conversation: Conversation, self_id: int) -> int: + return conversation.user_b_id if conversation.user_a_id == self_id else conversation.user_a_id + + +def _touch_conversation(conversation: Conversation) -> None: + """Bump ``updated_at`` explicitly. + + ``Conversation.updated_at`` has ``onupdate=func.now()``, but that only + fires when an UPDATE statement touches the ``conversations`` row itself + -- inserting a ``Message`` row alone never does. Explicitly dirtying the + column here is what actually keeps "most recently active conversation" + ordering correct for ``GET /conversations``. + """ + conversation.updated_at = datetime.now(timezone.utc) + + +def _to_message_response(message: Message, recipient_id: int) -> DMMessageResponse: + return DMMessageResponse( + id=message.id, + conversation_id=message.conversation_id, + sender_id=message.sender_id, + recipient_id=recipient_id, + body=message.body, + filtered=message.filtered, + created_at=message.created_at, + ) + + +# ──────────────────────────────── Endpoints ──────────────────────────────── + + +@router.post( + "/{user_id}/messages", + response_model=DMMessageResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(chat_dm_send_rate_limit)], +) +async def send_dm( + user_id: int, + body: SendDMRequest, + current_user: CurrentUser, + db: DbDep, +): + """Send a DM to ``user_id``.""" + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a DM to yourself" + ) + + await _get_target_user(db, user_id) + + if await _is_blocked_either_direction(db, current_user.id, user_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot message this user", + ) + + result = _text_filter.check(body.body) + if result.action is FilterAction.REJECT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Message rejected by the content filter.", + ) + + conversation = await _get_or_create_conversation(db, current_user.id, user_id) + + message = Message( + sender_id=current_user.id, + conversation_id=conversation.id, + body=result.filtered_text, + filtered=(result.action is FilterAction.REDACT), + ) + db.add(message) + _touch_conversation(conversation) + await db.commit() + await db.refresh(message) + + return _to_message_response(message, recipient_id=user_id) + + +@router.get("/{user_id}/messages", response_model=DMMessageListResponse) +async def get_dm_history( + user_id: int, + current_user: CurrentUser, + db: DbDep, + limit: int = Query(50, ge=1, le=100), + before: int | None = Query( + None, description="Return messages older than this message id (pagination cursor)." + ), +): + """Paginated DM history between the caller and ``user_id``, newest first. + + Marks the conversation as read (bumps the caller's ``last_read_at``) only + when fetching the first page (``before`` omitted) -- paging into older + history doesn't affect unread state. + """ + await _get_target_user(db, user_id) + + lo, hi = sorted((current_user.id, user_id)) + conv_result = await db.execute( + select(Conversation).where(Conversation.user_a_id == lo, Conversation.user_b_id == hi) + ) + conversation = conv_result.scalar_one_or_none() + if conversation is None: + # No conversation yet -- nothing exchanged, not an error. + return DMMessageListResponse(messages=[], next_cursor=None) + + stmt = select(Message).where(Message.conversation_id == conversation.id) + if before is not None: + stmt = stmt.where(Message.id < before) + stmt = stmt.order_by(Message.id.desc()).limit(limit + 1) + + rows = (await db.execute(stmt)).scalars().all() + has_more = len(rows) > limit + page = rows[:limit] + next_cursor = page[-1].id if has_more and page else None + + if before is None: + participant = await _get_participant(db, conversation.id, current_user.id) + if participant is not None: + participant.last_read_at = datetime.now(timezone.utc) + await db.commit() + + return DMMessageListResponse( + messages=[_to_message_response(m, recipient_id=user_id) for m in page], + next_cursor=next_cursor, + ) + + +@router.get("/conversations", response_model=DMConversationListResponse) +async def list_conversations( + current_user: CurrentUser, + db: DbDep, + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), +): + """List the caller's DM conversations, most recently active first.""" + total_result = await db.execute( + select(func.count()) + .select_from(ConversationParticipant) + .where(ConversationParticipant.user_id == current_user.id) + ) + total = total_result.scalar_one() + + stmt = ( + select(Conversation, ConversationParticipant) + .join( + ConversationParticipant, + ConversationParticipant.conversation_id == Conversation.id, + ) + .where(ConversationParticipant.user_id == current_user.id) + .order_by(Conversation.updated_at.desc()) + .offset(offset) + .limit(limit) + ) + rows = (await db.execute(stmt)).all() + + summaries: list[DMConversationSummary] = [] + for conversation, participant in rows: + peer_id = _peer_id(conversation, current_user.id) + + peer = ( + await db.execute(select(User).where(User.id == peer_id)) + ).scalar_one_or_none() + peer_username = peer.username if peer is not None else "(deleted user)" + + last_message_row = ( + await db.execute( + select(Message) + .where(Message.conversation_id == conversation.id) + .order_by(Message.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + last_message = ( + _to_message_response( + last_message_row, + recipient_id=( + peer_id if last_message_row.sender_id == current_user.id else current_user.id + ), + ) + if last_message_row is not None + else None + ) + + unread_filters = [ + Message.conversation_id == conversation.id, + Message.sender_id != current_user.id, + ] + if participant.last_read_at is not None: + unread_filters.append(Message.created_at > participant.last_read_at) + unread_count = ( + await db.execute(select(func.count()).select_from(Message).where(*unread_filters)) + ).scalar_one() + + summaries.append( + DMConversationSummary( + conversation_id=conversation.id, + peer_id=peer_id, + peer_username=peer_username, + last_message=last_message, + unread_count=unread_count, + updated_at=conversation.updated_at, + ) + ) + + return DMConversationListResponse(conversations=summaries, total=total) diff --git a/backend/tests/test_chat_dm.py b/backend/tests/test_chat_dm.py new file mode 100644 index 0000000..afed917 --- /dev/null +++ b/backend/tests/test_chat_dm.py @@ -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