snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Object metadata update should fail when imageIdentifier is not a uuid 1`] = `
[
{
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
"value": "not-a-uuid",
},
"message": "Invalid UUID: 'not-a-uuid'",
"name": "ValidationError",
},
]
`;
exports[`Object metadata update should fail when labelIdentifier is not a TEXT or NAME field 1`] = `
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"objectMetadata": [
{
"errors": [
{
"code": "INVALID_OBJECT_INPUT",
"message": "labelIdentifierFieldMetadataUniversalIdentifier validation failed: field type not compatible",
"userFriendlyMessage": "Field cannot be used as label identifier due to its type: should be of type UUID, text or full name",
},
],
"flatEntityMinimalInformation": {
"namePlural": "whatevers",
"nameSingular": "whatever",
"universalIdentifier": Any<String>,
},
"metadataName": "objectMetadata",
"type": "update",
},
],
},
"message": "Validation failed for 1 objectMetadata",
"summary": {
"objectMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Field cannot be used as label identifier due to its type: should be of type UUID, text or full name",
},
"message": "Multiple validation errors occurred while updating object",
"name": "GraphQLError",
},
]
`;
exports[`Object metadata update should fail when labelIdentifier is not a known field metadata id 1`] = `
[
{
"eventId": Any<String>,
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": Any<String>,
"subCode": "ENTITY_NOT_FOUND",
"userFriendlyMessage": "An error occurred.",
},
"message": "Could not find flat entity in maps",
"name": "InternalServerError",
},
]
`;
exports[`Object metadata update should fail when labelIdentifier is not a uuid 1`] = `
[
{
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
"value": "not-a-uuid",
},
"message": "Invalid UUID: 'not-a-uuid'",
"name": "ValidationError",
},
]
`;

View File

@@ -0,0 +1,49 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Standard object metadata update should fail when trying to update labelIdentifierFieldMetadataId on standard object 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_OBJECT_INPUT",
"userFriendlyMessage": "Invalid object input.",
},
"message": "Cannot edit standard object metadata properties: labelIdentifierFieldMetadataId",
"name": "UserInputError",
}
`;
exports[`Standard object metadata update should fail when trying to update namePlural on standard object 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_OBJECT_INPUT",
"userFriendlyMessage": "Invalid object input.",
},
"message": "Cannot edit standard object metadata properties: namePlural",
"name": "UserInputError",
}
`;
exports[`Standard object metadata update should fail when trying to update nameSingular on standard object 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_OBJECT_INPUT",
"userFriendlyMessage": "Invalid object input.",
},
"message": "Cannot edit standard object metadata properties: nameSingular",
"name": "UserInputError",
}
`;
exports[`Standard object metadata update should fail when trying to update several forbidden properties 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_OBJECT_INPUT",
"userFriendlyMessage": "Invalid object input.",
},
"message": "Cannot edit standard object metadata properties: isLabelSyncedWithName, namePlural",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,76 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Standard object metadata update should succeed when setting isActive to false 1`] = `
{
"color": "",
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": false,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
}
`;
exports[`Standard object metadata update should succeed when updating color 1`] = `
{
"color": "red",
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
}
`;
exports[`Standard object metadata update should succeed when updating description 1`] = `
{
"color": "",
"description": "Updated test description for company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
}
`;
exports[`Standard object metadata update should succeed when updating icon 1`] = `
{
"color": "",
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
}
`;
exports[`Standard object metadata update should succeed when updating labelSingular and labelPlural 1`] = `
{
"color": "",
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Businesses",
"labelSingular": "Business",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
}
`;

View File

@@ -0,0 +1,153 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
// Asserts the DB-level invariant the backfill relies on: a custom object's
// searchFieldMetadata rows are persisted with the object's own applicationId, never the
// standard application's. The backfill's selection/grouping/idempotency is unit-tested
// in build-search-field-metadata-backfill-operations.util.spec.ts.
const LABEL_FIELD_NAME = 'tag';
const PREFIX_OVERLAP_FIELD_NAME = 'tagline';
const STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER =
'20202020-64aa-4b6f-b003-9c74b97cee20';
type SearchFieldMetadataRow = {
objectApplicationId: string;
searchFieldApplicationId: string;
fieldObjectMetadataId: string;
fieldName: string;
};
const querySearchFieldMetadataRows = async (
objectMetadataId: string,
): Promise<SearchFieldMetadataRow[]> => {
return global.testDataSource.query(
`SELECT
om."applicationId" AS "objectApplicationId",
sfm."applicationId" AS "searchFieldApplicationId",
fm."objectMetadataId" AS "fieldObjectMetadataId",
fm.name AS "fieldName"
FROM core."searchFieldMetadata" sfm
INNER JOIN core."objectMetadata" om ON om.id = sfm."objectMetadataId"
INNER JOIN core."fieldMetadata" fm ON fm.id = sfm."fieldMetadataId"
WHERE sfm."objectMetadataId" = $1
ORDER BY fm.name ASC`,
[objectMetadataId],
);
};
// Resolves the standard application id of the workspace that owns the given object,
// without needing the object's (GraphQL-hidden) workspaceId.
const queryStandardApplicationIdForObjectWorkspace = async (
objectMetadataId: string,
): Promise<string> => {
const rows: { id: string }[] = await global.testDataSource.query(
`SELECT app.id AS id
FROM core."application" app
INNER JOIN core."objectMetadata" om ON om."workspaceId" = app."workspaceId"
WHERE om.id = $1 AND app."universalIdentifier" = $2`,
[objectMetadataId, STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER],
);
return rows[0].id;
};
describe('searchFieldMetadata rows - app-correct and deterministic per object', () => {
let testObjectMetadataId: string;
let labelFieldMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'backfillSearchFieldObject',
namePlural: 'backfillSearchFieldObjects',
labelSingular: 'Backfill Search Field Object',
labelPlural: 'Backfill Search Field Objects',
icon: 'IconSearch',
isLabelSyncedWithName: false,
},
});
testObjectMetadataId = objectMetadataId;
const {
data: {
createOneField: { id: labelFieldId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: LABEL_FIELD_NAME,
label: 'Tag',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `id name`,
});
labelFieldMetadataId = labelFieldId;
await createOneFieldMetadata({
expectToFail: false,
input: {
name: PREFIX_OVERLAP_FIELD_NAME,
label: 'Tagline',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `id name`,
});
await updateOneObjectMetadata({
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
labelIdentifierFieldMetadataId: labelFieldMetadataId,
},
},
expectToFail: false,
});
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
it('persists every searchFieldMetadata row of a custom object with the custom object application id, not the standard application id', async () => {
const rows = await querySearchFieldMetadataRows(testObjectMetadataId);
const standardApplicationId =
await queryStandardApplicationIdForObjectWorkspace(testObjectMetadataId);
expect(rows.length).toBeGreaterThan(0);
for (const row of rows) {
// A custom object's rows carry its own applicationId, never the standard one.
expect(row.searchFieldApplicationId).toBe(row.objectApplicationId);
expect(row.searchFieldApplicationId).not.toBe(standardApplicationId);
// No cross-object leakage: every targeted field belongs to this object.
expect(row.fieldObjectMetadataId).toBe(testObjectMetadataId);
}
expect(rows.some((row) => row.fieldName === LABEL_FIELD_NAME)).toBe(true);
});
});

View File

@@ -0,0 +1,338 @@
import { findCommandMenuItems } from 'test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util';
import { type CreateOneObjectFactoryInput } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
const findNavigationCommandMenuItemForObject = (
commandMenuItems: CommandMenuItemDTO[],
objectMetadataId: string,
) =>
commandMenuItems.find(
(item) =>
item.engineComponentKey === EngineComponentKey.NAVIGATION &&
item.navigationTargetObjectMetadataId === objectMetadataId,
);
const COMMAND_MENU_ITEM_GQL_FIELDS = `
id
engineComponentKey
label
icon
isActive
conditionalAvailabilityExpression
navigationTargetObjectMetadataId
payload {
... on PathCommandMenuItemPayload {
path
}
}
`;
describe('Command menu item side effect on object metadata', () => {
let createdObjectMetadataId: string | undefined = undefined;
const uniqueSuffix = Date.now().toString().slice(-8);
const createObjectInput: CreateOneObjectFactoryInput = {
namePlural: `sideEffectItems${uniqueSuffix}`,
nameSingular: `sideEffectItem${uniqueSuffix}`,
labelPlural: `Side Effect Items ${uniqueSuffix}`,
labelSingular: `Side Effect Item ${uniqueSuffix}`,
description: 'Object for command menu item side effect tests',
icon: 'IconBox',
isLabelSyncedWithName: false,
};
afterEach(async () => {
if (!isDefined(createdObjectMetadataId)) {
return;
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
expectToFail: false,
});
createdObjectMetadataId = undefined;
});
it('should create a navigation command menu item when a custom object is created', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
const {
data: { commandMenuItems },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
const navigationItem = findNavigationCommandMenuItemForObject(
commandMenuItems,
createdObjectMetadataId,
);
expect(navigationItem).toEqual(
expect.objectContaining({
label: `Go to ${createObjectInput.labelPlural}`,
icon: createObjectInput.icon,
engineComponentKey: EngineComponentKey.NAVIGATION,
}),
);
});
it('should delete the navigation command menu item when a custom object is deleted', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
const deletedObjectId = createdObjectMetadataId;
const {
data: { commandMenuItems: itemsBeforeDelete },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsBeforeDelete,
deletedObjectId,
),
).toBeDefined();
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
expectToFail: false,
});
createdObjectMetadataId = undefined;
const {
data: { commandMenuItems: itemsAfterDelete },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(itemsAfterDelete, deletedObjectId),
).toBeUndefined();
});
it('should deactivate the navigation command menu item when a custom object is disabled', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
const {
data: { commandMenuItems: itemsBeforeDisable },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsBeforeDisable,
createdObjectMetadataId,
),
).toEqual(expect.objectContaining({ isActive: true }));
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
const {
data: { commandMenuItems: itemsAfterDisable },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsAfterDisable,
createdObjectMetadataId,
),
).toEqual(expect.objectContaining({ isActive: false }));
});
it('should recompute the availability expression when the object is renamed, keeping the same command', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
const {
data: { commandMenuItems: itemsBeforeRename },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
const navigationItemBeforeRename = findNavigationCommandMenuItemForObject(
itemsBeforeRename,
createdObjectMetadataId,
);
expect(navigationItemBeforeRename).toEqual(
expect.objectContaining({
conditionalAvailabilityExpression: `targetObjectReadPermissions.${createObjectInput.nameSingular}`,
}),
);
const renamedNameSingular = `renamedItem${uniqueSuffix}`;
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
nameSingular: renamedNameSingular,
namePlural: `renamedItems${uniqueSuffix}`,
},
},
});
const {
data: { commandMenuItems: itemsAfterRename },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
const navigationItemAfterRename = findNavigationCommandMenuItemForObject(
itemsAfterRename,
createdObjectMetadataId,
);
expect(navigationItemAfterRename).toEqual(
expect.objectContaining({
id: navigationItemBeforeRename?.id,
conditionalAvailabilityExpression: `targetObjectReadPermissions.${renamedNameSingular}`,
}),
);
});
it('should reactivate the navigation command menu item when a disabled object is re-enabled', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
const {
data: { commandMenuItems: itemsWhileDisabled },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsWhileDisabled,
createdObjectMetadataId,
),
).toEqual(expect.objectContaining({ isActive: false }));
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: true },
},
});
const {
data: { commandMenuItems: itemsAfterReEnable },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
const navigationItem = findNavigationCommandMenuItemForObject(
itemsAfterReEnable,
createdObjectMetadataId,
);
expect(navigationItem).toEqual(
expect.objectContaining({
label: `Go to ${createObjectInput.labelPlural}`,
icon: createObjectInput.icon,
engineComponentKey: EngineComponentKey.NAVIGATION,
isActive: true,
}),
);
});
});

