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 } from '../src/quest/timeattack.js'; import { verifiedQuestCountOf } from '../src/ledger/fpi.js'; /** * 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06 검증 기준 * 「발동·마감 경계 시각의 참여 수락/거부 동작 확인」. */ const UID = 'test-timeattack'; const FANDOM = 'fandom-timeattack'; 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('실패해야 하는 호출이 성공했습니다.'); } /** 창을 직접 박은 퀘스트. [fromMs] · [toMs] 는 지금 기준 상대 ms. */ async function quest(id: string, fromMs: number, toMs: number, extra: Reply = {}) { await db().doc(`quests/${id}`).set({ type: 'timeattack', title: '돌발', fandomId: FANDOM, rewardPop: 30, dailyLimit: 1, active: true, activeFrom: Timestamp.fromMillis(Date.now() + fromMs), activeTo: Timestamp.fromMillis(Date.now() + toMs), ...extra, }); } beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { await wipeUser(UID); 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`)); }); 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 = { 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('창을 서버 시각으로 열고 같은 요청의 재전송은 한 건으로 모은다', 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.questId).toBe(first.questId); expect(again.duplicate).toBe(true); const doc = await db().doc(`quests/${first.questId as string}`).get(); expect(doc.get('type')).toBe('timeattack'); expect(doc.get('fandomId')).toBe(FANDOM); const span = (doc.get('activeTo') as Timestamp).toMillis() - (doc.get('activeFrom') as Timestamp).toMillis(); expect(span).toBe(10 * 60_000); expect((doc.get('activeFrom') as Timestamp).toMillis()).toBeGreaterThanOrEqual(before - 1000); }); it('보상 상한(config/policy.timeAttackMaxPop) · 경로 문자 · 긴 제목을 거부한다', async () => { const saved = (await db().doc('config/policy').get()).data(); await db().doc('config/policy').set({ timeAttackMaxPop: 100 }); try { 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.questId).toBeTruthy(); } finally { await db().doc('config/policy').delete(); } try { // 문서가 없으면 기본 상한 1,000. expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, rewardPop: 1001, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01'); } finally { if (saved) await db().doc('config/policy').set(saved); } expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, fandomId: 'a/b', requestId: 'r7' }, { role: 'master' }))).toBe('E-QST-01'); expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, title: 'x'.repeat(61), requestId: 'r8' }, { role: 'master' }))).toBe('E-QST-01'); }); it('하루를 넘는 창 · 없는 팬덤은 거부한다', async () => { expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, durationMinutes: 24 * 60 + 1, requestId: 'r2' }, { role: 'master' }))).toBe('E-QST-01'); expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, fandomId: 'no-such-fandom', requestId: 'r3' }, { role: 'master' }))).toBe('E-QST-01'); }); it('지속 시간 상한은 config/policy.timeAttackMaxMinutes 를 따른다', async () => { const saved = (await db().doc('config/policy').get()).data(); await db().doc('config/policy').set({ timeAttackMaxMinutes: 30 }); try { expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, durationMinutes: 31, requestId: 'r9' }, { role: 'master' }))).toBe('E-QST-01'); const ok = await call(fnQuestTimeAttackStart, { ...start, durationMinutes: 30, requestId: 'r10' }, { role: 'master' }); expect(ok.questId).toBeTruthy(); } finally { await db().doc('config/policy').delete(); if (saved) await db().doc('config/policy').set(saved); } }); }); describe('타임어택 참여 — fn-quest-timeattack', () => { it('마감 뒤 재시도여도 이미 받은 사용자는 첫 결과를 받는다', async () => { await quest('ta-retry', -60_000, 60_000); const first = await call(fnQuestTimeAttack, { questId: 'ta-retry' }); await db().doc('quests/ta-retry').update({ activeTo: Timestamp.fromMillis(Date.now() - 1) }); const again = await call(fnQuestTimeAttack, { questId: 'ta-retry' }); expect(again.txId).toBe(first.txId); expect(again.duplicate).toBe(true); expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1); }); it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위로 남고 재호출은 첫 결과를 돌려준다', async () => { await quest('ta-open', -60_000, 60_000); const reply = await call(fnQuestTimeAttack, { questId: 'ta-open' }); expect(reply.rewarded).toBe(30); expect(reply.balanceAfter).toBe(30); const again = await call(fnQuestTimeAttack, { questId: 'ta-open' }); expect(again).toMatchObject({ txId: reply.txId, balanceAfter: 30, rewarded: 30, duplicate: true }); // 인정 활동 1건 = EXP 100(C-18) · 검증 활동 1회 · FPI COMMUNITY 1 단위(Q-TA-05 권고 A안). 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); expect((await db().doc(`users/${UID}/questProgress/ta-open`).get()).get('count')).toBe(1); }); it('timeAttackFpiAxis 가 null 이면 FPI 없이 EXP 만(Q-TA-05 B안)', async () => { await db().doc('config/policy').set({ timeAttackFpiAxis: null }, { merge: true }); try { await quest('ta-no-fpi', -60_000, 60_000); await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' }); } finally { await db().doc('config/policy').update({ timeAttackFpiAxis: FieldValue.delete() }); } 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 quest('ta-early', 60_000, 120_000); await quest('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 db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0); }); it('다른 팬덤 · 다른 유형 퀘스트로는 받지 못한다', async () => { await quest('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' }); await db().doc('quests/ta-attend').set({ type: 'attend', active: true }); expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-other' }))).toBe('E-PERM-01'); expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-attend' }))).toBe('E-QST-01'); }); });