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

214 lines
13 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { grantPop, refundPop, reverseGrowth, spendPop } from '../src/ledger/ledger.js';
import { fnAdminGrowthReverse } from '../src/admin/admin.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
const uid = 'test-growth-user';
const a = 'test-growth-a';
const b = 'test-growth-b';
const grant = () => grantPop({ uid, popType: 'activity', amount: 10,
reasonCode: 'QUEST_ATTEND', growthSource: 'quest_reward', idempotencyKey: 'growth:reward' });
const spend = () => spendPop({ uid, popType: 'activity', amount: 10,
reasonCode: 'ZAP_CONTRIBUTION', fandomId: a, idempotencyKey: 'growth:zap' });
async function clean() {
const db = getFirestore();
for (const path of [`users/${uid}`, `fandoms/${a}`, `fandoms/${b}`]) {
await db.recursiveDelete(db.doc(path));
}
}
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
await clean();
const db = getFirestore();
await db.doc(`users/${uid}`).set({ fandomId: a, nickname: '공개팬' });
await db.doc(`fandoms/${a}`).set({ stage: 1, sfpi: 125 });
await db.doc(`fandoms/${b}`).set({ stage: 1, sfpi: 125 });
});
afterAll(async () => { await clean(); await shutdownAdmin(); });
it('보상10+ZAP10=성장20이며 동시 재시도와 대표 팬덤 변경에도 귀속이 고정된다', async () => {
const db = getFirestore();
const results = await Promise.all([grant(), grant()]);
expect(results[0]!.txId).toBe(results[1]!.txId);
await Promise.all([spend(), spend()]);
// 지급 · Zap 뒤에 대표 팬덤을 바꾸고 재시도해도 원 귀속이 유지된다(G-02).
await db.doc(`users/${uid}`).update({ fandomId: b });
await grant();
await spend();
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(20);
expect((await db.doc(`fandoms/${a}`).get()).get('sfpi')).toBe(125);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBeUndefined();
const events = await db.collection(`users/${uid}/growthEvents`).get();
expect(events.size).toBe(2);
expect(events.docs.map(d => d.get('sourceType')).sort()).toEqual(['quest_reward', 'zap']);
expect(events.docs.every(d => d.get('fandomIdAtPayout') === a && d.id === d.get('sourceEventId'))).toBe(true);
expect((await db.doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(0);
expect((await db.doc(`fandoms/${a}/members/${uid}`).get()).get('contributedPop')).toBe(10);
});
it('일반 광고/관리자 지급/보류 지급/예치성 차감에 성장을 자동 확대하지 않는다', async () => {
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'AD_REWARD', idempotencyKey: 'ad' });
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'admin' });
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND',
growthSource: 'quest_reward', pending: true, idempotencyKey: 'pending' });
await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QNA_BOUNTY', fandomId: a, idempotencyKey: 'deposit' });
expect((await getFirestore().collection(`users/${uid}/growthEvents`).get()).size).toBe(0);
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
});
it('지급 트랜잭션 실패는 성장도 남기지 않는다', async () => {
await expect(grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND',
growthSource: 'quest_reward', idempotencyKey: 'abort', alsoWrite: () => { throw new Error('abort'); },
})).rejects.toThrow('abort');
expect((await getFirestore().collection(`users/${uid}/growthEvents`).get()).size).toBe(0);
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
});
it('반환(CAMPAIGN_REFUND · QNA_REFUND)은 성장을 만들지도 되돌리지도 않는다(C-30)', async () => {
const db = getFirestore();
await grantPop({ uid, popType: 'activity', amount: 20, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'seed' });
const fund = await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND',
fandomId: a, idempotencyKey: 'fund' });
const bounty = await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QNA_BOUNTY',
fandomId: a, idempotencyKey: 'bounty' });
await refundPop({ uid, originalTxId: fund.txId, idempotencyKey: 'r1', reasonCode: 'CAMPAIGN_REFUND' });
await refundPop({ uid, originalTxId: bounty.txId, idempotencyKey: 'r2', reasonCode: 'QNA_REFUND' });
const events = await db.collection(`users/${uid}/growthEvents`).get();
expect(events.docs.map((d) => d.get('sourceType'))).toEqual(['funding']);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
});
it('오지급 확정 역분개는 원 이벤트 참조 · 사유 · 음수 수치를 남기고 한 번만 반영된다(C-32)', async () => {
const db = getFirestore();
const paid = await grant();
const reverse = () => reverseGrowth({ uid, sourceEventId: paid.txId, amount: 4,
reason: '중복 지급 확정', actorUid: 'ops-1' });
await Promise.all([reverse(), reverse()]);
const rev = await db.doc(`users/${uid}/growthEvents/rev-${paid.txId}`).get();
expect(rev.get('reversalOf')).toBe(paid.txId);
expect(rev.get('amount')).toBe(-4);
expect(rev.get('reason')).toBe('중복 지급 확정');
expect(rev.get('fandomIdAtPayout')).toBe(a);
// 정상분 6 은 유지된다. 원 행은 그대로다(append-only).
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(6);
expect((await db.doc(`users/${uid}/growthEvents/${paid.txId}`).get()).get('amount')).toBe(10);
// 일부 정정은 금액 오류라 완료(검증 활동)는 그대로다.
expect((await db.collection(`users/${uid}/verifiedCompletions`).where('voided', '==', true).get()).size).toBe(0);
// 원 성장량을 넘는 정정 · 역분개 행의 재역분개는 거부한다.
await expect(reverseGrowth({ uid, sourceEventId: `rev-${paid.txId}`, amount: 1, reason: 'x', actorUid: 'ops-1' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
});
it('Quest 보상 전액 역분개는 그 원장 행의 검증 활동 표식을 무효로 한다 · 일부 정정은 유지(Campaign 개설자격 A안)', async () => {
const db = getFirestore();
const paid = await grant();
await db.doc(`users/${uid}/verifiedCompletions/q_2026-09-28`).set({ questId: 'q', txIds: [paid.txId] });
await reverseGrowth({ uid, sourceEventId: paid.txId, amount: 10, reason: '허위 참여 확정', actorUid: 'ops-1' });
expect(await verifiedQuestCountOf(uid)).toBe(0);
});
it('역분개는 운영자(master/ops)만 부를 수 있다', async () => {
const paid = await grant();
const data = { uid, sourceEventId: paid.txId, amount: 1, reason: '오지급' };
for (const auth of [{ uid, token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
await expect(fnAdminGrowthReverse.run({ data, auth } as Parameters<typeof fnAdminGrowthReverse.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
}
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
});
it('캠페인 후원은 기여 POP 만 쓰고 targetPop 초과를 막으며 캠페인 팬덤에 귀속된다', async () => {
const db = getFirestore();
const { fundCampaign } = await import('../src/fandom/fandom.js');
const cid = 'test-growth-campaign';
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
await db.doc(`campaigns/${cid}`).set({
status: 'funding', targetPop: 30, raisedPop: 0, fandomId: b,
endsAt: Timestamp.fromMillis(Date.now() + 86_400_000),
});
await grantPop({ uid, popType: 'support', amount: 50, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'fund:seed' });
await grantPop({ uid, popType: 'activity', amount: 50, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'fund:seed-a' });
// 동시 후원 둘이 같은 잔여분(30)을 보고 함께 통과하지 않는다 — 판정이 차감 트랜잭션 안이다.
const settled = await Promise.allSettled([
fundCampaign({ uid, campaignId: cid, clientSeq: 's1', amount: 20 }),
fundCampaign({ uid, campaignId: cid, clientSeq: 's2', amount: 20 }),
]);
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect(String((settled.find((r) => r.status === 'rejected') as PromiseRejectedResult).reason))
.toContain('남은 모금 목표');
expect((await db.doc(`campaigns/${cid}`).get()).get('raisedPop')).toBe(20);
const wallet = (await db.doc(`users/${uid}/wallet/main`).get()).data()!;
expect(wallet.supportBalance).toBe(30);
expect(wallet.activityBalance).toBe(50);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(20);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
// FPI 도 캠페인 팬덤 b 로 — 성장과 갈라지지 않는다(10/3 #10).
expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(1);
expect((await db.collection(`fandoms/${a}/fpiEvents`).get()).size).toBe(0);
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
const donations = await db.collection(`campaigns/${cid}/donations`).get();
expect(donations.docs.map((d) => d.get('donorUid'))).toEqual([uid]);
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
});
it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 행성에 귀속하고 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
const db = getFirestore();
const { fnPopSpend } = await import('../src/ledger/pop.js');
const run = (data: Record<string, unknown>) => fnPopSpend.run(
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[0]);
await db.doc('config/policy').set({ zapPop: 10, zapMinIntervalMs: 0 }, { merge: true });
await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'zap:seed' });
await expect(run({ popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'zap:h1' }))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
await expect(run({ popType: 'activity', amount: 5, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:odd' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
// 가입하지 않은 행성 b 에는 Zap 할 수 없다 — 남의 행성 성장을 올리지 못한다(리뷰 M1).
await expect(run({ popType: 'activity', amount: 30, fandomId: b,
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b0' }))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
// b 에 가입한 뒤에는 **Zap 한 행성 b** 에 성장 · FPI · 기여가 귀속된다(상세정의 5장 · 10/3 #10).
// 대표 팬덤은 a 그대로다. 클라이언트 count 1e9 는 무시하고 30 ÷ 10 = 3 회로 적는다.
await db.doc(`users/${uid}`).update({ fandomIds: [a, b] });
await run({ popType: 'activity', amount: 30, count: 1e9, fandomId: b,
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b1' });
const events = await db.collection(`fandoms/${b}/fpiEvents`).get();
expect(events.docs.map((e) => [e.get('axis'), e.get('units')])).toEqual([['contribution', 3]]);
expect((await db.collection(`fandoms/${a}/fpiEvents`).get()).size).toBe(0);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(30);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
expect((await db.doc(`fandoms/${b}/members/${uid}`).get()).get('contributedPop')).toBe(30);
// 대상이 없는 구 클라이언트는 대표 팬덤 a 로 간다.
await run({ popType: 'activity', amount: 10, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:a1' });
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
expect((await db.doc(`users/${uid}`).get()).get('userExp')).toBeUndefined();
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
});
it('연속 ZAP Rate Limit — 한 호출 상한 · 호출 간격(운영값, FX §3)', async () => {
const db = getFirestore();
const { fnPopSpend } = await import('../src/ledger/pop.js');
const run = (data: Record<string, unknown>) => fnPopSpend.run(
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[0]);
await db.doc('config/policy').set({ zapPop: 10, zapMaxPerCall: 3, zapMinIntervalMs: 60_000 }, { merge: true });
await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'rl:seed' });
await expect(run({ popType: 'activity', amount: 40, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:4' }))
.rejects.toMatchObject({ details: { code: 'E-QST-02' } });
await run({ popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:1' });
// 같은 요청의 재시도는 간격에 걸리지 않는다(멱등).
await run({ popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:1' });
await expect(run({ popType: 'activity', amount: 10, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:2' }))
.rejects.toMatchObject({ details: { code: 'E-QST-02' } });
expect((await db.doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(70);
await db.doc('config/policy').set({ zapMaxPerCall: 50, zapMinIntervalMs: 400 }, { merge: true });
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
});