diff --git a/src/app/Routes.tsx b/src/app/Routes.tsx index a475074d..0da050b6 100644 --- a/src/app/Routes.tsx +++ b/src/app/Routes.tsx @@ -6,27 +6,29 @@ import { useLocation, } from 'react-router-dom'; import { - CollectionsList, + useAppSelector, + useFilteredParams, + usePageTracking, +} from '../common/hooks'; +import { CollectionDetail, - detailPath, + CollectionsList, detailDataProductPath, + detailPath, } from '../features/collections/Collections'; -import Legacy, { LEGACY_BASE_ROUTE } from '../features/legacy/Legacy'; +import PageNotFound from '../features/layout/PageNotFound'; import { Fallback } from '../features/legacy/IFrameFallback'; +import Legacy, { LEGACY_BASE_ROUTE } from '../features/legacy/Legacy'; import Navigator, { navigatorPath, navigatorPathWithCategory, } from '../features/navigator/Navigator'; -import PageNotFound from '../features/layout/PageNotFound'; +import ORCIDLinkConfirmLink from '../features/orcidlink/ConfirmLink'; +import ORCIDLinkCreateLink from '../features/orcidlink/CreateLink'; +import ORCIDLinkHome from '../features/orcidlink/Home'; +import ORCIDLinkServiceError from '../features/orcidlink/ServiceError'; import ProfileWrapper from '../features/profile/Profile'; import Status from '../features/status/Status'; -import { - useAppSelector, - useFilteredParams, - usePageTracking, -} from '../common/hooks'; -import ORCIDLinkFeature from '../features/orcidlink'; -import ORCIDLinkCreateLink from '../features/orcidlink/CreateLink'; export const LOGIN_ROUTE = '/legacy/login'; export const ROOT_REDIRECT_ROUTE = '/narratives'; @@ -82,11 +84,17 @@ const Routes: FC = () => { {/* orcidlink */} - } />} /> + } />} /> } />} /> + + } />} /> + + + } />} /> + {/* IFrame Fallback Routes */} diff --git a/src/common/api/utils/kbaseBaseQuery.ts b/src/common/api/utils/kbaseBaseQuery.ts index eff973d4..d99cc961 100644 --- a/src/common/api/utils/kbaseBaseQuery.ts +++ b/src/common/api/utils/kbaseBaseQuery.ts @@ -48,7 +48,7 @@ export interface JSONRPC20Body { export interface JSONRPC20Error { code: number; message: string; - data: unknown; + data?: unknown; } export type JSONRPCBody = JSONRPC11Body | JSONRPC20Body; diff --git a/src/features/orcidlink/ConfirmLink/index.mockedapi.test.tsx b/src/features/orcidlink/ConfirmLink/index.mockedapi.test.tsx new file mode 100644 index 00000000..b8f31170 --- /dev/null +++ b/src/features/orcidlink/ConfirmLink/index.mockedapi.test.tsx @@ -0,0 +1,94 @@ +import { render, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createTestStore } from '../../../app/store'; +import ORCIDLinkAPI from '../common/api/ORCIDLInkAPI'; +import { INITIAL_STORE_STATE } from '../test/data'; +import CreateLinkController from './index'; + +/** + * This set of tests focuses on artifically constructed error conditions. They + * use a jest spy to mock api methods. It seems that jest, at least the version + * in this codebase, does not or cannot reset the module level mocks, so these + * tests need to be separated from the others. + */ + +describe('The ConfirmLink controller component (api mocks for errors)', () => { + beforeEach(() => { + // jest.resetAllMocks(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the expected error message if the loading api calls return a non-JSON-RPC error', async () => { + // NB need to mock each call made, otherwise will get network errors in the + // test output (though tests will still pass). + // The calls are made in parallel (Promise.all), so there is no ensuring + // which one is called or completes first and triggers the Promise.all + // error. (Though in practice the first one, or perhaps the mocked one, + // fails first, as it does not invoke an actual network call, which consumes + // time.) + jest + .spyOn(ORCIDLinkAPI.prototype, 'getLinkingSession') + .mockImplementation(async () => { + throw new Error('Test Error'); + }); + + jest.spyOn(ORCIDLinkAPI.prototype, 'info').mockImplementation(async () => { + throw new Error('Test Error'); + }); + + const { container } = render( + + + + } + />{' '} + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Test Error'); + }); + }); + + it('renders the expected error message if the loading api calls return a non-Error error', async () => { + jest + .spyOn(ORCIDLinkAPI.prototype, 'getLinkingSession') + .mockImplementation(async () => { + // eslint-disable-next-line no-throw-literal + throw 'Not A Real Error'; + }); + + jest.spyOn(ORCIDLinkAPI.prototype, 'info').mockImplementation(async () => { + // eslint-disable-next-line no-throw-literal + throw 'Not A Real Error'; + }); + + const { container } = render( + + + + } + />{' '} + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Unknown error'); + }); + }); +}); diff --git a/src/features/orcidlink/ConfirmLink/index.mockedservice.test.tsx b/src/features/orcidlink/ConfirmLink/index.mockedservice.test.tsx new file mode 100644 index 00000000..81a774f8 --- /dev/null +++ b/src/features/orcidlink/ConfirmLink/index.mockedservice.test.tsx @@ -0,0 +1,462 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'jest-fetch-mock'; +import { FetchMock } from 'jest-fetch-mock/types'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createTestStore } from '../../../app/store'; +import { JSONRPC20Request } from '../common/api/JSONRPC20'; +import ORCIDLinkAPI from '../common/api/ORCIDLInkAPI'; +import { INITIAL_STORE_STATE } from '../test/data'; +import { + makeError2, + makeOrcidlinkServiceMock, + orcidlinkErrors, +} from '../test/orcidlinkServiceMock'; +import CreateLinkController from './index'; + +describe('The ConfirmLink controller component (without api spy)', () => { + const user = userEvent.setup(); + let mockService: FetchMock; + + beforeEach(() => { + fetchMock.enableMocks(); + mockService = makeOrcidlinkServiceMock(); + }); + afterEach(() => { + mockService.mockClear(); + fetchMock.disableMocks(); + jest.clearAllMocks(); + }); + + it('renders correctly if a linking session found', async () => { + const { container } = render( + + + + } + />{' '} + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Loading Linking Session...'); + + // Ensure the sections are all being displayed by checking for their titles. + await waitFor(() => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + }); + expect(container).toHaveTextContent('Your ORCID® Account'); + expect(container).toHaveTextContent('Scopes being granted to KBase'); + }); + + it('receives the cancel action', async () => { + let fakeHomeCalled = false; + + function FakeHome() { + fakeHomeCalled = true; + return
ORCIDLINK HOME
; + } + + const { container } = render( + + + + } + /> + } /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Loading Linking Session...'); + + // Ensure the sections are all being displayed by checking for their titles. + await waitFor(() => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + }); + expect(container).toHaveTextContent('Your ORCID® Account'); + expect(container).toHaveTextContent('Scopes being granted to KBase'); + + // After the check reports "false", the view is rendered, with the continue button enabled. + const cancelButton = await screen.findByText('Cancel'); + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + await user.click(cancelButton); + + await waitFor(() => { + expect(container).toHaveTextContent('ORCIDLINK HOME'); + }); + + await waitFor(() => { + expect(fakeHomeCalled).toBe(true); + }); + }); + + it('receives the finish action', async () => { + let fakeHomeCalled = false; + + function FakeHome() { + fakeHomeCalled = true; + return
ORCIDLINK HOME
; + } + + const { container } = render( + + + + } + /> + } /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Loading Linking Session...'); + + // Ensure the sections are all being displayed by checking for their titles. + await waitFor(() => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + }); + expect(container).toHaveTextContent('Your ORCID® Account'); + expect(container).toHaveTextContent('Scopes being granted to KBase'); + + // After the check reports "false", the view is rendered, with the continue button enabled. + // Ensure that the "Continue" button is displayed and can be clicked + const continueButton = await screen.findByText( + 'Finish Creating Your KBase ORCID® Link' + ); + expect(continueButton).toBeVisible(); + expect(continueButton).toBeEnabled(); + + await user.click(continueButton); + + await waitFor(() => { + expect(container).toHaveTextContent('ORCIDLINK HOME'); + }); + + await waitFor(() => { + expect(fakeHomeCalled).toBe(true); + }); + }); + + it('renders the expected error message if the session is expired', async () => { + const { container } = render( + + + + } + />{' '} + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + await waitFor(() => { + expect(container).toHaveTextContent('Linking Session Expired'); + }); + }); + + it('renders the expected error message if the loading api calls return a JSON-RPC error', async () => { + const { container } = render( + + + + } + />{' '} + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Authorization Required'); + }); + }); + + it('renders the expected error message if url called with an invalid session id', async () => { + const { container } = render( + + + + } + />{' '} + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('1020'); + expect(container).toHaveTextContent( + `The session id "not_a_session" does not exist` + ); + }); + }); + + it('handles an error when handling the cancel action', async () => { + // Here we override the "foo_session" linking session mock to return an + // error when delete-linking-session is called. + // This ability to override mock implementations alleviates us from creating + // special parameters for every possible scenario. We just have to create a + // basic scenario, and then we can override individual api calls to simulate + // errors, or other conditions. + + mockService.mockClear(); + fetchMock.disableMocks(); + mockService = makeOrcidlinkServiceMock({ + 'delete-linking-session': { + foo_session: ({ id, method, params }: JSONRPC20Request) => { + // Force this call to return an auth error. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return makeError2(id!, orcidlinkErrors['1010']); + }, + }, + }); + fetchMock.enableMocks(); + fetchMock.doMock(); + + const { container } = render( + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Loading Linking Session...'); + + // Ensure the sections are all being displayed by checking for their titles. + await waitFor(() => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + }); + expect(container).toHaveTextContent('Your ORCID® Account'); + expect(container).toHaveTextContent('Scopes being granted to KBase'); + + // After the check reports "false", the view is rendered, with the continue button enabled. + const cancelButton = await screen.findByText('Cancel'); + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + await user.click(cancelButton); + + await waitFor(() => { + expect(container).toHaveTextContent('Authorization Required'); + }); + }); + + it('handles an non-Error error when handling the cancel action', async () => { + // Here we override the "foo_session" linking session mock to return an + // error when delete-linking-session is called. + // This ability to override mock implementations alleviates us from creating + // special parameters for every possible scenario. We just have to create a + // basic scenario, and then we can override individual api calls to simulate + // errors, or other conditions. + + mockService.mockClear(); + fetchMock.disableMocks(); + mockService = makeOrcidlinkServiceMock({ + 'delete-linking-session': { + foo_session: ({ id, method, params }: JSONRPC20Request) => { + // Force this call to return an auth error. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return makeError2(id!, orcidlinkErrors['1010']); + }, + }, + }); + fetchMock.enableMocks(); + fetchMock.doMock(); + + jest + .spyOn(ORCIDLinkAPI.prototype, 'deleteLinkingSession') + .mockImplementation(async () => { + // eslint-disable-next-line no-throw-literal + throw 'Not A Real Error'; + }); + + const { container } = render( + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Loading Linking Session...'); + + // Ensure the sections are all being displayed by checking for their titles. + await waitFor(() => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + }); + expect(container).toHaveTextContent('Your ORCID® Account'); + expect(container).toHaveTextContent('Scopes being granted to KBase'); + + // After the check reports "false", the view is rendered, with the continue button enabled. + const cancelButton = await screen.findByText('Cancel'); + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + await user.click(cancelButton); + + await waitFor(() => { + expect(container).toHaveTextContent('Unknown error'); + }); + }); + + it('handles an error when handling the finalization action', async () => { + // Here we override the "foo_session" linking session mock to return an + // error when delete-linking-session is called. + // This ability to override mock implementations alleviates us from creating + // special parameters for every possible scenario. We just have to create a + // basic scenario, and then we can override individual api calls to simulate + // errors, or other conditions. + + mockService.mockClear(); + fetchMock.disableMocks(); + mockService = makeOrcidlinkServiceMock({ + 'finish-linking-session': { + foo_session: ({ id, method, params }: JSONRPC20Request) => { + // Force this call to return an auth error. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return makeError2(id!, orcidlinkErrors['1010']); + }, + }, + }); + fetchMock.enableMocks(); + fetchMock.doMock(); + + const { container } = render( + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Loading Linking Session...'); + + // Ensure the sections are all being displayed by checking for their titles. + await waitFor(() => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + }); + expect(container).toHaveTextContent('Your ORCID® Account'); + expect(container).toHaveTextContent('Scopes being granted to KBase'); + + // After the check reports "false", the view is rendered, with the continue button enabled. + const continueButton = await screen.findByText( + 'Finish Creating Your KBase ORCID® Link' + ); + expect(continueButton).toBeVisible(); + expect(continueButton).toBeEnabled(); + + await user.click(continueButton); + + await waitFor(() => { + expect(container).toHaveTextContent('Authorization Required'); + }); + }); + + it('handles an non-Error error when handling the finalization action', async () => { + // Here we override the "foo_session" linking session mock to return an + // error when delete-linking-session is called. + // This ability to override mock implementations alleviates us from creating + // special parameters for every possible scenario. We just have to create a + // basic scenario, and then we can override individual api calls to simulate + // errors, or other conditions. + + jest + .spyOn(ORCIDLinkAPI.prototype, 'finishLinkingSession') + .mockImplementation(async () => { + // eslint-disable-next-line no-throw-literal + throw 'Not A Real Error'; + }); + + const { container } = render( + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Loading Linking Session...'); + + // Ensure the sections are all being displayed by checking for their titles. + await waitFor(() => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + }); + expect(container).toHaveTextContent('Your ORCID® Account'); + expect(container).toHaveTextContent('Scopes being granted to KBase'); + + // After the check reports "false", the view is rendered, with the continue button enabled. + const continueButton = await screen.findByText( + 'Finish Creating Your KBase ORCID® Link' + ); + expect(continueButton).toBeVisible(); + expect(continueButton).toBeEnabled(); + + await user.click(continueButton); + + await waitFor(() => { + expect(container).toHaveTextContent('Unknown error'); + }); + }); +}); diff --git a/src/features/orcidlink/ConfirmLink/index.tsx b/src/features/orcidlink/ConfirmLink/index.tsx new file mode 100644 index 00000000..3054ea8d --- /dev/null +++ b/src/features/orcidlink/ConfirmLink/index.tsx @@ -0,0 +1,372 @@ +/** + * This is the ConfirmLink component entrypoint and controller. + * + * This component is invoked on the path which is the redirection target from + * the orcidlink service at the completion of creating an orcidlink. + * + * Within the design of linking, however, the service leaves the completed + * linkint in an incomplete state, pending the user's final approval of the + * link. In other words, all conditions have been satisfied for creating the + * link with ORCID and the service has saved the linking information including + * ORCID authorization, but we allow the user a final chance to inspect their + * link before creating it. + * + * This component is responsible for fetching the given current linking session + * and presenting it to the user. As a controller, it provides action functions + * as props, so that the user may finalize the link or cancel the linking session. + */ + +import { Alert, Box } from '@mui/material'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useAppSelector } from '../../../common/hooks'; +import { JSONRPC20Exception } from '../common/api/JSONRPC20'; +import ORCIDLinkAPI, { + InfoResult, + LinkingSessionPublicComplete, +} from '../common/api/ORCIDLInkAPI'; +import ErrorMessage, { + CommonError, + makeCommonError, +} from '../common/ErrorMessage'; +import Loading from '../common/Loading'; +import { API_CALL_TIMEOUT, ORCIDLINK_SERVICE_API_ENDPOINT } from '../constants'; +import ConfirmLink from './view'; + +export enum ConfirmLinkStatus { + NONE = 'NONE', + LOADING_SESSION_DATA = 'LOADING_SESSION_DATA', + SESSION_DATA_READY = 'SESSION_DATA_READY', + ERROR_LOADING_SESSION_DATA = 'ERROR_LOADING_SESSION_DATA', + CANCELING_SESSION = 'CANCELING_SESSION', + SESSION_CANCELATION_ERROR = 'SESSION_CANCELATION_ERROR', + FINALIZING_SESSION = 'FINALIZING_SESSION', + SESSION_FINALIZATION_ERROR = 'SESSION_FINALIZATION_ERROR', +} + +export interface ConfirmLinkStateBase { + status: ConfirmLinkStatus; +} + +export interface ConfirmLinkStateNone extends ConfirmLinkStateBase { + status: ConfirmLinkStatus.NONE; +} + +export interface ConfirmLinkStateLoadingSession extends ConfirmLinkStateBase { + status: ConfirmLinkStatus.LOADING_SESSION_DATA; +} + +export interface ConfirmLinkStateSessionLoadedBase + extends ConfirmLinkStateBase { + status: + | ConfirmLinkStatus.SESSION_DATA_READY + | ConfirmLinkStatus.CANCELING_SESSION + | ConfirmLinkStatus.FINALIZING_SESSION; + sessionId: string; + session: LinkingSessionPublicComplete; + info: InfoResult; + cancel: () => Promise; + finalize: () => Promise; +} +export interface ConfirmLinkStateSessionDataReady + extends ConfirmLinkStateSessionLoadedBase { + status: ConfirmLinkStatus.SESSION_DATA_READY; + session: LinkingSessionPublicComplete; + info: InfoResult; +} + +export interface ConfirmLinkStateErrorLoadingSessionData + extends ConfirmLinkStateBase { + status: ConfirmLinkStatus.ERROR_LOADING_SESSION_DATA; + error: CommonError; +} + +export interface ConfirmLinkStateCancelingSession + extends ConfirmLinkStateSessionLoadedBase { + status: ConfirmLinkStatus.CANCELING_SESSION; +} + +export interface ConfirmLinkStateSessionCancelationError + extends ConfirmLinkStateBase { + status: ConfirmLinkStatus.SESSION_CANCELATION_ERROR; + error: { + // TODO: enhance error; e.g. code + message: string; + }; +} + +export interface ConfirmLinkStateFinalizingSession + extends ConfirmLinkStateSessionLoadedBase { + status: ConfirmLinkStatus.FINALIZING_SESSION; +} + +export interface ConfirmLinkStateSessionFinalizationErrror + extends ConfirmLinkStateBase { + status: ConfirmLinkStatus.SESSION_FINALIZATION_ERROR; + error: { + // TODO: enhance error; e.g. code + message: string; + }; +} + +export type ConfirmLinkState = + | ConfirmLinkStateNone + | ConfirmLinkStateLoadingSession + | ConfirmLinkStateSessionDataReady + | ConfirmLinkStateErrorLoadingSessionData + | ConfirmLinkStateCancelingSession + | ConfirmLinkStateSessionCancelationError + | ConfirmLinkStateFinalizingSession + | ConfirmLinkStateSessionFinalizationErrror; + +export default function ConfirmLinkController() { + const { sessionId } = useParams() as { sessionId: string }; + + // sessionId is always defined, since the route that gets us here always has + // the session id path param defined (and thus required). + + const [state, setState] = useState({ + status: ConfirmLinkStatus.NONE, + }); + + const token = useAppSelector((state) => state.auth.token); + const navigate = useNavigate(); + + const doCancelSession = useCallback(async () => { + if (state.status !== ConfirmLinkStatus.SESSION_DATA_READY) { + return; + } + + setState({ + ...state, + status: ConfirmLinkStatus.CANCELING_SESSION, + }); + + const orcidLinkService = new ORCIDLinkAPI({ + url: ORCIDLINK_SERVICE_API_ENDPOINT, + timeout: API_CALL_TIMEOUT, + token, + }); + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await orcidLinkService.deleteLinkingSession({ session_id: sessionId! }); + navigate('/orcidlink'); + } catch (ex) { + const message = ex instanceof Error ? ex.message : 'Unknown error'; + setState({ + status: ConfirmLinkStatus.SESSION_CANCELATION_ERROR, + error: { + message, + }, + }); + } + }, [setState, state, token, navigate, sessionId]); + + const doFinishSession = useCallback(async () => { + if (state.status !== ConfirmLinkStatus.SESSION_DATA_READY) { + return; + } + + setState({ + ...state, + status: ConfirmLinkStatus.FINALIZING_SESSION, + }); + + const orcidLinkService = new ORCIDLinkAPI({ + url: ORCIDLINK_SERVICE_API_ENDPOINT, + timeout: API_CALL_TIMEOUT, + token, + }); + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await orcidLinkService.finishLinkingSession({ session_id: sessionId! }); + toast( + + Your KBase ORCID Link has been created! + , + { + duration: 5000, + } + ); + navigate('/orcidlink', { replace: true }); + } catch (ex) { + const message = ex instanceof Error ? ex.message : 'Unknown error'; + setState({ + status: ConfirmLinkStatus.SESSION_FINALIZATION_ERROR, + error: { + message, + }, + }); + } + }, [setState, state, token, navigate, sessionId]); + + const mountedRef = useRef(false); + + useEffect(() => { + if (mountedRef.current) { + return; + } + mountedRef.current = true; + + async function initialize(sessionId: string) { + try { + const orcidLinkService = new ORCIDLinkAPI({ + url: ORCIDLINK_SERVICE_API_ENDPOINT, + timeout: API_CALL_TIMEOUT, + token, + }); + + const [info, session] = await Promise.all([ + orcidLinkService.info(), + orcidLinkService.getLinkingSession({ session_id: sessionId }), + ]); + + if (session.expires_at <= Date.now()) { + setState({ + status: ConfirmLinkStatus.ERROR_LOADING_SESSION_DATA, + error: makeCommonError({ + message: 'Linking Session Expired', + details: 'A linking session expires after 10 minutes', + solutions: [ + { + description: + 'You should restart the linking process if you still want to create a KBase ORCID Link', + link: { + label: 'ORCIDLink Home Page', + url: '/orcidlink', + }, + }, + ], + }), + }); + return; + } + + setState({ + status: ConfirmLinkStatus.SESSION_DATA_READY, + sessionId, + info, + session, + cancel: doCancelSession, + finalize: doFinishSession, + }); + } catch (ex) { + if (ex instanceof JSONRPC20Exception) { + const error = ((): CommonError => { + switch (ex.error.code) { + case 1020: + return makeCommonError({ + title: `Not Found (${ex.error.code})`, + message: `The session id "${sessionId}" does not exist`, + details: + 'You may have refreshed this page after successfully creating your link', + solutions: [ + { + description: 'Return to the ORCID Link home page', + link: { + label: 'Home', + url: '/orcidlink', + }, + }, + ], + }); + default: + return makeCommonError({ + title: 'Error', + message: `orcidlink service error "${ex.error.code}"`, + details: ex.error.message, + }); + } + })(); + setState({ + status: ConfirmLinkStatus.ERROR_LOADING_SESSION_DATA, + error, + }); + } else if (ex instanceof Error) { + setState({ + status: ConfirmLinkStatus.ERROR_LOADING_SESSION_DATA, + error: makeCommonError({ + message: ex.message, + }), + }); + } else { + setState({ + status: ConfirmLinkStatus.ERROR_LOADING_SESSION_DATA, + error: makeCommonError({ + message: 'Unknown error', + }), + }); + } + } + } + initialize(sessionId); + }, [ + mountedRef, + sessionId, + token, + state, + navigate, + setState, + doFinishSession, + doCancelSession, + ]); + + switch (state.status) { + case ConfirmLinkStatus.NONE: + case ConfirmLinkStatus.LOADING_SESSION_DATA: + return ( + + Loading Linking Session... + + ); + case ConfirmLinkStatus.SESSION_DATA_READY: { + const { sessionId, session, info } = state; + return ( + + ); + } + case ConfirmLinkStatus.ERROR_LOADING_SESSION_DATA: + return ; + case ConfirmLinkStatus.CANCELING_SESSION: { + const { sessionId, session, info } = state; + return ( + + ); + } + case ConfirmLinkStatus.SESSION_CANCELATION_ERROR: + return ; + case ConfirmLinkStatus.FINALIZING_SESSION: { + const { sessionId, session, info } = state; + return ( + + ); + } + case ConfirmLinkStatus.SESSION_FINALIZATION_ERROR: + return ; + } +} diff --git a/src/features/orcidlink/ConfirmLink/view.test.tsx b/src/features/orcidlink/ConfirmLink/view.test.tsx new file mode 100644 index 00000000..bf710172 --- /dev/null +++ b/src/features/orcidlink/ConfirmLink/view.test.tsx @@ -0,0 +1,219 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { createTestStore } from '../../../app/store'; +import { + INITIAL_STORE_STATE, + LINKING_SESSION_1, + SERVICE_INFO_1, +} from '../test/data'; +import ConfirmLinkView, { ConfirmLinkProps } from './view'; + +describe('The ContinueLink component', () => { + const user = userEvent.setup(); + + it('the "continue" view correctly', async () => { + async function doCancelSession() { + return; + } + + async function doFinishSession() { + return; + } + + const props: ConfirmLinkProps = { + sessionId: 'foo_session', + info: SERVICE_INFO_1, + session: LINKING_SESSION_1, + doCancelSession, + canceling: false, + doFinishSession, + finishing: false, + }; + + const { container } = render( + + + + + + ); + + // Ensure the sections are all being displayed by checking for their titles. + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + expect(container).toHaveTextContent('Your ORCID® Account'); + expect(container).toHaveTextContent('Scopes being granted to KBase'); + + // Ensure that the timer is being displayed. + expect(container).toHaveTextContent('The linking session expires in'); + + // Ensure that the "Cancel" button is displayed and can be clicked + const cancelButton = await screen.findByText('Cancel'); + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + // Ensure that the "Continue" button is displayed and can be clicked + const continueButton = await screen.findByText( + 'Finish Creating Your KBase ORCID® Link' + ); + expect(continueButton).toBeVisible(); + expect(continueButton).toBeEnabled(); + + // expect(document.title).toBe('KBase: ORCID Link - Create Link'); + }); + + it('the "cancel" button functions correctly', async () => { + let cancelCalled = false; + + async function doCancelSession() { + cancelCalled = true; + return; + } + + async function doFinishSession() { + return; + } + + const props: ConfirmLinkProps = { + sessionId: 'foo_session', + info: SERVICE_INFO_1, + session: LINKING_SESSION_1, + doCancelSession, + canceling: false, + doFinishSession, + finishing: false, + }; + + render( + + + + + + ); + + // Ensure that the "Cancel" button is displayed and can be clicked + const cancelButton = await screen.findByText('Cancel'); + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + await user.click(cancelButton); + + await waitFor(() => { + expect(cancelCalled).toBe(true); + }); + }); + + it('when canceling, the appropriate message is displayed', async () => { + async function doCancelSession() { + return; + } + + async function doFinishSession() { + return; + } + + const props: ConfirmLinkProps = { + sessionId: 'foo_session', + info: SERVICE_INFO_1, + session: LINKING_SESSION_1, + doCancelSession, + canceling: true, + doFinishSession, + finishing: false, + }; + + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent( + 'Attempting to cancel your linking session' + ); + }); + }); + + it('the "finish" button functions correctly', async () => { + let finishCalled = false; + + async function doCancelSession() { + return; + } + + async function doFinishSession() { + finishCalled = true; + return; + } + + const props: ConfirmLinkProps = { + sessionId: 'foo_session', + info: SERVICE_INFO_1, + session: LINKING_SESSION_1, + doCancelSession, + canceling: false, + doFinishSession, + finishing: false, + }; + + render( + + + + + + ); + + // Ensure that the "Cancel" button is displayed and can be clicked + const cancelButton = await screen.findByText( + 'Finish Creating Your KBase ORCID® Link' + ); + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + await user.click(cancelButton); + + await waitFor(() => { + expect(finishCalled).toBe(true); + }); + }); + + it('when continuing, the appropriate message is displayed', async () => { + async function doCancelSession() { + return; + } + + async function doFinishSession() { + return; + } + + const props: ConfirmLinkProps = { + sessionId: 'foo_session', + info: SERVICE_INFO_1, + session: LINKING_SESSION_1, + doCancelSession, + canceling: false, + doFinishSession, + finishing: true, + }; + + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent( + 'Attempting to create your KBase ORCID Link' + ); + }); + }); +}); diff --git a/src/features/orcidlink/ConfirmLink/view.tsx b/src/features/orcidlink/ConfirmLink/view.tsx new file mode 100644 index 00000000..86ff0beb --- /dev/null +++ b/src/features/orcidlink/ConfirmLink/view.tsx @@ -0,0 +1,276 @@ +/** + * This component implements the ConfirmLink component's user interface. + * + * The main task of this component is to allow the user to finalize their link. + * It presents the current link information for the user to inspect, a button to + * create the link, and a button to cancel the linking process. It also provides + * a countdown timer, as a linking session has a 10 minute lifetime. + */ +import { + faClock, + faFlagCheckered, + faMailReply, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Alert, + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + FormControlLabel, + Switch, + Table, + TableBody, + TableCell, + TableRow, + Typography, + Unstable_Grid2 as Grid, +} from '@mui/material'; +import { Link } from 'react-router-dom'; +import { + InfoResult, + LinkingSessionPublicComplete, +} from '../common/api/ORCIDLInkAPI'; +import CountdownClock from '../common/CountdownClock'; +import Loading from '../common/Loading'; +import ORCIDId from '../common/ORCIDId'; +import { ORCIDIdLink } from '../common/ORCIDIdLink'; +import Scopes from '../common/Scopes'; +import styles from '../common/styles.module.scss'; + +export interface ConfirmLinkProps { + sessionId: string; + info: InfoResult; + session: LinkingSessionPublicComplete; + doCancelSession: () => Promise; + canceling: boolean; + doFinishSession: () => Promise; + finishing: boolean; +} + +export default function ConfirmLink({ + sessionId, + info, + session, + doCancelSession, + canceling, + doFinishSession, + finishing, +}: ConfirmLinkProps) { + function renderORCIDUserRecord() { + return ( + + + + ORCID® iD + + + + + + Name + + {session.orcid_auth.name || not public} + + + +
+ ); + } + + function renderMessage() { + if (canceling) { + return ( + + + + ); + } else if (finishing) { + return ( + + + + ); + } else { + return; + } + } + + function renderTimeRemaining() { + return ( + + }> + The linking session expires in{' '} + + { + doCancelSession(); + }} + /> + + + + ); + } + + return ( + + + + + + + {/* {this.renderPendingProgress()} */} + + Your ORCID® account{' '} + is ready for + linking to your KBase account {session.username}. + +

+ By linking the ORCID® account above you will be granting KBase + the ability to interact with that account on your behalf. You + may revoke this at any time. +

+

+ By default, your ORCID® iD will be displayed in your User + Profile and may be displayed in other contexts in which your + account is displayed. You may opt out below. After linking, you + can change this setting in either the KBase ORCID® Link or User + Profile tool. +

+ + + } + label=" Show in User Profile" + sx={{ ms: 1 }} + onChange={() => { + // toggleShowInProfile(); + // NOOP - not implemented yet + }} + /> + + +

+ When this option is enabled your ORCID® iD will be displayed + in{' '} + + your User Profile + +

+

+ You may change this option at time (after you have created + the link), either here in the KBase ORCID Link tool or + directly in your User Profile. +

+
+
+
+ +
+ {renderTimeRemaining()} + +
+
+ + +
+ + {renderMessage()} +
+
+
+
+
+ + + + +

+ The following ORCID® account will be linked to this KBase + account. +

+ +

+ You may follow the ORCID® iD link below to inspect + additional information about the account. +

+ + {renderORCIDUserRecord()} +
+
+ + + + +

+ KBase is requesting the "scopes" below to view or + manipulate your ORCID® account. A scope is a set of permissions + to access your ORCID® account. +

+ +

+ Note that that interaction with your ORCID® account will only be + conducted while you are logged in, in response to direct actions + you take, and we will always inform you when this is the case. +

+ + +
+
+
+
+
+ ); +} diff --git a/src/features/orcidlink/CreateLink/controller.test.tsx b/src/features/orcidlink/CreateLink/controller.test.tsx new file mode 100644 index 00000000..25d05ca9 --- /dev/null +++ b/src/features/orcidlink/CreateLink/controller.test.tsx @@ -0,0 +1,250 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'jest-fetch-mock'; +import { FetchMock } from 'jest-fetch-mock/types'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createTestStore } from '../../../app/store'; +import { JSONRPC20Request } from '../common/api/JSONRPC20'; +import ORCIDLinkAPI from '../common/api/ORCIDLInkAPI'; +import { INITIAL_STORE_STATE, INITIAL_STORE_STATE_BAR } from '../test/data'; +import { + makeError2, + makeOrcidlinkServiceMock, + orcidlinkErrors, +} from '../test/orcidlinkServiceMock'; +import CreateLinkController from './controller'; + +describe('The CreateLinkController component', () => { + let windowOpenSpy: jest.SpyInstance; + + let mockService: FetchMock; + + const user = userEvent.setup(); + + beforeEach(() => { + fetchMock.enableMocks(); + fetchMock.doMock(); + mockService = makeOrcidlinkServiceMock(); + + windowOpenSpy = jest + .spyOn(window, 'open') + // annoying to have to add a mock implementation; seems like a + // simple option to disable calling the upstream implementation would + // be so much easier. + .mockImplementation( + ( + url: string | URL | undefined, + _1: string | undefined, + _2: string | undefined + ) => { + // do nothing + return null; + } + ); + }); + afterEach(() => { + mockService.mockClear(); + fetchMock.disableMocks(); + + jest.resetAllMocks(); + }); + + it('renders correctly if not linked', async () => { + const { container } = render( + + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent( + 'Determining whether your account is already linked' + ); + + // After the check reports "false", the view is rendered, with the continue button enabled. + const button = await screen.findByText('Continue to ORCID®'); + expect(button).toBeVisible(); + expect(button).toBeEnabled(); + }); + + it('renders correctly if already linked', async () => { + // We use the initial store state for user "bar", as we have configured the + // mock orcidlink service above to respond that the "bar" user is linked. + const { container } = render( + + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent( + 'Determining whether your account is already linked' + ); + + // After the check reports "false", the view is rendered, with the continue + // button enabled. + await waitFor(async () => { + expect(container).toHaveTextContent('Already Linked'); + }); + }); + + it('renders an error message if an Error is thrown by the api call', async () => { + // We use the initial store state for user "bar", as we have configured the + // mock orcidlink service above to respond that the "bar" user is linked. + + mockService.mockClear(); + fetchMock.disableMocks(); + mockService = makeOrcidlinkServiceMock({ + 'is-linked': { + foo: ({ id, method, params }: JSONRPC20Request) => { + // Force this call to return an auth error. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return makeError2(id!, orcidlinkErrors['1010']); + }, + }, + }); + fetchMock.enableMocks(); + fetchMock.doMock(); + + const { container } = render( + + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent( + 'Determining whether your account is already linked' + ); + + // After the check reports "false", the view is rendered, with the continue + // button enabled. + await waitFor(() => { + expect(container).toHaveTextContent('Authorization Required'); + }); + }); + + it('renders an error message if a non-Error is thrown by the api call', async () => { + jest + .spyOn(ORCIDLinkAPI.prototype, 'isLinked') + .mockImplementation(async () => { + // eslint-disable-next-line no-throw-literal + throw 'Not A Real Error'; + }); + + const { container } = render( + + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent( + 'Determining whether your account is already linked' + ); + + // After the check reports "false", the view is rendered, with the continue + // button enabled. + await waitFor(() => { + expect(container).toHaveTextContent('Unknown error'); + }); + }); + + it('responds as expected when the continue button is clicked', async () => { + const { container } = render( + + + + + } + /> + + + + ); + + // Whle calling "is-linked" this message is displayed + + expect(container).toHaveTextContent( + 'Determining whether your account is already linked' + ); + + // After the check reports "false", the view is rendered, with the continue button enabled. + const button = await screen.findByText('Continue to ORCID®'); + expect(button).toBeVisible(); + expect(button).toBeEnabled(); + + await user.click(button); + + await waitFor(() => { + expect(container).toHaveTextContent('Creating Linking Session'); + }); + + await waitFor(() => { + expect(container).toHaveTextContent('Linking Session Created'); + }); + + await waitFor(() => { + expect(windowOpenSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/features/orcidlink/CreateLink/controller.tsx b/src/features/orcidlink/CreateLink/controller.tsx new file mode 100644 index 00000000..cbf6391f --- /dev/null +++ b/src/features/orcidlink/CreateLink/controller.tsx @@ -0,0 +1,265 @@ +/** + * A controller for the CreateLink component. + * + * The main task of this component is to determine if the user is linked or not. + * If so, then the CreateLink interface is displayed, otherwise an error message. + * + * This component is normally invoked in reaction to an unlinked user pressing a + * button displayed from the orcidlink home view. + * + * As a controller, other than loading the initial external state form the + * orcidlink service, it also provides action functions to allow the user to + * start the process of creating a new link. + * + * Note that we use a more context-specific state machine which is driven by + * changes in the RTK query state. + * + */ + +import { Box } from '@mui/material'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { usePageTitle } from '../../layout/layoutSlice'; +import ORCIDLinkAPI from '../common/api/ORCIDLInkAPI'; +import ErrorMessage, { + CommonError, + makeCommonError, +} from '../common/ErrorMessage'; +import Loading from '../common/Loading'; +import { API_CALL_TIMEOUT, ORCIDLINK_SERVICE_API_ENDPOINT } from '../constants'; +import View from './view'; + +export enum CreateLinkStatus { + NONE = 'NONE', + DETERMINING_ELIGIBILITY = 'DETERMINING_ELIGIBILITY', + CAN_CREATE_SESSION = 'CAN_CREATE_SESSION', + CREATING_SESSION = 'CREATING_SESSION', + SESSION_CREATED = 'SESSION_CREATED', + ERROR = 'ERROR', + CANCELED = 'CANCELED', +} + +export interface CreateLinkStateBase { + status: CreateLinkStatus; +} + +export interface CreateLinkStateNone extends CreateLinkStateBase { + status: CreateLinkStatus.NONE; +} + +export interface CreateLinkStateDeterminigEligibility + extends CreateLinkStateBase { + status: CreateLinkStatus.DETERMINING_ELIGIBILITY; +} + +export interface CreateLinkStateCanCreateSession extends CreateLinkStateBase { + status: CreateLinkStatus.CAN_CREATE_SESSION; +} + +export interface CreateLinkStateCreatingSession extends CreateLinkStateBase { + status: CreateLinkStatus.CREATING_SESSION; +} + +export interface CreateLinkStateSessionCreated extends CreateLinkStateBase { + status: CreateLinkStatus.SESSION_CREATED; + session_id: string; +} + +export interface CreateLinkStateSessionError extends CreateLinkStateBase { + status: CreateLinkStatus.ERROR; + error: CommonError; +} + +export interface CreateLinkStateCanceled extends CreateLinkStateBase { + status: CreateLinkStatus.CANCELED; +} + +export type CreateLinkState = + | CreateLinkStateNone + | CreateLinkStateDeterminigEligibility + | CreateLinkStateCanCreateSession + | CreateLinkStateCreatingSession + | CreateLinkStateSessionCreated + | CreateLinkStateCanceled + | CreateLinkStateSessionError; + +export interface CreateLinkControllerProps { + username: string; + token: string; +} + +export default function CreateLinkController({ + username, + token, +}: CreateLinkControllerProps) { + const navigate = useNavigate(); + + const [state, setState] = useState({ + status: CreateLinkStatus.NONE, + }); + + const createLinkSession = useCallback(async () => { + const orcidLinkService = new ORCIDLinkAPI({ + url: ORCIDLINK_SERVICE_API_ENDPOINT, + timeout: API_CALL_TIMEOUT, + token, + }); + + if (state.status !== CreateLinkStatus.CAN_CREATE_SESSION) { + return; + } + + setState({ + status: CreateLinkStatus.CREATING_SESSION, + }); + + try { + const { session_id } = await orcidLinkService.createLinkingSession({ + username, + }); + + setState({ + status: CreateLinkStatus.SESSION_CREATED, + session_id, + }); + + // Now redirect into the oauth flow... + const pathname = `/services/orcidlink/linking-sessions/${session_id}/oauth/start`; + + // TODO: implement return instructions to allow for landing here from + // somewhere other than the orcidlink home (e.g. user profile); the + // purpose is to allow redirecting to the original location when linking + // is complete. + + // TODO: ui_options - was used during development, not used any more, but + // a good feature to keep for now. It was used to provide a hint to the + // linking ui; the only usage I recall is that when we we implemented + // linking in a pop-out window from the Narrative, we needed a way of + // communicating this to kbase-ui, and elected for the more generalized ui + // options string rather than a specific flag. + + // TODO: skip_prompt - allows skipping the prompt for linking at the final + // confirmation step; in other words, skip the final step. I don't think + // this was being used in the final implementation, but was a feature + // we wanted to be able to exploit if it was felt that confirmation was + // not desired. + + const skipPrompt = 'false'; + + const url = new URL(window.location.origin); + url.pathname = pathname; + url.searchParams.set('skip_prompt', skipPrompt); + window.open(url, '_self'); + } catch (ex) { + const message = ex instanceof Error ? ex.message : 'Unknown error'; + setState({ + status: CreateLinkStatus.ERROR, + error: makeCommonError({ + message: `Cannot create linking session: ${message}`, + }), + }); + } + }, [setState, state, token, username]); + + usePageTitle('KBase ORCID Link - Create Link'); + + const mountedRef = useRef(false); + + useEffect(() => { + if (mountedRef.current) { + return; + } + mountedRef.current = true; + + setState({ + ...state, + status: CreateLinkStatus.DETERMINING_ELIGIBILITY, + }); + + async function initialize(username: string) { + const orcidLinkService = new ORCIDLinkAPI({ + url: ORCIDLINK_SERVICE_API_ENDPOINT, + timeout: API_CALL_TIMEOUT, + token, + }); + + try { + const isLinked = await orcidLinkService.isLinked({ username }); + if (isLinked) { + setState({ + status: CreateLinkStatus.ERROR, + error: makeCommonError({ + message: + 'Your KBase account is already linked to an ORCID account', + title: 'Already Linked', + solutions: [ + { + description: + 'Visit the ORCID Link home page to see your link', + link: { + label: 'KBase ORCID Link Home', + url: '/orcidlink', + }, + }, + ], + }), + }); + } else { + setState({ + status: CreateLinkStatus.CAN_CREATE_SESSION, + }); + } + } catch (ex) { + const message = ex instanceof Error ? ex.message : 'Unknown error'; + setState({ + status: CreateLinkStatus.ERROR, + error: makeCommonError({ + message, + }), + }); + } + } + + initialize(username); + }, [setState, state, token, username, navigate]); + + switch (state.status) { + case CreateLinkStatus.NONE: + // NB returning null because the Authed component's "element" prop, which + // follows the react-router's "element" prop, nevertheless is not typed + // the same. + return null; + case CreateLinkStatus.DETERMINING_ELIGIBILITY: + return ( + + Determining whether your account is already linked... + + ); + + case CreateLinkStatus.CAN_CREATE_SESSION: + return ( + + ); + + case CreateLinkStatus.CREATING_SESSION: + return ( + + ); + + case CreateLinkStatus.SESSION_CREATED: + return ( + + ); + + case CreateLinkStatus.ERROR: + return ( + + + + ); + + case CreateLinkStatus.CANCELED: + navigate('/orcidlink'); + return null; + } +} diff --git a/src/features/orcidlink/CreateLink/index.test.tsx b/src/features/orcidlink/CreateLink/index.test.tsx index 572fb967..2eb82f1a 100644 --- a/src/features/orcidlink/CreateLink/index.test.tsx +++ b/src/features/orcidlink/CreateLink/index.test.tsx @@ -1,135 +1,98 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import 'core-js/actual/structured-clone'; +import fetchMock from 'jest-fetch-mock'; +import { FetchMock } from 'jest-fetch-mock/types'; import { Provider } from 'react-redux'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { createTestStore } from '../../../app/store'; -import { INITIAL_STORE_STATE } from '../test/data'; +import { createTestStore, RootState } from '../../../app/store'; +import { INITIAL_STORE_STATE, INITIAL_STORE_STATE_BAR } from '../test/data'; +import { makeOrcidlinkServiceMock } from '../test/orcidlinkServiceMock'; import CreateLinkIndex from './index'; -describe('The CreateLink component', () => { - const user = userEvent.setup(); - let debugLogSpy: jest.SpyInstance; - beforeEach(() => { - jest.resetAllMocks(); - }); +describe('The CreateLinkIndex component', () => { + let mockService: FetchMock; + beforeEach(() => { - debugLogSpy = jest.spyOn(console, 'debug'); + fetchMock.enableMocks(); + mockService = makeOrcidlinkServiceMock(); }); - async function expectAccordion( - container: HTMLElement, - titleText: string, - contentText: string - ) { - expect(container).toHaveTextContent(titleText); - - const faq1Content = await screen.findByText(new RegExp(contentText), { - exact: false, - }); - - expect(faq1Content).not.toBeVisible(); - - const faq1Title = await screen.findByText(new RegExp(titleText), { - exact: false, - }); - await user.click(faq1Title); - - await waitFor(() => { - expect(faq1Content).toBeVisible(); - }); - } + afterEach(() => { + mockService.mockClear(); + fetchMock.disableMocks(); + }); - it('renders placeholder content', () => { + it('renders correct error if no username', async () => { + const state = structuredClone>(INITIAL_STORE_STATE); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + state.auth!.username = undefined; const { container } = render( - - - - + + ); - expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); - expect(container).toHaveTextContent('FAQs'); - expect(document.title).toBe('KBase: ORCID Link - Create Link'); + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Impossible - username is not present'); }); - it('cancel button returns to the ORCID Link home page', async () => { - const user = userEvent.setup(); - let fakeHomeCalled = false; - function FakeHome() { - fakeHomeCalled = true; - return
FAKE HOME
; - } + it('renders correct error if no username', async () => { + const state = structuredClone>(INITIAL_STORE_STATE); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + state.auth!.token = undefined; const { container } = render( - - - - } /> - } /> - - + + ); - expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); - expect(container).toHaveTextContent('FAQs'); - expect(document.title).toBe('KBase: ORCID Link - Create Link'); - - const cancelButton = await screen.findByText('Cancel'); - await user.click(cancelButton); - - await waitFor(() => { - expect(fakeHomeCalled).toBe(true); - }); + // Whle calling "is-linked" this message is displayed and the continue + // button is disabled. + expect(container).toHaveTextContent('Impossible - no token present'); }); - it('cancel button returns to the ORCID Link home page', async () => { - const user = userEvent.setup(); + it('renders normally for an already-linked user', async () => { const { container } = render( - + - } /> + } /> ); - expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); - expect(container).toHaveTextContent('FAQs'); - expect(document.title).toBe('KBase: ORCID Link - Create Link'); - - const continueButton = await screen.findByText('Continue to ORCID®'); - await user.click(continueButton); - - await waitFor(() => { - expect(debugLogSpy).toHaveBeenCalledWith( - 'WILL START THE LINKING PROCESS' + await waitFor(async () => { + expect(container).toHaveTextContent( + 'Determining whether your account is already linked' ); }); + + await waitFor(async () => { + expect(container).toHaveTextContent('Already Linked'); + }); }); - it('faq accordions are present and work', async () => { + it('renders normally for an unlinked user', async () => { const { container } = render( - + - } /> + } /> ); - expectAccordion( - container, - "What if I don't have an ORCID® Account", - "But what if you don't have an ORCID® account?" - ); + await waitFor(async () => { + expect(container).toHaveTextContent( + 'Determining whether your account is already linked' + ); + }); - expectAccordion( - container, - 'But I already log in with ORCID®', - 'Your ORCID® sign-in link is only used to obtain your ORCID® iD during sign-in' - ); + const button = await screen.findByText('Continue to ORCID®'); + expect(button).toBeVisible(); + expect(button).toBeEnabled(); }); }); diff --git a/src/features/orcidlink/CreateLink/index.tsx b/src/features/orcidlink/CreateLink/index.tsx index 499fea32..53be7fb9 100644 --- a/src/features/orcidlink/CreateLink/index.tsx +++ b/src/features/orcidlink/CreateLink/index.tsx @@ -1,174 +1,64 @@ -import { faArrowRight, faMailReply } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - Button, - Card, - CardActions, - CardContent, - CardHeader, - Unstable_Grid2 as Grid, -} from '@mui/material'; -import { useNavigate } from 'react-router-dom'; -import { usePageTitle } from '../../layout/layoutSlice'; -import { - ORCID_LABEL, - ORCID_LINK_LABEL, - ORCID_SIGN_IN_SCREENSHOT_URL, -} from '../constants'; -import styles from '../orcidlink.module.scss'; +/** + * Entrypoint for the CreateLink component. + * + * It's sole responsibility is to ensure that props required by the controller + * are actually present. It is basically a "filter component", whose purpose is + * to ensure that the child component's prop constraints are met in a case where + * the props come from external, unreliable or are otherwise typed incompatibly. + * In the case of a constraint violation, an error message is displayed. + * + * Note that in this case these constraint violations should never occcur. The + * uncertainty about props is due to the fact that they are derived from app + * state. App state is global, yet this component is only rendered if + * the parent component "Authed" is satisfied. So we "know" that the app is + * authenticated, and that the auth subset of the app state is fully populated. + * + * The problem is that global state and component flow are incompatible, + * incongruent. + * + * If one wants to avoid such a filter component, one solution could be to have + * the Authed component pass auth state to it's children. + * + */ +import { Box } from '@mui/material'; +import { useAppSelector } from '../../../common/hooks'; +import { authUsername } from '../../auth/authSlice'; +import ErrorMessage, { makeCommonError } from '../common/ErrorMessage'; +import styles from '../common/styles.module.scss'; +import Controller from './controller'; -export default function ORCIDLinkCreateLink() { - usePageTitle('ORCID Link - Create Link'); - const navigate = useNavigate(); - return ( - - - - - - -

- You do not currently have a link from your KBase account to an{' '} - {ORCID_LABEL} account. -

- -

- - After clicking the "Continue" button below, you will - be redirected to {ORCID_LABEL} - - , where you may sign in to your {ORCID_LABEL} account and grant - permission to KBase to access certain aspects of your{' '} - {ORCID_LABEL} account. -

+export default function CreateLinkPrecondition() { + const username = useAppSelector(authUsername); + const token = useAppSelector((state) => state.auth.token); -

- What if you don't have an {ORCID_LABEL} Account?{' '} - Check out the FAQs to the right for an answer. -

+ if (!username) { + // return
NO USERNAME
; + return ( + + + + ); + } -

- After finishing at {ORCID_LABEL}, you will be returned to KBase - and asked to confirm the link. Once confirmed, the{' '} - {ORCID_LINK_LABEL} - will be added to your account. -

+ if (!token) { + return ( + + + + ); + } -

- For security purposes, once you start a linking session, you - will have 10 minutes to complete the process. -

- -

- For more information,{' '} - - consult the {ORCID_LINK_LABEL} documentation - - . -

-
- {/* Note that the card actions padding is overridden so that it matches - that of the card content and header. There are a number of formatting - issues with Cards. Some will apparently be fixed in v6. */} - - - - -
-
- - - - - - - What if I don't have an {ORCID_LABEL} Account? - - -

- In order to link your {ORCID_LABEL} account to your KBase - account, you will need to sign in at {ORCID_LABEL}. -

-

- But what if you don't have an {ORCID_LABEL} account? -

-

- When you reach the {ORCID_LABEL} Sign In page, you may elect - to register for a new account. -

- ORCID® Sign In -

- After registering, the linking process will be resumed, just - as if you had simply signed in with an existing{' '} - {ORCID_LABEL} account. -

-
-
- - - But I already log in with {ORCID_LABEL} - - -

- If you already log in with {ORCID_LABEL}, it may seem odd to - need to create a separate {ORCID_LINK_LABEL}. -

-

- Your {ORCID_LABEL} sign-in link is only used to obtain your{' '} - {ORCID_LABEL} iD during sign-in. This is, in turn, used to - look up the associated KBase account and log you in. -

-

- In contrast, {ORCID_LINK_LABEL} provides expanded and - long-term access, which allows KBase to provide tools for - you that that can access limited aspects of your{' '} - {ORCID_LABEL} account. The {ORCID_LINK_LABEL} can be added - or removed at any time without affecting your ability to - sign in to KBase through {ORCID_LABEL}. -

-
-
-
-
-
-
+ return ( + + ); } diff --git a/src/features/orcidlink/CreateLink/view.test.tsx b/src/features/orcidlink/CreateLink/view.test.tsx new file mode 100644 index 00000000..3bd3f15e --- /dev/null +++ b/src/features/orcidlink/CreateLink/view.test.tsx @@ -0,0 +1,307 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createTestStore } from '../../../app/store'; +import { makeCommonError } from '../common/ErrorMessage'; +import { INITIAL_STORE_STATE } from '../test/data'; +import { CreateLinkStatus } from './controller'; +import CreateLinkView from './view'; + +describe('The CreateLink component', () => { + const user = userEvent.setup(); + + async function expectAccordion( + container: HTMLElement, + titleText: string, + contentText: string + ) { + expect(container).toHaveTextContent(titleText); + + const faq1Content = await screen.findByText(new RegExp(contentText), { + exact: false, + }); + + expect(faq1Content).not.toBeVisible(); + + const faq1Title = await screen.findByText(new RegExp(titleText), { + exact: false, + }); + await user.click(faq1Title); + + await waitFor(() => { + expect(faq1Content).toBeVisible(); + }); + } + + it('renders placeholder content', () => { + const { container } = render( + + + { + return; + }} + /> + + + ); + + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + expect(container).toHaveTextContent('FAQs'); + expect(document.title).toBe('KBase: ORCID Link - Create Link'); + }); + + it('renders DETERMINING_ELIGIBILITY state', async () => { + const { container } = render( + + + { + return; + }} + /> + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Determining Eligibility'); + }); + }); + + it('renders CREATING_SESSION state', async () => { + const { container } = render( + + + { + return; + }} + /> + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Creating Linking Session'); + }); + }); + + it('renders SESSION_CREATED state', async () => { + const { container } = render( + + + { + return; + }} + /> + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Linking Session Created'); + }); + }); + + it('renders ERROR state', async () => { + const { container } = render( + + + { + return; + }} + /> + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('An Error Message'); + }); + }); + + it('renders CANCELED state', async () => { + const { container } = render( + + + { + return; + }} + /> + + + ); + + await waitFor(async () => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + + const button = await screen.findByText('Continue to ORCID®'); + expect(button).toBeVisible(); + expect(button).toBeDisabled(); + }); + }); + + it('renders CAN_CREATE_SESSION state', async () => { + const { container } = render( + + + { + return; + }} + /> + + + ); + + await waitFor(async () => { + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + + const button = await screen.findByText('Continue to ORCID®'); + expect(button).toBeVisible(); + expect(button).toBeEnabled(); + }); + }); + + it('cancel button returns to the ORCID Link home page', async () => { + const user = userEvent.setup(); + let fakeHomeCalled = false; + function FakeHome() { + fakeHomeCalled = true; + return
FAKE HOME
; + } + const { container } = render( + + + + { + return; + }} + /> + } + /> + } /> + + + + ); + + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + expect(container).toHaveTextContent('FAQs'); + expect(document.title).toBe('KBase: ORCID Link - Create Link'); + + const cancelButton = await screen.findByText('Cancel'); + await user.click(cancelButton); + + await waitFor(() => { + expect(fakeHomeCalled).toBe(true); + }); + }); + + it('continue button returns to the ORCID Link home page', async () => { + const user = userEvent.setup(); + let createLinkSessionCalled = false; + const { container } = render( + + + + { + createLinkSessionCalled = true; + }} + /> + } + /> + + + + ); + + expect(container).toHaveTextContent('Create Your KBase ORCID® Link'); + expect(container).toHaveTextContent('FAQs'); + expect(document.title).toBe('KBase: ORCID Link - Create Link'); + + const continueButton = await screen.findByText('Continue to ORCID®'); + + await user.click(continueButton); + + await waitFor(() => { + expect(createLinkSessionCalled).toBe(true); + }); + }); + + it('faq accordions are present and work', async () => { + const { container } = render( + + + + { + return; + }} + /> + } + /> + + + + ); + + expectAccordion( + container, + "What if I don't have an ORCID® Account", + "But what if you don't have an ORCID® account?" + ); + + expectAccordion( + container, + 'But I already log in with ORCID®', + 'Your ORCID® sign-in link is only used to obtain your ORCID® iD during sign-in' + ); + }); +}); diff --git a/src/features/orcidlink/CreateLink/view.tsx b/src/features/orcidlink/CreateLink/view.tsx new file mode 100644 index 00000000..5ac96596 --- /dev/null +++ b/src/features/orcidlink/CreateLink/view.tsx @@ -0,0 +1,242 @@ +/** + * The CreateLink component user interface. + * + * The primary purpose of this component is to display a user interface to + * allow a user to create a link. + */ +import { faArrowRight, faMailReply } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + Unstable_Grid2 as Grid, +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { usePageTitle } from '../../layout/layoutSlice'; +import Loading from '../common/Loading'; +import { + ORCID_LABEL, + ORCID_LINK_LABEL, + ORCID_SIGN_IN_SCREENSHOT_URL, +} from '../constants'; +import { CreateLinkState, CreateLinkStatus } from './controller'; + +export interface ORCIDLinkCreateLinkProps { + createLinkState: CreateLinkState; + createLinkSession: () => void; +} + +export default function ORCIDLinkCreateLink({ + createLinkState, + createLinkSession, +}: ORCIDLinkCreateLinkProps) { + usePageTitle('ORCID Link - Create Link'); + const navigate = useNavigate(); + + function renderStatus() { + switch (createLinkState.status) { + case CreateLinkStatus.NONE: + return; + case CreateLinkStatus.DETERMINING_ELIGIBILITY: + return ( + + + + ); + case CreateLinkStatus.CREATING_SESSION: + return ( + + + + ); + case CreateLinkStatus.SESSION_CREATED: + return ( + + + Linking Session Created, Redirecting... + + + ); + case CreateLinkStatus.ERROR: + return ( + + {createLinkState.error.message} + + ); + case CreateLinkStatus.CANCELED: + case CreateLinkStatus.CAN_CREATE_SESSION: + return null; + } + } + + return ( + + + + + +

+ You do not currently have a link from your KBase account to an{' '} + {ORCID_LABEL} account. +

+ +

+ + After clicking the "Continue" button below, you will + be redirected to {ORCID_LABEL} + + , where you may sign in to your {ORCID_LABEL} account and grant + permission to KBase to access certain aspects of your{' '} + {ORCID_LABEL} account. +

+ +

+ What if you don't have an {ORCID_LABEL} Account? Check + out the FAQs to the right for an answer. +

+ +

+ After finishing at {ORCID_LABEL}, you will be returned to KBase + and asked to confirm the link. Once confirmed, the{' '} + {ORCID_LINK_LABEL} + will be added to your account. +

+ +

+ For security purposes, once you continue to ORCID (start a linking + session), you will have 10 minutes to complete the process. +

+ +

+ For more information,{' '} + + consult the {ORCID_LINK_LABEL} documentation + + . +

+
+ {/* Note that the card actions padding is overridden so that it matches + that of the card content and header. There are a number of formatting + issues with Cards. Some will apparently be fixed in v6. */} + +
+
+ + +
+ {renderStatus()} +
+
+
+
+ + + + + + + What if I don't have an {ORCID_LABEL} Account? + + +

+ In order to link your {ORCID_LABEL} account to your KBase + account, you will need to sign in at {ORCID_LABEL}. +

+

But what if you don't have an {ORCID_LABEL} account?

+

+ When you reach the {ORCID_LABEL} Sign In page, you may elect + to register for a new account. +

+ ORCID® Sign In +

+ After registering, the linking process will be resumed, just + as if you had simply signed in with an existing {ORCID_LABEL}{' '} + account. +

+
+
+ + + But I already log in with {ORCID_LABEL} + + +

+ If you already log in with {ORCID_LABEL}, it may seem odd to + need to create a separate {ORCID_LINK_LABEL}. +

+

+ Your {ORCID_LABEL} sign-in link is only used to obtain your{' '} + {ORCID_LABEL} iD during sign-in. This is, in turn, used to + look up the associated KBase account and log you in. +

+

+ In contrast, {ORCID_LINK_LABEL} provides expanded and + long-term access, which allows KBase to provide tools for you + that that can access limited aspects of your {ORCID_LABEL}{' '} + account. The {ORCID_LINK_LABEL} can be added or removed at any + time without affecting your ability to sign in to KBase + through {ORCID_LABEL}. +

+
+
+
+
+
+
+ ); +} diff --git a/src/features/orcidlink/Home/Home.test.tsx b/src/features/orcidlink/Home/Home.test.tsx deleted file mode 100644 index 3a3bc776..00000000 --- a/src/features/orcidlink/Home/Home.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { SERVICE_INFO_1 } from '../test/data'; -import Home from './Home'; - -// We are not testing the HomeLinked component; we just want to be sure that it -// is rendered. -jest.mock('../HomeLinked', () => { - return { - __esModule: true, - default: () => { - return
Mocked Linked Component
; - }, - }; -}); - -describe('The Home Component', () => { - it('renders correctly for unlinked', () => { - const { container } = render( - - - - ); - - expect(container).not.toBeNull(); - expect(container).toHaveTextContent( - 'You do not currently have a link from your KBase account' - ); - }); - - it('renders correctly for linked', () => { - const { container } = render( - - - - ); - - expect(container).not.toBeNull(); - expect(container).toHaveTextContent('Mocked Linked Component'); - }); -}); diff --git a/src/features/orcidlink/Home/Home.tsx b/src/features/orcidlink/Home/Home.tsx deleted file mode 100644 index 1e0a77b5..00000000 --- a/src/features/orcidlink/Home/Home.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/** - * The entrypoint to the root of the ORCID Link UI. - * - * Its primary responsibility is to branch to a view for a linked user or an - * unlinked user. - */ -import { InfoResult } from '../../../common/api/orcidlinkAPI'; -import HomeLinked from '../HomeLinked'; -import HomeUnlinked from '../HomeUnlinked'; - -export interface HomeProps { - isLinked: boolean; - info: InfoResult; -} - -export default function Home({ isLinked, info }: HomeProps) { - if (isLinked) { - return ; - } - return ; -} diff --git a/src/features/orcidlink/Home/controller.test.tsx b/src/features/orcidlink/Home/controller.test.tsx new file mode 100644 index 00000000..36bb8fce --- /dev/null +++ b/src/features/orcidlink/Home/controller.test.tsx @@ -0,0 +1,87 @@ +import { render, waitFor } from '@testing-library/react'; +import fetchMock, { FetchMock } from 'jest-fetch-mock'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { createTestStore } from '../../../app/store'; +import { INITIAL_STORE_STATE } from '../test/data'; +import { makeOrcidlinkServiceMock } from '../test/orcidlinkServiceMock'; +import HomeController from './controller'; + +// We mock the sub-components because we don't really want to evaluate whether +// they work correctly, just that they are invoked. +jest.mock('../HomeLinked', () => { + return { + __esModule: true, + default: () => { + return
Mocked Linked Component
; + }, + }; +}); + +jest.mock('../HomeUnlinked', () => { + return { + __esModule: true, + default: () => { + return
Mocked UnLinked Component
; + }, + }; +}); + +describe('The HomeController Component', () => { + let mockService: FetchMock; + + beforeEach(() => { + fetchMock.resetMocks(); + fetchMock.enableMocks(); + mockService = makeOrcidlinkServiceMock(); + }); + + afterEach(() => { + mockService.mockClear(); + fetchMock.disableMocks(); + }); + + it('renders mocked "Linked" component if user is linked', async () => { + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Mocked Linked Component'); + }); + }); + + it('renders "Unlinked" component if user is not linked', async () => { + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Mocked UnLinked Component'); + }); + }); + + it('renders a parse error correctly', async () => { + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent( + `SyntaxError: Unexpected token 'b', "bad" is not valid JSON` + ); + }); + }); +}); diff --git a/src/features/orcidlink/Home/controller.tsx b/src/features/orcidlink/Home/controller.tsx new file mode 100644 index 00000000..0dee15df --- /dev/null +++ b/src/features/orcidlink/Home/controller.tsx @@ -0,0 +1,43 @@ +/** + * This is the "controller" component for the Home view. + * + * It is responsible for determining whether the user is already linked or not. + * If linked it displays the HomeLinked view, which shows the user their link. + * If not, it displays HomeUnlinked, which explains orcidlink and allows the + * user to create a link. + * + */ +import { orcidlinkAPI } from '../../../common/api/orcidlinkAPI'; +import { usePageTitle } from '../../layout/layoutSlice'; +import ErrorMessage from '../common/ErrorMessage'; +import Loading from '../common/Loading'; +import HomeLinked from '../HomeLinked'; +import HomeUnlinked from '../HomeUnlinked'; + +export interface HomeControllerProps { + username: string; +} + +export default function HomeController({ username }: HomeControllerProps) { + usePageTitle('KBase ORCID Link'); + + const { data, error, isFetching, isError, isSuccess } = + orcidlinkAPI.useOrcidlinkInitialStateQuery({ username }); + + if (isFetching) { + return ( + + Checking for existing link to your account... + + ); + } else if (isError) { + return ; + } else if (isSuccess) { + if (data.isLinked) { + return ; + } + return ; + } else { + return <>; + } +} diff --git a/src/features/orcidlink/Home/index.test.tsx b/src/features/orcidlink/Home/index.test.tsx index 8d0e2cd5..da9ad2ee 100644 --- a/src/features/orcidlink/Home/index.test.tsx +++ b/src/features/orcidlink/Home/index.test.tsx @@ -1,16 +1,16 @@ -import { act, render, waitFor } from '@testing-library/react'; -import fetchMock, { MockResponseInit } from 'jest-fetch-mock'; +import { render, waitFor } from '@testing-library/react'; +import fetchMock, { FetchMock, MockResponseInit } from 'jest-fetch-mock'; import { ErrorBoundary } from 'react-error-boundary'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; +import HomeEntrypoint from '.'; import { createTestStore } from '../../../app/store'; -import { setAuth } from '../../auth/authSlice'; import { INITIAL_STORE_STATE, + INITIAL_STORE_STATE_BAR, INITIAL_UNAUTHENTICATED_STORE_STATE, } from '../test/data'; -import { mockIsLinkedNotResponse, mockIsLinkedResponse } from '../test/mocks'; -import HomeController from './index'; +import { makeOrcidlinkServiceMock } from '../test/orcidlinkServiceMock'; jest.mock('../HomeLinked', () => { return { @@ -21,40 +21,29 @@ jest.mock('../HomeLinked', () => { }; }); -describe('The HomeController Component', () => { +describe('The HomeEntrypoint Component', () => { + let mockService: FetchMock; + beforeEach(() => { fetchMock.resetMocks(); fetchMock.enableMocks(); + mockService = makeOrcidlinkServiceMock(); }); - it('renders mocked "Linked" component if user is linked', async () => { - fetchMock.mockResponseOnce( - async (request): Promise => { - const { pathname } = new URL(request.url); - switch (pathname) { - case '/services/orcidlink/api/v1': { - if (request.method !== 'POST') { - return ''; - } - const body = await request.json(); - switch (body['method']) { - case 'is-linked': { - return mockIsLinkedResponse(body); - } - default: - return ''; - } - } - default: - return ''; - } - } - ); + afterEach(() => { + mockService.mockClear(); + fetchMock.disableMocks(); + }); + /** + * The INITIAL_STORE_STATE for orcidlink tests establishes authentication for + * user "bar", who does have a link. + */ + it('renders mocked "Linked" component if user is linked', async () => { const { container } = render( - - - + + + ); @@ -64,34 +53,15 @@ describe('The HomeController Component', () => { }); }); + /** + * The INITIAL_STORE_STATE for orcidlink tests establishes authentication for + * user "foo", who does not have a link. + */ it('renders "Unlinked" component if user is not linked', async () => { - fetchMock.mockResponseOnce( - async (request): Promise => { - if (request.method !== 'POST') { - return ''; - } - const { pathname } = new URL(request.url); - switch (pathname) { - case '/services/orcidlink/api/v1': { - const body = await request.json(); - switch (body['method']) { - case 'is-linked': { - return mockIsLinkedNotResponse(body); - } - default: - return ''; - } - } - default: - return ''; - } - } - ); - const { container } = render( - + ); @@ -103,72 +73,11 @@ describe('The HomeController Component', () => { }); }); - it('re-renders correctly', async () => { - fetchMock.mockResponse( - async (request): Promise => { - if (request.method !== 'POST') { - return ''; - } - const { pathname } = new URL(request.url); - switch (pathname) { - // MOcks for the orcidlink api - case '/services/orcidlink/api/v1': { - const body = await request.json(); - switch (body['method']) { - case 'is-linked': { - // In this mock, user "foo" is linked, user "bar" is not. - return mockIsLinkedResponse(body); - } - default: - return ''; - } - } - default: - return ''; - } - } - ); - - const testStore = createTestStore(INITIAL_STORE_STATE); - - const { container } = render( - - - - - - ); - - await waitFor(() => { - expect(container).toHaveTextContent('Mocked Linked Component'); - }); - - act(() => { - testStore.dispatch( - setAuth({ - token: 'xyz123', - username: 'bar', - tokenInfo: { - created: 123, - expires: 456, - id: 'xyz123', - name: 'Bar Baz', - type: 'Login', - user: 'bar', - cachefor: 890, - }, - }) - ); - }); - - await waitFor(() => { - expect(container).toHaveTextContent( - 'You do not currently have a link from your KBase account to an ORCID® account.' - ); - }); - }); - it('renders a parse error correctly', async () => { + // Note that we use a custom response here, in order to trigger an error. We + // could use another technique to utilize the same response for user + // "not_json". I've left this here as an example of one-off mocks. + fetchMock.mockResponseOnce( async (request): Promise => { if (request.method !== 'POST') { @@ -198,10 +107,17 @@ describe('The HomeController Component', () => { } ); + const initialState = structuredClone(INITIAL_STORE_STATE); + + // Apropos of the comment above, this line would switch the user in auth + // state to "not_json", which in orcidlinkServiceMock.ts is programmed to + // return an erroneous response body, just like above. + // initialState.auth.username = 'not_json'; + const { container } = render( - - - + + + ); @@ -222,7 +138,7 @@ describe('The HomeController Component', () => { > - + diff --git a/src/features/orcidlink/Home/index.tsx b/src/features/orcidlink/Home/index.tsx index 71417558..5762f83e 100644 --- a/src/features/orcidlink/Home/index.tsx +++ b/src/features/orcidlink/Home/index.tsx @@ -1,26 +1,43 @@ -import { orcidlinkAPI } from '../../../common/api/orcidlinkAPI'; +/** + * This is the initial component of the "Home" component. + * + * Generally, the home component presents the appropriate view and options when + * a user navigates to "KBASE ORCID Link". + * + * Generally, the orcidlink home component determines whether the user has a + * link or not, and shows the appropriate component, HomeLinked or HomeUnlinked. + * + * This specific component exists to enforce the existence of the "username" + * parameter extracted from the state, even though this component is invoked by + * the Authed component, which ensures that the application state is authenticated. + * + * Another approach could be for Authed to pass auth state to the sub-component. + */ +import { Box } from '@mui/material'; import { useAppSelector } from '../../../common/hooks'; import { authUsername } from '../../auth/authSlice'; -import { usePageTitle } from '../../layout/layoutSlice'; -import ErrorMessage from '../common/ErrorMessage'; -import Home from './Home'; +import ErrorMessage, { makeCommonError } from '../common/ErrorMessage'; +import styles from '../common/styles.module.scss'; +import HomeController from './controller'; -export default function HomeController() { +export default function HomeEntrypoint() { const username = useAppSelector(authUsername); if (typeof username === 'undefined') { - throw new Error('Impossible - username is not defined'); + return ( + + + + ); } - usePageTitle('KBase ORCID Link'); - - const { data, error, isError, isSuccess } = - orcidlinkAPI.useOrcidlinkInitialStateQuery({ username }); - - if (isError) { - return ; - } else if (isSuccess) { - return ; - } - return <>; + return ( + + + + ); } diff --git a/src/features/orcidlink/HomeLinked/ManageTab.tsx b/src/features/orcidlink/HomeLinked/ManageTab.tsx index eb5312ba..fbbb129e 100644 --- a/src/features/orcidlink/HomeLinked/ManageTab.tsx +++ b/src/features/orcidlink/HomeLinked/ManageTab.tsx @@ -1,3 +1,13 @@ +/** + * Implements the "Manage Your Link" tab for the "HomeLinked" component. + * + * The primary task of this tab view is to provide options for the user to + * control their orcid link. + * + * At present this consists of removing their link and controlling whether their + * orcid id and link appears in their user profile. + * + */ import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { diff --git a/src/features/orcidlink/HomeLinked/OverviewTab.tsx b/src/features/orcidlink/HomeLinked/OverviewTab.tsx index 6571413b..c28d9cb6 100644 --- a/src/features/orcidlink/HomeLinked/OverviewTab.tsx +++ b/src/features/orcidlink/HomeLinked/OverviewTab.tsx @@ -1,3 +1,9 @@ +/** + * Implements the "Overview" tab of the linked home view. + * + * THe primary job of this tab view is to display a summary of the user's + * orcidlink and orcid profile. + */ import { Card, CardContent, @@ -10,8 +16,8 @@ import { LinkRecordPublic, ORCIDProfile, } from '../../../common/api/orcidLinkCommon'; +import LinkInfo from '../common/LinkInfo'; import MoreInformation from '../common/MoreInformation'; -import LinkInfo from './LinkInfo'; export interface OverviewTabProps { info: InfoResult; diff --git a/src/features/orcidlink/HomeLinked/index.test.tsx b/src/features/orcidlink/HomeLinked/index.test.tsx index 9a821ce7..639663ec 100644 --- a/src/features/orcidlink/HomeLinked/index.test.tsx +++ b/src/features/orcidlink/HomeLinked/index.test.tsx @@ -1,20 +1,17 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import fetchMock from 'jest-fetch-mock'; -import { ErrorBoundary } from 'react-error-boundary'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { createTestStore } from '../../../app/store'; +import { JSONRPC20Request } from '../common/api/JSONRPC20'; import { INITIAL_STORE_STATE, - INITIAL_UNAUTHENTICATED_STORE_STATE, + LINK_RECORD_1, PROFILE_1, SERVICE_INFO_1, } from '../test/data'; -import { - setupMockRegularUser, - setupMockRegularUserWithError, -} from '../test/mocks'; +import { makeJSONRPC20Server } from '../test/jsonrpc20ServiceMock'; import HomeLinkedController from './index'; describe('The HomeLinkedController component', () => { @@ -28,22 +25,47 @@ describe('The HomeLinkedController component', () => { debugLogSpy = jest.spyOn(console, 'debug'); }); - it('renders normally for a normal user', async () => { - setupMockRegularUser(); - + it('renders normally for a normal, linked user', async () => { const info = SERVICE_INFO_1; + makeJSONRPC20Server([ + { + path: '/services/orcidlink/api/v1', + method: 'owner-link', + result: (rpc: JSONRPC20Request) => { + return LINK_RECORD_1; + }, + }, + { + path: '/services/orcidlink/api/v1', + method: 'get-orcid-profile', + result: (rpc: JSONRPC20Request) => { + return PROFILE_1; + }, + }, + { + path: '/services/orcidlink/api/v1', + method: 'info', + result: (rpc: JSONRPC20Request) => { + return SERVICE_INFO_1; + }, + }, + ]); + render( - + ); // Now poke around and make sure things are there. await waitFor(async () => { - expect(screen.queryByText('Loading ORCID Link')).toBeVisible(); + expect(screen.queryByText('Fetching ORCID Link')).toBeVisible(); }); screen.queryByText('5/1/24'); @@ -58,17 +80,43 @@ describe('The HomeLinkedController component', () => { }); it('renders an error if something goes wrong', async () => { - /** - We arrange for something to go wrong. How about ... token doesn't exist. - */ - setupMockRegularUserWithError(); + // We arrange for something to go wrong. How about ... token doesn't exist. + makeJSONRPC20Server([ + { + path: '/services/orcidlink/api/v1', + method: 'owner-link', + error: (rpc: JSONRPC20Request) => { + return { + code: 1010, + message: 'Authorization Required', + }; + }, + }, + { + path: '/services/orcidlink/api/v1', + method: 'get-orcid-profile', + result: (rpc: JSONRPC20Request) => { + return PROFILE_1; + }, + }, + { + path: '/services/orcidlink/api/v1', + method: 'info', + result: (rpc: JSONRPC20Request) => { + return SERVICE_INFO_1; + }, + }, + ]); const info = SERVICE_INFO_1; const { container } = render( - + ); @@ -78,48 +126,52 @@ describe('The HomeLinkedController component', () => { }); }); - it('throws an impossible error if called without authentication', async () => { - const { container } = render( - { - return
{error.message}
; - }} - onError={() => { - // noop - }} - > - - - - - -
- ); - - await waitFor(() => { - expect(container).toHaveTextContent( - 'Impossible - username is not defined' - ); - }); - }); - it('responds as expected to the remove link button being pressed', async () => { const user = userEvent.setup(); - setupMockRegularUser(); + + makeJSONRPC20Server([ + { + path: '/services/orcidlink/api/v1', + method: 'owner-link', + result: (rpc: JSONRPC20Request) => { + return LINK_RECORD_1; + }, + }, + { + path: '/services/orcidlink/api/v1', + method: 'get-orcid-profile', + result: (rpc: JSONRPC20Request) => { + return PROFILE_1; + }, + }, + { + path: '/services/orcidlink/api/v1', + method: 'info', + result: (rpc: JSONRPC20Request) => { + return SERVICE_INFO_1; + }, + }, + ]); render( - + ); // Now poke around and make sure things are there. + + // A loading indicator should appear, briefly. await waitFor(async () => { - expect(screen.queryByText('Loading ORCID Link')).toBeVisible(); + expect(screen.queryByText('Fetching ORCID Link')).toBeVisible(); }); + // THe user's ORCID profile summary should be displayed. await waitFor(async () => { // Ensure some expected fields are rendered. expect( @@ -129,16 +181,16 @@ describe('The HomeLinkedController component', () => { expect(screen.queryByText('5/1/24')).toBeVisible(); }); - // First need to open the manage tab + // Now to test what we are here for... + // First open the manage tab const tab = await screen.findByText('Manage Your Link'); expect(tab).not.toBeNull(); await user.click(tab); + // Ensure that the "card" with the expected title is displayed await waitFor(() => { - expect( - screen.queryByText('Remove your KBase ORCID® Link') - ).not.toBeNull(); + expect(screen.queryByText('Remove your KBase ORCID® Link')).toBeVisible(); }); // Now find and click the Remove button @@ -152,12 +204,15 @@ describe('The HomeLinkedController component', () => { expect(title).toBeVisible(); }); + // And now we locate and click the button that will remove the link. const confirmButton = await screen.findByText( 'Yes, go ahead and remove this link' ); expect(confirmButton).toBeVisible(); await user.click(confirmButton); + // Since we haven't implemented the removal yet, console logging is used to + // provide something to test for. await waitFor(() => { expect(debugLogSpy).toHaveBeenCalledWith('WILL REMOVE LINK'); }); diff --git a/src/features/orcidlink/HomeLinked/index.tsx b/src/features/orcidlink/HomeLinked/index.tsx index 209ed186..68673028 100644 --- a/src/features/orcidlink/HomeLinked/index.tsx +++ b/src/features/orcidlink/HomeLinked/index.tsx @@ -1,28 +1,41 @@ +/** + * The initial, or "home", orcidlink view for a user with an existing link. + * + * The main job of this specific component is as a controller - to fetch the + * orcidlink for the user so that it may be displayed to the user - and to + * provide action properties to allow the user to take actions - remove link, + * toggle visibility in the user profile. + * + * TODO: at present the actions are not implemented; the functionality has been + * developed but is not implemented here yet in order to reduce the scope of + * this component. + */ import { InfoResult, orcidlinkAPI } from '../../../common/api/orcidlinkAPI'; -import { useAppSelector } from '../../../common/hooks'; -import { authUsername } from '../../auth/authSlice'; import ErrorMessage from '../common/ErrorMessage'; -import LoadingOverlay from '../common/LoadingOverlay'; +import Loading from '../common/Loading'; import HomeLinked from './view'; export interface HomeLinkedControllerProps { info: InfoResult; + username: string; } export default function HomeLinkedController({ info, + username, }: HomeLinkedControllerProps) { - const username = useAppSelector(authUsername); - - if (typeof username === 'undefined') { - throw new Error('Impossible - username is not defined'); - } - - const { data, error, isError, isFetching, isSuccess } = - orcidlinkAPI.useOrcidlinkLinkedUserInfoQuery( - { username }, - { refetchOnMountOrArgChange: true } - ); + const { + data, + error, + isUninitialized, + isError, + isFetching, + isLoading, + isSuccess, + } = orcidlinkAPI.useOrcidlinkLinkedUserInfoQuery( + { username }, + { refetchOnMountOrArgChange: true } + ); const removeLink = () => { // This console output is only for this intermediate state of the code, to facilitate testing. @@ -36,28 +49,24 @@ export default function HomeLinkedController({ console.debug('TOGGLE SHOW IN PROFILE'); }; - // Renderers - function renderState() { - if (isError) { - return ; - } else if (isSuccess) { - const { linkRecord, profile } = data; - return ( - - ); - } + if (isUninitialized || isLoading || isFetching) { + return ; + } else if (isError) { + return ; + } else if (isSuccess) { + const { linkRecord, profile } = data; + return ( + + ); + } else { + // I don't think this case is even possible, but TS doesn't know that due to + // the design of RTK query states. + return null; } - - return ( - <> - - {renderState()} - - ); } diff --git a/src/features/orcidlink/HomeUnlinked.test.tsx b/src/features/orcidlink/HomeUnlinked/index.test.tsx similarity index 91% rename from src/features/orcidlink/HomeUnlinked.test.tsx rename to src/features/orcidlink/HomeUnlinked/index.test.tsx index 9c35e1f5..f052db41 100644 --- a/src/features/orcidlink/HomeUnlinked.test.tsx +++ b/src/features/orcidlink/HomeUnlinked/index.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import HomeUnlinked from './HomeUnlinked'; +import HomeUnlinked from '.'; describe('The HomeUnlinked Component', () => { it('renders correctly', () => { diff --git a/src/features/orcidlink/HomeUnlinked.tsx b/src/features/orcidlink/HomeUnlinked/index.tsx similarity index 88% rename from src/features/orcidlink/HomeUnlinked.tsx rename to src/features/orcidlink/HomeUnlinked/index.tsx index 0963db30..3c106215 100644 --- a/src/features/orcidlink/HomeUnlinked.tsx +++ b/src/features/orcidlink/HomeUnlinked/index.tsx @@ -1,5 +1,8 @@ /** * Displays the "home page", or primary orcidlink view, for unlinked users. + * + * The main task fo this view is to explain briefly what an orcid link is, and + * to provide a button for the user to initiate the process of creating a link. */ import { faPlus } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -13,14 +16,14 @@ import { Unstable_Grid2 as Grid, } from '@mui/material'; import { Link } from 'react-router-dom'; -import MoreInformation from './common/MoreInformation'; +import MoreInformation from '../common/MoreInformation'; -export default function Unlinked() { +export default function HomeUnlinked() { return ( - + diff --git a/src/features/orcidlink/ServiceError/index.test.tsx b/src/features/orcidlink/ServiceError/index.test.tsx new file mode 100644 index 00000000..fd8a1e01 --- /dev/null +++ b/src/features/orcidlink/ServiceError/index.test.tsx @@ -0,0 +1,57 @@ +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import ServiceErrorController from './index'; + +describe('The ServiceError view component', () => { + it('renders correctly', () => { + const TEST_CODE = '123'; + const TEST_MESSAGE = 'My Error Message'; + const { container } = render( + + + + ); + + expect(container).toHaveTextContent(TEST_CODE); + expect(container).toHaveTextContent(TEST_MESSAGE); + }); + + it('renders correctly if params missing', () => { + const testCases: Array> = [ + { + code: '123', + }, + { + message: 'My Error Message', + }, + {}, + ]; + for (const testCase of testCases) { + const searchParams = new URLSearchParams(testCase); + const { container } = render( + + + + ); + + if ('code' in testCase) { + expect(container).toHaveTextContent(testCase.code); + } + if ('message' in testCase) { + expect(container).toHaveTextContent(testCase.message); + } + expect(container).toHaveTextContent('n/a'); + } + }); +}); diff --git a/src/features/orcidlink/ServiceError/index.tsx b/src/features/orcidlink/ServiceError/index.tsx new file mode 100644 index 00000000..a5065bba --- /dev/null +++ b/src/features/orcidlink/ServiceError/index.tsx @@ -0,0 +1,30 @@ +/** + * Entrypoint, or "controller", for the ServicError component. + * + * As with most other components in the orcidlink feature, we use an + * intermediary component to interface with the system to the extent we can. + * This component looks for parameters expected from the url and provides + * default values. + * + * It then invokes the "view" component with these parameter values. + * + * What is a service error? During the oauth flow, it is possible for the + * service to encounter an error. During the oauth flow we are not using the JSON-RPC + * api, but rather a interactive browser navigation. An error is handled by + * redirecting to this endpoint, with the error code and message. This, then, is + * simply displayed here. + * + * TODO: this is a placeholder for the final implementation, which will call the + * orcidlink to obtain the definition of the error, given the code. + */ +import { useSearchParams } from 'react-router-dom'; +import ORCIDLinkServiceErrorView from './view'; + +export default function ORCIDLinkServiceErrorController() { + const [searchParams] = useSearchParams(); + + const code = searchParams.get('code') || 'n/a'; + const message = searchParams.get('message') || 'n/a'; + + return ; +} diff --git a/src/features/orcidlink/ServiceError/view.test.tsx b/src/features/orcidlink/ServiceError/view.test.tsx new file mode 100644 index 00000000..3cc00006 --- /dev/null +++ b/src/features/orcidlink/ServiceError/view.test.tsx @@ -0,0 +1,15 @@ +import { render } from '@testing-library/react'; +import ServiceErrorView from './view'; + +describe('The ServiceError view component', () => { + it('renders correctly', () => { + const TEST_CODE = '123'; + const TEST_MESSAGE = 'My Error Message'; + const { container } = render( + + ); + + expect(container).toHaveTextContent(TEST_CODE); + expect(container).toHaveTextContent(TEST_MESSAGE); + }); +}); diff --git a/src/features/orcidlink/ServiceError/view.tsx b/src/features/orcidlink/ServiceError/view.tsx new file mode 100644 index 00000000..7de7f3df --- /dev/null +++ b/src/features/orcidlink/ServiceError/view.tsx @@ -0,0 +1,27 @@ +/** + * The "view" component for the ServiceError component. + * + * It should display the provided orcidlink service error code and message. + * + * TODO: when the controller fetches the error definition, that will be + * displayed as well. + */ + +import { Alert, AlertTitle } from '@mui/material'; + +export interface ORCIDLinkServiceErrorProps { + code: string; + message: string; +} + +export default function ORCIDLinkServiceError({ + code, + message, +}: ORCIDLinkServiceErrorProps) { + return ( + + Code: {code} + {message} + + ); +} diff --git a/src/features/orcidlink/common/CountdownClock.test.tsx b/src/features/orcidlink/common/CountdownClock.test.tsx new file mode 100644 index 00000000..85f64204 --- /dev/null +++ b/src/features/orcidlink/common/CountdownClock.test.tsx @@ -0,0 +1,191 @@ +import { render, waitFor } from '@testing-library/react'; +import CountdownClock, { formatTimeSpan } from './CountdownClock'; + +describe('The CountdownClock component', () => { + it('renders correctly', async () => { + const START = Date.now(); + const END = START + 3000; + let ended = false; + const ON_END = () => { + ended = true; + }; + + const { container } = render( + + ); + + await waitFor( + () => { + expect(container).toHaveTextContent('3 seconds'); + }, + { timeout: 2000 } + ); + await waitFor( + () => { + expect(container).toHaveTextContent('2 seconds'); + }, + { timeout: 2000 } + ); + await waitFor( + () => { + expect(container).toHaveTextContent('1 second'); + }, + { timeout: 2000 } + ); + + await waitFor( + () => { + expect(ended).toBe(true); + }, + { timeout: 2000 } + ); + }); +}); + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const WEEK = 7 * DAY; + +describe('The formatTimeSpan function', () => { + it('generates a bunch of time ranges correctly', async () => { + const cases = [ + { + params: { + span: 0, + }, + expected: '', + }, + { + params: { + span: 1 * SECOND, + }, + expected: '1 second', + }, + { + params: { + span: 2 * SECOND, + }, + expected: '2 seconds', + }, + { + params: { + span: MINUTE, + }, + expected: '1 minute', + }, + { + params: { + span: MINUTE + 30 * SECOND, + }, + expected: '1 minute, 30 seconds', + }, + { + params: { + // if it rounds to a minute, it will be a minute. + span: MINUTE - 200, + }, + expected: '1 minute', + }, + { + params: { + // if not, seconds. + span: MINUTE - 800, + }, + expected: '59 seconds', + }, + { + params: { + span: MINUTE + 30 * SECOND, + }, + expected: '1 minute, 30 seconds', + }, + { + params: { + span: 10 * MINUTE, + }, + expected: '10 minutes', + }, + { + params: { + span: HOUR - SECOND, + }, + expected: '59 minutes, 59 seconds', + }, + { + params: { + span: HOUR, + }, + expected: '1 hour', + }, + { + params: { + span: HOUR - 0.5 * SECOND, + }, + expected: '1 hour', + }, + { + params: { + span: HOUR - MINUTE + 10 * SECOND, + }, + expected: '59 minutes, 10 seconds', + }, + { + params: { + span: 2 * HOUR + 20 * MINUTE, + }, + expected: '2 hours, 20 minutes', + }, + { + params: { + span: 3 * HOUR, + }, + expected: '3 hours', + }, + { + params: { + span: DAY, + }, + expected: '1 day', + }, + + { + params: { + span: DAY - HOUR + 59 * MINUTE + 59 * SECOND + 0.5 * SECOND, + }, + expected: '1 day', + }, + + { + params: { + span: DAY - HOUR + 10 * MINUTE, + }, + expected: '23 hours, 10 minutes', + }, + { + params: { + span: 4 * DAY, + }, + expected: '4 days', + }, + { + params: { + span: 1 * WEEK, + }, + expected: '1 week', + }, + { + params: { + span: 13 * DAY, + }, + expected: '1 week, 6 days', + }, + ]; + + for (const { params, expected } of cases) { + const result = formatTimeSpan(params.span); + expect(result).toBe(expected); + } + }); +}); diff --git a/src/features/orcidlink/common/CountdownClock.tsx b/src/features/orcidlink/common/CountdownClock.tsx new file mode 100644 index 00000000..212dad3e --- /dev/null +++ b/src/features/orcidlink/common/CountdownClock.tsx @@ -0,0 +1,174 @@ +/** + * The CountdownClock component implements a simple display of the amount of + * time between now and some time in the future. + * + * At the heart is the display of a time range in clock and calendar units of + * weeks, days, hours, minutes and seconds. Any zero values are trimmed from the + * ends, leaving a concise expression of the time in seconds remaining. + * + * There could of course be options for controlling behavior, such as whether to + * trim 0 values from the end, or whether to display leading 0s, if so desired + * in the future. + * + * The component itself runs an interval timer which is used to update the time + * span display. + */ + +import { useEffect, useState } from 'react'; + +const CLOCK_INTERVAL = 100; +const MIN_SECS = 60; +const HOUR_SECS = 60 * MIN_SECS; +const DAY_SECS = 24 * HOUR_SECS; +const WEEK_SECS = 7 * DAY_SECS; + +export interface SpanUnit { + unit: string; + seconds: number; +} + +const spanUnits = [ + { + unit: 'week', + seconds: WEEK_SECS, + }, + { + unit: 'day', + seconds: DAY_SECS, + }, + { + unit: 'hour', + seconds: HOUR_SECS, + }, + { + unit: 'minute', + seconds: MIN_SECS, + }, + { + unit: 'second', + seconds: 1, + }, +]; + +export function formatTimeSpan(span: number) { + let spanSeconds = Math.round(span / 1000); + const measures = []; + + function measureUnit(span: number, unit: SpanUnit) { + const measure = Math.floor(span / unit.seconds); + const remaining = span - measure * unit.seconds; + return [measure, remaining]; + } + + for (const unit of spanUnits) { + const [measure, remaining] = measureUnit(spanSeconds, unit); + spanSeconds = remaining; + measures.push([measure, unit.unit]); + } + + // trim leading 0s. + const trimmed = []; + let trimming = true; + for (const [measure, unit] of measures) { + if (trimming) { + if (measure === 0) { + continue; + } else { + trimming = false; + } + } + trimmed.push([measure, unit]); + } + + // trim trailing 0s too + const reverseTrimmed = []; + trimming = true; + for (const [measure, unit] of trimmed.reverse()) { + if (trimming) { + if (measure === 0) { + continue; + } else { + trimming = false; + } + } + reverseTrimmed.push([measure, unit]); + } + + return [ + reverseTrimmed + .reverse() + .map(([measure, unit]) => { + if (measure !== 1) { + unit += 's'; + } + return [measure, unit].join(' '); + }) + .join(', '), + ].join(''); +} + +export interface CountdownClockProps { + startAt: number; + endAt: number; + onExpired: () => void; +} + +interface CountDownClockState { + now: number; + expired: boolean; +} + +const CountdownClock = ({ endAt, onExpired }: CountdownClockProps) => { + const [state, setState] = useState({ + now: Date.now(), + expired: false, + }); + + const [timer, setTimer] = useState(null); + + /** + * This effect should run only on mount - none of its dependencies change. + */ + useEffect(() => { + const timer = window.setInterval(() => { + const now = Date.now(); + if (now >= endAt) { + setState({ + now, + expired: true, + }); + if (timer) { + window.clearInterval(timer); + } + onExpired(); + return; + } + setState({ + now, + expired: false, + }); + }, CLOCK_INTERVAL); + setTimer(timer); + }, [endAt, onExpired, setState, setTimer]); + + /** + * This effect is used only to clean up the interval timer. The cleanup + * function will only be invoked upon component dismount if the timer is still running. + */ + useEffect(() => { + return () => { + if (timer) { + window.clearInterval(timer); + } + }; + }, [timer]); + + const { now, expired } = state; + let className = ''; + if (expired) { + className += 'text-danger'; + } + return {formatTimeSpan(endAt - now)}; +}; + +export default CountdownClock; diff --git a/src/features/orcidlink/common/ErrorMessage.module.scss b/src/features/orcidlink/common/ErrorMessage.module.scss index 509c5b5d..4e2a934c 100644 --- a/src/features/orcidlink/common/ErrorMessage.module.scss +++ b/src/features/orcidlink/common/ErrorMessage.module.scss @@ -6,3 +6,11 @@ justify-content: center; margin-top: 2rem; } + +ul.solutions_list { + padding: 0; +} + +.solution_item_link { + margin-left: 1rem; +} diff --git a/src/features/orcidlink/common/ErrorMessage.test.tsx b/src/features/orcidlink/common/ErrorMessage.test.tsx index 325829c0..e6d1cf56 100644 --- a/src/features/orcidlink/common/ErrorMessage.test.tsx +++ b/src/features/orcidlink/common/ErrorMessage.test.tsx @@ -1,10 +1,10 @@ import { SerializedError } from '@reduxjs/toolkit'; import { FetchBaseQueryError } from '@reduxjs/toolkit/dist/query'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { KBaseBaseQueryError } from '../../../common/api/utils/common'; -import ErrorMessage from './ErrorMessage'; +import ErrorMessage, { CommonError, makeCommonError } from './ErrorMessage'; -describe('The ErrorMessage Component', () => { +describe('The ErrorMessage Component for KBaseBaseQueryError', () => { it('renders CUSTOM_ERROR correctly', () => { const error: KBaseBaseQueryError = { status: 'CUSTOM_ERROR', @@ -76,7 +76,9 @@ describe('The ErrorMessage Component', () => { expect(container).toHaveTextContent(`HTTP Status Code: ${400}`); }); +}); +describe('The ErrorMessage Component for SerializedError', () => { it('renders a Redux SerializedError error with a message correctly', () => { const error: SerializedError = { code: '123', @@ -97,6 +99,87 @@ describe('The ErrorMessage Component', () => { }; const { container } = render(); - expect(container).toHaveTextContent('Unknown Error'); + expect(container).toHaveTextContent('Unknown error'); + }); +}); + +const TEST_ERROR_MESSAGE = 'Test Error Message'; +const TEST_ERROR_DETAILS = 'Test Error Details'; +const TEST_ERROR_TITLE = 'Test Error Title'; +const TEST_SOLUTION_DESCRIPTION = 'MY SOLUTION'; +const TEST_SOLUTION_LABEL = 'MY SOLUTION LINK LABEL'; +const TEST_SOLUTION_URL = 'http://example.com'; + +describe('The ErrorMessage Component for CommonError', () => { + it('renders minimal error correctly', () => { + const error: CommonError = makeCommonError({ + message: TEST_ERROR_MESSAGE, + }); + const { container } = render(); + + expect(container).toHaveTextContent(TEST_ERROR_MESSAGE); + }); + + it('renders error with details and title correctly', () => { + const error: CommonError = makeCommonError({ + message: TEST_ERROR_MESSAGE, + details: TEST_ERROR_DETAILS, + title: TEST_ERROR_TITLE, + }); + const { container } = render(); + + expect(container).toHaveTextContent(TEST_ERROR_MESSAGE); + expect(container).toHaveTextContent(TEST_ERROR_DETAILS); + expect(container).toHaveTextContent(TEST_ERROR_TITLE); + }); + + it('renders error with minimal solution', async () => { + const error: CommonError = makeCommonError({ + message: TEST_ERROR_MESSAGE, + details: TEST_ERROR_DETAILS, + title: TEST_ERROR_TITLE, + solutions: [ + { + description: TEST_SOLUTION_DESCRIPTION, + }, + ], + }); + const { container } = render(); + + expect(container).toHaveTextContent(TEST_ERROR_MESSAGE); + expect(container).toHaveTextContent(TEST_ERROR_DETAILS); + expect(container).toHaveTextContent(TEST_ERROR_TITLE); + expect(container).toHaveTextContent(TEST_SOLUTION_DESCRIPTION); + expect(container).toHaveTextContent(TEST_ERROR_TITLE); + }); + + it('renders error with full solution', async () => { + const error: CommonError = makeCommonError({ + message: TEST_ERROR_MESSAGE, + details: TEST_ERROR_DETAILS, + title: TEST_ERROR_TITLE, + solutions: [ + { + description: TEST_SOLUTION_DESCRIPTION, + link: { + label: TEST_SOLUTION_LABEL, + url: TEST_SOLUTION_URL, + }, + }, + ], + }); + const { container } = render(); + + expect(container).toHaveTextContent(TEST_ERROR_MESSAGE); + expect(container).toHaveTextContent(TEST_ERROR_DETAILS); + expect(container).toHaveTextContent(TEST_ERROR_TITLE); + expect(container).toHaveTextContent(TEST_SOLUTION_DESCRIPTION); + expect(container).toHaveTextContent(TEST_SOLUTION_LABEL); + expect(container).toHaveTextContent(TEST_ERROR_TITLE); + + const solutionLink = await screen.findByText(TEST_SOLUTION_LABEL); + + expect(solutionLink).toBeVisible(); + expect(solutionLink).toHaveAttribute('href', TEST_SOLUTION_URL); }); }); diff --git a/src/features/orcidlink/common/ErrorMessage.tsx b/src/features/orcidlink/common/ErrorMessage.tsx index 938958c7..f81f3745 100644 --- a/src/features/orcidlink/common/ErrorMessage.tsx +++ b/src/features/orcidlink/common/ErrorMessage.tsx @@ -1,21 +1,47 @@ /** - * Displays an error message as may be retured by an RTK query. + * Displays an error message as may be returned by an RTK query. * * Currently very basic, just displaying the message in an Alert. However, some * errors would benefit from a more specialized display. */ +import { AlertTitle } from '@mui/material'; import Alert from '@mui/material/Alert'; import { SerializedError } from '@reduxjs/toolkit'; import { KBaseBaseQueryError } from '../../../common/api/utils/common'; import styles from './ErrorMessage.module.scss'; +export interface SolutionLink { + label?: string; + url?: string; +} + +export interface Solution { + description: string; + link?: SolutionLink; +} + +export interface CommonError { + type: 'COMMON_ERROR'; + message: string; + details?: string; + title?: string; + solutions?: Array; +} + +export function makeCommonError(error: Omit): CommonError { + return { + type: 'COMMON_ERROR', + ...error, + }; +} + export interface ErrorMessageProps { - error: KBaseBaseQueryError | SerializedError; + error: KBaseBaseQueryError | SerializedError | CommonError; } export default function ErrorMessage({ error }: ErrorMessageProps) { - const message = (() => { - if ('status' in error) { + function renderKBaseQueryError(error: KBaseBaseQueryError) { + const message = (() => { switch (error.status) { case 'JSONRPC_ERROR': return error.data.error.message; @@ -31,15 +57,92 @@ export default function ErrorMessage({ error }: ErrorMessageProps) { if ('status' in error && typeof error.status === 'number') { return `HTTP Status Code: ${error.status}`; } - } else { - return error.message || 'Unknown Error'; + })(); + + return ( +
+ + {message} + +
+ ); + } + + function renderReduxSerializedError(error: SerializedError) { + // Bare minimal for redux error + return ( +
+ + {error.message || 'Unknown error'} + +
+ ); + } + + function renderCommonError({ + message, + details, + title, + solutions, + }: CommonError) { + function renderDetails() { + if (!details) { + return; + } + return

{details}

; } - })(); - return ( -
- - {message} - -
- ); + + function renderSolutions() { + if (solutions && solutions.length > 0) { + const solutionItems = solutions.map(({ description, link }, index) => { + if (link) { + return ( +
  • +
    {description}
    + +
  • + ); + } + return ( +
  • +
    {description}
    +
  • + ); + }); + return
      {solutionItems}
    ; + } + } + + if (title) { + return ( +
    + + {title} + {message} + {renderDetails()} + {renderSolutions()} + +
    + ); + } + return ( +
    + + {message} + {renderDetails()} + {renderSolutions()} + +
    + ); + } + + if ('status' in error) { + return renderKBaseQueryError(error); + } else if ('type' in error && error.type === 'COMMON_ERROR') { + return renderCommonError(error); + } else { + return renderReduxSerializedError(error); + } } diff --git a/src/features/orcidlink/common/LinkInfo.module.scss b/src/features/orcidlink/common/LinkInfo.module.scss new file mode 100644 index 00000000..7753ddbc --- /dev/null +++ b/src/features/orcidlink/common/LinkInfo.module.scss @@ -0,0 +1,21 @@ +@import "../../../common/colors"; + +.info_table { + display: flex; + flex-direction: column; +} + +.info_table > div { + display: flex; + flex-direction: row; + margin-bottom: 0.5rem; +} + +.info_table > div > div:nth-child(1) { + flex: 0 0 12rem; + font-weight: bold; +} + +.info_table > div > div:nth-child(2) { + flex: 1 1 0; +} diff --git a/src/features/orcidlink/HomeLinked/LinkInfo.test.tsx b/src/features/orcidlink/common/LinkInfo.test.tsx similarity index 100% rename from src/features/orcidlink/HomeLinked/LinkInfo.test.tsx rename to src/features/orcidlink/common/LinkInfo.test.tsx diff --git a/src/features/orcidlink/HomeLinked/LinkInfo.tsx b/src/features/orcidlink/common/LinkInfo.tsx similarity index 80% rename from src/features/orcidlink/HomeLinked/LinkInfo.tsx rename to src/features/orcidlink/common/LinkInfo.tsx index 710db12b..eac96f4e 100644 --- a/src/features/orcidlink/HomeLinked/LinkInfo.tsx +++ b/src/features/orcidlink/common/LinkInfo.tsx @@ -1,12 +1,17 @@ +/** + * Displays basic information about an orcid link from a user's orcidlink public + * record, and orcid profile. + * + */ import { LinkRecordPublic, ORCIDProfile, } from '../../../common/api/orcidLinkCommon'; -import CreditName from '../common/CreditName'; -import { ORCIDIdLink } from '../common/ORCIDIdLink'; -import RealName from '../common/RealName'; -import Scopes from '../common/Scopes'; -import styles from '../orcidlink.module.scss'; +import CreditName from './CreditName'; +import styles from './LinkInfo.module.scss'; +import { ORCIDIdLink } from './ORCIDIdLink'; +import RealName from './RealName'; +import Scopes from './Scopes'; export interface LinkInfoProps { linkRecord: LinkRecordPublic; @@ -21,7 +26,7 @@ export default function LinkInfo({ }: LinkInfoProps) { return (
    -
    +
    ORCID iD
    diff --git a/src/features/orcidlink/common/Loading.test.tsx b/src/features/orcidlink/common/Loading.test.tsx new file mode 100644 index 00000000..c2b1d7a3 --- /dev/null +++ b/src/features/orcidlink/common/Loading.test.tsx @@ -0,0 +1,31 @@ +import { render } from '@testing-library/react'; +import Loading from './Loading'; + +const TEST_TITLE = 'Test Loading Title'; +const TEST_MESSAGE = 'Test Loading Message'; + +describe('The Loading Component', () => { + it('renders minimal correctly', () => { + const { container } = render(); + + expect(container).toHaveTextContent(TEST_TITLE); + }); + + it('renders with all props correctly', () => { + const { container } = render( + + ); + + expect(container).toHaveTextContent(TEST_TITLE); + expect(container).toHaveTextContent(TEST_MESSAGE); + }); + + it('renders with children rather than message prop correctly', () => { + const { container } = render( + {TEST_MESSAGE} + ); + + expect(container).toHaveTextContent(TEST_TITLE); + expect(container).toHaveTextContent(TEST_MESSAGE); + }); +}); diff --git a/src/features/orcidlink/common/Loading.tsx b/src/features/orcidlink/common/Loading.tsx new file mode 100644 index 00000000..f208b61b --- /dev/null +++ b/src/features/orcidlink/common/Loading.tsx @@ -0,0 +1,33 @@ +/** + * A simple component to express a "loading" state. + * + * Although dubbed "loading", there is nothing specific to "loading" in this + * component, other than the name. It is more of a generalized async process + * feedback component, based on the MUI Alert component. + */ +import { Alert, AlertTitle, CircularProgress } from '@mui/material'; +import { PropsWithChildren } from 'react'; + +export interface LoadingProps extends PropsWithChildren { + title: string; + message?: string; +} + +export default function Loading({ title, message, children }: LoadingProps) { + if (message || children) { + return ( + }> + + {title} + + {message ?

    {message}

    : children} +
    + ); + } + + return ( + }> + {title} + + ); +} diff --git a/src/features/orcidlink/common/ORCIDId.module.scss b/src/features/orcidlink/common/ORCIDId.module.scss new file mode 100644 index 00000000..60657033 --- /dev/null +++ b/src/features/orcidlink/common/ORCIDId.module.scss @@ -0,0 +1,10 @@ +.main { + align-items: baseline; + display: inline-flex; + flex-direction: row; +} + +.icon { + height: 1rem; + margin-right: 0.25em; +} diff --git a/src/features/orcidlink/common/ORCIDId.test.tsx b/src/features/orcidlink/common/ORCIDId.test.tsx new file mode 100644 index 00000000..499242b5 --- /dev/null +++ b/src/features/orcidlink/common/ORCIDId.test.tsx @@ -0,0 +1,11 @@ +import { render } from '@testing-library/react'; +import ORCIDId from './ORCIDId'; + +describe('The ORCIDId Component', () => { + it('renders minimal correctly', () => { + const ID = 'foo'; + const { container } = render(); + + expect(container).toHaveTextContent(ID); + }); +}); diff --git a/src/features/orcidlink/common/ORCIDId.tsx b/src/features/orcidlink/common/ORCIDId.tsx new file mode 100644 index 00000000..4c0fc595 --- /dev/null +++ b/src/features/orcidlink/common/ORCIDId.tsx @@ -0,0 +1,19 @@ +/** + * A simple component to display an anchor link to an ORCID profile in the form recommended by ORCID. + * + */ +import { ORCID_ICON_URL } from '../constants'; +import styles from './ORCIDId.module.scss'; + +export interface ORCIDIdProps { + orcidId: string; +} + +export default function ORCIDId({ orcidId }: ORCIDIdProps) { + return ( + + ORCID Icon + {orcidId} + + ); +} diff --git a/src/features/orcidlink/common/Scopes.tsx b/src/features/orcidlink/common/Scopes.tsx index 2116b275..321a5b75 100644 --- a/src/features/orcidlink/common/Scopes.tsx +++ b/src/features/orcidlink/common/Scopes.tsx @@ -16,7 +16,6 @@ import { Typography, } from '@mui/material'; import { ORCIDScope, ScopeHelp, SCOPE_HELP } from '../constants'; -import styles from '../orcidlink.module.scss'; export interface ScopesProps { scopes: string; @@ -62,13 +61,13 @@ export default function Scopes({ scopes }: ScopesProps) { {orcid.label} -
    ORCID® Policy
    + ORCID® Policy

    {orcid.tooltip}

    -
    How KBase Uses It
    + How KBase Uses It {help.map((item, index) => { return

    {item}

    ; })} -
    See Also
    + See Also
      {seeAlso.map(({ url, label }, index) => { return ( diff --git a/src/features/orcidlink/common/api/JSONRPC20.test.ts b/src/features/orcidlink/common/api/JSONRPC20.test.ts new file mode 100644 index 00000000..91503686 --- /dev/null +++ b/src/features/orcidlink/common/api/JSONRPC20.test.ts @@ -0,0 +1,682 @@ +import fetchMock, { MockResponseInit } from 'jest-fetch-mock'; +import { FetchMock } from 'jest-fetch-mock/types'; +import { + APIOverrides, + jsonrpc20_response, + makeBatchResponseObject, +} from '../../test/jsonrpc20ServiceMock'; + +import { + assertJSONRPC20Response, + assertPlainObject, + batchResultOrThrow, + JSONRPC20Client, + JSONRPC20ResponseObject, + notIn, + resultOrThrow, +} from './JSONRPC20'; + +// Simulates latency in the request response; helps with detecting state driven +// by api calls which may not be detectable with tests if the mock rpc handling +// is too fast. +const RPC_DELAY = 300; + +// Used in at least one test which simulates a request timeout. +const RPC_DELAY_TIMEOUT = 2000; + +// The timeout used for most RPC calls (other than those to test timeout behavior) +const RPC_CALL_TIMEOUT = 1000; + +describe('The JSONRPC20 assertPlainObject function', () => { + it('correctly identifies a plain object', () => { + const testCases = [ + {}, + { foo: 'bar' }, + { bar: 123 }, + { foo: { bar: { baz: 'buzz' } } }, + ]; + + for (const testCase of testCases) { + expect(() => { + assertPlainObject(testCase); + }).not.toThrow(); + } + }); + it('correctly identifies a non-plain object', () => { + const testCases = [new Date(), new Set(), null]; + + for (const testCase of testCases) { + expect(() => { + assertPlainObject(testCase); + }).toThrow(); + } + }); +}); + +describe('The JSONRPC20 diff function', () => { + it('correctly identifies extra keys', () => { + const testCases: Array<{ + params: [Array, Array]; + expected: Array; + }> = [ + { + params: [ + [1, 2, 3], + [1, 2, 3], + ], + expected: [], + }, + + { + params: [ + [1, 2, 3, 4, 5, 6], + [1, 2, 3], + ], + expected: [4, 5, 6], + }, + ]; + + for (const { params, expected } of testCases) { + expect(notIn(...params)).toEqual(expected); + } + }); +}); + +describe('The JSONRPC20 assertJSONRPC20Response function', () => { + it('correctly identifies a valid JSON-RPC 2.0 response', () => { + const testCases: Array = [ + { + jsonrpc: '2.0', + id: '123', + result: null, + }, + + { + jsonrpc: '2.0', + id: '123', + result: 'foo', + }, + { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 'an error', + }, + }, + { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 'an error', + data: { some: 'details' }, + }, + }, + ]; + + for (const testCase of testCases) { + expect(() => { + assertJSONRPC20Response(testCase); + }).not.toThrow(); + } + }); + + it('correctly identifies an invalid JSON-RPC 2.0 response', () => { + const testCases: Array<{ param: unknown; expected: string }> = [ + { param: 'x', expected: 'JSON-RPC 2.0 response must be an object' }, + { + param: null, + expected: 'JSON-RPC 2.0 response must be a non-null object', + }, + { + param: new Date(), + expected: 'JSON-RPC 2.0 response must be a plain object', + }, + { + param: { + jsonrpc: '2.0', + id: '123', + }, + expected: + 'JSON-RPC 2.0 response must include either "result" or "error"', + }, + { + param: { + jsonrpc: '2.0', + result: null, + }, + expected: 'JSON-RPC 2.0 response must have the "id" property', + }, + { + param: { + id: '123', + result: null, + }, + expected: 'JSON-RPC 2.0 response must have the "jsonrpc" property', + }, + { + param: { + jsonrpc: 'X', + id: '123', + result: null, + }, + expected: + 'JSON-RPC 2.0 response "jsonrpc" property must be the string "2.0"', + }, + { + param: { + jsonrpc: '2.0', + id: ['x'], + result: null, + }, + expected: + 'JSON-RPC 2.0 response "id" property must be a string, number or null', + }, + { + param: { + jsonrpc: '2.0', + id: '123', + error: 'foo', + }, + expected: + 'JSON-RPC 2.0 response "error" property must be a plain object', + }, + { + param: { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + }, + }, + expected: + 'JSON-RPC 2.0 response "error" property must have a "message" property', + }, + { + param: { + jsonrpc: '2.0', + id: '123', + error: { + message: 'an error', + }, + }, + expected: + 'JSON-RPC 2.0 response "error" property must have a "code" property', + }, + { + param: { + jsonrpc: '2.0', + id: '123', + error: { + foo: 123, + bar: 'baz', + }, + }, + expected: + 'JSON-RPC 2.0 response "error" property has extra keys: foo, bar', + }, + { + param: { + jsonrpc: '2.0', + id: '123', + error: { + code: 'foo', + message: 'bar', + }, + }, + expected: + 'JSON-RPC 2.0 response "error.code" property must be an integer', + }, + { + param: { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 456, + }, + }, + expected: + 'JSON-RPC 2.0 response "error.message" property must be an string', + }, + ]; + + for (const { param, expected } of testCases) { + expect(() => { + assertJSONRPC20Response(param); + }).toThrowError(expected); + } + }); +}); + +describe('The JSONRPC20 resultOrThrow function works as expected', () => { + it('simply returns the result if found', () => { + const testCase: JSONRPC20ResponseObject = { + jsonrpc: '2.0', + id: '123', + result: 'fuzz', + }; + expect(resultOrThrow(testCase)).toEqual(testCase.result); + }); + + it('throws if an error is returned', () => { + const testCase: JSONRPC20ResponseObject = { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 'An Error', + }, + }; + expect(() => { + resultOrThrow(testCase); + }).toThrow('An Error'); + }); +}); + +describe('The JSONRPC20 batchResultOrThrow function works as expected', () => { + it('simply returns the result if found', () => { + const responseResult: JSONRPC20ResponseObject = { + jsonrpc: '2.0', + id: '123', + result: 'fuzz', + }; + const testCase: Array = [responseResult]; + expect(batchResultOrThrow(testCase)).toEqual([responseResult.result]); + }); + + it('throws if an error is returned', () => { + const testCase: Array = [ + { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 'An Error', + }, + }, + ]; + expect(() => { + batchResultOrThrow(testCase); + }).toThrow('An Error'); + }); +}); + +async function pause(duration: number) { + await new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, duration); + }); +} + +/** + * + * @param request The mock request + * @param method The rpc method + * @param params The rpc params + * @returns A JSON-RPC 2.0 response object + */ +async function jsonrpc20MethodResponse( + request: Request, + method: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _params: any +): Promise { + switch (method) { + case 'foo': + return { + jsonrpc: '2.0', + id: '123', + result: 'fuzz', + }; + case 'bar': { + return { + jsonrpc: '2.0', + id: '123', + result: 'buzz', + }; + } + case 'baz': { + if (request.headers.get('authorization') === 'my_token') { + return { + jsonrpc: '2.0', + id: '123', + result: 'is authorized', + }; + } else { + return { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 'Not Authorized', + data: { + foo: 'bar', + }, + }, + }; + } + } + case 'error': { + return { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 'An Error', + data: { + foo: 'bar', + }, + }, + }; + } + case 'timeout': { + await pause(RPC_DELAY_TIMEOUT); + return { + jsonrpc: '2.0', + id: '123', + result: 'fuzz', + }; + } + + default: + throw new Error('case not handled'); + } +} + +async function jsonrpc20Response( + request: Request, + method: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: any +) { + switch (method) { + case 'not_json': + return { + body: 'foo', + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + default: { + const response = await jsonrpc20MethodResponse(request, method, params); + return jsonrpc20_response(response); + } + } +} + +export function makeJSONRPC20Server(overrides: APIOverrides = {}) { + return fetchMock.mockResponse( + async (request): Promise => { + const { pathname } = new URL(request.url); + // put a little delay in here so that we have a better + // chance of catching temporary conditions, like loading. + await pause(RPC_DELAY); + switch (pathname) { + // Mocks for the orcidlink api + case '/services/foo': { + if (request.method !== 'POST') { + return ''; + } + const body = await request.json(); + + if (body instanceof Array) { + // batch case; normal request wrapped in an array; response array + // mirrors request. + const responses = await Promise.all( + body.map((rpc) => { + const method = rpc['method']; + const params = rpc['params']; + return jsonrpc20MethodResponse(request, method, params); + }) + ); + return makeBatchResponseObject(responses); + } else { + // single request + const method = body['method']; + const params = body['params']; + return jsonrpc20Response(request, method, params); + } + } + case '/services/bad_batch': { + return jsonrpc20Response(request, 'foo', {}); + } + default: + throw new Error('case not handled'); + } + } + ); +} + +describe('The JSONRPC20 client', () => { + let mockService: FetchMock; + + beforeEach(() => { + fetchMock.enableMocks(); + fetchMock.doMock(); + mockService = makeJSONRPC20Server(); + }); + afterEach(() => { + mockService.mockClear(); + fetchMock.disableMocks(); + }); + + it('correctly invokes simple POST endpoint', async () => { + const rpc = { + jsonrpc: '2.0', + id: '123', + method: 'foo', + params: { + bar: 'baz', + }, + }; + const expected = { + jsonrpc: '2.0', + id: '123', + result: 'fuzz', + }; + const headers = new Headers(); + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json'); + + // use a timeout detection duration tht is 1/2 of the testing delay used to + // force timeout. + const timeout = RPC_DELAY_TIMEOUT / 2; + const controller = new AbortController(); + const timeoutTimer = window.setTimeout(() => { + controller.abort('Timeout'); + }, timeout); + // const expected = { baz: 'buzzer' } + const response = await fetch('http://example.com/services/foo', { + method: 'POST', + body: JSON.stringify(rpc), + headers, + mode: 'cors', + signal: controller.signal, + }); + clearTimeout(timeoutTimer); + const result = await response.json(); + expect(result).toEqual(expected); + }); + + it('correctly invokes fictitious service', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: RPC_DELAY_TIMEOUT, + }); + + const expected = { + jsonrpc: '2.0', + id: '123', + result: 'fuzz', + }; + + const result = await client.callMethod('foo', { baz: 'buzz' }); + expect(result).toEqual(expected); + }); + + it('correctly invokes fictitious service with a batch request', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: RPC_CALL_TIMEOUT, + }); + + const expected = [ + { + jsonrpc: '2.0', + id: '123', + result: 'fuzz', + }, + { + jsonrpc: '2.0', + id: '123', + result: 'buzz', + }, + ]; + + const result = await client.callBatch([ + { method: 'foo', params: { baz: 'buzz' } }, + { method: 'bar', params: { baz: 'buzz' } }, + ]); + expect(result).toEqual(expected); + }); + + it('correctly invokes fictitious service 2', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: RPC_CALL_TIMEOUT, + }); + + const expected = { + jsonrpc: '2.0', + id: '123', + result: 'buzz', + }; + + const result = await client.callMethod('bar', { baz: 'buzz' }); + expect(result).toEqual(expected); + }); + + it('correctly invokes method with authorization', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: RPC_CALL_TIMEOUT, + token: 'my_token', + }); + + const expected = { + jsonrpc: '2.0', + id: '123', + result: 'is authorized', + }; + + const result = await client.callMethod('baz', { baz: 'buzz' }); + expect(result).toEqual(expected); + }); + + it('correctly invokes method which returns an error', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: RPC_CALL_TIMEOUT, + }); + + const expected = { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 'An Error', + data: { + foo: 'bar', + }, + }, + }; + + const result = await client.callMethod('error', { baz: 'buzz' }); + expect(result).toEqual(expected); + }); + + it('correctly invokes fictitious service with a batch request which returns an error', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: RPC_CALL_TIMEOUT, + }); + + const expected = [ + { + jsonrpc: '2.0', + id: '123', + error: { + code: 123, + message: 'An Error', + data: { + foo: 'bar', + }, + }, + }, + ]; + + const result = await client.callBatch([ + { method: 'error', params: { baz: 'buzz' } }, + ]); + expect(result).toEqual(expected); + }); + + it('correctly invokes service with a batch request which is not an array', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/bad_batch', + timeout: RPC_CALL_TIMEOUT, + }); + + expect(async () => { + await client.callBatch([ + { method: 'foo', params: { baz: 'buzz' } }, + { method: 'bar', params: { baz: 'buzz' } }, + ]); + }).rejects.toThrow('JSON-RPC 2.0 batch response must be an array'); + }); + + it('times out as expected', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: 100, + }); + + fetchMock.mockAbort(); + + // cannot use the appended abort error message, as it is different in + // jest-fetch-mock than browsers, and there is no way to supply the message. + await expect(client.callMethod('timeout', { baz: 'buzz' })).rejects.toThrow( + /Connection error AbortError:/ + ); + }); + + it('throws if no endpoint detected', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: RPC_CALL_TIMEOUT, + }); + + fetchMock.mockReject(new Error('Request error: Network request failed')); + + await expect( + client.callMethod('network_fail', { baz: 'buzz' }) + ).rejects.toThrow('Request error: Network request failed'); + }); + + it('throws if non-json returned', async () => { + const client = new JSONRPC20Client({ + url: 'http://example.com/services/foo', + timeout: RPC_CALL_TIMEOUT, + }); + + // server.passthrough(); + + await expect( + client.callMethod('not_json', { baz: 'buzz' }) + ).rejects.toThrow('The response from the service could not be parsed'); + }); +}); diff --git a/src/features/orcidlink/common/api/JSONRPC20.ts b/src/features/orcidlink/common/api/JSONRPC20.ts new file mode 100644 index 00000000..6bca652a --- /dev/null +++ b/src/features/orcidlink/common/api/JSONRPC20.ts @@ -0,0 +1,417 @@ +/** + * A JSON-RPC 2.0 client + * + * It is intended to be fully compliant; where not, it is an oversight. + * Code copied from kbase-ui and modified. + * There is some accomodation for KBase (e.g. token may be used in authorization + * header), but unlike the JSON-RPC 1.1 usage at KBase is more compliant with + * the specs. + */ +import * as uuid from 'uuid'; + +export function assertPlainObject( + value: unknown +): asserts value is JSONRPC20ResponseObject { + if (typeof value !== 'object') { + throw new Error('must be an object'); + } + if (value === null) { + throw new Error('must be a non-null object'); + } + if (value.constructor !== Object) { + throw new Error('must be a plain object'); + } +} + +/** + * Returns the values in a1 that are not in a2. + * + * @param a1 + * @param a2 + */ +export function notIn(a1: Array, a2: Array) { + return a1.filter((v1) => { + return !a2.includes(v1); + }); +} + +/** + * Ensures that the given value is a JSON-RPC 2.0 compliant response + * + * Note that I'd rather use JSON Schema, but I can't get AJV and TS to play nicely. + * + * We make the assumption that the value is the result of JSON.parse(), so all + * values are by definition JSON-compatible - no undefined, no non-plain + * objects, no functions, etc. + * + * @param value + */ +export function assertJSONRPC20Response( + value: unknown +): asserts value is JSONRPC20ResultResponseObject { + if (typeof value !== 'object') { + throw new Error('JSON-RPC 2.0 response must be an object'); + } + if (value === null) { + throw new Error('JSON-RPC 2.0 response must be a non-null object'); + } + if (value.constructor !== Object) { + throw new Error('JSON-RPC 2.0 response must be a plain object'); + } + + if (!('jsonrpc' in value)) { + throw new Error('JSON-RPC 2.0 response must have the "jsonrpc" property'); + } + + if (value.jsonrpc !== '2.0') { + throw new Error( + 'JSON-RPC 2.0 response "jsonrpc" property must be the string "2.0"' + ); + } + + if ('id' in value) { + if ( + !(['string', 'number'].includes(typeof value.id) && value.id !== null) + ) { + throw new Error( + 'JSON-RPC 2.0 response "id" property must be a string, number or null' + ); + } + } else { + throw new Error('JSON-RPC 2.0 response must have the "id" property'); + } + + if ('result' in value) { + // nothing to assert here? The result can be any valid JSON value. + } else if ('error' in value) { + try { + assertPlainObject(value.error); + } catch (ex) { + throw new Error( + 'JSON-RPC 2.0 response "error" property must be a plain object' + ); + } + + const extraKeys = notIn(Object.keys(value.error), [ + 'code', + 'message', + 'data', + ]); + if (extraKeys.length > 0) { + throw new Error( + `JSON-RPC 2.0 response "error" property has extra keys: ${extraKeys.join( + ', ' + )}` + ); + } + + if (!('code' in value.error)) { + throw new Error( + 'JSON-RPC 2.0 response "error" property must have a "code" property' + ); + } + if (!Number.isInteger(value.error.code)) { + throw new Error( + 'JSON-RPC 2.0 response "error.code" property must be an integer' + ); + } + if (!('message' in value.error)) { + throw new Error( + 'JSON-RPC 2.0 response "error" property must have a "message" property' + ); + } + if (typeof value.error.message !== 'string') { + throw new Error( + 'JSON-RPC 2.0 response "error.message" property must be an string' + ); + } + } else { + throw new Error( + 'JSON-RPC 2.0 response must include either "result" or "error"' + ); + } +} + +export function assertJSONRPC20BatchResponse( + values: unknown +): asserts values is Array { + if (!(values instanceof Array)) { + throw new Error('JSON-RPC 2.0 batch response must be an array'); + } + + for (const value of values) { + assertJSONRPC20Response(value); + } +} + +export interface JSONRPC20ObjectParams { + [key: string]: unknown; +} + +export type JSONRPC20Params = JSONRPC20ObjectParams | Array; + +export type JSONRPC20Id = string | number | null; + +// The entire JSON RPC request object +export interface JSONRPC20Request { + jsonrpc: '2.0'; + method: string; + id?: JSONRPC20Id; + params?: JSONRPC20Params; +} + +export type JSONRPC20Result = + | string + | number + | null + | Object + | Array; + +export interface JSONRPC20ResultResponseObject { + jsonrpc: '2.0'; + id?: JSONRPC20Id; + result: JSONRPC20Result; +} + +export interface JSONRPC20ErrorResponseObject { + jsonrpc: '2.0'; + id?: JSONRPC20Id; + error: JSONRPC20Error; +} + +export interface JSONRPC20Error { + code: number; + message: string; + data?: unknown; +} + +export class JSONRPC20Exception extends Error { + error: JSONRPC20Error; + constructor(error: JSONRPC20Error) { + super(error.message); + this.error = error; + } +} + +export function batchResultOrThrow( + responses: JSONRPC20BatchResponse +): Array { + return responses.map((response) => { + if ('result' in response) { + return response.result; + } + throw new JSONRPC20Exception(response.error); + }); +} + +export function resultOrThrow( + response: JSONRPC20ResponseObject +): JSONRPC20Result { + if ('result' in response) { + return response.result; + } + throw new JSONRPC20Exception(response.error); +} + +export type JSONRPC20ResponseObject = + | JSONRPC20ResultResponseObject + | JSONRPC20ErrorResponseObject; + +export type JSONRPC20BatchResponse = Array; + +export class ConnectionError extends Error {} + +export class RequestError extends Error {} + +/** + * Constructor parameters + */ +export interface JSONRPC20ClientParams { + url: string; + timeout: number; + token?: string; +} + +export interface JSONRPC20CallParams { + method: string; + params?: JSONRPC20Params; +} + +/** + * A JSON-RPC 2.0 client, with some accomodation for KBase usage (e.g. token) + */ +export class JSONRPC20Client { + url: string; + timeout: number; + token?: string; + + constructor({ url, timeout, token }: JSONRPC20ClientParams) { + this.url = url; + this.timeout = timeout; + this.token = token; + } + + /** + * Given a method name and parameters, call the known endpoint, process the response, + * and return the result. + * + * Exceptions included + * + * @param method JSON-RPC 2.0 method name + * @param params JSON-RPC 2.0 parameters; must be an object or array + * @param options An object containing optional parameters + * @returns A + */ + async callMethod( + method: string, + params?: JSONRPC20Params, + { timeout }: { timeout?: number } = {} + ): Promise { + // The innocuously named "payload" is the entire request object. + const payload = { + jsonrpc: '2.0', + method, + id: uuid.v4(), + params, + }; + + const headers = new Headers(); + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json'); + if (this.token) { + headers.set('authorization', this.token); + } + + // The abort controller allows us to abort the request after a specific amount + // of time passes. + const controller = new AbortController(); + const timeoutTimer = window.setTimeout(() => { + controller.abort('Timeout'); + }, timeout || this.timeout); + + let response; + try { + response = await fetch(this.url, { + method: 'POST', + body: JSON.stringify(payload), + headers, + mode: 'cors', + signal: controller.signal, + }); + } catch (ex) { + if (ex instanceof DOMException) { + throw new ConnectionError(`Connection error ${ex.name}: ${ex.message}`); + } else if (ex instanceof TypeError) { + throw new RequestError(`Request error: ${ex.message}`); + } else { + // Should never occur. + throw ex; + } + } + clearTimeout(timeoutTimer); + + const responseText = await response.text(); + const responseStatus = response.status; + let result; + try { + result = JSON.parse(responseText); + } catch (ex) { + throw new JSONRPC20Exception({ + code: 100, + message: 'The response from the service could not be parsed', + data: { + originalMessage: ex instanceof Error ? ex.message : 'Unknown error', + responseText, + responseStatus, + }, + }); + } + + assertJSONRPC20Response(result); + + return result; + } + + /** + * Given a method name and parameters, call the known endpoint, process the response, + * and return the result. + * + * Exceptions included + * + * @param method JSON-RPC 2.0 method name + * @param params JSON-RPC 2.0 parameters; must be an object or array + * @param options An object containing optional parameters + * @returns A + */ + async callBatch( + calls: Array, + { timeout }: { timeout?: number } = {} + ): Promise { + // The innocuously named "payload" is the entire request object. + + const payload = calls.map(({ method, params }) => { + return { + jsonrpc: '2.0', + method, + id: uuid.v4(), + params, + }; + }); + + const headers = new Headers(); + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json'); + if (this.token) { + headers.set('authorization', this.token); + } + + // The abort controller allows us to abort the request after a specific amount + // of time passes. + const controller = new AbortController(); + const timeoutTimer = window.setTimeout(() => { + controller.abort('Timeout'); + }, timeout || this.timeout); + + let response; + try { + response = await fetch(this.url, { + method: 'POST', + body: JSON.stringify(payload), + headers, + mode: 'cors', + signal: controller.signal, + }); + } catch (ex) { + if (ex instanceof DOMException) { + throw new ConnectionError(`Connection error ${ex.name}: ${ex.message}`); + } else if (ex instanceof TypeError) { + throw new RequestError(`Request error: ${ex.message}`); + } else { + // Should never occur. + throw ex; + } + } + clearTimeout(timeoutTimer); + + const responseText = await response.text(); + const responseStatus = response.status; + let result; + try { + result = JSON.parse(responseText); + } catch (ex) { + throw new JSONRPC20Exception({ + code: 100, + message: 'The response from the service could not be parsed', + data: { + originalMessage: ex instanceof Error ? ex.message : 'Unknown error', + responseText, + responseStatus, + }, + }); + } + + assertJSONRPC20BatchResponse(result); + + return result; + } +} diff --git a/src/features/orcidlink/common/api/ORCIDLInkAPI.ts b/src/features/orcidlink/common/api/ORCIDLInkAPI.ts new file mode 100644 index 00000000..f283a2e0 --- /dev/null +++ b/src/features/orcidlink/common/api/ORCIDLInkAPI.ts @@ -0,0 +1,305 @@ +/** + * A direct (not RTK) client for the orcidlink service. + * + * Using the JSON-RPC 2.0 and service client implementations in this same + * directory, implements enough of the orcidlink api to allow for functionality + * currently implemented in the ui. + * + * This separate client exists because it is only used for the ephemeral states + * for creating and perhaps removing an orcidlink. + * + * Future developers may move this into the RTK orcidlink client, but given the + * short amount of time to port this, I didn't want to go down that rabbit hole. + * + * This code was ported from kbase-ui and modified to fit. + */ + +import { JSONRPC20ObjectParams } from './JSONRPC20'; +import { + LinkRecordPublic, + LinkRecordPublicNonOwner, + ORCIDAuthPublic, + ORCIDProfile, +} from './orcidlinkAPICommon'; +import { ServiceClient } from './ServiceClient'; + +export interface StatusResult { + status: 'ok'; + current_time: number; + start_time: number; +} + +export interface ServiceDescription { + name: string; + title: string; + version: string; + language: string; + description: string; + repoURL: string; +} + +export interface ServiceConfig { + url: string; +} + +export interface Auth2Config extends ServiceConfig { + tokenCacheLifetime: number; + tokenCacheMaxSize: number; +} + +export interface GitInfo { + commit_hash: string; + commit_hash_abbreviated: string; + author_name: string; + committer_name: string; + committer_date: number; + url: string; + branch: string; + tag: string | null; +} + +export interface RuntimeInfo { + current_time: number; + orcid_api_url: string; + orcid_oauth_url: string; + orcid_site_url: string; +} + +export interface InfoResult { + 'service-description': ServiceDescription; + 'git-info': GitInfo; + runtime_info: RuntimeInfo; +} + +export interface ErrorInfo { + code: number; + title: string; + description: string; + status_code: number; +} + +export interface ErrorInfoResult { + error_info: ErrorInfo; +} + +export interface LinkingSessionPublicComplete { + session_id: string; + username: string; + created_at: number; + expires_at: number; + orcid_auth: ORCIDAuthPublic; + return_link: string | null; + skip_prompt: boolean; + ui_options: string; +} + +export interface LinkParams extends JSONRPC20ObjectParams { + username: string; +} + +export interface LinkForOtherParams extends JSONRPC20ObjectParams { + username: string; +} + +export interface DeleteLinkParams extends JSONRPC20ObjectParams { + username: string; +} + +export interface CreateLinkingSessionParams extends JSONRPC20ObjectParams { + username: string; +} + +export interface CreateLinkingSessionResult { + session_id: string; +} + +export interface DeleteLinkingSessionParams extends JSONRPC20ObjectParams { + session_id: string; +} + +export interface FinishLinkingSessionParams extends JSONRPC20ObjectParams { + session_id: string; +} + +export interface GetLinkingSessionParams extends JSONRPC20ObjectParams { + session_id: string; +} + +export interface IsLinkedParams extends JSONRPC20ObjectParams { + username: string; +} + +export interface GetProfileParams extends JSONRPC20ObjectParams { + username: string; +} + +// Works + +export interface ExternalId { + type: string; + value: string; + url: string; + relationship: string; +} + +export interface Citation { + type: string; + value: string; +} + +export interface ContributorORCIDInfo { + uri: string; + path: string; +} + +export interface ContributorRole { + role: string; +} + +export interface Contributor { + orcidId: string | null; + name: string; + roles: Array; +} + +export interface SelfContributor { + orcidId: string; + name: string; + roles: Array; +} +export interface WorkBase { + title: string; + journal: string; + date: string; + workType: string; + url: string; + doi: string; + externalIds: Array; + citation: Citation | null; + shortDescription: string; + selfContributor: SelfContributor; + otherContributors: Array | null; +} + +export type NewWork = WorkBase; + +export interface PersistedWork extends WorkBase { + putCode: string; +} + +export type WorkUpdate = PersistedWork; + +export interface Work extends PersistedWork { + createdAt: number; + updatedAt: number; + source: string; +} + +export type GetWorksResult = Array<{ + externalIds: Array; + updatedAt: number; + works: Array; +}>; + +export interface GetWorksParams extends JSONRPC20ObjectParams { + username: string; +} + +export interface GetWorkParams extends JSONRPC20ObjectParams { + username: string; + put_code: string; +} + +export interface GetWorkResult extends JSONRPC20ObjectParams { + work: Work; +} + +export interface SaveWorkParams extends JSONRPC20ObjectParams { + username: string; + work_update: WorkUpdate; +} + +export interface SaveWorkResult { + work: Work; +} + +export interface DeleteWorkParams extends JSONRPC20ObjectParams { + username: string; + put_code: string; +} + +export default class ORCIDLinkAPI extends ServiceClient { + module = 'ORCIDLink'; + prefix = false; + + async status(): Promise { + const result = await this.callFunc('status'); + return result as unknown as StatusResult; + } + + async info(): Promise { + const result = await this.callFunc('info'); + return result as unknown as InfoResult; + } + + async errorInfo(errorCode: number): Promise { + const result = await this.callFunc('error-info', { + error_code: errorCode, + }); + return result as unknown as ErrorInfoResult; + } + + async isLinked(params: IsLinkedParams): Promise { + const result = await this.callFunc('is-linked', params); + return result as unknown as boolean; + } + + async getOwnerLink(params: LinkParams): Promise { + const result = await this.callFunc('owner-link', params); + return result as unknown as LinkRecordPublic; + } + + async getOtherLink( + params: LinkForOtherParams + ): Promise { + const result = await this.callFunc('other-link', params); + return result as unknown as LinkRecordPublicNonOwner; + } + + async deleteOwnLink(params: DeleteLinkParams): Promise { + await this.callFunc('delete-own-link', params); + } + + async createLinkingSession( + params: CreateLinkingSessionParams + ): Promise { + const result = await this.callFunc('create-linking-session', params); + return result as unknown as CreateLinkingSessionResult; + } + + async getLinkingSession( + params: GetLinkingSessionParams + ): Promise { + const result = await this.callFunc('get-linking-session', params); + return result as unknown as LinkingSessionPublicComplete; + } + + async deleteLinkingSession( + params: DeleteLinkingSessionParams + ): Promise { + await this.callFunc('delete-linking-session', params); + } + + async finishLinkingSession( + params: FinishLinkingSessionParams + ): Promise { + await this.callFunc('finish-linking-session', params); + } + + // ORCID profile + + async getProfile(params: GetProfileParams): Promise { + const result = await this.callFunc('get-orcid-profile', params); + return result as unknown as ORCIDProfile; + } +} diff --git a/src/features/orcidlink/common/api/ORCIDLinkAPI.test.ts b/src/features/orcidlink/common/api/ORCIDLinkAPI.test.ts new file mode 100644 index 00000000..32255162 --- /dev/null +++ b/src/features/orcidlink/common/api/ORCIDLinkAPI.test.ts @@ -0,0 +1,251 @@ +import fetchMock from 'jest-fetch-mock'; +import { FetchMock } from 'jest-fetch-mock/types'; +import { ORCIDProfile } from '../../../../common/api/orcidLinkCommon'; +import { + ERROR_INFO_1, + LINKING_SESSION_1, + LINK_RECORD_1, + LINK_RECORD_OTHER_1, + PROFILE_1, +} from '../../test/data'; +import { makeORCIDLinkAPI } from '../../test/mocks'; +import { makeOrcidlinkServiceMock } from '../../test/orcidlinkServiceMock'; +import { + CreateLinkingSessionParams, + CreateLinkingSessionResult, + DeleteLinkingSessionParams, + DeleteLinkParams, + FinishLinkingSessionParams, + GetLinkingSessionParams, + GetProfileParams, + LinkingSessionPublicComplete, +} from './ORCIDLInkAPI'; + +describe('The ORCIDLink API', () => { + let mockService: FetchMock; + + beforeEach(() => { + fetchMock.enableMocks(); + fetchMock.doMock(); + mockService = makeOrcidlinkServiceMock(); + }); + + afterEach(() => { + mockService.mockClear(); + fetchMock.disableMocks(); + }); + + it('correctly calls the "status" method', async () => { + const api = makeORCIDLinkAPI(); + + const result = await api.status(); + + expect(result.status).toBe('ok'); + }); + + it('correctly calls the "info" method', async () => { + const api = makeORCIDLinkAPI(); + + const result = await api.info(); + + expect(result['git-info'].author_name).toBe('foo'); + }); + + it('correctly calls the "error-info" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases = [ + { + params: { + errorCode: 123, + }, + expected: ERROR_INFO_1, + }, + ]; + + for (const { params, expected } of testCases) { + const result = await api.errorInfo(params.errorCode); + expect(result).toMatchObject(expected); + } + }); + + it('correctly calls the "is-linked" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases = [ + { + params: { + username: 'foo', + }, + expected: false, + }, + { + params: { + username: 'bar', + }, + expected: true, + }, + ]; + + for (const { params, expected } of testCases) { + const result = await api.isLinked(params); + expect(result).toBe(expected); + } + }); + + it('correctly calls the "owner-link" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases = [ + { + params: { + username: 'foo', + }, + expected: LINK_RECORD_1, + }, + ]; + + for (const { params, expected } of testCases) { + const result = await api.getOwnerLink(params); + expect(result).toMatchObject(expected); + } + }); + + it('correctly calls the "other-link" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases = [ + { + params: { + username: 'bar', + }, + expected: LINK_RECORD_OTHER_1, + }, + ]; + + for (const { params, expected } of testCases) { + const result = await api.getOtherLink(params); + expect(result).toMatchObject(expected); + } + }); + + it('correctly calls the "delete-own-link" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases: Array<{ params: DeleteLinkParams }> = [ + { + params: { + username: 'bar', + }, + }, + ]; + + for (const { params } of testCases) { + const result = await api.deleteOwnLink(params); + expect(result).toBeUndefined(); + } + }); + + it('correctly calls the "create-linking-session" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases: Array<{ + params: CreateLinkingSessionParams; + expected: CreateLinkingSessionResult; + }> = [ + { + params: { + username: 'foo', + }, + expected: { + session_id: 'foo_session_id', + }, + }, + ]; + + for (const { params, expected } of testCases) { + const result = await api.createLinkingSession(params); + expect(result).toMatchObject(expected); + } + }); + + it('correctly calls the "get-linking-session" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases: Array<{ + params: GetLinkingSessionParams; + expected: LinkingSessionPublicComplete; + }> = [ + { + params: { + session_id: 'foo_session2', + }, + expected: LINKING_SESSION_1, + }, + ]; + + for (const { params, expected } of testCases) { + const result = await api.getLinkingSession(params); + expect(result).toMatchObject(expected); + } + }); + + it('correctly calls the "delete-linking-session" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases: Array<{ + params: DeleteLinkingSessionParams; + }> = [ + { + params: { + session_id: 'foo_session', + }, + }, + ]; + + for (const { params } of testCases) { + const result = await api.deleteLinkingSession(params); + expect(result).toBeUndefined(); + } + }); + + it('correctly calls the "finish-linking-session" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases: Array<{ + params: FinishLinkingSessionParams; + }> = [ + { + params: { + session_id: 'foo_session', + }, + }, + ]; + + for (const { params } of testCases) { + const result = await api.finishLinkingSession(params); + expect(result).toBeUndefined(); + } + }); + + it('correctly calls the "get-orcid-profile" method', async () => { + const api = makeORCIDLinkAPI(); + + const testCases: Array<{ + params: GetProfileParams; + expected: ORCIDProfile; + }> = [ + { + params: { + username: 'foo', + }, + expected: PROFILE_1, + }, + ]; + + for (const { params, expected } of testCases) { + const result = await api.getProfile(params); + expect(result).toMatchObject(expected); + } + }); +}); diff --git a/src/features/orcidlink/common/api/ServiceClient.test.ts b/src/features/orcidlink/common/api/ServiceClient.test.ts new file mode 100644 index 00000000..f7165daf --- /dev/null +++ b/src/features/orcidlink/common/api/ServiceClient.test.ts @@ -0,0 +1,205 @@ +import fetchMock from 'jest-fetch-mock'; +import { FetchMock, MockResponseInit } from 'jest-fetch-mock/types'; +import { API_CALL_TIMEOUT } from '../../test/data'; +import { + jsonrpc20_response, + makeResultObject, +} from '../../test/jsonrpc20ServiceMock'; +import { JSONRPC20ResponseObject } from './JSONRPC20'; +import { ServiceClient } from './ServiceClient'; + +export function makeMyServiceMock() { + function handleRPC( + id: string, + method: string, + params: unknown + ): JSONRPC20ResponseObject { + switch (method) { + case 'foo': + return makeResultObject(id, 'bar'); + case 'FooModule.foo': + return makeResultObject(id, 'RESULT FOR METHOD WITH MODULE PREFIX'); + case 'batch1': + return makeResultObject(id, 'batch_response_1'); + case 'batch2': + return makeResultObject(id, 'batch_response_2'); + case 'FooModule.batch1': + return makeResultObject(id, 'batch_response_1'); + case 'FooModule.batch2': + return makeResultObject(id, 'batch_response_2'); + default: + // eslint-disable-next-line no-console + console.debug('METHOD NOT HANDLED', method, params); + throw new Error('method not handled'); + } + } + + return fetchMock.mockResponse( + async (request): Promise => { + const { pathname } = new URL(request.url); + // put a little delay in here so that we have a better + // chance of catching temporary conditions, like loading. + await new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 300); + }); + switch (pathname) { + // Mocks for the orcidlink api + case '/myservice': { + if (request.method !== 'POST') { + return ''; + } + const body = await request.json(); + + const response: + | JSONRPC20ResponseObject + | Array = (() => { + if (body instanceof Array) { + // batch + return body.map((rpc) => { + const id = rpc['id']; + const method = rpc['method']; + const params = rpc['params']; + return handleRPC(id, method, params); + }); + } else { + // single reqquest + const id = body['id']; + const method = body['method']; + const params = body['params']; + return handleRPC(id, method, params); + } + })(); + + return jsonrpc20_response(response); + } + default: + // eslint-disable-next-line no-console + console.debug('PATH NOT HANDLED', pathname); + throw new Error('pathname not handled'); + } + } + ); +} + +describe('The ServiceClient abstract base class', () => { + let mockService: FetchMock; + + beforeEach(() => { + fetchMock.enableMocks(); + // fetchMock.doMock(); + mockService = makeMyServiceMock(); + }); + + afterEach(() => { + mockService.mockClear(); + fetchMock.disableMocks(); + }); + + it('can be used to create a basic JSON-RPC 2.0 client without method prefix', async () => { + class MyServiceClient extends ServiceClient { + module = 'FooModule'; + prefix = false; + + async foo(): Promise { + const result = await this.callFunc('foo'); + return result as unknown as string; + } + } + + const client = new MyServiceClient({ + timeout: API_CALL_TIMEOUT, + url: 'http://localhost/myservice', + }); + + expect(client).not.toBeNull(); + + const result = await client.foo(); + + expect(result).toBe('bar'); + }); + + it('can be used to create a basic JSON-RPC 2.0 client with method prefix', async () => { + class MyServiceClient extends ServiceClient { + module = 'FooModule'; + prefix = true; + + async foo(): Promise { + const result = await this.callFunc('foo'); + return result as unknown as string; + } + } + + const client = new MyServiceClient({ + timeout: API_CALL_TIMEOUT, + url: 'http://localhost/myservice', + }); + + expect(client).not.toBeNull(); + + const result = await client.foo(); + + expect(result).toBe('RESULT FOR METHOD WITH MODULE PREFIX'); + }); + + it('can be used to create a basic JSON-RPC 2.0 client with batch support', async () => { + class MyServiceClient extends ServiceClient { + module = 'FooModule'; + prefix = false; + + async batch(): Promise { + const result = await this.callBatch([ + { + funcName: 'batch1', + }, + { + funcName: 'batch2', + }, + ]); + return result as unknown as string; + } + } + + const client = new MyServiceClient({ + timeout: API_CALL_TIMEOUT, + url: 'http://localhost/myservice', + }); + + expect(client).not.toBeNull(); + + const result = await client.batch(); + + expect(result).toBeInstanceOf(Array); + }); + + it('can be used to create a basic JSON-RPC 2.0 client with batch support and module name prefix', async () => { + class MyServiceClient extends ServiceClient { + module = 'FooModule'; + prefix = true; + + async batch(): Promise { + const result = await this.callBatch([ + { + funcName: 'batch1', + }, + { + funcName: 'batch2', + }, + ]); + return result as unknown as string; + } + } + + const client = new MyServiceClient({ + timeout: API_CALL_TIMEOUT, + url: 'http://localhost/myservice', + }); + + expect(client).not.toBeNull(); + + const result = await client.batch(); + + expect(result).toBeInstanceOf(Array); + }); +}); diff --git a/src/features/orcidlink/common/api/ServiceClient.ts b/src/features/orcidlink/common/api/ServiceClient.ts new file mode 100644 index 00000000..09261dfe --- /dev/null +++ b/src/features/orcidlink/common/api/ServiceClient.ts @@ -0,0 +1,106 @@ +/** + * A base client for KBase services based on JSON-RPC 2.0 + * + * Basically just a wrapper around JSONRPCE20.ts, but captures KBase usage + * patterns. For example, KBase services typically have a "module name" + * (essentially a service name or identifier), and construct the method name + * from a concatenation of the module name and a method name. E.g. if the + * service is "Foo" and the method "bar", the method name for the RPC call is + * "Foo.bar". This is the pattern enforced by kb-sdk. But since kb-sdk only + * supports JSON-RPC 1.1, we are free to use plain method names; in the example + * above, simply "bar". + * + */ + +import { + batchResultOrThrow, + JSONRPC20Client, + JSONRPC20Params, + JSONRPC20Result, + resultOrThrow, +} from './JSONRPC20'; + +export interface ServiceClientParams { + url: string; + timeout: number; + token?: string; +} + +export interface BatchParams { + funcName: string; + params?: JSONRPC20Params; +} + +/** + * The base class for all KBase JSON-RPC 1.1 services + */ +export abstract class ServiceClient { + abstract module: string; + abstract prefix: boolean; + url: string; + timeout: number; + token?: string; + + constructor({ url, timeout, token }: ServiceClientParams) { + this.url = url; + this.timeout = timeout; + this.token = token; + } + + /** + * The single point of entry for RPC calls, just to help dry out the class. + * + * @param funcName + * @param params + * @returns + */ + + public async callFunc( + funcName: string, + params?: JSONRPC20Params + ): Promise { + const client = new JSONRPC20Client({ + url: this.url, + timeout: this.timeout, + token: this.token, + }); + const method = (() => { + if (this.prefix) { + return `${this.module}.${funcName}`; + } else { + return funcName; + } + })(); + const result = await client.callMethod(method, params, { + timeout: this.timeout, + }); + return resultOrThrow(result); + } + + public async callBatch( + batch: Array + ): Promise> { + const client = new JSONRPC20Client({ + url: this.url, + timeout: this.timeout, + token: this.token, + }); + + const batchParams = batch.map(({ funcName, params }) => { + const method = (() => { + if (this.prefix) { + return `${this.module}.${funcName}`; + } else { + return funcName; + } + })(); + + return { method, params }; + }); + + const result = await client.callBatch(batchParams, { + timeout: this.timeout, + }); + return batchResultOrThrow(result); + } +} diff --git a/src/features/orcidlink/common/api/orcidlinkAPICommon.ts b/src/features/orcidlink/common/api/orcidlinkAPICommon.ts new file mode 100644 index 00000000..ed490442 --- /dev/null +++ b/src/features/orcidlink/common/api/orcidlinkAPICommon.ts @@ -0,0 +1,72 @@ +export interface ORCIDAuthPublic { + expires_in: number; + name: string; + orcid: string; + scope: string; +} + +export interface LinkRecordPublic { + created_at: number; + expires_at: number; + retires_at: number; + username: string; + orcid_auth: ORCIDAuthPublic; +} + +export interface ORCIDAuthPublicNonOwner { + orcid: string; + name: string; +} + +export interface LinkRecordPublicNonOwner { + username: string; + orcid_auth: ORCIDAuthPublicNonOwner; +} + +// ORCID User Profile + +export interface Affiliation { + name: string; + role: string; + startYear: string; + endYear: string | null; +} + +export interface ORCIDFieldGroupBase { + private: boolean; +} + +export interface ORCIDFieldGroupPrivate extends ORCIDFieldGroupBase { + private: true; +} + +export interface ORCIDFieldGroupAccessible extends ORCIDFieldGroupBase { + private: false; + fields: T; +} + +export type ORCIDFieldGroup = + | ORCIDFieldGroupPrivate + | ORCIDFieldGroupAccessible; + +export interface ORCIDNameFieldGroup { + firstName: string; + lastName: string | null; + creditName: string | null; +} + +export interface ORCIDBiographyFieldGroup { + bio: string; +} + +export interface ORCIDEmailFieldGroup { + emailAddresses: Array; +} + +export interface ORCIDProfile { + orcidId: string; + nameGroup: ORCIDFieldGroup; + biographyGroup: ORCIDFieldGroup; + emailGroup: ORCIDFieldGroup; + employments: Array; +} diff --git a/src/features/orcidlink/common/styles.module.scss b/src/features/orcidlink/common/styles.module.scss new file mode 100644 index 00000000..733c353d --- /dev/null +++ b/src/features/orcidlink/common/styles.module.scss @@ -0,0 +1,11 @@ +@import "../../../common/colors"; + +/** +* A background color for orcidlink pages. +* +* Used to enable content contained in "Card" components to be distict, as cards +* are rather subtle. Similar approach, and same color, as Navigator. +*/ +.paper { + background-color: use-color("base-lightest"); +} diff --git a/src/features/orcidlink/constants.ts b/src/features/orcidlink/constants.ts index 4724bf42..8f4ea3c0 100644 --- a/src/features/orcidlink/constants.ts +++ b/src/features/orcidlink/constants.ts @@ -6,6 +6,8 @@ * If a constat value is hardcoded, it should be moved here. */ +import { baseUrl } from '../../common/api'; + export const MANAGER_ROLE = 'ORCIDLINK_MANAGER'; export type ORCIDScope = '/read-limited' | '/activities/update'; @@ -74,6 +76,32 @@ function image_url(filename: string): string { export const ORCID_ICON_URL = image_url('ORCID-iD_icon-vector.svg'); export const ORCID_SIGN_IN_SCREENSHOT_URL = image_url('ORCID-sign-in.png'); +/** + * Conforms to ORCID branding + */ export const ORCID_LABEL = 'ORCID®'; +/** + * Conforms to ORCID branding and helps us make a consistent label for this service. + */ export const ORCID_LINK_LABEL = `KBase ${ORCID_LABEL} Link`; + +/** + * Service paths. + * + * Ideally these would be in a central config for services, but there does not + * appear to be such a thing at present. Rather the service paths are embedded + * in the RTK api definitions. + */ +export const ORCIDLINK_SERVICE_API_ENDPOINT = `${baseUrl}services/orcidlink/api/v1`; +export const ORCIDLINK_SERVICE_OAUTH_ENDPOINT = `${baseUrl}/services/orcidlink`; + +/** + * An API call will be abandoned after this duration of time, 1 minute. + * + * Typically, the network stack may have other timeouts involved. Generally, any + * request used by orcidlink is expected to be short-duration. A long timeout + * that may be appropriate for a rare case like uploading a large file is simply + * not expected, so a short timeout limit should be appropriate. + */ +export const API_CALL_TIMEOUT = 60000; diff --git a/src/features/orcidlink/index.test.tsx b/src/features/orcidlink/index.test.tsx deleted file mode 100644 index 72b496a7..00000000 --- a/src/features/orcidlink/index.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import fetchMock from 'jest-fetch-mock'; -import { Provider } from 'react-redux'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { createTestStore } from '../../app/store'; -import MainView from './index'; -import { INITIAL_STORE_STATE } from './test/data'; -import { setupMockRegularUser } from './test/mocks'; - -describe('The Main Component', () => { - let debugLogSpy: jest.SpyInstance; - beforeEach(() => { - jest.resetAllMocks(); - }); - beforeEach(() => { - fetchMock.resetMocks(); - fetchMock.enableMocks(); - debugLogSpy = jest.spyOn(console, 'debug'); - }); - - it('renders with minimal props', async () => { - setupMockRegularUser(); - - render( - - - - } /> - - {/* */} - - - ); - - const creditName = 'Foo B. Bar'; - const realName = 'Foo Bar'; - - // Part of the profile should be available - expect(await screen.findByText(creditName)).toBeVisible(); - expect(await screen.findByText(realName)).toBeVisible(); - }); - - it('can switch to the "manage your link" tab', async () => { - const user = userEvent.setup(); - setupMockRegularUser(); - - render( - - - - } /> - - - - ); - - // Matches test data (see the setup function above) - const creditName = 'Foo B. Bar'; - // Matches what would be synthesized from the test data - const realName = 'Foo Bar'; - - // Part of the profile should be available - expect(await screen.findByText(creditName)).toBeVisible(); - expect(await screen.findByText(realName)).toBeVisible(); - - const tab = await screen.findByText('Manage Your Link'); - expect(tab).not.toBeNull(); - - await user.click(tab); - - await waitFor(() => { - expect( - screen.queryByText('Remove your KBase ORCID® Link') - ).not.toBeNull(); - expect(screen.queryByText('Settings')).not.toBeNull(); - }); - }); - - it('the "Show in User Profile?" switch calls the prop function we pass', async () => { - const user = userEvent.setup(); - setupMockRegularUser(); - - render( - - - - } /> - - - - ); - - const tab = await screen.findByText('Manage Your Link'); - expect(tab).not.toBeNull(); - await user.click(tab); - - await waitFor(() => { - expect(screen.queryByText('Settings')).not.toBeNull(); - }); - - const toggleControl = await screen.findByText('Yes'); - - await user.click(toggleControl); - - await waitFor(() => { - expect(debugLogSpy).toHaveBeenCalledWith('TOGGLE SHOW IN PROFILE'); - }); - }); -}); diff --git a/src/features/orcidlink/index.tsx b/src/features/orcidlink/index.tsx deleted file mode 100644 index b57131e2..00000000 --- a/src/features/orcidlink/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Box } from '@mui/material'; -import Home from './Home'; -import styles from './orcidlink.module.scss'; - -const ORCIDLinkFeature = () => { - return ( - - - - ); -}; - -export default ORCIDLinkFeature; diff --git a/src/features/orcidlink/orcidlink.module.scss b/src/features/orcidlink/orcidlink.module.scss deleted file mode 100644 index 6aa137f0..00000000 --- a/src/features/orcidlink/orcidlink.module.scss +++ /dev/null @@ -1,37 +0,0 @@ -@import "../../common/colors"; - -.box { - border: 4px solid use-color("primary"); - border-radius: 1rem; - margin: 1rem; - padding: 1rem; -} - -.prop-table { - display: flex; - flex-direction: column; -} - -.prop-table > div { - display: flex; - flex-direction: row; - margin-bottom: 0.5rem; -} - -.prop-table > div > div:nth-child(1) { - flex: 0 0 12rem; - font-weight: bold; -} - -.prop-table > div > div:nth-child(2) { - flex: 1 1 0; -} - -.paper { - background-color: use-color("base-lightest"); - height: 100%; -} - -.section-title { - font-weight: bold; -} diff --git a/src/features/orcidlink/test/data.ts b/src/features/orcidlink/test/data.ts index 0716be41..8f839511 100644 --- a/src/features/orcidlink/test/data.ts +++ b/src/features/orcidlink/test/data.ts @@ -1,16 +1,52 @@ +/** + * Contains test data used in orcidlink tests. + * + * Most test data should reside here. + */ + import { InfoResult } from '../../../common/api/orcidlinkAPI'; import { LinkRecordPublic, + LinkRecordPublicNonOwner, ORCIDProfile, } from '../../../common/api/orcidLinkCommon'; import { JSONRPC20Error } from '../../../common/api/utils/kbaseBaseQuery'; -import orcidlinkIsLinkedAuthorizationRequired from './data/orcidlink-is-linked-1010.json'; +import { + ErrorInfoResult, + LinkingSessionPublicComplete, + StatusResult, +} from '../common/api/ORCIDLInkAPI'; + +// We can have a short default timeout, as tests should be running against a +// local server with very low latency. +// +// Of course if you are testing timeout errors, you should ignore this and use +// whatever values are required to trigger whatever conditions are needed. +export const API_CALL_TIMEOUT = 1000; + +export const STATUS_1: StatusResult = { + status: 'ok', + current_time: 123, + start_time: 456, +}; -export const ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED: JSONRPC20Error = - orcidlinkIsLinkedAuthorizationRequired; +export const ERROR_INFO_1: ErrorInfoResult = { + error_info: { + code: 123, + title: 'Foo Error', + description: 'This is the foo error', + status_code: 400, + }, +}; + +export const ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED: JSONRPC20Error = { + code: 1010, + message: 'Authorization Required', + data: 'Authorization Required', +}; export const PROFILE_1: ORCIDProfile = { - orcidId: '0009-0006-1955-0944', + orcidId: 'foo_orcid_id', nameGroup: { private: false, fields: { @@ -39,6 +75,22 @@ export const PROFILE_1: ORCIDProfile = { ], }; +export const LINKING_SESSION_1: LinkingSessionPublicComplete = { + session_id: 'f85a9b43-5dd4-4e1d-ad66-9f923fde5de2', + username: 'kbaseuitest', + created_at: 1718044771667, + expires_at: 1718045371667, + return_link: null, + skip_prompt: false, + ui_options: '', + orcid_auth: { + name: 'Erik T. Pearson', + scope: '/read-limited /activities/update', + expires_in: 631138518, + orcid: '0009-0008-7728-946X', + }, +}; + export const LINK_RECORD_1: LinkRecordPublic = { username: 'foo', created_at: 1714546800000, @@ -52,6 +104,14 @@ export const LINK_RECORD_1: LinkRecordPublic = { }, }; +export const LINK_RECORD_OTHER_1: LinkRecordPublicNonOwner = { + username: 'bar', + orcid_auth: { + name: 'Bar', + orcid: 'bar_orcid_id', + }, +}; + export const SERVICE_INFO_1: InfoResult = { 'git-info': { author_name: 'foo', @@ -96,6 +156,32 @@ export const INITIAL_STORE_STATE = { }, }; +export const INITIAL_STORE_STATE_UNAUTHENTICATED = { + auth: { + token: undefined, + username: undefined, + tokenInfo: undefined, + initialized: false, + }, +}; + +export const INITIAL_STORE_STATE_BAR = { + auth: { + token: 'bar_token', + username: 'bar', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Bar Baz', + type: 'Login', + user: 'bar', + cachefor: 890, + }, + initialized: true, + }, +}; + export const INITIAL_UNAUTHENTICATED_STORE_STATE = { auth: { initialized: true, diff --git a/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json b/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json deleted file mode 100644 index 522486a9..00000000 --- a/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 1010, - "message": "Authorization Required", - "data": "Authorization Required" -} diff --git a/src/features/orcidlink/test/jsonrpc20ServiceMock.ts b/src/features/orcidlink/test/jsonrpc20ServiceMock.ts new file mode 100644 index 00000000..feef1d8c --- /dev/null +++ b/src/features/orcidlink/test/jsonrpc20ServiceMock.ts @@ -0,0 +1,212 @@ +/** + * Support for creating mocks of JSON-RPC 2.0 services. + * + * Contains utility functions to reduce the repetetiveness of ressponses, and a + * general-purpose mechanism for providing method implementations for testing + * using jest-fetch-mock. + * + */ +import { MockResponseInit } from 'jest-fetch-mock/types'; +import { + JSONRPC20Error, + JSONRPC20Id, + JSONRPC20Request, + JSONRPC20ResponseObject, + JSONRPC20Result, +} from '../common/api/JSONRPC20'; + +/** + * Constructs a JSON-RPC 2.0 repsonse object with a result. + */ +export function makeResultObject( + id: JSONRPC20Id, + result: JSONRPC20Result +): JSONRPC20ResponseObject { + return { + jsonrpc: '2.0', + id, + result, + }; +} + +/** + * Construct a JSON-RPC 2.0 response with an error. + */ +export function makeErrorObject( + id: JSONRPC20Id, + error: JSONRPC20Error +): JSONRPC20ResponseObject { + return { + jsonrpc: '2.0', + id, + error, + }; +} + +/** + * Convenience function to create a JSON-RPC 2.0 batch response, either result or error, within a + * jest-fetch-mock response + * + * JSON-RPC 2.0 has a batch mode, which can make multiple concurrent requests + * more efficient and faster. We don't currently use batch mode, at least in + * orcidlink, simply because it would take additional work to redesign the RTK + * query support. + * + * Batch mode, btw, is supported by the orcidlink service. + */ +export function makeBatchResponseObject( + result: Array +): MockResponseInit { + return { + body: JSON.stringify(result), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; +} + +/** + * Convenience funciton to create a JSON-RPC 2.0 result response within a + * jest-fetch-mock response + */ +export function jsonrpc20_resultResponse( + id: JSONRPC20Id, + result: JSONRPC20Result +): MockResponseInit { + return { + body: JSON.stringify(makeResultObject(id, result)), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; +} + +/** + * Convenience funciton to create a JSON-RPC 2.0 error response, either result or error, within a + * jest-fetch-mock response + */ +export function jsonrpc20_errorResponse( + id: JSONRPC20Id, + error: JSONRPC20Error +): MockResponseInit { + return { + body: JSON.stringify(makeErrorObject(id, error)), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; +} + +/** + * Convenience funciton to create a JSON-RPC 2.0 response, either result or error, within a + * jest-fetch-mock response. + */ +export function jsonrpc20_response( + rpc: JSONRPC20ResponseObject | Array +): MockResponseInit { + return { + body: JSON.stringify(rpc), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; +} + +/** + * The method spec is a mock implementation for one method in a service. + */ +export interface RPCMethodResultSpec { + path: string; + method: string; + result: (request: JSONRPC20Request) => JSONRPC20Result; +} + +export interface RPCMethodErrorSpec { + path: string; + method: string; + error: (request: JSONRPC20Request) => JSONRPC20Error; +} + +export type RPCMethodSpec = RPCMethodResultSpec | RPCMethodErrorSpec; + +// Determines a little pause in the handling of a request. +const REQUEST_LATENCY = 300; + +/** + * Creates a general-purpose JSON-RPC 2.0 server to be used in tests via the + * "jest-fetch-mock" library. The "specs" parameter provides any methods to be implemented. + * + * Note that the "jest-fetch-mock" provides the "fetchMock" variable globally + * when "enableMocks()" is called in a test. + * See https://www.npmjs.com/package/jest-fetch-mock + */ +export function makeJSONRPC20Server(specs: Array) { + fetchMock.mockResponse( + async (request): Promise => { + const { pathname } = new URL(request.url); + // put a little delay in here so that we have a better + // chance of catching passing conditions, like loading. + await new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, REQUEST_LATENCY); + }); + + if (request.method !== 'POST') { + return ''; + } + + const rpc = (await request.json()) as JSONRPC20Request; + + const id = rpc.id; + if (!id) { + throw new Error('Id must be provided (we do not use notifications)'); + } + + for (const spec of specs) { + const { path, method } = spec; + if (!(path === pathname && method === rpc['method'])) { + continue; + } + if ('result' in spec) { + return jsonrpc20_resultResponse(id, spec.result(rpc)); + } else { + return jsonrpc20_errorResponse(id, spec.error(rpc)); + } + } + + // If a service method is called, but is not mocked in the "specs", then + // this error should be displayed somewhere in the test failure. This is + // never an expected condition, and indicates that the mock server + // implementation is not complete, or a api call is incorrect during testing. + throw new Error(`NOT HANDLED: ${pathname}, ${rpc.method}`); + } + ); +} + +export type APIOverrides = Record< + string, + Record JSONRPC20ResponseObject> +>; + +export function getOverride( + method: string, + param: string, + overrides: APIOverrides +) { + if (!(method in overrides)) { + return; + } + + const overrideMethod = overrides[method]; + + if (!(param in overrideMethod)) { + return; + } + + return overrideMethod[param]; +} diff --git a/src/features/orcidlink/test/mocks.ts b/src/features/orcidlink/test/mocks.ts index d8ab8956..ca82490d 100644 --- a/src/features/orcidlink/test/mocks.ts +++ b/src/features/orcidlink/test/mocks.ts @@ -1,162 +1,15 @@ -import { MockResponseInit } from 'jest-fetch-mock/types'; -import { JSONRPC20Error } from '../../../common/api/utils/kbaseBaseQuery'; -import { - LINK_RECORD_1, - ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED, - PROFILE_1, - SERVICE_INFO_1, -} from './data'; - -export function jsonrpc20_resultResponse(id: string, result: unknown) { - return { - body: JSON.stringify({ - jsonrpc: '2.0', - id, - result, - }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; -} - -export function jsonrpc20_errorResponse(id: string, error: JSONRPC20Error) { - return { - body: JSON.stringify({ - jsonrpc: '2.0', - id, - error, - }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function restResponse(result: any, status = 200) { - return { - body: JSON.stringify(result), - status, - headers: { - 'content-type': 'application/json', - }, - }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function mockIsLinkedResponse(body: any) { - const username = body['params']['username']; - - const result = (() => { - switch (username) { - case 'foo': - return true; - case 'bar': - return false; - default: - throw new Error('Invalid test value for username'); - } - })(); - return jsonrpc20_resultResponse(body['id'], result); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function mockIsLinkedNotResponse(body: any) { - return jsonrpc20_resultResponse(body['id'], false); -} - -export function setupMockRegularUser() { - fetchMock.mockResponse( - async (request): Promise => { - const { pathname } = new URL(request.url); - // put a little delay in here so that we have a better - // chance of catching temporary conditions, like loading. - await new Promise((resolve) => { - setTimeout(() => { - resolve(null); - }, 300); - }); - switch (pathname) { - // Mocks for the orcidlink api - case '/services/orcidlink/api/v1': { - if (request.method !== 'POST') { - return ''; - } - const body = await request.json(); - const id = body['id']; - switch (body['method']) { - case 'is-linked': - // In this mock, user "foo" is linked, user "bar" is not. - return jsonrpc20_resultResponse(id, mockIsLinkedResponse(body)); - case 'get-orcid-profile': - // simulate fetching an orcid profile - return jsonrpc20_resultResponse(id, PROFILE_1); - case 'owner-link': - // simulate fetching the link record for a user - return jsonrpc20_resultResponse(id, LINK_RECORD_1); - case 'info': - // simulate getting service info. - return jsonrpc20_resultResponse(id, SERVICE_INFO_1); - default: - return ''; - } - } - default: - return ''; - } - } - ); -} - -export function setupMockRegularUserWithError() { - fetchMock.mockResponse( - async (request): Promise => { - const { pathname } = new URL(request.url); - // put a little delay in here so that we have a better - // chance of catching temporary conditions, like loading. - await new Promise((resolve) => { - setTimeout(() => { - resolve(null); - }, 300); - }); - switch (pathname) { - // Mocks for the orcidlink api - case '/services/orcidlink/api/v1': { - if (request.method !== 'POST') { - return ''; - } - const body = await request.json(); - const id = body['id'] as string; - switch (body['method']) { - case 'is-linked': - return jsonrpc20_errorResponse( - id, - ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED - ); - case 'get-orcid-profile': { - return jsonrpc20_errorResponse( - id, - ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED - ); - } - case 'owner-link': - // simulate fetching the link record for a user - return jsonrpc20_resultResponse(id, LINK_RECORD_1); - - case 'info': - // simulate getting service info - return jsonrpc20_resultResponse(id, SERVICE_INFO_1); - - default: - return ''; - } - } - default: - return ''; - } - } - ); +import ORCIDLinkAPI from '../common/api/ORCIDLInkAPI'; +import { ORCIDLINK_SERVICE_API_ENDPOINT } from '../constants'; +import { API_CALL_TIMEOUT } from './data'; + +/** + * A convienence function to make an orcidlink service endpoint for tests. + * + * Note that the service endpoint should be computed + */ +export function makeORCIDLinkAPI(): ORCIDLinkAPI { + return new ORCIDLinkAPI({ + timeout: API_CALL_TIMEOUT, + url: ORCIDLINK_SERVICE_API_ENDPOINT, + }); } diff --git a/src/features/orcidlink/test/orcidlinkServiceMock.ts b/src/features/orcidlink/test/orcidlinkServiceMock.ts new file mode 100644 index 00000000..4b4f67f4 --- /dev/null +++ b/src/features/orcidlink/test/orcidlinkServiceMock.ts @@ -0,0 +1,228 @@ +/** + * Mocks the orcidlink service for tests which rely upon the orcidlink service. + * + * Utilizes jest-fetch-mock to provide mock impelementations of orcidlink + * service methods. + */ +import 'core-js/actual/structured-clone'; +import { MockResponseInit } from 'jest-fetch-mock/types'; +import { + JSONRPC20ErrorResponseObject, + JSONRPC20Id, +} from '../common/api/JSONRPC20'; +import ORCIDLinkAPI, { + ErrorInfo, + LinkingSessionPublicComplete, +} from '../common/api/ORCIDLInkAPI'; +import { + ERROR_INFO_1, + LINKING_SESSION_1, + LINK_RECORD_1, + LINK_RECORD_OTHER_1, + PROFILE_1, + SERVICE_INFO_1, + STATUS_1, +} from './data'; +import { + APIOverrides, + getOverride, + jsonrpc20_errorResponse, + jsonrpc20_response, + jsonrpc20_resultResponse, +} from './jsonrpc20ServiceMock'; + +export function makeError2( + id: JSONRPC20Id, + error: ErrorInfo +): JSONRPC20ErrorResponseObject { + const { code, title } = error; + return { + jsonrpc: '2.0', + id, + error: { + code, + message: title, + }, + }; +} + +export function makeOrcidlinkTestClient(): ORCIDLinkAPI { + return new ORCIDLinkAPI({ + timeout: 1000, + url: 'http://localhost/services/orcidlink/api/v1', + }); +} + +export const orcidlinkErrors: Record = { + 1010: { + code: 1010, + title: 'Authorization Required', + description: '', + status_code: 100, + }, +}; + +export function makeOrcidlinkServiceMock(overrides: APIOverrides = {}) { + return fetchMock.mockResponse( + async (request): Promise => { + const { pathname } = new URL(request.url); + // put a little delay in here so that we have a better + // chance of catching temporary conditions, like loading. + await new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 300); + }); + switch (pathname) { + // Mocks for the orcidlink api + case '/services/orcidlink/api/v1': { + if (request.method !== 'POST') { + return ''; + } + const body = await request.json(); + const id = body['id']; + const method = body['method']; + const params = body['params']; + switch (method) { + case 'status': + return jsonrpc20_resultResponse(id, STATUS_1); + case 'info': + return jsonrpc20_resultResponse(id, SERVICE_INFO_1); + case 'error-info': + return jsonrpc20_resultResponse(id, ERROR_INFO_1); + case 'is-linked': { + // In this mock, user "foo" is linked, user "bar" is not. + // return jsonrpc20_resultResponse(id, + // mockIsLinkedResponse(body)); + const username = params['username'] as unknown as string; + + const override = getOverride(method, username, overrides); + if (override) { + return jsonrpc20_response(override(body)); + } + + switch (username) { + case 'foo': + return jsonrpc20_resultResponse(id, false); + case 'bar': + return jsonrpc20_resultResponse(id, true); + case 'not_json': + return { + body: 'bad', + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + default: + throw new Error('case not handled'); + } + } + case 'get-orcid-profile': + // simulate fetching an orcid profile + return jsonrpc20_resultResponse(id, PROFILE_1); + case 'owner-link': + // simulate fetching the link record for a user + return jsonrpc20_resultResponse(id, LINK_RECORD_1); + case 'other-link': + // simulate fetching the link record for a user + return jsonrpc20_resultResponse(id, LINK_RECORD_OTHER_1); + + case 'delete-own-link': + return jsonrpc20_resultResponse(id, null); + + case 'get-linking-session': { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sessionId = params['session_id'] as unknown as string; + + switch (sessionId) { + case 'foo_session': { + const result = + structuredClone( + LINKING_SESSION_1 + ); + result.expires_at = Date.now() + 10000; + return jsonrpc20_resultResponse(id, result); + } + case 'foo_session_expired': { + const result = + structuredClone( + LINKING_SESSION_1 + ); + result.expires_at = Date.now() - 10000; + return jsonrpc20_resultResponse(id, result); + } + case 'foo_session2': { + return jsonrpc20_resultResponse(id, LINKING_SESSION_1); + } + case 'foo_session_error_1': + return jsonrpc20_errorResponse(id, { + code: 1010, + message: 'Authorization Required', + }); + case 'not_a_session': + return jsonrpc20_errorResponse(id, { + code: 1020, + message: 'Not Found', + }); + case 'bar_session': + return jsonrpc20_resultResponse(id, LINKING_SESSION_1); + default: + throw new Error('case not handled'); + } + } + + case 'create-linking-session': { + // const username = getObjectParam('username', params); + const params = body['params']; + const username = params['username'] as unknown as string; + switch (username) { + case 'foo': + return jsonrpc20_resultResponse(id, { + session_id: 'foo_session_id', + }); + default: + throw new Error('case not handled'); + } + } + + case 'delete-linking-session': { + const sessionId = params['session_id'] as unknown as string; + const override = getOverride(method, sessionId, overrides); + if (override) { + return jsonrpc20_response(override(body)); + } + + switch (sessionId) { + case 'foo_session': + return jsonrpc20_resultResponse(id, null); + default: + throw new Error('case not handled'); + } + } + + case 'finish-linking-session': { + const sessionId = params['session_id'] as unknown as string; + const override = getOverride(method, sessionId, overrides); + if (override) { + return jsonrpc20_response(override(body)); + } + switch (sessionId) { + case 'foo_session': + return jsonrpc20_resultResponse(id, null); + default: { + throw new Error('case not handled'); + } + } + } + + default: + throw new Error('case not handled'); + } + } + default: + throw new Error('case not handled'); + } + } + ); +}