100 lines
5.5 KiB
TypeScript
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 아래로 내려가지 않고 랭킹 모집단에 들지 않는다(리뷰 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();
|
|
}
|
|
});
|