View File

@@ -0,0 +1,14 @@
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
export type FailingObjectMetadataCreationTestCase = {
input: Partial<Omit<CreateObjectInput, 'workspaceId' | 'dataSourceId'>>;
expected:
| {
errorCode: 'BAD_USER_INPUT';
messageContains: string;
}
| {
errorCode: 'METADATA_VALIDATION_FAILED';
objectValidationMessages: string[];
};
};

View File

@@ -0,0 +1,70 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { type FailingObjectMetadataCreationTestCase } from 'test/integration/metadata/suites/object-metadata/common/failing-object-metadata-creation-test-case.type';
const LABEL_TOO_LONG_MESSAGE = 'Object label is too long';
const LABEL_TOO_SHORT_MESSAGE = 'Object label is too short';
export const OBJECT_METADATA_LABEL_FAILING_TEST_CASES: EachTestingContext<FailingObjectMetadataCreationTestCase>[] =
[
{
title: 'when labelSingular is empty',
context: {
input: { labelSingular: '' },
expected: {
errorCode: 'BAD_USER_INPUT',
messageContains: 'labelSingular should not be empty',
},
},
},
{
title: 'when labelPlural is empty',
context: {
input: { labelPlural: '' },
expected: {
errorCode: 'BAD_USER_INPUT',
messageContains: 'labelPlural should not be empty',
},
},
},
{
title: 'when labelSingular exceeds maximum length',
context: {
input: { labelSingular: 'A'.repeat(64) },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [LABEL_TOO_LONG_MESSAGE],
},
},
},
{
title: 'when labelPlural exceeds maximum length',
context: {
input: { labelPlural: 'A'.repeat(64) },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [LABEL_TOO_LONG_MESSAGE],
},
},
},
{
title: 'when labelSingular contains only whitespace',
context: {
input: { labelSingular: ' ' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [LABEL_TOO_SHORT_MESSAGE],
},
},
},
{
title: 'when labelPlural contains only whitespace',
context: {
input: { labelPlural: ' ' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [LABEL_TOO_SHORT_MESSAGE],
},
},
},
];

View File

@@ -0,0 +1,150 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { type FailingObjectMetadataCreationTestCase } from 'test/integration/metadata/suites/object-metadata/common/failing-object-metadata-creation-test-case.type';
const INVALID_NAME_MESSAGE =
'Name is not valid: it must start with lowercase letter and contain only alphanumeric letters';
const RESERVED_NAME_MESSAGE =
'This name is reserved. Use a different name or the system will add "Custom" suffix.';
const IDENTICAL_NAMES_MESSAGE =
'The singular and plural names cannot be the same for an object';
export const OBJECT_METADATA_NAMES_FAILING_TEST_CASES: EachTestingContext<FailingObjectMetadataCreationTestCase>[] =
[
{
title: 'when nameSingular has invalid characters',
context: {
input: { nameSingular: 'μ' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [INVALID_NAME_MESSAGE],
},
},
},
{
title: 'when namePlural has invalid characters',
context: {
input: { namePlural: 'μ' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [INVALID_NAME_MESSAGE],
},
},
},
{
title: 'when nameSingular is a reserved keyword',
context: {
input: { nameSingular: 'user' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [RESERVED_NAME_MESSAGE],
},
},
},
{
title: 'when namePlural is a reserved keyword',
context: {
input: { namePlural: 'users' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [RESERVED_NAME_MESSAGE],
},
},
},
{
title: 'when nameSingular is not camelCased',
context: {
input: { nameSingular: 'Not_Camel_Case' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [INVALID_NAME_MESSAGE],
},
},
},
{
title: 'when namePlural is not camelCased',
context: {
input: { namePlural: 'Not_Camel_Case' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [INVALID_NAME_MESSAGE],
},
},
},
{
title: 'when namePlural is an empty string',
context: {
input: { namePlural: '' },
expected: {
errorCode: 'BAD_USER_INPUT',
messageContains: 'namePlural should not be empty',
},
},
},
{
title: 'when nameSingular is an empty string',
context: {
input: { nameSingular: '' },
expected: {
errorCode: 'BAD_USER_INPUT',
messageContains: 'nameSingular should not be empty',
},
},
},
{
title: 'when nameSingular contains only whitespaces',
context: {
input: { nameSingular: ' ' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: ['Name is too short', INVALID_NAME_MESSAGE],
},
},
},
{
title: 'when nameSingular contains only one char and whitespaces',
context: {
input: { nameSingular: ' a a ' },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [INVALID_NAME_MESSAGE],
},
},
},
{
title: 'when name exceeds maximum length',
context: {
input: { nameSingular: 'a'.repeat(64) },
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: ['Name is too long'],
},
},
},
{
title: 'when names are identical',
context: {
input: {
nameSingular: 'fooBar',
namePlural: 'fooBar',
},
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [IDENTICAL_NAMES_MESSAGE],
},
},
},
{
title: 'when names with whitespaces result to be identical',
context: {
input: {
nameSingular: ' fooBar ',
namePlural: 'fooBar',
},
expected: {
errorCode: 'METADATA_VALIDATION_FAILED',
objectValidationMessages: [IDENTICAL_NAMES_MESSAGE],
},
},
},
];

View File

@@ -0,0 +1,9 @@
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const CUSTOM_OBJECT_DISHES = {
namePlural: 'dishes',
nameSingular: 'dish',
description: 'My favorite dishes',
labelPlural: 'Dishes I love',
labelSingular: 'Dish I love',
} as const satisfies Partial<FlatObjectMetadata>;

View File

@@ -0,0 +1,9 @@
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const CUSTOM_OBJECT_FOOD = {
namePlural: 'foods',
nameSingular: 'food',
description: 'My favorite foods',
labelPlural: 'Foods I love',
labelSingular: 'food I love',
} as const satisfies Partial<FlatObjectMetadata>;

View File

@@ -0,0 +1,2 @@
export const LISTING_NAME_SINGULAR = 'listing';
export const LISTING_NAME_PLURAL = 'listings';

View File

@@ -0,0 +1,165 @@
import { type CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { type CreateOneObjectFactoryInput } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
describe('Handle same object sequential operations test suite', () => {
let createdObjectMetadataId: string | undefined = undefined;
const uniqueSuffix = Date.now().toString().slice(-8);
afterEach(async () => {
if (!isDefined(createdObjectMetadataId)) {
return;
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
expectToFail: false,
});
createdObjectMetadataId = undefined;
});
it('It should handle creation deletion and creation of the same metadata entity gracefully', async () => {
const createObjectInput: CreateOneObjectFactoryInput = {
namePlural: `dishes${uniqueSuffix}`,
nameSingular: `dish${uniqueSuffix}`,
labelPlural: `Dishes ${uniqueSuffix}`,
labelSingular: `Dish ${uniqueSuffix}`,
description: 'My favorite dishes',
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
};
const objectCreationGqlFields = `
id
labelPlural
description
labelSingular
namePlural
nameSingular
icon
isLabelSyncedWithName
`;
const fieldCreationGqlFields = `
id
label
name
type
description
`;
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: objectCreationGqlFields,
});
createdObjectMetadataId = createOneObject.id;
expect(createOneObject).toMatchObject(createObjectInput);
const createFieldInput: Omit<
CreateOneFieldFactoryInput,
'objectMetadataId'
> = {
label: 'field label',
name: 'fieldName',
type: FieldMetadataType.TEXT,
description: 'custom description',
};
const {
data: { createOneField },
} = await createOneFieldMetadata({
input: {
...createFieldInput,
objectMetadataId: createdObjectMetadataId,
},
expectToFail: false,
gqlFields: fieldCreationGqlFields,
});
expect(createOneField).toMatchObject(createFieldInput);
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createOneField.id,
updatePayload: {
isActive: false,
},
},
});
await deleteOneFieldMetadata({
input: {
idToDelete: createOneField.id,
},
expectToFail: false,
});
const {
data: { createOneField: secondCreateOneField },
} = await createOneFieldMetadata({
input: {
...createFieldInput,
objectMetadataId: createdObjectMetadataId,
},
expectToFail: false,
gqlFields: fieldCreationGqlFields,
});
expect(secondCreateOneField).toMatchObject(createFieldInput);
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
expectToFail: false,
});
createdObjectMetadataId = undefined;
const {
data: { createOneObject: secondCreateOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: objectCreationGqlFields,
});
createdObjectMetadataId = secondCreateOneObject.id;
expect(secondCreateOneObject).toMatchObject(createObjectInput);
const {
data: { createOneField: thirdCreateOneField },
} = await createOneFieldMetadata({
input: {
...createFieldInput,
objectMetadataId: createdObjectMetadataId,
},
expectToFail: false,
gqlFields: fieldCreationGqlFields,
});
expect(thirdCreateOneField).toMatchObject(createFieldInput);
});
});

View File

@@ -0,0 +1,194 @@
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import {
getFieldUniversalIdentifier,
getSystemRelationFieldUniversalIdentifier,
} from 'twenty-shared/application';
import { capitalize, isDefined } from 'twenty-shared/utils';
const SYSTEM_FIELD_NAMES = [
'id',
'name',
'createdAt',
'updatedAt',
'deletedAt',
'createdBy',
'updatedBy',
'position',
'searchVector',
];
const DEFAULT_RELATION_FORWARD_FIELD_NAMES = [
'timelineActivities',
'attachments',
'noteTargets',
'taskTargets',
];
const OBJECT_NAME_SINGULAR = 'rocketForUniversalIdentifier';
type FetchedField = {
id: string;
name: string;
universalIdentifier: string;
relation: {
targetFieldMetadata: {
id: string;
name: string;
universalIdentifier: string;
};
targetObjectMetadata: {
id: string;
nameSingular: string;
universalIdentifier: string;
};
} | null;
};
describe('Custom object creation deterministic field universal identifiers', () => {
let createdObjectMetadataId: string;
let objectUniversalIdentifier: string;
let applicationUniversalIdentifier: string;
let fetchedFields: FetchedField[];
beforeAll(async () => {
const { data: createObjectData } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: `${OBJECT_NAME_SINGULAR}s`,
labelSingular: 'Rocket For Universal Identifier',
labelPlural: 'Rockets For Universal Identifier',
icon: 'IconRocket',
},
gqlFields: 'id universalIdentifier applicationId',
});
createdObjectMetadataId = createObjectData.createOneObject.id;
objectUniversalIdentifier =
createObjectData.createOneObject.universalIdentifier;
const { data: findManyApplicationsData } = await findManyApplications({
expectToFail: false,
});
const workspaceCustomApplication =
findManyApplicationsData.findManyApplications.find(
(application) =>
application.id === createObjectData.createOneObject.applicationId,
);
if (!isDefined(workspaceCustomApplication)) {
throw new Error(
'Could not resolve the application of the created custom object',
);
}
applicationUniversalIdentifier =
workspaceCustomApplication.universalIdentifier;
const { fields } = await findManyFieldsMetadata({
expectToFail: false,
input: {
filter: { objectMetadataId: { eq: createdObjectMetadataId } },
paging: { first: 100 },
},
gqlFields: `
id
name
universalIdentifier
relation {
targetFieldMetadata { id name universalIdentifier }
targetObjectMetadata { id nameSingular universalIdentifier }
}
`,
});
fetchedFields = fields.map((edge: { node: FetchedField }) => edge.node);
});
afterAll(async () => {
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it.each(SYSTEM_FIELD_NAMES)(
'should derive the %s system field universal identifier deterministically',
(systemFieldName) => {
const systemField = fetchedFields.find(
(field) => field.name === systemFieldName,
);
expect(systemField).toBeDefined();
expect(systemField?.universalIdentifier).toBe(
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
name: systemFieldName,
}),
);
},
);
it.each(DEFAULT_RELATION_FORWARD_FIELD_NAMES)(
'should derive the %s default relation field universal identifiers deterministically',
async (forwardFieldName) => {
const forwardField = fetchedFields.find(
(field) => field.name === forwardFieldName,
);
expect(forwardField).toBeDefined();
const standardRelationObject =
forwardField?.relation?.targetObjectMetadata;
expect(standardRelationObject).toBeDefined();
if (!isDefined(standardRelationObject)) {
return;
}
const { fields: standardObjectFieldEdges } = await findManyFieldsMetadata(
{
expectToFail: false,
input: {
filter: { objectMetadataId: { eq: standardRelationObject.id } },
paging: { first: 1000 },
},
gqlFields: 'id name universalIdentifier',
},
);
const storedReverseFieldName = `target${capitalize(OBJECT_NAME_SINGULAR)}`;
const reverseField = standardObjectFieldEdges
.map((edge: { node: FetchedField }) => edge.node)
.find((field: FetchedField) => field.name === storedReverseFieldName);
expect(reverseField).toBeDefined();
// Both sides of a system relation use the name-free deterministic
// identifier (host + relation target object, direction encoded by
// swapping them) so an object rename stays a lossless update.
expect(forwardField?.universalIdentifier).toBe(
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectUniversalIdentifier,
relationTargetObjectUniversalIdentifier:
standardRelationObject.universalIdentifier,
}),
);
expect(reverseField?.universalIdentifier).toBe(
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier:
standardRelationObject.universalIdentifier,
relationTargetObjectUniversalIdentifier: objectUniversalIdentifier,
}),
);
},
);
});

