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"