Strengthen nickname uniqueness validation with Firestore fallback.
- RTDB 예약 시스템에만 의존하던 닉네임 중복 검증에 Firestore 조회를 통한 fallback 로직을 추가했습니다. - `consumeReservation`을 검증(`verify`)과 삭제(`delete`) 단계로 분리하여 유저 생성 과정의 안정성을 높였습니다. - 이미 가입이 완료되어 RTDB 예약 기록이 삭제된 닉네임에 대해서도 중복 가입을 차단하도록 개선했습니다. - 확정된 유저의 닉네임 중복 요청 시 409 NICKNAME_TAKEN 에러를 반환하는 테스트 케이스를 추가했습니다.
This commit is contained in:
parent
dfd21ba4c2
commit
697e0ff11a
@ -56,7 +56,7 @@ export async function reserveNickname(
|
|||||||
return { previousReservation };
|
return { previousReservation };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumeReservation(
|
export async function verifyReservation(
|
||||||
uid: string,
|
uid: string,
|
||||||
name: string
|
name: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -77,7 +77,12 @@ export async function consumeReservation(
|
|||||||
"RESERVATION_MISSING"
|
"RESERVATION_MISSING"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReservation(
|
||||||
|
uid: string,
|
||||||
|
name: string
|
||||||
|
): Promise<void> {
|
||||||
await rtdb.ref().update({
|
await rtdb.ref().update({
|
||||||
[userNicknamePath(uid)]: null,
|
[userNicknamePath(uid)]: null,
|
||||||
[nicknamePath(name)]: null,
|
[nicknamePath(name)]: null,
|
||||||
|
|||||||
@ -29,6 +29,21 @@ export async function getUser(uid: string): Promise<User | null> {
|
|||||||
return snap.exists ? (snap.data() as 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`은 서버 타임스탬프로 기록되며,
|
* 신규 유저 문서를 생성한다. `createdAt`은 서버 타임스탬프로 기록되며,
|
||||||
* 같은 UID가 있으면 전체 덮어쓴다(`merge: false`).
|
* 같은 UID가 있으면 전체 덮어쓴다(`merge: false`).
|
||||||
|
|||||||
@ -4,13 +4,15 @@ import { auth } from "../firebase.js";
|
|||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
findUidByDisplayName,
|
||||||
getUser,
|
getUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "../repositories/userRepository.js";
|
} from "../repositories/userRepository.js";
|
||||||
import {
|
import {
|
||||||
consumeReservation,
|
deleteReservation,
|
||||||
releaseReservation,
|
releaseReservation,
|
||||||
reserveNickname,
|
reserveNickname,
|
||||||
|
verifyReservation,
|
||||||
} from "../repositories/nicknameRepository.js";
|
} from "../repositories/nicknameRepository.js";
|
||||||
import {
|
import {
|
||||||
KnowledgeLevel,
|
KnowledgeLevel,
|
||||||
@ -119,7 +121,7 @@ export async function createMe(
|
|||||||
}
|
}
|
||||||
const provider = providerFromToken(token);
|
const provider = providerFromToken(token);
|
||||||
|
|
||||||
await consumeReservation(token.uid, displayName);
|
await verifyReservation(token.uid, displayName);
|
||||||
|
|
||||||
await createUser(token.uid, {
|
await createUser(token.uid, {
|
||||||
displayName,
|
displayName,
|
||||||
@ -130,6 +132,8 @@ export async function createMe(
|
|||||||
knowledgeLevel,
|
knowledgeLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await deleteReservation(token.uid, displayName);
|
||||||
|
|
||||||
const created = await getUser(token.uid);
|
const created = await getUser(token.uid);
|
||||||
if (!created) {
|
if (!created) {
|
||||||
throw new HttpError(500, "failed to read created user");
|
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");
|
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(
|
const { previousReservation } = await reserveNickname(
|
||||||
token.uid,
|
token.uid,
|
||||||
displayName
|
displayName
|
||||||
|
|||||||
@ -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 () => {
|
it("displayName 형식 오류 → 400", async () => {
|
||||||
await expect(checkNickname(fakeToken(), "")).rejects.toMatchObject({
|
await expect(checkNickname(fakeToken(), "")).rejects.toMatchObject({
|
||||||
status: 400,
|
status: 400,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user