import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { getFirestore, FieldValue, Timestamp, Transaction } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { grantPop } from '../src/ledger/ledger.js'; import { aggregateActiveEclipses, defendEclipse, runEclipseSchedule, fnFpiAggregate, } from '../src/fandom/fandom.js'; /** v1.5 §5: 운영 일정, DAU 오류 취소, 시작 스냅샷, 실패 확정 차감과 경합을 검증한다. */ const FANDOM = 'test-trig-fandom'; const A = 'test-trig-user-a'; /** 침식률 — 수치표 v1.4 「침식 0.25% FPI」. */ const EROSION_PERCENT = 0.25; /** * 방어 목표(팬덤 공동 Shake 누적). **POP 이 아니다** — 방어는 POP 을 소모하지 않는다 * (설계서 v1.1 §9-#5). 한 번의 참여로 채울 수 있게 작게 잡았다. */ const TARGET = 60; /** 축별 기준 FPI. 0.25% 가 정수로 떨어지도록 400의 배수로 잡았다. */ const BASE_AXES: Record = { bpm: 400_000, health: 200_000, referral: 80_000, community: 40_000, contribution: 120_000, }; const AXES = Object.keys(BASE_AXES); /** 기준 FPI 총합. 침식 전후를 이 값으로 견준다. */ const BASE_TOTAL = Object.values(BASE_AXES).reduce((a, b) => a + b, 0); /** KST 시각을 절대 시각으로. 발동 시각 판정이 KST 기준이다. */ const kst = (day: string, hhmm: string) => new Date(`${day}T${hhmm}:00+09:00`); const DAY = '2026-09-20'; const FIRST = '13:00'; const SECOND = '21:00'; const WARN_SECONDS = 300; beforeAll(() => { // 발동과 방어가 같은 시각을 보도록 Date만 고정한다. Firestore 타이머는 실제로 돈다. vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(kst(DAY, '13:10')); bootAdmin(); }); // **끝나고도 치운다.** 남겨 두면 다음 파일의 전역 질의(`fn-fpi-aggregate` · // `fn-eclipse-trigger` 는 팬덤 전수를 돈다)가 이 팬덤까지 집어 값이 흔들린다. afterAll(async () => { await wipeUser(A); await wipeFandom(); await shutdownAdmin(); vi.useRealTimers(); }); async function wipeFandom(): Promise { const db = getFirestore(); const root = db.doc(`fandoms/${FANDOM}`); const eclipses = await root.collection('eclipseRounds').get(); for (const doc of eclipses.docs) { for (const sub of ['shards', 'defenseLogs', 'participants']) { const snap = await doc.ref.collection(sub).get(); await Promise.all(snap.docs.map((d) => d.ref.delete())); } await doc.ref.delete(); } for (const sub of ['fpiEvents', 'liveEvents', 'members', 'fandomDailyStats']) { const snap = await root.collection(sub).get(); await Promise.all(snap.docs.map((d) => d.ref.delete())); } await root.delete(); await db.doc('config/eclipse').delete(); } /** * 팬덤 하나에 FPI 원장을 세운다. * * **`fpiAxes` 를 직접 쓰지 않고 이벤트로 만든다.** 집계가 이벤트에서 요약을 다시 * 만들므로, 요약만 심어 두면 첫 집계에서 전부 0 이 되어 침식할 것이 없어진다. */ async function seedFandom(overrides: Record = {}): Promise { const db = getFirestore(); await db.doc('config/eclipse').set({ enabled: true, schedule: [{ hhmm: FIRST }, { hhmm: SECOND }], erosionPercent: EROSION_PERCENT, warnSeconds: WARN_SECONDS, perCallMaxShakes: 60, windowSec: 1800, // 같은 사용자로 연속 참여해 보므로 속도 제한을 끈다. 제한 자체는 // eclipse.test.ts 의 전용 테스트가 본다. minIntervalMs: 0, ...overrides, }); await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS', stage: 1, fandomName: 'BTS', createdAt: Timestamp.fromDate(kst(DAY, '00:00')), }); // 활동 이벤트는 발동 시각보다 앞서고 7 일 창 안이다. const occurredAt = Timestamp.fromDate(kst(DAY, '09:00')); for (const axis of AXES) { await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-${axis}`).set({ axis, value: BASE_AXES[axis], verified: true, occurredAt, reasonCode: 'SEED', }); } await db.doc(`users/${A}`).set({ uid: A, nickname: '아미보이', fandomId: FANDOM }); await grantPop({ uid: A, popType: 'activity', amount: 10_000, reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:trig:${A}`, }); } /** 집계를 실제로 한 번 돌린다. 스케줄 래퍼를 직접 호출한다. */ const aggregateFpi = () => (fnFpiAggregate.run as (e: unknown) => Promise)({}); /** 가중치와 실패 차감까지 반영한 현재 FPI 총점. */ async function currentFpiTotal(): Promise { const snap = await getFirestore().doc(`fandoms/${FANDOM}`).get(); return snap.get('fpiScore') as number; } /** 사유 코드별 FPI 이벤트 수. 「감소 경로가 Eclipse 뿐인가」의 확인 수단이다. */ async function fpiEventsByReason(): Promise> { const snap = await getFirestore() .collection(`fandoms/${FANDOM}/fpiEvents`).get(); const out: Record = {}; for (const doc of snap.docs) { const reason = (doc.get('reasonCode') as string | undefined) ?? '(없음)'; out[reason] = (out[reason] ?? 0) + 1; } return out; } beforeEach(async () => { await wipeUser(A); await wipeFandom(); }); describe('발동 — 하루 정확히 2 회 (정책 원장 B-55)', () => { it('DAU 누락 회차는 취소 사유를 남기고 활성화·FPI 차감 없이 종료한다', async () => { await seedFandom(); await aggregateFpi(); const db = getFirestore(); await db.doc(`fandoms/${FANDOM}`).update({ createdAt: Timestamp.fromDate(kst('2026-09-19', '00:00')) }); await runEclipseSchedule(kst(DAY, FIRST)); const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`); expect((await ref.get()).data()).toMatchObject({ status: 'cancelled', result: 'cancelled_error', cancelReason: 'MISSING_OR_INVALID_DAU', }); expect((await ref.get()).get('activatedAt')).toBeUndefined(); expect((await fpiEventsByReason())['ECLIPSE_EROSION'] ?? 0).toBe(0); expect(await currentFpiTotal()).toBe(BASE_TOTAL); await runEclipseSchedule(kst(DAY, FIRST)); expect((await ref.get()).get('status')).toBe('cancelled'); await db.doc(`fandoms/${FANDOM}/fandomDailyStats/2026-09-19`).set({ dau: 30 }); await runEclipseSchedule(kst(DAY, FIRST)); expect((await ref.get()).get('status')).toBe('cancelled'); await runEclipseSchedule(kst(DAY, SECOND)); expect((await db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-2`).get()).data()).toMatchObject({ status: 'active', target: 2, averageDau: 30, }); }); it('v1.5 기본값으로 07:47/17:47 예고하고 07:50/17:50부터 60초 방어한다', async () => { const db = getFirestore(); await db.doc('config/eclipse').set({ enabled: true }); await db.doc(`fandoms/${FANDOM}`).set({ name: '기본 운영값 검증', fpiScore: 0, createdAt: Timestamp.fromDate(kst(DAY, '00:00')) }); for (const [round, warning, start] of [[1, '07:47', '07:50'], [2, '17:47', '17:50']] as const) { const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-${round}`); await runEclipseSchedule(new Date(kst(DAY, warning).getTime() - 1)); expect((await ref.get()).exists).toBe(false); await runEclipseSchedule(kst(DAY, warning)); const scheduled = await ref.get(); expect(scheduled.get('status')).toBe('scheduled'); expect(scheduled.get('target')).toBe(1); expect(scheduled.get('dauDays')).toEqual([]); expect(scheduled.get('warnAt').toMillis()).toBe(kst(DAY, warning).getTime()); expect(scheduled.get('startAt').toMillis()).toBe(kst(DAY, start).getTime()); expect(scheduled.get('endsAt').toMillis() - scheduled.get('startAt').toMillis()).toBe(60_000); expect(scheduled.get('windowSec')).toBe(60); expect(scheduled.get('qualifyShakes')).toBe(60); // C-22 「푸시 발송 시각 = 시작 − 3분」 — 예고 판정 시각이 회차 문서에 남는다. expect(scheduled.get('alertPushedAt').toMillis()).toBe(kst(DAY, warning).getTime()); await runEclipseSchedule(kst(DAY, start)); expect((await ref.get()).get('status')).toBe('active'); await runEclipseSchedule(new Date(kst(DAY, start).getTime() + 60_000)); expect((await ref.get()).get('status')).toBe('ended'); } }); it('사전 알림 시각에 예약되고 발동 시각에 활성이 된다', async () => { await seedFandom(); await aggregateFpi(); const db = getFirestore(); const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`); // **반환 카운터를 단정하지 않는다.** `runEclipseSchedule` 은 모든 팬덤을 돌아 // 전역 건수를 돌려주므로, 다른 테스트가 남긴 팬덤이 있으면 값이 흔들린다. // 이 테스트가 볼 것은 「이 팬덤의 이 회차 문서가 어떤 상태인가」다. // 사전 알림 전에는 문서가 없다. await runEclipseSchedule(kst(DAY, '12:50')); expect((await ref.get()).exists).toBe(false); // 사전 알림 시각(발동 300초 전 = 12:55)에 예약된다. await runEclipseSchedule(kst(DAY, '12:56')); let snap = await ref.get(); expect(snap.get('status')).toBe('scheduled'); // 푸시 워커가 읽을 사전 알림 시각. 예약 시점보다 앞이면 예고가 불가능하다. expect((snap.get('warnAt') as Timestamp).toMillis()) .toBe(kst(DAY, '12:55').getTime()); expect((snap.get('startAt') as Timestamp).toMillis()) .toBe(kst(DAY, FIRST).getTime()); // 발동 시각에 활성이 된다. await runEclipseSchedule(kst(DAY, '13:00')); snap = await ref.get(); expect(snap.get('status')).toBe('active'); }); it('하루에 1차·2차 딱 둘만 만든다', async () => { await seedFandom(); await aggregateFpi(); // 하루를 1분 간격이 아니라 구간별로 훑는다. for (const at of ['00:10', '12:56', '13:00', '13:20', '20:56', '21:00', '23:50']) { await runEclipseSchedule(kst(DAY, at)); } const eclipses = await getFirestore() .collection(`fandoms/${FANDOM}/eclipseRounds`).get(); expect(eclipses.size).toBe(2); expect(eclipses.docs.map((d) => d.id).sort()).toEqual([`${DAY}-1`, `${DAY}-2`]); expect(eclipses.docs.map((d) => d.get('round')).sort()).toEqual([1, 2]); }); it('같은 회차를 여러 번 돌려도 중복 발동 0 건이다', async () => { await seedFandom(); await aggregateFpi(); // 같은 분을 열 번 돌린다. 조회 후 쓰기로 막았다면 여기서 새 문서가 생긴다. for (let i = 0; i < 10; i++) await runEclipseSchedule(kst(DAY, '13:05')); // 동시에도 돌려 본다 — 스케줄 인스턴스가 겹치는 상황이다. await Promise.all( Array.from({ length: 5 }, () => runEclipseSchedule(kst(DAY, '13:06'))), ); const eclipses = await getFirestore() .collection(`fandoms/${FANDOM}/eclipseRounds`).get(); expect(eclipses.size).toBe(1); expect(eclipses.docs[0]!.get('status')).toBe('active'); // 시작 시점에는 차감 이벤트가 없어야 한다. const events = await getFirestore() .collection(`fandoms/${FANDOM}/fpiEvents`).get(); const erosions = events.docs .filter((d) => d.get('reasonCode') === 'ECLIPSE_EROSION'); expect(erosions).toHaveLength(0); }); it('활성화가 꺼진 팬덤은 발동하지 않는다', async () => { await seedFandom({ enabled: false }); await aggregateFpi(); await runEclipseSchedule(kst(DAY, '13:05')); const eclipses = await getFirestore() .collection(`fandoms/${FANDOM}/eclipseRounds`).get(); expect(eclipses.size).toBe(0); }); it('발동 시각·침식률을 운영값으로 바꾸면 재배포 없이 따라간다', async () => { // 정책 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」. await seedFandom({ schedule: [{ hhmm: '06:30' }], erosionPercent: 1, warnSeconds: 60 }); await aggregateFpi(); await runEclipseSchedule(kst(DAY, '06:20')); expect((await getFirestore() .collection(`fandoms/${FANDOM}/eclipseRounds`).get()).size).toBe(0); await runEclipseSchedule(kst(DAY, '06:31')); const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get(); expect(snap.get('status')).toBe('active'); expect(snap.get('erosionPercent')).toBe(1); expect(snap.get('fpiAtStart')).toBe(BASE_TOTAL); expect(snap.get('erodedTotal')).toBeUndefined(); expect(await currentFpiTotal()).toBe(BASE_TOTAL); }); it('v0.3.0 팬덤별 firstAt 설정도 하위호환으로 읽는다', async () => { await seedFandom(); await aggregateFpi(); await getFirestore().doc(`fandoms/${FANDOM}`) .set({ eclipseSettings: { firstAt: '08:00' } }, { merge: true }); await runEclipseSchedule(kst(DAY, '08:01')); const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get(); expect((snap.get('startAt') as Timestamp).toMillis()) .toBe(kst(DAY, '08:00').getTime()); }); it('방어 창이 지나면 닫는다', async () => { await seedFandom(); await aggregateFpi(); await runEclipseSchedule(kst(DAY, '13:01')); // 창은 30분이다. await runEclipseSchedule(kst(DAY, '13:20')); expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get()) .get('status')).toBe('active'); await runEclipseSchedule(kst(DAY, '13:31')); // 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다. expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get()) .get('status')).toBe('ended'); }); }); describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (정책 원장 C-24)', () => { const ids = async () => (await getFirestore().collection(`fandoms/${FANDOM}/eclipseRounds`).get()) .docs.map((d) => [d.id, (d.get('startAt') as Timestamp).toMillis()]).sort(); it('공통 일정 3회를 설정하면 하루 3회차를 만든다', async () => { await seedFandom({ schedule: [{ hhmm: '07:50' }, { hhmm: '12:00' }, { hhmm: '17:50' }] }); await aggregateFpi(); for (const at of ['07:50', '12:00', '17:50', '23:59']) await runEclipseSchedule(kst(DAY, at)); expect(await ids()).toEqual([ [`${DAY}-1`, kst(DAY, '07:50').getTime()], [`${DAY}-2`, kst(DAY, '12:00').getTime()], [`${DAY}-3`, kst(DAY, '17:50').getTime()], ]); }); it('팬덤별 eclipseSchedule 이 있으면 공통 일정 대신 그것을 쓰고 푸시·종료를 다시 계산한다', async () => { await seedFandom({ warnSeconds: 180, windowSec: 60 }); await aggregateFpi(); await getFirestore().doc(`fandoms/${FANDOM}`) .set({ eclipseSchedule: [{ hhmm: '09:10', tz: 'Asia/Seoul' }] }, { merge: true }); for (const at of ['09:07', '09:10', '13:00', '21:00', '23:59']) await runEclipseSchedule(kst(DAY, at)); expect(await ids()).toEqual([[`${DAY}-1`, kst(DAY, '09:10').getTime()]]); const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get(); expect(snap.get('warnAt').toMillis()).toBe(kst(DAY, '09:07').getTime()); expect(snap.get('alertPushedAt').toMillis()).toBe(kst(DAY, '09:07').getTime()); expect(snap.get('endsAt').toMillis()).toBe(kst(DAY, '09:10').getTime() + 60_000); }); }); describe('v1.5 실패 확정 시에만 시작 FPI 기준으로 1회 차감', () => { const round = () => getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`); async function start() { await seedFandom(); await aggregateFpi(); await getFirestore().doc(`fandoms/${FANDOM}`).update({ sfpi: 12345 }); await runEclipseSchedule(kst(DAY, '13:00')); } const penalty = BASE_TOTAL * EROSION_PERCENT / 100; it('시작과 방어 중에는 차감·복구 이벤트 없이 시작값만 보관한다', async () => { await start(); expect((await round().get()).get('fpiAtStart')).toBe(BASE_TOTAL); await aggregateFpi(); expect(await currentFpiTotal()).toBe(BASE_TOTAL); expect(await fpiEventsByReason()).toEqual({ SEED: 5 }); }); it('성공은 마감 후에도 무차감이며 재집계가 보너스 FPI를 만들지 않는다', async () => { await start(); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: `${DAY}-1`, clientSeq: 'win', shakes: TARGET }); await runEclipseSchedule(kst(DAY, '13:30')); await aggregateActiveEclipses(); await aggregateFpi(); expect((await round().get()).data()).toMatchObject({ result: 'success', appliedAt: null }); expect(await currentFpiTotal()).toBe(BASE_TOTAL); expect(await fpiEventsByReason()).toEqual({ SEED: 5 }); }); it('새 활동을 보존하고 마감 경계부터 실패 1건만 차감하며 과거 sFPI는 그대로다', async () => { await start(); const db = getFirestore(); await db.doc(`fandoms/${FANDOM}/fpiEvents/new-activity`).set({ axis: 'bpm', value: 1000000, verified: true, reasonCode: 'SEED', occurredAt: Timestamp.fromDate(kst(DAY, '13:05')), }); await aggregateFpi(); await runEclipseSchedule(new Date(kst(DAY, '13:30').getTime() - 1)); expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000); await Promise.all([runEclipseSchedule(kst(DAY, '13:30')), runEclipseSchedule(kst(DAY, '13:30'))]); expect((await round().get()).data()).toMatchObject({ result: 'failed', penalty }); expect((await round().get()).get('appliedAt').toMillis()).toBe(kst(DAY, '13:30').getTime()); // 이미 확정된 회차를 다시 돌려도 차감은 1회다(G-02). await runEclipseSchedule(kst(DAY, '13:31')); expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty); await aggregateFpi(); await aggregateFpi(); expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty); expect((await db.doc(`fandoms/${FANDOM}`).get()).get('sfpi')).toBe(12345); expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBe(1); const event = await db.doc(`fandoms/${FANDOM}/fpiEvents/eclipse-${DAY}-1-penalty`).get(); expect(event.get('occurredAt').toMillis()).toBe(kst(DAY, '13:30').getTime()); expect(event.get('value')).toBe(-penalty); }); it('차감 기록 저장 실패는 회차 결과와 FPI를 함께 취소하며 재시도로 복구한다', async () => { await start(); const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => { throw new Error('penalty write failed'); }); try { await expect(runEclipseSchedule(kst(DAY, '13:30'))).rejects.toThrow('penalty write failed'); } finally { spy.mockRestore(); } expect((await round().get()).get('status')).toBe('active'); expect(await currentFpiTotal()).toBe(BASE_TOTAL); expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBeUndefined(); await runEclipseSchedule(kst(DAY, '13:30')); expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty); expect((await round().get()).get('result')).toBe('failed'); }); it('결과 집계가 오염되면 실패로 처리하지 않고 무차감 취소한다', async () => { await start(); await round().collection('shards').doc('invalid').set({ qualifiedParticipants: '1' }); await runEclipseSchedule(kst(DAY, '13:30')); expect((await round().get()).data()).toMatchObject({ result: 'cancelled_error', status: 'cancelled' }); expect(await currentFpiTotal()).toBe(BASE_TOTAL); expect(await fpiEventsByReason()).toEqual({ SEED: 5 }); }); it('축별 가중치와 소수 차감에도 시작 FPI의 정확한 비율을 유지한다', async () => { const db = getFirestore(); const config = db.doc('config/policy'); const original = (await config.get()).data(); try { await config.set({ fpiWeights: { bpm: 2, health: 0.5, referral: 1, community: 1, contribution: 1 } }, { merge: true }); await seedFandom(); await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-bpm`).update({ value: BASE_AXES.bpm! + 1 }); await aggregateFpi(); const startFpi = await currentFpiTotal(); expect(startFpi).toBe(1140002); await runEclipseSchedule(kst(DAY, '13:00')); await runEclipseSchedule(kst(DAY, '13:30')); const loss = startFpi * 0.0025; expect((await round().get()).get('penalty')).toBe(loss); expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8); await aggregateFpi(); expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8); } finally { if (original) await config.set(original); else await config.delete(); } }); it('마감 전 읽은 FPI 집계가 뒤늦게 저장돼도 확정 차감을 덮어쓰지 않는다', async () => { await start(); const original = Transaction.prototype.get; let closed = false; const spy = vi.spyOn(Transaction.prototype, 'get').mockImplementation(async function (this: Transaction, ...args) { if (!closed && 'path' in args[0] && args[0].path === `fandoms/${FANDOM}`) { closed = true; await runEclipseSchedule(kst(DAY, '13:30')); } return original.apply(this, args); }); try { await aggregateFpi(); } finally { spy.mockRestore(); } expect(closed).toBe(true); expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty); await aggregateFpi(); expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty); }); });