라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응
This commit is contained in:
@@ -190,7 +190,8 @@ describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)',
|
||||
expect(summary.get('defense')).toBe(60);
|
||||
expect(summary.get('defenseCount')).toBe(20);
|
||||
expect(await logCount()).toBe(20);
|
||||
expect(await tickerCount()).toBe(20);
|
||||
// 티커는 사람당 합류 1회 — 개별 Shake 를 방송하지 않는다(FX §5.1).
|
||||
expect(await tickerCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,8 +264,8 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () =
|
||||
});
|
||||
expect(third.accepted).toBe(0);
|
||||
expect((await shardSum()).defense).toBe(60);
|
||||
// 받아들인 것이 없으면 티커에도 올리지 않는다 — 빈 참여가 흐르면 안 된다.
|
||||
expect(await tickerCount()).toBe(2);
|
||||
// 티커는 합류 1회뿐이다 — 묶음마다 · 빈 참여마다 흐르지 않는다(FX §1.1 · §5.1).
|
||||
expect(await tickerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('속도 제한보다 빠른 연속 호출을 끊는다', async () => {
|
||||
@@ -301,7 +302,7 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () =
|
||||
});
|
||||
|
||||
describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
||||
it('참여마다 종류·닉네임·참여량·우선순위·이동처를 남긴다', async () => {
|
||||
it('합류할 때 종류·닉네임·우선순위·이동처를 남기고 Shake 횟수는 싣지 않는다', async () => {
|
||||
await seed();
|
||||
await defendEclipse({
|
||||
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7,
|
||||
@@ -314,7 +315,7 @@ describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
||||
// 다른 종류(Artist Hot Time · Hero Gift · Campaign · 공지)가 뒤에 들어올 자리다.
|
||||
expect(event.get('kind')).toBe('eclipse_defense');
|
||||
expect(event.get('nickname')).toBe('아미보이');
|
||||
expect(event.get('defense')).toBe(7);
|
||||
expect(event.get('defense')).toBeUndefined();
|
||||
expect(event.get('eclipseId')).toBe(ECLIPSE);
|
||||
expect(typeof event.get('priority')).toBe('number');
|
||||
expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`);
|
||||
|
||||
@@ -160,7 +160,7 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입
|
||||
const { fnPopSpend } = await import('../src/ledger/pop.js');
|
||||
const run = (data: Record<string, unknown>) => fnPopSpend.run(
|
||||
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[0]);
|
||||
await db.doc('config/policy').set({ zapPop: 10 }, { merge: true });
|
||||
await db.doc('config/policy').set({ zapPop: 10, zapMinIntervalMs: 0 }, { merge: true });
|
||||
await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'zap:seed' });
|
||||
|
||||
await expect(run({ popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'zap:h1' }))
|
||||
@@ -192,3 +192,22 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입
|
||||
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
|
||||
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
|
||||
});
|
||||
|
||||
it('연속 ZAP Rate Limit — 한 호출 상한 · 호출 간격(운영값, FX §3)', async () => {
|
||||
const db = getFirestore();
|
||||
const { fnPopSpend } = await import('../src/ledger/pop.js');
|
||||
const run = (data: Record<string, unknown>) => fnPopSpend.run(
|
||||
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[0]);
|
||||
await db.doc('config/policy').set({ zapPop: 10, zapMaxPerCall: 3, zapMinIntervalMs: 60_000 }, { merge: true });
|
||||
await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'rl:seed' });
|
||||
await expect(run({ popType: 'activity', amount: 40, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:4' }))
|
||||
.rejects.toMatchObject({ details: { code: 'E-QST-02' } });
|
||||
await run({ popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:1' });
|
||||
// 같은 요청의 재시도는 간격에 걸리지 않는다(멱등).
|
||||
await run({ popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:1' });
|
||||
await expect(run({ popType: 'activity', amount: 10, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:2' }))
|
||||
.rejects.toMatchObject({ details: { code: 'E-QST-02' } });
|
||||
expect((await db.doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(70);
|
||||
await db.doc('config/policy').set({ zapMaxPerCall: 50, zapMinIntervalMs: 400 }, { merge: true });
|
||||
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest';
|
||||
import { getFirestore } from 'firebase-admin/firestore';
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import { snapshotWeeklyRanking, weekKeyOf } from '../src/fandom/weekly.js';
|
||||
|
||||
/** 별첨 B 「Ranking Snapshot — 주 1회 영구 Archive」. */
|
||||
const ids = ['test-week-a', 'test-week-b', 'test-week-c', 'test-week-out'];
|
||||
beforeAll(async () => {
|
||||
bootAdmin();
|
||||
const db = getFirestore();
|
||||
await db.doc('fandoms/test-week-a').set({ name: 'A', fpiScore: 300, rankingEligible: true });
|
||||
await db.doc('fandoms/test-week-b').set({ name: 'B', fpiScore: 300, rankingEligible: true });
|
||||
await db.doc('fandoms/test-week-c').set({ name: 'C', fpiScore: 100, rankingEligible: true });
|
||||
await db.doc('fandoms/test-week-out').set({ name: 'X', fpiScore: 999, rankingEligible: false });
|
||||
});
|
||||
afterAll(async () => {
|
||||
const db = getFirestore();
|
||||
for (const id of ids) await db.recursiveDelete(db.doc(`fandoms/${id}`));
|
||||
await db.recursiveDelete(db.collection('rankingSnapshots'));
|
||||
await shutdownAdmin();
|
||||
});
|
||||
|
||||
it('월요일 00:10 KST 에 돌면 방금 끝난 주를 가리킨다', () => {
|
||||
// 2026-10-05(월) 00:10 KST → 9/28~10/4 = 2026-W40.
|
||||
expect(weekKeyOf(new Date('2026-10-05T00:10:00+09:00'))).toBe('2026-W40');
|
||||
expect(weekKeyOf(new Date('2027-01-04T00:10:00+09:00'))).toBe('2026-W53');
|
||||
});
|
||||
|
||||
it('모집단 행성의 공동 순위를 한 번만 영구 보관한다', async () => {
|
||||
const db = getFirestore();
|
||||
const at = new Date('2026-10-05T00:10:00+09:00');
|
||||
expect(await snapshotWeeklyRanking(at)).toEqual({ week: '2026-W40', created: true });
|
||||
const snap = (await db.doc('rankingSnapshots/2026-W40').get()).data()!;
|
||||
const ours = (snap.planets as Array<{ fandomId: string; rank: number }>)
|
||||
.filter((p) => ids.includes(p.fandomId)).map((p) => [p.fandomId, p.rank]);
|
||||
expect(ours.map(([id]) => id)).toEqual(['test-week-a', 'test-week-b', 'test-week-c']);
|
||||
// 같은 점수는 같은 순위(1·1·3) — 다른 테스트 행성이 섞여도 상대 순서는 같다.
|
||||
expect(ours[0]![1]).toBe(ours[1]![1]);
|
||||
// 다시 돌아도 덮지 않는다.
|
||||
await db.doc('fandoms/test-week-c').update({ fpiScore: 9_999 });
|
||||
expect(await snapshotWeeklyRanking(at)).toEqual({ week: '2026-W40', created: false });
|
||||
const again = (await db.doc('rankingSnapshots/2026-W40').get()).data()!;
|
||||
expect(again.planets).toEqual(snap.planets);
|
||||
});
|
||||
Reference in New Issue
Block a user