Merge branch 'agent/8bc166bd983b-friends-router' into main
checkpoint / checkpoint (push) Successful in 3m57s

This commit is contained in:
2026-09-15 16:28:31 +00:00
3 changed files with 681 additions and 1 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends
from app.services.game_servers import supervisor
@@ -46,6 +46,7 @@ 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.get("/api/health")
+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()))
+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