diff --git a/src/sections/account/Account.tsx b/src/sections/account/Account.tsx index 3d06c6ec3..a9e4a77a8 100644 --- a/src/sections/account/Account.tsx +++ b/src/sections/account/Account.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { Tabs } from '@iqss/dataverse-design-system' import { AccountHelper, AccountPanelTabKey } from './AccountHelper' +import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/UserJSDataverseRepository' import { ApiTokenSection } from './api-token-section/ApiTokenSection' import styles from './Account.module.scss' @@ -8,9 +9,10 @@ const tabsKeys = AccountHelper.ACCOUNT_PANEL_TABS_KEYS interface AccountProps { defaultActiveTabKey: AccountPanelTabKey + userRepository: UserJSDataverseRepository } -export const Account = ({ defaultActiveTabKey }: AccountProps) => { +export const Account = ({ defaultActiveTabKey, userRepository }: AccountProps) => { const { t } = useTranslation('account') return ( @@ -34,7 +36,7 @@ export const Account = ({ defaultActiveTabKey }: AccountProps) => {
- +
diff --git a/src/sections/account/AccountFactory.tsx b/src/sections/account/AccountFactory.tsx index 0ecbab6b5..d4c2a6e20 100644 --- a/src/sections/account/AccountFactory.tsx +++ b/src/sections/account/AccountFactory.tsx @@ -2,6 +2,9 @@ import { ReactElement } from 'react' import { useSearchParams } from 'react-router-dom' import { AccountHelper } from './AccountHelper' import { Account } from './Account' +import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/UserJSDataverseRepository' + +const userRepository = new UserJSDataverseRepository() export class AccountFactory { static create(): ReactElement { @@ -13,5 +16,5 @@ function AccountWithSearchParams() { const [searchParams] = useSearchParams() const defaultActiveTabKey = AccountHelper.defineSelectedTabKey(searchParams) - return + return } diff --git a/src/sections/account/api-token-section/ApiTokenSection.tsx b/src/sections/account/api-token-section/ApiTokenSection.tsx index 7ead9d935..f63edb090 100644 --- a/src/sections/account/api-token-section/ApiTokenSection.tsx +++ b/src/sections/account/api-token-section/ApiTokenSection.tsx @@ -1,23 +1,78 @@ import { Trans, useTranslation } from 'react-i18next' +import { useEffect, useState } from 'react' +import { useGetApiToken } from './useGetCurrentApiToken' +import { useRecreateApiToken } from './useRecreateApiToken' +import { useRevokeApiToken } from './useRevokeApiToken' +import { ApiTokenSectionSkeleton } from './ApiTokenSectionSkeleton' +import { TokenInfo } from '@/users/domain/models/TokenInfo' +import { DateHelper } from '@/shared/helpers/DateHelper' +import { UserRepository } from '@/users/domain/repositories/UserRepository' import { Button } from '@iqss/dataverse-design-system' +import { Alert } from '@iqss/dataverse-design-system' import accountStyles from '../Account.module.scss' import styles from './ApiTokenSection.module.scss' -export const ApiTokenSection = () => { +interface ApiTokenSectionProps { + repository: UserRepository +} + +export const ApiTokenSection = ({ repository }: ApiTokenSectionProps) => { const { t } = useTranslation('account', { keyPrefix: 'apiToken' }) + const [currentApiTokenInfo, setCurrentApiTokenInfo] = useState() + + const { error: getError, apiTokenInfo, isLoading } = useGetApiToken(repository) + + useEffect(() => { + setCurrentApiTokenInfo(apiTokenInfo) + }, [apiTokenInfo]) + + const { + recreateToken, + isRecreating, + error: recreatingError, + apiTokenInfo: updatedTokenInfo + } = useRecreateApiToken(repository) + + useEffect(() => { + if (updatedTokenInfo) { + setCurrentApiTokenInfo(updatedTokenInfo) + } + }, [updatedTokenInfo]) + + const handleCreateToken = () => { + void recreateToken() + } - // TODO: When we have the use cases we need to mock stub to unit test this with or without token - const apiToken = '999fff-666rrr-this-is-not-a-real-token-123456' - const expirationDate = '2025-09-04' + const { revokeToken, isRevoking, error: revokingError } = useRevokeApiToken(repository) + + const handleRevokeToken = async () => { + await revokeToken() + setCurrentApiTokenInfo({ + apiToken: '', + expirationDate: new Date(0) + }) + } const copyToClipboard = () => { - navigator.clipboard.writeText(apiToken).catch( + navigator.clipboard.writeText(currentApiTokenInfo?.apiToken ?? '').catch( /* istanbul ignore next */ (error) => { console.error('Failed to copy text:', error) } ) } + if (isLoading || isRecreating || isRevoking) { + return + } + + if (getError || recreatingError || revokingError) { + return ( + + {getError || recreatingError || revokingError} + + ) + } + return ( <>

