Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
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<string, number> = {
|
||||
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<void> {
|
||||
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<string, unknown> = {}): Promise<void> {
|
||||
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<void>)({});
|
||||
|
||||
/** 가중치와 실패 차감까지 반영한 현재 FPI 총점. */
|
||||
async function currentFpiTotal(): Promise<number> {
|
||||
const snap = await getFirestore().doc(`fandoms/${FANDOM}`).get();
|
||||
return snap.get('fpiScore') as number;
|
||||
}
|
||||
|
||||
/** 사유 코드별 FPI 이벤트 수. 「감소 경로가 Eclipse 뿐인가」의 확인 수단이다. */
|
||||
async function fpiEventsByReason(): Promise<Record<string, number>> {
|
||||
const snap = await getFirestore()
|
||||
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
||||
const out: Record<string, number> = {};
|
||||
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'));
|
||||
// 검수 D3 — 마감이 지나면 `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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user