import { beforeEach, describe, expect, it } from "vitest"; import type { DecodedIdToken } from "firebase-admin/auth"; import { firestore, rtdb } from "../../src/firebase"; import { checkNickname, createMe, deleteMe, getMe, purgeExpiredAccounts, PURGE_GRACE_DAYS, updateMe, } from "../../src/services/userService"; import { HttpError } from "../../src/middleware/errors"; import { RESERVATION_TTL_MS, reserveNickname, } from "../../src/repositories/nicknameRepository"; const uid = "user-1"; function fakeToken(overrides: Partial = {}): 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 = 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("응답 직렬화", () => { const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; it("createMe / getMe / updateMe 모두 createdAt이 UTC ISO 문자열이다", async () => { const created = await createMeWithReservation(); const fetched = await getMe(fakeToken()); const updated = await updateMe(fakeToken(), { knowledgeLevel: "expert" }); for (const profile of [created, fetched, updated]) { expect(typeof profile.createdAt).toBe("string"); expect(profile.createdAt).toMatch(UTC_ISO); } }); it("응답 JSON에 Firestore Timestamp가 남지 않는다", async () => { await createMeWithReservation(); const json = JSON.stringify(await getMe(fakeToken())); expect(json).not.toContain("_seconds"); expect(json).not.toContain("_nanoseconds"); }); }); 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("문서는 남기고 active:false + deactivatedAt만 기록한다 (기록·닉네임 점유 보존)", async () => { await createMeWithReservation(); await firestore .collection("users").doc(uid) .collection("voteHistory").doc("2026-04-12") .set({ data: [] }); await deleteMe(fakeToken()); const snap = await firestore.collection("users").doc(uid).get(); expect(snap.exists).toBe(true); expect(snap.data()?.active).toBe(false); expect(snap.data()?.deactivatedAt).toBeTruthy(); // 기록은 파기 전까지 보존된다. const sub = await firestore .collection("users").doc(uid) .collection("voteHistory").get(); expect(sub.empty).toBe(false); // 비활성 계정은 조회 API에서 미존재로 취급 → 재가입(온보딩) 유도. await expect(getMe(fakeToken())).rejects.toMatchObject({ code: "USER_NOT_FOUND" }); // 이미 비활성화된 계정의 중복 탈퇴 요청도 404. await expect(deleteMe(fakeToken())).rejects.toMatchObject({ code: "USER_NOT_FOUND" }); }); it("미존재 유저는 404 + USER_NOT_FOUND", async () => { await expect( deleteMe(fakeToken({ uid: "nonexistent", sub: "nonexistent" })) ).rejects.toMatchObject({ status: 404, code: "USER_NOT_FOUND", }); }); it("유예 기간이 지난 계정만 purge가 영구 파기한다", async () => { await createMeWithReservation(); await firestore .collection("users").doc(uid) .collection("voteHistory").doc("2026-04-12") .set({ data: [] }); await deleteMe(fakeToken()); // 유예 기간 내 → 파기 대상 아님. expect(await purgeExpiredAccounts()).toEqual([]); // 유예 기간 +1일 시점 → 파기. const later = new Date( Date.now() + (PURGE_GRACE_DAYS + 1) * 24 * 60 * 60 * 1000 ); expect(await purgeExpiredAccounts(later)).toEqual([uid]); const snap = await firestore.collection("users").doc(uid).get(); expect(snap.exists).toBe(false); const sub = await firestore .collection("users").doc(uid) .collection("voteHistory").get(); expect(sub.empty).toBe(true); }); }); 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); }); }); });