import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin } from './support.js'; import * as ledger from '../src/ledger/ledger.js'; import { txIdOf } from '../src/core/ids.js'; import { fnHeroDropTrigger, fnHeroDropClaim } from '../src/fandom/herodrop.js'; const uid = 'hero-atomic-test'; const fandomId = 'hero-atomic-test'; async function call(fn: { run(request: never): unknown }, data: Record) { return await fn.run({ data, auth: { uid, token: {} } } as never) as Record; } async function clean() { for (const path of [`users/${uid}`, `fandoms/${fandomId}`]) await getFirestore().recursiveDelete(getFirestore().doc(path)); } beforeAll(() => { bootAdmin(); }); beforeEach(async () => { await clean(); await getFirestore().doc('config/policy').set({ herodrop: { totalPop: 100, perUserCap: 5, perUserCapPercent: 5, durationSec: 600 } }, { merge: true }); await getFirestore().doc(`users/${uid}`).set({ nickname: '공개팬', displayName: '비공개이름', email: 'private@example.com' }); await ledger.grantPop({ uid, amount: 200, popType: 'support', reasonCode: 'ADMIN_GRANT', idempotencyKey: 'hero-seed' }); }); afterEach(() => vi.restoreAllMocks()); afterAll(async () => { await clean(); await shutdownAdmin(); }); const trigger = () => call(fnHeroDropTrigger, { fandomId, productId: 'gift', clientSeq: '1', heroNickname: '위조닉네임' }); it('발동 재시도는 소진된 Pool과 종료시각을 다시 쓰지 않는다', async () => { const first = await trigger(); const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${first.dropId}`); const before = (await ref.get()).get('endsAt'); expect((await ref.get()).get('heroNickname')).toBe('공개팬'); expect((await ref.get()).get('email')).toBeUndefined(); await ref.update({ remainingPop: 0, claimedCount: 20 }); await Promise.all([trigger(), trigger()]); const after = await ref.get(); expect(after.get('remainingPop')).toBe(0); expect(after.get('claimedCount')).toBe(20); expect(after.get('endsAt')).toEqual(before); expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(100); }); it('수령 지급 실패는 Pool과 수령 기록도 남기지 않는다', async () => { const { dropId } = await trigger(); const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`); vi.spyOn(ledger, 'grantPop').mockRejectedValueOnce(new Error('grant unavailable')); await expect(call(fnHeroDropClaim, { fandomId, dropId })).rejects.toThrow('grant unavailable'); expect((await ref.get()).get('remainingPop')).toBe(100); expect((await ref.collection('claims').get()).size).toBe(0); }); it('동시 수령과 종료 후 응답 재확인은 같은 지급 결과를 돌려준다', async () => { const { dropId } = await trigger(); const results = await Promise.all([ call(fnHeroDropClaim, { fandomId, dropId }), call(fnHeroDropClaim, { fandomId, dropId }), ]); expect(results[0]).toEqual(results[1]); const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`); await ref.update({ endsAt: Timestamp.fromMillis(0) }); expect(await call(fnHeroDropClaim, { fandomId, dropId })).toEqual(results[0]); expect((await ref.get()).get('remainingPop')).toBe(95); expect((await ref.get()).get('claimedCount')).toBe(1); expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(5); }); it('이벤트 생성 실패 시 지원 POP 차감도 되돌린다', async () => { const dropId = txIdOf(`herodrop:trigger:${uid}:1`); await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).create({ remainingPop: 7 }); await expect(trigger()).rejects.toThrow(); expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(200); expect((await getFirestore().doc(`users/${uid}/popTransactions/${dropId}`).get()).exists).toBe(false); }); it('공개 닉네임이 없으면 비공개 이름이나 이메일로 대체하지 않고 「팬」으로 싣는다', async () => { await getFirestore().doc(`users/${uid}`).set({ displayName: '비공개이름', email: 'private@example.com' }); const { dropId } = await trigger(); const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get(); expect(drop.get('heroNickname')).toBe('팬'); expect(drop.get('displayName')).toBeUndefined(); expect(drop.get('email')).toBeUndefined(); }); it('기본 5% 상한과 10분을 적용하고 기존 고정 POP 상한은 사용하지 않는다', async () => { await getFirestore().doc('config/policy').update({ herodrop: { totalPop: 200, perUserCap: 150 } }); const before = Date.now(); const { dropId } = await trigger(); const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get(); expect(drop.get('perUserCap')).toBe(10); expect(drop.get('perUserCapPercent')).toBe(5); expect(drop.get('endsAt').toMillis()).toBeGreaterThanOrEqual(before + 600000); const claim = await call(fnHeroDropClaim, { fandomId, dropId }); expect(claim.claimedPop).toBe(10); }); it('운영 상한 비율을 이벤트에 고정하고 소수 POP은 상한을 넘겨 올리지 않는다', async () => { await getFirestore().doc('config/policy').update({ herodrop: { totalPop: 101, perUserCapPercent: 3, durationSec: 120, } }); const { dropId } = await trigger(); const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get(); expect(drop.get('perUserCap')).toBe(3); await getFirestore().doc('config/policy').update({ 'herodrop.perUserCapPercent': 10 }); expect((await call(fnHeroDropClaim, { fandomId, dropId })).claimedPop).toBe(3); }); it('유효하지 않은 운영 비율은 차감 전에 거부한다', async () => { for (const cap of [0, -1, 101, '5']) { await getFirestore().doc('config/policy').update({ 'herodrop.perUserCapPercent': cap }); await expect(trigger()).rejects.toThrow(); } expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(200); });