Skip to content
Draft
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
15 changes: 15 additions & 0 deletions app/components/UI/Ramp/headless/useHeadlessBuy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,24 @@ describe('useHeadlessBuy', () => {
providers: ['/providers/transak-native'],
redirectUrl: 'https://callback.metamask.io',
forceRefresh: undefined,
autoSelectProvider: true,
});
});

it('triggers the shared controller pick via autoSelectProvider so the UI never ranks locally', async () => {
const { result } = renderHook(() => useHeadlessBuy());
await act(async () => {
await result.current.getQuotes({
assetId: 'eip155:1/erc20:0xabc',
amount: 10,
walletAddress: '0xOVERRIDE',
});
});
expect(mockGetQuotesRaw).toHaveBeenCalledWith(
expect.objectContaining({ autoSelectProvider: true }),
);
});

it('uses an explicit walletAddress override when provided', async () => {
const { result } = renderHook(() => useHeadlessBuy());
await act(async () => {
Expand Down
5 changes: 5 additions & 0 deletions app/components/UI/Ramp/headless/useHeadlessBuy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export function useHeadlessBuy(): HeadlessBuyResult {
providers: params.providerIds,
redirectUrl: params.redirectUrl ?? getRampCallbackBaseUrl(),
forceRefresh: params.forceRefresh,
// Trigger the controller's shared, scope-aware selection so the best
// quote lands at `success[0]` and headless consumers never rank or
// sort quotes locally (P2.M6). Ignored by the controller when an
// explicit `providers` list is passed.
autoSelectProvider: true,
});
},
[getQuotesRaw],
Expand Down
226 changes: 226 additions & 0 deletions app/components/UI/Ramp/hooks/useContinueWithQuote.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { renderHook, act } from '@testing-library/react-native';
import { useNavigation } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import { RampsOrderStatus } from '@metamask/ramps-controller';
import Routes from '../../../../constants/navigation/Routes';
import { useContinueWithQuote } from './useContinueWithQuote';
import {
createSession,
getSession,
setStatus,
__resetSessionRegistryForTests,
} from '../headless/sessionRegistry';
import type {
HeadlessBuyCallbacks,
HeadlessBuyParams,
} from '../headless/types';

jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
Expand Down Expand Up @@ -112,6 +123,8 @@ const mockNavigate = jest.fn();
const mockNavigationReset = jest.fn();
const mockGetBuyWidgetData = jest.fn();
const mockAddPrecreatedOrder = jest.fn();
const mockGetOrderFromCallback = jest.fn();
const mockAddOrder = jest.fn();
const mockCheckExistingToken = jest.fn();
const mockGetBuyQuote = jest.fn();
const mockRouteAfterAuth = jest.fn();
Expand Down Expand Up @@ -201,6 +214,8 @@ const buildController = (overrides: ControllerOverrides = {}) => ({
selectedPaymentMethod: SELECTED_PAYMENT_METHOD,
getBuyWidgetData: mockGetBuyWidgetData,
addPrecreatedOrder: mockAddPrecreatedOrder,
getOrderFromCallback: mockGetOrderFromCallback,
addOrder: mockAddOrder,
...overrides,
});

Expand Down Expand Up @@ -569,6 +584,217 @@ describe('useContinueWithQuote', () => {
});
});

describe('headless external-browser branch (P2.M1 / P2.M4)', () => {
const HEADLESS_ORDER = {
id: '/providers/moonpay/orders/ord-777',
providerOrderId: 'ord-777',
status: RampsOrderStatus.Pending,
};

const CUSTOM_ACTION_QUOTE = {
provider: 'paypal',
id: 'quote-paypal-1',
inputAmount: 100,
inputCurrency: 'USD',
outputAmount: '0.05',
outputCurrency: { symbol: 'ETH', assetId: 'eip155:1/slip44:60' },
quote: {
// No external-browser hint; the custom-action flag alone must route it
// through the external path.
isCustomAction: true,
buyURL: 'https://widget.example.com/checkout',
},
} as const;

let callbacks: {
onOrderCreated: jest.Mock;
onError: jest.Mock;
onClose: jest.Mock;
};
let sessionId: string;

const startSession = () => {
callbacks = {
onOrderCreated: jest.fn(),
onError: jest.fn(),
onClose: jest.fn(),
};
const session = createSession(
{
quote: WIDGET_PROVIDER_QUOTE as unknown as HeadlessBuyParams['quote'],
assetId: 'eip155:1/slip44:60',
amount: 100,
},
callbacks as unknown as HeadlessBuyCallbacks,
);
sessionId = session.id;
setStatus(sessionId, 'continued');
};

beforeEach(() => {
__resetSessionRegistryForTests();
mockDeviceIsAndroid.mockReturnValue(false);
mockInAppBrowser.isAvailable.mockResolvedValue(true);
mockGetBuyWidgetData.mockResolvedValue({
url: 'https://widget.example.com/checkout',
browser: 'IN_APP_OS_BROWSER',
});
startSession();
});

it('resolves the order and fires onOrderCreated + onClose(completed) on iOS openAuth success', async () => {
mockInAppBrowser.openAuth.mockResolvedValue({
type: 'success',
url: 'metamask://on-ramp/providers/moonpay?orderId=ord-777',
});
mockGetOrderFromCallback.mockResolvedValue(HEADLESS_ORDER);

const { result } = renderHook(() => useContinueWithQuote());
const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, {
...CTX,
headlessSessionId: sessionId,
});

expect(caught).toBeUndefined();
expect(mockGetOrderFromCallback).toHaveBeenCalledWith(
'moonpay',
'metamask://on-ramp/providers/moonpay?orderId=ord-777',
'0x1234567890123456789012345678901234567890',
);
expect(mockAddOrder).toHaveBeenCalledWith(HEADLESS_ORDER);
expect(callbacks.onOrderCreated).toHaveBeenCalledWith('ord-777');
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'completed' });
expect(callbacks.onError).not.toHaveBeenCalled();
// Never navigates to RAMPS_ORDER_DETAILS in headless mode.
expect(mockNavigationReset).not.toHaveBeenCalled();
expect(getSession(sessionId)).toBeUndefined();
});

