import { afterAll, beforeAll, expect, it, vi } from 'vitest'; import { getFirestore, Timestamp, Transaction } from 'firebase-admin/firestore'; import { hibernationPatch, hibernationSettings } from '../src/fandom/hibernation.js'; import { fnFpiAggregate } from '../src/fandom/fandom.js'; import { bootAdmin, shutdownAdmin } from './support.js'; const config = { enabled: true, wakeFpi: 20, wakeSeconds: 60 }; const settings = hibernationSettings(config)!; const day = 86_400_000; const at = (ms: number) => Timestamp.fromMillis(ms); let originalConfig: FirebaseFirestore.DocumentData | undefined; beforeAll(async () => { bootAdmin(); originalConfig = (await getFirestore().doc('config/hibernation').get()).data(); }); afterAll(async () => { const db = getFirestore(); const ref = db.doc('config/hibernation'); if (originalConfig) await ref.set(originalConfig); else await ref.delete(); await db.recursiveDelete(db.doc('fandoms/test-hibernation')); await shutdownAdmin(); }); it('진입 기본값만 원문에서 가져오고 복귀 설정 누락·잘못된 값은 거부한다', () => { expect(settings.enterFpi).toBe(10); expect(settings.enterSeconds).toBe(30 * 86400); expect(hibernationSettings({})).toBeNull(); for (const invalid of [ { enabled: true }, { ...config, wakeFpi: 10 }, { ...config, wakeSeconds: -1 }, { ...config, enterFpi: '10' }, ]) expect(() => hibernationSettings(invalid)).toThrow(); expect(hibernationPatch({}, Number.NaN, settings, at(0))).toEqual({}); }); it('30일 연속 미만·경계값·복귀 유지시간·재시도·정책 변경을 구분한다', () => { let state = hibernationPatch({}, 9, settings, at(0)); state = { ...state, ...hibernationPatch(state, 9, settings, at(30 * day - 1)) }; expect(state.isHibernating).toBe(false); const interrupted = hibernationPatch(state, 10, settings, at(30 * day)); expect(interrupted.hibernationLowSince).toBeNull(); expect(interrupted.isHibernating).toBe(false); const changed = hibernationPatch(state, 9, { ...settings, enterFpi: 11 }, at(30 * day)); expect(changed.isHibernating).toBe(false); state = { ...state, ...hibernationPatch(state, 9, settings, at(30 * day)) }; expect(state.isHibernating).toBe(true); expect(hibernationPatch(state, 99, settings, at(0))).toEqual({}); const noActivity = hibernationPatch(state, 99, settings, at(31 * day)); expect(noActivity.isHibernating).toBe(true); expect(noActivity.hibernationWakeSince).toBeNull(); const activity = { id: 'new-activity', at: at(31 * day) }; state = { ...state, ...hibernationPatch(state, 20, settings, at(31 * day), activity) }; expect(state.isHibernating).toBe(true); state = { ...state, ...hibernationPatch(state, 19, settings, at(31 * day + 30_000), activity) }; expect(state.hibernationWakeSince).toBeNull(); state = { ...state, ...hibernationPatch(state, 20, settings, at(32 * day), activity) }; expect(state.isHibernating).toBe(true); state = { ...state, ...hibernationPatch(state, 20, settings, at(32 * day + 60_000), activity) }; expect(state.isHibernating).toBe(false); }); it('실제 FPI 집계가 휴면을 함께 확정하며 sFPI나 별도 점수를 차감하지 않는다', async () => { const db = getFirestore(); const ref = db.doc('fandoms/test-hibernation'); await db.doc('config/hibernation').set(config); await ref.set({ stage: 1, sfpi: 100, fpiScore: 99, ...hibernationPatch({}, 0, settings, at(Date.now() - 31 * day)), }); const create = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => { throw new Error('history write failed'); }); try { await expect(fnFpiAggregate.run({ scheduleTime: new Date().toISOString() })) .rejects.toThrow('history write failed'); } finally { create.mockRestore(); } expect((await ref.get()).get('isHibernating')).toBe(false); expect((await ref.get()).get('fpiScore')).toBe(99); expect((await ref.collection('stateHistory').get()).size).toBe(0); await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }); const result = await ref.get(); expect(result.get('isHibernating')).toBe(true); expect(result.get('fpiScore')).toBe(0); expect(result.get('sfpi')).toBe(100); expect((await ref.collection('stateHistory').get()).size).toBe(1); await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }); expect((await ref.get()).get('hibernationChangedAt').toMillis()) .toBe(result.get('hibernationChangedAt').toMillis()); await db.doc('config/hibernation').set({ ...config, wakeSeconds: 0 }); const event = ref.collection('fpiEvents').doc('wake-activity'); await event.set({ axis: 'contribution', value: 100, verified: true, occurredAt: Timestamp.now(), reasonCode: 'ECLIPSE_RESTORE' }); await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }); expect((await ref.get()).get('isHibernating')).toBe(true); await event.update({ reasonCode: 'QUEST_ACTIVITY' }); await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }); const awake = await ref.get(); expect(awake.get('isHibernating')).toBe(false); const history = await ref.collection('stateHistory').orderBy('occurredAt').get(); expect(history.size).toBe(2); expect(history.docs.map((entry) => entry.get('kind'))).toEqual(['hibernation', 'wake_up']); expect(history.docs[0]!.get('fpiScore')).toBe(0); expect(history.docs[1]!.get('sourceEventId')).toBe('wake-activity'); expect(history.docs[1]!.get('settings').wakeSeconds).toBe(0); expect(awake.get('hibernationWakeEventId')).toBeUndefined(); await Promise.all([ fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }), fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }), ]); expect((await ref.collection('stateHistory').get()).size).toBe(2); expect(awake.get('sfpi')).toBe(100); });