Skip to content

Commit a6b6f12

Browse files
feat(ramp): expose order observation API (Phase 9)
Phase 9 (MMPay TPC dependency) - New imperative `awaitOrderTerminalState(orderId, { timeoutMs?, pollIntervalMs?, walletAddress? })` in `headless/orderTerminalState.ts` resolves with the `RampsOrder` once its status reaches `Completed | Failed | Cancelled | IdExpired`. Drives a redux subscription as the fast path and self-polls `RampsController.getOrder` as the slow path so it is not coupled to the unified order processor's `<FiatOrders />` mount lifecycle. - New imperative `getOrder` and `refreshOrder(stringOrOrder)` siblings in the same module — controllers can call them without going through React. `useHeadlessBuy()` exposes thin passthroughs on the same names for React consumers; `getOrderById` is preserved (deprecated) for back-compat. - Two typed errors with Hermes-safe `Object.setPrototypeOf` fixes: `OrderTerminalStateTimeoutError` and `RefreshOrderUnresolvableError`. - Playground gets an Order tracking panel after `onOrderCreated` — surfaces `orderId`, current status (live from `getOrder`), and Refresh / Await terminal state actions wired to the new API. CHANGELOG entry: null
1 parent 6461151 commit a6b6f12

10 files changed

Lines changed: 1232 additions & 17 deletions

File tree

app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.styles.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,28 @@ const styleSheet = (params: { theme: Theme }) => {
215215
paddingVertical: 4,
216216
paddingHorizontal: 0,
217217
},
218+
orderTrackingSection: {
219+
marginTop: 12,
220+
paddingVertical: 10,
221+
paddingHorizontal: 12,
222+
borderRadius: 8,
223+
backgroundColor: theme.colors.background.alternative,
224+
},
225+
orderTrackingTitle: {
226+
marginBottom: 8,
227+
},
228+
orderTrackingRow: {
229+
paddingVertical: 4,
230+
},
231+
orderTrackingActions: {
232+
flexDirection: 'row',
233+
flexWrap: 'wrap',
234+
gap: 8,
235+
marginTop: 8,
236+
},
237+
orderTrackingBadge: {
238+
marginTop: 8,
239+
},
218240
});
219241
};
220242

