import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { countParticipant, participateStreaming } from '../src/quest/quest.js'; import { TEST_PAYOUT_MARK, popIssuance } from '../src/admin/admin.js'; /** * 운영 Quest 「참여 인원」(10/6 관찰 「참여 인원 0」) · C-123 테스트 지급분 집계 제외. * 트리거 본체([countParticipant])를 진행 문서 값으로 직접 부른다 — 에뮬레이터에 함수 트리거가 없다. */ const A = 'test-part-a'; const B = 'test-part-b'; const db = () => getFirestore(); const countOf = async (id: string) => (await db().doc(`quests/${id}`).get()).get('participantCount') as number | undefined; beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { for (const uid of [A, B]) await wipeUser(uid); for (const id of ['op-stream', 'op-shake', 'ugc-q']) await db().recursiveDelete(db().doc(`quests/${id}`)); await db().doc(`users/${A}`).set({ uid: A }); await db().doc(`users/${B}`).set({ uid: B }); }); describe('운영 Quest 참여 인원 — 사람 단위 · 멱등', () => { it('완료 진행 문서로 1명 · 같은 사람 재전달 · 다음 날 재참여는 그대로 · 다른 사람은 +1', async () => { await db().doc('quests/op-stream').set({ type: 'streaming', title: '스밍', dailyLimit: 1 }); await participateStreaming(A, 'op-stream'); const progress = (await db().doc(`users/${A}/questProgress/op-stream`).get()).data()!; expect(await countParticipant(A, 'op-stream', progress)).toBe(true); expect(await countParticipant(A, 'op-stream', progress)).toBe(false); expect(await countParticipant(A, 'op-stream', { count: 1 })).toBe(false); expect(await countOf('op-stream')).toBe(1); expect(await countParticipant(B, 'op-stream', { count: 1 })).toBe(true); expect(await countOf('op-stream')).toBe(2); }); it('진행 세션 시작(Music Shake 「참여」)도 1명 · 빈 진행 문서는 세지 않는다', async () => { await db().doc('quests/op-shake').set({ type: 'musicShake', title: '셰이크' }); expect(await countParticipant(A, 'op-shake', {})).toBe(false); expect(await countParticipant(A, 'op-shake', { session: { state: 'inProgress' } })).toBe(true); expect(await countParticipant(A, 'op-shake', { count: 1 })).toBe(false); expect(await countOf('op-shake')).toBe(1); }); it('사용자 생성 Quest · 퀘스트 문서가 없는 진행 문서(ad)는 건너뛴다', async () => { await db().doc('quests/ugc-q').set({ type: 'place', authorUid: 'someone', participantCount: 3 }); expect(await countParticipant(A, 'ugc-q', { count: 1 })).toBe(false); expect(await countOf('ugc-q')).toBe(3); expect(await countParticipant(A, 'ad', { count: 1 })).toBe(false); expect((await db().doc('quests/ad').get()).exists).toBe(false); }); }); describe('C-123 테스트 지급분 — 원장 불변 · 발행량 대조에서 제외', () => { const at = (h: number) => Timestamp.fromDate(new Date(Date.UTC(2020, 0, 1, h))); const row = (uid: string, id: string, data: Record) => db().doc(`users/${uid}/popTransactions/${id}`).set({ type: 'grant', ...data }); it('표시된 행은 bySource 에서 빠지고 testPayout 에 따로 · 원장 행은 그대로', async () => { await row(A, 'tx-test', { popType: 'activity', reasonCode: 'AD_REWARD', amount: 10, createdAt: at(1) }); await row(A, 'tx-ok', { popType: 'support', reasonCode: 'AD_REWARD', amount: 10, createdAt: at(2) }); await row(B, 'tx-attend', { popType: 'activity', reasonCode: 'QUEST_ATTEND', amount: 10, createdAt: at(3) }); await db().doc('popTransactionMarks/tx-test').set({ mark: TEST_PAYOUT_MARK, reason: 'C-123', path: `users/${A}/popTransactions/tx-test`, markedAt: at(5), }); try { const report = await popIssuance(at(0).toDate(), at(4).toDate()); expect(report.bySource).toEqual({ 'grant:support:AD_REWARD': { count: 1, amount: 10 }, 'grant:activity:QUEST_ATTEND': { count: 1, amount: 10 }, }); expect(report.testPayout).toEqual({ 'grant:activity:AD_REWARD': { count: 1, amount: 10 } }); // 원장 행은 수정 · 삭제 0건. expect((await db().doc(`users/${A}/popTransactions/tx-test`).get()).data()) .toMatchObject({ amount: 10, popType: 'activity', reasonCode: 'AD_REWARD' }); } finally { await db().doc('popTransactionMarks/tx-test').delete(); } }); });