499 lines
22 KiB
TypeScript
499 lines
22 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('가입하지 않은 팬덤의 이클립스는 막을 수 없다(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);
|
|
});
|
|
});
|