import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest'; import { FieldValue, getFirestore } from 'firebase-admin/firestore'; import { aggregateLive, closeDay, fnFpiAggregate, fnFpiDailySnapshot } from '../src/fandom/fandom.js'; import { Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin } from './support.js'; const id = 'test-atomic-sfpi'; const run = () => fnFpiDailySnapshot.run({ scheduleTime: new Date().toISOString() }); beforeAll(() => { bootAdmin(); }); beforeEach(async () => { const ref = getFirestore().doc(`fandoms/${id}`); await getFirestore().recursiveDelete(ref); await ref.set({ fpiScore: 125, sfpi: 50 }); }); afterAll(async () => { vi.restoreAllMocks(); await getFirestore().recursiveDelete(getFirestore().doc(`fandoms/${id}`)); await shutdownAdmin(); }); it('누적 쓰기 준비가 실패해도 스냅샷만 남지 않고 재시도로 하루치를 복구한다', async () => { const increment = vi.spyOn(FieldValue, 'increment').mockImplementationOnce(() => { throw new Error('injected write failure'); }); await expect(run()).rejects.toThrow('injected write failure'); increment.mockRestore(); const ref = getFirestore().doc(`fandoms/${id}`); expect((await ref.collection('fpiSnapshots').get()).size).toBe(0); expect((await ref.get()).get('sfpi')).toBe(50); await run(); await Promise.all([run(), run()]); const snapshots = await ref.collection('fpiSnapshots').get(); expect(snapshots.size).toBe(1); expect(snapshots.docs[0]!.get('fpiScore')).toBe(125); expect((await ref.get()).get('sfpi')).toBe(175); }); it('KST 자정 실행은 전날 키로 스냅샷 · DAU 를 남기고 DAU 는 인정 활동의 고유 사용자 수다', async () => { const ref = getFirestore().doc(`fandoms/${id}`); const at = (iso: string) => Timestamp.fromDate(new Date(iso)); const events = ref.collection('fpiEvents'); // 9/20 KST 하루: 사용자 a 2건 · b 1건 · 미인정 c · uid 없는 Eclipse 차감 · 경계 밖 d. await events.add({ uid: 'a', verified: true, axis: 'community', value: 1, occurredAt: at('2026-09-20T00:00:00+09:00') }); await events.add({ uid: 'a', verified: true, axis: 'health', value: 1, occurredAt: at('2026-09-20T12:00:00+09:00') }); await events.add({ uid: 'b', verified: true, axis: 'bpm', value: 1, occurredAt: at('2026-09-20T23:59:59+09:00') }); await events.add({ uid: 'c', verified: false, axis: 'bpm', value: 1, occurredAt: at('2026-09-20T10:00:00+09:00') }); await events.add({ kind: 'score_adjustment', verified: true, value: -1, occurredAt: at('2026-09-20T17:51:00+09:00') }); await events.add({ uid: 'd', verified: true, axis: 'bpm', value: 1, occurredAt: at('2026-09-21T00:00:00+09:00') }); const result = await closeDay(new Date('2026-09-21T00:00:30+09:00')); expect(result.day).toBe('2026-09-20'); expect((await ref.collection('fandomDailyStats').doc('2026-09-20').get()).get('dau')).toBe(2); expect((await ref.collection('fpiSnapshots').doc('2026-09-20').get()).exists).toBe(true); expect((await ref.collection('fpiSnapshots').doc('2026-09-21').get()).exists).toBe(false); // 활동 0명인 날도 0 으로 남는다(C-27). 재실행은 스냅샷 · sFPI 를 한 번만 반영한다. await closeDay(new Date('2026-09-22T00:00:30+09:00')); await closeDay(new Date('2026-09-22T00:00:30+09:00')); expect((await ref.collection('fandomDailyStats').doc('2026-09-21').get()).get('dau')).toBe(1); await closeDay(new Date('2026-09-23T00:00:30+09:00')); expect((await ref.collection('fandomDailyStats').doc('2026-09-22').get()).get('dau')).toBe(0); expect((await ref.get()).get('sfpi')).toBe(50 + 125 * 3); }); it('Eclipse 차감만 있는 팬덤은 FPI 0 아래로 내려가지 않고 랭킹 모집단에 들지 않는다(리뷰 M2)', async () => { const ref = getFirestore().doc(`fandoms/${id}`); await ref.set({ stage: 1 }, { merge: true }); await ref.collection('fpiEvents').add({ kind: 'score_adjustment', reasonCode: 'ECLIPSE_EROSION', value: -5, verified: true, occurredAt: Timestamp.now(), }); await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }); const doc = await ref.get(); expect(doc.get('fpiScore')).toBe(0); expect(doc.get('rankingEligible')).toBe(false); }); it('LIVE — 최근 창(분) 안에 앱을 쓴 사용자를 fandomIds 마다 세고 빠진 팬덤은 0 으로 내린다(Q-GM-03 A안)', async () => { const db = getFirestore(); const now = Date.now(); const seed: Array<[string, number, string[]]> = [ ['test-live-1', now - 60_000, ['test-live-f1', 'test-live-f2']], ['test-live-2', now - 10 * 60_000, ['test-live-f1']], // 창 밖 ['test-live-3', now - 1_000, ['test-live-f1', 'test-live-gone']], // 없는 팬덤은 건너뛴다 ]; try { await db.doc('fandoms/test-live-f1').set({ onlineCount: 0 }); await db.doc('fandoms/test-live-f2').set({ onlineCount: 0 }); await db.doc('fandoms/test-live-f3').set({ onlineCount: 7 }); // 지난 집계의 값 for (const [u, at, fandomIds] of seed) await db.doc(`users/${u}`).set({ lastSeenAt: Timestamp.fromMillis(at), fandomIds }); await aggregateLive(now); const online = async (id: string) => (await db.doc(`fandoms/${id}`).get()).get('onlineCount'); expect([await online('test-live-f1'), await online('test-live-f2'), await online('test-live-f3')]).toEqual([2, 1, 0]); expect((await db.doc('fandoms/test-live-gone').get()).exists).toBe(false); } finally { for (const [u] of seed) await db.recursiveDelete(db.doc(`users/${u}`)); for (const f of ['f1', 'f2', 'f3']) await db.doc(`fandoms/test-live-${f}`).delete(); } });