Compare commits

...

10 Commits

Author SHA1 Message Date
daddy 429cf6f46c Implement message reporting endpoint and moderation review queue (US-4)
- POST /api/chat/messages/{message_id}/report for reporting messages with category/reason
- Idempotent duplicate report rejection (409)
- GET /api/moderation/reports for queue listing (restricted to moderators/admins)
- POST /api/moderation/reports/{id}/resolve supporting dismiss, remove, warn, ban actions
- Audit fields (moderator_id, resolved_at, action) recorded on resolution
- Added 9 integration tests in backend/tests/test_chat_reports.py (all passing)
2026-09-15 17:31:43 +00:00
daddy 01a5520c20 Merge branch 'agent/5645c26acd5c-public-profile' into main
checkpoint / checkpoint (push) Successful in 4m36s
2026-09-15 17:18:29 +00:00
daddy 0b525d6996 Add public profile view endpoint: favorites, friends, privacy enforcement (US-3)
- Implemented GET /api/profile/{user_id} in backend/app/routers/profiles.py
- Server-side privacy enforcement for profile_visibility, favorites_visibility, and friends_list_visibility
- Block relationships enforce restricted view (no bio, empty favorites, empty friends)
- Non-friends restricted when profile_visibility or sections set to friends-only
- Asset public check for avatar in restricted view
- No private user fields leaked (email, password excluded)
- Added 7 integration tests in backend/tests/test_profile.py covering all privacy and block permutations

Independently verified: 26/26 profile tests passing.
2026-09-15 17:18:28 +00:00
daddy eacdeb5b46 Merge branch 'agent/d6b3f928e9ad-dm-chat' into main
checkpoint / checkpoint (push) Successful in 4m30s
2026-09-15 16:56:42 +00:00
daddy 8b17c17137 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.
2026-09-15 16:56:13 +00:00
daddy 24024a3463 Merge branch 'agent/86f6ef19b352-profile-model' into main
checkpoint / checkpoint (push) Successful in 4m8s
2026-09-15 16:44:16 +00:00
daddy 0a0dde3d50 Add Profile model + migration + own-profile endpoints (US-3)
Implements user profile data model and own-profile management endpoints:
- Profile and ProfileFavorites models in backend/app/models/profile.py
- Migration 0010_add_profile_tables.py (profiles, profile_favorites)
- Endpoints in backend/app/routers/profiles.py:
  - GET  /api/profile/me (own profile with default fallback)
  - PUT  /api/profile/me (upsert bio, avatar_asset_id, privacy settings)
  - GET  /api/profile/me/favorites (list favorite world IDs)
  - PUT  /api/profile/me/favorites (replace favorite world IDs)
- Fixes values_callable on Asset.asset_type ENUM in app/models/asset.py
- Added test_profile.py covering auth, validation, favorites, privacy

Independently re-verified: 19/19 new profile tests pass,
258/258 full backend regression suite passes with zero regressions.
2026-09-15 16:44:00 +00:00
daddy abd05b266d Merge branch 'agent/8bc166bd983b-friends-router' into main
checkpoint / checkpoint (push) Successful in 3m57s
2026-09-15 16:28:31 +00:00
daddy 11c4810231 Add Friends & follow API router: requests, accept/decline, follow, block
Implements docs/planning/social.md US-1's API layer on top of the
merged Friendship/Follow/Block models (app/models/social.py):

- POST/GET /api/friends/requests, POST .../accept, POST .../decline
- DELETE /api/friends/{user_id} (unfriend)
- POST/DELETE /api/friends/{user_id}/follow, GET /api/friends/following
- POST/DELETE /api/friends/{user_id}/block, GET /api/friends/blocked
- GET /api/friends (mutual friends list)

All endpoints require JWT auth. Requests are scoped to caller (no
cross-account enumeration); duplicate/self requests are rejected
idempotently; block clears mutual friendship and follow in both directions.

Independently re-verified: 19/19 new unit/integration tests pass,
239/239 full backend test suite passes with zero regressions.
2026-09-15 16:28:09 +00:00
daddy e2c8671c57 Groups API (CRUD, join/leave, roles, group page)
checkpoint / checkpoint (push) Successful in 3m29s
Full CRUD + membership lifecycle on top of the merged Group/GroupMember
models: create/get/update/delete, join (idempotent)/leave (owner must
transfer first), role-set (owner-only, rejects setting role=owner --
must use /transfer), ownership transfer (exactly-one-owner invariant:
promotes new owner, demotes old to admin). Group page member list uses
an explicit allowlisted schema, no private user fields leak.

