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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user