Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user