204 lines
11 KiB
TypeScript
204 lines
11 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');
|
|
// 운영 Quest 의 참여 인원은 진행 문서 트리거([countParticipant])가 사람 단위로 센다 — 회차 함수는 올리지 않는다.
|
|
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBeUndefined();
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|