From d8c9a94e14f7a84191063f20e12c283c0acc05ce Mon Sep 17 00:00:00 2001 From: "gitstart-app[bot]" <57568882+gitstart-app[bot]@users.noreply.github.com> Date: Thu, 18 Jan 2024 11:25:27 +0100 Subject: [PATCH] TWNTY-3381 - Add tests for `modules/apollo` (#3530) Add tests for `modules/apollo` Co-authored-by: gitstart-twenty Co-authored-by: v1b3m --- package.json | 1 + .../hooks/__tests__/useApolloFactory.test.tsx | 96 ++++++++++++ .../__tests__/useOptimisticEffect.test.tsx | 100 +++++++++++++ .../__tests__/useOptimisticEvict.test.tsx | 50 +++++++ .../services/__tests__/apollo.factory.test.ts | 141 ++++++++++++++++++ yarn.lock | 20 ++- 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx create mode 100644 packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEffect.test.tsx create mode 100644 packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEvict.test.tsx create mode 100644 packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts diff --git a/package.json b/package.json index 2958760404..d97c299106 100644 --- a/package.json +++ b/package.json @@ -240,6 +240,7 @@ "http-server": "^14.1.1", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", + "jest-fetch-mock": "^3.0.3", "msw": "^2.0.11", "msw-storybook-addon": "2.0.0--canary.122.b3ed3b1.0", "nx": "^17.2.8", diff --git a/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx b/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx new file mode 100644 index 0000000000..bea66cc15c --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx @@ -0,0 +1,96 @@ +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { ApolloError, gql } from '@apollo/client'; +import { act, renderHook } from '@testing-library/react'; +import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; +import { RecoilRoot } from 'recoil'; + +import { useApolloFactory } from '../useApolloFactory'; + +enableFetchMocks(); + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => { + const initialRouter = jest.requireActual('react-router-dom'); + + return { + ...initialRouter, + useNavigate: () => mockNavigate, + }; +}); + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +); + +describe('useApolloFactory', () => { + it('should work as expected', () => { + const { result } = renderHook(() => useApolloFactory(), { + wrapper: Wrapper, + }); + + const res = result.current; + + expect(res).toBeDefined(); + expect(res).toHaveProperty('link'); + expect(res).toHaveProperty('cache'); + expect(res).toHaveProperty('query'); + }); + + it('should navigate to /sign-in on unauthenticated error', async () => { + const errors = [ + { + extensions: { + code: 'UNAUTHENTICATED', + }, + }, + ]; + fetchMock.mockResponse(() => + Promise.resolve({ + body: JSON.stringify({ + data: {}, + errors, + }), + }), + ); + + const { result } = renderHook( + () => { + const location = useLocation(); + return { factory: useApolloFactory(), location }; + }, + { + wrapper: Wrapper, + }, + ); + + expect(result.current.location.pathname).toBe('/opportunities'); + + try { + await act(async () => { + await result.current.factory.mutate({ + mutation: gql` + mutation CreateEvent($type: String!, $data: JSON!) { + createEvent(type: $type, data: $data) { + success + } + } + `, + }); + }); + } catch (error) { + expect(error).toBeInstanceOf(ApolloError); + expect((error as ApolloError).message).toBe('Error message not found.'); + + expect(mockNavigate).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/sign-in'); + } + }); +}); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEffect.test.tsx b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEffect.test.tsx new file mode 100644 index 0000000000..ce7f07f6e3 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEffect.test.tsx @@ -0,0 +1,100 @@ +import { useApolloClient } from '@apollo/client'; +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; +import { optimisticEffectState } from '@/apollo/optimistic-effect/states/optimisticEffectState'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('useOptimisticEffect', () => { + it('should work as expected', async () => { + const { result } = renderHook( + () => { + const optimisticEffect = useRecoilValue(optimisticEffectState); + const client = useApolloClient(); + const { findManyRecordsQuery } = useObjectMetadataItem({ + objectNameSingular: 'person', + }); + return { + ...useOptimisticEffect({ objectNameSingular: 'person' }), + optimisticEffect, + cache: client.cache, + findManyRecordsQuery, + }; + }, + { + wrapper: Wrapper, + }, + ); + + const { + registerOptimisticEffect, + unregisterOptimisticEffect, + triggerOptimisticEffects, + optimisticEffect, + findManyRecordsQuery, + } = result.current; + + expect(registerOptimisticEffect).toBeDefined(); + expect(typeof registerOptimisticEffect).toBe('function'); + expect(optimisticEffect).toEqual({}); + + const optimisticEffectDefinition = { + variables: {}, + definition: { + typename: 'Person', + resolver: () => ({ + people: [], + pageInfo: { + endCursor: '', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + }, + edges: [], + }), + }, + }; + + act(() => { + registerOptimisticEffect(optimisticEffectDefinition); + }); + + await waitFor(() => { + expect(result.current.optimisticEffect).toHaveProperty('Person-{}'); + }); + + expect( + result.current.cache.readQuery({ query: findManyRecordsQuery }), + ).toBeNull(); + + act(() => { + triggerOptimisticEffects({ + typename: 'Person', + createdRecords: [{ id: 'id-0' }], + }); + }); + + await waitFor(() => { + expect( + result.current.cache.readQuery({ query: findManyRecordsQuery }), + ).toHaveProperty('people'); + }); + + act(() => { + unregisterOptimisticEffect(optimisticEffectDefinition); + }); + + await waitFor(() => { + expect(result.current.optimisticEffect).not.toHaveProperty('Person-{}'); + expect(result.current.optimisticEffect).toEqual({}); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEvict.test.tsx b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEvict.test.tsx new file mode 100644 index 0000000000..64834e7451 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEvict.test.tsx @@ -0,0 +1,50 @@ +import { useApolloClient } from '@apollo/client'; +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { useOptimisticEvict } from '@/apollo/optimistic-effect/hooks/useOptimisticEvict'; + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('useOptimisticEvict', () => { + it('should perform cache eviction', async () => { + const mockedData = { + 'someType:1': { __typename: 'someType', id: '1', fieldName: 'value1' }, + 'someType:2': { __typename: 'someType', id: '2', fieldName: 'value2' }, + 'otherType:1': { __typename: 'otherType', id: '1', fieldName: 'value3' }, + }; + + const { result } = renderHook( + () => { + const { cache } = useApolloClient(); + cache.restore(mockedData); + + return { + ...useOptimisticEvict(), + cache, + }; + }, + { + wrapper: Wrapper, + }, + ); + + const { performOptimisticEvict, cache } = result.current; + + act(() => { + performOptimisticEvict('someType', 'fieldName', 'value1'); + }); + + const cacheSnapshot = cache.extract(); + + expect(cacheSnapshot).toEqual({ + 'someType:2': { __typename: 'someType', id: '2', fieldName: 'value2' }, + 'otherType:1': { __typename: 'otherType', id: '1', fieldName: 'value3' }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts new file mode 100644 index 0000000000..7bd43be26a --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts @@ -0,0 +1,141 @@ +import { ApolloError, gql, InMemoryCache } from '@apollo/client'; +import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; + +import { ApolloFactory, Options } from '../apollo.factory'; + +enableFetchMocks(); + +jest.mock('@/auth/services/AuthService', () => { + const initialAuthService = jest.requireActual('@/auth/services/AuthService'); + return { + ...initialAuthService, + renewToken: jest.fn().mockReturnValue( + Promise.resolve({ + accessToken: { token: 'newAccessToken', expiresAt: '' }, + refreshToken: { token: 'newRefreshToken', expiresAt: '' }, + }), + ), + }; +}); + +const mockOnError = jest.fn(); +const mockOnNetworkError = jest.fn(); + +const createMockOptions = (): Options => ({ + uri: 'http://localhost:3000', + initialTokenPair: { + accessToken: { token: 'mockAccessToken', expiresAt: '' }, + refreshToken: { token: 'mockRefreshToken', expiresAt: '' }, + }, + cache: new InMemoryCache(), + isDebugMode: true, + onError: mockOnError, + onNetworkError: mockOnNetworkError, +}); + +const makeRequest = async () => { + const options = createMockOptions(); + const apolloFactory = new ApolloFactory(options); + + const client = apolloFactory.getClient(); + + await client.mutate({ + mutation: gql` + mutation CreateEvent($type: String!, $data: JSON!) { + createEvent(type: $type, data: $data) { + success + } + } + `, + }); +}; + +describe('xApolloFactory', () => { + it('should create an instance of ApolloFactory', () => { + const options = createMockOptions(); + const apolloFactory = new ApolloFactory(options); + expect(apolloFactory).toBeInstanceOf(ApolloFactory); + }); + + it('should call onError when encountering "Unauthorized" error', async () => { + const errors = [{ message: 'Unauthorized' }]; + fetchMock.mockResponse(() => + Promise.resolve({ + body: JSON.stringify({ + data: {}, + errors, + }), + }), + ); + try { + await makeRequest(); + } catch (error) { + expect(error).toBeInstanceOf(ApolloError); + expect((error as ApolloError).message).toBe('Unauthorized'); + expect(mockOnError).toHaveBeenCalledWith(errors); + } + }, 10000); + + it('should call onError when encountering "UNAUTHENTICATED" error', async () => { + const errors = [ + { + extensions: { + code: 'UNAUTHENTICATED', + }, + }, + ]; + fetchMock.mockResponse(() => + Promise.resolve({ + body: JSON.stringify({ + data: {}, + errors, + }), + }), + ); + + try { + await makeRequest(); + } catch (error) { + expect(error).toBeInstanceOf(ApolloError); + expect((error as ApolloError).message).toBe('Error message not found.'); + expect(mockOnError).toHaveBeenCalledWith(errors); + } + }, 10000); + + it('should call onNetworkError when encountering a network error', async () => { + const errors = [ + { + message: 'Unknown error', + }, + ]; + fetchMock.mockResponse(() => + Promise.resolve({ + body: JSON.stringify({ + data: {}, + errors, + }), + }), + ); + + try { + await makeRequest(); + } catch (error) { + expect(error).toBeInstanceOf(ApolloError); + expect((error as ApolloError).message).toBe('Unknown error'); + expect(mockOnError).toHaveBeenCalledWith(errors); + } + }, 10000); + + it('should call renewToken when encountering any error', async () => { + const mockError = { message: 'Unknown error' }; + fetchMock.mockReject(() => Promise.reject(mockError)); + + try { + await makeRequest(); + } catch (error) { + expect(error).toBeInstanceOf(ApolloError); + expect((error as ApolloError).message).toBe('Unknown error'); + expect(mockOnNetworkError).toHaveBeenCalledWith(mockError); + } + }, 10000); +}); diff --git a/yarn.lock b/yarn.lock index 9db6072803..9aa5a02929 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21095,7 +21095,7 @@ __metadata: languageName: node linkType: hard -"cross-fetch@npm:^3.1.5": +"cross-fetch@npm:^3.0.4, cross-fetch@npm:^3.1.5": version: 3.1.8 resolution: "cross-fetch@npm:3.1.8" dependencies: @@ -28901,6 +28901,16 @@ __metadata: languageName: node linkType: hard +"jest-fetch-mock@npm:^3.0.3": + version: 3.0.3 + resolution: "jest-fetch-mock@npm:3.0.3" + dependencies: + cross-fetch: "npm:^3.0.4" + promise-polyfill: "npm:^8.1.3" + checksum: 21ffe8c902ca5adafa7ed61760e100e4c290e99b0b487645f5bb92938ea64c2d1d9dc8af46e65fb7917d6237586067d53af756583a77330dbb4fbda079a63c29 + languageName: node + linkType: hard + "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -36887,6 +36897,13 @@ __metadata: languageName: node linkType: hard +"promise-polyfill@npm:^8.1.3": + version: 8.3.0 + resolution: "promise-polyfill@npm:8.3.0" + checksum: 97141f07a31a6be135ec9ed53700a3423a771cabec0ba5e08fcb2bf8cfda855479ff70e444fceb938f560be42b450cd032c14f4a940ed2ad1e5b4dcb78368dce + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -42560,6 +42577,7 @@ __metadata: immer: "npm:^10.0.2" jest: "npm:29.7.0" jest-environment-jsdom: "npm:29.7.0" + jest-fetch-mock: "npm:^3.0.3" jest-mock-extended: "npm:^3.0.4" js-cookie: "npm:^3.0.5" js-levenshtein: "npm:^1.1.6"