Files
fanuzu/app/firebase/functions/test/herodrop.test.ts
T

107 lines
6.1 KiB
TypeScript

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<string, unknown>) {
return await fn.run({ data, auth: { uid, token: {} } } as never) as Record<string, unknown>;
}
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);
});