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

Add support for fetchClientSecret param to Embedded Checkout #481

Merged
merged 2 commits into from
Mar 8, 2024
Merged
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
31 changes: 25 additions & 6 deletions src/components/EmbeddedCheckout.client.test.tsx
Original file line number Diff line number Diff line change
@@ -13,7 +13,8 @@ describe('EmbeddedCheckout on the client', () => {
let mockEmbeddedCheckout: any;
let mockEmbeddedCheckoutPromise: any;
const fakeClientSecret = 'cs_123_secret_abc';
const fakeOptions = {clientSecret: fakeClientSecret};
const fetchClientSecret = () => Promise.resolve(fakeClientSecret);
const fakeOptions = {fetchClientSecret};

beforeEach(() => {
mockStripe = mocks.mockStripe();
@@ -73,10 +74,13 @@ describe('EmbeddedCheckout on the client', () => {
expect(mockEmbeddedCheckout.mount).toBeCalledWith(container.firstChild);
});

it('does not mount until Embedded Checkouts has been initialized', async () => {
it('does not mount until Embedded Checkout has been initialized', async () => {
// Render with no stripe instance and client secret
const {container, rerender} = render(
<EmbeddedCheckoutProvider stripe={null} options={{clientSecret: null}}>
<EmbeddedCheckoutProvider
stripe={null}
options={{fetchClientSecret: null}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
@@ -86,18 +90,18 @@ describe('EmbeddedCheckout on the client', () => {
rerender(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={{clientSecret: null}}
options={{fetchClientSecret: null}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
expect(mockEmbeddedCheckout.mount).not.toBeCalled();

// Set client secret
// Set fetchClientSecret
rerender(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={{clientSecret: fakeClientSecret}}
options={{fetchClientSecret}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
@@ -154,4 +158,19 @@ describe('EmbeddedCheckout on the client', () => {
);
}).not.toThrow();
});

it('still works with clientSecret param (deprecated)', async () => {
const {container} = render(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={{clientSecret: 'cs_123_456'}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);

await act(() => mockEmbeddedCheckoutPromise);

expect(mockEmbeddedCheckout.mount).toBeCalledWith(container.firstChild);
});
});
150 changes: 113 additions & 37 deletions src/components/EmbeddedCheckoutProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -14,7 +14,8 @@ describe('EmbeddedCheckoutProvider', () => {
let mockEmbeddedCheckout: any;
let mockEmbeddedCheckoutPromise: any;
const fakeClientSecret = 'cs_123_secret_abc';
const fakeOptions = {clientSecret: fakeClientSecret};
const fetchClientSecret = () => Promise.resolve(fakeClientSecret);
const fakeOptions = {fetchClientSecret};
let consoleWarn: any;
let consoleError: any;

@@ -87,26 +88,6 @@ describe('EmbeddedCheckoutProvider', () => {
expect(result.current.embeddedCheckout).toBe(mockEmbeddedCheckout);
});

it('allows a transition from null to a valid client secret', async () => {
let optionsProp: any = {clientSecret: null};
const wrapper = ({children}: {children?: React.ReactNode}) => (
<EmbeddedCheckoutProvider stripe={mockStripe} options={optionsProp}>
{children}
</EmbeddedCheckoutProvider>
);

const {result, rerender} = renderHook(() => useEmbeddedCheckoutContext(), {
wrapper,
});
expect(result.current.embeddedCheckout).toBe(null);

optionsProp = {clientSecret: fakeClientSecret};
rerender();

await act(() => mockEmbeddedCheckoutPromise);
expect(result.current.embeddedCheckout).toBe(mockEmbeddedCheckout);
});

it('works with a Promise resolving to a valid Stripe object', async () => {
const wrapper = ({children}: {children?: React.ReactNode}) => (
<EmbeddedCheckoutProvider
@@ -247,40 +228,135 @@ describe('EmbeddedCheckoutProvider', () => {
);
});

it('does not allow changes to clientSecret option', async () => {
const optionsProp1 = {clientSecret: 'cs_123_secret_abc'};
const optionsProp2 = {clientSecret: 'cs_abc_secret_123'};
describe('clientSecret param (deprecated)', () => {
it('allows a transition from null to a valid client secret', async () => {
let optionsProp: any = {clientSecret: null};
const wrapper = ({children}: {children?: React.ReactNode}) => (
<EmbeddedCheckoutProvider stripe={mockStripe} options={optionsProp}>
{children}
</EmbeddedCheckoutProvider>
);

const {result, rerender} = renderHook(
() => useEmbeddedCheckoutContext(),
{
wrapper,
}
);
expect(result.current.embeddedCheckout).toBe(null);

optionsProp = {clientSecret: fakeClientSecret};
rerender();

await act(() => mockEmbeddedCheckoutPromise);
expect(result.current.embeddedCheckout).toBe(mockEmbeddedCheckout);
});

it('does not allow changes to clientSecret option', async () => {
const optionsProp1 = {clientSecret: 'cs_123_secret_abc'};
const optionsProp2 = {clientSecret: 'cs_abc_secret_123'};

// Silence console output so test output is less noisy
consoleWarn.mockImplementation(() => {});

const {rerender} = render(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={optionsProp1}
></EmbeddedCheckoutProvider>
);
await act(() => mockEmbeddedCheckoutPromise);

rerender(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={optionsProp2}
></EmbeddedCheckoutProvider>
);

expect(consoleWarn).toHaveBeenCalledWith(
'Unsupported prop change on EmbeddedCheckoutProvider: You cannot change the client secret after setting it. Unmount and create a new instance of EmbeddedCheckoutProvider instead.'
);
});
});

describe('fetchClientSecret param', () => {
it('allows a transition from null to a valid fetchClientSecret', async () => {
let optionsProp: any = {fetchClientSecret: null};
const wrapper = ({children}: {children?: React.ReactNode}) => (
<EmbeddedCheckoutProvider stripe={mockStripe} options={optionsProp}>
{children}
</EmbeddedCheckoutProvider>
);

const {result, rerender} = renderHook(
() => useEmbeddedCheckoutContext(),
{
wrapper,
}
);
expect(result.current.embeddedCheckout).toBe(null);

optionsProp = {fetchClientSecret};
rerender();

await act(() => mockEmbeddedCheckoutPromise);
expect(result.current.embeddedCheckout).toBe(mockEmbeddedCheckout);
});

it('does not allow changes to fetchClientSecret option', async () => {
const optionsProp1 = {fetchClientSecret};
const optionsProp2 = {
fetchClientSecret: () => Promise.resolve('cs_abc_secret_123'),
};

// Silence console output so test output is less noisy
consoleWarn.mockImplementation(() => {});

const {rerender} = render(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={optionsProp1}
></EmbeddedCheckoutProvider>
);
await act(() => mockEmbeddedCheckoutPromise);

rerender(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={optionsProp2}
></EmbeddedCheckoutProvider>
);

expect(consoleWarn).toHaveBeenCalledWith(
'Unsupported prop change on EmbeddedCheckoutProvider: You cannot change fetchClientSecret after setting it. Unmount and create a new instance of EmbeddedCheckoutProvider instead.'
);
});
});

it('errors if both clientSecret and fetchClientSecret are undefined', async () => {
// Silence console output so test output is less noisy
consoleWarn.mockImplementation(() => {});

const {rerender} = render(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={optionsProp1}
></EmbeddedCheckoutProvider>
);
await act(() => mockEmbeddedCheckoutPromise);

rerender(
render(
<EmbeddedCheckoutProvider
stripe={mockStripe}
options={optionsProp2}
options={{}}
></EmbeddedCheckoutProvider>
);

expect(consoleWarn).toHaveBeenCalledWith(
'Unsupported prop change on EmbeddedCheckoutProvider: You cannot change the client secret after setting it. Unmount and create a new instance of EmbeddedCheckoutProvider instead.'
'Invalid props passed to EmbeddedCheckoutProvider: You must provide one of either `options.fetchClientSecret` or `options.clientSecret`.'
);
});

it('does not allow changes to onComplete option', async () => {
const optionsProp1 = {
clientSecret: 'cs_123_secret_abc',
fetchClientSecret,
onComplete: () => 'foo',
};
const optionsProp2 = {
clientSecret: 'cs_123_secret_abc',
fetchClientSecret,
onComplete: () => 'bar',
};
// Silence console output so test output is less noisy
30 changes: 25 additions & 5 deletions src/components/EmbeddedCheckoutProvider.tsx
Original file line number Diff line number Diff line change
@@ -46,11 +46,13 @@ interface EmbeddedCheckoutProviderProps {
stripe: PromiseLike<stripeJs.Stripe | null> | stripeJs.Stripe | null;
/**
* Embedded Checkout configuration options.
* You can initially pass in `null` as `options.clientSecret` if you are
* performing an initial server-side render or when generating a static site.
* You can initially pass in `null` to `options.clientSecret` or
* `options.fetchClientSecret` if you are performing an initial server-side
* render or when generating a static site.
*/
options: {
clientSecret: string | null;
clientSecret?: string | null;
fetchClientSecret?: (() => Promise<string>) | null;
onComplete?: () => void;
};
}
@@ -102,7 +104,7 @@ export const EmbeddedCheckoutProvider: FunctionComponent<PropsWithChildren<
if (
parsed.tag === 'async' &&
!loadedStripe.current &&
options.clientSecret
(options.clientSecret || options.fetchClientSecret)
) {
parsed.stripePromise.then((stripe) => {
if (stripe) {
@@ -112,7 +114,7 @@ export const EmbeddedCheckoutProvider: FunctionComponent<PropsWithChildren<
} else if (
parsed.tag === 'sync' &&
!loadedStripe.current &&
options.clientSecret
(options.clientSecret || options.fetchClientSecret)
) {
// Or, handle a sync stripe instance going from null -> populated
setStripeAndInitEmbeddedCheckout(parsed.stripe);
@@ -171,6 +173,15 @@ export const EmbeddedCheckoutProvider: FunctionComponent<PropsWithChildren<
return;
}

if (
options.clientSecret === undefined &&
options.fetchClientSecret === undefined
) {
console.warn(
'Invalid props passed to EmbeddedCheckoutProvider: You must provide one of either `options.fetchClientSecret` or `options.clientSecret`.'
);
}

if (
prevOptions.clientSecret != null &&
options.clientSecret !== prevOptions.clientSecret
@@ -180,6 +191,15 @@ export const EmbeddedCheckoutProvider: FunctionComponent<PropsWithChildren<
);
}

if (
prevOptions.fetchClientSecret != null &&
options.fetchClientSecret !== prevOptions.fetchClientSecret
) {
console.warn(
'Unsupported prop change on EmbeddedCheckoutProvider: You cannot change fetchClientSecret after setting it. Unmount and create a new instance of EmbeddedCheckoutProvider instead.'
);
}

if (
prevOptions.onComplete != null &&
options.onComplete !== prevOptions.onComplete
Loading