Implement nickname reservation system and add user delete functionality.
- 닉네임 선점 및 검증을 위한 예약 시스템을 구현하고 관련 로직을 추가했습니다. - 유저 데이터 및 하위 컬렉션을 완전히 삭제하는 회원 탈퇴 기능을 구현했습니다. - 토큰의 프로필 사진 변경 시 유저 정보를 자동으로 동기화하도록 개선했습니다. - RTDB를 활용하여 닉네임 예약 상태를 관리하고, 회원 가입 시 이를 검증하도록 변경했습니다.
This commit is contained in:
parent
f98fa84da8
commit
dfd21ba4c2
@ -18,6 +18,14 @@
|
|||||||
"cache": {
|
"cache": {
|
||||||
".read": false,
|
".read": false,
|
||||||
".write": false
|
".write": false
|
||||||
|
},
|
||||||
|
"nicknames": {
|
||||||
|
".read": false,
|
||||||
|
".write": false
|
||||||
|
},
|
||||||
|
"userNicknames": {
|
||||||
|
".read": false,
|
||||||
|
".write": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,40 @@
|
|||||||
import { onRequest } from "firebase-functions/https";
|
import { onRequest } from "firebase-functions/https";
|
||||||
import { requireAuth, requireAuthToken } from "../middleware/auth.js";
|
import { requireAuthToken } from "../middleware/auth.js";
|
||||||
import { sendError } from "../middleware/errors.js";
|
import { sendError } from "../middleware/errors.js";
|
||||||
import { createMe, getMe } from "../services/userService.js";
|
import {
|
||||||
|
checkNickname,
|
||||||
|
createMe,
|
||||||
|
deleteMe,
|
||||||
|
getMe,
|
||||||
|
} from "../services/userService.js";
|
||||||
|
|
||||||
export const user = onRequest(async (req, res) => {
|
export const user = onRequest(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET" && req.path === "/check-nickname") {
|
||||||
const uid = await requireAuth(req);
|
const token = await requireAuthToken(req);
|
||||||
const u = await getMe(uid);
|
const result = await checkNickname(token, req.query.displayName);
|
||||||
res.status(200).json({ user: { uid, ...u } });
|
res.status(200).json(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === "POST") {
|
if (req.method === "GET" && req.path === "/") {
|
||||||
|
const token = await requireAuthToken(req);
|
||||||
|
const u = await getMe(token);
|
||||||
|
res.status(200).json({ user: { uid: token.uid, ...u } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "POST" && req.path === "/") {
|
||||||
const token = await requireAuthToken(req);
|
const token = await requireAuthToken(req);
|
||||||
const u = await createMe(token, req.body ?? {});
|
const u = await createMe(token, req.body ?? {});
|
||||||
res.status(201).json({ user: { uid: token.uid, ...u } });
|
res.status(201).json({ user: { uid: token.uid, ...u } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.status(405).json({ error: `method ${req.method} not allowed` });
|
if (req.method === "DELETE" && req.path === "/") {
|
||||||
|
const token = await requireAuthToken(req);
|
||||||
|
await deleteMe(token);
|
||||||
|
res.status(204).send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendError(res, err);
|
sendError(res, err);
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/repositories/nicknameRepository.ts
Normal file
100
src/repositories/nicknameRepository.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { rtdb } from "../firebase.js";
|
||||||
|
import { HttpError } from "../middleware/errors.js";
|
||||||
|
|
||||||
|
export const RESERVATION_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
interface ReservationNode {
|
||||||
|
uid: string;
|
||||||
|
reservedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nicknamePath(name: string): string {
|
||||||
|
return `/nicknames/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userNicknamePath(uid: string): string {
|
||||||
|
return `/userNicknames/${uid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reserveNickname(
|
||||||
|
uid: string,
|
||||||
|
name: string
|
||||||
|
): Promise<{ previousReservation: string | null }> {
|
||||||
|
const prevSnap = await rtdb.ref(userNicknamePath(uid)).get();
|
||||||
|
const previous = prevSnap.exists() ? (prevSnap.val() as string) : null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const txResult = await rtdb
|
||||||
|
.ref(nicknamePath(name))
|
||||||
|
.transaction((current: ReservationNode | null) => {
|
||||||
|
if (current === null) {
|
||||||
|
return { uid, reservedAt: now };
|
||||||
|
}
|
||||||
|
if (current.uid === uid) {
|
||||||
|
return { uid, reservedAt: now };
|
||||||
|
}
|
||||||
|
if (now - current.reservedAt > RESERVATION_TTL_MS) {
|
||||||
|
return { uid, reservedAt: now };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!txResult.committed) {
|
||||||
|
throw new HttpError(409, "nickname already taken", "NICKNAME_TAKEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
[userNicknamePath(uid)]: name,
|
||||||
|
};
|
||||||
|
if (previous && previous !== name) {
|
||||||
|
updates[nicknamePath(previous)] = null;
|
||||||
|
}
|
||||||
|
await rtdb.ref().update(updates);
|
||||||
|
|
||||||
|
const previousReservation =
|
||||||
|
previous && previous !== name ? previous : null;
|
||||||
|
return { previousReservation };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeReservation(
|
||||||
|
uid: string,
|
||||||
|
name: string
|
||||||
|
): Promise<void> {
|
||||||
|
const [userSnap, nameSnap] = await Promise.all([
|
||||||
|
rtdb.ref(userNicknamePath(uid)).get(),
|
||||||
|
rtdb.ref(nicknamePath(name)).get(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userName = userSnap.exists() ? (userSnap.val() as string) : null;
|
||||||
|
const node = nameSnap.exists()
|
||||||
|
? (nameSnap.val() as ReservationNode)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (userName !== name || !node || node.uid !== uid) {
|
||||||
|
throw new HttpError(
|
||||||
|
409,
|
||||||
|
"nickname reservation missing or mismatched",
|
||||||
|
"RESERVATION_MISSING"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rtdb.ref().update({
|
||||||
|
[userNicknamePath(uid)]: null,
|
||||||
|
[nicknamePath(name)]: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseReservation(uid: string): Promise<void> {
|
||||||
|
const snap = await rtdb.ref(userNicknamePath(uid)).get();
|
||||||
|
if (!snap.exists()) return;
|
||||||
|
const name = snap.val() as string;
|
||||||
|
|
||||||
|
const nameSnap = await rtdb.ref(nicknamePath(name)).get();
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
[userNicknamePath(uid)]: null,
|
||||||
|
};
|
||||||
|
if (nameSnap.exists() && (nameSnap.val() as ReservationNode).uid === uid) {
|
||||||
|
updates[nicknamePath(name)] = null;
|
||||||
|
}
|
||||||
|
await rtdb.ref().update(updates);
|
||||||
|
}
|
||||||
@ -1,6 +1,11 @@
|
|||||||
import { FieldValue } from "firebase-admin/firestore";
|
import { FieldValue } from "firebase-admin/firestore";
|
||||||
import { firestore } from "../firebase.js";
|
import { firestore } from "../firebase";
|
||||||
import type { KnowledgeLevel, Provider, TeamCode, User } from "../types/panit.js";
|
import type {
|
||||||
|
KnowledgeLevel,
|
||||||
|
Provider,
|
||||||
|
TeamCode,
|
||||||
|
User,
|
||||||
|
} from "../types/panit";
|
||||||
|
|
||||||
const COLLECTION = "users";
|
const COLLECTION = "users";
|
||||||
|
|
||||||
@ -40,3 +45,20 @@ export async function createUser(uid: string, input: RegisterInput): Promise<voi
|
|||||||
if (input.photoUrl) doc.photoUrl = input.photoUrl;
|
if (input.photoUrl) doc.photoUrl = input.photoUrl;
|
||||||
await firestore.collection(COLLECTION).doc(uid).set(doc, { merge: false });
|
await firestore.collection(COLLECTION).doc(uid).set(doc, { merge: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유저 문서의 일부 필드를 병합 업데이트한다.
|
||||||
|
*/
|
||||||
|
export async function updateUser(
|
||||||
|
uid: string,
|
||||||
|
patch: Partial<Pick<User, "displayName" | "photoUrl" | "favoriteTeamCode" | "knowledgeLevel">>
|
||||||
|
): Promise<void> {
|
||||||
|
await firestore.collection(COLLECTION).doc(uid).set(patch, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유저 문서 및 하위 컬렉션(voteHistory 등)을 모두 삭제한다.
|
||||||
|
*/
|
||||||
|
export async function deleteUser(uid: string): Promise<void> {
|
||||||
|
await firestore.recursiveDelete(firestore.collection(COLLECTION).doc(uid));
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
import type { DecodedIdToken } from "firebase-admin/auth";
|
import type { DecodedIdToken } from "firebase-admin/auth";
|
||||||
import { HttpError } from "../middleware/errors.js";
|
import { HttpError } from "../middleware/errors.js";
|
||||||
|
import { auth } from "../firebase.js";
|
||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
|
deleteUser,
|
||||||
getUser,
|
getUser,
|
||||||
|
updateUser,
|
||||||
} from "../repositories/userRepository.js";
|
} from "../repositories/userRepository.js";
|
||||||
|
import {
|
||||||
|
consumeReservation,
|
||||||
|
releaseReservation,
|
||||||
|
reserveNickname,
|
||||||
|
} from "../repositories/nicknameRepository.js";
|
||||||
import {
|
import {
|
||||||
KnowledgeLevel,
|
KnowledgeLevel,
|
||||||
TeamCode,
|
TeamCode,
|
||||||
@ -33,7 +41,7 @@ function parseDisplayName(value: unknown): string {
|
|||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
throw new HttpError(400, "displayName must be a string", "INVALID_INPUT");
|
throw new HttpError(400, "displayName must be a string", "INVALID_INPUT");
|
||||||
}
|
}
|
||||||
if (value.length < 1 || value.length > 10) {
|
if (value.length < 1 || value.length > 9) {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
400,
|
400,
|
||||||
"displayName length must be 1~10",
|
"displayName length must be 1~10",
|
||||||
@ -56,12 +64,22 @@ function providerFromToken(token: DecodedIdToken): Provider {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 현재 로그인한 유저 문서를 조회한다. 없으면 404 + `USER_NOT_FOUND`.
|
* 현재 로그인한 유저 문서를 조회한다. 없으면 404 + `USER_NOT_FOUND`.
|
||||||
|
*
|
||||||
|
* 토큰의 `picture`가 저장된 `photoUrl`과 다르면 자동 동기화한다
|
||||||
|
* (프로필 사진 변경 반영). 응답은 동기화된 값으로 반환한다.
|
||||||
*/
|
*/
|
||||||
export async function getMe(uid: string): Promise<User> {
|
export async function getMe(token: DecodedIdToken): Promise<User> {
|
||||||
const user = await getUser(uid);
|
const user = await getUser(token.uid);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
|
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenPhoto = token.picture;
|
||||||
|
if (tokenPhoto && tokenPhoto !== user.photoUrl) {
|
||||||
|
await updateUser(token.uid, { photoUrl: tokenPhoto });
|
||||||
|
user.photoUrl = tokenPhoto;
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,12 +117,15 @@ export async function createMe(
|
|||||||
if (!token.email) {
|
if (!token.email) {
|
||||||
throw new HttpError(400, "token has no email", "INVALID_INPUT");
|
throw new HttpError(400, "token has no email", "INVALID_INPUT");
|
||||||
}
|
}
|
||||||
|
const provider = providerFromToken(token);
|
||||||
|
|
||||||
|
await consumeReservation(token.uid, displayName);
|
||||||
|
|
||||||
await createUser(token.uid, {
|
await createUser(token.uid, {
|
||||||
displayName,
|
displayName,
|
||||||
email: token.email,
|
email: token.email,
|
||||||
photoUrl: token.picture,
|
photoUrl: token.picture,
|
||||||
provider: providerFromToken(token),
|
provider,
|
||||||
favoriteTeamCode,
|
favoriteTeamCode,
|
||||||
knowledgeLevel,
|
knowledgeLevel,
|
||||||
});
|
});
|
||||||
@ -115,3 +136,44 @@ export async function createMe(
|
|||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인한 유저의 Firestore 문서(+ 하위 컬렉션)와 Firebase Auth 계정을 삭제한다.
|
||||||
|
* 존재하지 않으면 404 + `USER_NOT_FOUND`.
|
||||||
|
*/
|
||||||
|
export async function deleteMe(token: DecodedIdToken): Promise<void> {
|
||||||
|
const existing = await getUser(token.uid);
|
||||||
|
if (!existing) {
|
||||||
|
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
await deleteUser(token.uid);
|
||||||
|
await releaseReservation(token.uid);
|
||||||
|
try {
|
||||||
|
await auth.deleteUser(token.uid);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
if (code !== "auth/user-not-found") throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 닉네임 중복 체크 & 예약. 성공 시 요청 uid로 해당 닉네임을 선점한다.
|
||||||
|
* 같은 uid가 이전에 다른 닉네임을 예약했다면 해제 후 이전 이름을 반환한다.
|
||||||
|
*/
|
||||||
|
export async function checkNickname(
|
||||||
|
token: DecodedIdToken,
|
||||||
|
displayNameRaw: unknown
|
||||||
|
): Promise<{ available: true; previousReservation: string | null }> {
|
||||||
|
const displayName = parseDisplayName(displayNameRaw);
|
||||||
|
|
||||||
|
const existing = await getUser(token.uid);
|
||||||
|
if (existing) {
|
||||||
|
throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { previousReservation } = await reserveNickname(
|
||||||
|
token.uid,
|
||||||
|
displayName
|
||||||
|
);
|
||||||
|
return { available: true, previousReservation };
|
||||||
|
}
|
||||||
|
|||||||
@ -1,8 +1,17 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { DecodedIdToken } from "firebase-admin/auth";
|
import type { DecodedIdToken } from "firebase-admin/auth";
|
||||||
import { firestore } from "../../src/firebase.js";
|
import { firestore, rtdb } from "../../src/firebase.js";
|
||||||
import { createMe, getMe } from "../../src/services/userService.js";
|
import {
|
||||||
|
checkNickname,
|
||||||
|
createMe,
|
||||||
|
deleteMe,
|
||||||
|
getMe,
|
||||||
|
} from "../../src/services/userService.js";
|
||||||
import { HttpError } from "../../src/middleware/errors.js";
|
import { HttpError } from "../../src/middleware/errors.js";
|
||||||
|
import {
|
||||||
|
RESERVATION_TTL_MS,
|
||||||
|
reserveNickname,
|
||||||
|
} from "../../src/repositories/nicknameRepository.js";
|
||||||
|
|
||||||
const uid = "user-1";
|
const uid = "user-1";
|
||||||
|
|
||||||
@ -34,28 +43,50 @@ const validBody = {
|
|||||||
describe("userService", () => {
|
describe("userService", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await firestore.recursiveDelete(firestore.collection("users"));
|
await firestore.recursiveDelete(firestore.collection("users"));
|
||||||
|
await rtdb.ref("/nicknames").remove();
|
||||||
|
await rtdb.ref("/userNicknames").remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function createMeWithReservation(
|
||||||
|
token = fakeToken(),
|
||||||
|
body: Record<string, unknown> = validBody
|
||||||
|
) {
|
||||||
|
await reserveNickname(token.uid, body.displayName as string);
|
||||||
|
return createMe(token, body);
|
||||||
|
}
|
||||||
|
|
||||||
describe("getMe", () => {
|
describe("getMe", () => {
|
||||||
it("미존재 유저는 404 + USER_NOT_FOUND", async () => {
|
it("미존재 유저는 404 + USER_NOT_FOUND", async () => {
|
||||||
await expect(getMe(uid)).rejects.toMatchObject({
|
await expect(getMe(fakeToken())).rejects.toMatchObject({
|
||||||
status: 404,
|
status: 404,
|
||||||
code: "USER_NOT_FOUND",
|
code: "USER_NOT_FOUND",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("존재하는 유저를 반환한다", async () => {
|
it("존재하는 유저를 반환한다", async () => {
|
||||||
await createMe(fakeToken(), validBody);
|
await createMeWithReservation();
|
||||||
const u = await getMe(uid);
|
const u = await getMe(fakeToken());
|
||||||
expect(u.displayName).toBe("유저1");
|
expect(u.displayName).toBe("유저1");
|
||||||
expect(u.favoriteTeamCode).toBe("LG");
|
expect(u.favoriteTeamCode).toBe("LG");
|
||||||
expect(u.knowledgeLevel).toBe("casual");
|
expect(u.knowledgeLevel).toBe("casual");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("토큰의 photoUrl이 바뀌면 자동 동기화한다", async () => {
|
||||||
|
await createMeWithReservation();
|
||||||
|
const newToken = fakeToken({ picture: "https://cdn.example.com/new.png" });
|
||||||
|
|
||||||
|
const u = await getMe(newToken);
|
||||||
|
expect(u.photoUrl).toBe("https://cdn.example.com/new.png");
|
||||||
|
|
||||||
|
// Firestore 저장본도 갱신되었는지 재조회로 확인
|
||||||
|
const u2 = await getMe(fakeToken({ picture: "https://cdn.example.com/new.png" }));
|
||||||
|
expect(u2.photoUrl).toBe("https://cdn.example.com/new.png");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createMe", () => {
|
describe("createMe", () => {
|
||||||
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
|
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
|
||||||
const u = await createMe(fakeToken(), validBody);
|
const u = await createMeWithReservation();
|
||||||
expect(u.email).toBe("tester@example.com");
|
expect(u.email).toBe("tester@example.com");
|
||||||
expect(u.photoUrl).toBe("https://cdn.example.com/p.png");
|
expect(u.photoUrl).toBe("https://cdn.example.com/p.png");
|
||||||
expect(u.provider).toBe("google");
|
expect(u.provider).toBe("google");
|
||||||
@ -67,12 +98,12 @@ describe("userService", () => {
|
|||||||
const t = fakeToken({
|
const t = fakeToken({
|
||||||
firebase: { identities: {}, sign_in_provider: "apple.com" },
|
firebase: { identities: {}, sign_in_provider: "apple.com" },
|
||||||
});
|
});
|
||||||
const u = await createMe(t, validBody);
|
const u = await createMeWithReservation(t);
|
||||||
expect(u.provider).toBe("apple");
|
expect(u.provider).toBe("apple");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("중복 생성 시 409 + USER_ALREADY_EXISTS", async () => {
|
it("중복 생성 시 409 + USER_ALREADY_EXISTS", async () => {
|
||||||
await createMe(fakeToken(), validBody);
|
await createMeWithReservation();
|
||||||
await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({
|
await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({
|
||||||
status: 409,
|
status: 409,
|
||||||
code: "USER_ALREADY_EXISTS",
|
code: "USER_ALREADY_EXISTS",
|
||||||
@ -99,4 +130,130 @@ describe("userService", () => {
|
|||||||
await expect(createMe(t, validBody)).rejects.toBeInstanceOf(HttpError);
|
await expect(createMe(t, validBody)).rejects.toBeInstanceOf(HttpError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("deleteMe", () => {
|
||||||
|
it("유저 문서와 하위 컬렉션을 삭제한다", async () => {
|
||||||
|
await createMeWithReservation();
|
||||||
|
await firestore
|
||||||
|
.collection("users").doc(uid)
|
||||||
|
.collection("voteHistory").doc("2026-04-12")
|
||||||
|
.set({ data: [] });
|
||||||
|
|
||||||
|
await deleteMe(fakeToken());
|
||||||
|
|
||||||
|
await expect(getMe(fakeToken())).rejects.toMatchObject({ code: "USER_NOT_FOUND" });
|
||||||
|
const sub = await firestore
|
||||||
|
.collection("users").doc(uid)
|
||||||
|
.collection("voteHistory").get();
|
||||||
|
expect(sub.empty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("미존재 유저는 404 + USER_NOT_FOUND", async () => {
|
||||||
|
await expect(
|
||||||
|
deleteMe(fakeToken({ uid: "nonexistent", sub: "nonexistent" }))
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
code: "USER_NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkNickname", () => {
|
||||||
|
it("새 예약 성공 → previousReservation: null", async () => {
|
||||||
|
const result = await checkNickname(fakeToken(), "닉네임A");
|
||||||
|
expect(result).toEqual({ available: true, previousReservation: null });
|
||||||
|
const snap = await rtdb.ref("/nicknames/닉네임A").get();
|
||||||
|
expect(snap.val()).toMatchObject({ uid });
|
||||||
|
const userSnap = await rtdb.ref(`/userNicknames/${uid}`).get();
|
||||||
|
expect(userSnap.val()).toBe("닉네임A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("같은 uid 재요청(다른 이름) → 이전 이름 반환 & 이전 경로 제거", async () => {
|
||||||
|
await checkNickname(fakeToken(), "닉네임A");
|
||||||
|
const result = await checkNickname(fakeToken(), "닉네임B");
|
||||||
|
expect(result).toEqual({
|
||||||
|
available: true,
|
||||||
|
previousReservation: "닉네임A",
|
||||||
|
});
|
||||||
|
const oldSnap = await rtdb.ref("/nicknames/닉네임A").get();
|
||||||
|
expect(oldSnap.exists()).toBe(false);
|
||||||
|
const newSnap = await rtdb.ref("/nicknames/닉네임B").get();
|
||||||
|
expect(newSnap.val()).toMatchObject({ uid });
|
||||||
|
const userSnap = await rtdb.ref(`/userNicknames/${uid}`).get();
|
||||||
|
expect(userSnap.val()).toBe("닉네임B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("같은 uid 동일 이름 재요청 → 멱등, previousReservation: null", async () => {
|
||||||
|
await checkNickname(fakeToken(), "닉네임A");
|
||||||
|
const result = await checkNickname(fakeToken(), "닉네임A");
|
||||||
|
expect(result).toEqual({ available: true, previousReservation: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("타 uid 점유(TTL 이내) → 409 NICKNAME_TAKEN", async () => {
|
||||||
|
await checkNickname(
|
||||||
|
fakeToken({ uid: "other", sub: "other" }),
|
||||||
|
"닉네임A"
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
checkNickname(fakeToken(), "닉네임A")
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: "NICKNAME_TAKEN",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("타 uid 점유(TTL 초과) → 탈취 성공", async () => {
|
||||||
|
await rtdb.ref("/nicknames/닉네임A").set({
|
||||||
|
uid: "other",
|
||||||
|
reservedAt: Date.now() - RESERVATION_TTL_MS - 1000,
|
||||||
|
});
|
||||||
|
const result = await checkNickname(fakeToken(), "닉네임A");
|
||||||
|
expect(result.available).toBe(true);
|
||||||
|
const snap = await rtdb.ref("/nicknames/닉네임A").get();
|
||||||
|
expect(snap.val()).toMatchObject({ uid });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("이미 가입된 유저 → 409 USER_ALREADY_EXISTS", async () => {
|
||||||
|
await createMeWithReservation();
|
||||||
|
await expect(
|
||||||
|
checkNickname(fakeToken(), "닉네임X")
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: "USER_ALREADY_EXISTS",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displayName 형식 오류 → 400", async () => {
|
||||||
|
await expect(checkNickname(fakeToken(), "")).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
code: "INVALID_INPUT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createMe 예약 연동", () => {
|
||||||
|
it("예약 없이 createMe → 409 RESERVATION_MISSING", async () => {
|
||||||
|
await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: "RESERVATION_MISSING",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createMe 성공 후 예약 경로가 소비된다", async () => {
|
||||||
|
await createMeWithReservation();
|
||||||
|
const nameSnap = await rtdb
|
||||||
|
.ref(`/nicknames/${validBody.displayName}`)
|
||||||
|
.get();
|
||||||
|
expect(nameSnap.exists()).toBe(false);
|
||||||
|
const userSnap = await rtdb.ref(`/userNicknames/${uid}`).get();
|
||||||
|
expect(userSnap.exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteMe 후 예약 잔재 없음", async () => {
|
||||||
|
await createMeWithReservation();
|
||||||
|
await deleteMe(fakeToken());
|
||||||
|
const userSnap = await rtdb.ref(`/userNicknames/${uid}`).get();
|
||||||
|
expect(userSnap.exists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user