View File

@@ -0,0 +1,45 @@
import { OBJECT_METADATA_LABEL_FAILING_TEST_CASES } from 'test/integration/metadata/suites/object-metadata/common/object-metadata-label-failing-tests-cases';
import { OBJECT_METADATA_NAMES_FAILING_TEST_CASES } from 'test/integration/metadata/suites/object-metadata/common/object-metadata-names-failing-tests-cases';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { eachTestingContextFilter } from 'twenty-shared/testing';
const allTestsUseCases = [
...OBJECT_METADATA_NAMES_FAILING_TEST_CASES,
...OBJECT_METADATA_LABEL_FAILING_TEST_CASES,
];
describe('Object metadata creation should fail v2', () => {
it.each(eachTestingContextFilter(allTestsUseCases))(
'$title',
async ({ context: { input, expected } }) => {
const { errors } = await createOneObjectMetadata({
input: getMockCreateObjectInput(input),
expectToFail: true,
});
expect(errors.length).toBe(1);
const [error] = errors;
expect(error.extensions.code).toBe(expected.errorCode);
if (expected.errorCode === 'BAD_USER_INPUT') {
expect(error.message).toContain(expected.messageContains);
return;
}
const objectMetadataFailures = error.extensions.errors.objectMetadata;
expect(objectMetadataFailures).toHaveLength(1);
expect(objectMetadataFailures[0].errors).toEqual(
expect.arrayContaining(
expected.objectValidationMessages.map((message) =>
expect.objectContaining({ message }),
),
),
);
},
);
});

View File

@@ -0,0 +1,136 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
type TestingRuntimeContext = {
objectMetadataId: string;
numberFieldMetadataId: string;
};
type CreateOneObjectMetadataItemTestingContext = EachTestingContext<
| ((args: TestingRuntimeContext) => Partial<UpdateObjectPayload>)
| Partial<UpdateObjectPayload>
>[];
const labelIdentifierFailingTestsUseCase: CreateOneObjectMetadataItemTestingContext =
[
{
title: 'when labelIdentifier is not a uuid',
context: {
labelIdentifierFieldMetadataId: 'not-a-uuid',
},
},
{
title: 'when labelIdentifier is not a known field metadata id',
context: {
labelIdentifierFieldMetadataId: '42422020-f49c-4159-8751-76a24f47b360',
},
},
{
title: 'when labelIdentifier is not a TEXT or NAME field',
context: ({ numberFieldMetadataId }) => ({
labelIdentifierFieldMetadataId: numberFieldMetadataId,
}),
},
];
const imageIdentifierFailingTestsUseCase: CreateOneObjectMetadataItemTestingContext =
[
{
title: 'when imageIdentifier is not a uuid',
context: {
imageIdentifierFieldMetadataId: 'not-a-uuid',
},
},
];
const allTestsUseCases = [
...labelIdentifierFailingTestsUseCase,
...imageIdentifierFailingTestsUseCase,
];
describe('Object metadata update should fail', () => {
let objectMetadataId: string;
let numberFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelPlural: 'whatevers',
labelSingular: 'whatever',
namePlural: 'whatevers',
nameSingular: 'whatever',
},
});
objectMetadataId = data.createOneObject.id;
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: objectMetadataId,
name: 'testName',
label: 'Test name',
isLabelSyncedWithName: true,
type: FieldMetadataType.NUMBER,
},
});
numberFieldMetadataId = createOneField.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: objectMetadataId,
},
});
});
it.each(eachTestingContextFilter(allTestsUseCases))(
'$title',
async ({ context }) => {
const updatePayload =
typeof context === 'function'
? context({
numberFieldMetadataId,
objectMetadataId,
})
: context;
const { errors } = await updateOneObjectMetadata({
input: {
idToUpdate: objectMetadataId,
updatePayload,
},
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
},
);
});

View File

