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 } from '../src/fandom/fandom.js'; /** * 이클립스 방어 = **팬덤 공동 Shake 참여** · POP 소모 없음. * * 근거 — 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 · Firestore 설계서 v1.1 * §9-#5: *방어는 Shake 참여로 이루어지며 1 회당 POP 소모를 두지 않는다*. 별첨 1 ① * 시나리오 4 의 「번개 연타·오토 도네이션」은 별첨 B 가 상위 기준이라 따르지 않는다 * (공통 기준 2항). * * 보는 것 * 1. **POP 이 움직이지 않는다** — 지갑과 원장이 그대로다. * 2. **분산 카운터 유실 0** — 샤드를 흩어도 합이 참여 총량과 같다(별첨 1 ① 예외 처리). * 3. **멱등** — 재시도가 샤드·방어 로그·티커 이벤트를 두 번 올리지 않는다(검수 D1·D6). * 4. **서버 검증** — 단말이 보낸 흔들기 수를 상한과 속도로 다시 판정한다(별첨 1 ②). * 5. **즉시 성공 판정** — 목표에 닿은 순간 `defended` 가 선다(검수 D2). */ const FANDOM = 'test-ecl-fandom'; const ECLIPSE = 'test-ecl-1'; const A = 'test-ecl-user-a'; const B = 'test-ecl-user-b'; /** 지갑 기준액. 방어가 POP 을 쓰지 않으므로 끝까지 이 값이어야 한다. */ const WALLET = 10_000; beforeAll(() => { bootAdmin(); }); // 남긴 팬덤은 다음 파일의 팬덤 전수 질의에 섞인다. 끝나고 치운다. afterAll(async () => { await wipeUser(A); await wipeUser(B); await wipeEclipse(); await shutdownAdmin(); }); async function wipeEclipse(): Promise { const db = getFirestore(); const root = db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`); for (const sub of ['shards', 'defenseLogs', 'participants']) { const snap = await root.collection(sub).get(); await Promise.all(snap.docs.map((d) => d.ref.delete())); } const live = await db.collection(`fandoms/${FANDOM}/liveEvents`).get(); await Promise.all(live.docs.map((d) => d.ref.delete())); await root.delete(); await db.doc(`fandoms/${FANDOM}`).delete(); await db.doc('config/eclipse').delete(); } /** * 활성 이클립스 하나와 사용자 둘을 세운다. * * 기본 설정은 **속도 제한을 끈다**(`minIntervalMs: 0`). 테스트는 같은 사용자로 연속 * 호출해 유실과 멱등을 보는데, 속도 제한이 켜져 있으면 그 확인 자체가 막힌다. * 속도 제한은 전용 테스트에서 따로 켜서 본다. */ async function seed(overrides: Record = {}): Promise { const db = getFirestore(); const target = (overrides.target as number | undefined) ?? 1_000_000; await db.doc('config/eclipse').set({ enabled: true, perCallMaxShakes: 50, perUserMaxDefense: 500, minIntervalMs: 0, ...overrides, }); await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS', fandomName: 'BTS' }); await db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).set({ status: 'active', target, erosionRate: 100, defense: 0, defenseCount: 0, defended: false, startAt: FieldValue.serverTimestamp(), endsAt: Timestamp.fromMillis(Date.now() + 30 * 60 * 1000), }); const people: [string, string][] = [[A, '아미보이'], [B, '버니즈']]; for (const [uid, nickname] of people) { await db.doc(`users/${uid}`).set({ uid, nickname, fandomId: FANDOM }); await grantPop({ uid, popType: 'activity', amount: WALLET, reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:ecl:${uid}`, }); } } /** 샤드 합. 「분산 카운터로 카운트 유실 없음」의 확인 수단이다. */ async function shardSum(): Promise<{ defense: number; calls: number }> { const snap = await getFirestore() .collection(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/shards`).get(); return { defense: snap.docs.reduce((a, d) => a + ((d.get('defense') as number) ?? 0), 0), calls: snap.docs.reduce((a, d) => a + ((d.get('defenseCount') as number) ?? 0), 0), }; } const countOf = async (path: string): Promise => (await getFirestore().collection(path).get()).size; const logCount = () => countOf(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/defenseLogs`); const tickerCount = () => countOf(`fandoms/${FANDOM}/liveEvents`); async function balanceOf(uid: string): Promise { const snap = await getFirestore().doc(`users/${uid}/wallet/main`).get(); return (snap.get('activityBalance') as number | undefined) ?? 0; } beforeEach(async () => { await wipeUser(A); await wipeUser(B); await wipeEclipse(); }); describe('방어는 POP 을 소모하지 않는다 (설계서 v1.1 §9-#5)', () => { it('참여해도 지갑과 원장이 그대로다', async () => { await seed(); const db = getFirestore(); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 20, }); // 지갑이 한 푼도 움직이지 않는다. expect(await balanceOf(A)).toBe(WALLET); // 방어로 원장 행이 생기지 않는다 — 지급 1 건(ADMIN_GRANT)뿐이다. const ledger = await db.collection(`users/${A}/popTransactions`).get(); expect(ledger.size).toBe(1); expect(ledger.docs[0]!.get('reasonCode')).toBe('ADMIN_GRANT'); // 누적 기여도 움직이지 않는다 — 방어는 기여 POP 이 아니다. const user = await db.doc(`users/${A}`).get(); expect(user.get('totalContributedPop') ?? 0).toBe(0); }); it('참여량은 흔들기 수로 쌓인다', async () => { await seed(); const result = await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 20, }); expect(result.accepted).toBe(20); expect(result.clamped).toBe(0); expect(result.myTotal).toBe(20); expect(await shardSum()).toEqual({ defense: 20, calls: 1 }); }); }); describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)', () => { it('샤드를 흩어 적어도 합이 참여 총량과 같다', async () => { await seed(); const shakes = [1, 2, 3, 1, 5, 1, 2, 1, 3, 1]; for (const [i, n] of shakes.entries()) { await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `seq-${i}`, shakes: n, }); } const total = shakes.reduce((a, b) => a + b, 0); expect(await shardSum()).toEqual({ defense: total, calls: shakes.length }); await aggregateActiveEclipses(); const summary = await getFirestore() .doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get(); expect(summary.get('defense')).toBe(total); expect(summary.get('defenseCount')).toBe(shakes.length); expect(summary.get('erosionRate')).toBe( 100, ); }); it('두 사용자가 동시에 참여해도 집계가 일치한다', async () => { await seed(); await Promise.all([ ...Array.from({ length: 10 }, (_, i) => defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `a-${i}`, shakes: 3, })), ...Array.from({ length: 10 }, (_, i) => defendEclipse({ uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `b-${i}`, shakes: 3, })), ]); await aggregateActiveEclipses(); const summary = await getFirestore() .doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get(); expect(summary.get('defense')).toBe(60); expect(summary.get('defenseCount')).toBe(20); expect(await logCount()).toBe(20); expect(await tickerCount()).toBe(20); }); }); describe('멱등 — 재시도가 이중 계산되지 않는다 (검수 D1·D6)', () => { it('같은 clientSeq 를 두 번 보내도 샤드·로그·티커가 한 번만 늘어난다', async () => { await seed(); const first = await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'same', shakes: 30, }); const again = await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'same', shakes: 30, }); expect(first.duplicate).toBe(false); expect(again.duplicate).toBe(true); expect(again.accepted).toBe(first.accepted); // 여기가 검수 지적의 자리다 — 예전에는 60/2/2 가 되었다. expect(await shardSum()).toEqual({ defense: 30, calls: 1 }); expect(await logCount()).toBe(1); expect(await tickerCount()).toBe(1); // 참여자 누적도 한 번만 오른다. const me = await getFirestore() .doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/participants/${A}`).get(); expect(me.get('validShakes')).toBe(30); expect(me.get('calls')).toBe(1); }); it('같은 키를 동시에 다섯 번 보내도 한 번만 쌓인다', async () => { await seed(); // 경합이 문서 ID 충돌로 판정되는지 본다. 조회 후 쓰기라면 여기서 여러 번 쌓인다. const results = await Promise.allSettled( Array.from({ length: 5 }, () => defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'race', shakes: 10, })), ); expect(results.some((r) => r.status === 'fulfilled')).toBe(true); expect(await shardSum()).toEqual({ defense: 10, calls: 1 }); expect(await logCount()).toBe(1); expect(await tickerCount()).toBe(1); }); }); describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () => { it('한 호출 상한을 넘긴 흔들기 수를 깎는다', async () => { await seed({ perCallMaxShakes: 10 }); const result = await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 9_999, }); expect(result.accepted).toBe(10); expect(result.clamped).toBe(9_989); expect((await shardSum()).defense).toBe(10); }); it('사용자당 상한에 닿으면 더 쌓이지 않는다', async () => { await seed({ perCallMaxShakes: 50, perUserMaxDefense: 60 }); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 50, }); const second = await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's2', shakes: 50, }); // 남은 10 만 받는다 — 통째로 버리면 정상 참여의 마지막 묶음이 사라진다. expect(second.accepted).toBe(10); expect(second.clamped).toBe(40); const third = await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's3', shakes: 10, }); expect(third.accepted).toBe(0); expect((await shardSum()).defense).toBe(60); // 받아들인 것이 없으면 티커에도 올리지 않는다 — 빈 참여가 흐르면 안 된다. expect(await tickerCount()).toBe(2); }); it('속도 제한보다 빠른 연속 호출을 끊는다', async () => { await seed({ minIntervalMs: 60_000 }); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 5, }); await expect(defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's2', shakes: 5, })).rejects.toMatchObject({ details: { code: 'E-QST-02' } }); expect((await shardSum()).defense).toBe(5); }); it('가입하지 않은 팬덤의 이클립스는 막을 수 없다(10/3 #10)', async () => { await seed(); await getFirestore().doc('users/test-eclipse-outsider').set({ nickname: '외부', fandomId: 'other' }); await expect(defendEclipse({ uid: 'test-eclipse-outsider', fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'o1', shakes: 5, })).rejects.toMatchObject({ details: { code: 'E-PERM-01' } }); expect((await shardSum()).defense).toBe(0); await getFirestore().doc('users/test-eclipse-outsider').delete(); }); it('다른 사용자는 서로의 속도 제한에 걸리지 않는다', async () => { await seed({ minIntervalMs: 60_000 }); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'a1', shakes: 5, }); await defendEclipse({ uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'b1', shakes: 5, }); expect((await shardSum()).defense).toBe(10); }); }); describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => { it('참여마다 종류·닉네임·참여량·우선순위·이동처를 남긴다', async () => { await seed(); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7, }); const events = await getFirestore() .collection(`fandoms/${FANDOM}/liveEvents`).get(); expect(events.size).toBe(1); const event = events.docs[0]!; // 다른 종류(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('eclipseId')).toBe(ECLIPSE); expect(typeof event.get('priority')).toBe('number'); expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`); // 티커가 최신순으로 읽으므로 시각이 없으면 정렬 자체가 불가능하다. expect(event.get('createdAt')).toBeTruthy(); // TTL 정책의 입력. 없으면 컬렉션이 무한히 쌓인다. expect((event.get('expiresAt') as Timestamp).toMillis()) .toBeGreaterThan(Date.now()); }); it('닉네임이 없는 사용자도 문구가 비지 않는다', async () => { await seed(); await getFirestore().doc(`users/${A}`).set({ nickname: '' }, { merge: true }); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't2', shakes: 1, }); const events = await getFirestore() .collection(`fandoms/${FANDOM}/liveEvents`).get(); expect(events.docs[0]!.get('nickname')).toBe('팬'); }); }); describe('성공 판정 — 목표에 닿은 즉시 (검수 D2)', () => { it('한 사람의 추가 Shake는 목표 두 명을 채울 수 없고 두 번째 60회에서만 성공한다', async () => { await seed({ target: 2, perCallMaxShakes: 60 }); const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`); const send = (uid: string, clientSeq: string, shakes: number) => defendEclipse({ uid, clientSeq, shakes, fandomId: FANDOM, eclipseId: ECLIPSE, }); await send(A, 'one', 60); await send(A, 'extra', 60); await send(B, 'under', 59); await aggregateActiveEclipses(); expect((await ref.get()).data()).toMatchObject({ qualifiedParticipants: 1, defended: false, erosionRate: 50, }); await send(B, 'two', 1); const result = await ref.get(); expect(result.get('defended')).toBe(true); expect(result.get('qualifiedParticipants')).toBe(2); const at = result.get('defendedAt'); await aggregateActiveEclipses(); expect((await ref.get()).get('defendedAt')).toEqual(at); }); it('인원 목표를 판정할 수 없으면 옛 Shake 목표로 성공시키지 않고 취소한다', async () => { await seed(); const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`); await ref.update({ target: FieldValue.delete(), targetPop: 1 }); await aggregateActiveEclipses(); expect((await ref.get()).data()).toMatchObject({ status: 'cancelled', result: 'cancelled_error', defended: false, }); }); it('집계를 기다리지 않고 방어 호출이 defended 를 세운다', async () => { await seed({ target: 1, perCallMaxShakes: 60 }); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w1', shakes: 60, }); // 집계(aggregateActiveEclipses)를 한 번도 돌리지 않았다. const snap = await getFirestore() .doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get(); expect(snap.get('defended')).toBe(true); expect(snap.get('erosionRate')).toBe(0); // 활성 질의에서 빠지면 화면이 성공한 순간을 놓친다 — status 는 그대로다. expect(snap.get('status')).toBe('active'); }); it('성공한 이클립스는 더 받지 않는다 (E-ECL-01)', async () => { await seed({ target: 1, perCallMaxShakes: 60 }); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w1', shakes: 60, }); await expect(defendEclipse({ uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w2', shakes: 5, })).rejects.toMatchObject({ details: { code: 'E-ECL-01' } }); expect((await shardSum()).defense).toBe(60); }); it('종료된 이클립스는 참여를 받지 않는다 (E-ECL-01)', async () => { await seed(); await getFirestore() .doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`) .set({ status: 'ended' }, { merge: true }); await expect(defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'c1', shakes: 1, })).rejects.toMatchObject({ details: { code: 'E-ECL-01' } }); }); it('마감이 지난 이클립스는 참여를 받지 않는다 (E-ECL-01)', async () => { await seed(); await getFirestore() .doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`) .set({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) }, { merge: true }); await expect(defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'c2', shakes: 1, })).rejects.toMatchObject({ details: { code: 'E-ECL-01' } }); }); }); describe('v1.5 유효 참여의 원자적 기록', () => { it('59회는 미인정, 60회는 한 명이며 추가 참여·동시 요청·재전송도 중복 인정하지 않는다', async () => { await seed(); const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`); const send = (uid: string, clientSeq: string, shakes: number) => defendEclipse({ uid, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq, shakes, }); const qualifiedCount = async () => (await ref.collection('shards').get()).docs .reduce((sum, doc) => sum + (doc.get('qualifiedParticipants') ?? 0), 0); await send(A, 'q50', 50); await send(A, 'q59', 9); expect(await qualifiedCount()).toBe(0); expect((await ref.collection('participants').doc(A).get()).get('qualified')).toBeUndefined(); await Promise.all([send(A, 'q60', 1), send(A, 'q60', 1)]); const qualifiedAt = (await ref.collection('participants').doc(A).get()).get('qualifiedAt'); expect(qualifiedAt).toBeInstanceOf(Timestamp); expect(await qualifiedCount()).toBe(1); // 120회를 흔들어도 1명이다(C-25). await Promise.all([send(A, 'extra1', 20), send(A, 'extra2', 20), send(A, 'extra3', 20)]); await send(B, 'b50', 50); await send(B, 'b60', 10); expect(await qualifiedCount()).toBe(2); expect((await ref.collection('participants').doc(A).get()).get('qualifiedAt')).toEqual(qualifiedAt); expect((await ref.collection('participants').doc(A).get()).get('validShakes')).toBe(120); expect((await send(A, 'q60', 1)).myTotal).toBe(60); await aggregateActiveEclipses(); expect((await ref.get()).get('qualifiedParticipants')).toBe(2); }); it('마지막 티커 저장이 실패하면 로그·개인 누적·샤드까지 취소하고 같은 요청으로 복구한다', async () => { await seed(); const db = getFirestore(); const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`); const input = { uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'atomic', shakes: 30 }; const original = Transaction.prototype.create; let calls = 0; const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementation(function (this: Transaction, ...args) { if (++calls === 2) throw new Error('ticker write failed'); return original.apply(this, args); }); try { await expect(defendEclipse(input)).rejects.toThrow('ticker write failed'); } finally { spy.mockRestore(); } for (const sub of ['defenseLogs', 'participants', 'shards']) { expect((await ref.collection(sub).get()).size).toBe(0); } expect((await db.collection(`fandoms/${FANDOM}/liveEvents`).get()).size).toBe(0); expect((await defendEclipse(input)).duplicate).toBe(false); expect(await shardSum()).toEqual({ defense: 30, calls: 1 }); expect((await db.collection(`fandoms/${FANDOM}/liveEvents`).get()).size).toBe(1); }); it('회차가 닫혀도 확정된 요청은 재확인할 수 있고 신규 요청은 거절한다', async () => { await seed(); const input = { uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'lost-response', shakes: 10 }; await defendEclipse(input); const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`); await ref.update({ status: 'ended', endsAt: Timestamp.fromMillis(0) }); expect(await defendEclipse(input)).toEqual({ accepted: 10, clamped: 0, myTotal: 10, duplicate: true }); await expect(defendEclipse({ ...input, clientSeq: 'late' })).rejects.toMatchObject({ details: { code: 'E-ECL-01' } }); expect(await shardSum()).toEqual({ defense: 10, calls: 1 }); }); }); describe('개인 인정 횟수는 운영값이다 (원장 C-25)', () => { it('회차에 고정된 인정 횟수로 판정하고 운영값 문서만 바꿔서는 진행 중 회차 기준이 바뀌지 않는다', async () => { await seed({ target: 1, perCallMaxShakes: 60, qualifyShakes: 60 }); const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`); await ref.update({ qualifyShakes: 30 }); await getFirestore().doc('config/eclipse').set({ qualifyShakes: 90 }, { merge: true }); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'q29', shakes: 29 }); expect((await ref.get()).get('defended')).toBe(false); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'q30', shakes: 1 }); expect((await ref.get()).get('defended')).toBe(true); }); });