Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fund button - setupOnrampEventListeners util for setting up onSuccess, onEvent and onExit callbacks #1626

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
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