@@ -0,0 +1,115 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
type TestingRuntimeContext = {
objectMetadataId: string;
textFieldMetadataId: string;
};
type UpdateOneStandardObjectMetadataTestingContext = EachTestingContext<
| ((args: TestingRuntimeContext) => Partial<UpdateObjectPayload>)
| Partial<UpdateObjectPayload>
>[];
const failingUpdateTestsUseCase: UpdateOneStandardObjectMetadataTestingContext =
[
{
title: 'when trying to update nameSingular on standard object',
context: {
nameSingular: 'newCompany',
},
},
{
title: 'when trying to update namePlural on standard object',
context: {
namePlural: 'newCompanies',
},
},
{
title:
'when trying to update labelIdentifierFieldMetadataId on standard object',
context: ({ textFieldMetadataId }) => ({
labelIdentifierFieldMetadataId: textFieldMetadataId,
}),
},
{
title: 'when trying to update several forbidden properties',
context: {
namePlural: 'newCompanies',
isActive: false,
isLabelSyncedWithName: false,
shortcut: 'whatever',
},
},
];
describe('Standard object metadata update should fail', () => {
let companyObjectMetadataId: string;
let companyNameFieldMetadataId: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
nameSingular
namePlural
labelSingular
labelPlural
isLabelSyncedWithName
fieldsList {
id
name
type
}
`,
});
const companyObject = objects.find((o) => o.nameSingular === 'company');
jestExpectToBeDefined(companyObject);
companyObjectMetadataId = companyObject.id;
const nameField = companyObject.fieldsList?.find(
(field: { name: string }) => field.name === 'name',
);
jestExpectToBeDefined(nameField);
companyNameFieldMetadataId = nameField!.id;
});
it.each(eachTestingContextFilter(failingUpdateTestsUseCase))(
'$title',
async ({ context }) => {
const updatePayload =
typeof context === 'function'
? context({
objectMetadataId: companyObjectMetadataId,
textFieldMetadataId: companyNameFieldMetadataId,
})
: context;
const { errors } = await updateOneObjectMetadata({
input: {
idToUpdate: companyObjectMetadataId,
updatePayload,
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
},
);
});

View File

@@ -0,0 +1,73 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Rename an object metadata with morph relation should succeed should update indexes on ONE_TO_MANY morph field related target object name update 1`] = `
{
"indexFieldMetadataList": [
{
"createdAt": Any<String>,
"fieldMetadataId": Any<String>,
"id": Any<String>,
"order": 0,
"updatedAt": Any<String>,
},
],
"indexType": "BTREE",
"isCustom": true,
"isUnique": false,
"name": "IDX_d46ee05346f12228875612c7a2a",
}
`;
exports[`Rename an object metadata with morph relation should succeed should update indexes on ONE_TO_MANY morph field related target object name update 2`] = `
{
"indexFieldMetadataList": [
{
"createdAt": Any<String>,
"fieldMetadataId": Any<String>,
"id": Any<String>,
"order": 0,
"updatedAt": Any<String>,
},
],
"indexType": "BTREE",
"isCustom": true,
"isUnique": false,
"name": "IDX_270c22872e92d429bb4f07a15dd",
}
`;
exports[`Rename an object metadata with morph relation should succeed should update indexes on ONE_TO_MANY morph field related target object name update 3`] = `
{
"indexFieldMetadataList": [
{
"createdAt": Any<String>,
"fieldMetadataId": Any<String>,
"id": Any<String>,
"order": 0,
"updatedAt": Any<String>,
},
],
"indexType": "BTREE",
"isCustom": true,
"isUnique": false,
"name": "IDX_aabda09e5917c5a7cbe5b6c84fc",
}
`;
exports[`Rename an object metadata with morph relation should succeed should update indexes on ONE_TO_MANY morph field related target object name update 4`] = `
{
"indexFieldMetadataList": [
{
"createdAt": Any<String>,
"fieldMetadataId": Any<String>,
"id": Any<String>,
"order": 0,
"updatedAt": Any<String>,
},
],
"indexType": "BTREE",
"isCustom": true,
"isUnique": false,
"name": "IDX_270c22872e92d429bb4f07a15dd",
}
`;

View File

@@ -0,0 +1,156 @@
import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('Delete Object metadata with morph relation should succeed', () => {
let opportunityId: undefined | string;
let personId = '';
let companyId = '';
let morphRelationField: { id: string };
beforeEach(async () => {
const {
data: {
createOneObject: { id: aId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'opportunityForDelete',
namePlural: 'opportunitiesForDelete',
labelSingular: 'Opportunity For Delete',
labelPlural: 'Opportunities For Delete',
icon: 'IconOpportunity',
},
});
opportunityId = aId;
const {
data: {
createOneObject: { id: bId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'personForDelete',
namePlural: 'peopleForDelete',
labelSingular: 'Person For Delete',
labelPlural: 'People For Delete',
icon: 'IconPerson',
},
});
personId = bId;
const {
data: {
createOneObject: { id: cId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'companyForDelete',
namePlural: 'companiesForDelete',
labelSingular: 'Company For Delete',
labelPlural: 'Companies For Delete',
icon: 'IconCompany',
},
});
companyId = cId;
});
afterEach(async () => {
if (isDefined(opportunityId)) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: opportunityId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({ input: { idToDelete: opportunityId } });
opportunityId = undefined;
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: personId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({ input: { idToDelete: personId } });
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: companyId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({ input: { idToDelete: companyId } });
});
it('When deleting source object, the relation on the target should be deleted', async () => {
jestExpectToBeDefined(opportunityId);
morphRelationField = await createMorphRelationBetweenObjects({
objectMetadataId: opportunityId,
firstTargetObjectMetadataId: personId,
secondTargetObjectMetadataId: companyId,
type: FieldMetadataType.MORPH_RELATION,
relationType: RelationType.MANY_TO_ONE,
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: opportunityId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({ input: { idToDelete: opportunityId } });
const fieldAfterDeletion = await findFieldMetadata({
fieldMetadataId: morphRelationField.id,
});
expect(fieldAfterDeletion).toBeUndefined();
opportunityId = undefined;
});
});
const findFieldMetadata = async ({
fieldMetadataId,
}: {
fieldMetadataId: string;
}) => {
const operation = findManyFieldsMetadataQueryFactory({
gqlFields: `
id
name
object { id nameSingular }
relation { type targetFieldMetadata { id } targetObjectMetadata { id } }
settings
`,
input: {
filter: { id: { eq: fieldMetadataId } },
paging: { first: 1 },
},
});
const fields = await makeMetadataAPIRequest(operation);
const field = fields.body.data.fields.edges?.[0]?.node;
return field;
};

View File

@@ -0,0 +1,501 @@
import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { type IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
const findFieldMetadata = async ({
fieldMetadataId,
}: {
fieldMetadataId: string;
}) => {
const { fields } = await findManyFieldsMetadata({
gqlFields: `
id
name
object { id nameSingular }
relation { type targetFieldMetadata { id } targetObjectMetadata { id } }
settings
`,
input: {
filter: { id: { eq: fieldMetadataId } },
paging: { first: 1 },
},
expectToFail: false,
});
return fields[0]?.node;
};
const allTestsUseCases: EachTestingContext<{
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
isLabelSyncedWithName: boolean;
newJoinColumnName: string | undefined;
relationType: RelationType;
}>[] = [
{
title:
'should rename custom object, and update both the field name and join column name of the morph relation that contains the object name',
context: {
nameSingular: 'personForRenameSecond2',
namePlural: 'peopleForRenameSecond2',
labelSingular: 'Person For Rename2',
labelPlural: 'People For Rename2',
isLabelSyncedWithName: false,
newJoinColumnName: 'ownerPersonForRenameSecond2Id',
relationType: RelationType.MANY_TO_ONE,
},
},
{
title:
'should rename custom object, and update both the field name and join column name of the morph relation that contains the object name if label is sync with name',
context: {
nameSingular: 'personForRenameSecond3',
namePlural: 'peopleForRenameSecond3',
labelSingular: 'person For Rename Second3',
labelPlural: 'people For Rename Second3',
isLabelSyncedWithName: true,
newJoinColumnName: 'ownerPersonForRenameSecond3Id',
relationType: RelationType.MANY_TO_ONE,
},
},
{
title:
'should rename custom object, and update both the field name and join column name of the morph relation that contains the object name with ONE_TO_MANY relation type',
context: {
nameSingular: 'personForRenameSecond4',
namePlural: 'peopleForRenameSecond4',
labelSingular: 'Person For Rename Second4',
labelPlural: 'People For Rename Second4',
isLabelSyncedWithName: true,
newJoinColumnName: undefined,
relationType: RelationType.ONE_TO_MANY,
},
},
];
describe('Rename an object metadata with morph relation should succeed', () => {
let createdObjectMetadataPersonId: string;
let createdObjectMetadataOpportunityId: string;
let createdObjectMetadataCompanyId: string;
beforeEach(async () => {
const {
data: {
createOneObject: { id: aId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'opportunityForRenameSecond',
namePlural: 'opportunitiesForRenameSecond',
labelSingular: 'Opportunity For Rename',
labelPlural: 'Opportunities For Rename',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = aId;
const {
data: {
createOneObject: { id: bId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'personForRenameSecond',
namePlural: 'peopleForRenameSecond',
labelSingular: 'Person For Rename',
labelPlural: 'People For Rename',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = bId;
const {
data: {
createOneObject: { id: cId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'companyForRenameSecond',
namePlural: 'companiesForRenameSecond',
labelSingular: 'Company For Rename',
labelPlural: 'Companies For Rename',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = cId;
});
afterEach(async () => {
const createdObjectMetadataIds = [
createdObjectMetadataPersonId,
createdObjectMetadataOpportunityId,
createdObjectMetadataCompanyId,
];
for (const objectMetadataId of createdObjectMetadataIds) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: objectMetadataId },
});
}
});
it.each(eachTestingContextFilter(allTestsUseCases))(
'$title',
async ({ context }) => {
const {
nameSingular,
namePlural,
labelSingular,
labelPlural,
isLabelSyncedWithName,
newJoinColumnName,
relationType,
} = context;
const morphRelationField = await createMorphRelationBetweenObjects({
name: 'owner',
objectMetadataId: createdObjectMetadataOpportunityId,
firstTargetObjectMetadataId: createdObjectMetadataPersonId,
secondTargetObjectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
relationType,
});
expect(morphRelationField.morphRelations.length).toBe(2);
const { data } = await updateOneObjectMetadata({
expectToFail: false,
gqlFields: `
nameSingular
labelSingular
namePlural
labelPlural
`,
input: {
idToUpdate: createdObjectMetadataPersonId,
updatePayload: {
nameSingular,
namePlural,
labelSingular,
labelPlural,
isLabelSyncedWithName,
},
},
});
expect(data.updateOneObject.nameSingular).toBe(nameSingular);
const ownerFieldMetadataOnPersonId =
morphRelationField.morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.id ===
createdObjectMetadataPersonId,
)?.sourceFieldMetadata.id;
if (!ownerFieldMetadataOnPersonId) {
throw new Error(
'Morph Relation Error: Owner field metadata on person not found',
);
}
const fieldAfterRenaming = await findFieldMetadata({
fieldMetadataId: ownerFieldMetadataOnPersonId,
});
expect(fieldAfterRenaming.settings.joinColumnName).toBe(
newJoinColumnName,
);
},
);
it('should update indexes on ONE_TO_MANY morph field related target object name update', async () => {
const morphRelationField = await createMorphRelationBetweenObjects({
name: 'owner',
objectMetadataId: createdObjectMetadataOpportunityId,
firstTargetObjectMetadataId: createdObjectMetadataPersonId,
secondTargetObjectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
relationType: RelationType.ONE_TO_MANY,
});
expect(morphRelationField.morphRelations.length).toBe(2);
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
let relationIndexByFieldId: Record<
string,
IndexMetadataDTO & {
indexFieldMetadataList: IndexFieldMetadataDTO[];
}
> = {};
const morphParentObject = objects.find(
(object) => object.id === createdObjectMetadataOpportunityId,
);
jestExpectToBeDefined(morphParentObject);
for (const {
targetObjectMetadata,
targetFieldMetadata,
sourceFieldMetadata,
} of morphRelationField.morphRelations) {
const relatedObject = objects.find(
(object) => object.id === targetObjectMetadata.id,
);
jestExpectToBeDefined(relatedObject);
const objectRelatedIndexes = relatedObject.indexMetadataList.filter(
(index) =>
index.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId === targetFieldMetadata.id,
),
);
expect(objectRelatedIndexes.length).toBe(1);
const [relationIndex] = objectRelatedIndexes;
jestExpectToBeDefined(relationIndex);
expect(relationIndex).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...relationIndex }),
);
relationIndexByFieldId[targetFieldMetadata.id] = relationIndex;
const parentRelationIndex = morphParentObject.indexMetadataList.filter(
(index) =>
index.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId == sourceFieldMetadata.id,
),
);
expect(parentRelationIndex.length).toBe(0);
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataPersonId,
updatePayload: {
nameSingular: 'personForRenameSecondUpdated',
namePlural: 'peopleForRenameSecondUpdated',
labelSingular: 'Person For Rename Updated',
labelPlural: 'People For Rename Updated',
},
},
});
const updatedObjects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const morphParentObjectAfterUpdate = objects.find(
(object) => object.id === createdObjectMetadataOpportunityId,
);
jestExpectToBeDefined(morphParentObjectAfterUpdate);
for (const {
targetObjectMetadata,
targetFieldMetadata,
sourceFieldMetadata,
} of morphRelationField.morphRelations) {
const relatedObject = updatedObjects.find(
(object) => object.id === targetObjectMetadata.id,
);
jestExpectToBeDefined(relatedObject);
const objectRelatedIndexes = relatedObject.indexMetadataList.filter(
(index) =>
index.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId === targetFieldMetadata.id,
),
);
expect(objectRelatedIndexes.length).toBe(1);
const [relationIndex] = objectRelatedIndexes;
jestExpectToBeDefined(relationIndex);
expect(relationIndex).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...relationIndex }),
);
const previousIndex = relationIndexByFieldId[targetFieldMetadata.id];
jestExpectToBeDefined(previousIndex);
if (targetObjectMetadata.id === createdObjectMetadataPersonId) {
expect(previousIndex.name).not.toBe(relationIndex.name);
} else {
expect(previousIndex.name).toBe(relationIndex.name);
}
const parentRelationIndex =
morphParentObjectAfterUpdate.indexMetadataList.filter((index) =>
index.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId == sourceFieldMetadata.id,
),
);
expect(parentRelationIndex.length).toBe(0);
}
});
it('should not update morph index on MANY_TO_ONE morph field related target object name update', async () => {
const morphRelationField = await createMorphRelationBetweenObjects({
name: 'owner',
objectMetadataId: createdObjectMetadataOpportunityId,
firstTargetObjectMetadataId: createdObjectMetadataPersonId,
secondTargetObjectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
relationType: RelationType.MANY_TO_ONE,
});
expect(morphRelationField.morphRelations.length).toBe(2);
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
let relationIndexByFieldId: Record<
string,
IndexMetadataDTO & {
indexFieldMetadataList: IndexFieldMetadataDTO[];
}
> = {};
const morphParentObject = objects.find(
(object) => object.id === createdObjectMetadataOpportunityId,
);
jestExpectToBeDefined(morphParentObject);
for (const {
targetObjectMetadata,
targetFieldMetadata,
sourceFieldMetadata,
} of morphRelationField.morphRelations) {
const relatedObject = objects.find(
(object) => object.id === targetObjectMetadata.id,
);
jestExpectToBeDefined(relatedObject);
const objectRelatedIndexes = relatedObject.indexMetadataList.filter(
(index) =>
index.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId === targetFieldMetadata.id,
),
);
expect(objectRelatedIndexes.length).toBe(0);
const parentRelationIndex = morphParentObject.indexMetadataList.filter(
(index) =>
index.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId == sourceFieldMetadata.id,
),
);
expect(parentRelationIndex.length).toBe(1);
const [relationIndex] = parentRelationIndex;
relationIndexByFieldId[
relationIndex.indexFieldMetadataList[0].fieldMetadataId
] = relationIndex;
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataPersonId,
updatePayload: {
nameSingular: 'personForRenameSecondUpdated',
namePlural: 'peopleForRenameSecondUpdated',
labelSingular: 'Person For Rename Updated',
labelPlural: 'People For Rename Updated',
},
},
});
const updatedObjects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const morphParentObjectAfterUpdate = objects.find(
(object) => object.id === createdObjectMetadataOpportunityId,
);
jestExpectToBeDefined(morphParentObjectAfterUpdate);
for (const {
targetObjectMetadata,
targetFieldMetadata,
sourceFieldMetadata,
} of morphRelationField.morphRelations) {
const relatedObject = updatedObjects.find(
(object) => object.id === targetObjectMetadata.id,
);
jestExpectToBeDefined(relatedObject);
const objectRelatedIndexes = relatedObject.indexMetadataList.filter(
(index) =>
index.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId === targetFieldMetadata.id,
),
);
expect(objectRelatedIndexes.length).toBe(0);
const parentRelationIndex =
morphParentObjectAfterUpdate.indexMetadataList.filter((index) =>
index.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId == sourceFieldMetadata.id,
),
);
expect(parentRelationIndex.length).toBe(1);
const [relationIndex] = parentRelationIndex;
const previousIndex = relationIndexByFieldId[sourceFieldMetadata.id];
expect(previousIndex.name).toBe(relationIndex.name);
}
});
});

View File

