Compare commits

..

4 Commits

Author SHA1 Message Date
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
11 changed files with 1353 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")
+3 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles
from app.services.game_servers import supervisor
@@ -46,6 +46,8 @@ app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
app.include_router(groups.router, prefix="/api/groups", tags=["groups"])
app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"])
app.include_router(profiles.router, prefix="/api/profile", tags=["profile"])
@app.get("/api/health")
+2
View File
@@ -9,6 +9,7 @@ from app.models.chat import (
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
)
from app.models.groups import Group, GroupMember, GroupRole
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
@@ -16,4 +17,5 @@ __all__ = [
"Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
"Group", "GroupMember", "GroupRole",
"Profile", "ProfileFavorite", "ProfileVisibility",
]
+6 -1
View File
@@ -23,7 +23,12 @@ class Asset(Base):
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
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,
)
file_path = Column(String(512), nullable=False)
+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",
)
# 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)
sent_friend_requests = relationship(
"Friendship",
+205
View File
@@ -0,0 +1,205 @@
"""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
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.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 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)
+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 = (
"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_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
+253
View File
@@ -0,0 +1,253 @@
"""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
+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