mmday-firebase/tests/services/userService.test.ts
윤정민 a59ccf3314 Implement user profile updates and synthesize KBO game IDs.
- 사용자 프로필 수정을 위한 `PATCH` 핸들러와 `updateMe` 서비스를 구현하여 닉네임, 응원팀, 지식 수준 변경 기능을 추가했습니다.
- HTML 정규식 파싱 오류로 인해 `null`이 반환되던 `gameId` 문제를 해결하기 위해 일정 메타데이터 기반의 ID 생성 로직을 도입했습니다.
- `updateUser`가 `FieldValue`를 수용하도록 개선하여 응원팀 설정 해제(필드 삭제)가 가능하도록 수정했습니다.
- 닉네임 중복 확인 및 변경 규칙 검증을 포함한 유저 서비스 테스트 케이스를 작성하고 `TODO.md` 이슈를 해결했습니다.
2026-04-18 15:53:29 +09:00

378 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import type { DecodedIdToken } from "firebase-admin/auth";
import { firestore, rtdb } from "../../src/firebase.js";
import {
checkNickname,
createMe,
deleteMe,
getMe,
updateMe,
} from "../../src/services/userService.js";
import { HttpError } from "../../src/middleware/errors.js";
import {
RESERVATION_TTL_MS,
reserveNickname,
} from "../../src/repositories/nicknameRepository.js";
const uid = "user-1";
function fakeToken(overrides: Partial<DecodedIdToken> = {}): DecodedIdToken {
return {
uid,
email: "tester@example.com",
picture: "https://cdn.example.com/p.png",
firebase: {
identities: {},
sign_in_provider: "google.com",
},
aud: "test",
auth_time: 0,
exp: 0,
iat: 0,
iss: "test",
sub: uid,
...overrides,
} as DecodedIdToken;
}
const validBody = {
displayName: "유저1",
favoriteTeamCode: "LG",
knowledgeLevel: "casual",
};
describe("userService", () => {
beforeEach(async () => {
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", () => {
it("미존재 유저는 404 + USER_NOT_FOUND", async () => {
await expect(getMe(fakeToken())).rejects.toMatchObject({
status: 404,
code: "USER_NOT_FOUND",
});
});
it("존재하는 유저를 반환한다", async () => {
await createMeWithReservation();
const u = await getMe(fakeToken());
expect(u.displayName).toBe("유저1");
expect(u.favoriteTeamCode).toBe("LG");
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", () => {
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
const u = await createMeWithReservation();
expect(u.email).toBe("tester@example.com");
expect(u.photoUrl).toBe("https://cdn.example.com/p.png");
expect(u.provider).toBe("google");
expect(u.favoriteTeamCode).toBe("LG");
expect(u.knowledgeLevel).toBe("casual");
});
it("apple.com 프로바이더 매핑", async () => {
const t = fakeToken({
firebase: { identities: {}, sign_in_provider: "apple.com" },
});
const u = await createMeWithReservation(t);
expect(u.provider).toBe("apple");
});
it("중복 생성 시 409 + USER_ALREADY_EXISTS", async () => {
await createMeWithReservation();
await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({
status: 409,
code: "USER_ALREADY_EXISTS",
});
});
it.each([
["빈 displayName", { ...validBody, displayName: "" }],
["11자 displayName", { ...validBody, displayName: "1234567890X" }],
["숫자 displayName", { ...validBody, displayName: 123 }],
["잘못된 팀코드", { ...validBody, favoriteTeamCode: "XX" }],
["잘못된 레벨", { ...validBody, knowledgeLevel: "master" }],
])("%s → 400 INVALID_INPUT", async (_label, body) => {
await expect(createMe(fakeToken(), body as never)).rejects.toMatchObject({
status: 400,
code: "INVALID_INPUT",
});
});
it("지원하지 않는 provider → 400", async () => {
const t = fakeToken({
firebase: { identities: {}, sign_in_provider: "password" },
});
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이면 타 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,
code: "INVALID_INPUT",
});
});
});
describe("updateMe", () => {
it("미존재 유저는 404 + USER_NOT_FOUND", async () => {
await expect(
updateMe(fakeToken(), { displayName: "새이름" })
).rejects.toMatchObject({
status: 404,
code: "USER_NOT_FOUND",
});
});
it("displayName만 변경 성공", async () => {
await createMeWithReservation();
const u = await updateMe(fakeToken(), { displayName: "새이름" });
expect(u.displayName).toBe("새이름");
expect(u.favoriteTeamCode).toBe("LG");
expect(u.knowledgeLevel).toBe("casual");
});
it("favoriteTeamCode만 변경 성공", async () => {
await createMeWithReservation();
const u = await updateMe(fakeToken(), { favoriteTeamCode: "KT" });
expect(u.favoriteTeamCode).toBe("KT");
expect(u.displayName).toBe("유저1");
});
it("knowledgeLevel만 변경 성공", async () => {
await createMeWithReservation();
const u = await updateMe(fakeToken(), { knowledgeLevel: "expert" });
expect(u.knowledgeLevel).toBe("expert");
});
it("favoriteTeamCode를 null로 보내면 필드가 삭제된다", async () => {
await createMeWithReservation();
const u = await updateMe(fakeToken(), { favoriteTeamCode: null });
expect(u.favoriteTeamCode).toBeUndefined();
});
it("동일 이름으로 변경 시도 → 400 NAME_CHANGE_DENIED", async () => {
await createMeWithReservation();
await expect(
updateMe(fakeToken(), { displayName: "유저1" })
).rejects.toMatchObject({
status: 400,
code: "NAME_CHANGE_DENIED",
});
});
it("타인이 사용 중인 닉네임 → 409 NICKNAME_TAKEN", async () => {
await createMeWithReservation();
await createMeWithReservation(
fakeToken({ uid: "other", sub: "other" }),
{ ...validBody, displayName: "타유저" }
);
await expect(
updateMe(fakeToken(), { displayName: "타유저" })
).rejects.toMatchObject({
status: 409,
code: "NICKNAME_TAKEN",
});
});
it("빈 body → 400 INVALID_INPUT", async () => {
await createMeWithReservation();
await expect(updateMe(fakeToken(), {})).rejects.toMatchObject({
status: 400,
code: "INVALID_INPUT",
});
});
it.each([
["빈 displayName", { displayName: "" }],
["11자 displayName", { displayName: "1234567890X" }],
["잘못된 팀코드", { favoriteTeamCode: "XX" }],
["잘못된 레벨", { knowledgeLevel: "master" }],
])("%s → 400 INVALID_INPUT", async (_label, body) => {
await createMeWithReservation();
await expect(
updateMe(fakeToken(), body as never)
).rejects.toMatchObject({
status: 400,
code: "INVALID_INPUT",
});
});
it("여러 필드를 동시에 변경할 수 있다", async () => {
await createMeWithReservation();
const u = await updateMe(fakeToken(), {
displayName: "새이름",
favoriteTeamCode: "NC",
knowledgeLevel: "beginner",
});
expect(u.displayName).toBe("새이름");
expect(u.favoriteTeamCode).toBe("NC");
expect(u.knowledgeLevel).toBe("beginner");
});
});
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);
});
});
});