Skip to content

feat: add user profile and streak features - #7

Merged
makachanm merged 1 commit into
mainfrom
feat/ranking-page
May 28, 2026
Merged

makachanm merged 1 commit into
mainfrom
feat/ranking-page

Conversation

@comts224

Copy link
Copy Markdown
Contributor

Update user click behavior on the ranking page

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +140 to +182
{!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>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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>
          )}

Comment on lines +30 to +35
useEffect(() => {
if (!userId) return
let cancelled = false
setLoading(true)
setError(null)
apiGet<PublicProfile>(`/users/${userId}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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}`)

Comment on lines +19 to +94
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

@makachanm
makachanm merged commit e31a784 into main May 28, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants