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 } from '../src/ledger/ledger.js'; import { popForProduct, PRODUCT_POP, SUBSCRIPTION_PRODUCTS, type Platform, type ReceiptClaim, type ReceiptVerifier, } from '../src/payment/receipt.js'; import { StoreReceiptVerifier } from '../src/payment/receipt.js'; /** * 결제 검증 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」. * * **스토어 왕복은 여기서 재지 않는다.** 자격증명이 스토어 계정에 묶여 있어 * 샌드박스 실행은 계정이 열린 뒤다. 대신 **금액 결정 · 멱등 · 환불 회수**를 * 지금 검증한다 — 계정이 열려도 이 세 가지가 틀리면 검수를 통과하지 못한다. */ const UID = 'test-pay-user'; class FakeVerifier implements ReceiptVerifier { constructor(private readonly claim: Partial = {}) {} async verify(platform: Platform, _receipt: string): Promise { return { platform, transactionId: 'tx-1', productId: 'pop.support.5500', consumable: true, purchasedAtMillis: 1_788_000_000_000, revoked: false, ...this.claim, }; } } beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { await wipeUser(UID); }); describe('상품 표 — 금액은 서버가 정한다', () => { it('등록된 상품만 지급 수량을 낸다', () => { expect(popForProduct('pop.support.5500')).toBe(5_500); }); it('표에 없는 상품 ID 는 E-PAY-01 로 끊는다', () => { // 위조이거나 스토어 상품 등록과 코드가 어긋난 상태다. 둘 다 지급하면 안 된다. expect(() => popForProduct('pop.support.999999')) .toThrowError(expect.objectContaining({ details: { code: 'E-PAY-01' } })); }); it('구독 상품은 POP 표에 없다', () => { for (const product of SUBSCRIPTION_PRODUCTS) { expect(PRODUCT_POP[product]).toBeUndefined(); } }); it('모든 상품 수량이 양의 정수다', () => { for (const amount of Object.values(PRODUCT_POP)) { expect(Number.isInteger(amount)).toBe(true); expect(amount).toBeGreaterThan(0); } }); }); describe('검증 구현이 비어 있는 동안', () => { it('자격증명 없이 부르면 지급하지 않고 E-PAY-02 로 끊는다', async () => { // 「나중에 채우자」로 비워 두면 검증 없는 지급 경로가 열린 채 배포된다. await expect(new StoreReceiptVerifier().verify('ios', 'receipt')) .rejects.toMatchObject({ details: { code: 'E-PAY-02' } }); }); }); describe('지급 — 스토어 거래 ID 가 멱등키다', () => { it('같은 거래로 두 번 지급하지 않는다', async () => { const claim = await new FakeVerifier().verify('ios', 'r'); const key = `iap:${claim.platform}:${claim.transactionId}`; const first = await grantPop({ uid: UID, popType: 'support', amount: popForProduct(claim.productId), reasonCode: 'IAP_PURCHASE', idempotencyKey: key, }); const second = await grantPop({ uid: UID, popType: 'support', amount: popForProduct(claim.productId), reasonCode: 'IAP_PURCHASE', idempotencyKey: key, }); expect(second.txId).toBe(first.txId); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('supportBalance')).toBe(5_500); }); it('플랫폼이 다르면 다른 거래로 본다', async () => { const ios = await new FakeVerifier().verify('ios', 'r'); const android = await new FakeVerifier().verify('android', 'r'); expect(`iap:${ios.platform}:${ios.transactionId}`) .not.toBe(`iap:${android.platform}:${android.transactionId}`); }); it('지원 POP 으로만 들어가고 활동 POP 은 그대로다', async () => { await grantPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-a', }); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('supportBalance')).toBe(5_500); expect(wallet.get('activityBalance')).toBe(0); }); }); describe('환불 회수 — 별첨 1 ⑥ 예외 처리', () => { it('잔액이 남아 있으면 회수한다', async () => { await grantPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-b', }); const clawed = await spendPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-b', }); expect(clawed.balanceAfter).toBe(0); }); it('이미 써 버려 잔액이 모자라면 음수 잔액을 만들지 않는다', async () => { await grantPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-c', }); await spendPop({ uid: UID, popType: 'support', amount: 5_000, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:pay-1', }); // 사용자는 쓴 적 없는 마이너스를 보면 안 되고, 원장 합과 잔액이 어긋나서도 안 된다. await expect(spendPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-c', })).rejects.toMatchObject({ details: { code: 'E-POP-01' } }); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('supportBalance')).toBe(500); }); it('회수도 멱등이다 — 스토어 알림은 중복 도착한다', async () => { await grantPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-d', }); const key = 'clawback:ios:tx-d'; await spendPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: key, }); const again = await spendPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: key, }); expect(again.balanceAfter).toBe(0); }); it('회수는 기여 통계를 건드리지 않는다', async () => { await grantPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-e', }); await spendPop({ uid: UID, popType: 'support', amount: 5_500, reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-e', }); const user = await getFirestore().doc(`users/${UID}`).get(); // 결제 회수는 팬덤 기여가 아니다. expect(user.get('totalContributedPop')).toBeUndefined(); }); });