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; const call = async ( fn: { run(request: never): unknown }, data: Record, token: Record = {}, uid = UID, ): Promise => (await fn.run({ data, auth: { uid, token } } as unknown as never)) as Reply; async function codeOf(run: () => Promise): Promise { 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'); }); });