Strengthen nickname uniqueness validation with Firestore fallback.

- RTDB 예약 시스템에만 의존하던 닉네임 중복 검증에 Firestore 조회를 통한 fallback 로직을 추가했습니다.
- `consumeReservation`을 검증(`verify`)과 삭제(`delete`) 단계로 분리하여 유저 생성 과정의 안정성을 높였습니다.
- 이미 가입이 완료되어 RTDB 예약 기록이 삭제된 닉네임에 대해서도 중복 가입을 차단하도록 개선했습니다.
- 확정된 유저의 닉네임 중복 요청 시 409 NICKNAME_TAKEN 에러를 반환하는 테스트 케이스를 추가했습니다.
This commit is contained in:
윤정민 2026-04-14 13:26:43 +09:00
parent dfd21ba4c2
commit 697e0ff11a
4 changed files with 52 additions and 3 deletions

View File

@ -56,7 +56,7 @@ export async function reserveNickname(
return { previousReservation };
}
export async function consumeReservation(
export async function verifyReservation(
uid: string,
name: string
): Promise<void> {
@ -77,7 +77,12 @@ export async function consumeReservation(
"RESERVATION_MISSING"
);
}
}
export async function deleteReservation(
uid: string,
name: string
): Promise<void> {
await rtdb.ref().update({
[userNicknamePath(uid)]: null,
[nicknamePath(name)]: null,

View File

@ -29,6 +29,21 @@ export async function getUser(uid: string): Promise<User | null> {
return snap.exists ? (snap.data() as User) : null;
}
/**
* displayName으로 uid를 . `null`.
* fallback.
*/
export async function findUidByDisplayName(
displayName: string
): Promise<string | null> {
const snap = await firestore
.collection(COLLECTION)
.where("displayName", "==", displayName)
.limit(1)
.get();
return snap.empty ? null : snap.docs[0].id;
}
/**
* . `createdAt` ,
* UID가 (`merge: false`).

View File

@ -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

View File

@ -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,