- NotificationKey에 predictionRemind·jjaekTalk 추가(마케팅은 opt-in 맵에서 제외) - GET/POST/PATCH /user 응답에 notifications 맵과 marketingOptIn 포함 — 클라가 서버 설정을 읽을 수 있게 함 - updateMe에 marketingOptIn 처리 추가: 동의 시각은 서버 serverTimestamp로 기록, 철회 시 동의 이력 보존 (기존에는 미파싱으로 단독 PATCH가 400) - updateNotifications가 전체 맵 재기록 대신 patch 키만 merge 기록하도록 변경(동시 토글 유실 방지) - userService 테스트 8건 추가(허용/차단 키, boolean 검증, 동의 시각 기록·보존, 기본값) - backend-writes.md §1.1에 새 쓰기 필드·규칙 반영
529 lines
18 KiB
TypeScript
529 lines
18 KiB
TypeScript
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,
|
|
updateNotifications,
|
|
} 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> = {}): 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("응답 직렬화", () => {
|
|
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("updateNotifications", () => {
|
|
it("허용 키(attendance/predictionRemind/jjaekTalk)를 저장하고 병합해 반환한다", async () => {
|
|
await createMeWithReservation();
|
|
const first = await updateNotifications(fakeToken(), {
|
|
notifications: { attendance: true, predictionRemind: true },
|
|
});
|
|
expect(first).toEqual({ attendance: true, predictionRemind: true });
|
|
|
|
// 부분 업데이트 — 미포함 키는 유지된다.
|
|
const second = await updateNotifications(fakeToken(), {
|
|
notifications: { jjaekTalk: true, predictionRemind: false },
|
|
});
|
|
expect(second).toEqual({
|
|
attendance: true,
|
|
predictionRemind: false,
|
|
jjaekTalk: true,
|
|
});
|
|
});
|
|
|
|
it("whitelist 밖 키는 400 + INVALID_INPUT (marketing 포함)", async () => {
|
|
await createMeWithReservation();
|
|
// marketing은 marketingOptIn 필드가 단일 소스라 notifications 맵에서 제외.
|
|
for (const key of ["marketing", "unknown"]) {
|
|
await expect(
|
|
updateNotifications(fakeToken(), { notifications: { [key]: true } })
|
|
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
|
}
|
|
});
|
|
|
|
it("boolean이 아닌 값은 400", async () => {
|
|
await createMeWithReservation();
|
|
await expect(
|
|
updateNotifications(fakeToken(), {
|
|
notifications: { attendance: "yes" },
|
|
})
|
|
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
|
});
|
|
|
|
it("getMe 응답에 notifications 맵이 포함된다 (미설정이면 빈 맵)", async () => {
|
|
await createMeWithReservation();
|
|
expect((await getMe(fakeToken())).notifications).toEqual({});
|
|
|
|
await updateNotifications(fakeToken(), {
|
|
notifications: { predictionRemind: true },
|
|
});
|
|
expect((await getMe(fakeToken())).notifications).toEqual({
|
|
predictionRemind: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("마케팅 동의 (updateMe.marketingOptIn)", () => {
|
|
async function readUserDoc() {
|
|
const snap = await firestore.collection("users").doc(uid).get();
|
|
return snap.data() ?? {};
|
|
}
|
|
|
|
it("marketingOptIn 단독 PATCH가 성공하고 동의 시각을 서버가 기록한다", async () => {
|
|
await createMeWithReservation();
|
|
const u = await updateMe(fakeToken(), { marketingOptIn: true });
|
|
expect(u.marketingOptIn).toBe(true);
|
|
|
|
const doc = await readUserDoc();
|
|
expect(doc.marketingOptIn).toBe(true);
|
|
expect(doc.marketingOptInAt).toBeTruthy();
|
|
});
|
|
|
|
it("철회(false) 시 동의 이력(marketingOptInAt)은 보존된다", async () => {
|
|
await createMeWithReservation();
|
|
await updateMe(fakeToken(), { marketingOptIn: true });
|
|
const optInAt = (await readUserDoc()).marketingOptInAt;
|
|
|
|
const u = await updateMe(fakeToken(), { marketingOptIn: false });
|
|
expect(u.marketingOptIn).toBe(false);
|
|
|
|
const doc = await readUserDoc();
|
|
expect(doc.marketingOptIn).toBe(false);
|
|
expect(doc.marketingOptInAt).toEqual(optInAt);
|
|
});
|
|
|
|
it("boolean이 아니면 400", async () => {
|
|
await createMeWithReservation();
|
|
await expect(
|
|
updateMe(fakeToken(), { marketingOptIn: "yes" })
|
|
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
|
});
|
|
|
|
it("미설정 유저의 getMe는 marketingOptIn=false", async () => {
|
|
await createMeWithReservation();
|
|
expect((await getMe(fakeToken())).marketingOptIn).toBe(false);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|