From 11c4810231a77a9dafc460d9b096adc9bb679835 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 12:08:41 +0000 Subject: [PATCH] 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. --- backend/app/main.py | 3 +- backend/app/routers/social_friends.py | 362 ++++++++++++++++++++++++++ backend/tests/test_social_friends.py | 317 ++++++++++++++++++++++ 3 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/social_friends.py create mode 100644 backend/tests/test_social_friends.py diff --git a/backend/app/main.py b/backend/app/main.py index 0d4c9cf..a65bf29 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups +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") diff --git a/backend/app/routers/social_friends.py b/backend/app/routers/social_friends.py new file mode 100644 index 0000000..842bf77 --- /dev/null +++ b/backend/app/routers/social_friends.py @@ -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())) diff --git a/backend/tests/test_social_friends.py b/backend/tests/test_social_friends.py new file mode 100644 index 0000000..c2957ff --- /dev/null +++ b/backend/tests/test_social_friends.py @@ -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