Skip to content

Commit

Permalink
Fund button - setupOnrampEventListeners util for setting up onSuccess…
Browse files Browse the repository at this point in the history
…, onEvent and onExit callbacks (#1626)
  • Loading branch information
rustam-cb authored Nov 19, 2024
1 parent 82f9667 commit 77a31d7
Show file tree
Hide file tree
Showing 7 changed files with 418 additions and 1 deletion.
3 changes: 2 additions & 1 deletion src/fund/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const DEFAULT_ONRAMP_URL = 'https://pay.coinbase.com';
// The base URL for the Coinbase Onramp widget.
export const ONRAMP_BUY_URL = 'https://pay.coinbase.com/buy';
export const ONRAMP_BUY_URL = `${DEFAULT_ONRAMP_URL}/buy`;
// The recommended height of a Coinbase Onramp popup window.
export const ONRAMP_POPUP_HEIGHT = 720;
// The recommended width of a Coinbase Onramp popup window.
Expand Down
2 changes: 2 additions & 0 deletions src/fund/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export { FundButton } from './components/FundButton';
export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl';
export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl';
export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners';

export type {
GetOnrampUrlWithProjectIdParams,
GetOnrampUrlWithSessionTokenParams,
Expand Down
69 changes: 69 additions & 0 deletions src/fund/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,72 @@ export type FundButtonReact = {
rel?: string; // Specifies the relationship between the current document and the linked document
target?: string; // Where to open the target if `openIn` is set to tab
};

/**
* Matches a JSON object.
* This type can be useful to enforce some input to be JSON-compatible or as a super-type to be extended from. Don't use this as a direct return type as the user would have to double-cast it: `jsonObject as unknown as CustomResponse`. Instead, you could extend your CustomResponse type from it to ensure your type only uses JSON-compatible types: `interface CustomResponse extends JsonObject { … }`.
* @category JSON
*/
export type JsonObject = { [Key in string]?: JsonValue };

/**
* Matches a JSON array.
* @category JSON
*/
export type JsonArray = JsonValue[];

/**
* Matches any valid JSON primitive value.
* @category JSON
*/
export type JsonPrimitive = string | number | boolean | null;

/**
* Matches any valid JSON value.
* @see `Jsonify` if you need to transform a type to one that is assignable to `JsonValue`.
* @category JSON
*/
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;

export type OpenEvent = {
eventName: 'open';
widgetName: string;
};

export type TransitionViewEvent = {
eventName: 'transition_view';
pageRoute: string;
};

export type PublicErrorEvent = {
eventName: 'error';
error: OnRampError;
};

export type ExitEvent = {
eventName: 'exit';
error?: OnRampError;
};

export type SuccessEvent = {
eventName: 'success';
};

export type RequestOpenUrlEvent = {
eventName: 'request_open_url';
url: string;
};

export type EventMetadata =
| OpenEvent
| TransitionViewEvent
| PublicErrorEvent
| ExitEvent
| SuccessEvent
| RequestOpenUrlEvent;

export type OnRampError = {
errorType: 'internal_error' | 'handled_error' | 'network_error';
code?: string;
debugMessage?: string;
};
110 changes: 110 additions & 0 deletions src/fund/utils/setupOnrampEventListeners.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EventMetadata } from '../types';
import { setupOnrampEventListeners } from './setupOnrampEventListeners';
import { subscribeToWindowMessage } from './subscribeToWindowMessage';

vi.mock('./subscribeToWindowMessage', () => ({
subscribeToWindowMessage: vi.fn(),
}));

describe('setupOnrampEventListeners', () => {
let unsubscribe: ReturnType<typeof vi.fn>;

beforeEach(() => {
unsubscribe = vi.fn();
});

afterEach(() => {
vi.resetAllMocks();
});

it('should call subscribeToWindowMessage with correct parameters', () => {
const onEvent = vi.fn();
const onExit = vi.fn();
const onSuccess = vi.fn();
const host = 'https://example.com';

setupOnrampEventListeners({ onEvent, onExit, onSuccess, host });

expect(subscribeToWindowMessage).toHaveBeenCalledWith({
allowedOrigin: host,
onMessage: expect.any(Function),
});
});

it('should call onSuccess callback when success event is received', () => {
const onEvent = vi.fn();
const onExit = vi.fn();
const onSuccess = vi.fn();
const host = 'https://example.com';

setupOnrampEventListeners({ onEvent, onExit, onSuccess, host });

const eventMetadata: EventMetadata = { eventName: 'success' };

vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage(
eventMetadata,
);

expect(onSuccess).toHaveBeenCalled();
expect(onExit).not.toHaveBeenCalled();
expect(onEvent).toHaveBeenCalledWith(eventMetadata);
});

it('should call onExit callback when exit event is received', () => {
const onEvent = vi.fn();
const onExit = vi.fn();
const onSuccess = vi.fn();
const host = 'https://example.com';

setupOnrampEventListeners({ onEvent, onExit, onSuccess, host });

const eventMetadata: EventMetadata = {
eventName: 'exit',
error: {
errorType: 'internal_error',
},
};
vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage(
eventMetadata,
);

expect(onExit).toHaveBeenCalledWith(eventMetadata.error);
expect(onSuccess).not.toHaveBeenCalled();
expect(onEvent).toHaveBeenCalledWith(eventMetadata);
});

it('should call onEvent callback for any event received', () => {
const onEvent = vi.fn();
const onExit = vi.fn();
const onSuccess = vi.fn();
const host = 'https://example.com';

setupOnrampEventListeners({ onEvent, onExit, onSuccess, host });

const eventMetadata: EventMetadata = { eventName: 'success' };
vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage(
eventMetadata,
);

expect(onEvent).toHaveBeenCalledWith(eventMetadata);
});

it('should return the unsubscribe function', () => {
const onEvent = vi.fn();
const onExit = vi.fn();
const onSuccess = vi.fn();
const host = 'https://example.com';

vi.mocked(subscribeToWindowMessage).mockReturnValue(unsubscribe);

const result = setupOnrampEventListeners({
onEvent,
onExit,
onSuccess,
host,
});

expect(result).toBe(unsubscribe);
});
});
41 changes: 41 additions & 0 deletions src/fund/utils/setupOnrampEventListeners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { DEFAULT_ONRAMP_URL } from '../constants';
import type { EventMetadata, OnRampError } from '../types';
import { subscribeToWindowMessage } from './subscribeToWindowMessage';

type SetupOnrampEventListenersParams = {
host?: string;
onSuccess?: () => void;
onExit?: (error?: OnRampError) => void;
onEvent?: (event: EventMetadata) => void;
};

/**
* Subscribes to events from the Coinbase Onramp widget.
* @param onEvent - Callback for when any event is received.
* @param onExit - Callback for when an exit event is received.
* @param onSuccess - Callback for when a success event is received.
* @returns a function to unsubscribe from the event listener.
*/
export function setupOnrampEventListeners({
onEvent,
onExit,
onSuccess,
host = DEFAULT_ONRAMP_URL,
}: SetupOnrampEventListenersParams) {
const unsubscribe = subscribeToWindowMessage({
allowedOrigin: host,
onMessage: (data) => {
const metadata = data as EventMetadata;

if (metadata.eventName === 'success') {
onSuccess?.();
}
if (metadata.eventName === 'exit') {
onExit?.(metadata.error);
}
onEvent?.(metadata);
},
});

return unsubscribe;
}
122 changes: 122 additions & 0 deletions src/fund/utils/subscribeToWindowMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MessageCodes,
subscribeToWindowMessage,
} from './subscribeToWindowMessage';

