feat(mvp): complete M13 clean-up milestone (real usernames, avatar studio redesign, pause menu, audio manager)
checkpoint / checkpoint (push) Successful in 6m22s

This commit is contained in:
2026-09-18 21:54:01 +00:00
parent 4c8a60fbc8
commit ee4006d217
31 changed files with 2394 additions and 262 deletions
+38 -5
View File
@@ -46,7 +46,8 @@ DbDep = Annotated[AsyncSession, Depends(get_db)]
class FriendRequestCreate(BaseModel):
user_id: int
user_id: int | None = None
username: str | None = None
class FriendRequestResponse(BaseModel):
@@ -55,6 +56,7 @@ class FriendRequestResponse(BaseModel):
addressee_id: int
status: FriendshipStatus
created_at: datetime
requester_username: str | None = None
model_config = {"from_attributes": True}
@@ -132,7 +134,18 @@ async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
@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
target_id: int
if body.user_id is not None and body.user_id > 0:
target_id = body.user_id
elif body.username is not None and body.username.strip() != "":
u_result = await db.execute(select(User).where(User.username == body.username.strip()))
user = u_result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
target_id = user.id
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_id or username required")
if target_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a friend request to yourself")
@@ -153,19 +166,39 @@ async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUs
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
await db.refresh(fr)
return fr
return FriendRequestResponse(
id=fr.id,
requester_id=fr.requester_id,
addressee_id=fr.addressee_id,
status=fr.status,
created_at=fr.created_at,
requester_username=current_user.username,
)
@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(
select(Friendship, User.username)
.join(User, Friendship.requester_id == User.id)
.where(
Friendship.addressee_id == current_user.id,
Friendship.status == FriendshipStatus.PENDING,
)
)
return FriendRequestListResponse(requests=list(result.scalars().all()))
requests = [
FriendRequestResponse(
id=fr.id,
requester_id=fr.requester_id,
addressee_id=fr.addressee_id,
status=fr.status,
created_at=fr.created_at,
requester_username=uname,
)
for fr, uname in result.all()
]
return FriendRequestListResponse(requests=requests)
@router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse)
+44
View File
@@ -25,6 +25,13 @@ class UserResponse(BaseModel):
model_config = {"from_attributes": True}
class UserSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class UserUpdate(BaseModel):
email: EmailStr | None = None
is_active: bool | None = None
@@ -40,6 +47,43 @@ async def get_current_user_profile(
return current_user
@router.get("/search", response_model=list[UserSummary])
async def search_users(
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
q: str = "",
limit: int = 20,
):
"""Search active users by username substring."""
cleaned_q = q.strip()
result = await db.execute(
select(User)
.where(
User.is_active == True,
User.username.ilike(f"%{cleaned_q}%"),
User.id != current_user.id,
)
.limit(limit)
)
return list(result.scalars().all())
@router.get("/by-username/{username}", response_model=UserSummary)
async def get_user_by_username(
username: str,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get active user by exact username."""
result = await db.execute(
select(User).where(User.username == username.strip(), User.is_active == True)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
+44
View File
@@ -127,6 +127,26 @@ class TestFriendRequestFlow:
again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert again.status_code == 404
def test_send_request_by_username_populates_requester_username(self):
client = TestClient(app)
a = _register(client, "fr_uname_a")
b = _register(client, "fr_uname_b")
resp = client.post(
"/api/friends/requests", json={"username": "fr_uname_b"}, headers=a["headers"]
)
assert resp.status_code == 201, resp.text
req_data = resp.json()
assert req_data["status"] == "pending"
assert req_data.get("requester_username") == "fr_uname_a"
incoming = client.get("/api/friends/requests", headers=b["headers"])
assert incoming.status_code == 200, incoming.text
reqs = incoming.json()["requests"]
matching = [r for r in reqs if r["id"] == req_data["id"]]
assert len(matching) == 1
assert matching[0]["requester_username"] == "fr_uname_a"
class TestSelfAndDuplicateRejection:
def test_self_request_rejected(self):
@@ -315,3 +335,27 @@ class TestDataIsolation:
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
class TestUserSearchAndLookup:
def test_search_and_lookup_by_username(self):
client = TestClient(app)
u1 = _register(client, "usr_srch_1")
u2 = _register(client, "usr_srch_2")
# Search excludes current user
search_res = client.get("/api/users/search?q=usr_srch", headers=u1["headers"])
assert search_res.status_code == 200, search_res.text
names = [u["username"] for u in search_res.json()]
assert "usr_srch_2" in names
assert "usr_srch_1" not in names
# Lookup by username
lookup_res = client.get("/api/users/by-username/usr_srch_2", headers=u1["headers"])
assert lookup_res.status_code == 200, lookup_res.text
assert lookup_res.json()["username"] == "usr_srch_2"
assert lookup_res.json()["id"] == u2["id"]
# Lookup 404 for unknown user
missing = client.get("/api/users/by-username/nonexistent_usr_xyz", headers=u1["headers"])
assert missing.status_code == 404