import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getFirestore } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { grantPop, spendPop, refundPop, reconcileUser, } from '../src/ledger/ledger.js'; /** * POP 원장 검증 — 별첨 1 「조건부 적합 제외 항목 ② POP/FPI 데이터 정합성」. * * 이 파일이 통과하지 못하면 검수는 조건부 적합이 아니라 부적합이다. 그래서 * 「값이 맞는가」가 아니라 **「불변식이 깨지지 않는가」**를 본다. */ const UID = 'test-ledger-user'; beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { await wipeUser(UID); }); describe('지급', () => { it('lot·원장·지갑을 한 번에 만든다', async () => { const r = await grantPop({ uid: UID, popType: 'activity', amount: 100, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'attend:test:2026-09-01', }); expect(r.balanceAfter).toBe(100); const db = getFirestore(); const wallet = await db.doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('activityBalance')).toBe(100); const lots = await db.collection(`users/${UID}/popLots`).get(); expect(lots.size).toBe(1); expect(lots.docs[0]!.get('remaining')).toBe(100); expect(lots.docs[0]!.get('status')).toBe('active'); const tx = await db.doc(`users/${UID}/popTransactions/${r.txId}`).get(); expect(tx.get('amount')).toBe(100); expect(tx.get('serverVerified')).toBe(true); }); it('같은 멱등키로 두 번 불러도 한 번만 지급한다', async () => { const key = 'attend:test:2026-09-02'; const first = await grantPop({ uid: UID, popType: 'activity', amount: 50, reasonCode: 'QUEST_ATTEND', idempotencyKey: key, }); const second = await grantPop({ uid: UID, popType: 'activity', amount: 50, reasonCode: 'QUEST_ATTEND', idempotencyKey: key, }); expect(second.txId).toBe(first.txId); const db = getFirestore(); const wallet = await db.doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('activityBalance')).toBe(50); const txs = await db.collection(`users/${UID}/popTransactions`).get(); expect(txs.size).toBe(1); }); it('활동 POP 과 지원 POP 의 잔액을 섞지 않는다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 100, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'a:1', }); await grantPop({ uid: UID, popType: 'support', amount: 300, reasonCode: 'IAP_PURCHASE', idempotencyKey: 's:1', }); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('activityBalance')).toBe(100); expect(wallet.get('supportBalance')).toBe(300); }); it('보류 지급은 잔액이 아니라 확정 대기에 쌓인다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 70, reasonCode: 'TRANSLATION', idempotencyKey: 'trn:1', pending: true, }); const db = getFirestore(); const wallet = await db.doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('activityBalance')).toBe(0); expect(wallet.get('pendingActivity')).toBe(70); const lots = await db.collection(`users/${UID}/popLots`).get(); expect(lots.docs[0]!.get('status')).toBe('pending'); }); }); describe('차감', () => { it('잔액이 모자라면 E-POP-01 로 끊는다', async () => { await expect(spendPop({ uid: UID, popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:none', })).rejects.toMatchObject({ details: { code: 'E-POP-01' } }); }); it('원장에 음수로 남고 lot 이 줄어든다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 100, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:1', }); const r = await spendPop({ uid: UID, popType: 'activity', amount: 40, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:1', fandomId: 'fandom-a', }); expect(r.balanceAfter).toBe(60); const db = getFirestore(); const tx = await db.doc(`users/${UID}/popTransactions/${r.txId}`).get(); expect(tx.get('amount')).toBe(-40); const lots = await db.collection(`users/${UID}/popLots`).get(); expect(lots.docs[0]!.get('remaining')).toBe(60); // 기여 사유의 차감은 통계와 팬덤 멤버 기여도에 함께 반영된다. const user = await db.doc(`users/${UID}`).get(); expect(user.get('totalContributedPop')).toBe(40); // 기여 POP 문턱 등급은 폐기됐다(C-18). 후원 · Zap 은 1 POP 단위로 쪼개 반복할 수 있어 // EXP 를 주지 않는다 — EXP 는 Quest 인정 활동에서만 쌓인다. expect(user.get('tier')).toBeUndefined(); expect(user.get('userExp')).toBeUndefined(); const member = await db.doc(`fandoms/fandom-a/members/${UID}`).get(); expect(member.get('contributedPop')).toBe(40); }); it('lot 여러 개에 걸쳐 차감하고 소진된 lot 을 닫는다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:a', }); await grantPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'AD_REWARD', idempotencyKey: 'g:b', }); const r = await spendPop({ uid: UID, popType: 'activity', amount: 45, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:1', }); expect(r.balanceAfter).toBe(15); expect(r.lotRefs).toHaveLength(2); const lots = await getFirestore() .collection(`users/${UID}/popLots`).orderBy('grantedAt').get(); const statuses = lots.docs.map((d) => d.get('status')); expect(statuses).toContain('exhausted'); }); it('같은 멱등키의 재시도는 두 번 차감하지 않는다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 100, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:2', }); const key = 'fund:dup'; await spendPop({ uid: UID, popType: 'activity', amount: 25, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: key, }); const again = await spendPop({ uid: UID, popType: 'activity', amount: 25, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: key, }); expect(again.balanceAfter).toBe(75); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('activityBalance')).toBe(75); }); }); describe('환불', () => { it('원 거래를 고치지 않고 반대 부호의 새 원장을 쓴다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 100, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:3', }); const spent = await spendPop({ uid: UID, popType: 'activity', amount: 60, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:refundable', }); const refunded = await refundPop({ uid: UID, originalTxId: spent.txId, idempotencyKey: `refund:${spent.txId}`, reasonCode: 'CAMPAIGN_REFUND', }); expect(refunded.balanceAfter).toBe(100); const db = getFirestore(); const original = await db.doc(`users/${UID}/popTransactions/${spent.txId}`).get(); // 금액은 그대로 남아 있어야 한다 — append-only. expect(original.get('amount')).toBe(-60); expect(original.get('refundedBy')).toBe(refunded.txId); const user = await db.doc(`users/${UID}`).get(); expect(user.get('totalContributedPop')).toBe(0); }); it('같은 거래를 두 번 환불하지 않는다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 100, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:4', }); const spent = await spendPop({ uid: UID, popType: 'activity', amount: 60, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:2', }); await refundPop({ uid: UID, originalTxId: spent.txId, idempotencyKey: 'refund:once', reasonCode: 'QNA_REFUND', }); await expect(refundPop({ uid: UID, originalTxId: spent.txId, idempotencyKey: 'refund:twice', reasonCode: 'QNA_REFUND', })).rejects.toMatchObject({ details: { code: 'E-POP-03' } }); }); }); describe('대사 — 별첨 1 ② 3자 교차 대조', () => { it('정상 거래 뒤에는 잔액·lot·원장이 모두 맞는다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 100, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:5', }); await spendPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:2', }); const row = await reconcileUser(UID); expect(row.ok).toBe(true); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('reconcileStatus')).toBe('ok'); }); it('레벨과 Passport 위조를 서로 오염시키지 않고 따로 잡아낸다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 5_000, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:rec-tier', }); await spendPop({ uid: UID, popType: 'activity', amount: 3_000, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:rec-tier', }); const failedNames = async () => (await reconcileUser(UID)).checks .filter((c) => c.expected !== c.actual).map((c) => c.name); expect(await failedNames()).toEqual([]); // 원장을 거치지 않은 레벨 위조. await getFirestore().doc(`users/${UID}`).set({ userLevel: 50 }, { merge: true }); expect(await failedNames()).toEqual(['level-vs-exp']); }); it('지갑을 몰래 고치면 대사가 잡아낸다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 100, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:6', }); // 원장을 거치지 않은 잔액 변경 — 정확히 이것을 잡아내야 한다. await getFirestore().doc(`users/${UID}/wallet/main`) .set({ activityBalance: 999 }, { merge: true }); const row = await reconcileUser(UID); expect(row.ok).toBe(false); const failed = row.checks.filter((c) => c.expected !== c.actual); expect(failed.length).toBeGreaterThan(0); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); // 자동으로 고치지 않는다 — 감지하고 표시만 한다(§3.6). expect(wallet.get('activityBalance')).toBe(999); expect(wallet.get('reconcileStatus')).toBe('mismatch'); }); }); describe('User EXP · Level · Passport 갱신 (C-18 · B-65)', () => { it('기여 차감은 EXP · 등급을 만들지 않는다 · 기여 POP 문턱 승급 없음', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 5_000, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:tier', }); await spendPop({ uid: UID, popType: 'activity', amount: 3_000, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:tier', }); const user = await getFirestore().doc(`users/${UID}`).get(); expect(user.get('totalContributedPop')).toBe(3_000); // 3,000 POP 후원도 EXP 를 만들지 않는다(C-18 기여 POP 치환 금지). expect(user.get('userExp')).toBeUndefined(); expect(user.get('passportTier')).toBeUndefined(); expect(user.get('tier')).toBeUndefined(); }); it('환불은 기여 통계만 되돌리고 EXP · Passport 는 내리지 않는다', async () => { await getFirestore().doc(`users/${UID}`).set({ passportTier: 'GOLD' }, { merge: true }); await grantPop({ uid: UID, popType: 'activity', amount: 5_000, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:tier2', }); const spent = await spendPop({ uid: UID, popType: 'activity', amount: 3_000, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:tier2', }); await refundPop({ uid: UID, originalTxId: spent.txId, idempotencyKey: 'refund:tier2', reasonCode: 'CAMPAIGN_REFUND', }); const user = await getFirestore().doc(`users/${UID}`).get(); expect(user.get('totalContributedPop')).toBe(0); expect(user.get('userExp')).toBeUndefined(); // 레벨이 낮아도 이미 GOLD 였으므로 강등하지 않는다. expect(user.get('passportTier')).toBe('GOLD'); }); it('기여가 아닌 차감은 EXP 를 건드리지 않는다', async () => { await grantPop({ uid: UID, popType: 'support', amount: 1_000, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:tier3', }); await spendPop({ uid: UID, popType: 'support', amount: 500, reasonCode: 'HERO_DROP_TRIGGER', idempotencyKey: 'spend:tier3', }); const user = await getFirestore().doc(`users/${UID}`).get(); expect(user.get('userExp')).toBeUndefined(); }); });