@@ -0,0 +1,143 @@
import gql from 'graphql-tag';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
const TEST_TABLE_NAME = '_mostlyEmptyProbe';
const MOSTLY_EMPTY_FIELD_METADATA_IDS_QUERY = gql`
query MostlyEmptyFieldMetadataIds($objectMetadataId: UUID!) {
mostlyEmptyFieldMetadataIds(objectMetadataId: $objectMetadataId)
}
`;
const fetchMostlyEmptyFieldMetadataIds = async (
objectMetadataId: string,
): Promise<string[]> => {
const response = await makeMetadataAPIRequest({
query: MOSTLY_EMPTY_FIELD_METADATA_IDS_QUERY,
variables: { objectMetadataId },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.mostlyEmptyFieldMetadataIds;
};
describe('mostlyEmptyFieldMetadataIds', () => {
let testObjectMetadataId: string;
let probeNotesFieldMetadataId: string;
let testSchemaName: string;
const insertProbeRecords = async (count: number) => {
await global.testDataSource.query(
`INSERT INTO "${testSchemaName}"."${TEST_TABLE_NAME}"
(name, "createdByName", "createdBySource", "updatedByName", "updatedBySource", position)
SELECT 'Probe record ' || i, 'Integration Test', 'MANUAL', 'Integration Test', 'MANUAL', i
FROM generate_series(1, ${count}) AS i`,
);
};
// Statistics are normally refreshed by autovacuum; tests need them on demand
const analyzeProbeTable = async () => {
await global.testDataSource.query(
`ANALYZE "${testSchemaName}"."${TEST_TABLE_NAME}"`,
);
};
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'mostlyEmptyProbe',
namePlural: 'mostlyEmptyProbes',
labelSingular: 'Mostly Empty Probe',
labelPlural: 'Mostly Empty Probes',
icon: 'IconChartHistogram',
isLabelSyncedWithName: false,
},
});
testObjectMetadataId = objectMetadataId;
const {
data: {
createOneField: { id: notesFieldId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'probeNotes',
label: 'Probe Notes',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `id name`,
});
probeNotesFieldMetadataId = notesFieldId;
const schemaRows: { schema_name: string }[] =
await global.testDataSource.query(
`SELECT w."databaseSchema" AS schema_name
FROM core."objectMetadata" om
JOIN core.workspace w ON w.id = om."workspaceId"
WHERE om.id = $1`,
[testObjectMetadataId],
);
testSchemaName = schemaRows[0].schema_name;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
it('should flag a never-filled field only while the record gate passes and the field stays empty', async () => {
// Below the minimum record count nothing is flagged
await insertProbeRecords(50);
await analyzeProbeTable();
expect(
await fetchMostlyEmptyFieldMetadataIds(testObjectMetadataId),
).toEqual([]);
// Past the gate, the untouched custom text field is the only expected hit:
// name is the label identifier and system fields are excluded
await insertProbeRecords(100);
await analyzeProbeTable();
expect(
await fetchMostlyEmptyFieldMetadataIds(testObjectMetadataId),
).toEqual([probeNotesFieldMetadataId]);
// Once the field is backfilled everywhere the hint disappears
await global.testDataSource.query(
`UPDATE "${testSchemaName}"."${TEST_TABLE_NAME}" SET "probeNotes" = 'filled'`,
);
await analyzeProbeTable();
expect(
await fetchMostlyEmptyFieldMetadataIds(testObjectMetadataId),
).toEqual([]);
});
});

View File

@@ -0,0 +1,216 @@
import request from 'supertest';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
extractMetadataItemPayload,
extractMetadataListPayload,
} from 'test/integration/rest/utils/metadata-rest-api.util';
import { assertRestApiSuccessfulResponse } from 'test/integration/rest/utils/rest-test-assertions.util';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
const client = request(`http://localhost:${APP_PORT}`);
const objectsQuery = {
query: `
query ObjectsI18n {
objects(paging: { first: 100 }) {
edges {
node {
nameSingular
labelSingular
labelPlural
description
}
}
}
}
`,
};
type ObjectNode = {
nameSingular: string;
labelSingular: string;
labelPlural: string;
description: string;
};
type RestFieldShape = { id: string; name: string; label: string };
type RestObjectShape = {
nameSingular: string;
labelSingular: string;
labelPlural: string;
description: string;
fields: RestFieldShape[];
};
const updateWorkspaceMemberLocale = async (locale: string) => {
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
update: { locale },
},
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkspaceMemberSettings).toBe(true);
};
const queryMetadataObjects = () =>
client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(objectsQuery);
const findObjectByName = (
edges: Array<{ node: ObjectNode }>,
nameSingular: string,
): ObjectNode | undefined =>
edges.find((edge) => edge.node.nameSingular === nameSingular)?.node;
describe('object metadata i18n', () => {
afterAll(async () => {
await updateWorkspaceMemberLocale('en');
});
it('should return English labels when user locale is en', async () => {
const response = await queryMetadataObjects();
expect(response.body.data).toBeDefined();
expect(response.body.errors).toBeUndefined();
const edges = response.body.data.objects.edges;
const company = findObjectByName(edges, 'company');
expect(company).toBeDefined();
expect(company!.labelSingular).toBe('Company');
expect(company!.labelPlural).toBe('Companies');
expect(company!.description).toBe('A company');
});
it('should return French labels when user locale is fr-FR', async () => {
await updateWorkspaceMemberLocale('fr-FR');
const response = await queryMetadataObjects();
expect(response.body.data).toBeDefined();
expect(response.body.errors).toBeUndefined();
const edges = response.body.data.objects.edges;
const company = findObjectByName(edges, 'company');
const person = findObjectByName(edges, 'person');
const opportunity = findObjectByName(edges, 'opportunity');
expect(company).toBeDefined();
expect(company!.labelSingular).toBe('Entreprise');
expect(company!.labelPlural).toBe('Entreprises');
expect(company!.description).toBe('Une entreprise');
expect(person).toBeDefined();
expect(person!.labelSingular).toBe('Personne');
expect(person!.labelPlural).toBe('Personnes');
expect(person!.description).toBe('Une personne');
expect(opportunity).toBeDefined();
expect(opportunity!.labelSingular).toBe('Opportunité');
expect(opportunity!.labelPlural).toBe('Opportunités');
expect(opportunity!.description).toBe('Une opportunité');
});
describe('REST metadata API', () => {
beforeAll(async () => {
await updateWorkspaceMemberLocale('fr-FR');
});
it('should return French labels from GET /metadata/objects', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=200',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const { items } = extractMetadataListPayload<RestObjectShape>(
response.body,
'objects',
);
const person = items.find((item) => item.nameSingular === 'person');
expect(person).toBeDefined();
expect(person!.labelSingular).toBe('Personne');
expect(person!.labelPlural).toBe('Personnes');
expect(person!.description).toBe('Une personne');
});
it('should return French labels on the fields inlined by GET /metadata/objects', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=200',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const { items } = extractMetadataListPayload<RestObjectShape>(
response.body,
'objects',
);
const person = items.find((item) => item.nameSingular === 'person');
// A field only reachable through person proves the inlined fields were
// regrouped onto the object they belong to, not merely onto some object.
const companyField = person?.fields.find(
(field) => field.name === 'company',
);
expect(companyField).toBeDefined();
expect(companyField!.label).toBe('Entreprise');
});
it('should return French labels from GET /metadata/fields/:id', async () => {
const objectsResponse = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=200',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(objectsResponse);
const { items } = extractMetadataListPayload<RestObjectShape>(
objectsResponse.body,
'objects',
);
const companyField = items
.find((item) => item.nameSingular === 'person')
?.fields.find((field) => field.name === 'company');
expect(companyField).toBeDefined();
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields/${companyField!.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
expect(
extractMetadataItemPayload<RestFieldShape>(response.body, 'field')
.label,
).toBe('Entreprise');
});
});
});

View File

@@ -0,0 +1,73 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
const OBJECT_RECORD_COUNTS_QUERY = gql`
query ObjectRecordCounts {
objectRecordCounts {
objectNamePlural
totalCount
}
}
`;
describe('objectRecordCounts', () => {
it('should return counts for all workspace objects', async () => {
const response = await makeMetadataAPIRequest({
query: OBJECT_RECORD_COUNTS_QUERY,
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.objectRecordCounts).toBeDefined();
const counts: { objectNamePlural: string; totalCount: number }[] =
response.body.data.objectRecordCounts;
expect(Array.isArray(counts)).toBe(true);
expect(counts.length).toBeGreaterThan(0);
for (const entry of counts) {
expect(typeof entry.objectNamePlural).toBe('string');
expect(entry.objectNamePlural.length).toBeGreaterThan(0);
expect(typeof entry.totalCount).toBe('number');
expect(entry.totalCount).toBeGreaterThanOrEqual(0);
}
});
it('should include well-known standard objects', async () => {
const response = await makeMetadataAPIRequest({
query: OBJECT_RECORD_COUNTS_QUERY,
});
const counts: { objectNamePlural: string; totalCount: number }[] =
response.body.data.objectRecordCounts;
const objectNames = counts.map((entry) => entry.objectNamePlural);
expect(objectNames).toContain('people');
expect(objectNames).toContain('companies');
expect(objectNames).toContain('opportunities');
});
it('should return non-negative totalCount values', async () => {
const response = await makeMetadataAPIRequest({
query: OBJECT_RECORD_COUNTS_QUERY,
});
const counts: { objectNamePlural: string; totalCount: number }[] =
response.body.data.objectRecordCounts;
const negativeEntries = counts.filter((entry) => entry.totalCount < 0);
expect(negativeEntries).toHaveLength(0);
});
it('should reject requests with an invalid token', async () => {
const response = await makeMetadataAPIRequest(
{ query: OBJECT_RECORD_COUNTS_QUERY },
INVALID_ACCESS_TOKEN,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,226 @@
import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { type EachTestingContext } from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
type DeleteOneObjectMetadataItemTestingContext = EachTestingContext<
(args: { objectMetadataIdToDelete: string; relationFieldId: string }) => {
objectMetadataIdToDelete: string;
relationFieldId: string;
}
>[];
const successfulDeleteSourceUseCase: DeleteOneObjectMetadataItemTestingContext =
[
{
title:
'When deleting source object, the relation on the target should be deleted',
context: ({ objectMetadataIdToDelete, relationFieldId }) => ({
objectMetadataIdToDelete,
relationFieldId,
}),
},
];
const successfulDeleteTargetUseCase: DeleteOneObjectMetadataItemTestingContext =
[
{
title:
'When deleting target object, the relation on the source should be deleted',
context: ({ objectMetadataIdToDelete, relationFieldId }) => ({
objectMetadataIdToDelete,
relationFieldId,
}),
},
];
describe('Delete Object metadata with relation should succeed', () => {
let createdObjectMetadataPersonId: undefined | string;
let createdObjectMetadataOpportunityId: undefined | string;
let relationField: FieldMetadataDTO & {
relation: RelationDTO;
};
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'personForRelation',
namePlural: 'peopleForRelation',
labelSingular: 'Person For Relation',
labelPlural: 'People For Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'opportunityForRelation',
namePlural: 'opportunitiesForRelation',
labelSingular: 'Opportunity For Relation',
labelPlural: 'Opportunities For Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
relationField =
await createRelationBetweenObjects<FieldMetadataType.RELATION>({
objectMetadataId: createdObjectMetadataOpportunityId,
targetObjectMetadataId: createdObjectMetadataPersonId,
type: FieldMetadataType.RELATION,
relationType: RelationType.MANY_TO_ONE,
});
});
afterEach(async () => {
if (isDefined(createdObjectMetadataPersonId)) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataPersonId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataPersonId },
});
}
if (isDefined(createdObjectMetadataOpportunityId)) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataOpportunityId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataOpportunityId },
});
}
});
it.each(successfulDeleteSourceUseCase)('$title', async ({ context }) => {
jestExpectToBeDefined(createdObjectMetadataPersonId);
const computedContext = context({
objectMetadataIdToDelete: createdObjectMetadataPersonId,
relationFieldId: relationField.id,
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: computedContext.objectMetadataIdToDelete,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: computedContext.objectMetadataIdToDelete },
});
createdObjectMetadataPersonId = undefined;
const opportunityFieldOnPersonAfterDeletion = await findFieldMetadata({
fieldMetadataId: computedContext.relationFieldId,
});
expect(opportunityFieldOnPersonAfterDeletion).toBeUndefined();
});
it.each(successfulDeleteTargetUseCase)('$title', async ({ context }) => {
jestExpectToBeDefined(createdObjectMetadataOpportunityId);
const computedContext = context({
objectMetadataIdToDelete: createdObjectMetadataOpportunityId,
relationFieldId: relationField.id,
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: computedContext.objectMetadataIdToDelete,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: computedContext.objectMetadataIdToDelete },
});
createdObjectMetadataOpportunityId = undefined;
const personFieldOnOpportunityAfterDeletion = await findFieldMetadata({
fieldMetadataId: computedContext.relationFieldId,
});
expect(personFieldOnOpportunityAfterDeletion).toBeUndefined();
});
});
const findFieldMetadata = async ({
fieldMetadataId,
}: {
fieldMetadataId: string;
}) => {
const operation = findManyFieldsMetadataQueryFactory({
gqlFields: `
id
name
object {
id
nameSingular
}
relation {
type
targetFieldMetadata {
id
}
targetObjectMetadata {
id
}
}
settings
`,
input: {
filter: {
id: { eq: fieldMetadataId },
},
paging: { first: 1 },
},
});
const fields = await makeMetadataAPIRequest(operation);
const field = fields.body.data.fields.edges?.[0]?.node;
return field;
};

