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

322 lines
13 KiB
TypeScript

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();
});
});