mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
5462bce9c3
The plan picker sent buyers to the website, which the App Store does not allow. It now sells through StoreKit 2 in the TrekShell plugin (feature storeKit): App Store price strings in the buyer's currency, the purchase sheet, the signed transaction redeemed at the control plane, and the transaction finished only once the instance exists. A purchase whose redeem failed is redeemed again on retry, never bought twice. Restore purchases, the auto-renewal terms and the links to the terms of use and the privacy policy sit under the plans. Where no store purchase is possible (Android, an older binary) the picker shows the plans with no way to buy and no pointer to the web.
115 lines
5.0 KiB
TypeScript
115 lines
5.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const platform = vi.hoisted(() => ({ ios: true }));
|
|
const shell = vi.hoisted(() => ({
|
|
getCapabilities: vi.fn(),
|
|
storeProducts: vi.fn(),
|
|
storePurchase: vi.fn(),
|
|
storeFinish: vi.fn(),
|
|
storeRestore: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./platform', () => ({ isNativeIos: () => platform.ios }));
|
|
vi.mock('./trekShell', () => ({ TrekShell: shell }));
|
|
|
|
import { fakeIapClient, nativeIapClient, productIdFor } from './iap';
|
|
|
|
/** A signed transaction as far as the client reads it: only the payload matters here. */
|
|
function jwsFor(productId: string): string {
|
|
const payload = btoa(JSON.stringify({ productId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
return `header.${payload}.signature`;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
platform.ios = true;
|
|
for (const fn of Object.values(shell)) fn.mockReset();
|
|
});
|
|
|
|
describe('in-app purchase client', () => {
|
|
it('names store products by the shared scheme', () => {
|
|
expect(productIdFor('start', 'month')).toBe('cloud.start.monthly');
|
|
expect(productIdFor('pro', 'year')).toBe('cloud.pro.yearly');
|
|
});
|
|
|
|
it('the fake client yields a receipt for the product asked for', async () => {
|
|
expect(await fakeIapClient.isAvailable()).toBe(true);
|
|
const outcome = await fakeIapClient.purchase('cloud.plus.monthly');
|
|
expect(outcome.kind).toBe('bought');
|
|
if (outcome.kind !== 'bought') return;
|
|
expect(outcome.purchase.productId).toBe('cloud.plus.monthly');
|
|
expect(outcome.purchase.platform).toBe('apple');
|
|
expect(outcome.purchase.receipt).toContain('cloud.plus.monthly');
|
|
});
|
|
});
|
|
|
|
describe('native client', () => {
|
|
it('is available only on iOS with the storeKit feature', async () => {
|
|
shell.getCapabilities.mockResolvedValue({ features: ['storeKit'] });
|
|
expect(await nativeIapClient.isAvailable()).toBe(true);
|
|
|
|
shell.getCapabilities.mockResolvedValue({ features: ['widgets'] });
|
|
expect(await nativeIapClient.isAvailable()).toBe(false);
|
|
|
|
shell.getCapabilities.mockRejectedValue({ code: 'UNIMPLEMENTED' });
|
|
expect(await nativeIapClient.isAvailable()).toBe(false);
|
|
|
|
platform.ios = false;
|
|
shell.getCapabilities.mockResolvedValue({ features: ['storeKit'] });
|
|
expect(await nativeIapClient.isAvailable()).toBe(false);
|
|
});
|
|
|
|
it('maps the store price strings by product', async () => {
|
|
shell.storeProducts.mockResolvedValue({ products: [{ id: 'cloud.plus.monthly', displayPrice: '9,99 €' }] });
|
|
const prices = await nativeIapClient.prices(['cloud.plus.monthly', 'cloud.pro.monthly']);
|
|
expect(shell.storeProducts).toHaveBeenCalledWith({ ids: ['cloud.plus.monthly', 'cloud.pro.monthly'] });
|
|
expect(prices.get('cloud.plus.monthly')).toBe('9,99 €');
|
|
expect(prices.has('cloud.pro.monthly')).toBe(false);
|
|
|
|
shell.storeProducts.mockRejectedValue(new Error('down'));
|
|
expect((await nativeIapClient.prices(['cloud.plus.monthly'])).size).toBe(0);
|
|
});
|
|
|
|
it('turns a bought transaction into a receipt', async () => {
|
|
shell.storePurchase.mockResolvedValue({ kind: 'bought', jws: 'signed', transactionId: '42' });
|
|
expect(await nativeIapClient.purchase('cloud.plus.yearly')).toEqual({
|
|
kind: 'bought',
|
|
purchase: { platform: 'apple', productId: 'cloud.plus.yearly', receipt: 'signed', transactionId: '42' },
|
|
});
|
|
});
|
|
|
|
it('passes cancel and pending through, and fails on anything else', async () => {
|
|
shell.storePurchase.mockResolvedValue({ kind: 'cancelled' });
|
|
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'cancelled' });
|
|
shell.storePurchase.mockResolvedValue({ kind: 'pending' });
|
|
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'pending' });
|
|
shell.storePurchase.mockResolvedValue({ kind: 'bought' });
|
|
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'failed' });
|
|
shell.storePurchase.mockRejectedValue(new Error('boom'));
|
|
expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'failed' });
|
|
});
|
|
|
|
it('finishes by transaction id and swallows a failure', async () => {
|
|
const purchase = { platform: 'apple' as const, productId: 'cloud.plus.monthly', receipt: 'x', transactionId: '7' };
|
|
shell.storeFinish.mockResolvedValue(undefined);
|
|
await nativeIapClient.finish(purchase);
|
|
expect(shell.storeFinish).toHaveBeenCalledWith({ transactionId: '7' });
|
|
shell.storeFinish.mockRejectedValue(new Error('gone'));
|
|
await expect(nativeIapClient.finish(purchase)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('restores the subscription the Apple ID holds, with its product', async () => {
|
|
const jws = jwsFor('cloud.pro.yearly');
|
|
shell.storeRestore.mockResolvedValue({ jws, transactionId: '9' });
|
|
expect(await nativeIapClient.restore()).toEqual({
|
|
platform: 'apple',
|
|
productId: 'cloud.pro.yearly',
|
|
receipt: jws,
|
|
transactionId: '9',
|
|
});
|
|
expect(shell.storeRestore).toHaveBeenCalledWith({ prefix: 'cloud.' });
|
|
|
|
shell.storeRestore.mockResolvedValue({});
|
|
expect(await nativeIapClient.restore()).toBeNull();
|
|
});
|
|
});
|