Files
fanuzu/app/firebase/functions/test/eclipse.test.ts
T

489 lines
21 KiB
TypeScript

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<void> {
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<string, unknown> = {}): Promise<void> {
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<number> =>
(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<number> {
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('다른 사용자는 서로의 속도 제한에 걸리지 않는다', 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);
});
});