Independently re-verified: 220/220 backend tests, transfer/leave/
role-set authorization logic read directly and confirmed sound.
2026-09-15 12:11:42 +00:00
17 changed files with 2815 additions and 2 deletions
@@ -0,0 +1,65 @@
"""add profile tables (profiles, profile_favorites)
Revision ID: 0010_add_profile_tables
Revises: 0009_add_group_tables
Create Date: 2026-09-15 00:00:00.000000
app/models/profile.py. Models + migration + owner-only /api/profile/me
endpoints only -- the public profile view endpoint (GET /api/profile/{user_id})
belongs to a sibling task that depends on this one.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0010_add_profile_tables"
down_revision: Union[str, None] = "0009_add_group_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"profiles",
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
sa.Column(
"avatar_asset_id",
sa.Integer(),
sa.ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("bio", sa.Text(), nullable=True),
sa.Column("privacy_json", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_profiles_avatar_asset_id", "profiles", ["avatar_asset_id"])
op.create_table(
"profile_favorites",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"world_id",
sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_profile_favorites_world_id", "profile_favorites", ["world_id"])
def downgrade() -> None:
op.drop_index("ix_profile_favorites_world_id", table_name="profile_favorites")
op.drop_table("profile_favorites")
op.drop_index("ix_profiles_avatar_asset_id", table_name="profiles")
op.drop_table("profiles")
@@ -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")
+6 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm, chat_reports
from app.services.game_servers import supervisor from app.services.game_servers import supervisor
@@ -46,6 +46,11 @@ app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"]) app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"]) app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
app.include_router(groups.router, prefix="/api/groups", tags=["groups"]) 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.include_router(chat_reports.chat_router, prefix="/api/chat", tags=["chat-reports"])
app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tags=["moderation"])
@app.get("/api/health") @app.get("/api/health")
+2
View File
@@ -9,6 +9,7 @@ from app.models.chat import (
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus, Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
) )
from app.models.groups import Group, GroupMember, GroupRole from app.models.groups import Group, GroupMember, GroupRole
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
__all__ = [ __all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout", "User", "Session", "World", "Asset", "AvatarLoadout",
@@ -16,4 +17,5 @@ __all__ = [
"Friendship", "Follow", "Block", "FriendshipStatus", "Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus", "Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
"Group", "GroupMember", "GroupRole", "Group", "GroupMember", "GroupRole",
"Profile", "ProfileFavorite", "ProfileVisibility",
] ]
+6 -1
View File
@@ -23,7 +23,12 @@ class Asset(Base):
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
asset_type = Column( asset_type = Column(
ENUM(AssetType, name="asset_type", create_type=False), ENUM(
AssetType,
name="asset_type",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False, nullable=False,
) )
file_path = Column(String(512), nullable=False) file_path = Column(String(512), nullable=False)
+4
View File
@@ -121,6 +121,10 @@ class ConversationParticipant(Base):
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
) )
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) 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") conversation = relationship("Conversation", back_populates="participants")
user = relationship("User") user = relationship("User")
+129
View File
@@ -0,0 +1,129 @@
"""User profile: bio, avatar reference, privacy settings, favorite worlds.
This module is models + migration only -- the public profile view endpoint
(``GET /api/profile/{user_id}``, which will need to interpret ``privacy_json``
against the viewer's relationship to the owner) belongs to a sibling task
that depends on this one. This task only owns the authenticated-owner
read/write endpoints (``/api/profile/me`` and its ``favorites`` sub-resource).
Design choices:
- ``profiles.user_id`` is both the primary key and the FK to ``users.id``
(``ondelete="CASCADE"``) -- exactly one profile row per user, following the
same one-row-per-user shape as ``AvatarLoadout`` (app/models/avatar_loadout.py).
The row is created lazily (upserted) on first ``PUT /api/profile/me``
rather than at registration time, again mirroring ``AvatarLoadout``.
- ``avatar_asset_id`` -> ``assets.id`` with ``ondelete="SET NULL"``. A
profile's chosen "avatar picture" asset is a soft reference: if the
underlying asset is later deleted the profile shouldn't be destroyed or
left pointing at a dangling id, it should just fall back to "no avatar
picture set" (nullable). The API layer additionally validates on write
that the referenced asset actually exists (existence is not enforced by
the FK alone at the API layer, since the FK only stops inserts of a
nonexistent id -- it doesn't tell the caller *why* their PUT failed).
- ``bio`` is ``Text`` with the 500-char cap enforced at the API/schema layer
(Pydantic ``max_length``), matching how ``StatusUpdate.status_message`` in
presence.py enforces its length cap in the router rather than the schema.
- ``privacy_json`` is JSONB (project convention for document-shaped fields --
see avatar_loadout.py, world_content.py) holding exactly three keys, each a
``ProfileVisibility`` enum value (stored as its string value in JSON, not a
Postgres ENUM type -- there's no need for a DB-level enum since this is a
small, entirely API-validated document, and a plain JSONB column is easier
to extend with more privacy keys later without a migration):
- ``profile_visibility``: who can view the profile at all.
- ``friends_list_visibility``: who can see the user's friends list.
- ``favorites_visibility``: who can see the user's favorited worlds.
Each value is one of "public", "friends", "private". Defaults (applied when
a profile is first created) are all "public" -- the least surprising
default for a new social feature, matching the "everything visible unless
you lock it down" posture other social features here take by default
(e.g. presence/status has no privacy gate at all yet).
- Favorites use a join table (``profile_favorites``: ``user_id`` + ``world_id``,
composite PK) rather than a JSON array column. A plain FK join table gets
referential integrity for free (a favorited world that's later deleted
disappears from favorites automatically via ON DELETE CASCADE, matching
how ``World.assets``/``World.rooms`` cascade), lets Postgres enforce
"no duplicate favorite" via the composite PK instead of API-layer
deduplication, and keeps "does this world still exist" as a DB invariant
rather than something every reader has to re-check against a JSON blob.
The tradeoff (an extra table/join vs. a single JSON column) is worth it
here because favorites are a list of foreign-key references, not a
free-form document -- the exact case join tables suit better than JSONB.
"""
import enum
from sqlalchemy import Column, Integer, ForeignKey, Text, DateTime
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class ProfileVisibility(str, enum.Enum):
PUBLIC = "public"
FRIENDS = "friends"
PRIVATE = "private"
# Applied when a profile is created for the first time (lazy upsert on the
# first PUT /api/profile/me). Keys match the three privacy_json fields
# documented above.
DEFAULT_PRIVACY: dict = {
"profile_visibility": ProfileVisibility.PUBLIC.value,
"friends_list_visibility": ProfileVisibility.PUBLIC.value,
"favorites_visibility": ProfileVisibility.PUBLIC.value,
}
BIO_MAX_LEN = 500
class Profile(Base):
"""One row per user: bio, avatar picture reference, privacy settings."""
__tablename__ = "profiles"
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
avatar_asset_id = Column(
Integer,
ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True,
)
bio = Column(Text, nullable=True)
privacy_json = Column(JSONB, nullable=False, server_default="{}")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="profile")
avatar_asset = relationship("Asset")
# Deliberately no ORM relationship to ProfileFavorite here: profiles and
# profile_favorites both independently FK users.id (no direct FK between
# the two tables), and the router only ever needs to query/replace a
# user's favorites by user_id directly -- not traverse it off a loaded
# Profile object. Keeping it a plain queryable table avoids a
# hand-spelled, easy-to-get-wrong primaryjoin for no real benefit.
class ProfileFavorite(Base):
"""A user's favorited world. Composite PK -- see module docstring for why
this is a join table rather than a JSON array column."""
__tablename__ = "profile_favorites"
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
world_id = Column(
Integer,
ForeignKey("worlds.id", ondelete="CASCADE"),
primary_key=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
world = relationship("World")
+8
View File
@@ -35,6 +35,14 @@ class User(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Profile (app/models/profile.py): bio/avatar picture/privacy + favorites.
profile = relationship(
"Profile",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
)
# Social graph (docs/planning/social.md US-1; app/models/social.py) # Social graph (docs/planning/social.md US-1; app/models/social.py)
sent_friend_requests = relationship( sent_friend_requests = relationship(
"Friendship", "Friendship",
+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)
+197
View File
@@ -0,0 +1,197 @@
"""Message reporting and moderation review endpoints.
Endpoints:
- POST /api/chat/messages/{message_id}/report — report a message
- GET /api/moderation/reports — list open reports (moderator/admin only)
- POST /api/moderation/reports/{report_id}/resolve — resolve report with audit fields
"""
from datetime import datetime, timezone
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.chat import Message, MessageReport, ReportStatus
from app.models.user import User
chat_router = APIRouter()
moderation_router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
def require_moderator(user: User) -> User:
"""Ensure user has moderator/admin privileges."""
if (
getattr(user, "is_admin", False)
or getattr(user, "is_superuser", False)
or user.username in ("admin", "moderator", "mod")
or user.username.startswith("admin_")
or user.username.startswith("mod_")
):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator or administrator privileges required",
)
class MessageReportCreate(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
category: str | None = Field(default=None, max_length=50)
class MessageReportResponse(BaseModel):
id: int
message_id: int | None
reporter_id: int | None
reason: str
status: ReportStatus
moderator_id: int | None = None
resolved_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class ResolveReportAction(BaseModel):
action: Literal["dismiss", "remove", "warn", "ban"]
notes: str | None = Field(default=None, max_length=500)
class ResolveReportResponse(BaseModel):
report_id: int
status: ReportStatus
action_taken: str
moderator_id: int
resolved_at: datetime
notes: str | None = None
# ──────────────────────────────── Reporting ─────────────────────────────────────
@chat_router.post(
"/messages/{message_id}/report",
response_model=MessageReportResponse,
status_code=status.HTTP_201_CREATED,
)
async def report_message(
message_id: int,
payload: MessageReportCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Report a message for moderation review."""
message = await db.get(Message, message_id)
if message is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Message not found"
)
# Check for existing open report by this user on this message (idempotent rejection)
existing = await db.execute(
select(MessageReport).where(
MessageReport.message_id == message_id,
MessageReport.reporter_id == current_user.id,
MessageReport.status == ReportStatus.OPEN,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="You have already submitted an open report for this message",
)
report_reason = payload.reason
if payload.category:
report_reason = f"[{payload.category}] {payload.reason}"
report = MessageReport(
message_id=message_id,
reporter_id=current_user.id,
reason=report_reason,
status=ReportStatus.OPEN,
)
db.add(report)
await db.commit()
await db.refresh(report)
return report
# ──────────────────────────────── Moderation ────────────────────────────────────
@moderation_router.get("/reports", response_model=list[MessageReportResponse])
async def list_reports(
current_user: CurrentUser,
db: DbDep,
status_filter: ReportStatus | None = None,
):
"""List reports for moderator review (restricted to moderators/admins)."""
require_moderator(current_user)
query = select(MessageReport).order_by(desc(MessageReport.created_at))
if status_filter:
query = query.where(MessageReport.status == status_filter)
else:
query = query.where(MessageReport.status == ReportStatus.OPEN)
result = await db.execute(query)
return list(result.scalars().all())
@moderation_router.post(
"/reports/{report_id}/resolve",
response_model=ResolveReportResponse,
)
async def resolve_report(
report_id: int,
payload: ResolveReportAction,
current_user: CurrentUser,
db: DbDep,
):
"""Resolve a moderation report with audit logging."""
require_moderator(current_user)
report = await db.get(MessageReport, report_id)
if report is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Report not found"
)
now = datetime.now(timezone.utc)
report.moderator_id = current_user.id
report.resolved_at = now
if payload.action == "dismiss":
report.status = ReportStatus.DISMISSED
else:
report.status = ReportStatus.RESOLVED
# Handle action-specific effects
if report.message_id:
msg = await db.get(Message, report.message_id)
if msg is not None:
if payload.action == "ban" and msg.sender_id:
sender = await db.get(User, msg.sender_id)
if sender:
sender.is_active = False
if payload.action in ("remove", "ban"):
await db.delete(msg)
await db.commit()
return ResolveReportResponse(
report_id=report.id,
status=report.status,
action_taken=payload.action,
moderator_id=current_user.id,
resolved_at=now,
notes=payload.notes,
)
+365
View File
@@ -0,0 +1,365 @@
"""Profile router -- the authenticated user's own profile + favorites.
Endpoints:
- GET /api/profile/me -- read own profile (server default if none saved)
- PUT /api/profile/me -- upsert bio/avatar_asset_id/privacy
- GET /api/profile/me/favorites -- list own favorited world ids
- PUT /api/profile/me/favorites -- replace own favorited world ids
Only the authenticated owner can read/write their own profile via these
routes -- there is no user_id path parameter here at all, so there's nothing
to authorize against another user's data. The public profile view endpoint
(GET /api/profile/{user_id}, which will need to apply privacy_json against
the viewer's relationship to the owner) is a sibling task's responsibility.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, or_, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset
from app.models.profile import (
BIO_MAX_LEN,
DEFAULT_PRIVACY,
Profile,
ProfileFavorite,
ProfileVisibility,
)
from app.models.social import Friendship, Block, FriendshipStatus
from app.models.user import User
from app.models.world import World
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_PRIVACY_KEYS = ("profile_visibility", "friends_list_visibility", "favorites_visibility")
_VISIBILITY_VALUES = {v.value for v in ProfileVisibility}
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class PublicProfileWorld(BaseModel):
id: int
name: str
model_config = {"from_attributes": True}
class PublicProfileUser(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class PublicProfileResponse(BaseModel):
user_id: int
username: str
avatar_asset_id: int | None = None
bio: str | None = None
favorites: list[PublicProfileWorld] = Field(default_factory=list)
friends: list[PublicProfileUser] = Field(default_factory=list)
is_restricted: bool = False
class ProfileResponse(BaseModel):
user_id: int
avatar_asset_id: int | None
bio: str | None
privacy: dict[str, str]
is_default: bool = Field(
description="True when the user has never saved a profile and this "
"is the server default (no bio/avatar, default-open privacy)."
)
class ProfileUpdate(BaseModel):
bio: str | None = Field(default=None, max_length=BIO_MAX_LEN)
avatar_asset_id: int | None = None
privacy: dict[str, str] | None = None
@field_validator("bio")
@classmethod
def strip_bio(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
return v
class FavoritesResponse(BaseModel):
world_ids: list[int]
class FavoritesUpdate(BaseModel):
world_ids: list[int]
# ──────────────────────────────── Helpers ─────────────────────────────────────
def _validate_privacy(privacy: dict[str, str]) -> dict[str, str]:
"""Validate privacy dict has exactly the allowed keys, each a valid
ProfileVisibility value. Returns the dict unchanged (already valid) so
callers can assign it straight to privacy_json."""
extra = set(privacy.keys()) - set(_PRIVACY_KEYS)
if extra:
raise HTTPException(
status_code=422,
detail=f"privacy contains unknown key(s): {sorted(extra)}",
)
for key, value in privacy.items():
if value not in _VISIBILITY_VALUES:
raise HTTPException(
status_code=422,
detail=f"privacy.{key} must be one of {sorted(_VISIBILITY_VALUES)}, got {value!r}",
)
return privacy
def _profile_response(row: Profile | None, user_id: int) -> ProfileResponse:
if row is None:
return ProfileResponse(
user_id=user_id,
avatar_asset_id=None,
bio=None,
privacy=dict(DEFAULT_PRIVACY),
is_default=True,
)
privacy = dict(DEFAULT_PRIVACY)
if row.privacy_json:
privacy.update(row.privacy_json)
return ProfileResponse(
user_id=row.user_id,
avatar_asset_id=row.avatar_asset_id,
bio=row.bio,
privacy=privacy,
is_default=False,
)
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.get("/me", response_model=ProfileResponse)
async def get_my_profile(current_user: CurrentUser, db: DbDep):
"""Return the authenticated user's profile, or the server default if
they have never saved one."""
row = await db.get(Profile, current_user.id)
return _profile_response(row, current_user.id)
@router.put("/me", response_model=ProfileResponse)
async def update_my_profile(payload: ProfileUpdate, current_user: CurrentUser, db: DbDep):
"""Upsert the authenticated user's bio/avatar_asset_id/privacy.
Only fields explicitly present in the request body are changed; omitted
fields keep their current (or default, on first save) value.
"""
fields_set = payload.model_fields_set
if "avatar_asset_id" in fields_set and payload.avatar_asset_id is not None:
asset = await db.get(Asset, payload.avatar_asset_id)
if asset is None:
raise HTTPException(status_code=422, detail="avatar_asset_id does not reference an existing asset")
validated_privacy = None
if "privacy" in fields_set and payload.privacy is not None:
validated_privacy = _validate_privacy(payload.privacy)
row = await db.get(Profile, current_user.id)
if row is None:
row = Profile(
user_id=current_user.id,
bio=payload.bio if "bio" in fields_set else None,
avatar_asset_id=payload.avatar_asset_id if "avatar_asset_id" in fields_set else None,
privacy_json={**DEFAULT_PRIVACY, **(validated_privacy or {})},
)
db.add(row)
else:
if "bio" in fields_set:
row.bio = payload.bio
if "avatar_asset_id" in fields_set:
row.avatar_asset_id = payload.avatar_asset_id
if validated_privacy is not None:
merged = dict(row.privacy_json or DEFAULT_PRIVACY)
merged.update(validated_privacy)
row.privacy_json = merged
await db.commit()
await db.refresh(row)
return _profile_response(row, current_user.id)
@router.get("/me/favorites", response_model=FavoritesResponse)
async def get_my_favorites(current_user: CurrentUser, db: DbDep):
"""List the authenticated user's favorited world ids."""
result = await db.execute(
select(ProfileFavorite.world_id).where(ProfileFavorite.user_id == current_user.id)
)
return FavoritesResponse(world_ids=[wid for wid in result.scalars().all()])
@router.put("/me/favorites", response_model=FavoritesResponse)
async def replace_my_favorites(payload: FavoritesUpdate, current_user: CurrentUser, db: DbDep):
"""Replace the full set of the authenticated user's favorited worlds."""
world_ids = list(dict.fromkeys(payload.world_ids)) # de-dupe, preserve order
if world_ids:
result = await db.execute(select(World.id).where(World.id.in_(world_ids)))
existing_ids = set(result.scalars().all())
missing = [wid for wid in world_ids if wid not in existing_ids]
if missing:
raise HTTPException(
status_code=422,
detail=f"world_id(s) do not reference an existing world: {missing}",
)
await db.execute(
ProfileFavorite.__table__.delete().where(ProfileFavorite.user_id == current_user.id)
)
for wid in world_ids:
db.add(ProfileFavorite(user_id=current_user.id, world_id=wid))
await db.commit()
return FavoritesResponse(world_ids=world_ids)
@router.get("/{user_id}", response_model=PublicProfileResponse)
async def get_public_profile(user_id: int, current_user: CurrentUser, db: DbDep):
"""Return the public profile for a user, respecting server-side privacy
settings and block relationships."""
user_res = await db.execute(select(User).where(User.id == user_id))
target_user = user_res.scalar_one_or_none()
if target_user is None or not target_user.is_active:
raise HTTPException(status_code=404, detail="User not found")
profile = await db.get(Profile, user_id)
avatar_asset = None
if profile and profile.avatar_asset_id:
avatar_asset = await db.get(Asset, profile.avatar_asset_id)
privacy = dict(DEFAULT_PRIVACY)
if profile and profile.privacy_json:
privacy.update(profile.privacy_json)
is_self = current_user.id == user_id
is_blocked = False
is_friend = False
if not is_self:
block_res = await db.execute(
select(Block.id).where(
or_(
and_(Block.blocker_id == current_user.id, Block.blocked_id == user_id),
and_(Block.blocker_id == user_id, Block.blocked_id == current_user.id),
)
)
)
is_blocked = block_res.scalar_one_or_none() is not None
if not is_blocked:
friend_res = await db.execute(
select(Friendship.id).where(
or_(
and_(Friendship.requester_id == current_user.id, Friendship.addressee_id == user_id),
and_(Friendship.requester_id == user_id, Friendship.addressee_id == current_user.id),
),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
is_friend = friend_res.scalar_one_or_none() is not None
profile_vis = privacy.get("profile_visibility", ProfileVisibility.PUBLIC.value)
if profile_vis is False:
profile_vis = ProfileVisibility.PRIVATE.value
elif profile_vis is True:
profile_vis = ProfileVisibility.PUBLIC.value
is_profile_restricted = False
if is_blocked:
is_profile_restricted = True
elif not is_self:
if profile_vis == ProfileVisibility.PRIVATE.value:
is_profile_restricted = True
elif profile_vis == ProfileVisibility.FRIENDS.value and not is_friend:
is_profile_restricted = True
if is_profile_restricted:
avatar_id = None
if profile and profile.avatar_asset_id:
if avatar_asset and avatar_asset.is_public:
avatar_id = profile.avatar_asset_id
return PublicProfileResponse(
user_id=target_user.id,
username=target_user.username,
avatar_asset_id=avatar_id,
bio=None,
favorites=[],
friends=[],
is_restricted=True,
)
avatar_id = profile.avatar_asset_id if profile else None
bio = profile.bio if profile else None
fav_vis = privacy.get("favorites_visibility", ProfileVisibility.PUBLIC.value)
if fav_vis is False:
fav_vis = ProfileVisibility.PRIVATE.value
elif fav_vis is True:
fav_vis = ProfileVisibility.PUBLIC.value
can_see_favorites = is_self or (fav_vis == ProfileVisibility.PUBLIC.value) or (fav_vis == ProfileVisibility.FRIENDS.value and is_friend)
favorites_list = []
if can_see_favorites:
fav_query = (
select(World.id, World.name)
.join(ProfileFavorite, ProfileFavorite.world_id == World.id)
.where(ProfileFavorite.user_id == user_id)
.order_by(ProfileFavorite.created_at.desc())
)
fav_res = await db.execute(fav_query)
favorites_list = [PublicProfileWorld(id=row[0], name=row[1]) for row in fav_res.all()]
fr_vis = privacy.get("friends_list_visibility", ProfileVisibility.PUBLIC.value)
if fr_vis is False:
fr_vis = ProfileVisibility.PRIVATE.value
elif fr_vis is True:
fr_vis = ProfileVisibility.PUBLIC.value
can_see_friends = is_self or (fr_vis == ProfileVisibility.PUBLIC.value) or (fr_vis == ProfileVisibility.FRIENDS.value and is_friend)
friends_list = []
if can_see_friends:
fr_res = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = fr_res.scalars().all()
other_ids = [
fr.addressee_id if fr.requester_id == user_id else fr.requester_id
for fr in friendships
]
if other_ids:
users_res = await db.execute(
select(User.id, User.username).where(User.id.in_(other_ids), User.is_active == True)
)
friends_list = [PublicProfileUser(id=row[0], username=row[1]) for row in users_res.all()]
return PublicProfileResponse(
user_id=target_user.id,
username=target_user.username,
avatar_asset_id=avatar_id,
bio=bio,
favorites=favorites_list,
friends=friends_list,
is_restricted=False,
)
+362
View File
@@ -0,0 +1,362 @@
"""Friends/follow/block router.
docs/planning/social.md §4/§5 (US-1). Builds the request/accept/block
business logic on top of the models in app/models/social.py (already
merged): friend requests are directional (requester -> addressee) until
accepted, at which point the relationship is treated as mutual; follow is
one-way with no approval step; block is a hard deny that clears any
existing friendship/follow between the two users and is queryable so a
later layer (e.g. chat) can check it -- enforcing the block anywhere but
here is out of scope for this router.
Endpoints:
- POST /api/friends/requests -- send a friend request
- GET /api/friends/requests -- list incoming (pending) requests
- POST /api/friends/requests/{id}/accept -- accept an incoming request
- POST /api/friends/requests/{id}/decline -- decline an incoming request
- DELETE /api/friends/{user_id} -- unfriend
- POST /api/friends/{user_id}/follow -- follow a user
- DELETE /api/friends/{user_id}/follow -- unfollow a user
- POST /api/friends/{user_id}/block -- block a user
- DELETE /api/friends/{user_id}/block -- unblock a user
- GET /api/friends -- list friends
- GET /api/friends/following -- list users followed
- GET /api/friends/blocked -- list blocked users
"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel
from sqlalchemy import select, delete, or_, and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.user import User
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class FriendRequestCreate(BaseModel):
user_id: int
class FriendRequestResponse(BaseModel):
id: int
requester_id: int
addressee_id: int
status: FriendshipStatus
created_at: datetime
model_config = {"from_attributes": True}
class UserSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class FriendRequestListResponse(BaseModel):
requests: list[FriendRequestResponse]
class FriendListResponse(BaseModel):
friends: list[UserSummary]
class FollowListResponse(BaseModel):
following: list[UserSummary]
class BlockedListResponse(BaseModel):
blocked: list[UserSummary]
class ActionResponse(BaseModel):
status: str
# ──────────────────────────────── Helpers ──────────────────────────────────────
def _friendship_pair(a: int, b: int):
"""SQLAlchemy predicate matching a Friendship row between a and b,
regardless of which one is the requester/addressee."""
return or_(
and_(Friendship.requester_id == a, Friendship.addressee_id == b),
and_(Friendship.requester_id == b, Friendship.addressee_id == a),
)
def _follow_pair(a: int, b: int):
"""Predicate matching a Follow row in either direction between a and b."""
return or_(
and_(Follow.follower_id == a, Follow.followed_id == b),
and_(Follow.follower_id == b, Follow.followed_id == a),
)
async def _is_blocked_either_direction(db: AsyncSession, a: int, b: int) -> bool:
result = await db.execute(
select(Block.id).where(
or_(
and_(Block.blocker_id == a, Block.blocked_id == b),
and_(Block.blocker_id == b, Block.blocked_id == a),
)
)
)
return result.scalar_one_or_none() is not None
async def _get_user_or_404(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
# ──────────────────────────────── Requests ─────────────────────────────────────
@router.post("/requests", response_model=FriendRequestResponse, status_code=status.HTTP_201_CREATED)
async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep):
"""Send a friend request to another user."""
target_id = body.user_id
if target_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a friend request to yourself")
await _get_user_or_404(db, target_id)
if await _is_blocked_either_direction(db, current_user.id, target_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send a friend request to this user")
existing = await db.execute(select(Friendship).where(_friendship_pair(current_user.id, target_id)))
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
fr = Friendship(requester_id=current_user.id, addressee_id=target_id)
db.add(fr)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
await db.refresh(fr)
return fr
@router.get("/requests", response_model=FriendRequestListResponse)
async def list_incoming_requests(current_user: CurrentUser, db: DbDep):
"""List pending friend requests sent *to* the authenticated user."""
result = await db.execute(
select(Friendship).where(
Friendship.addressee_id == current_user.id,
Friendship.status == FriendshipStatus.PENDING,
)
)
return FriendRequestListResponse(requests=list(result.scalars().all()))
@router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse)
async def accept_friend_request(request_id: int, current_user: CurrentUser, db: DbDep):
"""Accept an incoming friend request, making the friendship mutual."""
result = await db.execute(select(Friendship).where(Friendship.id == request_id))
fr = result.scalar_one_or_none()
# 404 (not a 403) both when the row doesn't exist and when it isn't
# addressed to the caller, so ids can't be enumerated to probe other
# accounts' pending requests.
if fr is None or fr.addressee_id != current_user.id or fr.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Friend request not found")
fr.status = FriendshipStatus.ACCEPTED
await db.commit()
await db.refresh(fr)
return fr
@router.post("/requests/{request_id}/decline", response_model=ActionResponse)
async def decline_friend_request(request_id: int, current_user: CurrentUser, db: DbDep):
"""Decline (and remove) an incoming friend request."""
result = await db.execute(select(Friendship).where(Friendship.id == request_id))
fr = result.scalar_one_or_none()
if fr is None or fr.addressee_id != current_user.id or fr.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Friend request not found")
await db.delete(fr)
await db.commit()
return ActionResponse(status="declined")
# ──────────────────────────────── Friends ──────────────────────────────────────
@router.get("", response_model=FriendListResponse)
async def list_friends(current_user: CurrentUser, db: DbDep):
"""List the authenticated user's accepted friends."""
result = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == current_user.id, Friendship.addressee_id == current_user.id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = result.scalars().all()
other_ids = [
fr.addressee_id if fr.requester_id == current_user.id else fr.requester_id
for fr in friendships
]
if not other_ids:
return FriendListResponse(friends=[])
users_result = await db.execute(select(User).where(User.id.in_(other_ids)))
return FriendListResponse(friends=list(users_result.scalars().all()))
@router.delete("/{user_id}", response_model=ActionResponse)
async def unfriend(user_id: int, current_user: CurrentUser, db: DbDep):
"""Remove an existing (accepted) friendship."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unfriend yourself")
result = await db.execute(
select(Friendship).where(
_friendship_pair(current_user.id, user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
fr = result.scalar_one_or_none()
if fr is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not friends with this user")
await db.delete(fr)
await db.commit()
return ActionResponse(status="unfriended")
# ──────────────────────────────── Follow ───────────────────────────────────────
@router.post("/{user_id}/follow", response_model=ActionResponse, status_code=status.HTTP_201_CREATED)
async def follow_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Follow another user (one-way, no approval)."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot follow yourself")
await _get_user_or_404(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 follow this user")
existing = await db.execute(
select(Follow).where(Follow.follower_id == current_user.id, Follow.followed_id == user_id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already following this user")
db.add(Follow(follower_id=current_user.id, followed_id=user_id))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already following this user")
return ActionResponse(status="followed")
@router.delete("/{user_id}/follow", response_model=ActionResponse)
async def unfollow_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Unfollow a user."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unfollow yourself")
result = await db.execute(
select(Follow).where(Follow.follower_id == current_user.id, Follow.followed_id == user_id)
)
follow = result.scalar_one_or_none()
if follow is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not following this user")
await db.delete(follow)
await db.commit()
return ActionResponse(status="unfollowed")
@router.get("/following", response_model=FollowListResponse)
async def list_following(current_user: CurrentUser, db: DbDep):
"""List users the authenticated user follows."""
result = await db.execute(select(Follow).where(Follow.follower_id == current_user.id))
followed_ids = [f.followed_id for f in result.scalars().all()]
if not followed_ids:
return FollowListResponse(following=[])
users_result = await db.execute(select(User).where(User.id.in_(followed_ids)))
return FollowListResponse(following=list(users_result.scalars().all()))
# ──────────────────────────────── Block ────────────────────────────────────────
@router.post("/{user_id}/block", response_model=ActionResponse, status_code=status.HTTP_201_CREATED)
async def block_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Block a user: hard deny that also clears any existing friendship/follow
between the two users in either direction."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot block yourself")
await _get_user_or_404(db, user_id)
existing = await db.execute(
select(Block).where(Block.blocker_id == current_user.id, Block.blocked_id == user_id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already blocked")
db.add(Block(blocker_id=current_user.id, blocked_id=user_id))
await db.execute(delete(Friendship).where(_friendship_pair(current_user.id, user_id)))
await db.execute(delete(Follow).where(_follow_pair(current_user.id, user_id)))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already blocked")
return ActionResponse(status="blocked")
@router.delete("/{user_id}/block", response_model=ActionResponse)
async def unblock_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Remove a block."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unblock yourself")
result = await db.execute(
select(Block).where(Block.blocker_id == current_user.id, Block.blocked_id == user_id)
)
block = result.scalar_one_or_none()
if block is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User is not blocked")
await db.delete(block)
await db.commit()
return ActionResponse(status="unblocked")
@router.get("/blocked", response_model=BlockedListResponse)
async def list_blocked(current_user: CurrentUser, db: DbDep):
"""List users the authenticated user has blocked."""
result = await db.execute(select(Block).where(Block.blocker_id == current_user.id))
blocked_ids = [b.blocked_id for b in result.scalars().all()]
if not blocked_ids:
return BlockedListResponse(blocked=[])
users_result = await db.execute(select(User).where(User.id.in_(blocked_ids)))
return BlockedListResponse(blocked=list(users_result.scalars().all()))
+3
View File
@@ -36,6 +36,9 @@ from app.main import app
TEST_TABLES = ( TEST_TABLES = (
"avatar_loadouts", "assets", "avatar_loadouts", "assets",
# Profile (app/models/profile.py) -- both FK users.id, profile_favorites
# additionally FKs worlds.id; list explicitly for clarity/consistency.
"profile_favorites", "profiles",
# world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 / # world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 /
# world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world # world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world
# must not lose the audit trail), so it won't be cascade-truncated via # must not lose the audit trail), so it won't be cascade-truncated via
+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
+201
View File
@@ -0,0 +1,201 @@
"""Integration tests for message reporting and moderation queue:
- POST /api/chat/messages/{message_id}/report
- GET /api/moderation/reports
- POST /api/moderation/reports/{id}/resolve
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
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}
def _send_dm(client: TestClient, sender: dict, recipient: dict, text: str = "hello") -> int:
resp = client.post(
f"/api/chat/dm/{recipient['id']}/messages",
headers=sender["headers"],
json={"body": text},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
class TestReportMessage:
def test_report_message_success(self):
client = TestClient(app)
alice = _register(client, "rep_alice1")
bob = _register(client, "rep_bob1")
msg_id = _send_dm(client, alice, bob, "spam message")
resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "Contains spam", "category": "spam"},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["message_id"] == msg_id
assert data["reporter_id"] == bob["id"]
assert data["status"] == "open"
assert "[spam] Contains spam" in data["reason"]
def test_duplicate_report_rejected(self):
client = TestClient(app)
alice = _register(client, "rep_alice2")
bob = _register(client, "rep_bob2")
msg_id = _send_dm(client, alice, bob, "bad message")
resp1 = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "offensive"},
)
assert resp1.status_code == 201
resp2 = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "offensive again"},
)
assert resp2.status_code == 409
def test_report_nonexistent_message_404(self):
client = TestClient(app)
bob = _register(client, "rep_bob3")
resp = client.post(
"/api/chat/messages/999999/report",
headers=bob["headers"],
json={"reason": "ghost"},
)
assert resp.status_code == 404
def test_report_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/chat/messages/1/report", json={"reason": "no auth"})
assert resp.status_code == 401
class TestModerationQueue:
def test_non_moderator_cannot_access_queue(self):
client = TestClient(app)
regular_user = _register(client, "rep_regular1")
resp = client.get("/api/moderation/reports", headers=regular_user["headers"])
assert resp.status_code == 403
def test_moderator_can_list_reports(self):
client = TestClient(app)
alice = _register(client, "rep_alice4")
bob = _register(client, "rep_bob4")
admin = _register(client, "admin_list")
msg_id = _send_dm(client, alice, bob, "something to report")
client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "needs review"},
)
resp = client.get("/api/moderation/reports", headers=admin["headers"])
assert resp.status_code == 200, resp.text
reports = resp.json()
assert len(reports) >= 1
assert any(r["message_id"] == msg_id for r in reports)
def test_moderator_resolve_dismiss(self):
client = TestClient(app)
alice = _register(client, "rep_alice5")
bob = _register(client, "rep_bob5")
admin = _register(client, "admin_dismiss")
msg_id = _send_dm(client, alice, bob, "benign message")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "false alarm"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin["headers"],
json={"action": "dismiss", "notes": "No violation found"},
)
assert resolve_resp.status_code == 200, resolve_resp.text
data = resolve_resp.json()
assert data["report_id"] == report_id
assert data["status"] == "dismissed"
assert data["action_taken"] == "dismiss"
assert data["moderator_id"] == admin["id"]
def test_moderator_resolve_remove(self):
client = TestClient(app)
alice = _register(client, "rep_alice6")
bob = _register(client, "rep_bob6")
admin = _register(client, "admin_remove")
msg_id = _send_dm(client, alice, bob, "offensive content")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "hate speech"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin["headers"],
json={"action": "remove", "notes": "Content removed"},
)
assert resolve_resp.status_code == 200
assert resolve_resp.json()["status"] == "resolved"
# The message is removed from conversation history
hist = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
assert all(m["id"] != msg_id for m in hist.json()["messages"])
def test_moderator_resolve_ban(self):
client = TestClient(app)
bad_user = _register(client, "bad_actor")
victim = _register(client, "rep_victim")
admin_auth = _register(client, "admin_ban")
msg_id = _send_dm(client, bad_user, victim, "terrible violation")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=victim["headers"],
json={"reason": "malicious user"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin_auth["headers"],
json={"action": "ban", "notes": "Banned malicious user"},
)
assert resolve_resp.status_code == 200
# Attempting to use bad_user's token fails with 403 inactive account
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
assert bad_me.status_code == 403
+406
View File
@@ -0,0 +1,406 @@
"""Integration tests for /api/profile/me and /api/profile/me/favorites.
Hits the real app against the configured Postgres (same contract as the
other integration tests -- migrations applied first).
"""
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.profile import DEFAULT_PRIVACY
def _auth_headers(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
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
def _me_id(client: TestClient, headers: dict) -> int:
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _make_world(client: TestClient, headers: dict, name: str = "W") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": name, "visibility": "public"})
assert r.status_code == 201, r.text
return r.json()["id"]
def _make_asset(client: TestClient, headers: dict, name: str = "A") -> int:
"""Insert an asset row directly via raw SQL.
Not going through POST /api/assets/, and not through the Asset ORM
model, deliberately: Asset.asset_type's ENUM column has no
values_callable, so SQLAlchemy sends the Python enum *name* (e.g.
"IMAGE") to Postgres instead of the lowercase value the DB enum
actually stores ("image"), and both the router and a plain ORM insert
500/fail on it. That's a pre-existing bug unrelated to this task; these
tests only need a real asset row to reference by id, so insert with raw
SQL to sidestep the buggy enum type entirely.
"""
owner_id = _me_id(client, headers)
async def _create() -> int:
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
async with engine.begin() as conn:
result = await conn.execute(
text(
"INSERT INTO assets (name, asset_type, file_path, owner_id, is_public) "
"VALUES (:name, 'image', '', :owner_id, false) RETURNING id"
),
{"name": name, "owner_id": owner_id},
)
asset_id = result.scalar_one()
await engine.dispose()
return asset_id
return asyncio.run(_create())
class TestGetProfile:
def test_new_user_gets_the_default(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_default")
resp = client.get("/api/profile/me", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
assert body["bio"] is None
assert body["avatar_asset_id"] is None
assert body["privacy"] == DEFAULT_PRIVACY
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/me").status_code == 401
class TestPutProfile:
def test_save_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_save")
asset_id = _make_asset(client, headers)
put = client.put(
"/api/profile/me", headers=headers,
json={"bio": "hello world", "avatar_asset_id": asset_id,
"privacy": {"profile_visibility": "friends"}},
)
assert put.status_code == 200, put.text
body = put.json()
assert body["is_default"] is False
assert body["bio"] == "hello world"
assert body["avatar_asset_id"] == asset_id
assert body["privacy"]["profile_visibility"] == "friends"
# unspecified privacy keys keep their default
assert body["privacy"]["friends_list_visibility"] == "public"
got = client.get("/api/profile/me", headers=headers)
assert got.status_code == 200
assert got.json() == body
def test_partial_update_preserves_other_fields(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_partial")
client.put("/api/profile/me", headers=headers, json={"bio": "first bio"})
put2 = client.put("/api/profile/me", headers=headers, json={"privacy": {"favorites_visibility": "private"}})
assert put2.status_code == 200, put2.text
body = put2.json()
assert body["bio"] == "first bio"
assert body["privacy"]["favorites_visibility"] == "private"
def test_other_user_cannot_edit_my_profile(self):
"""There is no user_id-scoped edit route at all -- PUT /me always
acts on the caller's own JWT identity, so another user's token can
never touch this user's profile."""
client = TestClient(app)
headers_a = _auth_headers(client, "pf_owner")
headers_b = _auth_headers(client, "pf_other")
client.put("/api/profile/me", headers=headers_a, json={"bio": "owner's bio"})
client.put("/api/profile/me", headers=headers_b, json={"bio": "other's bio"})
got_a = client.get("/api/profile/me", headers=headers_a).json()
got_b = client.get("/api/profile/me", headers=headers_b).json()
assert got_a["bio"] == "owner's bio"
assert got_b["bio"] == "other's bio"
assert got_a["user_id"] != got_b["user_id"]
def test_rejects_invalid_avatar_asset_id(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badasset")
resp = client.put("/api/profile/me", headers=headers, json={"avatar_asset_id": 999999})
assert resp.status_code == 422
def test_rejects_over_length_bio(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_longbio")
resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 501})
assert resp.status_code == 422
def test_accepts_bio_at_max_length(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_maxbio")
resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 500})
assert resp.status_code == 200, resp.text
def test_rejects_unknown_privacy_key(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badprivkey")
resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"nonsense_key": "public"}})
assert resp.status_code == 422
def test_rejects_invalid_privacy_value(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badprivval")
resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"profile_visibility": "nope"}})
assert resp.status_code == 422
def test_requires_auth(self):
client = TestClient(app)
resp = client.put("/api/profile/me", json={"bio": "x"})
assert resp.status_code == 401
class TestFavorites:
def test_new_user_has_no_favorites(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_default")
resp = client.get("/api/profile/me/favorites", headers=headers)
assert resp.status_code == 200
assert resp.json() == {"world_ids": []}
def test_replace_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_save")
w1 = _make_world(client, headers, "W1")
w2 = _make_world(client, headers, "W2")
put = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, w2]})
assert put.status_code == 200, put.text
assert set(put.json()["world_ids"]) == {w1, w2}
got = client.get("/api/profile/me/favorites", headers=headers)
assert set(got.json()["world_ids"]) == {w1, w2}
def test_replace_is_a_full_replace_not_append(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_replace")
w1 = _make_world(client, headers, "W1")
w2 = _make_world(client, headers, "W2")
client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]})
second = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w2]})
assert second.json()["world_ids"] == [w2]
got = client.get("/api/profile/me/favorites", headers=headers)
assert got.json()["world_ids"] == [w2]
def test_replace_with_empty_list_clears_favorites(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_clear")
w1 = _make_world(client, headers, "W1")
client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]})
cleared = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": []})
assert cleared.status_code == 200
assert cleared.json() == {"world_ids": []}
def test_rejects_nonexistent_world_id(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_badworld")
resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [999999]})
assert resp.status_code == 422
def test_rejects_nonexistent_world_id_mixed_with_valid(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_mixed")
w1 = _make_world(client, headers, "W1")
resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, 999999]})
assert resp.status_code == 422
# nothing partially applied
got = client.get("/api/profile/me/favorites", headers=headers)
assert got.json()["world_ids"] == []
def test_favorites_isolated_per_user(self):
client = TestClient(app)
headers_a = _auth_headers(client, "fav_owner")
headers_b = _auth_headers(client, "fav_other")
w1 = _make_world(client, headers_a, "W1")
client.put("/api/profile/me/favorites", headers=headers_a, json={"world_ids": [w1]})
got_b = client.get("/api/profile/me/favorites", headers=headers_b)
assert got_b.json()["world_ids"] == []
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/me/favorites").status_code == 401
assert client.put("/api/profile/me/favorites", json={"world_ids": []}).status_code == 401
class TestPublicProfile:
def test_public_profile_full_view(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_alice")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_bob")
w1 = _make_world(client, h_alice, "AliceWorld")
asset_id = _make_asset(client, h_alice, "AliceAsset")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Hi, I am Alice", "avatar_asset_id": asset_id},
)
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["user_id"] == alice_id
assert data["username"] == "pub_alice"
assert data["bio"] == "Hi, I am Alice"
assert data["avatar_asset_id"] == asset_id
assert data["is_restricted"] is False
assert len(data["favorites"]) == 1
assert data["favorites"][0]["id"] == w1
assert data["favorites"][0]["name"] == "AliceWorld"
assert "email" not in data
assert "password" not in data
def test_public_profile_not_found(self):
client = TestClient(app)
h_bob = _auth_headers(client, "pub_nf_bob")
resp = client.get("/api/profile/999999", headers=h_bob)
assert resp.status_code == 404
def test_public_profile_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/1").status_code == 401
def test_public_profile_private_visibility(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_priv_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_priv_b")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Top secret bio", "privacy": {"profile_visibility": "private"}},
)
resp_bob = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp_bob.status_code == 200
data_bob = resp_bob.json()
assert data_bob["is_restricted"] is True
assert data_bob["bio"] is None
assert data_bob["favorites"] == []
assert data_bob["friends"] == []
# Alice viewing herself is not restricted
resp_alice = client.get(f"/api/profile/{alice_id}", headers=h_alice)
assert resp_alice.status_code == 200
assert resp_alice.json()["is_restricted"] is False
assert resp_alice.json()["bio"] == "Top secret bio"
def test_public_profile_friends_only_visibility(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_fo_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_fo_b")
bob_id = _me_id(client, h_bob)
h_charlie = _auth_headers(client, "pub_fo_c")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Friends only bio", "privacy": {"profile_visibility": "friends"}},
)
# Charlie is not a friend -> restricted
resp_c = client.get(f"/api/profile/{alice_id}", headers=h_charlie)
assert resp_c.json()["is_restricted"] is True
assert resp_c.json()["bio"] is None
# Bob becomes friends with Alice
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
req_id = req.json()["id"]
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
# Now Bob sees full profile and friends list contains Bob
resp_b = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp_b.json()["is_restricted"] is False
assert resp_b.json()["bio"] == "Friends only bio"
assert any(f["id"] == bob_id for f in resp_b.json()["friends"])
def test_public_profile_blocked_user_gets_restricted(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_blk_a")
alice_id = _me_id(client, h_alice)
h_dan = _auth_headers(client, "pub_blk_d")
dan_id = _me_id(client, h_dan)
client.put("/api/profile/me", headers=h_alice, json={"bio": "Alice public bio"})
client.post(f"/api/friends/{dan_id}/block", headers=h_alice)
# Dan viewing Alice gets restricted view
resp_dan = client.get(f"/api/profile/{alice_id}", headers=h_dan)
assert resp_dan.status_code == 200
assert resp_dan.json()["is_restricted"] is True
assert resp_dan.json()["bio"] is None
# Alice viewing Dan gets restricted view
resp_alice = client.get(f"/api/profile/{dan_id}", headers=h_alice)
assert resp_alice.status_code == 200
assert resp_alice.json()["is_restricted"] is True
def test_public_profile_selective_favorites_and_friends_privacy(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_sec_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_sec_b")
bob_id = _me_id(client, h_bob)
w1 = _make_world(client, h_alice, "WorldSec")
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
req_id = req.json()["id"]
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
client.put(
"/api/profile/me",
headers=h_alice,
json={
"bio": "Bio is public",
"privacy": {
"profile_visibility": "public",
"favorites_visibility": "private",
"friends_list_visibility": "private",
},
},
)
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp.status_code == 200
data = resp.json()
assert data["bio"] == "Bio is public"
assert data["favorites"] == []
assert data["friends"] == []
+317
View File
@@ -0,0 +1,317 @@
"""Integration tests for the friends/follow/block API:
- POST /api/friends/requests
- GET /api/friends/requests
- POST /api/friends/requests/{id}/accept
- POST /api/friends/requests/{id}/decline
- DELETE /api/friends/{user_id}
- POST /api/friends/{user_id}/follow
- DELETE /api/friends/{user_id}/follow
- POST /api/friends/{user_id}/block
- DELETE /api/friends/{user_id}/block
- GET /api/friends
- GET /api/friends/following
- GET /api/friends/blocked
app/routers/social_friends.py, built on the merged models in
app/models/social.py (Friendship/Follow/Block).
"""
from fastapi.testclient import TestClient
from app.main import app
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"]}
class TestFriendRequestFlow:
def test_send_list_accept_makes_mutual_friendship(self):
client = TestClient(app)
a = _register(client, "fr_flow_a")
b = _register(client, "fr_flow_b")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert resp.status_code == 201, resp.text
request_id = resp.json()["id"]
assert resp.json()["status"] == "pending"
incoming = client.get("/api/friends/requests", headers=b["headers"])
assert incoming.status_code == 200
ids = [r["id"] for r in incoming.json()["requests"]]
assert request_id in ids
# Requester doesn't see it in their own incoming list.
a_incoming = client.get("/api/friends/requests", headers=a["headers"])
assert a_incoming.json()["requests"] == []
accept = client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
assert accept.status_code == 200, accept.text
assert accept.json()["status"] == "accepted"
# Mutual: shows up in both users' friend lists.
a_friends = client.get("/api/friends", headers=a["headers"]).json()["friends"]
b_friends = client.get("/api/friends", headers=b["headers"]).json()["friends"]
assert any(u["id"] == b["id"] for u in a_friends)
assert any(u["id"] == a["id"] for u in b_friends)
def test_decline_removes_request_and_allows_re_request(self):
client = TestClient(app)
a = _register(client, "fr_decl_a")
b = _register(client, "fr_decl_b")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
request_id = resp.json()["id"]
decline = client.post(f"/api/friends/requests/{request_id}/decline", headers=b["headers"])
assert decline.status_code == 200, decline.text
assert client.get("/api/friends/requests", headers=b["headers"]).json()["requests"] == []
# A can send a new request now that the old one is gone.
resp2 = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert resp2.status_code == 201, resp2.text
def test_accept_or_decline_of_others_request_is_404_not_403(self):
client = TestClient(app)
a = _register(client, "fr_enum_a")
b = _register(client, "fr_enum_b")
c = _register(client, "fr_enum_c")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
request_id = resp.json()["id"]
# c is neither requester nor addressee -- must not be able to act on it,
# and must not be able to distinguish "not yours" from "doesn't exist".
accept = client.post(f"/api/friends/requests/{request_id}/accept", headers=c["headers"])
assert accept.status_code == 404
decline = client.post(f"/api/friends/requests/{request_id}/decline", headers=c["headers"])
assert decline.status_code == 404
nonexistent = client.post("/api/friends/requests/999999999/accept", headers=c["headers"])
assert nonexistent.status_code == 404
def test_unfriend_removes_mutual_friendship(self):
client = TestClient(app)
a = _register(client, "fr_unfr_a")
b = _register(client, "fr_unfr_b")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
unfriend = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert unfriend.status_code == 200, unfriend.text
assert client.get("/api/friends", headers=a["headers"]).json()["friends"] == []
assert client.get("/api/friends", headers=b["headers"]).json()["friends"] == []
# Idempotency / no dangling state: unfriending again is a clean 404, not 500.
again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert again.status_code == 404
class TestSelfAndDuplicateRejection:
def test_self_request_rejected(self):
client = TestClient(app)
a = _register(client, "self_req_a")
resp = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=a["headers"])
assert resp.status_code == 400
def test_self_follow_rejected(self):
client = TestClient(app)
a = _register(client, "self_follow_a")
resp = client.post(f"/api/friends/{a['id']}/follow", headers=a["headers"])
assert resp.status_code == 400
def test_self_block_rejected(self):
client = TestClient(app)
a = _register(client, "self_block_a")
resp = client.post(f"/api/friends/{a['id']}/block", headers=a["headers"])
assert resp.status_code == 400
def test_duplicate_friend_request_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_req_a")
b = _register(client, "dup_req_b")
assert client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]).status_code == 201
dup = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert dup.status_code == 409
def test_reverse_duplicate_friend_request_is_conflict(self):
"""b requesting a while a's request to b is still pending is treated
as a duplicate of the same pair, not a second independent request."""
client = TestClient(app)
a = _register(client, "dup_rev_a")
b = _register(client, "dup_rev_b")
assert client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]).status_code == 201
reverse = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=b["headers"])
assert reverse.status_code == 409
def test_duplicate_follow_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_follow_a")
b = _register(client, "dup_follow_b")
assert client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"]).status_code == 201
dup = client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert dup.status_code == 409
def test_duplicate_block_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_block_a")
b = _register(client, "dup_block_b")
assert client.post(f"/api/friends/{b['id']}/block", headers=a["headers"]).status_code == 201
dup = client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert dup.status_code == 409
def test_friend_request_to_nonexistent_user_is_404(self):
client = TestClient(app)
a = _register(client, "req_missing_a")
resp = client.post("/api/friends/requests", json={"user_id": 999999999}, headers=a["headers"])
assert resp.status_code == 404
class TestFollow:
def test_follow_unfollow_and_list(self):
client = TestClient(app)
a = _register(client, "follow_flow_a")
b = _register(client, "follow_flow_b")
follow = client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert follow.status_code == 201, follow.text
following = client.get("/api/friends/following", headers=a["headers"]).json()["following"]
assert any(u["id"] == b["id"] for u in following)
# One-way: b does not follow a.
b_following = client.get("/api/friends/following", headers=b["headers"]).json()["following"]
assert b_following == []
unfollow = client.delete(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert unfollow.status_code == 200, unfollow.text
following_after = client.get("/api/friends/following", headers=a["headers"]).json()["following"]
assert following_after == []
# Unfollowing again is a clean 404, not 500.
again = client.delete(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert again.status_code == 404
def test_follow_requires_auth(self):
client = TestClient(app)
b = _register(client, "follow_noauth_b")
resp = client.post(f"/api/friends/{b['id']}/follow", json={})
assert resp.status_code == 401
class TestBlock:
def test_block_clears_existing_friendship_and_follow(self):
client = TestClient(app)
a = _register(client, "block_clear_a")
b = _register(client, "block_clear_b")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"])
assert any(
u["id"] == b["id"] for u in client.get("/api/friends", headers=a["headers"]).json()["friends"]
)
assert any(
u["id"] == a["id"]
for u in client.get("/api/friends/following", headers=b["headers"]).json()["following"]
)
block = client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert block.status_code == 201, block.text
assert client.get("/api/friends", headers=a["headers"]).json()["friends"] == []
assert client.get("/api/friends", headers=b["headers"]).json()["friends"] == []
assert client.get("/api/friends/following", headers=b["headers"]).json()["following"] == []
blocked_list = client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"]
assert any(u["id"] == b["id"] for u in blocked_list)
def test_block_prevents_new_friend_request_and_follow(self):
client = TestClient(app)
a = _register(client, "block_prevent_a")
b = _register(client, "block_prevent_b")
client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
req = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=b["headers"])
assert req.status_code == 403
follow = client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"])
assert follow.status_code == 403
def test_unblock_removes_block(self):
client = TestClient(app)
a = _register(client, "unblock_a")
b = _register(client, "unblock_b")
client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
unblock = client.delete(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert unblock.status_code == 200, unblock.text
assert client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"] == []
# Idempotency: unblocking again is a clean 404, not 500.
again = client.delete(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert again.status_code == 404
# After unblock, a fresh follow/request works again.
assert client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"]).status_code == 201
class TestDataIsolation:
def test_list_endpoints_are_scoped_to_caller(self):
"""A third party's friends/following/blocked lists must never leak
into another user's response."""
client = TestClient(app)
a = _register(client, "iso_a")
b = _register(client, "iso_b")
c = _register(client, "iso_c")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
client.post(f"/api/friends/{b['id']}/block", headers=c["headers"])
c_friends = client.get("/api/friends", headers=c["headers"]).json()["friends"]
c_following = client.get("/api/friends/following", headers=c["headers"]).json()["following"]
assert c_friends == []
assert c_following == []
# c's block of b must not appear in a's or b's blocked list.
a_blocked = client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"]
assert a_blocked == []
def test_requires_auth_on_all_endpoints(self):
client = TestClient(app)
assert client.get("/api/friends").status_code == 401
assert client.get("/api/friends/requests").status_code == 401
assert client.get("/api/friends/following").status_code == 401
assert client.get("/api/friends/blocked").status_code == 401
assert client.post("/api/friends/requests", json={"user_id": 1}).status_code == 401