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

203 lines
10 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
fnQuestTimeAttack, fnQuestTimeAttackStart, fnServerTime, joinTimeAttack, notifyDueRounds,
} from '../src/quest/timeattack.js';
import { participateStreaming } from '../src/quest/quest.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
/**
* TIME ATTACK — 10/3 통합회신 3절(기존 Quest 에 붙는 제한시간 모드 · Q-TA-01~05) · 별첨 1 ③-4
* 「발동·마감 경계 시각의 참여 수락/거부 동작 확인」.
*/
const UID = 'test-timeattack';
const FANDOM = 'fandom-timeattack';
const BASE = 'ta-base-stream';
const db = () => getFirestore();
type Reply = Record<string, unknown>;
const call = async (
fn: { run(request: never): unknown },
data: Record<string, unknown>,
token: Record<string, unknown> = {},
uid = UID,
): Promise<Reply> =>
(await fn.run({ data, auth: { uid, token } } as unknown as never)) as Reply;
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
return (error as { details?: { code?: string } }).details?.code ?? (error as Error).message;
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
/** 회차를 직접 박은 원 Quest. [fromMs] · [toMs] 는 지금 기준 상대 ms. */
async function round(id: string, fromMs: number, toMs: number, extra: Reply = {}, type = 'streaming') {
await db().doc(`quests/${id}`).set({
type, title: '총공', fandomId: FANDOM, dailyLimit: 1, active: true,
timeAttack: {
roundId: `r-${id}`, title: '돌발', fandomId: FANDOM, rewardPop: 30, scheduled: false,
activeFrom: Timestamp.fromMillis(Date.now() + fromMs),
activeTo: Timestamp.fromMillis(Date.now() + toMs),
notifyAt: null, preNotified: true,
},
...extra,
});
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
await db().recursiveDelete(db().collection('quests'));
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM });
await db().doc(`fandoms/${FANDOM}`).set({ name: '타임어택 행성' }, { merge: true });
await db().recursiveDelete(db().collection(`fandoms/${FANDOM}/fpiEvents`));
await db().doc(`quests/${BASE}`).set({ type: 'streaming', title: '스트리밍 총공', fandomId: FANDOM, dailyLimit: 1 });
await db().doc('config/policy').delete();
});
describe('서버 시각 — fn-server-time', () => {
it('서버 시각(ms)을 돌려준다', async () => {
const before = Date.now();
const reply = await call(fnServerTime, {});
expect(reply.now as number).toBeGreaterThanOrEqual(before);
});
});
describe('회차 발동 — fn-quest-timeattack-start', () => {
const start = { questId: BASE, fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 };
it('master 가 아니면 거부한다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }))).toBe('E-PERM-01');
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }, { role: 'ops' })))
.toBe('E-PERM-01');
});
it('별도 유형을 만들지 않고 원 Quest 에 즉시 회차를 붙인다 · 재전송은 한 건', async () => {
const before = Date.now();
const first = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
const again = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
expect(again.roundId).toBe(first.roundId);
expect(again.duplicate).toBe(true);
expect((await db().collection('quests').get()).size).toBe(1);
const doc = await db().doc(`quests/${BASE}`).get();
expect(doc.get('type')).toBe('streaming');
const r = doc.get('timeAttack');
expect(r.activeTo.toMillis() - r.activeFrom.toMillis()).toBe(10 * 60_000);
expect(r.activeFrom.toMillis()).toBeGreaterThanOrEqual(before - 1000);
expect(r.scheduled).toBe(false);
// 진행 중 회차가 있으면 새 회차를 붙이지 않는다.
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r1b' }, { role: 'master' })))
.toBe('E-QST-01');
});
it('보상 상한은 초기 100(Q-TA-04 A) · 운영값으로 바꿀 수 있다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 101, requestId: 'r4' }, { role: 'master' }))).toBe('E-QST-01');
const ok = await call(fnQuestTimeAttackStart, { ...start, rewardPop: 100, requestId: 'r5' }, { role: 'master' });
expect(ok.roundId).toBeTruthy();
await db().doc('config/policy').set({ timeAttackMaxPop: 50 });
await db().doc(`quests/${BASE}`).update({ timeAttack: FieldValue.delete() });
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 51, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01');
});
it('검증형 · 사용자 생성 · 다른 팬덤 Quest, 경로 문자 · 긴 제목 · 하루 초과는 거부한다', async () => {
await db().doc('quests/ta-walk').set({ type: 'walk', fandomId: FANDOM });
await db().doc('quests/ta-ugc').set({ type: 'vote', fandomId: FANDOM, authorUid: 'someone' });
await db().doc('quests/ta-else').set({ type: 'vote', fandomId: 'fandom-else' });
const master = { role: 'master' };
for (const [over, id] of [
[{ questId: 'ta-walk' }, 'a'], [{ questId: 'ta-ugc' }, 'b'], [{ questId: 'ta-else' }, 'c'],
[{ fandomId: 'a/b' }, 'd'], [{ title: 'x'.repeat(61) }, 'e'], [{ durationMinutes: 24 * 60 + 1 }, 'f'],
[{ fandomId: 'no-such-fandom' }, 'g'], [{ startAt: new Date(Date.now() - 60_000).toISOString() }, 'h'],
] as Array<[Reply, string]>) {
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, ...over, requestId: id }, master)))
.toBe('E-QST-01');
}
});
it('예약 발동 — 즉시 Push 없이 시작 3분 전에 한 번 보낸다(Q-TA-03 C)', async () => {
const startAt = new Date(Date.now() + 10 * 60_000);
const reply = await call(fnQuestTimeAttackStart,
{ ...start, startAt: startAt.toISOString(), requestId: 'r7' }, { role: 'master' });
expect(reply).toMatchObject({ scheduled: true, pushed: false });
const r = (await db().doc(`quests/${BASE}`).get()).get('timeAttack');
expect(r.notifyAt.toMillis()).toBe(startAt.getTime() - 3 * 60_000);
// 알림 시각 전에는 보내지 않는다.
await notifyDueRounds(new Date(startAt.getTime() - 4 * 60_000));
expect((await db().doc(`quests/${BASE}`).get()).get('timeAttack.preNotified')).toBe(false);
// 알림 시각이 지나면 표시하고(에뮬레이터에는 FCM 이 없어 발송 수는 세지 않는다), 다시 보내지 않는다.
await notifyDueRounds(new Date(startAt.getTime() - 3 * 60_000));
expect((await db().doc(`quests/${BASE}`).get()).get('timeAttack.preNotified')).toBe(true);
expect(await notifyDueRounds(new Date(startAt.getTime() - 60_000))).toBe(0);
// 시작 전에는 참여할 수 없다.
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: BASE }))).toBe('E-QST-01');
});
});
describe('회차 참여 — fn-quest-timeattack', () => {
it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위, 재호출은 첫 결과(마감 뒤도)', async () => {
await round('ta-open', -60_000, 60_000);
const reply = await call(fnQuestTimeAttack, { questId: 'ta-open' });
expect(reply.rewarded).toBe(30);
expect(reply.balanceAfter).toBe(30);
await db().doc('quests/ta-open').update({ 'timeAttack.activeTo': Timestamp.fromMillis(Date.now() - 1) });
const again = await call(fnQuestTimeAttack, { questId: 'ta-open' });
expect(again).toMatchObject({ txId: reply.txId, balanceAfter: 30, rewarded: 30, duplicate: true });
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
expect(await verifiedQuestCountOf(UID)).toBe(1);
const fpi = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
expect(fpi.docs.map((d) => [d.get('axis'), d.get('units'), d.get('activity')]))
.toEqual([['community', 1, 'timeattack']]);
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
expect(ledger.docs[0]!.get('reasonCode')).toBe('QUEST_TIMEATTACK');
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBe(1);
});
it('같은 원 행동의 중복 FPI 금지 — 회차 참여가 원 Quest 그날 참여를 겸한다', async () => {
await round('ta-dup', -60_000, 60_000);
await joinTimeAttack(UID, 'ta-dup');
// 같은 날 일반 참여는 이미 한도.
expect(await codeOf(() => participateStreaming(UID, 'ta-dup'))).toBe('E-POP-03');
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1);
});
it('그날 원 Quest 에 이미 참여했으면 회차 보상만 받고 FPI 는 더하지 않는다', async () => {
await round('ta-after', -60_000, 60_000);
await participateStreaming(UID, 'ta-after');
const reply = await joinTimeAttack(UID, 'ta-after');
expect(reply.rewarded).toBe(30);
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1);
});
it('timeAttackFpiAxis 가 null 이면 FPI 없이 EXP 만(Q-TA-05 B안)', async () => {
await db().doc('config/policy').set({ timeAttackFpiAxis: null });
await round('ta-no-fpi', -60_000, 60_000);
await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' });
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(0);
});
it('시작 전 · 마감 후(끝 시각 포함) · 회차 없음은 거부한다', async () => {
await round('ta-early', 60_000, 120_000);
await round('ta-late', -120_000, -1);
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-early' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-late' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: BASE }))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('다른 팬덤 Quest 로는 받지 못한다', async () => {
await round('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' });
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-other' }))).toBe('E-PERM-01');
});
});