refactor: apply second round of suggestions

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-03-14 08:42:35 +01:00
parent b806745876
commit aaab35ee17
12 changed files with 145 additions and 258 deletions
@@ -18,10 +18,19 @@ import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CookieAuthRefreshProvider } from './CookieAuthRefreshProvider';
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api';
import {
MockStorageApi,
TestApiProvider,
renderInTestApp,
} from '@backstage/test-utils';
import {
discoveryApiRef,
fetchApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
describe('CookieAuthRefreshProvider', () => {
const storageApiMock = MockStorageApi.create();
const discoveryApiMock = {
getBaseUrl: jest
.fn()
@@ -33,12 +42,6 @@ describe('CookieAuthRefreshProvider', () => {
return new Date(Date.now() + tenMinutesInMilliseconds).toISOString();
}
global.BroadcastChannel = jest.fn().mockImplementation(() => ({
postMessage: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
it('should render a progress bar', async () => {
const fetchApiMock = {
fetch: jest.fn().mockReturnValue(new Promise(() => {})),
@@ -48,6 +51,7 @@ describe('CookieAuthRefreshProvider', () => {
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -57,7 +61,9 @@ describe('CookieAuthRefreshProvider', () => {
</TestApiProvider>,
);
expect(screen.getByTestId('progress')).toBeInTheDocument();
expect(screen.queryByText('Test Content')).not.toBeInTheDocument();
expect(screen.getByTestId('progress')).toBeVisible();
});
it('should render an error panel', async () => {
@@ -70,6 +76,7 @@ describe('CookieAuthRefreshProvider', () => {
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -100,6 +107,7 @@ describe('CookieAuthRefreshProvider', () => {
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -118,6 +126,8 @@ describe('CookieAuthRefreshProvider', () => {
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Test Content')).not.toBeInTheDocument();
expect(screen.getByText(error.message)).toBeInTheDocument();
await userEvent.click(screen.getByText('Retry'));
@@ -143,6 +153,7 @@ describe('CookieAuthRefreshProvider', () => {
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -18,13 +18,20 @@ import React, { ReactNode } from 'react';
import { ErrorPanel } from '@backstage/core-components';
import { useApp } from '@backstage/core-plugin-api';
import { Button } from '@material-ui/core';
import { useCookieAuthRefresh, CookieAuthRefreshOptions } from '../../hooks';
import { useCookieAuthRefresh } from '../../hooks';
/**
* @public
* Props for the {@link CookieAuthRefreshProvider} component.
*/
export type CookieAuthRefreshProviderProps = CookieAuthRefreshOptions & {
export type CookieAuthRefreshProviderProps = {
// The plugin ID to used for discovering the API origin
pluginId: string;
// Options for configuring the refresh cookie endpoint
options?: {
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
};
// The children to render when the refresh is successful
children: ReactNode;
};
@@ -33,28 +40,28 @@ export type CookieAuthRefreshProviderProps = CookieAuthRefreshOptions & {
* @public
* A provider that will refresh the cookie when it is about to expire.
*/
export function CookieAuthRefreshProvider({
children,
...rest
}: CookieAuthRefreshProviderProps) {
export function CookieAuthRefreshProvider(
props: CookieAuthRefreshProviderProps,
): JSX.Element {
const { children, ...params } = props;
const app = useApp();
const { Progress } = app.getComponents();
const { state, actions } = useCookieAuthRefresh(rest);
const { loading, error, retry } = useCookieAuthRefresh(params);
if (state.status === 'error' && state.error) {
if (loading) {
return <Progress />;
}
if (error) {
return (
<ErrorPanel error={state.error}>
<Button variant="outlined" onClick={actions.execute}>
<ErrorPanel error={error}>
<Button variant="outlined" onClick={retry}>
Retry
</Button>
</ErrorPanel>
);
}
if (state.status === 'loading') {
return <Progress />;
}
return children;
return <>{children}</>;
}
@@ -14,7 +14,4 @@
* limitations under the License.
*/
export {
useCookieAuthRefresh,
type CookieAuthRefreshOptions,
} from './useCookieAuthRefresh';
export { useCookieAuthRefresh } from './useCookieAuthRefresh';
@@ -16,8 +16,12 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api';
import { TestApiProvider } from '@backstage/test-utils';
import {
fetchApiRef,
discoveryApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { MockStorageApi, TestApiProvider } from '@backstage/test-utils';
import { useCookieAuthRefresh } from './useCookieAuthRefresh';
describe('useCookieAuthRefresh', () => {
@@ -39,31 +43,11 @@ describe('useCookieAuthRefresh', () => {
}),
};
type Listener = (event: { data: any }) => void;
let listeners: Listener[];
let channelMock: any;
const storageApiMock = MockStorageApi.create();
beforeEach(() => {
jest.useFakeTimers({ now });
jest.clearAllMocks();
listeners = [];
channelMock = {
postMessage: jest.fn((message: any) => {
listeners.forEach(listener => listener({ data: message }));
}),
addEventListener: jest.fn((event: string, listener: Listener) => {
if (event === 'message') {
listeners.push(listener);
}
}),
removeEventListener: jest.fn((event: string, listener: Listener) => {
if (event === 'message') {
listeners = listeners.filter(l => l !== listener);
}
}),
};
global.BroadcastChannel = jest.fn().mockImplementation(() => channelMock);
});
afterEach(() => {
@@ -83,6 +67,7 @@ describe('useCookieAuthRefresh', () => {
fetch: jest.fn(),
},
],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -92,7 +77,7 @@ describe('useCookieAuthRefresh', () => {
},
);
expect(result.current.state.status).toBe('loading');
expect(result.current.loading).toBeTruthy();
});
it('should return an error status when the refresh has failed', async () => {
@@ -110,6 +95,7 @@ describe('useCookieAuthRefresh', () => {
fetch: jest.fn().mockRejectedValue(error),
},
],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -119,9 +105,7 @@ describe('useCookieAuthRefresh', () => {
},
);
await waitFor(() => expect(result.current.state.status).toBe('error'));
expect(result.current.state.error).toStrictEqual(error);
await waitFor(() => expect(result.current.error).toStrictEqual(error));
});
it('should call the api to get the cookie and use it', async () => {
@@ -132,6 +116,7 @@ describe('useCookieAuthRefresh', () => {
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -148,46 +133,18 @@ describe('useCookieAuthRefresh', () => {
),
);
expect(result.current.state.result).toMatchObject({ expiresAt });
});
it('should send a message to other tabs when the cookie is refreshed', async () => {
renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), {
wrapper: ({ children }) => (
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
});
expect(global.BroadcastChannel).toHaveBeenCalledWith(
'techdocs-auth-cookie-channel',
);
await waitFor(() =>
expect(channelMock.postMessage).toHaveBeenCalledTimes(1),
);
// posting the message to other tabs when the cookie is requested in the first time
await waitFor(() =>
expect(channelMock.postMessage).toHaveBeenCalledWith({
action: 'COOKIE_REFRESHED',
payload: { expiresAt },
}),
);
expect(result.current.value).toMatchObject({ expiresAt });
});
it('should cancel the refresh when a message is received from another tab', async () => {
renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), {
const pluginId = 'techdocs';
renderHook(() => useCookieAuthRefresh({ pluginId }), {
wrapper: ({ children }) => (
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -196,29 +153,25 @@ describe('useCookieAuthRefresh', () => {
),
});
await waitFor(() =>
expect(channelMock.addEventListener).toHaveBeenCalledTimes(1),
);
const twentyMinutesFromNowInMilliseconds =
now + 2 * tenMinutesInMilliseconds;
// simulating other tab refreshing the cookie
channelMock.postMessage({
action: 'COOKIE_REFRESHED',
payload: {
expiresAt: new Date(twentyMinutesFromNowInMilliseconds).toISOString(),
},
});
storageApiMock
.forBucket(`${pluginId}-auth-cookie-storage`)
.set(
'expiresAt',
new Date(twentyMinutesFromNowInMilliseconds).toISOString(),
);
// advance the timers in 10 minutes to match the old expires at
jest.advanceTimersByTime(tenMinutesInMilliseconds);
// should not call the api
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1));
// advance the timers in more 10 minutes to match the new expires at
jest.advanceTimersByTime(tenMinutesInMilliseconds);
jest.advanceTimersByTime(tenMinutesInMilliseconds - 1000);
// should call the api
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2));
@@ -232,6 +185,7 @@ describe('useCookieAuthRefresh', () => {
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -243,16 +197,10 @@ describe('useCookieAuthRefresh', () => {
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1));
expect(result.current.state.result).toMatchObject({ expiresAt });
expect(result.current.value).toMatchObject({ expiresAt });
unmount();
expect(channelMock.removeEventListener).toHaveBeenCalledTimes(1);
expect(channelMock.removeEventListener).toHaveBeenCalledWith(
'message',
expect.any(Function),
);
// advance the timers to ensure that the refresh is not called
jest.advanceTimersByTime(tenMinutesInMilliseconds);
@@ -268,6 +216,7 @@ describe('useCookieAuthRefresh', () => {
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
@@ -279,7 +228,7 @@ describe('useCookieAuthRefresh', () => {
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1));
// advance the timers to the expiration date
jest.advanceTimersByTime(tenMinutesInMilliseconds);
jest.advanceTimersByTime(tenMinutesInMilliseconds - 1000);
// should call the api
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2));
@@ -14,51 +14,38 @@
* limitations under the License.
*/
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useCallback } from 'react';
import {
discoveryApiRef,
fetchApiRef,
storageApiRef,
useApi,
} from '@backstage/core-plugin-api';
import { useAsync, useMountEffect } from '@react-hookz/web';
import { ResponseError } from '@backstage/errors';
type CookieAuthRefreshMessage = MessageEvent<{
action: string;
payload: {
expiresAt: string;
};
}>;
/**
* @public
* The options for the {@link useCookieAuthRefresh} hook.
*/
export type CookieAuthRefreshOptions = {
// The plugin ID to used for discovering the API origin
pluginId: string;
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
};
/**
* @public
* A hook that will refresh the cookie when it is about to expire.
* @remarks
* This hook expects a `BroadcastChannel` to be available in the global scope.
*/
export function useCookieAuthRefresh({
pluginId,
path = '/cookie',
}: CookieAuthRefreshOptions) {
export function useCookieAuthRefresh(params: {
// The plugin ID to used for discovering the API origin
pluginId: string;
// Options for configuring the refresh cookie endpoint
options?: {
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
};
}) {
const { pluginId, options: { path = '/cookie' } = {} } = params;
const fetchApi = useApi(fetchApiRef);
const storageApi = useApi(storageApiRef);
const discoveryApi = useApi(discoveryApiRef);
const [channel] = useState(
() => new BroadcastChannel(`${pluginId}-auth-cookie-channel`),
);
const store = storageApi.forBucket(`${pluginId}-auth-cookie-storage`);
const [state, actions] = useAsync(async () => {
const [state, actions] = useAsync<{ expiresAt: string }>(async () => {
const apiOrigin = await discoveryApi.getBaseUrl(pluginId);
const requestUrl = `${apiOrigin}${path}`;
const response = await fetchApi.fetch(`${requestUrl}`, {
@@ -86,28 +73,27 @@ export function useCookieAuthRefresh({
useEffect(() => {
if (!state.result) return () => {};
channel.postMessage({
action: 'COOKIE_REFRESHED',
payload: state.result,
});
store.set('expiresAt', state.result.expiresAt);
let cancel = refresh(state.result);
const handleMessage = (event: CookieAuthRefreshMessage): void => {
const { action, payload } = event.data;
if (action === 'COOKIE_REFRESHED') {
cancel();
cancel = refresh(payload);
}
};
channel.addEventListener('message', handleMessage);
const observable = store.observe$<string>('expiresAt');
const subscription = observable.subscribe(({ value }) => {
if (!value) return;
cancel();
cancel = refresh({ expiresAt: value });
});
return () => {
cancel();
channel.removeEventListener('message', handleMessage);
subscription.unsubscribe();
};
}, [state, refresh, channel]);
}, [state, refresh, store]);
return { state, actions };
return {
loading: state.status === 'loading',
error: state.error,
value: state.result,
retry: actions.execute,
};
}
-25
View File
@@ -14,28 +14,3 @@
* limitations under the License.
*/
import '@testing-library/jest-dom';
type Listener = (event: { data: any }) => void;
global.BroadcastChannel = jest
.fn()
.mockImplementation((_channelName: string) => {
let listeners: Listener[] = [];
return {
postMessage: jest.fn((message: any) => {
// Simulate message event for all listeners
listeners.forEach(listener => listener({ data: message }));
}),
addEventListener: jest.fn((event: string, listener: Listener) => {
if (event === 'message') {
listeners.push(listener);
}
}),
removeEventListener: jest.fn((event: string, listener: Listener) => {
if (event === 'message') {
listeners = listeners.filter(l => l !== listener);
}
}),
close: jest.fn(),
};
});