feat(mvp): complete M13 clean-up milestone (real usernames, avatar studio redesign, pause menu, audio manager)
checkpoint / checkpoint (push) Successful in 6m22s
checkpoint / checkpoint (push) Successful in 6m22s
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user