View File

@@ -0,0 +1,286 @@
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { findManyObjectMetadataQueryFactory } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-query-factory.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
describe('Custom object renaming', () => {
let listingObjectId = '';
const uniqueSuffix = Date.now().toString().slice(-8);
const STANDARD_OBJECT_RELATIONS = [
'noteTarget',
'attachment',
'taskTarget',
'timelineActivity',
];
const standardObjectRelationsMap = STANDARD_OBJECT_RELATIONS.reduce(
(acc, relation) => ({
...acc,
[relation]: {
objectMetadataId: '',
foreignKeyFieldMetadataId: '',
relationFieldMetadataId: '',
relationFieldMetadataUniversalIdentifier: '',
},
}),
{},
);
const standardObjectsGraphqlOperation = findManyObjectMetadataQueryFactory({
gqlFields: `
id
nameSingular
`,
input: {
filter: {},
paging: { first: 1000 },
},
});
const fieldsGraphqlOperation = findManyFieldsMetadataQueryFactory({
gqlFields: `
id
name
label
type
universalIdentifier
object {
id
}
`,
input: {
filter: {},
paging: { first: 1000 },
},
});
// @ts-expect-error legacy noImplicitAny
const fillStandardObjectRelationsMapObjectMetadataId = (standardObjects) => {
STANDARD_OBJECT_RELATIONS.forEach((relation) => {
// @ts-expect-error legacy noImplicitAny
standardObjectRelationsMap[relation].objectMetadataId =
standardObjects.body.data.objects.edges.find(
// @ts-expect-error legacy noImplicitAny
(object) =>
object.node.nameSingular === relation ||
object.node.nameSingular === `target${relation}`,
).node.id;
});
};
it('1. should create one custom object with standard relations', async () => {
const standardObjects = await makeMetadataAPIRequest(
standardObjectsGraphqlOperation,
);
fillStandardObjectRelationsMapObjectMetadataId(standardObjects);
const CUSTOM_OBJECT = {
namePlural: `customObjects${uniqueSuffix}`,
nameSingular: `customObject${uniqueSuffix}`,
labelPlural: `Custom Objects ${uniqueSuffix}`,
labelSingular: `Custom Object ${uniqueSuffix}`,
description: 'Custom object description',
icon: 'IconListNumbers',
isLabelSyncedWithName: false,
};
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: CUSTOM_OBJECT,
gqlFields: `
id
nameSingular
`,
});
expect(data.createOneObject.nameSingular).toBe(CUSTOM_OBJECT.nameSingular);
listingObjectId = data.createOneObject.id;
const fields = await makeMetadataAPIRequest(fieldsGraphqlOperation);
const relationFieldsMetadataForListing = fields.body.data.fields.edges
.filter(
// @ts-expect-error legacy noImplicitAny
(field) =>
(field.node.name === `${CUSTOM_OBJECT.nameSingular}` &&
FieldMetadataType.RELATION) ||
(field.node.name ===
`target${capitalize(CUSTOM_OBJECT.nameSingular)}` &&
FieldMetadataType.MORPH_RELATION),
)
// @ts-expect-error legacy noImplicitAny
.map((field) => field.node);
STANDARD_OBJECT_RELATIONS.forEach((relation) => {
const relationFieldMetadata = relationFieldsMetadataForListing.find(
// @ts-expect-error legacy noImplicitAny
(field) =>
field.object.id ===
// @ts-expect-error legacy noImplicitAny
standardObjectRelationsMap[relation].objectMetadataId,
);
const relationFieldMetadataId = relationFieldMetadata?.id;
expect(relationFieldMetadataId).not.toBeUndefined();
// Reverse system relation fields carry the engine-derived label
// (capitalized source object nameSingular)
expect(relationFieldMetadata?.label).toBe(
capitalize(CUSTOM_OBJECT.nameSingular),
);
// @ts-expect-error legacy noImplicitAny
standardObjectRelationsMap[relation].relationFieldMetadataId =
relationFieldMetadataId;
// @ts-expect-error legacy noImplicitAny
standardObjectRelationsMap[relation].relationFieldMetadataUniversalIdentifier =
relationFieldMetadata?.universalIdentifier;
});
});
it('2. should rename custom object', async () => {
const HOUSE_NAME_SINGULAR = `house${uniqueSuffix}`;
const HOUSE_NAME_PLURAL = `houses${uniqueSuffix}`;
const HOUSE_LABEL_SINGULAR = `House ${uniqueSuffix}`;
const HOUSE_LABEL_PLURAL = `Houses ${uniqueSuffix}`;
const { data } = await updateOneObjectMetadata({
expectToFail: false,
gqlFields: `
nameSingular
labelSingular
namePlural
labelPlural
`,
input: {
idToUpdate: listingObjectId,
updatePayload: {
nameSingular: HOUSE_NAME_SINGULAR,
namePlural: HOUSE_NAME_PLURAL,
labelSingular: HOUSE_LABEL_SINGULAR,
labelPlural: HOUSE_LABEL_PLURAL,
},
},
});
expect(data.updateOneObject.nameSingular).toBe(HOUSE_NAME_SINGULAR);
expect(data.updateOneObject.namePlural).toBe(HOUSE_NAME_PLURAL);
expect(data.updateOneObject.labelSingular).toBe(HOUSE_LABEL_SINGULAR);
expect(data.updateOneObject.labelPlural).toBe(HOUSE_LABEL_PLURAL);
// The reverse morph fields on the standard objects must be renamed in place
// (name and engine-derived label follow the new object name) while keeping
// their universal identifier stable, so the rename stays lossless.
const expectedReverseFieldName = `target${capitalize(HOUSE_NAME_SINGULAR)}`;
const expectedReverseFieldLabel = capitalize(HOUSE_NAME_SINGULAR);
const fields = await makeMetadataAPIRequest(fieldsGraphqlOperation);
STANDARD_OBJECT_RELATIONS.forEach((relation) => {
// @ts-expect-error legacy noImplicitAny
const relationEntry = standardObjectRelationsMap[relation];
const relationFieldMetadataId = relationEntry.relationFieldMetadataId;
const relationFieldMetadataUniversalIdentifier =
relationEntry.relationFieldMetadataUniversalIdentifier;
const renamedReverseField = fields.body.data.fields.edges
// @ts-expect-error legacy noImplicitAny
.map((field) => field.node)
// @ts-expect-error legacy noImplicitAny
.find((field) => field.id === relationFieldMetadataId);
expect(renamedReverseField).toBeDefined();
expect(renamedReverseField.name).toBe(expectedReverseFieldName);
expect(renamedReverseField.label).toBe(expectedReverseFieldLabel);
expect(renamedReverseField.universalIdentifier).toBe(
relationFieldMetadataUniversalIdentifier,
);
});
});
it('3. should reject direct deletion of a system side-effect relation field', async () => {
// @ts-expect-error legacy noImplicitAny
const timelineActivityRelation = standardObjectRelationsMap['timelineActivity'];
const relationFieldMetadataId =
timelineActivityRelation.relationFieldMetadataId;
const { errors } = await deleteOneFieldMetadata({
expectToFail: true,
input: { idToDelete: relationFieldMetadataId },
});
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
});
it('4. should reject direct edition of a system side-effect relation field', async () => {
// @ts-expect-error legacy noImplicitAny
const timelineActivityRelation = standardObjectRelationsMap['timelineActivity'];
const relationFieldMetadataId =
timelineActivityRelation.relationFieldMetadataId;
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: relationFieldMetadataId,
updatePayload: { label: 'Should not be editable' },
},
});
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
});
it('5. should reject a morph relations update payload on a system side-effect relation field', async () => {
// @ts-expect-error legacy noImplicitAny
const timelineActivityRelation = standardObjectRelationsMap['timelineActivity'];
const relationFieldMetadataId =
timelineActivityRelation.relationFieldMetadataId;
// morphRelationsUpdatePayload is not an editable property, so it must be
// rejected explicitly for engine-owned fields instead of silently creating
// relation fields and indexes on them
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: relationFieldMetadataId,
updatePayload: {
morphRelationsUpdatePayload: [
{ targetObjectMetadataId: listingObjectId },
],
},
},
});
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
});
it('6. should delete custom object', async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: listingObjectId,
updatePayload: {
isActive: false,
},
},
});
const { data } = await deleteOneObjectMetadata({
input: {
idToDelete: listingObjectId,
},
});
expect(data.deleteOneObject.id).toBe(listingObjectId);
});
});

View File

@@ -0,0 +1,59 @@
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { type EachTestingContext } from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
type CreateObjectInputPayload = Omit<
CreateObjectInput,
'workspaceId' | 'dataSourceId'
>;
type CreateOneObjectMetadataItemTestingContext = EachTestingContext<
Partial<CreateObjectInputPayload>
>[];
const successfulObjectMetadataItemCreateOneUseCase: CreateOneObjectMetadataItemTestingContext =
[
{
title: 'with basic input',
context: {},
},
];
const allTestsUseCases = [...successfulObjectMetadataItemCreateOneUseCase];
describe('Object metadata creation should succeed', () => {
let createdObjectId: string | undefined;
afterEach(async () => {
if (!isDefined(createdObjectId)) {
return;
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectId },
});
});
it.each(allTestsUseCases)('$title', async ({ context }) => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: getMockCreateObjectInput(context),
});
expect(data.createOneObject.id).toBeDefined();
createdObjectId = data.createOneObject.id;
});
});

View File

