Merge branch 'agent/d6b3f928e9ad-dm-chat' into main
checkpoint / checkpoint (push) Successful in 4m30s

This commit is contained in:
2026-09-15 16:56:42 +00:00
5 changed files with 750 additions and 1 deletions
@@ -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")
+2 -1
View File
@@ -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")
+4
View File
@@ -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")
+396
View File
@@ -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)
+312
View File
@@ -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