176 lines
6.7 KiB
TypeScript
176 lines
6.7 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 } 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<ReceiptClaim> = {}) {}
|
|
|
|
async verify(platform: Platform, _receipt: string): Promise<ReceiptClaim> {
|
|
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();
|
|
});
|
|
});
|