@@ -0,0 +1,152 @@
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
type TestingRuntimeContext = {
objectMetadataId: string;
};
type UpdateOneStandardObjectMetadataTestingContext = EachTestingContext<
| ((args: TestingRuntimeContext) => Partial<UpdateObjectPayload>)
| Partial<UpdateObjectPayload>
>[];
const successfulUpdateTestsUseCase: UpdateOneStandardObjectMetadataTestingContext =
[
{
title: 'when updating description',
context: {
description: 'Updated test description for company',
},
},
{
title: 'when updating icon',
context: {
icon: 'IconBuildingSkyscraper',
},
},
{
title: 'when setting isActive to false',
context: {
isActive: false,
},
},
{
title: 'when updating labelSingular and labelPlural',
context: {
labelSingular: 'Business',
labelPlural: 'Businesses',
},
},
{
title: 'when updating color',
context: {
color: 'red',
},
},
];
const allTestsUseCases = [...successfulUpdateTestsUseCase];
describe('Standard object metadata update should succeed', () => {
let companyObjectMetadataId: string;
let originalCompanyMetadata: ObjectMetadataDTO;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
nameSingular
namePlural
labelSingular
labelPlural
color
description
icon
isActive
shortcut
`,
});
const companyObject = objects.find((o) => o.nameSingular === 'company');
jestExpectToBeDefined(companyObject);
companyObjectMetadataId = companyObject.id;
originalCompanyMetadata = companyObject;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: companyObjectMetadataId,
updatePayload: {
labelSingular: originalCompanyMetadata.labelSingular,
labelPlural: originalCompanyMetadata.labelPlural,
description: originalCompanyMetadata.description,
icon: originalCompanyMetadata.icon,
isActive: originalCompanyMetadata.isActive,
shortcut: originalCompanyMetadata.shortcut,
},
},
});
});
it.each(eachTestingContextFilter(allTestsUseCases))(
'$title',
async ({ context }) => {
const updatePayload =
typeof context === 'function'
? context({ objectMetadataId: companyObjectMetadataId })
: context;
const {
data: { updateOneObject },
errors,
} = await updateOneObjectMetadata({
input: {
idToUpdate: companyObjectMetadataId,
updatePayload,
},
expectToFail: false,
gqlFields: `
id
nameSingular
namePlural
labelSingular
labelPlural
color
description
icon
isActive
shortcut
`,
});
expect(errors).toBeUndefined();
expect(updateOneObject).toBeDefined();
expect(updateOneObject.id).toBe(companyObjectMetadataId);
expect(updateOneObject).toMatchObject({
...updatePayload,
});
expect(updateOneObject).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...updateOneObject }),
);
},
);
});

View File

@@ -0,0 +1,139 @@
import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { isDefined } from 'twenty-shared/utils';
type FetchedField = {
id: string;
name: string;
};
const getCompanyImageIdentifier = async (): Promise<string | null> => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: { filter: {}, paging: { first: 100 } },
gqlFields: `
id
nameSingular
imageIdentifierFieldMetadataId
`,
});
const company = objects.find((object) => object.nameSingular === 'company');
jestExpectToBeDefined(company);
return company.imageIdentifierFieldMetadataId ?? null;
};
describe('Standard object image identifier override should succeed', () => {
let companyObjectMetadataId: string;
let originalImageIdentifierFieldMetadataId: string | null;
let linkedinLinkFieldId: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: { filter: {}, paging: { first: 100 } },
gqlFields: `
id
nameSingular
imageIdentifierFieldMetadataId
`,
});
const company = objects.find((object) => object.nameSingular === 'company');
jestExpectToBeDefined(company);
companyObjectMetadataId = company.id;
originalImageIdentifierFieldMetadataId =
company.imageIdentifierFieldMetadataId ?? null;
const { fields } = await findManyFieldsMetadata({
expectToFail: false,
input: {
filter: { objectMetadataId: { eq: companyObjectMetadataId } },
paging: { first: 100 },
},
gqlFields: 'id name type',
});
const linkedinLinkField = fields
.map((edge: { node: FetchedField }) => edge.node)
.find((field: FetchedField) => field.name === 'linkedinLink');
jestExpectToBeDefined(linkedinLinkField);
linkedinLinkFieldId = linkedinLinkField.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: companyObjectMetadataId,
updatePayload: {
imageIdentifierFieldMetadataId:
originalImageIdentifierFieldMetadataId ?? undefined,
},
},
});
});
it('stores a non-default image identifier in overrides and resolves it', async () => {
const {
data: { updateOneObject },
errors,
} = await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: companyObjectMetadataId,
updatePayload: {
imageIdentifierFieldMetadataId: linkedinLinkFieldId,
},
},
gqlFields: `
id
imageIdentifierFieldMetadataId
`,
});
expect(errors).toBeUndefined();
expect(updateOneObject.imageIdentifierFieldMetadataId).toBe(
linkedinLinkFieldId,
);
});
it('persists the overridden image identifier across refetch', async () => {
const imageIdentifierFieldMetadataId = await getCompanyImageIdentifier();
expect(imageIdentifierFieldMetadataId).toBe(linkedinLinkFieldId);
});
it('respects an explicit null override instead of falling back to the base value', async () => {
const {
data: { updateOneObject },
errors,
} = await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: companyObjectMetadataId,
updatePayload: {
imageIdentifierFieldMetadataId: null,
},
},
gqlFields: `
id
imageIdentifierFieldMetadataId
`,
});
expect(errors).toBeUndefined();
expect(updateOneObject.imageIdentifierFieldMetadataId).toBeNull();
const imageIdentifierFieldMetadataId = await getCompanyImageIdentifier();
expect(imageIdentifierFieldMetadataId).toBeNull();
expect(isDefined(imageIdentifierFieldMetadataId)).toBe(false);
});
});

View File

@@ -0,0 +1,133 @@
import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util';
import { search } from 'test/integration/graphql/utils/search.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
describe('Object metadata update - search vector side effect', () => {
let testObjectMetadataId: string;
let testFieldMetadataId: string;
let createdRecordId: string;
const OBJECT_NAME_SINGULAR = 'searchVectorTestObject';
const OBJECT_NAME_PLURAL = 'searchVectorTestObjects';
const NEW_LABEL_IDENTIFIER_FIELD_NAME = 'searchableTitle';
const RECORD_FIELD_VALUE = 'UniqueSearchableValue123';
const RECORD_NAME_FIELD_VALUE = 'NameValue';
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Search Vector Test Object',
labelPlural: 'Search Vector Test Objects',
icon: 'IconSearch',
isLabelSyncedWithName: false,
},
});
testObjectMetadataId = objectMetadataId;
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: NEW_LABEL_IDENTIFIER_FIELD_NAME,
label: 'Searchable Title',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `
id
name
label
`,
});
testFieldMetadataId = fieldMetadataId;
const { data } = await createManyOperation({
objectMetadataSingularName: OBJECT_NAME_SINGULAR,
objectMetadataPluralName: OBJECT_NAME_PLURAL,
gqlFields: `id name ${NEW_LABEL_IDENTIFIER_FIELD_NAME}`,
data: [
{
[NEW_LABEL_IDENTIFIER_FIELD_NAME]: RECORD_FIELD_VALUE,
name: RECORD_NAME_FIELD_VALUE,
},
],
expectToFail: false,
});
createdRecordId = data.createdRecords[0].id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
it('should index the new label identifier without dropping the existing name surface', async () => {
await updateOneObjectMetadata({
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
labelIdentifierFieldMetadataId: testFieldMetadataId,
},
},
expectToFail: false,
});
// The record is now reachable through the new label identifier value.
const searchByNewLabelField = await search({
searchInput: RECORD_FIELD_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchByNewLabelField.data.search.edges.length).toBe(1);
expect(searchByNewLabelField.data.search.edges[0].node.recordId).toBe(
createdRecordId,
);
expect(
searchByNewLabelField.data.search.edges[0].node.objectNameSingular,
).toBe(OBJECT_NAME_SINGULAR);
// The previously indexed name field remains searchable.
const searchByName = await search({
searchInput: RECORD_NAME_FIELD_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchByName.data.search.edges.length).toBe(1);
expect(searchByName.data.search.edges[0].node.recordId).toBe(
createdRecordId,
);
});
});

View File

@@ -0,0 +1,92 @@
import { type CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { type FieldMetadataType } from 'twenty-shared/types';
import { type RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const createMorphRelationBetweenObjects = async ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type,
relationType,
name,
label,
isLabelSyncedWithName,
targetFieldLabel,
targetFieldIcon,
}: {
objectMetadataId: string;
firstTargetObjectMetadataId: string;
secondTargetObjectMetadataId: string;
type: FieldMetadataType;
relationType: RelationType;
name?: string;
label?: string;
isLabelSyncedWithName?: boolean;
targetFieldLabel?: string;
targetFieldIcon?: string;
}) => {
const createFieldInput: CreateOneFieldFactoryInput = {
name: name || 'owner',
label: label || 'owner field',
type,
objectMetadataId,
isLabelSyncedWithName: isLabelSyncedWithName || false,
morphRelationsCreationPayload: [
{
targetObjectMetadataId: firstTargetObjectMetadataId,
targetFieldLabel: targetFieldLabel || 'opportunity',
targetFieldIcon: targetFieldIcon || 'IconListOpportunity',
type: relationType,
},
{
targetObjectMetadataId: secondTargetObjectMetadataId,
targetFieldLabel: targetFieldLabel || 'opportunity',
targetFieldIcon: targetFieldIcon || 'IconListOpportunity',
type: relationType,
},
],
};
const {
data: { createOneField: createdFieldPerson },
} = await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
type
name
label
isLabelSyncedWithName
settings
object {
id
nameSingular
}
morphRelations {
type
targetFieldMetadata {
id
}
targetObjectMetadata {
id
}
sourceFieldMetadata {
id
}
sourceObjectMetadata {
id
}
}
`,
expectToFail: false,
});
return createdFieldPerson as FieldMetadataEntity<FieldMetadataType.MORPH_RELATION> & {
morphRelations: RelationDTO[];
};
};

View File

@@ -0,0 +1,25 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
export type CreateOneObjectFactoryInput = Omit<
CreateObjectInput,
'workspaceId' | 'dataSourceId'
>;
export const createOneObjectMetadataQueryFactory = ({
input,
gqlFields = 'id',
}: PerformMetadataQueryParams<CreateOneObjectFactoryInput>) => ({
query: gql`
mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) {
createOneObject(input: $input) {
${gqlFields}
}
}
`,
variables: {
input: { object: input },
},
});

View File

