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[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(); 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 만 받고 횟수 · 귀속 팬덤을 서버가 정하며 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => { const db = getFirestore(); const { fnPopSpend } = await import('../src/ledger/pop.js'); const run = (data: Record) => fnPopSpend.run( { data, auth: { uid, token: {} } } as Parameters[0]); await db.doc('config/policy').set({ zapPop: 10 }, { 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' } }); // 클라이언트가 count 1e9 · 남의 팬덤 b 를 보내도 서버는 30 ÷ 10 = 3 회 · 대표 팬덤 a 로 적는다. await run({ popType: 'activity', amount: 30, count: 1e9, fandomId: b, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b1' }); const events = await db.collection(`fandoms/${a}/fpiEvents`).get(); expect(events.docs.map((e) => [e.get('axis'), e.get('units')])).toEqual([['contribution', 3]]); expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(0); expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(30); expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBeUndefined(); expect((await db.doc(`users/${uid}`).get()).get('userExp')).toBeUndefined(); await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`)); });