app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import HeadlessPlayground, {
2626
HEADLESS_PLAYGROUND_RESET_ASSET_TEST_ID,
2727
HEADLESS_PLAYGROUND_RESET_PAYMENT_METHOD_TEST_ID,
2828
HEADLESS_PLAYGROUND_RESET_PROVIDER_TEST_ID,
29+
HEADLESS_PLAYGROUND_ORDER_TRACKING_AWAIT_TEST_ID,
30+
HEADLESS_PLAYGROUND_ORDER_TRACKING_REFRESH_TEST_ID,
31+
HEADLESS_PLAYGROUND_ORDER_TRACKING_SECTION_TEST_ID,
32+
HEADLESS_PLAYGROUND_ORDER_TRACKING_STATUS_BADGE_TEST_ID,
2933
HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID,
3034
HEADLESS_PLAYGROUND_SUMMARY_DIVIDER_TEST_ID,
3135
HEADLESS_PLAYGROUND_SUMMARY_TEST_ID,
@@ -155,6 +159,10 @@ const mockUseRampsControllerInitialValues: ReturnType<
155159

156160
let mockUseRampsControllerValues = mockUseRampsControllerInitialValues;
157161

162+
const mockGetOrder = jest.fn();
163+
const mockRefreshOrder = jest.fn();
164+
const mockAwaitOrderTerminalState = jest.fn();
165+
158166
const mockUseHeadlessBuyInitialValues: ReturnType<typeof useHeadlessBuy> = {
159167
userRegion: mockUserRegion,
160168
providers: mockProviders,
@@ -163,6 +171,9 @@ const mockUseHeadlessBuyInitialValues: ReturnType<typeof useHeadlessBuy> = {
163171
tokens: { topTokens: mockTokens, allTokens: mockTokens },
164172
orders: [],
165173
getOrderById: mockGetOrderById,
174+
getOrder: mockGetOrder,
175+
refreshOrder: mockRefreshOrder,
176+
awaitOrderTerminalState: mockAwaitOrderTerminalState,
166177
getQuotes: mockGetQuotes,
167178
startHeadlessBuy: mockStartHeadlessBuy,
168179
isLoading: false,
@@ -1182,5 +1193,186 @@ describe('HeadlessPlayground', () => {
11821193
screen.queryByTestId(HEADLESS_PLAYGROUND_CANCEL_BUTTON_TEST_ID),
11831194
).not.toBeOnTheScreen();
11841195
});
1196+
1197+
describe('Phase 9 order tracking panel', () => {
1198+
it('does not render the order tracking panel before onOrderCreated fires', async () => {
1199+
await renderWithQuotes();
1200+
expect(
1201+
screen.queryByTestId(
1202+
HEADLESS_PLAYGROUND_ORDER_TRACKING_SECTION_TEST_ID,
1203+
),
1204+
).not.toBeOnTheScreen();
1205+
});
1206+
1207+
it('renders order id, status, and refresh/await actions after onOrderCreated', async () => {
1208+
mockGetOrder.mockReturnValue({
1209+
providerOrderId: 'order-xyz',
1210+
status: 'PENDING',
1211+
provider: { id: '/providers/moonpay' },
1212+
walletAddress: '0xWALLET',
1213+
});
1214+
await renderWithQuotes();
1215+
fireEvent.press(
1216+
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
1217+
);
1218+
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
1219+
onOrderCreated: (orderId: string) => void;
1220+
};
1221+
act(() => {
1222+
callbacks.onOrderCreated('order-xyz');
1223+
});
1224+
expect(
1225+
screen.getByTestId(
1226+
HEADLESS_PLAYGROUND_ORDER_TRACKING_SECTION_TEST_ID,
1227+
),
1228+
).toBeOnTheScreen();
1229+
expect(screen.getByText('order-xyz')).toBeOnTheScreen();
1230+
expect(screen.getByText('PENDING')).toBeOnTheScreen();
1231+
});
1232+
1233+
it('renders the (not yet in state) status placeholder when getOrder returns undefined', async () => {
1234+
mockGetOrder.mockReturnValue(undefined);
1235+
await renderWithQuotes();
1236+
fireEvent.press(
1237+
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
1238+
);
1239+
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
1240+
onOrderCreated: (orderId: string) => void;
1241+
};
1242+
act(() => {
1243+
callbacks.onOrderCreated('order-xyz');
1244+
});
1245+
expect(screen.getByText(/not yet in state/i)).toBeOnTheScreen();
1246+
});
1247+
1248+
it('disables the refresh button when the order is not yet in state', async () => {
1249+
mockGetOrder.mockReturnValue(undefined);
1250+
await renderWithQuotes();
1251+
fireEvent.press(
1252+
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
1253+
);
1254+
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
1255+
onOrderCreated: (orderId: string) => void;
1256+
};
1257+
act(() => {
1258+
callbacks.onOrderCreated('order-xyz');
1259+
});
1260+
const refreshButton = screen.getByTestId(
1261+
HEADLESS_PLAYGROUND_ORDER_TRACKING_REFRESH_TEST_ID,
1262+
);
1263+
expect(refreshButton.props.accessibilityState?.disabled).toBe(true);
1264+
});
1265+
1266+
it('calls refreshOrder when the user taps the refresh button', async () => {
1267+
mockGetOrder.mockReturnValue({
1268+
providerOrderId: 'order-xyz',
1269+
status: 'PENDING',
1270+
provider: { id: '/providers/moonpay' },
1271+
walletAddress: '0xWALLET',
1272+
});
1273+
mockRefreshOrder.mockResolvedValue({
1274+
providerOrderId: 'order-xyz',
1275+
status: 'COMPLETED',
1276+
});
1277+
await renderWithQuotes();
1278+
fireEvent.press(
1279+
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
1280+
);
1281+
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
1282+
onOrderCreated: (orderId: string) => void;
1283+
};
1284+
act(() => {
1285+
callbacks.onOrderCreated('order-xyz');
1286+
});
1287+
await act(async () => {
1288+
fireEvent.press(
1289+
screen.getByTestId(
1290+
HEADLESS_PLAYGROUND_ORDER_TRACKING_REFRESH_TEST_ID,
1291+
),
1292+
);
1293+
});
1294+
expect(mockRefreshOrder).toHaveBeenCalledWith('order-xyz');
1295+
});
1296+
1297+
it('renders an "Awaiting…" badge while awaitOrderTerminalState is in flight', async () => {
1298+
mockGetOrder.mockReturnValue({
1299+
providerOrderId: 'order-xyz',
1300+
status: 'PENDING',
1301+
provider: { id: '/providers/moonpay' },
1302+
walletAddress: '0xWALLET',
1303+
});
1304+
let resolveAwait: ((order: unknown) => void) | undefined;
1305+
mockAwaitOrderTerminalState.mockImplementation(
1306+
() =>
1307+
new Promise((resolve) => {
1308+
resolveAwait = resolve;
1309+
}),
1310+
);
1311+
await renderWithQuotes();
1312+
fireEvent.press(
1313+
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
1314+
);
1315+
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
1316+
onOrderCreated: (orderId: string) => void;
1317+
};
1318+
act(() => {
1319+
callbacks.onOrderCreated('order-xyz');
1320+
});
1321+
await act(async () => {
1322+
fireEvent.press(
1323+
screen.getByTestId(
1324+
HEADLESS_PLAYGROUND_ORDER_TRACKING_AWAIT_TEST_ID,
1325+
),
1326+
);
1327+
});
1328+
expect(
1329+
screen.getByTestId(
1330+
HEADLESS_PLAYGROUND_ORDER_TRACKING_STATUS_BADGE_TEST_ID,
1331+
),
1332+
).toBeOnTheScreen();
1333+
expect(screen.getByText(/Awaiting/i)).toBeOnTheScreen();
1334+
1335+
// Resolve the promise to keep the test isolation clean.
1336+
await act(async () => {
1337+
resolveAwait?.({
1338+
providerOrderId: 'order-xyz',
1339+
status: 'COMPLETED',
1340+
});
1341+
});
1342+
expect(screen.getByText(/Terminal state reached/i)).toBeOnTheScreen();
1343+
});
1344+
1345+
it('renders a timed-out badge when awaitOrderTerminalState rejects with OrderTerminalStateTimeoutError', async () => {
1346+
const { OrderTerminalStateTimeoutError } =
1347+
jest.requireActual('../../headless');
1348+
mockGetOrder.mockReturnValue({
1349+
providerOrderId: 'order-xyz',
1350+
status: 'PENDING',
1351+
provider: { id: '/providers/moonpay' },
1352+
walletAddress: '0xWALLET',
1353+
});
1354+
mockAwaitOrderTerminalState.mockRejectedValue(
1355+
new OrderTerminalStateTimeoutError('boom'),
1356+
);
1357+
await renderWithQuotes();
1358+
fireEvent.press(
1359+
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
1360+
);
1361+
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
1362+
onOrderCreated: (orderId: string) => void;
1363+
};
1364+
act(() => {
1365+
callbacks.onOrderCreated('order-xyz');
1366+
});
1367+
await act(async () => {
1368+
fireEvent.press(
1369+
screen.getByTestId(
1370+
HEADLESS_PLAYGROUND_ORDER_TRACKING_AWAIT_TEST_ID,
1371+
),
1372+
);
1373+
});
1374+
expect(screen.getByText(/Timed out/i)).toBeOnTheScreen();
1375+
});
1376+
});
11851377
});
11861378
});

0 commit comments

Comments
 (0)