Files
fanuzu/app/firebase/functions/test/fpi_snapshot.test.ts

100 lines
5.5 KiB
TypeScript

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 아래로 내려가지 않고 랭킹 모집단에 들지 않는다', 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();
}
});