@@ -35,22 +90,27 @@ export const ApiTokenSection = () => { }} />

- {apiToken ? ( + {currentApiTokenInfo?.apiToken ? ( <>

- {t('expirationDate')} + {t('expirationDate')}{' '} +

- {apiToken} + {currentApiTokenInfo.apiToken}
- -
@@ -60,8 +120,8 @@ export const ApiTokenSection = () => {
{t('notCreatedApiToken')}
-
-
diff --git a/src/sections/account/api-token-section/ApiTokenSectionSkeleton.tsx b/src/sections/account/api-token-section/ApiTokenSectionSkeleton.tsx new file mode 100644 index 000000000..435eacd1e --- /dev/null +++ b/src/sections/account/api-token-section/ApiTokenSectionSkeleton.tsx @@ -0,0 +1,49 @@ +import { Trans, useTranslation } from 'react-i18next' +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' +import { Button } from '@iqss/dataverse-design-system' +import accountStyles from '../Account.module.scss' +import styles from './ApiTokenSection.module.scss' + +export const ApiTokenSectionSkeleton = () => { + const { t } = useTranslation('account', { keyPrefix: 'apiToken' }) + + return ( + <> +

+ + ) + }} + /> +

+ +
+

+ {t('expirationDate')}{' '} + +

+
+ + + +
+
+ + + +
+
+
+ + ) +} diff --git a/src/sections/account/api-token-section/useGetCurrentApiToken.tsx b/src/sections/account/api-token-section/useGetCurrentApiToken.tsx new file mode 100644 index 000000000..beea77ca8 --- /dev/null +++ b/src/sections/account/api-token-section/useGetCurrentApiToken.tsx @@ -0,0 +1,49 @@ +import { useState, useEffect, useCallback } from 'react' +import { TokenInfo } from '@/users/domain/models/TokenInfo' +import { UserRepository } from '@/users/domain/repositories/UserRepository' +import { getCurrentApiToken } from '@/users/domain/useCases/getCurrentApiToken' + +interface UseGetApiTokenResult { + apiTokenInfo: TokenInfo + isLoading: boolean + error: string | null +} + +export const useGetApiToken = (repository: UserRepository): UseGetApiTokenResult => { + const [apiTokenInfo, setApiTokenInfo] = useState({ + apiToken: '', + expirationDate: new Date(0) + }) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchTokenInfo = useCallback(async () => { + try { + setIsLoading(true) + const tokenInfo = await getCurrentApiToken(repository) + setApiTokenInfo({ + apiToken: tokenInfo.apiToken, + expirationDate: tokenInfo.expirationDate + }) + setError(null) + } catch (err) { + const errorMessage = + err instanceof Error && err.message + ? err.message + : 'Something went wrong getting the current api token. Try again later.' + setError( + errorMessage === + 'There was an error when reading the resource. Reason was: [404] Token not found.' + ? null + : errorMessage + ) + } finally { + setIsLoading(false) + } + }, [repository]) + + useEffect(() => { + void fetchTokenInfo() + }, [fetchTokenInfo]) + return { isLoading, error, apiTokenInfo } +} diff --git a/src/sections/account/api-token-section/useRecreateApiToken.tsx b/src/sections/account/api-token-section/useRecreateApiToken.tsx new file mode 100644 index 000000000..d957554a2 --- /dev/null +++ b/src/sections/account/api-token-section/useRecreateApiToken.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react' +import { recreateApiToken } from '@/users/domain/useCases/recreateApiToken' +import { TokenInfo } from '@/users/domain/models/TokenInfo' +import { UserRepository } from '@/users/domain/repositories/UserRepository' + +interface UseRecreateApiTokenResult { + recreateToken: () => Promise + isRecreating: boolean + error: string | null + apiTokenInfo: TokenInfo | null +} + +export const useRecreateApiToken = (repository: UserRepository): UseRecreateApiTokenResult => { + const [isRecreating, setIsRecreating] = useState(false) + const [error, setError] = useState(null) + const [apiTokenInfo, setApiTokenInfo] = useState(null) + + const recreateToken = async () => { + setIsRecreating(true) + setError(null) + try { + const newTokenInfo = await recreateApiToken(repository) + setApiTokenInfo(newTokenInfo) + } catch (err) { + const errorMessage = + err instanceof Error && err.message + ? err.message + : 'Something went wrong creating the api token. Try again later.' + setError(errorMessage) + } finally { + setIsRecreating(false) + } + } + + return { recreateToken, isRecreating, error, apiTokenInfo } +} diff --git a/src/sections/account/api-token-section/useRevokeApiToken.tsx b/src/sections/account/api-token-section/useRevokeApiToken.tsx new file mode 100644 index 000000000..4bbb6ec85 --- /dev/null +++ b/src/sections/account/api-token-section/useRevokeApiToken.tsx @@ -0,0 +1,32 @@ +import { useState } from 'react' +import { revokeApiToken } from '@/users/domain/useCases/revokeApiToken' +import { UserRepository } from '@/users/domain/repositories/UserRepository' + +interface UseRevokeApiTokenResult { + revokeToken: () => Promise + isRevoking: boolean + error: string | null +} + +export const useRevokeApiToken = (repository: UserRepository): UseRevokeApiTokenResult => { + const [isRevoking, setIsRevoking] = useState(false) + const [error, setError] = useState(null) + + const revokeToken = async () => { + setIsRevoking(true) + setError(null) + try { + await revokeApiToken(repository) + } catch (err) { + const errorMessage = + err instanceof Error && err.message + ? err.message + : 'Something went wrong revoking the api token. Try again later.' + setError(errorMessage) + } finally { + setIsRevoking(false) + } + } + + return { revokeToken, isRevoking, error } +} diff --git a/src/stories/account/Account.stories.tsx b/src/stories/account/Account.stories.tsx index b19475721..3d0817452 100644 --- a/src/stories/account/Account.stories.tsx +++ b/src/stories/account/Account.stories.tsx @@ -4,6 +4,9 @@ import { WithI18next } from '../WithI18next' import { WithLayout } from '../WithLayout' import { WithLoggedInUser } from '../WithLoggedInUser' import { AccountHelper } from '../../sections/account/AccountHelper' +import { AccountPageMockUserRepository } from './AccountPageMockUserRepository' +import { AccountPageMockLoadingUserRepository } from './AccountPageMockLoadingUserRepository' +import { AccountPageMockErrorUserRepository } from './AccountPageMockErrorUserRepository' const meta: Meta = { title: 'Pages/Account', @@ -14,9 +17,56 @@ const meta: Meta = { chromatic: { delay: 15000, pauseAnimationAtEnd: true } } } + export default meta type Story = StoryObj -export const APITokenTab: Story = { - render: () => +export const APITokenTabDefault: Story = { + render: () => ( + + ) +} + +export const APITokenTabLoading: Story = { + render: () => ( + + ) +} + +export const APITokenTabError: Story = { + render: () => ( + + ) +} + +export const APITokenTabNoToken: Story = { + render: () => { + const noTokenRepository = new AccountPageMockUserRepository() + noTokenRepository.getCurrentApiToken = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + apiToken: '', + expirationDate: new Date() + }) + }, 1_000) + }) + } + + return ( + + ) + } } diff --git a/src/stories/account/AccountPageMockErrorUserRepository.ts b/src/stories/account/AccountPageMockErrorUserRepository.ts new file mode 100644 index 000000000..7662eb4ed --- /dev/null +++ b/src/stories/account/AccountPageMockErrorUserRepository.ts @@ -0,0 +1,46 @@ +import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/UserJSDataverseRepository' +import { TokenInfo } from '@/users/domain/models/TokenInfo' +import { User } from '@/users/domain/models/User' +import { FakerHelper } from '@tests/component/shared/FakerHelper' + +export class AccountPageMockErrorUserRepository extends UserJSDataverseRepository { + getAuthenticated(): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Something went wrong getting authentication. Try again later.') + }, FakerHelper.loadingTimout()) + }) + } + + removeAuthenticated(): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Something went wrong removing authentication. Try again later.') + }, FakerHelper.loadingTimout()) + }) + } + + getCurrentApiToken(): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Something went wrong getting the current api token. Try again later.') + }, FakerHelper.loadingTimout()) + }) + } + + recreateApiToken(): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Something went wrong creating the api token. Try again later.') + }, FakerHelper.loadingTimout()) + }) + } + + deleteApiToken(): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Something went wrong revoking the api token. Try again later.') + }, FakerHelper.loadingTimout()) + }) + } +} diff --git a/src/stories/account/AccountPageMockLoadingUserRepository.ts b/src/stories/account/AccountPageMockLoadingUserRepository.ts new file mode 100644 index 000000000..57668a59f --- /dev/null +++ b/src/stories/account/AccountPageMockLoadingUserRepository.ts @@ -0,0 +1,25 @@ +import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/UserJSDataverseRepository' +import { TokenInfo } from '@/users/domain/models/TokenInfo' +import { User } from '@/users/domain/models/User' + +export class AccountPageMockLoadingUserRepository extends UserJSDataverseRepository { + getAuthenticated(): Promise { + return new Promise(() => {}) + } + + removeAuthenticated(): Promise { + return new Promise(() => {}) + } + + getCurrentApiToken(): Promise { + return new Promise(() => {}) + } + + recreateApiToken(): Promise { + return new Promise(() => {}) + } + + deleteApiToken(): Promise { + return new Promise(() => {}) + } +} diff --git a/src/stories/account/AccountPageMockUserRepository.ts b/src/stories/account/AccountPageMockUserRepository.ts new file mode 100644 index 000000000..fdbf578b1 --- /dev/null +++ b/src/stories/account/AccountPageMockUserRepository.ts @@ -0,0 +1,39 @@ +import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/UserJSDataverseRepository' +import { TokenInfo } from '@/users/domain/models/TokenInfo' +import { User } from '@/users/domain/models/User' + +export class AccountPageMockUserRepository extends UserJSDataverseRepository { + getAuthenticated(): Promise { + return Promise.resolve({ + displayName: 'mockDisplayName', + persistentId: 'mockPersistentId', + firstName: 'mockFirstName', + lastName: 'mockLastName', + email: 'mockEmail', + affiliation: 'mockAffiliation', + superuser: true + }) + } + + removeAuthenticated(): Promise { + return Promise.resolve() + } + + getCurrentApiToken(): Promise { + return Promise.resolve({ + apiToken: 'mock api token', + expirationDate: new Date() + }) + } + + recreateApiToken(): Promise { + return Promise.resolve({ + apiToken: 'updated mock api token', + expirationDate: new Date() + }) + } + + deleteApiToken(): Promise { + return Promise.resolve() + } +} diff --git a/src/users/domain/models/TokenInfo.ts b/src/users/domain/models/TokenInfo.ts new file mode 100644 index 000000000..1916f5e19 --- /dev/null +++ b/src/users/domain/models/TokenInfo.ts @@ -0,0 +1,4 @@ +export interface TokenInfo { + apiToken: string + expirationDate: Date +} diff --git a/src/users/domain/repositories/UserRepository.tsx b/src/users/domain/repositories/UserRepository.tsx index 796037f72..77295dedc 100644 --- a/src/users/domain/repositories/UserRepository.tsx +++ b/src/users/domain/repositories/UserRepository.tsx @@ -1,6 +1,10 @@ import { User } from '../models/User' +import { TokenInfo } from '../.././domain/models/TokenInfo' export interface UserRepository { getAuthenticated: () => Promise removeAuthenticated: () => Promise + getCurrentApiToken: () => Promise + recreateApiToken: () => Promise + deleteApiToken: () => Promise } diff --git a/src/users/domain/useCases/getCurrentApiToken.ts b/src/users/domain/useCases/getCurrentApiToken.ts new file mode 100644 index 000000000..d3b8dfb08 --- /dev/null +++ b/src/users/domain/useCases/getCurrentApiToken.ts @@ -0,0 +1,6 @@ +import { TokenInfo } from '../models/TokenInfo' +import { UserRepository } from '../repositories/UserRepository' + +export function getCurrentApiToken(userRepository: UserRepository): Promise { + return userRepository.getCurrentApiToken() +} diff --git a/src/users/domain/useCases/recreateApiToken.ts b/src/users/domain/useCases/recreateApiToken.ts new file mode 100644 index 000000000..9c1029e62 --- /dev/null +++ b/src/users/domain/useCases/recreateApiToken.ts @@ -0,0 +1,6 @@ +import { TokenInfo } from '../models/TokenInfo' +import { UserRepository } from '../repositories/UserRepository' + +export function recreateApiToken(userRepository: UserRepository): Promise { + return userRepository.recreateApiToken() +} diff --git a/src/users/domain/useCases/revokeApiToken.ts b/src/users/domain/useCases/revokeApiToken.ts new file mode 100644 index 000000000..47a895045 --- /dev/null +++ b/src/users/domain/useCases/revokeApiToken.ts @@ -0,0 +1,5 @@ +import { UserRepository } from '../repositories/UserRepository' + +export function revokeApiToken(userRepository: UserRepository): Promise { + return userRepository.deleteApiToken() +} diff --git a/src/users/infrastructure/repositories/UserJSDataverseRepository.ts b/src/users/infrastructure/repositories/UserJSDataverseRepository.ts index 4307885e3..a4c3639fe 100644 --- a/src/users/infrastructure/repositories/UserJSDataverseRepository.ts +++ b/src/users/infrastructure/repositories/UserJSDataverseRepository.ts @@ -1,10 +1,19 @@ import { User } from '../../domain/models/User' +import { TokenInfo } from '../../domain/models/TokenInfo' +import { UserRepository } from '../../domain/repositories/UserRepository' import { AuthenticatedUser, - getCurrentAuthenticatedUser + getCurrentAuthenticatedUser, + getCurrentApiToken, + recreateCurrentApiToken, + deleteCurrentApiToken } from '@iqss/dataverse-client-javascript/dist/users' import { logout, ReadError, WriteError } from '@iqss/dataverse-client-javascript' -import { UserRepository } from '../../domain/repositories/UserRepository' + +interface ApiTokenInfoPayload { + apiToken: string + expirationDate: Date +} export class UserJSDataverseRepository implements UserRepository { getAuthenticated(): Promise { @@ -31,4 +40,26 @@ export class UserJSDataverseRepository implements UserRepository { throw new Error(error.message) }) } + + getCurrentApiToken(): Promise { + return getCurrentApiToken.execute().then((apiTokenInfo: ApiTokenInfoPayload) => { + return { + apiToken: apiTokenInfo.apiToken, + expirationDate: apiTokenInfo.expirationDate + } + }) + } + + recreateApiToken(): Promise { + return recreateCurrentApiToken.execute().then((apiTokenInfo: ApiTokenInfoPayload) => { + return { + apiToken: apiTokenInfo.apiToken, + expirationDate: apiTokenInfo.expirationDate + } + }) + } + + deleteApiToken(): Promise { + return deleteCurrentApiToken.execute() + } } diff --git a/tests/component/sections/account/Account.spec.tsx b/tests/component/sections/account/Account.spec.tsx index 3a3176ba4..bd32411fd 100644 --- a/tests/component/sections/account/Account.spec.tsx +++ b/tests/component/sections/account/Account.spec.tsx @@ -1,10 +1,14 @@ import { Account } from '../../../../src/sections/account/Account' import { AccountHelper } from '../../../../src/sections/account/AccountHelper' +import { UserJSDataverseRepository } from '../../../../src/users/infrastructure/repositories/UserJSDataverseRepository' describe('Account', () => { it('should render the component', () => { cy.mountAuthenticated( - + ) cy.get('h1').should('contain.text', 'Account') diff --git a/tests/component/sections/account/ApiTokenSection.spec.tsx b/tests/component/sections/account/ApiTokenSection.spec.tsx index 26ff3220b..83547deee 100644 --- a/tests/component/sections/account/ApiTokenSection.spec.tsx +++ b/tests/component/sections/account/ApiTokenSection.spec.tsx @@ -1,8 +1,41 @@ import { ApiTokenSection } from '../../../../src/sections/account/api-token-section/ApiTokenSection' +import { DateHelper } from '@/shared/helpers/DateHelper' +import { UserRepository } from '@/users/domain/repositories/UserRepository' describe('ApiTokenSection', () => { + const mockApiTokenInfo = { + apiToken: 'mocked-api', + expirationDate: new Date('2024-12-31') + } + const newMockApiTokenInfo = { + apiToken: 'new-mocked-api', + expirationDate: new Date('2025-12-31') + } + + let apiTokenRepository: UserRepository + beforeEach(() => { - cy.mountAuthenticated() + apiTokenRepository = { + getCurrentApiToken: cy.stub().resolves(mockApiTokenInfo), + recreateApiToken: cy.stub().resolves(mockApiTokenInfo), + deleteApiToken: cy.stub().resolves(), + getAuthenticated: cy.stub().resolves(), + removeAuthenticated: cy.stub().resolves() + } + + cy.mountAuthenticated() + }) + + it('should show the loading skeleton while fetching the token', () => { + apiTokenRepository.getCurrentApiToken = cy.stub().callsFake(() => { + return Cypress.Promise.delay(500).then(() => mockApiTokenInfo) + }) + + cy.mount() + cy.get('[data-testid="loadingSkeleton"]').should('exist') + + cy.wait(500) + cy.get('[data-testid="loadingSkeleton"]').should('not.exist') }) it('should copy the api token to the clipboard', () => { @@ -20,5 +53,27 @@ describe('ApiTokenSection', () => { }) }) - // TODO: When we get the api token from the use case, we could mock the response and test more things. + it('should fetch and display the current API token', () => { + cy.get('[data-testid="api-token"]').should('contain.text', mockApiTokenInfo.apiToken) + cy.get('[data-testid="expiration-date"]').should( + 'contain.text', + DateHelper.toISO8601Format(mockApiTokenInfo.expirationDate) + ) + }) + + it('should recreate and display a new API token', () => { + apiTokenRepository.recreateApiToken = cy.stub().resolves(newMockApiTokenInfo) + cy.get('button').contains('Recreate Token').click() + cy.get('[data-testid="api-token"]').should('contain.text', newMockApiTokenInfo.apiToken) + cy.get('[data-testid="expiration-date"]').should( + 'contain.text', + DateHelper.toISO8601Format(newMockApiTokenInfo.expirationDate) + ) + }) + + it('should revoke the API token and show the create token state when there is no api token', () => { + cy.get('button').contains('Revoke Token').click() + cy.get('[data-testid="noApiToken"]').should('exist') + cy.get('button').contains('Create Token').should('exist') + }) }) diff --git a/tests/component/sections/account/useGetApiTokenInfo.spec.tsx b/tests/component/sections/account/useGetApiTokenInfo.spec.tsx new file mode 100644 index 000000000..53768c586 --- /dev/null +++ b/tests/component/sections/account/useGetApiTokenInfo.spec.tsx @@ -0,0 +1,85 @@ +import { act, renderHook } from '@testing-library/react' +import { useGetApiToken } from '@/sections/account/api-token-section/useGetCurrentApiToken' +import { UserRepository } from '@/users/domain/repositories/UserRepository' +import { TokenInfo } from '@/users/domain/models/TokenInfo' +import { DateHelper } from '@/shared/helpers/DateHelper' + +describe('useGetApiToken', () => { + let UserRepository: UserRepository + + const mockTokenInfo: TokenInfo = { + apiToken: 'mocked-api-token', + expirationDate: new Date('2024-11-05') + } + + beforeEach(() => { + UserRepository = {} as UserRepository + }) + + it('should return the API token correctly', async () => { + UserRepository.getCurrentApiToken = cy.stub().resolves(mockTokenInfo) + + const { result } = renderHook(() => useGetApiToken(UserRepository)) + await act(() => { + expect(result.current.isLoading).to.equal(true) + expect(result.current.error).to.equal(null) + + return expect(result.current.apiTokenInfo).to.deep.equal({ + apiToken: '', + expirationDate: new Date(0) + }) + }) + + await act(() => { + expect(result.current.isLoading).to.equal(false) + expect(result.current.error).to.equal(null) + const apiTokenInfo = { + ...result.current.apiTokenInfo, + expirationDate: DateHelper.toISO8601Format(result.current.apiTokenInfo.expirationDate) + } + console.log( + 'test', + DateHelper.toISO8601Format(result.current.apiTokenInfo.expirationDate), + DateHelper.toISO8601Format(mockTokenInfo.expirationDate) + ) + return expect(apiTokenInfo).to.deep.equal({ + apiToken: mockTokenInfo.apiToken, + expirationDate: DateHelper.toISO8601Format(mockTokenInfo.expirationDate) + }) + }) + }) + + describe('Error Handling', () => { + it('should handle error correctly when an error is thrown', async () => { + UserRepository.getCurrentApiToken = cy.stub().rejects(new Error('API Error')) + + const { result } = renderHook(() => useGetApiToken(UserRepository)) + + await act(() => { + expect(result.current.isLoading).to.deep.equal(true) + return expect(result.current.error).to.equal(null) + }) + await act(() => { + expect(result.current.isLoading).to.deep.equal(false) + return expect(result.current.error).to.equal('API Error') + }) + }) + + it('should return correct error message when there is not an error type catched', async () => { + UserRepository.getCurrentApiToken = cy.stub().rejects('Error message') + + const { result } = renderHook(() => useGetApiToken(UserRepository)) + + await act(() => { + expect(result.current.isLoading).to.deep.equal(true) + return expect(result.current.error).to.equal(null) + }) + await act(() => { + expect(result.current.isLoading).to.deep.equal(false) + return expect(result.current.error).to.deep.equal( + 'Something went wrong getting the current api token. Try again later.' + ) + }) + }) + }) +}) diff --git a/tests/component/sections/account/useRecreateApiTokenInfo.spec.tsx b/tests/component/sections/account/useRecreateApiTokenInfo.spec.tsx new file mode 100644 index 000000000..c3b354e76 --- /dev/null +++ b/tests/component/sections/account/useRecreateApiTokenInfo.spec.tsx @@ -0,0 +1,98 @@ +import { act, renderHook } from '@testing-library/react' +import { UserRepository } from '@/users/domain/repositories/UserRepository' +import { useRecreateApiToken } from '@/sections/account/api-token-section/useRecreateApiToken' +import { TokenInfo } from '@/users/domain/models/TokenInfo' + +describe('useRecreateApiToken', () => { + let UserRepository: UserRepository + + const mockTokenInfo: TokenInfo = { + apiToken: 'new-mocked-api-token', + expirationDate: new Date('2024-12-31') + } + + beforeEach(() => { + UserRepository = {} as UserRepository + }) + + it('should return the API token correctly', async () => { + UserRepository.recreateApiToken = cy.stub().resolves(mockTokenInfo) + + const { result } = renderHook(() => useRecreateApiToken(UserRepository)) + + expect(result.current.isRecreating).to.equal(false) + expect(result.current.error).to.equal(null) + expect(result.current.apiTokenInfo).to.deep.equal(null) + + act(() => { + void result.current.recreateToken() + }) + + await act(() => { + expect(result.current.isRecreating).to.equal(true) + expect(result.current.error).to.equal(null) + return expect(result.current.apiTokenInfo).to.equal(null) + }) + + await act(() => { + expect(result.current.isRecreating).to.equal(false) + expect(result.current.error).to.equal(null) + return expect(result.current.apiTokenInfo).to.deep.equal(mockTokenInfo) + }) + }) + + describe('Error Handling', () => { + it('should handle error correctly when an error is thrown', async () => { + const errorMessage = 'Failed to recreate API token.' + UserRepository.recreateApiToken = cy.stub().rejects(new Error(errorMessage)) + + const { result } = renderHook(() => useRecreateApiToken(UserRepository)) + + expect(result.current.isRecreating).to.equal(false) + expect(result.current.error).to.equal(null) + expect(result.current.apiTokenInfo).to.equal(null) + + act(() => { + void result.current.recreateToken() + }) + + await act(() => { + expect(result.current.isRecreating).to.equal(true) + return expect(result.current.error).to.equal(null) + }) + + await act(() => { + expect(result.current.isRecreating).to.equal(false) + expect(result.current.error).to.equal('Failed to recreate API token.') + return expect(result.current.apiTokenInfo).to.equal(null) + }) + }) + + it('should return correct error message when there is not an error type catched', async () => { + UserRepository.recreateApiToken = cy.stub().rejects('Unexpected error message') + + const { result } = renderHook(() => useRecreateApiToken(UserRepository)) + + expect(result.current.isRecreating).to.equal(false) + expect(result.current.error).to.equal(null) + expect(result.current.apiTokenInfo).to.equal(null) + + act(() => { + void result.current.recreateToken() + }) + + await act(() => { + expect(result.current.isRecreating).to.equal(true) + return expect(result.current.error).to.equal(null) + }) + + await act(() => { + expect(result.current.isRecreating).to.equal(false) + expect(result.current.error).to.equal( + 'Something went wrong creating the api token. Try again later.' + ) + return expect(result.current.apiTokenInfo).to.equal(null) + }) + }) + }) +}) diff --git a/tests/component/sections/account/useRevokeApiTokenInfo.spec.tsx b/tests/component/sections/account/useRevokeApiTokenInfo.spec.tsx new file mode 100644 index 000000000..c412b685e --- /dev/null +++ b/tests/component/sections/account/useRevokeApiTokenInfo.spec.tsx @@ -0,0 +1,58 @@ +import { act, renderHook } from '@testing-library/react' +import { UserRepository } from '@/users/domain/repositories/UserRepository' +import { useRevokeApiToken } from '@/sections/account/api-token-section/useRevokeApiToken' + +describe('useRevokeApiToken', () => { + let UserRepository: UserRepository + + beforeEach(() => { + UserRepository = {} as UserRepository + }) + + it('should revoke the API token successfully', async () => { + UserRepository.deleteApiToken = cy.stub().resolves() + + const { result } = renderHook(() => useRevokeApiToken(UserRepository)) + + expect(result.current.isRevoking).to.equal(false) + expect(result.current.error).to.equal(null) + + await act(async () => { + await result.current.revokeToken() + }) + + expect(result.current.isRevoking).to.equal(false) + expect(result.current.error).to.equal(null) + }) + + describe('Error Handling', () => { + it('should handle error correctly when an error is thrown', async () => { + const errorMessage = 'API token revocation failed.' + UserRepository.deleteApiToken = cy.stub().rejects(new Error(errorMessage)) + + const { result } = renderHook(() => useRevokeApiToken(UserRepository)) + + await act(async () => { + await result.current.revokeToken() + }) + + expect(result.current.isRevoking).to.equal(false) + expect(result.current.error).to.equal(errorMessage) + }) + + it('should handle non-error rejection gracefully', async () => { + UserRepository.deleteApiToken = cy.stub().rejects('Unexpected error') + + const { result } = renderHook(() => useRevokeApiToken(UserRepository)) + + await act(async () => { + await result.current.revokeToken() + }) + + expect(result.current.isRevoking).to.equal(false) + expect(result.current.error).to.equal( + 'Something went wrong revoking the api token. Try again later.' + ) + }) + }) +}) diff --git a/tests/e2e-integration/integration/account/ApiTokenInfoJSDataverseRepository.spec.ts b/tests/e2e-integration/integration/account/ApiTokenInfoJSDataverseRepository.spec.ts new file mode 100644 index 000000000..162b2a329 --- /dev/null +++ b/tests/e2e-integration/integration/account/ApiTokenInfoJSDataverseRepository.spec.ts @@ -0,0 +1,36 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { UserJSDataverseRepository } from '../../../../src/users/infrastructure/repositories/UserJSDataverseRepository' + +import { TestsUtils } from '../../shared/TestsUtils' + +chai.use(chaiAsPromised) +const expect = chai.expect + +const userRepository = new UserJSDataverseRepository() + +describe('API Token Info JSDataverse Repository', () => { + before(() => TestsUtils.setup()) + beforeEach(() => TestsUtils.login()) + it('revoke the API token', async () => { + await expect(userRepository.deleteApiToken()).to.be.fulfilled + }) + + it('create or recreate the API token and return the new token info', async () => { + const recreatedTokenInfo = await userRepository.recreateApiToken() + if (!recreatedTokenInfo) { + throw new Error('Failed to recreate API token') + } + expect(recreatedTokenInfo).to.have.property('apiToken').that.is.a('string') + expect(recreatedTokenInfo).to.have.property('expirationDate').that.is.a('Date') + }) + + it('fetch the current API token', async () => { + const tokenInfo = await userRepository.getCurrentApiToken() + if (!tokenInfo) { + throw new Error('API Token not found') + } + expect(tokenInfo).to.have.property('apiToken').that.is.a('string') + expect(tokenInfo).to.have.property('expirationDate').that.is.a('Date') + }) +})