[TEST] Covering useDeleteOne relations optimistic cache behavior (#10238)

## Introduction
Added coverage on the `useDeleteOneRecord` hooks, especially its
optimistic behavior feature.
Introduced a new testing tool `InMemoryTestingCacheInstance` that has
builtin very basic expectors in order to avoid future duplication when
covering others record hooks `update, create, destroy` etc etc

## Notes
Added few comments in this PR regarding some builtin functions I've
created around companies and people mocked object model and that I think
could be cool to spread and centralize within a dedicated "class
template"

Also put in light that unless I'm mistaken some tests are running on
`RecordNode` and not `RecordObject`

Took few directions on my own that as I always I would suggestion nor
remarks on them !

Let me know

## Misc
- Should we refactor `useDeleteOneRecord` tests to follow `eachTesting`
pattern ? => I feel like this is inappropriate as this hooks is already
high level, the only plus value would be less tests code despite
readability IMO
This commit is contained in:
Paul Rastoin
2025-03-03 10:22:26 +01:00
committed by GitHub
parent c6e5238d71
commit 2e4c596644
30 changed files with 2989 additions and 289 deletions

View File

@ -0,0 +1,113 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { expect } from '@storybook/jest';
import { isDefined } from 'twenty-shared';
type ObjectMetadataItemAndRecordId = {
recordId: string;
objectMetadataItem: ObjectMetadataItem;
};
type RecordsWithObjectMetadataItem = {
records: ObjectRecord[];
objectMetadataItem: ObjectMetadataItem;
}[];
type GetMockCachedRecord = {
recordId: string;
objectMetadataItem: ObjectMetadataItem;
matchObject?: Record<string, unknown>;
snapshotPropertyMatchers?: {
deletedAt?: any;
updatedAt?: any;
createdAt?: any;
};
};
type InMemoryTestingCacheInstanceArgs = {
objectMetadataItems: ObjectMetadataItem[];
initialRecordsInCache?: RecordsWithObjectMetadataItem;
};
export class InMemoryTestingCacheInstance {
private _cache: InMemoryCache;
private objectMetadataItems: ObjectMetadataItem[];
private initialStateExtract: NormalizedCacheObject;
constructor({
objectMetadataItems,
initialRecordsInCache = [],
}: InMemoryTestingCacheInstanceArgs) {
this.objectMetadataItems = objectMetadataItems;
this._cache = new InMemoryCache();
this.populateRecordsInCache(initialRecordsInCache);
this.initialStateExtract = this._cache.extract();
}
public populateRecordsInCache = (
recordsWithObjectMetadataItem: RecordsWithObjectMetadataItem,
) => {
recordsWithObjectMetadataItem.forEach(({ objectMetadataItem, records }) =>
records.forEach((record) =>
updateRecordFromCache({
cache: this._cache,
objectMetadataItem,
objectMetadataItems: this.objectMetadataItems,
record,
recordGqlFields: computeDepthOneRecordGqlFieldsFromRecord({
objectMetadataItem,
record,
}),
}),
),
);
};
public assertCachedRecordIsNull = ({
objectMetadataItem,
recordId,
}: ObjectMetadataItemAndRecordId) => {
const cachedRecord = getRecordFromCache({
cache: this._cache,
objectMetadataItem,
objectMetadataItems: this.objectMetadataItems,
recordId,
});
expect(cachedRecord).toBeNull();
};
public assertCachedRecordMatchSnapshot = ({
objectMetadataItem,
recordId,
matchObject,
snapshotPropertyMatchers,
}: GetMockCachedRecord) => {
const cachedRecord = getRecordFromCache({
cache: this._cache,
objectMetadataItem,
objectMetadataItems: this.objectMetadataItems,
recordId,
});
expect(cachedRecord).not.toBeNull();
if (cachedRecord === null) {
throw new Error('Should never occurs, cachedRecord is null');
}
if (isDefined(matchObject)) {
expect(cachedRecord).toMatchObject(matchObject);
}
expect(cachedRecord).toMatchSnapshot(snapshotPropertyMatchers ?? {});
};
public restoreCacheToInitialState = async () => {
return this._cache.restore(this.initialStateExtract);
};
public get cache() {
return this._cache;
}
}

View File

@ -6,7 +6,7 @@ import { contextStoreCurrentObjectMetadataItemComponentState } from '@/context-s
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { isUndefined } from '@sniptt/guards';
import { getCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
import { getMockCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
export const ContextStoreDecorator: Decorator = (Story, context) => {
const { contextStore } = context.parameters;
@ -24,7 +24,7 @@ export const ContextStoreDecorator: Decorator = (Story, context) => {
const [isLoaded, setIsLoaded] = useState(false);
const objectMetadataItem = getCompanyObjectMetadataItem();
const objectMetadataItem = getMockCompanyObjectMetadataItem();
useEffect(() => {
setCurrentObjectMetadataItem(objectMetadataItem);

View File

@ -14,7 +14,7 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getPeopleMock } from '~/testing/mock-data/people';
import { getPeopleRecordConnectionMock } from '~/testing/mock-data/people';
import { mockedTasks } from '~/testing/mock-data/tasks';
const RecordMockSetterEffect = ({
@ -71,7 +71,7 @@ export const getFieldDecorator =
]
: companiesMock;
const peopleMock = getPeopleMock();
const peopleMock = getPeopleRecordConnectionMock();
const people =
objectNameSingular === 'person' && isDefined(fieldValue)

View File

@ -14,7 +14,7 @@ import { mockedClientConfig } from '~/testing/mock-data/config';
import { mockedFavoritesData } from '~/testing/mock-data/favorite';
import { mockedFavoriteFoldersData } from '~/testing/mock-data/favorite-folders';
import { mockedNotes } from '~/testing/mock-data/notes';
import { getPeopleMock } from '~/testing/mock-data/people';
import { getPeopleRecordConnectionMock } from '~/testing/mock-data/people';
import { mockedRemoteTables } from '~/testing/mock-data/remote-tables';
import { mockedUserData } from '~/testing/mock-data/users';
import { mockedViewsData } from '~/testing/mock-data/views';
@ -33,7 +33,7 @@ import {
import { mockedRemoteServers } from './mock-data/remote-servers';
import { mockedViewFieldsData } from './mock-data/view-fields';
const peopleMock = getPeopleMock();
const peopleMock = getPeopleRecordConnectionMock();
const companiesMock = getCompaniesMock();
const duplicateCompanyMock = getCompanyDuplicateMock();

View File

@ -1,50 +1,7 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
export const getCompaniesMock = () => {
return companiesQueryResult.companies.edges.map((edge) => edge.node);
};
export const getCompanyObjectMetadataItem = () => {
const companyObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
);
if (!companyObjectMetadataItem) {
throw new Error('Company object metadata item not found');
}
return companyObjectMetadataItem;
};
export const getCompanyDuplicateMock = () => {
return {
...companiesQueryResult.companies.edges[0].node,
id: '8b40856a-2ec9-4c03-8bc0-c032c89e1824',
};
};
export const getEmptyCompanyMock = () => {
return {
id: '9231e6ee-4cc2-4c7b-8c55-dff16f4d968a',
name: '',
domainName: {
__typename: 'Links',
primaryLinkUrl: '',
primaryLinkLabel: '',
secondaryLinks: [],
},
address: {},
accountOwner: null,
createdAt: null,
updatedAt: null,
employees: null,
idealCustomerProfile: null,
linkedinLink: null,
xLink: null,
_activityCount: null,
__typename: 'Company',
};
};
export const companiesQueryResult = {
companies: {
__typename: 'CompanyConnection',
@ -774,3 +731,75 @@ export const companiesQueryResult = {
],
},
};
const allMockedCompanyRecords = companiesQueryResult.companies.edges.map(
(edge) => edge.node,
);
export const getCompaniesMock = () => {
return [...allMockedCompanyRecords];
};
export const getMockCompanyObjectMetadataItem = () => {
const companyObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
);
if (!companyObjectMetadataItem) {
throw new Error('Company object metadata item not found');
}
return companyObjectMetadataItem;
};
export const getCompanyDuplicateMock = () => {
return {
...companiesQueryResult.companies.edges[0].node,
id: '8b40856a-2ec9-4c03-8bc0-c032c89e1824',
};
};
export const getMockCompanyRecord = (
overrides?: Partial<ObjectRecord>,
index = 0,
) => {
return {
...allMockedCompanyRecords[index],
...overrides,
};
};
export const findMockCompanyRecord = ({
id: queriedCompanyId,
}: Pick<ObjectRecord, 'id'>) => {
const company = allMockedCompanyRecords.find(
({ id: currentCompanyId }) => currentCompanyId === queriedCompanyId,
);
if (!isDefined(company)) {
throw new Error(`Could not find company with id, ${queriedCompanyId}`);
}
return company;
};
export const getEmptyCompanyMock = () => {
return {
id: '9231e6ee-4cc2-4c7b-8c55-dff16f4d968a',
name: '',
domainName: {
__typename: 'Links',
primaryLinkUrl: '',
primaryLinkLabel: '',
secondaryLinks: [],
},
address: {},
accountOwner: null,
createdAt: null,
updatedAt: null,
employees: null,
idealCustomerProfile: null,
linkedinLink: null,
xLink: null,
_activityCount: null,
__typename: 'Company',
};
};

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +1,9 @@
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from 'twenty-shared';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
export const getPeopleMock = (): ObjectRecord[] => {
const peopleMock = peopleQueryResult.people.edges.map((edge) => edge.node);
return peopleMock;
};
export const getPersonObjectMetadataItem = () => {
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
return personObjectMetadataItem;
};
export const getPersonFieldMetadataItem = (
fieldMetadataType: FieldMetadataType,
objectMetadataItem = getPersonObjectMetadataItem(),
) => {
const result = objectMetadataItem.fields.find(
(field) => field.type === fieldMetadataType,
);
if (!result) {
throw new Error(
`Person fieldmetadata item type ${fieldMetadataType} not found`,
);
}
return result;
};
export const getPersonRecord = (
overrides?: Partial<ObjectRecord>,
index = 0,
) => {
const personRecords = getPeopleMock();
return {
...personRecords[index],
...overrides,
};
};
export const mockedEmptyPersonData = {
id: 'ce7f0a37-88d7-4cd8-8b41-6721c57195b5',
firstName: '',
lastName: '',
phone: null,
email: null,
city: null,
createdBy: null,
displayName: null,
avatarUrl: null,
createdAt: null,
jobTitle: null,
linkedinUrl: null,
xUrl: null,
_activityCount: null,
company: null,
deletedAt: null,
__typename: 'Person',
};
export const peopleQueryResult = {
people: {
__typename: 'PersonConnection',
@ -1762,3 +1698,71 @@ export const peopleQueryResult = {
],
},
} satisfies { people: RecordGqlConnection };
export const allMockPersonRecords = peopleQueryResult.people.edges.map((edge) =>
getRecordFromRecordNode({ recordNode: edge.node }),
);
export const getPeopleRecordConnectionMock = () => {
const peopleMock = peopleQueryResult.people.edges.map((edge) => edge.node);
return peopleMock;
};
export const getMockPersonObjectMetadataItem = () => {
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
return personObjectMetadataItem;
};
export const getMockPersonFieldMetadataItem = (
fieldMetadataType: FieldMetadataType,
objectMetadataItem = getMockPersonObjectMetadataItem(),
) => {
const result = objectMetadataItem.fields.find(
(field) => field.type === fieldMetadataType,
);
if (!result) {
throw new Error(
`Person fieldmetadata item type ${fieldMetadataType} not found`,
);
}
return result;
};
export const getMockPersonRecord = (
overrides?: Partial<ObjectRecord>,
index = 0,
) => {
return {
...allMockPersonRecords[index],
...overrides,
};
};
export const mockedEmptyPersonData = {
id: 'ce7f0a37-88d7-4cd8-8b41-6721c57195b5',
firstName: '',
lastName: '',
phone: null,
email: null,
city: null,
createdBy: null,
displayName: null,
avatarUrl: null,
createdAt: null,
jobTitle: null,
linkedinUrl: null,
xUrl: null,
_activityCount: null,
company: null,
deletedAt: null,
__typename: 'Person',
};