Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -789,19 +789,50 @@ describe('CreditNativeIapOwner', () => {
expect(mockedIap.fetchProducts).toHaveBeenCalledTimes(2);
});

it('shows the store-unavailable copy when the second ask also fails', async () => {
it('shows the connection copy when the second ask also fails', async () => {
mockedIap.connected = true;
mockedIap.fetchProducts.mockRejectedValue(notReady);
const { handle } = await mountOwner();

const failure = await loadPlayCatalog(handle).catch((error: unknown) => error);

expect(failure).toBeInstanceOf(StoreCreditProductsError);
expect(failure).toMatchObject({ key: 'credits.noMatchingProductsPlay' });
expect(failure).toMatchObject({ key: 'kiloPass.couldNotConnectToPlay' });
expect(mockedIap.reconnect).toHaveBeenCalledTimes(1);
expect(mockedIap.fetchProducts).toHaveBeenCalledTimes(2);
});

it('does not ask again once the caller has given up on the store', async () => {
mockedIap.connected = true;
mockedIap.fetchProducts.mockRejectedValue(notReady);
const { handle } = await mountOwner();
const controller = new AbortController();
controller.abort();

await expect(
handle.value?.fetchStoreProducts(['credits_usd10'], { signal: controller.signal })
).rejects.toBe(notReady);
expect(mockedIap.reconnect).not.toHaveBeenCalled();
expect(mockedIap.fetchProducts).toHaveBeenCalledTimes(1);
});

it('does not ask again when the caller gives up during the reconnect', async () => {
mockedIap.connected = true;
mockedIap.fetchProducts.mockRejectedValue(notReady);
const { handle } = await mountOwner();
const controller = new AbortController();
// eslint-disable-next-line eslint/require-await -- the abort is the behavior under test; reconnect answers at once
mockedIap.reconnect.mockImplementation(async () => {
controller.abort();
return true;
});

await expect(
handle.value?.fetchStoreProducts(['credits_usd10'], { signal: controller.signal })
).rejects.toBe(notReady);
expect(mockedIap.fetchProducts).toHaveBeenCalledTimes(1);
});

it('does not ask again when the reconnect fails', async () => {
mockedIap.connected = true;
mockedIap.reconnect.mockResolvedValue(false);
Expand Down
25 changes: 22 additions & 3 deletions apps/mobile/src/components/credits/credit-native-iap-owner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { currentAuthEpoch, isCurrentAuthEpoch } from '@/lib/auth/auth-epoch';
import {
type StoreCreditProduct,
type StoreCreditProductListing,
type StoreProductFetchOptions,
} from '@/lib/credits/store-products';
import { getCreditStorefront } from '@/lib/credits/storefront';
import { fetchPendingStorePurchases } from '@/lib/iap/pending-store-purchases';
Expand Down Expand Up @@ -67,7 +68,10 @@ async function fetchCreditStoreProducts(

export type CreditNativeIapContextValue = {
connected: boolean;
fetchStoreProducts: (productSkus: string[]) => Promise<readonly StoreCreditProductListing[]>;
fetchStoreProducts: (
productSkus: string[],
options?: StoreProductFetchOptions
) => Promise<readonly StoreCreditProductListing[]>;
/**
* Restores the store connection after a failed initialization. expo-iap
* removes its purchase-update listeners on that failure, and only the hook's
Expand Down Expand Up @@ -565,12 +569,27 @@ export function CreditNativeIapOwner({ children }: { children: ReactNode }) {
// once more, so one dropped connection is not reported as a store outage. A
// reconnect that fails, or a second failure, reaches the loader, which shows
// the store-unavailable copy.
//
// The ask once more is skipped once the caller's deadline has settled this
// call: the store SDK has no cancellation, so the first call can surface a
// stale failure long after the screen gave up, and a store that stayed
// silent for the whole deadline stays silent for a retry too. Checked before
// and after the reconnect, which is itself a store call.
const fetchStoreProducts = useCallback(
async (productSkus: string[]): Promise<StoreCreditProductListing[]> => {
async (
productSkus: string[],
options?: StoreProductFetchOptions
): Promise<StoreCreditProductListing[]> => {
const signal = options?.signal;
try {
return await fetchCreditStoreProducts(productSkus);
} catch (error) {
if (!isStoreNotReadyError(error) || !(await reconnectStore())) {
if (
signal?.aborted ||
!isStoreNotReadyError(error) ||
!(await reconnectStore()) ||
signal?.aborted
) {
throw error;
}
return fetchCreditStoreProducts(productSkus);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,18 @@ describe('CreditPurchaseScreen', () => {
await waitFor(() => allText(renderer).includes('App Store products unavailable'));

expect(packRows(renderer)).toHaveLength(4);
expect(allText(renderer)).toContain('No matching credit packs were returned by App Store.');
expect(allText(renderer)).toContain(
'Could not connect to the App Store. Check your connection and try again.'
);
// The iOS card names the App Store for both its title and its body; the
// Google Play copy must never leak onto it.
expect(allText(renderer)).not.toContain('Google Play products unavailable');
expect(allText(renderer)).not.toContain(
'No matching credit packs were returned by Google Play.'
'Could not connect to Google Play. Check your connection and try again.'
);
// A store failure is not a catalog claim: the no-match body belongs to a
// store that answered and priced no pack.
expect(allText(renderer)).not.toContain('No matching credit packs were returned by App Store.');
expect(allText(renderer)).toContain('Price unavailable');
// The store SDK's own wording never reaches the screen.
expect(allText(renderer)).not.toContain('Failed to query product');
Expand All @@ -313,10 +318,60 @@ describe('CreditPurchaseScreen', () => {
const { renderer, unmount } = await renderWithProviders(createElement(CreditPurchaseScreen));
await waitFor(() => allText(renderer).includes('Google Play products unavailable'));

expect(allText(renderer)).toContain('No matching credit packs were returned by Google Play.');
expect(allText(renderer)).toContain(
'Could not connect to Google Play. Check your connection and try again.'
);
// The App Store copy must never leak onto the Android card.
expect(allText(renderer)).not.toContain('App Store products unavailable');
expect(allText(renderer)).not.toContain('No matching credit packs were returned by App Store.');
expect(allText(renderer)).not.toContain(
'Could not connect to the App Store. Check your connection and try again.'
);
expect(packRows(renderer)).toHaveLength(4);
unmount();
});

it('timeout: a store that never answers shows the timeout copy, not a catalog claim', async () => {
mockedPlatform.OS = 'android';
owner.fetchStoreProducts.mockImplementation(
// eslint-disable-next-line @typescript-eslint/promise-function-async -- the store never answers, which is the scenario under test
() => new Promise<never>(() => undefined)
);
vi.useFakeTimers();
try {
const { renderer, unmount } = await renderWithProviders(createElement(CreditPurchaseScreen));
// Let the mount reach the store call and arm the loader's deadline.
await settleTimers(() => owner.fetchStoreProducts.mock.calls.length === 1);
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});
await settleTimers(() => allText(renderer).includes('Google Play products unavailable'));

expect(allText(renderer)).toContain('Google Play products unavailable');
expect(allText(renderer)).toContain("Google Play didn't respond in time. Try again.");
expect(allText(renderer)).not.toContain(
'No matching credit packs were returned by Google Play.'
);
expect(allText(renderer)).not.toContain(
'Could not connect to Google Play. Check your connection and try again.'
);
// The backend amounts stay on screen, unpriced, with a retry to offer.
expect(packRows(renderer)).toHaveLength(4);
expect(allText(renderer)).toContain('Price unavailable');
unmount();
} finally {
vi.useRealTimers();
}
});

it('no match: a store that answers without pricing a pack keeps the catalog claim', async () => {
mockedPlatform.OS = 'android';
owner.fetchStoreProducts.mockResolvedValue([{ id: 'credits_usd250', displayPrice: '$249.99' }]);

const { renderer, unmount } = await renderWithProviders(createElement(CreditPurchaseScreen));
await waitFor(() => allText(renderer).includes('Google Play products unavailable'));

expect(allText(renderer)).toContain('No matching credit packs were returned by Google Play.');
expect(allText(renderer)).not.toContain("Google Play didn't respond in time. Try again.");
expect(packRows(renderer)).toHaveLength(4);
unmount();
});
Expand Down Expand Up @@ -390,7 +445,9 @@ describe('CreditPurchaseScreen', () => {
expect(owner.fetchStoreProducts.mock.calls.length).toBe(2);
expect(packRows(renderer)).toHaveLength(4);
expect(allText(renderer)).toContain('App Store products unavailable');
expect(allText(renderer)).toContain('No matching credit packs were returned by App Store.');
expect(allText(renderer)).toContain(
'Could not connect to the App Store. Check your connection and try again.'
);
expect(allText(renderer)).toContain('Price unavailable');
expect(renderer.root.findAll(node => String(node.type) === 'Skeleton')).toHaveLength(0);
// The button that started the retry stays on screen, busy and disabled.
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/af.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/am.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/ar.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/az.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/be.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/bg.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/bn.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/bs.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/ca.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/ckb.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/cs.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading