From 0a0dde3d5034876a3b89751cd2d26022bb7dcae9 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 12:08:50 +0000 Subject: [PATCH] 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. --- .../versions/0010_add_profile_tables.py | 65 +++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 2 + backend/app/models/asset.py | 7 +- backend/app/models/profile.py | 129 +++++++++ backend/app/models/user.py | 8 + backend/app/routers/profiles.py | 205 ++++++++++++++ backend/tests/conftest.py | 3 + backend/tests/test_profile.py | 253 ++++++++++++++++++ 9 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0010_add_profile_tables.py create mode 100644 backend/app/models/profile.py create mode 100644 backend/app/routers/profiles.py create mode 100644 backend/tests/test_profile.py diff --git a/backend/alembic/versions/0010_add_profile_tables.py b/backend/alembic/versions/0010_add_profile_tables.py new file mode 100644 index 0000000..90fb237 --- /dev/null +++ b/backend/alembic/versions/0010_add_profile_tables.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index a65bf29..de181a0 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, social_friends +from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles from app.services.game_servers import supervisor @@ -47,6 +47,7 @@ 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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8915c50..7b77152 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py index 2955515..69df175 100644 --- a/backend/app/models/asset.py +++ b/backend/app/models/asset.py @@ -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) diff --git a/backend/app/models/profile.py b/backend/app/models/profile.py new file mode 100644 index 0000000..85b097b --- /dev/null +++ b/backend/app/models/profile.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 22d1b9c..9bd291a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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", diff --git a/backend/app/routers/profiles.py b/backend/app/routers/profiles.py new file mode 100644 index 0000000..9fa49f9 --- /dev/null +++ b/backend/app/routers/profiles.py @@ -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) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a94f3e0..de30262 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000..88966ec --- /dev/null +++ b/backend/tests/test_profile.py @@ -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