From 697e0ff11a6fd1402b60696256240d13d294d24e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Tue, 14 Apr 2026 13:26:43 +0900 Subject: [PATCH] Strengthen nickname uniqueness validation with Firestore fallback. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RTDB 예약 시스템에만 의존하던 닉네임 중복 검증에 Firestore 조회를 통한 fallback 로직을 추가했습니다. - `consumeReservation`을 검증(`verify`)과 삭제(`delete`) 단계로 분리하여 유저 생성 과정의 안정성을 높였습니다. - 이미 가입이 완료되어 RTDB 예약 기록이 삭제된 닉네임에 대해서도 중복 가입을 차단하도록 개선했습니다. - 확정된 유저의 닉네임 중복 요청 시 409 NICKNAME_TAKEN 에러를 반환하는 테스트 케이스를 추가했습니다. --- src/repositories/nicknameRepository.ts | 7 ++++++- src/repositories/userRepository.ts | 15 +++++++++++++++ src/services/userService.ts | 13 +++++++++++-- tests/services/userService.test.ts | 20 ++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/repositories/nicknameRepository.ts b/src/repositories/nicknameRepository.ts index 14e35e3..44973aa 100644 --- a/src/repositories/nicknameRepository.ts +++ b/src/repositories/nicknameRepository.ts @@ -56,7 +56,7 @@ export async function reserveNickname( return { previousReservation }; } -export async function consumeReservation( +export async function verifyReservation( uid: string, name: string ): Promise { @@ -77,7 +77,12 @@ export async function consumeReservation( "RESERVATION_MISSING" ); } +} +export async function deleteReservation( + uid: string, + name: string +): Promise { await rtdb.ref().update({ [userNicknamePath(uid)]: null, [nicknamePath(name)]: null, diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index d24382c..27f81c6 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -29,6 +29,21 @@ export async function getUser(uid: string): Promise { return snap.exists ? (snap.data() as User) : null; } +/** + * displayName으로 유저의 uid를 조회한다. 없으면 `null`. + * 닉네임 유니크성 검증용 fallback. + */ +export async function findUidByDisplayName( + displayName: string +): Promise { + const snap = await firestore + .collection(COLLECTION) + .where("displayName", "==", displayName) + .limit(1) + .get(); + return snap.empty ? null : snap.docs[0].id; +} + /** * 신규 유저 문서를 생성한다. `createdAt`은 서버 타임스탬프로 기록되며, * 같은 UID가 있으면 전체 덮어쓴다(`merge: false`). diff --git a/src/services/userService.ts b/src/services/userService.ts index b8c8e3a..c05869e 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -4,13 +4,15 @@ import { auth } from "../firebase.js"; import { createUser, deleteUser, + findUidByDisplayName, getUser, updateUser, } from "../repositories/userRepository.js"; import { - consumeReservation, + deleteReservation, releaseReservation, reserveNickname, + verifyReservation, } from "../repositories/nicknameRepository.js"; import { KnowledgeLevel, @@ -119,7 +121,7 @@ export async function createMe( } const provider = providerFromToken(token); - await consumeReservation(token.uid, displayName); + await verifyReservation(token.uid, displayName); await createUser(token.uid, { displayName, @@ -130,6 +132,8 @@ export async function createMe( knowledgeLevel, }); + await deleteReservation(token.uid, displayName); + const created = await getUser(token.uid); if (!created) { throw new HttpError(500, "failed to read created user"); @@ -171,6 +175,11 @@ export async function checkNickname( throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS"); } + const confirmedOwner = await findUidByDisplayName(displayName); + if (confirmedOwner && confirmedOwner !== token.uid) { + throw new HttpError(409, "nickname already taken", "NICKNAME_TAKEN"); + } + const { previousReservation } = await reserveNickname( token.uid, displayName diff --git a/tests/services/userService.test.ts b/tests/services/userService.test.ts index 7be5616..e564e5f 100644 --- a/tests/services/userService.test.ts +++ b/tests/services/userService.test.ts @@ -223,6 +223,26 @@ describe("userService", () => { }); }); + it("확정된 유저의 displayName이면 타 uid는 409 NICKNAME_TAKEN", async () => { + // userA 가입 후 RTDB 예약은 소비된 상태 + await createMeWithReservation(fakeToken({ uid: "userA", sub: "userA" })); + const nameSnap = await rtdb + .ref(`/nicknames/${validBody.displayName}`) + .get(); + expect(nameSnap.exists()).toBe(false); // 선결 조건 + + // userB가 같은 이름 요청 → Firestore fallback이 차단 + await expect( + checkNickname( + fakeToken({ uid: "userB", sub: "userB" }), + validBody.displayName + ) + ).rejects.toMatchObject({ + status: 409, + code: "NICKNAME_TAKEN", + }); + }); + it("displayName 형식 오류 → 400", async () => { await expect(checkNickname(fakeToken(), "")).rejects.toMatchObject({ status: 400,