Repository navigation
feat: add user profile and streak features - #7
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements a public user profile feature, adding a new GET /users/{user_id} endpoint with server-side masking for anonymous users, alongside corresponding frontend pages, routing, and shared UI components like StreakGrass. The code reviewer provided valuable feedback, identifying a misleading UI condition in UserProfile.tsx when public user stats are missing, suggesting client-side validation for userId to prevent unnecessary 422 API errors, and recommending a try...finally block in the smoke test to ensure temporary database cleanup upon assertion failures.
| {!data.is_anonymous && stats ? ( | ||
| <div className="lg:col-span-2 flex flex-col gap-6"> | ||
| <Card> | ||
| <CardHead title="활동 요약" icon={<span>📈</span>} /> | ||
| <div className="mb-3 rounded-lg border border-gray-200 bg-white px-4 py-3"> | ||
| <div className="flex items-center justify-between"> | ||
| <div> | ||
| <div className="text-[10px] font-bold uppercase tracking-wider text-gray-500"> | ||
| 현재 스트릭 | ||
| </div> | ||
| <div className="text-xl font-bold tabular-nums text-gray-900"> | ||
| {stats.current_streak}일 | ||
| </div> | ||
| </div> | ||
| <div className="flex flex-col items-end text-right"> | ||
| <span className="text-[10px] font-semibold uppercase tracking-wider text-gray-500"> | ||
| 최장 | ||
| </span> | ||
| <span className="text-sm font-semibold tabular-nums text-gray-700"> | ||
| {stats.longest_streak}일 | ||
| </span> | ||
| </div> | ||
| </div> | ||
| {stats.daily_solves.length > 0 && ( | ||
| <StreakGrass days={stats.daily_solves} /> | ||
| )} | ||
| </div> | ||
| <div className="grid grid-cols-3 gap-3"> | ||
| <Stat label="누적 제출" value={stats.total_submissions} /> | ||
| <Stat label="해결 문제" value={stats.solved} /> | ||
| <Stat label="누적 EXP" value={data.exp} valueClass="text-violet-600" /> | ||
| </div> | ||
| </Card> | ||
| </div> | ||
| ) : ( | ||
| <div className="lg:col-span-2"> | ||
| <Card> | ||
| <p className="text-sm text-gray-500 py-6 text-center"> | ||
| 이 사용자는 익명 표시를 설정해 활동 정보를 공개하지 않습니다. | ||
| </p> | ||
| </Card> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
If data.is_anonymous is false but stats is somehow null or undefined, the current condition falls back to the else block and displays:
"이 사용자는 익명 표시를 설정해 활동 정보를 공개하지 않습니다."
This is misleading because the user is actually public, but their stats are missing. It is better to separate the anonymity check from the stats presence check to provide an accurate and clear user experience.
{data.is_anonymous ? (
<div className="lg:col-span-2">
<Card>
<p className="text-sm text-gray-500 py-6 text-center">
이 사용자는 익명 표시를 설정해 활동 정보를 공개하지 않습니다.
</p>
</Card>
</div>
) : stats ? (
<div className="lg:col-span-2 flex flex-col gap-6">
<Card>
<CardHead title="활동 요약" icon={<span>📈</span>} />
<div className="mb-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<div className="flex items-center justify-between">
<div>
<div className="text-[10px] font-bold uppercase tracking-wider text-gray-500">
현재 스트릭
</div>
<div className="text-xl font-bold tabular-nums text-gray-900">
{stats.current_streak}일
</div>
</div>
<div className="flex flex-col items-end text-right">
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
최장
</span>
<span className="text-sm font-semibold tabular-nums text-gray-700">
{stats.longest_streak}일
</span>
</div>
</div>
{stats.daily_solves.length > 0 && (
<StreakGrass days={stats.daily_solves} />
)}
</div>
<div className="grid grid-cols-3 gap-3">
<Stat label="누적 제출" value={stats.total_submissions} />
<Stat label="해결 문제" value={stats.solved} />
<Stat label="누적 EXP" value={data.exp} valueClass="text-violet-600" />
</div>
</Card>
</div>
) : (
<div className="lg:col-span-2">
<Card>
<p className="text-sm text-gray-500 py-6 text-center">
활동 정보가 없습니다.
</p>
</Card>
</div>
)}
| useEffect(() => { | ||
| if (!userId) return | ||
| let cancelled = false | ||
| setLoading(true) | ||
| setError(null) | ||
| apiGet<PublicProfile>(`/users/${userId}`) |
There was a problem hiding this comment.
If the userId path parameter is not a valid integer (e.g., /users/abc), the client will still make an API request to /users/abc, which will result in a 422 Unprocessable Entity error from FastAPI.
Adding a simple client-side validation check using a regular expression prevents unnecessary API calls and immediately displays a friendly "User not found" error message.
useEffect(() => {
if (!userId) return
if (!/^\d+$/.test(userId)) {
setError('존재하지 않는 사용자입니다.')
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
apiGet<PublicProfile>(`/users/${userId}`)
| init_db() | ||
|
|
||
| with get_session() as s: | ||
| pub = get_or_create_user( | ||
| s, provider="dev_stub", external_id="pub1", display_name="공개유저" | ||
| ) | ||
| pub.exp = 500 | ||
| pub.tier = "silver" | ||
| pub.grade = 3 | ||
| pub.department = "컴퓨터공학부" | ||
| pub.nickname = "pubnick" | ||
| s.add(pub) | ||
|
|
||
| anon = get_or_create_user( | ||
| s, provider="dev_stub", external_id="anon1", display_name="실명숨김" | ||
| ) | ||
| anon.exp = 800 | ||
| anon.tier = "gold" | ||
| anon.grade = 4 | ||
| anon.department = "전자공학부" | ||
| anon.nickname = "익명닉" | ||
| anon.is_anonymous = True | ||
| s.add(anon) | ||
| s.commit() | ||
| s.refresh(pub) | ||
| s.refresh(anon) | ||
| pub_id, anon_id = pub.id, anon.id | ||
| # 공개유저에게 AC 제출 2건(서로 다른 문제) — solved/total 검증용 | ||
| s.add(SubmissionRow(user_id=pub_id, problem_id=1, code="x", final_verdict="AC")) | ||
| s.add(SubmissionRow(user_id=pub_id, problem_id=2, code="x", final_verdict="AC")) | ||
| s.add(SubmissionRow(user_id=pub_id, problem_id=2, code="x", final_verdict="SUS")) | ||
| s.commit() | ||
|
|
||
| client = TestClient(app) | ||
|
|
||
| r_pub = client.get(f"/users/{pub_id}") | ||
| r_anon = client.get(f"/users/{anon_id}") | ||
| r_404 = client.get("/users/999999") | ||
|
|
||
| print("=== 비익명 ===", r_pub.status_code) | ||
| print(r_pub.json()) | ||
| print("=== 익명 ===", r_anon.status_code) | ||
| print(r_anon.json()) | ||
| print("=== 404 ===", r_404.status_code) | ||
|
|
||
| pub_body = r_pub.json() | ||
| anon_body = r_anon.json() | ||
|
|
||
| assert r_pub.status_code == 200 | ||
| assert r_pub.json()["display_name"] == "공개유저" | ||
| assert pub_body["grade"] == 3 and pub_body["department"] == "컴퓨터공학부" | ||
| assert pub_body["stats"]["solved"] == 2 | ||
| assert pub_body["stats"]["total_submissions"] == 3 | ||
| assert pub_body["is_anonymous"] is False | ||
|
|
||
| assert r_anon.status_code == 200 | ||
| assert anon_body["display_name"] == "익명닉" # nickname 으로 마스킹 | ||
| assert anon_body["is_anonymous"] is True | ||
| # 익명: 신원 단서/통계는 전부 None 이어야 함 | ||
| assert anon_body["avatar_url"] is None | ||
| assert anon_body["grade"] is None | ||
| assert anon_body["department"] is None | ||
| assert anon_body["stats"] is None | ||
| assert anon_body["rank"] is None | ||
| # 응답에 실명/이메일이 새지 않아야 함 | ||
| assert "실명숨김" not in r_anon.text | ||
| assert "email" not in anon_body | ||
|
|
||
| assert r_404.status_code == 404 | ||
|
|
||
| print("\nALL ASSERTIONS PASSED") | ||
|
|
||
| try: | ||
| os.unlink(_db_path) | ||
| except OSError: | ||
| pass |
There was a problem hiding this comment.
If any of the assertions fail during the execution of this smoke test, the script will raise an AssertionError and terminate immediately. This prevents the cleanup code at the end of the file from running, leaving the temporary SQLite database file on disk.
Wrapping the test execution in a try...finally block ensures that the temporary database file is always cleaned up regardless of whether the assertions pass or fail.
try:
init_db()
with get_session() as s:
pub = get_or_create_user(
s, provider="dev_stub", external_id="pub1", display_name="공개유저"
)
pub.exp = 500
pub.tier = "silver"
pub.grade = 3
pub.department = "컴퓨터공학부"
pub.nickname = "pubnick"
s.add(pub)
anon = get_or_create_user(
s, provider="dev_stub", external_id="anon1", display_name="실명숨김"
)
anon.exp = 800
anon.tier = "gold"
anon.grade = 4
anon.department = "전자공학부"
anon.nickname = "익명닉"
anon.is_anonymous = True
s.add(anon)
s.commit()
s.refresh(pub)
s.refresh(anon)
pub_id, anon_id = pub.id, anon.id
# 공개유저에게 AC 제출 2건(서로 다른 문제) — solved/total 검증용
s.add(SubmissionRow(user_id=pub_id, problem_id=1, code="x", final_verdict="AC"))
s.add(SubmissionRow(user_id=pub_id, problem_id=2, code="x", final_verdict="AC"))
s.add(SubmissionRow(user_id=pub_id, problem_id=2, code="x", final_verdict="SUS"))
s.commit()
client = TestClient(app)
r_pub = client.get(f"/users/{pub_id}")
r_anon = client.get(f"/users/{anon_id}")
r_404 = client.get("/users/999999")
print("=== 비익명 ===", r_pub.status_code)
print(r_pub.json())
print("=== 익명 ===", r_anon.status_code)
print(r_anon.json())
print("=== 404 ===", r_404.status_code)
pub_body = r_pub.json()
anon_body = r_anon.json()
assert r_pub.status_code == 200
assert r_pub.json()["display_name"] == "공개유저"
assert pub_body["grade"] == 3 and pub_body["department"] == "컴퓨터공학부"
assert pub_body["stats"]["solved"] == 2
assert pub_body["stats"]["total_submissions"] == 3
assert pub_body["is_anonymous"] is False
assert r_anon.status_code == 200
assert anon_body["display_name"] == "익명닉" # nickname 으로 마스킹
assert anon_body["is_anonymous"] is True
# 익명: 신원 단서/통계는 전부 None 이어야 함
assert anon_body["avatar_url"] is None
assert anon_body["grade"] is None
assert anon_body["department"] is None
assert anon_body["stats"] is None
assert anon_body["rank"] is None
# 응답에 실명/이메일이 새지 않아야 함
assert "실명숨김" not in r_anon.text
assert "email" not in anon_body
assert r_404.status_code == 404
print("\nALL ASSERTIONS PASSED")
finally:
try:
os.unlink(_db_path)
except OSError:
pass
Update user click behavior on the ranking page