import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin } from './support.js'; import { fnFocusDailyClaim, fnIapVerify, setReceiptVerifier } from '../src/payment/iap.js'; import { StoreReceiptVerifier, type Platform, type ReceiptClaim, type ReceiptHint, type ReceiptVerifier, } from '../src/payment/receipt.js'; /** * `fn-iap-verify` 콜러블 — 별첨 1 ⑥ 샌드박스 연결분. * * 스토어 왕복은 대역으로 두고, 상품 ID 에 따른 세 갈래(구독 · Hero Gift · 상품 표)와 * 재전송 · 계정 간 영수증 재사용을 본다. */ const UID = 'iap-verify-user'; const OTHER = 'iap-verify-other'; const FANDOM = 'iap-verify-fandom'; const GIFT = 'herogift.test.basic'; let claim: Partial = {}; let lastHint: ReceiptHint | undefined; class FakeVerifier implements ReceiptVerifier { async verify(platform: Platform, _receipt: string, hint?: ReceiptHint): Promise { lastHint = hint; return { platform, transactionId: 'gpa.1', productId: 'pop.support.5500', consumable: true, purchasedAtMillis: 1_788_000_000_000, revoked: false, ...claim, }; } } const db = () => getFirestore(); async function call(uid: string, data: Record) { return await fnIapVerify.run( { data: { platform: 'android', receipt: 'token', ...data }, auth: { uid, token: {} } } as never, ) as Record; } async function codeOf(fn: () => Promise): Promise { try { await fn(); } catch (e) { return (e as { details?: { code?: string } }).details?.code; } return undefined; } async function clean() { for (const path of [`users/${UID}`, `users/${OTHER}`, `fandoms/${FANDOM}`, `heroGiftProducts/${GIFT}`]) { await db().recursiveDelete(db().doc(path)); } const owners = await db().collection('iapTransactions').get(); await Promise.all(owners.docs.map((d) => d.ref.delete())); } beforeAll(() => { bootAdmin(); }); beforeEach(async () => { claim = {}; setReceiptVerifier(new FakeVerifier()); await clean(); await db().doc(`users/${UID}`).set({ nickname: '영웅팬', fandomId: FANDOM }); await db().doc('config/policy').set({ herodrop: { totalPop: 100, perUserCapPercent: 5, durationSec: 600 }, popPackSalesEnabled: false, focusDailyPop: 40, }, { merge: true }); await db().doc(`heroGiftProducts/${GIFT}`).set( { name: '베이직 드랍', buyerPop: 500, dropPop: 500, active: true }); }); afterEach(() => { setReceiptVerifier(new StoreReceiptVerifier()); }); afterAll(async () => { await clean(); await shutdownAdmin(); }); describe('Hero Gift — 구매자 기여 POP + 팬덤 Drop Pool (B-84)', () => { it('Catalog 값대로 구매자에게 기여 POP, 소속 팬덤에 같은 규격의 Drop 을 연다', async () => { claim = { productId: GIFT }; const result = await call(UID, { productId: GIFT }); expect(result.granted).toBe(500); const wallet = await db().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('supportBalance')).toBe(500); const drop = await db().doc(`fandoms/${FANDOM}/heroDrops/${result.dropId}`).get(); expect(drop.get('totalPop')).toBe(500); expect(drop.get('remainingPop')).toBe(500); expect(drop.get('perUserCap')).toBe(25); expect(drop.get('heroNickname')).toBe('영웅팬'); const purchase = await db().doc(`users/${UID}/purchases/android_gpa.1`).get(); expect(purchase.get('state')).toBe('granted'); expect(purchase.get('kind')).toBe('hero_gift'); expect(purchase.get('itemName')).toBe('베이직 드랍'); }); it('같은 구매를 다시 보내도 지급과 Drop 은 한 번이다', async () => { claim = { productId: GIFT }; const first = await call(UID, { productId: GIFT }); const again = await call(UID, { productId: GIFT }); expect(again.duplicate).toBe(true); expect(again.txId).toBe(first.txId); const drops = await db().collection(`fandoms/${FANDOM}/heroDrops`).get(); expect(drops.size).toBe(1); const wallet = await db().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('supportBalance')).toBe(500); }); it('구매자 POP 과 Drop Pool 이 다른 Catalog 값은 지급하지 않는다 (B-84 동일 수량)', async () => { await db().doc(`heroGiftProducts/${GIFT}`).update({ buyerPop: 100 }); claim = { productId: GIFT }; expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-02'); expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false); expect((await db().collection(`fandoms/${FANDOM}/heroDrops`).get()).size).toBe(0); }); it('지급 전 실패 뒤 재시도는 첫 시도의 팬덤으로 Drop 을 연다', async () => { claim = { productId: GIFT }; await db().doc(`users/${UID}/purchases/android_gpa.1`).set({ state: 'verified', fandomId: FANDOM }); await db().doc(`users/${UID}`).set({ nickname: '영웅팬', fandomId: 'moved-away' }); const result = await call(UID, { productId: GIFT }); expect((await db().doc(`fandoms/${FANDOM}/heroDrops/${result.dropId}`).get()).exists).toBe(true); }); it('소속 행성이 없으면 지급도 Drop 도 없이 E-PAY-01(재시도 불가)로 끊는다', async () => { await db().doc(`users/${UID}`).set({ nickname: '영웅팬' }); claim = { productId: GIFT }; expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-01'); expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false); }); it('판매를 끈 상품은 지급하지 않는다', async () => { await db().doc(`heroGiftProducts/${GIFT}`).update({ active: false }); claim = { productId: GIFT }; expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-01'); }); }); describe('영수증 재사용 · 재전송', () => { it('다른 계정이 같은 거래를 보내면 E-PAY-01 이고 지급하지 않는다', async () => { await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true }); await call(UID, {}); expect(await codeOf(() => call(OTHER, {}))).toBe('E-PAY-01'); expect((await db().doc(`users/${OTHER}/wallet/main`).get()).exists).toBe(false); }); it('환불 요청 뒤 복원 재전송이 상태를 「지급 완료」로 되돌리지 않는다', async () => { await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true }); await call(UID, {}); const ref = db().doc(`users/${UID}/purchases/android_gpa.1`); await ref.update({ state: 'refund-requested' }); const again = await call(UID, {}); expect(again.duplicate).toBe(true); expect((await ref.get()).get('state')).toBe('refund-requested'); }); it('표시용 가격은 저장하되 지급량은 상품 표를 따른다', async () => { await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true }); const result = await call(UID, { priceAmount: 99, currency: 'KRW', title: '서포터 팩' }); expect(result.granted).toBe(5_500); const purchase = await db().doc(`users/${UID}/purchases/android_gpa.1`).get(); expect(purchase.get('priceAmount')).toBe(99); expect(purchase.get('itemName')).toBe('서포터 팩'); expect(purchase.get('method')).toBe('Google Play'); }); }); describe('POP 직접 판매 금지 (B-02)', () => { it('스위치가 꺼져 있으면(기본) POP 묶음 상품을 E-PAY-01 로 거부하고 지급하지 않는다', async () => { expect(await codeOf(() => call(UID, {}))).toBe('E-PAY-01'); expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false); expect((await db().doc(`users/${UID}/purchases/android_gpa.1`).get()).exists).toBe(false); }); it('popPackSalesEnabled 를 켜면 상품 표대로 지급한다', async () => { await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true }); expect((await call(UID, {})).granted).toBe(5_500); }); }); describe('Focus 구독 — 광고 제거 (B-04)', () => { const future = () => Date.now() + 30 * 86_400_000; it('만료가 미래면 구독을 켜고 만료 시각을 저장하며 POP 은 주지 않는다', async () => { const expires = future(); claim = { productId: 'focus.monthly', consumable: false, expiresAtMillis: expires }; const result = await call(UID, { productId: 'focus.monthly', transactionId: 'gpa.1' }); expect(result.subscription).toBe('active'); const sub = await db().doc(`users/${UID}/subscription/current`).get(); expect(sub.get('adsRemoved')).toBe(true); expect((sub.get('expiresAt') as Timestamp).toMillis()).toBe(expires); expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false); // iOS 영수증에서 이 거래를 고르도록 거래 ID 를 검증기에 넘긴다. expect(lastHint).toEqual({ productId: 'focus.monthly', transactionId: 'gpa.1' }); }); it('만료가 지났거나 없으면 활성으로 보지 않는다', async () => { claim = { productId: 'focus.monthly', consumable: false, expiresAtMillis: Date.now() - 1000 }; expect((await call(UID, {})).subscription).toBe('expired'); const sub = await db().doc(`users/${UID}/subscription/current`).get(); expect(sub.get('adsRemoved')).toBe(false); }); }); describe('Focus 일일 Claim — 일 40 기여 POP · 당일 · 이월 없음 (B-04)', () => { const claimFocus = async () => await fnFocusDailyClaim.run( { data: {}, auth: { uid: UID, token: {} } } as never, ) as Record; const subscribe = (expiresAtMillis: number, state = 'active') => db().doc(`users/${UID}/subscription/current`).set({ state, adsRemoved: true, expiresAt: Timestamp.fromMillis(expiresAtMillis), }); it('구독 중이면 하루 한 번 40 기여 POP 을 주고 같은 날 재요청은 첫 결과다', async () => { await subscribe(Date.now() + 86_400_000); const first = await claimFocus(); expect(first.granted).toBe(40); const again = await claimFocus(); expect(again.duplicate).toBe(true); expect(again.txId).toBe(first.txId); const wallet = await db().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('supportBalance')).toBe(40); const sub = await db().doc(`users/${UID}/subscription/current`).get(); expect(sub.get('focusClaimedDay')).toBe(first.day); // C-31 — 구독 보너스는 성장 반영을 자동 적용하지 않는다. expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(0); }); it('해지했어도 만료 전이면 받는다', async () => { await subscribe(Date.now() + 86_400_000, 'canceled'); expect((await claimFocus()).granted).toBe(40); }); it('구독이 없거나 만료됐으면 E-PAY-03 이고 지급하지 않는다', async () => { expect(await codeOf(claimFocus)).toBe('E-PAY-03'); await subscribe(Date.now() - 1000); expect(await codeOf(claimFocus)).toBe('E-PAY-03'); expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false); }); it('운영값 focusDailyPop 을 따른다', async () => { await db().doc('config/policy').set({ focusDailyPop: 55 }, { merge: true }); await subscribe(Date.now() + 86_400_000); expect((await claimFocus()).granted).toBe(55); await db().doc('config/policy').set({ focusDailyPop: 40 }, { merge: true }); }); }); describe('검증 구현이 비어 있는 동안', () => { it('콜러블도 지급 없이 E-PAY-02 로 끊는다', async () => { setReceiptVerifier(new StoreReceiptVerifier()); expect(await codeOf(() => call(UID, {}))).toBe('E-PAY-02'); expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false); }); });