Implement soft-delete for user accounts and add account purge job

- 회원 탈퇴 시 즉시 삭제 대신 `active: false` 및 `deactivatedAt`을 기록하여 30일간 데이터를 유예하는 소프트 삭제 방식을 도입했습니다.
- 경기 취소 시 투표를 삭제하는 대신 `cancelled: true`로 마킹하여 참여 흔적을 보존하고, 통계 집계에서만 제외하도록 로직을 변경했습니다.
- 유예 기간이 지난 비활성 계정을 영구적으로 파기하는 `accountPurge` 스케줄러를 추가했습니다.
This commit is contained in:
윤정민 2026-07-02 11:29:40 +09:00
parent e980410858
commit 4d1a46e587
15 changed files with 287 additions and 43 deletions

View File

@ -55,6 +55,48 @@
{ "fieldPath": "tierPoints", "order": "DESCENDING" }
]
},
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "active", "order": "ASCENDING" },
{ "fieldPath": "tierPoints", "order": "DESCENDING" }
]
},
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "active", "order": "ASCENDING" },
{ "fieldPath": "tierPoints", "order": "ASCENDING" }
]
},
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "favoriteTeamCode", "order": "ASCENDING" },
{ "fieldPath": "active", "order": "ASCENDING" },
{ "fieldPath": "tierPoints", "order": "DESCENDING" }
]
},
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "favoriteTeamCode", "order": "ASCENDING" },
{ "fieldPath": "active", "order": "ASCENDING" },
{ "fieldPath": "tierPoints", "order": "ASCENDING" }
]
},
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "active", "order": "ASCENDING" },
{ "fieldPath": "deactivatedAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "users",
"queryScope": "COLLECTION",

View File

@ -73,9 +73,10 @@
3. `deleteGameVotes(gameId)``/votes/{gameId}` 제거 (집계 데이터는 이후 불필요)
4. 전 유저 `invalidateStats`
### 4-b. cancelled 전이 →
### 4-b. cancelled 전이 → 무효 처
`before.status !== "cancelled" && after.status === "cancelled"`일 때:
- 전 유저 `/userVotes/{uid}/{date}/{gameId}` 제거
- 전 유저 `/userVotes/{uid}/{date}/{gameId}``{ team, cancelled: true }`로 무효 마킹
(참여 흔적 보존 — 판정·승률 집계에서는 제외)
- `/votes/{gameId}` 제거
- 투표했던 유저들 `invalidateStats`
@ -87,18 +88,28 @@
어제 날짜(`daysAgoKst(1)`)의 `/userVotes` 스캔:
1. 각 유저의 모든 경기 투표가 `result` 보유인지 확인 (`allJudged`)
1. 각 유저의 모든 경기 투표가 `result` 또는 `cancelled` 보유인지 확인 (`allJudged`)
2. **리컨실리에이션**: 미판정 경기가 있으면 `getGame`으로 Firestore 조회 후 분기
- `completed` + `winningTeamCode``processGameEndWithGame` 즉석 호출(트리거 누락 자가치유)
- `cancelled` → 해당 vote 항목 삭제
- `cancelled` → 해당 vote 항목 무효(`cancelled: true`) 마킹
- `scheduled`/`live` → warn 로그 + 유저 스킵 (실데이터 이슈)
3. 모두 정리된 유저만 `voteHistory``setDay(uid, date, { data })` 저장
3. 모두 정리된 유저만 `voteHistory``setDay(uid, date, { data })` 저장 — 무효표는
`result` 없이 `cancelled: true`로 포함되고 판정·승률 집계에서 제외
4. RTDB `/userVotes/{uid}/{date}` 제거 + `invalidateStats`
## 6. 통계 응답
`stats` 핸들러 → `statsService`가 캐시 미스면 **`voteHistory` + 오늘 `/userVotes`**를 합산해 계산, 캐시 저장.
## 스코어보드 불변식
리스트(top10·totalCount)는 사전계산 스냅샷(RTDB `/scoreboardCache/{날짜}`), 내 순위(me)는
요청 시점 라이브 카운트다. 둘이 항상 일치하는 근거는 **"tierPoints는 새벽 판정에서만
변하고, 변경 직후 `precomputeScoreboardCache`가 반드시 실행된다"**는 불변식뿐이다.
백필·어드민 보정 등 파이프라인 밖에서 tierPoints를 변경했다면 반드시
`GET /debug/dailyArchive`(또는 `precomputeScoreboardCache` 직접 호출)로 재계산할 것.
탈퇴(비활성화)도 이 불변식에 포함되어 `deleteMe`가 재계산을 호출한다.
## 타이밍 요약
| 시각(KST) | 작업 |

View File

@ -9,6 +9,16 @@ import {
listGamesByDate,
} from "../services/predictionService";
import { getScoreboard } from "../services/scoreboardService";
import { kboTodayKst } from "../types/dateString";
/**
* `date` .
* ··`today` KBO (03:00 KST ) .
*/
function resolveDateParam(raw: unknown): string {
const s = String(raw ?? "");
return s === "" || s === "today" ? kboTodayKst() : s;
}
export const prediction = onRequest(async (req, res) => {
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
@ -16,7 +26,7 @@ export const prediction = onRequest(async (req, res) => {
try {
if (tail === "games" && req.method === "GET") {
const date = String(req.query.date ?? "");
const date = resolveDateParam(req.query.date);
const games = await listGamesByDate(date);
res.status(200).json({ date, games });
return;
@ -57,7 +67,7 @@ export const prediction = onRequest(async (req, res) => {
}
if (req.method === "GET") {
const uid = await requireAuth(req);
const date = String(req.query.date ?? "");
const date = resolveDateParam(req.query.date);
const result = await getMyVotes(uid, date);
res.status(200).json(result);
return;

View File

@ -13,4 +13,5 @@ export { attendance } from "./handlers/attendanceHandlers";
export { chat } from "./handlers/chatHandlers";
export { kboDailyRefresh } from "./scheduled/kboRefresh";
export { dailyArchive } from "./scheduled/dailyArchive";
export { accountPurge } from "./scheduled/accountPurge";
export { onGameCompleted } from "./triggers/onGameCompleted";

View File

@ -1,4 +1,4 @@
import { FieldValue } from "firebase-admin/firestore";
import { FieldValue, Timestamp } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import type {
DailyJudgment,
@ -85,7 +85,8 @@ export interface ScoreboardUserEntry {
/**
* `tierPoints` N명을 . `teamCode`
* . `tierPoints` .
* . `tierPoints` 0 ()
* `countRankedUsers`() "랭킹 대상" .
* `rankSnapshot` delta .
*/
export async function listTopByTierPoints(
@ -94,7 +95,11 @@ export async function listTopByTierPoints(
): Promise<ScoreboardUserEntry[]> {
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
// 비활성화(탈퇴) 계정은 랭킹에서 제외.
query = query.where("active", "==", true);
const snap = await query
// 0pt는 랭킹 미노출 — 리스트 인원과 totalCount 분모가 정의상 일치한다.
.where("tierPoints", ">", 0)
.orderBy("tierPoints", "desc")
.limit(limit)
.select(
@ -129,7 +134,11 @@ export async function countUsersAboveTierPoints(
): Promise<number> {
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
const agg = await query.where("tierPoints", ">", threshold).count().get();
const agg = await query
.where("active", "==", true)
.where("tierPoints", ">", threshold)
.count()
.get();
return agg.data().count;
}
@ -140,10 +149,31 @@ export async function countUsersAboveTierPoints(
export async function countRankedUsers(teamCode?: TeamCode): Promise<number> {
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
const agg = await query.where("tierPoints", ">", 0).count().get();
const agg = await query
.where("active", "==", true)
.where("tierPoints", ">", 0)
.count()
.get();
return agg.data().count;
}
/**
* `cutoff` uid . `accountPurge` .
*/
export async function listDeactivatedBefore(
cutoff: Date,
limit = 100
): Promise<string[]> {
const snap = await firestore
.collection(COLLECTION)
.where("active", "==", false)
.where("deactivatedAt", "<=", Timestamp.fromDate(cutoff))
.limit(limit)
.select()
.get();
return snap.docs.map((d) => d.id);
}
/**
* displayName으로 uid를 . `null`.
* fallback.
@ -169,6 +199,7 @@ export async function createUser(uid: string, input: RegisterInput): Promise<voi
email: input.email,
provider: input.provider,
knowledgeLevel: input.knowledgeLevel,
active: true,
createdAt: FieldValue.serverTimestamp(),
};
if (input.photoUrl) doc.photoUrl = input.photoUrl;

View File

@ -122,6 +122,14 @@ export async function deleteGameVotes(gameId: string): Promise<void> {
await rtdb.ref(`/votes/${gameId}`).remove();
}
/**
* (`/userVotes/{uid}`) .
* .
*/
export async function deleteUserVoteIndex(uid: string): Promise<void> {
await rtdb.ref(`/userVotes/${uid}`).remove();
}
/**
* .
* / .

View File

@ -0,0 +1,15 @@
import { onSchedule } from "firebase-functions/scheduler";
import { logger } from "firebase-functions";
import { purgeExpiredAccounts } from "../services/userService";
/**
* 04:30 KST () (30) .
* kboDailyRefresh(02:00)·dailyArchive(03:00) .
*/
export const accountPurge = onSchedule(
{ schedule: "30 4 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" },
async () => {
const purged = await purgeExpiredAccounts();
logger.info(`accountPurge done: ${purged.length} accounts purged`);
}
);

View File

@ -10,7 +10,6 @@ import {
} from "../services/rankSnapshotService";
import { todayKst } from "../types/dateString";
import { getGame, createGameDayCache } from "../repositories/gameRepository";
import { deleteUserVoteGame } from "../repositories/voteRepository";
import { processGameEndWithGame } from "../services/gameResultService";
import type { RankSnapshot, VoteHistoryDoc } from "../types/panit";
import {
@ -22,6 +21,8 @@ import {
interface RawVote {
team: string;
result?: boolean;
/** 경기 취소로 무효 처리된 투표 — 판정에서 제외하되 기록에는 남긴다. */
cancelled?: boolean;
}
type DayVotes = Record<string, RawVote>;
@ -29,10 +30,10 @@ type DayVotes = Record<string, RawVote>;
/**
* games .
* - completed + winningTeamCode: 즉석 processGameEndWithGame result .
* - cancelled: 유저 .
* - cancelled: 무효(cancelled) .
* - : warn ( ).
*
* 반환: 갱신된 dayVotes (result cancelled ).
* 반환: 갱신된 dayVotes (result cancelled ).
*/
async function reconcileDayVotes(
uid: string,
@ -41,7 +42,7 @@ async function reconcileDayVotes(
): Promise<DayVotes> {
const result: DayVotes = { ...dayVotes };
for (const [gameId, vote] of Object.entries(dayVotes)) {
if (vote.result !== undefined) continue;
if (vote.result !== undefined || vote.cancelled) continue;
const game = await getGame(gameId);
if (!game) {
@ -65,11 +66,11 @@ async function reconcileDayVotes(
} catch (err) {
logger.error(`reconcile: processGameEnd failed ${gameId}`, err);
}
// 취소 경기 뒤늦게 감지: 아카이브 대상에서 제외해 allJudged 통과를 허용.
// 취소 경기 뒤늦게 감지: 무효 마킹으로 allJudged 통과를 허용하되
// "픽했지만 취소됨"이라는 참여 흔적은 보존한다.
} else if (game.status === "cancelled") {
await deleteUserVoteGame(uid, date, gameId);
delete result[gameId];
logger.info(`reconcile: dropped cancelled ${gameId} (uid=${uid})`);
result[gameId] = { team: vote.team, cancelled: true };
logger.info(`reconcile: voided cancelled ${gameId} (uid=${uid})`);
} else {
logger.warn(
`reconcile: ${gameId} still ${game.status} (uid=${uid}, date=${date})`
@ -109,7 +110,7 @@ export async function runDailyArchive(
if (!dayVotes) continue;
const hasUnjudged = Object.values(dayVotes).some(
(v) => v.result === undefined
(v) => v.result === undefined && !v.cancelled
);
if (hasUnjudged) {
dayVotes = await reconcileDayVotes(uid, date, dayVotes);
@ -118,6 +119,11 @@ export async function runDailyArchive(
const data: VoteHistoryDoc["data"] = [];
let allJudged = true;
for (const [gameId, vote] of Object.entries(dayVotes)) {
// 취소 무효표: 판정 계산에는 안 들어가지만 참여 흔적으로 기록에 남긴다.
if (vote.cancelled) {
data.push({ gameId, team: vote.team, cancelled: true });
continue;
}
if (vote.result === undefined) {
allJudged = false;
break;
@ -129,7 +135,7 @@ export async function runDailyArchive(
continue;
}
// 리컨실 결과 모든 경기가 cancelled로 제거된 경우에도 skip 판정은 남겨
// 리컨실 결과 모든 경기가 취소 무효 처리된 경우에도 skip 판정은 남겨
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
// 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다.
// 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존).

View File

@ -253,7 +253,9 @@ export function formatPredictionBreakdown(
` ${g.awayScore}:${g.homeScore}` :
"";
const pick = teamLabel(e.team as TeamCode);
return `- ${matchup}${score}${pick} 픽: ${e.result ? "적중" : "오답"}`;
// 취소 경기 무효표는 result가 없다 — 오답으로 말하지 않는다.
const outcome = e.cancelled ? "경기 취소(무효)" : e.result ? "적중" : "오답";
return `- ${matchup}${score}${pick} 픽: ${outcome}`;
});
const summary =
doc.correctCount != null && doc.completedCount != null ?

View File

@ -85,6 +85,8 @@ function aggregate(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): {
let correct = 0;
for (const {doc} of entries) {
for (const v of doc.data) {
// 취소 경기 무효표(result 없음)는 승률·예측 수 집계에서 제외.
if (typeof v.result !== "boolean") continue;
total += 1;
if (v.result) correct += 1;
}

View File

@ -1,5 +1,6 @@
import type { DecodedIdToken } from "firebase-admin/auth";
import { FieldValue, Timestamp } from "firebase-admin/firestore";
import { logger } from "firebase-functions";
import { HttpError } from "../middleware/errors";
import { auth } from "../firebase";
import {
@ -7,6 +8,7 @@ import {
deleteUser,
findUidByDisplayName,
getUser,
listDeactivatedBefore,
updateUser,
} from "../repositories/userRepository";
import {
@ -15,6 +17,10 @@ import {
reserveNickname,
verifyReservation,
} from "../repositories/nicknameRepository";
import { deleteUserVoteIndex } from "../repositories/voteRepository";
import { invalidateStats } from "./statsService";
import { precomputeScoreboardCache } from "./rankSnapshotService";
import { todayKst } from "../types/dateString";
import {
KnowledgeLevel,
NotificationKey,
@ -109,7 +115,8 @@ function samePhotoUrl(a: string | undefined, b: string | undefined): boolean {
*/
export async function getMe(token: DecodedIdToken): Promise<UserProfile> {
const user = await getUser(token.uid);
if (!user) {
// 비활성화(탈퇴) 계정은 미존재로 취급 — 잔여 토큰으로 접근해도 온보딩으로 유도.
if (!user || user.active === false) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
}
@ -188,23 +195,68 @@ export async function createMe(
return profile;
}
/** 비활성화 후 영구 파기까지의 유예 기간(일). 개인정보처리방침의 보존 기간과 일치해야 한다. */
export const PURGE_GRACE_DAYS = 30;
/**
* Firestore (+ ) Firebase Auth .
* 404 + `USER_NOT_FOUND`.
* ( ). 404.
*
* - user `active: false` + `deactivatedAt` .
* displayName으로 , `accountPurge` .
* - Firebase Auth ** uid**
* () ( 정책: 기존 ).
* - `active` , .
*/
export async function deleteMe(token: DecodedIdToken): Promise<void> {
const existing = await getUser(token.uid);
if (!existing) {
if (!existing || existing.active === false) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
}
await deleteUser(token.uid);
await updateUser(token.uid, {
active: false,
deactivatedAt: FieldValue.serverTimestamp(),
});
await releaseReservation(token.uid);
// 아카이브가 비활성 계정을 판정하지 않도록 진행 중 투표 인덱스와 통계 캐시를
// 정리한다. (경기별 투표 카운트는 익명 집계라 그대로 둔다.)
await deleteUserVoteIndex(token.uid);
await invalidateStats(token.uid).catch(() => undefined);
try {
await auth.deleteUser(token.uid);
} catch (err) {
const code = (err as { code?: string }).code;
if (code !== "auth/user-not-found") throw err;
}
// 사전계산된 랭킹 리스트에서 즉시 제외. 실패해도 다음 새벽 재계산이 정리한다.
try {
await precomputeScoreboardCache(todayKst());
} catch (err) {
logger.error(
`deactivate: precomputeScoreboardCache failed uid=${token.uid}`,
err
);
}
}
/**
* (+ ).
* . `now` .
*
* @returns uid
*/
export async function purgeExpiredAccounts(
now: Date = new Date()
): Promise<string[]> {
const cutoff = new Date(
now.getTime() - PURGE_GRACE_DAYS * 24 * 60 * 60 * 1000
);
const uids = await listDeactivatedBefore(cutoff);
for (const uid of uids) {
await deleteUser(uid);
await releaseReservation(uid);
await invalidateStats(uid).catch(() => undefined);
}
return uids;
}
// ── 이름 변경 조건 ──────────────────────────────────────────────
@ -247,7 +299,7 @@ export async function updateMe(
body: UpdateMeBody
): Promise<UserProfile> {
const user = await getUser(token.uid);
if (!user) {
if (!user || user.active === false) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
}

View File

@ -8,14 +8,15 @@ import {
} from "../repositories/voteRepository";
import {invalidateStats} from "../services/statsService";
import {fromTimestamp} from "../types/dateString";
import type {Game} from "../types/panit";
import type {Game, VoteEntry} from "../types/panit";
/**
* `games/{gameId}` Firestore .
*
* :
* - status `completed` (+ winningTeamCode): `processGameEndWithGame` result .
* - status `cancelled`: / .
* - status `cancelled`: (cancelled)
* · "픽했지만 취소됨" .
*
* ( ) .
*/
@ -49,9 +50,14 @@ export const onGameCompleted = onDocumentUpdated(
const uids = Object.keys(votes);
if (uids.length > 0) {
const updates: Record<string, null> = {};
// 삭제하지 않고 무효 마킹 — 기록 화면에 "픽했지만 취소"로 남고,
// 판정·승률 집계에서는 제외된다.
const updates: Record<string, VoteEntry> = {};
for (const uid of uids) {
updates[`/userVotes/${uid}/${date}/${gameId}`] = null;
updates[`/userVotes/${uid}/${date}/${gameId}`] = {
team: votes[uid].team,
cancelled: true,
};
}
await rtdb.ref().update(updates);
}
@ -59,7 +65,7 @@ export const onGameCompleted = onDocumentUpdated(
await Promise.all(
uids.map((uid) => invalidateStats(uid).catch(() => undefined))
);
logger.info(`onGameCancelled: ${gameId} cleared ${uids.length} votes`);
logger.info(`onGameCancelled: ${gameId} voided ${uids.length} votes`);
} catch (err) {
logger.error(`onGameCancelled failed for ${gameId}`, err);
}

View File

@ -106,6 +106,16 @@ export interface User {
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
fcmToken?: string;
/**
* . false() ·
* , (`active == true` ) .
* true로 ,
* .
*/
active?: boolean;
/** 비활성화 시각. 유예 기간(30일) 경과 시 `accountPurge` 배치가 영구 파기한다. */
deactivatedAt?: Timestamp;
/**
* (`applyDailyJudgmentTx`) .
*
@ -120,6 +130,14 @@ export interface User {
currentStreak?: number;
highestStreak?: number;
/**
* (· ). `applyDailyJudgmentTx` .
*
* 불변식: ** **(· )
* `precomputeScoreboardCache` .
* , .
*/
tierPoints?: number;
tickets?: TicketMap;
@ -179,10 +197,13 @@ export interface Game {
export interface VoteEntry {
team: string;
result?: boolean;
/** 경기 취소로 무효 처리된 투표 — 참여 흔적만 남고 판정·집계에서 제외된다. */
cancelled?: boolean;
}
export interface VoteHistoryDoc {
data: Array<{ gameId: string; team: string; result: boolean }>;
/** `result` 없이 `cancelled: true`인 항목은 취소 경기 무효표 — 판정·집계 제외. */
data: Array<{ gameId: string; team: string; result?: boolean; cancelled?: boolean }>;
judgment?: DailyJudgment;
correctCount?: number;
completedCount?: number;

View File

@ -26,6 +26,8 @@ async function seedUser(u: SeedUser): Promise<void> {
email: `${u.uid}@example.com`,
provider: "google",
knowledgeLevel: "casual",
// 신규 가입 기본값과 동일 — 랭킹 쿼리의 active 필터를 통과해야 한다.
active: true,
};
if (u.tierPoints !== undefined) doc.tierPoints = u.tierPoints;
if (u.favoriteTeamCode) doc.favoriteTeamCode = u.favoriteTeamCode;
@ -229,15 +231,15 @@ describe("rankSnapshotService.snapshotRankForUser", () => {
it("overall/team rank를 rankSnapshot에 기록한다", async () => {
await firestore.collection("users").doc("a").set({
displayName: "A", email: "a@e", provider: "google", knowledgeLevel: "casual",
tierPoints: 200, favoriteTeamCode: TeamCode.LG,
active: true, tierPoints: 200, favoriteTeamCode: TeamCode.LG,
});
await firestore.collection("users").doc("b").set({
displayName: "B", email: "b@e", provider: "google", knowledgeLevel: "casual",
tierPoints: 100, favoriteTeamCode: TeamCode.LG,
active: true, tierPoints: 100, favoriteTeamCode: TeamCode.LG,
});
await firestore.collection("users").doc("c").set({
displayName: "C", email: "c@e", provider: "google", knowledgeLevel: "casual",
tierPoints: 150, favoriteTeamCode: TeamCode.KT,
active: true, tierPoints: 150, favoriteTeamCode: TeamCode.KT,
});
await snapshotRankForUser("b", "2026-04-22" as DateString);
@ -255,7 +257,7 @@ describe("rankSnapshotService.snapshotRankForUser", () => {
it("tierPoints=0이면 스냅샷을 남기지 않는다", async () => {
await firestore.collection("users").doc("zero").set({
displayName: "Z", email: "z@e", provider: "google", knowledgeLevel: "casual",
tierPoints: 0,
active: true, tierPoints: 0,
});
await snapshotRankForUser("zero", "2026-04-22" as DateString);
@ -267,7 +269,7 @@ describe("rankSnapshotService.snapshotRankForUser", () => {
it("favoriteTeamCode가 없으면 team 필드 없이 overall만 기록", async () => {
await firestore.collection("users").doc("solo").set({
displayName: "S", email: "s@e", provider: "google", knowledgeLevel: "casual",
tierPoints: 50,
active: true, tierPoints: 50,
});
await snapshotRankForUser("solo", "2026-04-22" as DateString);

View File

@ -6,6 +6,8 @@ import {
createMe,
deleteMe,
getMe,
purgeExpiredAccounts,
PURGE_GRACE_DAYS,
updateMe,
} from "../../src/services/userService";
import { HttpError } from "../../src/middleware/errors";
@ -132,8 +134,8 @@ describe("userService", () => {
});
});
describe("deleteMe", () => {
it("유저 문서와 하위 컬렉션을 삭제한다", async () => {
describe("deleteMe (비활성화)", () => {
it("문서는 남기고 active:false + deactivatedAt만 기록한다 (기록·닉네임 점유 보존)", async () => {
await createMeWithReservation();
await firestore
.collection("users").doc(uid)
@ -142,11 +144,19 @@ describe("userService", () => {
await deleteMe(fakeToken());
await expect(getMe(fakeToken())).rejects.toMatchObject({ code: "USER_NOT_FOUND" });
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(true);
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 () => {
@ -157,6 +167,31 @@ describe("userService", () => {
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", () => {