262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
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<ReceiptClaim> = {};
|
|
let lastHint: ReceiptHint | undefined;
|
|
|
|
class FakeVerifier implements ReceiptVerifier {
|
|
async verify(platform: Platform, _receipt: string, hint?: ReceiptHint): Promise<ReceiptClaim> {
|
|
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<string, unknown>) {
|
|
return await fnIapVerify.run(
|
|
{ data: { platform: 'android', receipt: 'token', ...data }, auth: { uid, token: {} } } as never,
|
|
) as Record<string, unknown>;
|
|
}
|
|
|
|
async function codeOf(fn: () => Promise<unknown>): Promise<string | undefined> {
|
|
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<string, unknown>;
|
|
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);
|
|
});
|
|
});
|