describe('subscribeToWindowMessage', () => {
let unsubscribe: () => void;
const DEFAULT_ORIGIN = 'https://default.origin';
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const mockMessageEvent = (data: any, origin = DEFAULT_ORIGIN) =>
new MessageEvent('message', { data, origin });

beforeEach(() => {
unsubscribe = () => {};
});

afterEach(() => {
unsubscribe();
});

it('should subscribe to window message and call onMessage when message is received', async () => {
const onMessage = vi.fn();
unsubscribe = subscribeToWindowMessage({
onMessage,
allowedOrigin: DEFAULT_ORIGIN,
});

const event = mockMessageEvent({
eventName: MessageCodes.Event,
data: { key: 'value' },
});
window.dispatchEvent(event);

//wait for the async code to run
await Promise.resolve();

expect(onMessage).toHaveBeenCalledWith({ key: 'value' });
});

it('should not call onMessage if the origin is not allowed', async () => {
const onMessage = vi.fn();
subscribeToWindowMessage({
onMessage,
allowedOrigin: 'https://not.allowed.origin',
});

const event = mockMessageEvent({
eventName: MessageCodes.Event,
data: { key: 'value' },
});
window.dispatchEvent(event);

//wait for the async code to run
await Promise.resolve();

expect(onMessage).not.toHaveBeenCalled();
});

it('should validate the origin using onValidateOrigin callback', async () => {
const onMessage = vi.fn();
const onValidateOrigin = vi.fn().mockResolvedValue(true);
subscribeToWindowMessage({
onMessage,
allowedOrigin: DEFAULT_ORIGIN,
onValidateOrigin,
});

const event = mockMessageEvent({
eventName: MessageCodes.Event,
data: { key: 'value' },
});
window.dispatchEvent(event);

//wait for the async code to run
await Promise.resolve();

expect(onValidateOrigin).toHaveBeenCalledWith(DEFAULT_ORIGIN);
expect(onMessage).toHaveBeenCalledWith({ key: 'value' });
});

it('should not call onMessage if onValidateOrigin returns false', async () => {
const onMessage = vi.fn();
const onValidateOrigin = vi.fn().mockResolvedValue(false);
subscribeToWindowMessage({
onMessage,
allowedOrigin: DEFAULT_ORIGIN,
onValidateOrigin,
});

const event = mockMessageEvent({
eventName: MessageCodes.Event,
data: { key: 'value' },
});
window.dispatchEvent(event);

//wait for the async code to run
await Promise.resolve();

expect(onValidateOrigin).toHaveBeenCalledWith(DEFAULT_ORIGIN);
expect(onMessage).not.toHaveBeenCalled();
});

it('should not call onMessage if the message code is not "event"', async () => {
const onMessage = vi.fn();
subscribeToWindowMessage({
onMessage,
allowedOrigin: DEFAULT_ORIGIN,
});

const event = mockMessageEvent({
eventName: 'not-event',
data: { key: 'value' },
});
window.dispatchEvent(event);

//wait for the async code to run
await Promise.resolve();

expect(onMessage).not.toHaveBeenCalled();
});
});
Loading

0 comments on commit 77a31d7

Please sign in to comment.