@@ -0,0 +1,43 @@
import {
type CreateOneObjectFactoryInput,
createOneObjectMetadataQueryFactory,
} from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
export const createOneObjectMetadata = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateOneObjectFactoryInput>): CommonResponseBody<{
createOneObject: ObjectMetadataDTO;
}> => {
const graphqlOperation = createOneObjectMetadataQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Object Metadata creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Object Metadata creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,87 @@
import { type CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { type FieldMetadataType } from 'twenty-shared/types';
import { type RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
export const createRelationBetweenObjects = async <
T extends FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION,
>({
objectMetadataId,
targetObjectMetadataId,
type,
relationType,
name,
label,
isLabelSyncedWithName,
targetFieldLabel,
targetFieldIcon,
}: {
objectMetadataId: string;
targetObjectMetadataId: string;
type: T;
relationType: RelationType;
name?: string;
label?: string;
isLabelSyncedWithName?: boolean;
targetFieldLabel?: string;
targetFieldIcon?: string;
}) => {
const createFieldInput: CreateOneFieldFactoryInput = {
name: name || 'person',
label: label || 'person field',
type: type,
objectMetadataId: objectMetadataId,
isLabelSyncedWithName: isLabelSyncedWithName || false,
relationCreationPayload: {
targetObjectMetadataId: targetObjectMetadataId,
targetFieldLabel: targetFieldLabel || 'opportunity',
targetFieldIcon: targetFieldIcon || 'IconListOpportunity',
type: relationType,
},
};
const { data } = await createOneFieldMetadata<typeof type>({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
settings
relation {
type
sourceObjectMetadata {
id
nameSingular
namePlural
}
targetObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
targetFieldMetadata {
id
name
}
}
object {
id
nameSingular
}
`,
expectToFail: false,
});
return data.createOneField as unknown as FieldMetadataDTO<T> & {
relation: RelationDTO;
};
};

View File

@@ -0,0 +1,22 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type DeleteOneObjectFactoryInput = {
idToDelete: string;
};
export const deleteOneObjectMetadataQueryFactory = ({
input,
gqlFields = 'id',
}: PerformMetadataQueryParams<DeleteOneObjectFactoryInput>) => ({
query: gql`
mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {
deleteOneObject(input: { id: $idToDelete }) {
${gqlFields}
}
}
`,
variables: {
idToDelete: input.idToDelete,
},
});

View File

@@ -0,0 +1,37 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import {
type DeleteOneObjectFactoryInput,
deleteOneObjectMetadataQueryFactory,
} from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata-query-factory.util';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
export const deleteOneObjectMetadata = async ({
input,
gqlFields,
expectToFail = false,
}: PerformMetadataQueryParams<DeleteOneObjectFactoryInput>) => {
const graphqlOperation = deleteOneObjectMetadataQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Object Metadata deletion should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Object Metadata deletion has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,28 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type FindManyObjectMetadataFactoryInput = {
filter: object;
paging: object;
};
export const findManyObjectMetadataQueryFactory = ({
gqlFields = 'id',
input,
}: PerformMetadataQueryParams<FindManyObjectMetadataFactoryInput>) => ({
query: gql`
query ObjectsMetadata($filter: ObjectFilter!, $paging: CursorPaging!) {
objects(filter: $filter, paging: $paging) {
edges {
node {
${gqlFields}
}
}
}
}
`,
variables: {
filter: input.filter,
paging: input.paging,
},
});

View File

@@ -0,0 +1,83 @@
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { type IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { type IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
export const findManyObjectMetadataWithIndexes = async ({
expectToFail,
}: {
expectToFail: boolean;
}) => {
const { objects } = await findManyObjectMetadata({
expectToFail,
input: {
filter: {},
paging: {
first: 100,
},
},
gqlFields: `
id
nameSingular
universalIdentifier
fieldsList {
id
type
name
applicationId
description
icon
label
universalIdentifier
relation {
type
sourceObjectMetadata {
id
nameSingular
namePlural
}
targetObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
targetFieldMetadata {
id
name
}
}
}
indexMetadataList {
name
isUnique
isCustom
indexType
indexFieldMetadataList {
id
fieldMetadataId
createdAt
updatedAt
order
}
}
`,
});
return objects as Array<
ObjectMetadataDTO & {
fieldsList: (FieldMetadataDTO & { relation: RelationDTO | null })[];
indexMetadataList: Array<
IndexMetadataDTO & {
indexFieldMetadataList: IndexFieldMetadataDTO[];
}
>;
}
>;
};

View File

@@ -0,0 +1,49 @@
import {
type FindManyObjectMetadataFactoryInput,
findManyObjectMetadataQueryFactory,
} from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type BaseGraphQLError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
export const findManyObjectMetadata = async ({
input,
gqlFields,
expectToFail,
}: PerformMetadataQueryParams<FindManyObjectMetadataFactoryInput>): Promise<{
errors: BaseGraphQLError[];
objects: (ObjectMetadataDTO & { fieldsList?: FieldMetadataDTO[] })[];
}> => {
const graphqlOperation = findManyObjectMetadataQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Object Metadata retrieval should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Object Metadata retrieval has failed but should not',
});
}
return {
errors: response.body.errors,
objects: response.body.data.objects?.edges.map(
({ node }: { node: unknown }) => node,
),
};
};

View File

@@ -0,0 +1,34 @@
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { type SearchFieldMetadataDTO } from 'src/engine/metadata-modules/search-field-metadata/dtos/search-field-metadata.dto';
export const findOneObjectMetadataWithSearchFieldMetadataList = async ({
objectMetadataId,
}: {
objectMetadataId: string;
}) => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: { id: { eq: objectMetadataId } },
paging: { first: 1 },
},
gqlFields: `
id
labelIdentifierFieldMetadataId
searchFieldMetadataList {
id
fieldMetadataId
position
}
`,
});
return objects[0] as ObjectMetadataDTO & {
searchFieldMetadataList: Pick<
SearchFieldMetadataDTO,
'id' | 'fieldMetadataId' | 'position'
>[];
};
};

View File

@@ -0,0 +1,15 @@
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
// TODO would tend to use faker
export const getMockCreateObjectInput = (
overrides?: Partial<Omit<CreateObjectInput, 'workspaceId' | 'dataSourceId'>>,
) => ({
namePlural: 'listingas',
nameSingular: 'listinga',
labelPlural: 'Listings',
labelSingular: 'Listing',
description: 'Listing object',
icon: 'IconListNumbers',
isLabelSyncedWithName: false,
...overrides,
});

View File

@@ -0,0 +1,23 @@
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { type MetadataReadability } from 'twenty-shared/types';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
// Written straight to the table and pushed into the caches: the metadata API
// refuses most levels, and an update through it would leave an override behind
export const setObjectReadability = async (
objectMetadataId: string,
readability: MetadataReadability,
) => {
await getCoreRepository<ObjectMetadataEntity>(ObjectMetadataEntity).update(
objectMetadataId,
{ readability },
);
await getAppProviderByClassName<WorkspaceCacheService>(
'WorkspaceCacheService',
).invalidateAndRecompute(SEED_APPLE_WORKSPACE_ID, ['flatObjectMetadataMaps']);
};

View File

@@ -0,0 +1,26 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
export type UpdateOneObjectFactoryInput = {
idToUpdate: string;
updatePayload: UpdateObjectPayload;
};
export const updateOneObjectMetadataQueryFactory = ({
gqlFields = 'id',
input,
}: PerformMetadataQueryParams<UpdateOneObjectFactoryInput>) => ({
query: gql`
mutation UpdateOneObjectMetadataItem($idToUpdate: UUID!, $updatePayload: UpdateObjectPayload!) {
updateOneObject(input: {id: $idToUpdate, update: $updatePayload}) {
${gqlFields}
}
}
`,
variables: {
idToUpdate: input.idToUpdate,
updatePayload: input.updatePayload,
},
});

View File

@@ -0,0 +1,42 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import {
type UpdateOneObjectFactoryInput,
updateOneObjectMetadataQueryFactory,
} from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata-query-factory.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
export const updateOneObjectMetadata = async ({
input,
gqlFields,
expectToFail,
}: PerformMetadataQueryParams<UpdateOneObjectFactoryInput>): CommonResponseBody<{
updateOneObject: ObjectMetadataDTO;
}> => {
const graphqlOperation = updateOneObjectMetadataQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Object Metadata update should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Object Metadata update has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,248 @@
import { CUSTOM_OBJECT_DISHES } from 'test/integration/metadata/suites/object-metadata/constants/custom-object-dishes.constants';
import { type CreateOneObjectFactoryInput } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util';
import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey, ViewType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
describe('View side effect on object creation', () => {
let createdObjectMetadataId: string | undefined = undefined;
afterEach(async () => {
if (!isDefined(createdObjectMetadataId)) {
return;
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
expectToFail: false,
});
createdObjectMetadataId = undefined;
});
const objectCreationGqlFields = `
id
labelPlural
description
labelSingular
namePlural
nameSingular
icon
isLabelSyncedWithName
`;
const createDishesObject = async () => {
const {
labelPlural,
description,
labelSingular,
namePlural,
nameSingular,
} = CUSTOM_OBJECT_DISHES;
const createObjectInput: CreateOneObjectFactoryInput = {
labelPlural,
description,
labelSingular,
namePlural,
nameSingular,
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
};
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: objectCreationGqlFields,
});
return createOneObject;
};
const findInitialViews = <TView extends { key: string | null; type: string }>(
views: TView[],
) =>
views.filter(
(view) => !isDefined(view.key) && view.type === ViewType.TABLE,
);
const findIndexView = async (objectMetadataId: string) => {
const {
data: { getViews },
} = await findViews({ objectMetadataId, expectToFail: false });
return getViews.find((view) => view.key === 'INDEX');
};
it('should engine-provision the INDEX view and its view fields on object metadata creation', async () => {
const createOneObject = await createDishesObject();
createdObjectMetadataId = createOneObject.id;
const {
data: { getViews: createdViews },
} = await findViews({
objectMetadataId: createdObjectMetadataId,
expectToFail: false,
});
expect(createdViews).toBeDefined();
const indexView = createdViews.find((view) => view.key === 'INDEX');
if (!isDefined(indexView)) {
throw new Error('expected an INDEX view to be provisioned');
}
expect(indexView).toMatchObject<Partial<FlatView>>({
objectMetadataId: createdObjectMetadataId,
type: ViewType.TABLE,
});
expect(createdViews.length).toBe(2);
expect(findInitialViews(createdViews)).toHaveLength(0);
const {
data: { getViewFields: indexViewFields },
} = await findViewFields({
viewId: indexView.id,
expectToFail: false,
});
expect(indexViewFields.length).toBe(5);
});
it('should expose the initial view and its view fields once the feature flag is enabled', async () => {
const createOneObject = await createDishesObject();
createdObjectMetadataId = createOneObject.id;
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_INITIAL_OBJECT_VIEW_ENABLED,
value: true,
expectToFail: false,
});
try {
const {
data: { getViews: createdViews },
} = await findViews({
objectMetadataId: createdObjectMetadataId,
expectToFail: false,
});
const [initialView, ...extraInitialViews] = findInitialViews(createdViews);
if (!isDefined(initialView)) {
throw new Error('expected a initial user-owned view to be exposed');
}
expect(extraInitialViews).toHaveLength(0);
expect(initialView).toMatchObject<Partial<FlatView>>({
objectMetadataId: createdObjectMetadataId,
type: ViewType.TABLE,
name: 'All Dishes I love',
});
const {
data: { getViewFields: initialViewFields },
} = await findViewFields({
viewId: initialView.id,
expectToFail: false,
});
expect(initialViewFields.length).toBe(5);
} finally {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_INITIAL_OBJECT_VIEW_ENABLED,
value: false,
expectToFail: false,
});
}
});
it('should keep the same INDEX view when the object is renamed (lossless, deterministic identifier)', async () => {
const createOneObject = await createDishesObject();
createdObjectMetadataId = createOneObject.id;
const indexViewBeforeRename = await findIndexView(createdObjectMetadataId);
if (!isDefined(indexViewBeforeRename)) {
throw new Error('expected an INDEX view to be provisioned');
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
labelPlural: 'Renamed dishes',
labelSingular: 'Renamed dish',
},
},
});
const indexViewAfterRename = await findIndexView(createdObjectMetadataId);
if (!isDefined(indexViewAfterRename)) {
throw new Error('expected the INDEX view to survive the rename');
}
expect(indexViewAfterRename.id).toBe(indexViewBeforeRename.id);
const {
data: { getViewFields: indexViewFieldsAfterRename },
} = await findViewFields({
viewId: indexViewAfterRename.id,
expectToFail: false,
});
expect(indexViewFieldsAfterRename.length).toBe(5);
});
it('should cascade-delete the INDEX view and its view fields when the object is deleted', async () => {
const createOneObject = await createDishesObject();
const objectMetadataId = createOneObject.id;
const indexView = await findIndexView(objectMetadataId);
expect(isDefined(indexView)).toBe(true);
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
input: { idToDelete: objectMetadataId },
expectToFail: false,
});
const {
data: { getViews: viewsAfterDelete },
} = await findViews({ objectMetadataId, expectToFail: false });
expect(viewsAfterDelete.length).toBe(0);
});
});