From 5dcc4a08b5235665a77cd41680728867eae68bab Mon Sep 17 00:00:00 2001 From: tlangs Date: Wed, 14 Aug 2024 14:08:52 -0400 Subject: [PATCH 1/2] ID-1357 Use Sam for Starred Workspaces --- src/auth/login.test.ts | 2 +- src/auth/user-profile/user.ts | 3 +- src/libs/ajax/User.test.ts | 1 - src/libs/ajax/User.ts | 36 ++++++- src/libs/state.ts | 7 +- src/profile/Profile.test.ts | 2 - .../personal-info/PersonalInfo.test.ts | 2 - src/profile/useUserProfile.test.ts | 6 +- .../TermsOfServicePage.test.ts | 1 + src/workspaces/list/RenderedWorkspaces.tsx | 7 +- src/workspaces/list/WorkspaceStarControl.ts | 100 ------------------ src/workspaces/list/WorkspaceStarControl.tsx | 79 ++++++++++++++ src/workspaces/utils.ts | 9 ++ 13 files changed, 135 insertions(+), 120 deletions(-) delete mode 100644 src/workspaces/list/WorkspaceStarControl.ts create mode 100644 src/workspaces/list/WorkspaceStarControl.tsx diff --git a/src/auth/login.test.ts b/src/auth/login.test.ts index e52f9bdbfd..66effb33ce 100644 --- a/src/auth/login.test.ts +++ b/src/auth/login.test.ts @@ -60,7 +60,6 @@ const mockTerraUserProfile = { programLocationState: 'testProgramLocationState', programLocationCountry: 'testProgramLocationCountry', researchArea: 'testResearchArea', - starredWorkspaces: 'testStarredWorkspaces', }; const testSamUserAllowancesDetails = { @@ -79,6 +78,7 @@ const mockSamUserCombinedState: SamUserCombinedStateResponse = { terraUserAttributes: { marketingConsent: false }, termsOfService: mockSamUserTermsOfServiceDetails, enterpriseFeatures: [], + favoriteResources: [], }; const mockNihDatasetPermission = { diff --git a/src/auth/user-profile/user.ts b/src/auth/user-profile/user.ts index c6e375ae44..4b1576e160 100644 --- a/src/auth/user-profile/user.ts +++ b/src/auth/user-profile/user.ts @@ -18,7 +18,7 @@ export const loadTerraUser = async (): Promise => { const getProfile = User().profile.get(); const getCombinedState = User().getSamUserCombinedState(); const [profile, terraUserCombinedState] = await Promise.all([getProfile, getCombinedState]); - const { terraUserAttributes, enterpriseFeatures, samUser, terraUserAllowances, termsOfService } = + const { terraUserAttributes, enterpriseFeatures, samUser, terraUserAllowances, termsOfService, favoriteResources } = terraUserCombinedState; clearNotification(sessionExpirationProps.id); userStore.update((state: TerraUserState) => ({ @@ -27,6 +27,7 @@ export const loadTerraUser = async (): Promise => { terraUserAttributes, enterpriseFeatures, samUser, + favoriteResources, })); authStore.update((state: AuthState) => ({ ...state, diff --git a/src/libs/ajax/User.test.ts b/src/libs/ajax/User.test.ts index 64fc0d4d64..6ea9ffaf66 100644 --- a/src/libs/ajax/User.test.ts +++ b/src/libs/ajax/User.test.ts @@ -35,7 +35,6 @@ const completeUserProfile: TerraUserProfile = { programLocationCity: 'testCity', programLocationCountry: 'testCountry', programLocationState: 'testState', - starredWorkspaces: 'testStarredWorkspaces', title: 'testTitle', researchArea: 'testResearchArea', } as const satisfies TerraUserProfile; diff --git a/src/libs/ajax/User.ts b/src/libs/ajax/User.ts index 4ff7182214..7c8b9d9662 100644 --- a/src/libs/ajax/User.ts +++ b/src/libs/ajax/User.ts @@ -2,6 +2,7 @@ import { jsonBody } from '@terra-ui-packages/data-client-core'; import _ from 'lodash/fp'; import { authOpts } from 'src/auth/auth-fetch'; import { fetchOrchestration, fetchSam } from 'src/libs/ajax/ajax-common'; +import { FullyQualifiedResourceId } from 'src/libs/ajax/SamResources'; import { SamUserTermsOfServiceDetails } from 'src/libs/ajax/TermsOfService'; import { TerraUserProfile } from 'src/libs/state'; @@ -22,8 +23,6 @@ export interface SamUserAllowances { } export type TerraUserPreferences = { - starredWorkspaces?: string; -} & { // These are the key value pairs from the workspace notification settings in the form of: // 'notifications/SuccessfulSubmissionNotification/${workspace.workspace.namespace}/${workspace.workspace.name}' : true // TODO for a follow-up ticket: @@ -171,6 +170,7 @@ export type SamUserCombinedStateResponse = { terraUserAttributes: SamUserAttributes; termsOfService: SamUserTermsOfServiceDetails; enterpriseFeatures: string[]; + favoriteResources: FullyQualifiedResourceId[]; }; export type OrchestrationUserRegistrationRequest = object; @@ -229,12 +229,15 @@ export const User = (signal?: AbortSignal) => { ? responseJson.additionalDetails.enterpriseFeatures.resources.map((resource) => resource.resourceId) : []; + const favoriteResources = responseJson.favoriteResources; + return { samUser, terraUserAllowances, terraUserAttributes, termsOfService, enterpriseFeatures, + favoriteResources, }; }, @@ -291,6 +294,35 @@ export const User = (signal?: AbortSignal) => { }, }, + favorites: { + get: async (): Promise => { + const res = await fetchSam('api/users/v2/self/favoriteResources', _.merge(authOpts(), { signal })); + return res.json(); + }, + + getResourceType: async (resourceTypeName: string): Promise => { + const res = await fetchSam( + `api/users/v2/self/favoriteResources/${resourceTypeName}`, + _.merge(authOpts(), { signal }) + ); + return res.json(); + }, + + put: async (resource: FullyQualifiedResourceId): Promise => { + await fetchSam( + `api/users/v2/self/favoriteResources/${resource.resourceTypeName}/${resource.resourceId}`, + _.merge(authOpts(), { signal, method: 'PUT' }) + ); + }, + + delete: async (resource: FullyQualifiedResourceId): Promise => { + await fetchSam( + `api/users/v2/self/favoriteResources/${resource.resourceTypeName}/${resource.resourceId}`, + _.merge(authOpts(), { signal, method: 'DELETE' }) + ); + }, + }, + // Returns the proxy group email of the user with the given email getProxyGroup: async (email: string): Promise => { const res = await fetchOrchestration( diff --git a/src/libs/state.ts b/src/libs/state.ts index 1908d073ad..01d793c3d6 100644 --- a/src/libs/state.ts +++ b/src/libs/state.ts @@ -6,6 +6,7 @@ import { OidcUser } from 'src/auth/oidc-broker'; import { Dataset } from 'src/libs/ajax/Catalog'; import { EcmLinkAccountResponse } from 'src/libs/ajax/ExternalCredentials'; import { OidcConfig } from 'src/libs/ajax/OAuth2'; +import { FullyQualifiedResourceId } from 'src/libs/ajax/SamResources'; import { SamTermsOfServiceConfig } from 'src/libs/ajax/TermsOfService'; import { NihDatasetPermission, SamUserAllowances, SamUserAttributes, SamUserResponse } from 'src/libs/ajax/User'; import { getLocalStorage, getSessionStorage, staticStorageSlot } from 'src/libs/browser-storage'; @@ -109,7 +110,7 @@ export interface TerraUser { export interface TerraUserProfile { // TODO: anonymousGroup is here from getProfile from orch // TODO: for future ticket, separate items updated via register/profile (personal info) - // from things that are updated via api/profile/preferences (starred workspaces, notification settings) + // from things that are updated via api/profile/preferences (notification settings) firstName: string | undefined; lastName: string | undefined; institute: string | undefined; @@ -121,7 +122,6 @@ export interface TerraUserProfile { programLocationState?: string; programLocationCountry?: string; researchArea?: string; - starredWorkspaces?: string; } export interface TerraUserState { @@ -130,6 +130,7 @@ export interface TerraUserState { terraUserAttributes: SamUserAttributes; enterpriseFeatures: string[]; samUser: SamUserResponse; + favoriteResources: FullyQualifiedResourceId[]; } /** @@ -147,7 +148,6 @@ export const userStore: Atom = atom({ programLocationState: undefined, programLocationCountry: undefined, interestInTerra: undefined, - starredWorkspaces: undefined, }, terraUser: { token: undefined, @@ -174,6 +174,7 @@ export const userStore: Atom = atom({ registeredAt: undefined, updatedAt: undefined, }, + favoriteResources: [], }); export const getTerraUser = (): TerraUser => userStore.get().terraUser; diff --git a/src/profile/Profile.test.ts b/src/profile/Profile.test.ts index 29098661cf..08b7ecfbda 100644 --- a/src/profile/Profile.test.ts +++ b/src/profile/Profile.test.ts @@ -63,8 +63,6 @@ describe('Profile', () => { researchArea: '', interestInTerra: '', - - starredWorkspaces: undefined, }; it('loads the user profile', () => { diff --git a/src/profile/personal-info/PersonalInfo.test.ts b/src/profile/personal-info/PersonalInfo.test.ts index 7c697b3d6c..e767aeecbf 100644 --- a/src/profile/personal-info/PersonalInfo.test.ts +++ b/src/profile/personal-info/PersonalInfo.test.ts @@ -44,8 +44,6 @@ describe('PersonalInfo', () => { researchArea: '', interestInTerra: '', - - starredWorkspaces: undefined, }; it('renders the initial profile', () => { diff --git a/src/profile/useUserProfile.test.ts b/src/profile/useUserProfile.test.ts index 8207259e50..47adb4b24a 100644 --- a/src/profile/useUserProfile.test.ts +++ b/src/profile/useUserProfile.test.ts @@ -54,9 +54,7 @@ const mockProfile: TerraUserProfile = { programLocationCountry: '', researchArea: '', - interestInTerra: '', - - starredWorkspaces: undefined, + interestInTerra: undefined, }; describe('useUserProfile', () => { @@ -192,7 +190,7 @@ describe('useUserProfile', () => { // Assert // Not all profile fields are updated via this request. - expect(updateProfile).toHaveBeenCalledWith(_.omit(['email', 'starredWorkspaces'], updatedProfile)); + expect(updateProfile).toHaveBeenCalledWith(_.omit(['email', 'interestInTerra'], updatedProfile)); }); it('returns loading status while profile is updating', async () => { diff --git a/src/registration/terms-of-service/TermsOfServicePage.test.ts b/src/registration/terms-of-service/TermsOfServicePage.test.ts index f792ceed1c..581ad305a5 100644 --- a/src/registration/terms-of-service/TermsOfServicePage.test.ts +++ b/src/registration/terms-of-service/TermsOfServicePage.test.ts @@ -80,6 +80,7 @@ const setupMockAjax = async ( terraUserAttributes: { marketingConsent: false }, termsOfService, enterpriseFeatures: [], + favoriteResources: [], }; const getTermsOfServiceText = jest.fn().mockResolvedValue('some text'); const getUserTermsOfServiceDetails = jest diff --git a/src/workspaces/list/RenderedWorkspaces.tsx b/src/workspaces/list/RenderedWorkspaces.tsx index 481d499319..d06155e012 100644 --- a/src/workspaces/list/RenderedWorkspaces.tsx +++ b/src/workspaces/list/RenderedWorkspaces.tsx @@ -21,6 +21,7 @@ import { canRead, getCloudProviderFromWorkspace, isGoogleWorkspace, + starredWorkspacesFromFavoriteResources, workspaceAccessLevels, WorkspaceInfo, WorkspacePolicy, @@ -105,10 +106,8 @@ const getColumns = ( export const RenderedWorkspaces = (props: RenderedWorkspacesProps): ReactNode => { const { workspaces } = props; - const { - profile: { starredWorkspaces }, - } = useStore(userStore); - const starredWorkspaceIds = _.isEmpty(starredWorkspaces) ? [] : _.split(',', starredWorkspaces); + const { favoriteResources } = useStore(userStore); + const starredWorkspaceIds = starredWorkspacesFromFavoriteResources(favoriteResources); const [sort, setSort] = useState({ field: 'lastModified', direction: 'desc' }); diff --git a/src/workspaces/list/WorkspaceStarControl.ts b/src/workspaces/list/WorkspaceStarControl.ts deleted file mode 100644 index 2ef7d7ad62..0000000000 --- a/src/workspaces/list/WorkspaceStarControl.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Spinner } from '@terra-ui-packages/components'; -import _ from 'lodash/fp'; -import { ReactNode, useState } from 'react'; -import { h } from 'react-hyperscript-helpers'; -import { Clickable } from 'src/components/common'; -import { icon } from 'src/components/icons'; -import { Ajax } from 'src/libs/ajax'; -import colors from 'src/libs/colors'; -import { withErrorReporting } from 'src/libs/error'; -import Events, { extractWorkspaceDetails } from 'src/libs/events'; -import { useStore } from 'src/libs/react-utils'; -import { TerraUserState, userStore } from 'src/libs/state'; -import * as Utils from 'src/libs/utils'; -import { WorkspaceWrapper } from 'src/workspaces/utils'; - -interface WorkspaceStarControlProps { - workspace: WorkspaceWrapper; -} - -export const WorkspaceStarControl = (props: WorkspaceStarControlProps): ReactNode => { - const { - workspace: { workspaceId }, - } = props.workspace; - const { - profile: { starredWorkspaces }, - } = useStore(userStore); - const stars = _.isEmpty(starredWorkspaces) ? [] : _.split(',', starredWorkspaces); - - const [updatingStars, setUpdatingStars] = useState(false); - const isStarred = _.includes(workspaceId, stars); - - // Thurloe has a limit of 2048 bytes for its VALUE column. That means we can store a max of 55 - // workspaceIds in list format. We'll use 50 because it's a nice round number and should be plenty - // for the intended use case. If we find that 50 is not enough, consider introducing more powerful - // workspace organization functionality like folders - const MAX_STARRED_WORKSPACES = 50; - const maxStarredWorkspacesReached = _.size(stars) >= MAX_STARRED_WORKSPACES; - - const refreshStarredWorkspacesList = async () => { - const { starredWorkspaces } = await Ajax().User.profile.get(); - return _.isEmpty(starredWorkspaces) ? [] : _.split(',', starredWorkspaces); - }; - - const toggleStar = _.flow( - Utils.withBusyState(setUpdatingStars), - withErrorReporting(`Unable to ${isStarred ? 'unstar' : 'star'} workspace`) - )(async (star) => { - const refreshedStarredWorkspaceList = await refreshStarredWorkspacesList(); - const updatedWorkspaceIds = star - ? _.concat(refreshedStarredWorkspaceList, [workspaceId]) - : _.without([workspaceId], refreshedStarredWorkspaceList); - await Ajax().User.profile.setPreferences({ starredWorkspaces: _.join(',', updatedWorkspaceIds) }); - Ajax().Metrics.captureEvent(Events.workspaceStar, { - workspaceId, - starred: star, - ...extractWorkspaceDetails(props.workspace.workspace), - }); - userStore.update(_.set('profile.starredWorkspaces', updatedWorkspaceIds.join(','))); - }); - - return h( - Clickable, - { - tagName: 'span', - role: 'checkbox', - 'aria-checked': isStarred, - tooltip: Utils.cond( - [updatingStars, () => 'Updating starred workspaces.'], - [isStarred, () => 'Unstar this workspace.'], - [ - !isStarred && !maxStarredWorkspacesReached, - () => 'Star this workspace. Starred workspaces will appear at the top of your workspace list.', - ], - [ - !isStarred && maxStarredWorkspacesReached, - () => - `A maximum of ${MAX_STARRED_WORKSPACES} workspaces can be starred. Please un-star another workspace before starring this workspace.`, - ] - ), - - 'aria-label': isStarred ? 'This workspace is starred' : '', - className: 'fa-layers fa-fw', - disabled: updatingStars || (maxStarredWorkspacesReached && !isStarred), - style: { verticalAlign: 'middle' }, - onKeyDown: (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - e.target.click(); - } - }, - onClick: () => toggleStar(!isStarred), - }, - [ - updatingStars - ? h(Spinner, { size: 20 }) - : icon('star', { size: 20, color: isStarred ? colors.warning() : colors.light(2) }), - ] - ); -}; diff --git a/src/workspaces/list/WorkspaceStarControl.tsx b/src/workspaces/list/WorkspaceStarControl.tsx new file mode 100644 index 0000000000..9a01cde7f1 --- /dev/null +++ b/src/workspaces/list/WorkspaceStarControl.tsx @@ -0,0 +1,79 @@ +import { Icon, Spinner } from '@terra-ui-packages/components'; +import _ from 'lodash/fp'; +import { ReactNode, useState } from 'react'; +import React from 'react'; +import { Clickable } from 'src/components/common'; +import { Ajax } from 'src/libs/ajax'; +import colors from 'src/libs/colors'; +import { withErrorReporting } from 'src/libs/error'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; +import { useStore } from 'src/libs/react-utils'; +import { TerraUserState, userStore } from 'src/libs/state'; +import * as Utils from 'src/libs/utils'; +import { + starredWorkspacesFromFavoriteResources, + WorkspaceResourceTypeName, + WorkspaceWrapper, +} from 'src/workspaces/utils'; + +interface WorkspaceStarControlProps { + workspace: WorkspaceWrapper; +} + +export const WorkspaceStarControl = (props: WorkspaceStarControlProps): ReactNode => { + const { + workspace: { workspaceId }, + } = props.workspace; + const { favoriteResources } = useStore(userStore); + const stars = starredWorkspacesFromFavoriteResources(favoriteResources); + + const [updatingStars, setUpdatingStars] = useState(false); + const isStarred = _.includes(workspaceId, stars); + + const toggleFunc = async (star: boolean) => { + const resource = { resourceTypeName: WorkspaceResourceTypeName, resourceId: workspaceId }; + star ? await Ajax().User.favorites.put(resource) : await Ajax().User.favorites.delete(resource); + const favoriteResources = await Ajax().User.favorites.get(); + Ajax().Metrics.captureEvent(Events.workspaceStar, { + workspaceId, + starred: star, + ...extractWorkspaceDetails(props.workspace.workspace), + }); + userStore.update((state: TerraUserState) => ({ + ...state, + favoriteResources, + })); + }; + + const toggleStar = _.flow( + Utils.withBusyState(setUpdatingStars), + withErrorReporting(`Unable to ${isStarred ? 'unstar' : 'star'} workspace`) + )(toggleFunc); + + const tooltip = Utils.cond( + [updatingStars, () => 'Updating starred workspaces.'], + [isStarred, () => 'Unstar this workspace.'] + ); + + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleStar(!isStarred); + } + }} + onClick={() => toggleStar(!isStarred)} + > + {updatingStars && } + {!updatingStars && } + + ); +}; diff --git a/src/workspaces/utils.ts b/src/workspaces/utils.ts index 140cab4acd..08d5104b80 100644 --- a/src/workspaces/utils.ts +++ b/src/workspaces/utils.ts @@ -2,6 +2,7 @@ import { cond, safeCurry } from '@terra-ui-packages/core-utils'; import _ from 'lodash/fp'; import pluralize from 'pluralize'; import { AzureBillingProject, BillingProject } from 'src/billing-core/models'; +import { FullyQualifiedResourceId } from 'src/libs/ajax/SamResources'; import { azureRegions } from 'src/libs/azure-regions'; export type CloudProvider = 'AZURE' | 'GCP'; @@ -356,3 +357,11 @@ export const getWorkspaceAnalysisControlProps = ( export const azureControlledAccessRequestMessage = 'We recommend asking the person who invited you to the workspace if it includes any controlled-access data. ' + 'If it does, they may be able to help you gain access by assisting with a valid Data Access Request (DAR), for example.'; + +export const WorkspaceResourceTypeName = 'workspace'; + +export const starredWorkspacesFromFavoriteResources = (favoriteResources: FullyQualifiedResourceId[]) => { + return favoriteResources + .filter((fqrid) => fqrid.resourceTypeName === WorkspaceResourceTypeName) + .map((fqrid) => fqrid.resourceId); +}; From e7c3d2b93c15cd0559e66375a57a19797f90c9bb Mon Sep 17 00:00:00 2001 From: tlangs Date: Wed, 14 Aug 2024 16:32:46 -0400 Subject: [PATCH 2/2] unit tests --- .../list/WorkspaceStarControl.test.tsx | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/workspaces/list/WorkspaceStarControl.test.tsx diff --git a/src/workspaces/list/WorkspaceStarControl.test.tsx b/src/workspaces/list/WorkspaceStarControl.test.tsx new file mode 100644 index 0000000000..15820b6417 --- /dev/null +++ b/src/workspaces/list/WorkspaceStarControl.test.tsx @@ -0,0 +1,138 @@ +import { expect } from '@storybook/test'; +import { DeepPartial } from '@terra-ui-packages/core-utils'; +import { act, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { User } from 'src/libs/ajax/User'; +import { userStore } from 'src/libs/state'; +import { asMockedFn, renderWithAppContexts as render } from 'src/testing/test-utils'; +import { defaultInitializedGoogleWorkspace } from 'src/testing/workspace-fixtures'; +import { WorkspaceResourceTypeName } from 'src/workspaces/utils'; + +import { WorkspaceStarControl } from './WorkspaceStarControl'; + +type UserExports = typeof import('src/libs/ajax/User'); +jest.mock('src/libs/ajax/User', (): UserExports => { + return { + ...jest.requireActual('src/libs/ajax/User'), + User: jest.fn(), + }; +}); +type UserContract = ReturnType; + +const favoriteResources = [ + { + resourceTypeName: WorkspaceResourceTypeName, + resourceId: defaultInitializedGoogleWorkspace.workspace.workspaceId, + }, +]; + +describe('WorkspaceStarControl', () => { + describe('When a user has been loaded', () => { + describe('and the workspace is not starred', () => { + it('renders an "unchecked" star', async () => { + // Arrange + userStore.update((state) => { + return { ...state, favoriteResources: [] }; + }); + // Act + await render(); + + // Assert + const star = screen.getByRole('checkbox'); + expect(star).not.toBeChecked(); + }); + }); + describe('and the workspace is starred', () => { + it('renders a "checked" star', async () => { + // Arrange + userStore.update((state) => { + return { ...state, favoriteResources }; + }); + + // Act + await render(); + + // Assert + const star = screen.getByRole('checkbox'); + expect(star).toBeChecked(); + }); + }); + }); + describe('when a user stars a workspace', () => { + it('tells sam to favorite the workspace and renders a "checked" star', async () => { + // Arrange + const user = userEvent.setup(); + + userStore.update((state) => { + return { ...state, favoriteResources: [] }; + }); + + const getFavoriteResourcesFunction = jest.fn().mockResolvedValue(favoriteResources); + const putFavoriteResourcesFunction = jest.fn().mockResolvedValue({}); + const deleteFavoriteResourcesFunction = jest.fn().mockResolvedValue({}); + asMockedFn(User).mockImplementation(() => { + return { + favorites: { + get: getFavoriteResourcesFunction, + put: putFavoriteResourcesFunction, + delete: deleteFavoriteResourcesFunction, + }, + } as DeepPartial as UserContract; + }); + // Act + await act(() => render()); + + // Assert + const star = screen.getByRole('checkbox'); + expect(star).not.toBeChecked(); + + await user.click(star); + + expect(getFavoriteResourcesFunction).toHaveBeenCalled(); + expect(putFavoriteResourcesFunction).toHaveBeenCalled(); + expect(deleteFavoriteResourcesFunction).not.toHaveBeenCalled(); + + const newStar = screen.getByRole('checkbox'); + expect(newStar).toBeChecked; + }); + }); + describe('when a user un-stars a workspace', () => { + it('tells sam to un-favorite the workspace and renders an "unchecked" star', async () => { + // Arrange + const user = userEvent.setup(); + + userStore.update((state) => { + return { ...state, favoriteResources }; + }); + + const getFavoriteResourcesFunction = jest.fn().mockResolvedValue([]); + const putFavoriteResourcesFunction = jest.fn().mockResolvedValue({}); + const deleteFavoriteResourcesFunction = jest.fn().mockResolvedValue({}); + asMockedFn(User).mockImplementation(() => { + return { + favorites: { + get: getFavoriteResourcesFunction, + put: putFavoriteResourcesFunction, + delete: deleteFavoriteResourcesFunction, + }, + } as DeepPartial as UserContract; + }); + // Act + await act(() => render()); + + // Assert + const star = screen.getByRole('checkbox'); + expect(star).toBeChecked(); + + await user.click(star); + + expect(getFavoriteResourcesFunction).toHaveBeenCalled(); + expect(putFavoriteResourcesFunction).not.toHaveBeenCalled(); + expect(deleteFavoriteResourcesFunction).toHaveBeenCalled(); + + const newStar = screen.getByRole('checkbox'); + expect(newStar).not.toBeChecked; + }); + }); +});