"""Integration tests for /api/profile/me and /api/profile/me/favorites. Hits the real app against the configured Postgres (same contract as the other integration tests -- migrations applied first). """ import asyncio import pytest from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.pool import NullPool from app.config import settings from app.main import app from app.models.profile import DEFAULT_PRIVACY def _auth_headers(client: TestClient, username: str) -> dict: resp = client.post( "/api/auth/register", json={ "username": username, "email": f"{username}@example.com", "password": "secret123", "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 201, resp.text return {"Authorization": f"Bearer {resp.json()['access_token']}"} def _me_id(client: TestClient, headers: dict) -> int: me = client.get("/api/users/me", headers=headers) assert me.status_code == 200, me.text return me.json()["id"] def _make_world(client: TestClient, headers: dict, name: str = "W") -> int: r = client.post("/api/worlds/", headers=headers, json={"name": name, "visibility": "public"}) assert r.status_code == 201, r.text return r.json()["id"] def _make_asset(client: TestClient, headers: dict, name: str = "A") -> int: """Insert an asset row directly via raw SQL. Not going through POST /api/assets/, and not through the Asset ORM model, deliberately: Asset.asset_type's ENUM column has no values_callable, so SQLAlchemy sends the Python enum *name* (e.g. "IMAGE") to Postgres instead of the lowercase value the DB enum actually stores ("image"), and both the router and a plain ORM insert 500/fail on it. That's a pre-existing bug unrelated to this task; these tests only need a real asset row to reference by id, so insert with raw SQL to sidestep the buggy enum type entirely. """ owner_id = _me_id(client, headers) async def _create() -> int: engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) async with engine.begin() as conn: result = await conn.execute( text( "INSERT INTO assets (name, asset_type, file_path, owner_id, is_public) " "VALUES (:name, 'image', '', :owner_id, false) RETURNING id" ), {"name": name, "owner_id": owner_id}, ) asset_id = result.scalar_one() await engine.dispose() return asset_id return asyncio.run(_create()) class TestGetProfile: def test_new_user_gets_the_default(self): client = TestClient(app) headers = _auth_headers(client, "pf_default") resp = client.get("/api/profile/me", headers=headers) assert resp.status_code == 200 body = resp.json() assert body["is_default"] is True assert body["bio"] is None assert body["avatar_asset_id"] is None assert body["privacy"] == DEFAULT_PRIVACY def test_requires_auth(self): client = TestClient(app) assert client.get("/api/profile/me").status_code == 401 class TestPutProfile: def test_save_then_read_back(self): client = TestClient(app) headers = _auth_headers(client, "pf_save") asset_id = _make_asset(client, headers) put = client.put( "/api/profile/me", headers=headers, json={"bio": "hello world", "avatar_asset_id": asset_id, "privacy": {"profile_visibility": "friends"}}, ) assert put.status_code == 200, put.text body = put.json() assert body["is_default"] is False assert body["bio"] == "hello world" assert body["avatar_asset_id"] == asset_id assert body["privacy"]["profile_visibility"] == "friends" # unspecified privacy keys keep their default assert body["privacy"]["friends_list_visibility"] == "public" got = client.get("/api/profile/me", headers=headers) assert got.status_code == 200 assert got.json() == body def test_partial_update_preserves_other_fields(self): client = TestClient(app) headers = _auth_headers(client, "pf_partial") client.put("/api/profile/me", headers=headers, json={"bio": "first bio"}) put2 = client.put("/api/profile/me", headers=headers, json={"privacy": {"favorites_visibility": "private"}}) assert put2.status_code == 200, put2.text body = put2.json() assert body["bio"] == "first bio" assert body["privacy"]["favorites_visibility"] == "private" def test_other_user_cannot_edit_my_profile(self): """There is no user_id-scoped edit route at all -- PUT /me always acts on the caller's own JWT identity, so another user's token can never touch this user's profile.""" client = TestClient(app) headers_a = _auth_headers(client, "pf_owner") headers_b = _auth_headers(client, "pf_other") client.put("/api/profile/me", headers=headers_a, json={"bio": "owner's bio"}) client.put("/api/profile/me", headers=headers_b, json={"bio": "other's bio"}) got_a = client.get("/api/profile/me", headers=headers_a).json() got_b = client.get("/api/profile/me", headers=headers_b).json() assert got_a["bio"] == "owner's bio" assert got_b["bio"] == "other's bio" assert got_a["user_id"] != got_b["user_id"] def test_rejects_invalid_avatar_asset_id(self): client = TestClient(app) headers = _auth_headers(client, "pf_badasset") resp = client.put("/api/profile/me", headers=headers, json={"avatar_asset_id": 999999}) assert resp.status_code == 422 def test_rejects_over_length_bio(self): client = TestClient(app) headers = _auth_headers(client, "pf_longbio") resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 501}) assert resp.status_code == 422 def test_accepts_bio_at_max_length(self): client = TestClient(app) headers = _auth_headers(client, "pf_maxbio") resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 500}) assert resp.status_code == 200, resp.text def test_rejects_unknown_privacy_key(self): client = TestClient(app) headers = _auth_headers(client, "pf_badprivkey") resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"nonsense_key": "public"}}) assert resp.status_code == 422 def test_rejects_invalid_privacy_value(self): client = TestClient(app) headers = _auth_headers(client, "pf_badprivval") resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"profile_visibility": "nope"}}) assert resp.status_code == 422 def test_requires_auth(self): client = TestClient(app) resp = client.put("/api/profile/me", json={"bio": "x"}) assert resp.status_code == 401 class TestFavorites: def test_new_user_has_no_favorites(self): client = TestClient(app) headers = _auth_headers(client, "fav_default") resp = client.get("/api/profile/me/favorites", headers=headers) assert resp.status_code == 200 assert resp.json() == {"world_ids": []} def test_replace_then_read_back(self): client = TestClient(app) headers = _auth_headers(client, "fav_save") w1 = _make_world(client, headers, "W1") w2 = _make_world(client, headers, "W2") put = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, w2]}) assert put.status_code == 200, put.text assert set(put.json()["world_ids"]) == {w1, w2} got = client.get("/api/profile/me/favorites", headers=headers) assert set(got.json()["world_ids"]) == {w1, w2} def test_replace_is_a_full_replace_not_append(self): client = TestClient(app) headers = _auth_headers(client, "fav_replace") w1 = _make_world(client, headers, "W1") w2 = _make_world(client, headers, "W2") client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]}) second = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w2]}) assert second.json()["world_ids"] == [w2] got = client.get("/api/profile/me/favorites", headers=headers) assert got.json()["world_ids"] == [w2] def test_replace_with_empty_list_clears_favorites(self): client = TestClient(app) headers = _auth_headers(client, "fav_clear") w1 = _make_world(client, headers, "W1") client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]}) cleared = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": []}) assert cleared.status_code == 200 assert cleared.json() == {"world_ids": []} def test_rejects_nonexistent_world_id(self): client = TestClient(app) headers = _auth_headers(client, "fav_badworld") resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [999999]}) assert resp.status_code == 422 def test_rejects_nonexistent_world_id_mixed_with_valid(self): client = TestClient(app) headers = _auth_headers(client, "fav_mixed") w1 = _make_world(client, headers, "W1") resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, 999999]}) assert resp.status_code == 422 # nothing partially applied got = client.get("/api/profile/me/favorites", headers=headers) assert got.json()["world_ids"] == [] def test_favorites_isolated_per_user(self): client = TestClient(app) headers_a = _auth_headers(client, "fav_owner") headers_b = _auth_headers(client, "fav_other") w1 = _make_world(client, headers_a, "W1") client.put("/api/profile/me/favorites", headers=headers_a, json={"world_ids": [w1]}) got_b = client.get("/api/profile/me/favorites", headers=headers_b) assert got_b.json()["world_ids"] == [] def test_requires_auth(self): client = TestClient(app) assert client.get("/api/profile/me/favorites").status_code == 401 assert client.put("/api/profile/me/favorites", json={"world_ids": []}).status_code == 401 class TestPublicProfile: def test_public_profile_full_view(self): client = TestClient(app) h_alice = _auth_headers(client, "pub_alice") alice_id = _me_id(client, h_alice) h_bob = _auth_headers(client, "pub_bob") w1 = _make_world(client, h_alice, "AliceWorld") asset_id = _make_asset(client, h_alice, "AliceAsset") client.put( "/api/profile/me", headers=h_alice, json={"bio": "Hi, I am Alice", "avatar_asset_id": asset_id}, ) client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]}) resp = client.get(f"/api/profile/{alice_id}", headers=h_bob) assert resp.status_code == 200, resp.text data = resp.json() assert data["user_id"] == alice_id assert data["username"] == "pub_alice" assert data["bio"] == "Hi, I am Alice" assert data["avatar_asset_id"] == asset_id assert data["is_restricted"] is False assert len(data["favorites"]) == 1 assert data["favorites"][0]["id"] == w1 assert data["favorites"][0]["name"] == "AliceWorld" assert "email" not in data assert "password" not in data def test_public_profile_not_found(self): client = TestClient(app) h_bob = _auth_headers(client, "pub_nf_bob") resp = client.get("/api/profile/999999", headers=h_bob) assert resp.status_code == 404 def test_public_profile_requires_auth(self): client = TestClient(app) assert client.get("/api/profile/1").status_code == 401 def test_public_profile_private_visibility(self): client = TestClient(app) h_alice = _auth_headers(client, "pub_priv_a") alice_id = _me_id(client, h_alice) h_bob = _auth_headers(client, "pub_priv_b") client.put( "/api/profile/me", headers=h_alice, json={"bio": "Top secret bio", "privacy": {"profile_visibility": "private"}}, ) resp_bob = client.get(f"/api/profile/{alice_id}", headers=h_bob) assert resp_bob.status_code == 200 data_bob = resp_bob.json() assert data_bob["is_restricted"] is True assert data_bob["bio"] is None assert data_bob["favorites"] == [] assert data_bob["friends"] == [] # Alice viewing herself is not restricted resp_alice = client.get(f"/api/profile/{alice_id}", headers=h_alice) assert resp_alice.status_code == 200 assert resp_alice.json()["is_restricted"] is False assert resp_alice.json()["bio"] == "Top secret bio" def test_public_profile_friends_only_visibility(self): client = TestClient(app) h_alice = _auth_headers(client, "pub_fo_a") alice_id = _me_id(client, h_alice) h_bob = _auth_headers(client, "pub_fo_b") bob_id = _me_id(client, h_bob) h_charlie = _auth_headers(client, "pub_fo_c") client.put( "/api/profile/me", headers=h_alice, json={"bio": "Friends only bio", "privacy": {"profile_visibility": "friends"}}, ) # Charlie is not a friend -> restricted resp_c = client.get(f"/api/profile/{alice_id}", headers=h_charlie) assert resp_c.json()["is_restricted"] is True assert resp_c.json()["bio"] is None # Bob becomes friends with Alice req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id}) req_id = req.json()["id"] client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice) # Now Bob sees full profile and friends list contains Bob resp_b = client.get(f"/api/profile/{alice_id}", headers=h_bob) assert resp_b.json()["is_restricted"] is False assert resp_b.json()["bio"] == "Friends only bio" assert any(f["id"] == bob_id for f in resp_b.json()["friends"]) def test_public_profile_blocked_user_gets_restricted(self): client = TestClient(app) h_alice = _auth_headers(client, "pub_blk_a") alice_id = _me_id(client, h_alice) h_dan = _auth_headers(client, "pub_blk_d") dan_id = _me_id(client, h_dan) client.put("/api/profile/me", headers=h_alice, json={"bio": "Alice public bio"}) client.post(f"/api/friends/{dan_id}/block", headers=h_alice) # Dan viewing Alice gets restricted view resp_dan = client.get(f"/api/profile/{alice_id}", headers=h_dan) assert resp_dan.status_code == 200 assert resp_dan.json()["is_restricted"] is True assert resp_dan.json()["bio"] is None # Alice viewing Dan gets restricted view resp_alice = client.get(f"/api/profile/{dan_id}", headers=h_alice) assert resp_alice.status_code == 200 assert resp_alice.json()["is_restricted"] is True def test_public_profile_selective_favorites_and_friends_privacy(self): client = TestClient(app) h_alice = _auth_headers(client, "pub_sec_a") alice_id = _me_id(client, h_alice) h_bob = _auth_headers(client, "pub_sec_b") bob_id = _me_id(client, h_bob) w1 = _make_world(client, h_alice, "WorldSec") client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]}) req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id}) req_id = req.json()["id"] client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice) client.put( "/api/profile/me", headers=h_alice, json={ "bio": "Bio is public", "privacy": { "profile_visibility": "public", "favorites_visibility": "private", "friends_list_visibility": "private", }, }, ) resp = client.get(f"/api/profile/{alice_id}", headers=h_bob) assert resp.status_code == 200 data = resp.json() assert data["bio"] == "Bio is public" assert data["favorites"] == [] assert data["friends"] == []