it('fires onClose(user_dismissed) when the iOS auth browser is cancelled', async () => {
mockInAppBrowser.openAuth.mockResolvedValue({ type: 'cancel' });

const { result } = renderHook(() => useContinueWithQuote());
const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, {
...CTX,
headlessSessionId: sessionId,
});

expect(caught).toBeUndefined();
expect(callbacks.onClose).toHaveBeenCalledWith({
reason: 'user_dismissed',
});
expect(callbacks.onOrderCreated).not.toHaveBeenCalled();
expect(callbacks.onError).not.toHaveBeenCalled();
expect(mockNavigationReset).not.toHaveBeenCalled();
});

it('fires onClose(user_dismissed) when the resolved order is a bailed/placeholder order', async () => {
mockInAppBrowser.openAuth.mockResolvedValue({
type: 'success',
url: 'metamask://on-ramp/providers/moonpay',
});
mockGetOrderFromCallback.mockResolvedValue({
...HEADLESS_ORDER,
status: RampsOrderStatus.Precreated,
});

const { result } = renderHook(() => useContinueWithQuote());
const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, {
...CTX,
headlessSessionId: sessionId,
});

expect(caught).toBeUndefined();
expect(callbacks.onClose).toHaveBeenCalledWith({
reason: 'user_dismissed',
});
expect(callbacks.onOrderCreated).not.toHaveBeenCalled();
expect(mockAddOrder).not.toHaveBeenCalled();
});

it('fails the session when the callback order resolution throws', async () => {
mockInAppBrowser.openAuth.mockResolvedValue({
type: 'success',
url: 'metamask://on-ramp/providers/moonpay',
});
mockGetOrderFromCallback.mockRejectedValue(new Error('lookup failed'));

const { result } = renderHook(() => useContinueWithQuote());
const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, {
...CTX,
headlessSessionId: sessionId,
});

expect(caught).toBeUndefined();
expect(callbacks.onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'QUOTE_FAILED' }),
);
expect(callbacks.onOrderCreated).not.toHaveBeenCalled();
expect(callbacks.onClose).not.toHaveBeenCalled();
});

it('keeps the session live on Android/system-browser open success (deeplink return handles completion)', async () => {
mockDeviceIsAndroid.mockReturnValue(true);

const { result } = renderHook(() => useContinueWithQuote());
const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, {
...CTX,
headlessSessionId: sessionId,
});

expect(caught).toBeUndefined();
expect(mockLinkingOpenURL).toHaveBeenCalledWith(
'https://widget.example.com/checkout',
);
// No terminal callback and no BuildQuote reset: the session stays live.
expect(callbacks.onOrderCreated).not.toHaveBeenCalled();
expect(callbacks.onClose).not.toHaveBeenCalled();
expect(callbacks.onError).not.toHaveBeenCalled();
expect(mockNavigationReset).not.toHaveBeenCalled();
expect(getSession(sessionId)).toBeDefined();
});

it('throws (so the Host fails the session) when the external open is rejected', async () => {
mockDeviceIsAndroid.mockReturnValue(true);
mockLinkingOpenURL.mockRejectedValueOnce(new Error('cannot open url'));

const { result } = renderHook(() => useContinueWithQuote());
const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, {
...CTX,
headlessSessionId: sessionId,
});

expect(caught).toBeInstanceOf(Error);
expect(callbacks.onOrderCreated).not.toHaveBeenCalled();
expect(callbacks.onClose).not.toHaveBeenCalled();
});

it('routes a custom-action quote through the headless external path (P2.M4)', async () => {
mockInAppBrowser.openAuth.mockResolvedValue({
type: 'success',
url: 'metamask://on-ramp/providers/paypal?orderId=ord-777',
});
mockGetOrderFromCallback.mockResolvedValue(HEADLESS_ORDER);

const { result } = renderHook(() => useContinueWithQuote());
const caught = await invoke(result, CUSTOM_ACTION_QUOTE, {
...CTX,
headlessSessionId: sessionId,
});

expect(caught).toBeUndefined();
// Custom-action quotes always use the external path, so openAuth ran and
// the terminal order-created callback fired.
expect(mockInAppBrowser.openAuth).toHaveBeenCalled();
expect(mockGetOrderFromCallback).toHaveBeenCalledWith(
'paypal',
'metamask://on-ramp/providers/paypal?orderId=ord-777',
'0x1234567890123456789012345678901234567890',
);
expect(callbacks.onOrderCreated).toHaveBeenCalledWith('ord-777');
});
});

describe('context overrides (headless-ready)', () => {
// These tests prove that callers without controller-seeded state (the
// headless flow's Host) can drive `continueWithQuote` end-to-end by
Expand Down
Loading
Loading