diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/__tests__/validate-navigation-menu-item-type-required-properties.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/__tests__/validate-navigation-menu-item-type-required-properties.util.spec.ts new file mode 100644 index 0000000000..b74fcab6d5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/__tests__/validate-navigation-menu-item-type-required-properties.util.spec.ts @@ -0,0 +1,184 @@ +import { NavigationMenuItemType } from 'twenty-shared/types'; + +import { validateNavigationMenuItemTypeRequiredProperties } from 'src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util'; +import { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception'; +import { type UniversalFlatNavigationMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-navigation-menu-item.type'; + +const VALID_UUID = '20202020-b001-4b01-8b01-c0aba11c0001'; +const OTHER_VALID_UUID = '20202020-b002-4b02-8b02-c0aba11c0002'; + +const buildFlatNavigationMenuItem = ( + overrides: Partial, +): UniversalFlatNavigationMenuItem => + ({ + universalIdentifier: VALID_UUID, + name: null, + link: null, + icon: null, + color: null, + position: 0, + targetRecordId: null, + userWorkspaceId: null, + folderUniversalIdentifier: null, + viewUniversalIdentifier: null, + pageLayoutUniversalIdentifier: null, + targetObjectMetadataUniversalIdentifier: null, + ...overrides, + }) as UniversalFlatNavigationMenuItem; + +describe('validateNavigationMenuItemTypeRequiredProperties', () => { + it('should return an error when type is not defined', () => { + const errors = validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem: buildFlatNavigationMenuItem({ + type: undefined, + }), + }); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: 'Navigation menu item type is required', + }); + }); + + it.each([ + { + type: NavigationMenuItemType.FOLDER, + expectedMessages: ['A name is required for FOLDER type'], + }, + { + type: NavigationMenuItemType.OBJECT, + expectedMessages: [ + 'A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type', + ], + }, + { + type: NavigationMenuItemType.VIEW, + expectedMessages: [ + 'A valid viewUniversalIdentifier is required for VIEW type', + ], + }, + { + type: NavigationMenuItemType.RECORD, + expectedMessages: [ + 'A valid targetRecordId is required for RECORD type', + 'A valid targetObjectMetadataUniversalIdentifier is required for RECORD type', + ], + }, + { + type: NavigationMenuItemType.LINK, + expectedMessages: ['A valid link is required for LINK type'], + }, + { + type: NavigationMenuItemType.PAGE_LAYOUT, + expectedMessages: [ + 'A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type', + ], + }, + ])( + 'should report every missing property for $type type', + ({ type, expectedMessages }) => { + const errors = validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem: buildFlatNavigationMenuItem({ type }), + }); + + expect(errors.map(({ message }) => message)).toEqual(expectedMessages); + }, + ); + + it.each([ + { + type: NavigationMenuItemType.FOLDER, + overrides: { name: 'My folder' }, + }, + { + type: NavigationMenuItemType.OBJECT, + overrides: { targetObjectMetadataUniversalIdentifier: VALID_UUID }, + }, + { + type: NavigationMenuItemType.VIEW, + overrides: { viewUniversalIdentifier: VALID_UUID }, + }, + { + type: NavigationMenuItemType.RECORD, + overrides: { + targetRecordId: VALID_UUID, + targetObjectMetadataUniversalIdentifier: OTHER_VALID_UUID, + }, + }, + { + type: NavigationMenuItemType.LINK, + overrides: { link: 'https://twenty.com' }, + }, + { + type: NavigationMenuItemType.PAGE_LAYOUT, + overrides: { pageLayoutUniversalIdentifier: VALID_UUID }, + }, + ])( + 'should not report any error when $type type properties are valid', + ({ type, overrides }) => { + const errors = validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem: buildFlatNavigationMenuItem({ + type, + ...overrides, + }), + }); + + expect(errors).toEqual([]); + }, + ); + + it('should return an error when type is unknown', () => { + const errors = validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem: buildFlatNavigationMenuItem({ + type: 'UNKNOWN_NAVIGATION_MENU_ITEM_TYPE' as NavigationMenuItemType, + }), + }); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: + 'Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE', + }); + }); + + it('should treat blank folder names as missing', () => { + const errors = validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem: buildFlatNavigationMenuItem({ + type: NavigationMenuItemType.FOLDER, + name: ' ', + }), + }); + + expect(errors.map(({ message }) => message)).toEqual([ + 'A name is required for FOLDER type', + ]); + }); + + it('should report an error when the link is not a valid url', () => { + const errors = validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem: buildFlatNavigationMenuItem({ + type: NavigationMenuItemType.LINK, + link: 'not a link', + }), + }); + + expect(errors.map(({ message }) => message)).toEqual([ + 'A valid link is required for LINK type', + ]); + }); + + it('should report an error when a universal identifier is not a valid uuid', () => { + const errors = validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem: buildFlatNavigationMenuItem({ + type: NavigationMenuItemType.VIEW, + viewUniversalIdentifier: 'not-a-uuid', + }), + }); + + expect(errors.map(({ message }) => message)).toEqual([ + 'A valid viewUniversalIdentifier is required for VIEW type', + ]); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util.ts new file mode 100644 index 0000000000..380a1fd226 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util.ts @@ -0,0 +1,130 @@ +import { type MessageDescriptor } from '@lingui/core'; +import { msg, t } from '@lingui/core/macro'; +import { + type AssertUnreachable, + NavigationMenuItemType, +} from 'twenty-shared/types'; +import { isDefined, isValidUrl, isValidUuid } from 'twenty-shared/utils'; + +import { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception'; +import { type UniversalFlatNavigationMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-navigation-menu-item.type'; +import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; + +type NavigationMenuItemValidationError = + FlatEntityValidationError; + +const buildInvalidInputError = ( + message: string, + userFriendlyMessage: MessageDescriptor, +): NavigationMenuItemValidationError => ({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message, + userFriendlyMessage, +}); + +const validateUuidProperty = ({ + value, + message, + userFriendlyMessage, +}: { + value: string | null | undefined; + message: string; + userFriendlyMessage: MessageDescriptor; +}): NavigationMenuItemValidationError[] => + isDefined(value) && isValidUuid(value) + ? [] + : [buildInvalidInputError(message, userFriendlyMessage)]; + +export const validateNavigationMenuItemTypeRequiredProperties = ({ + flatNavigationMenuItem, +}: { + flatNavigationMenuItem: UniversalFlatNavigationMenuItem; +}): NavigationMenuItemValidationError[] => { + const { + type, + name, + link, + targetRecordId, + targetObjectMetadataUniversalIdentifier, + viewUniversalIdentifier, + pageLayoutUniversalIdentifier, + } = flatNavigationMenuItem; + + if (!isDefined(type)) { + return [ + buildInvalidInputError( + t`Navigation menu item type is required`, + msg`Navigation menu item type is required`, + ), + ]; + } + + switch (type) { + case NavigationMenuItemType.FOLDER: { + return isDefined(name) && name.trim() !== '' + ? [] + : [ + buildInvalidInputError( + t`A name is required for FOLDER type`, + msg`A name is required for FOLDER type`, + ), + ]; + } + case NavigationMenuItemType.OBJECT: { + return validateUuidProperty({ + value: targetObjectMetadataUniversalIdentifier, + message: t`A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type`, + userFriendlyMessage: msg`A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type`, + }); + } + case NavigationMenuItemType.VIEW: { + return validateUuidProperty({ + value: viewUniversalIdentifier, + message: t`A valid viewUniversalIdentifier is required for VIEW type`, + userFriendlyMessage: msg`A valid viewUniversalIdentifier is required for VIEW type`, + }); + } + case NavigationMenuItemType.RECORD: { + return [ + ...validateUuidProperty({ + value: targetRecordId, + message: t`A valid targetRecordId is required for RECORD type`, + userFriendlyMessage: msg`A valid targetRecordId is required for RECORD type`, + }), + ...validateUuidProperty({ + value: targetObjectMetadataUniversalIdentifier, + message: t`A valid targetObjectMetadataUniversalIdentifier is required for RECORD type`, + userFriendlyMessage: msg`A valid targetObjectMetadataUniversalIdentifier is required for RECORD type`, + }), + ]; + } + case NavigationMenuItemType.LINK: { + return isDefined(link) && isValidUrl(link) + ? [] + : [ + buildInvalidInputError( + t`A valid link is required for LINK type`, + msg`A valid link is required for LINK type`, + ), + ]; + } + case NavigationMenuItemType.PAGE_LAYOUT: { + return validateUuidProperty({ + value: pageLayoutUniversalIdentifier, + message: t`A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type`, + userFriendlyMessage: msg`A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type`, + }); + } + default: { + // oxlint-disable-next-line unused-imports/no-unused-vars + type UnhandledNavigationMenuItemType = AssertUnreachable; + + return [ + buildInvalidInputError( + t`Unknown navigation menu item type ${type}`, + msg`Unknown navigation menu item type ${type}`, + ), + ]; + } + } +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts index 5972799eae..2b720fc08f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts @@ -1,12 +1,11 @@ import { Injectable } from '@nestjs/common'; import { msg, t } from '@lingui/core/macro'; -import { isNonEmptyString } from '@sniptt/guards'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; -import { NavigationMenuItemType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { validateNavigationMenuItemTypeRequiredProperties } from 'src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util'; import { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception'; import { type MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type'; import { validateFlatEntityCircularDependency } from 'src/engine/workspace-manager/workspace-migration/utils/validate-flat-entity-circular-dependency.util'; @@ -22,96 +21,6 @@ const NAVIGATION_MENU_ITEM_MAX_DEPTH = 2; @Injectable() export class FlatNavigationMenuItemValidatorService { - private validateNavigationMenuItemType({ - type, - hasTargetRecordId, - hasTargetObjectMetadataId, - hasViewId, - hasLink, - hasPageLayoutId, - name, - }: { - type: NavigationMenuItemType | null | undefined; - hasTargetRecordId: boolean; - hasTargetObjectMetadataId: boolean; - hasViewId: boolean; - hasLink: boolean; - hasPageLayoutId: boolean; - name: string | null | undefined; - }): FlatEntityValidationError[] { - const errors: FlatEntityValidationError[] = - []; - - if (!isDefined(type)) { - errors.push({ - code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, - message: t`Navigation menu item type is required`, - userFriendlyMessage: msg`Navigation menu item type is required`, - }); - - return errors; - } - - switch (type) { - case NavigationMenuItemType.FOLDER: - if (!isDefined(name) || name.trim() === '') { - errors.push({ - code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, - message: t`Folder name is required`, - userFriendlyMessage: msg`Folder name is required`, - }); - } - break; - case NavigationMenuItemType.OBJECT: - if (!hasTargetObjectMetadataId) { - errors.push({ - code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, - message: t`targetObjectMetadataId is required for OBJECT type`, - userFriendlyMessage: msg`targetObjectMetadataId is required for OBJECT type`, - }); - } - break; - case NavigationMenuItemType.VIEW: - if (!hasViewId) { - errors.push({ - code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, - message: t`viewId is required for VIEW type`, - userFriendlyMessage: msg`viewId is required for VIEW type`, - }); - } - break; - case NavigationMenuItemType.RECORD: - if (!hasTargetRecordId || !hasTargetObjectMetadataId) { - errors.push({ - code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, - message: t`targetRecordId and targetObjectMetadataId are required for RECORD type`, - userFriendlyMessage: msg`targetRecordId and targetObjectMetadataId are required for RECORD type`, - }); - } - break; - case NavigationMenuItemType.LINK: - if (!hasLink) { - errors.push({ - code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, - message: t`link is required for LINK type`, - userFriendlyMessage: msg`link is required for LINK type`, - }); - } - break; - case NavigationMenuItemType.PAGE_LAYOUT: - if (!hasPageLayoutId) { - errors.push({ - code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, - message: t`pageLayoutId is required for PAGE_LAYOUT type`, - userFriendlyMessage: msg`pageLayoutId is required for PAGE_LAYOUT type`, - }); - } - break; - } - - return errors; - } - private getCircularDependencyValidationErrors({ navigationMenuItemUniversalIdentifier, folderUniversalIdentifier, @@ -173,6 +82,8 @@ export class FlatNavigationMenuItemValidatorService { const validationResult = getEmptyFlatEntityValidationError({ flatEntityMinimalInformation: { universalIdentifier: flatNavigationMenuItem.universalIdentifier, + name: flatNavigationMenuItem.name, + type: flatNavigationMenuItem.type, }, metadataName: 'navigationMenuItem', type: 'create', @@ -189,23 +100,11 @@ export class FlatNavigationMenuItemValidatorService { }); } - const typeValidationErrors = this.validateNavigationMenuItemType({ - type: flatNavigationMenuItem.type, - hasTargetRecordId: isDefined(flatNavigationMenuItem.targetRecordId), - hasTargetObjectMetadataId: isDefined( - flatNavigationMenuItem.targetObjectMetadataUniversalIdentifier, - ), - hasViewId: isDefined(flatNavigationMenuItem.viewUniversalIdentifier), - hasLink: - isDefined(flatNavigationMenuItem.link) && - isNonEmptyString(flatNavigationMenuItem.link), - hasPageLayoutId: isDefined( - flatNavigationMenuItem.pageLayoutUniversalIdentifier, - ), - name: flatNavigationMenuItem.name, - }); - - validationResult.errors.push(...typeValidationErrors); + validationResult.errors.push( + ...validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem, + }), + ); if (isDefined(flatNavigationMenuItem.folderUniversalIdentifier)) { const circularDependencyErrors = @@ -257,6 +156,8 @@ export class FlatNavigationMenuItemValidatorService { const validationResult = getEmptyFlatEntityValidationError({ flatEntityMinimalInformation: { universalIdentifier: flatEntityToValidate.universalIdentifier, + name: flatEntityToValidate.name, + type: flatEntityToValidate.type, }, metadataName: 'navigationMenuItem', type: 'delete', @@ -295,6 +196,8 @@ export class FlatNavigationMenuItemValidatorService { const validationResult = getEmptyFlatEntityValidationError({ flatEntityMinimalInformation: { universalIdentifier, + name: flatEntityUpdate.name ?? fromFlatNavigationMenuItem?.name, + type: flatEntityUpdate.type ?? fromFlatNavigationMenuItem?.type, }, metadataName: 'navigationMenuItem', type: 'update', @@ -325,23 +228,11 @@ export class FlatNavigationMenuItemValidatorService { ...flatEntityUpdate, }; - const nameUpdate = flatEntityUpdate.name; - - const typeValidationErrors = this.validateNavigationMenuItemType({ - type: toFlatNavigationMenuItem.type, - hasTargetRecordId: isDefined(toFlatNavigationMenuItem.targetRecordId), - hasTargetObjectMetadataId: isDefined( - toFlatNavigationMenuItem.targetObjectMetadataUniversalIdentifier, - ), - hasViewId: isDefined(toFlatNavigationMenuItem.viewUniversalIdentifier), - hasLink: isNonEmptyString((toFlatNavigationMenuItem.link ?? '').trim()), - hasPageLayoutId: isDefined( - toFlatNavigationMenuItem.pageLayoutUniversalIdentifier, - ), - name: isDefined(nameUpdate) ? nameUpdate : toFlatNavigationMenuItem.name, - }); - - validationResult.errors.push(...typeValidationErrors); + validationResult.errors.push( + ...validateNavigationMenuItemTypeRequiredProperties({ + flatNavigationMenuItem: toFlatNavigationMenuItem, + }), + ); const folderUniversalIdentifierUpdate = flatEntityUpdate.folderUniversalIdentifier; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-navigation-menu-item-validation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-navigation-menu-item-validation.integration-spec.ts.snap new file mode 100644 index 0000000000..6f577a6437 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-navigation-menu-item-validation.integration-spec.ts.snap @@ -0,0 +1,334 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Sync application should fail on invalid navigation menu items when syncing a FOLDER item with a blank name 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "A name is required for FOLDER type", + "userFriendlyMessage": "A name is required for FOLDER type", + }, + ], + "flatEntityMinimalInformation": { + "name": " ", + "type": "FOLDER", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "A name is required for FOLDER type", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on invalid navigation menu items when syncing a FOLDER item without name 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "A name is required for FOLDER type", + "userFriendlyMessage": "A name is required for FOLDER type", + }, + ], + "flatEntityMinimalInformation": { + "name": null, + "type": "FOLDER", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "A name is required for FOLDER type", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on invalid navigation menu items when syncing a LINK item with a link that is not a valid url 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "A valid link is required for LINK type", + "userFriendlyMessage": "A valid link is required for LINK type", + }, + ], + "flatEntityMinimalInformation": { + "name": "Link with invalid url", + "type": "LINK", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "A valid link is required for LINK type", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on invalid navigation menu items when syncing a LINK item without link 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "A valid link is required for LINK type", + "userFriendlyMessage": "A valid link is required for LINK type", + }, + ], + "flatEntityMinimalInformation": { + "name": "Link without url", + "type": "LINK", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "A valid link is required for LINK type", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on invalid navigation menu items when syncing a PAGE_LAYOUT item with a pageLayoutUniversalIdentifier that is not a uuid 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type", + "userFriendlyMessage": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type", + }, + ], + "flatEntityMinimalInformation": { + "name": "Page layout with malformed identifier", + "type": "PAGE_LAYOUT", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on invalid navigation menu items when syncing a VIEW item with a viewUniversalIdentifier that is not a uuid 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "A valid viewUniversalIdentifier is required for VIEW type", + "userFriendlyMessage": "A valid viewUniversalIdentifier is required for VIEW type", + }, + ], + "flatEntityMinimalInformation": { + "name": "View with malformed identifier", + "type": "VIEW", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "A valid viewUniversalIdentifier is required for VIEW type", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on invalid navigation menu items when syncing an OBJECT item with a targetObjectUniversalIdentifier that is not a uuid 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type", + "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type", + }, + ], + "flatEntityMinimalInformation": { + "name": "Object with malformed target", + "type": "OBJECT", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on invalid navigation menu items when syncing an OBJECT item without targetObjectUniversalIdentifier 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type", + "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type", + }, + ], + "flatEntityMinimalInformation": { + "name": "Object without target", + "type": "OBJECT", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on invalid navigation menu items when syncing an item with an unknown type 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE", + "userFriendlyMessage": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE", + }, + ], + "flatEntityMinimalInformation": { + "name": "Item with unknown type", + "type": "UNKNOWN_NAVIGATION_MENU_ITEM_TYPE", + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-navigation-menu-item-validation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-navigation-menu-item-validation.integration-spec.ts new file mode 100644 index 0000000000..91c3ac2394 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-navigation-menu-item-validation.integration-spec.ts @@ -0,0 +1,166 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { type NavigationMenuItemManifest } from 'twenty-shared/application'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; +import { NavigationMenuItemType } from 'twenty-shared/types'; + +const TEST_APP_ID = 'c1b2c3d4-0001-4000-a000-000000000001'; +const TEST_ROLE_ID = 'c1b2c3d4-0002-4000-a000-000000000002'; +const TEST_NAVIGATION_MENU_ITEM_ID = 'c1b2c3d4-0003-4000-a000-000000000003'; + +type TestContext = { + navigationMenuItem: NavigationMenuItemManifest; +}; + +const failingNavigationMenuItemSyncTestCases: EachTestingContext[] = + [ + { + title: 'when syncing a FOLDER item without name', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: NavigationMenuItemType.FOLDER, + position: 0, + icon: 'IconFolder', + }, + }, + }, + { + title: 'when syncing a FOLDER item with a blank name', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: NavigationMenuItemType.FOLDER, + position: 0, + name: ' ', + }, + }, + }, + { + title: 'when syncing a LINK item without link', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: NavigationMenuItemType.LINK, + position: 0, + name: 'Link without url', + }, + }, + }, + { + title: 'when syncing a LINK item with a link that is not a valid url', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: NavigationMenuItemType.LINK, + position: 0, + name: 'Link with invalid url', + link: 'not a link', + }, + }, + }, + { + title: + 'when syncing an OBJECT item without targetObjectUniversalIdentifier', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: NavigationMenuItemType.OBJECT, + position: 0, + name: 'Object without target', + }, + }, + }, + { + title: + 'when syncing an OBJECT item with a targetObjectUniversalIdentifier that is not a uuid', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: NavigationMenuItemType.OBJECT, + position: 0, + name: 'Object with malformed target', + targetObjectUniversalIdentifier: 'not-a-uuid', + }, + }, + }, + { + title: + 'when syncing a VIEW item with a viewUniversalIdentifier that is not a uuid', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: NavigationMenuItemType.VIEW, + position: 0, + name: 'View with malformed identifier', + viewUniversalIdentifier: 'not-a-uuid', + }, + }, + }, + { + title: + 'when syncing a PAGE_LAYOUT item with a pageLayoutUniversalIdentifier that is not a uuid', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: NavigationMenuItemType.PAGE_LAYOUT, + position: 0, + name: 'Page layout with malformed identifier', + pageLayoutUniversalIdentifier: 'not-a-uuid', + }, + }, + }, + { + title: 'when syncing an item with an unknown type', + context: { + navigationMenuItem: { + universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID, + type: 'UNKNOWN_NAVIGATION_MENU_ITEM_TYPE' as NavigationMenuItemType, + position: 0, + name: 'Item with unknown type', + }, + }, + }, + ]; + +describe('Sync application should fail on invalid navigation menu items', () => { + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Invalid Navigation Menu Item App', + description: 'App for testing navigation menu item manifest validation', + sourcePath: 'test-invalid-navigation-menu-item', + }); + }, 60000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it.each(eachTestingContextFilter(failingNavigationMenuItemSyncTestCases))( + '$title', + async ({ context }) => { + const { errors } = await syncApplication({ + manifest: buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + navigationMenuItems: [context.navigationMenuItem], + }, + }), + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }, + 60000, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap index 7cac66f006..cbe9f7791a 100644 --- a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`NavigationMenuItem creation should fail when creating with empty targetObjectMetadataId 1`] = ` { @@ -100,11 +100,13 @@ exports[`NavigationMenuItem creation should fail when creating with missing targ "errors": [ { "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", - "message": "targetRecordId and targetObjectMetadataId are required for RECORD type", - "userFriendlyMessage": "targetRecordId and targetObjectMetadataId are required for RECORD type", + "message": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type", + "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type", }, ], "flatEntityMinimalInformation": { + "name": null, + "type": "RECORD", "universalIdentifier": Any, }, "metadataName": "navigationMenuItem", @@ -118,7 +120,7 @@ exports[`NavigationMenuItem creation should fail when creating with missing targ "navigationMenuItem": 1, "totalErrors": 1, }, - "userFriendlyMessage": "targetRecordId and targetObjectMetadataId are required for RECORD type", + "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type", }, "message": "Multiple validation errors occurred while creating navigation menu items", "name": "GraphQLError", diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap index 3c58e5d86a..8529db571b 100644 --- a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Navigation Menu Item update should fail with circular dependency when folderId equals id (self-reference) 1`] = ` { @@ -15,6 +15,8 @@ exports[`Navigation Menu Item update should fail with circular dependency when f }, ], "flatEntityMinimalInformation": { + "name": "Standalone Folder", + "type": "FOLDER", "universalIdentifier": Any, }, "metadataName": "navigationMenuItem", @@ -50,6 +52,8 @@ exports[`Navigation Menu Item update should fail with circular dependency when u }, ], "flatEntityMinimalInformation": { + "name": "Parent Folder", + "type": "FOLDER", "universalIdentifier": Any, }, "metadataName": "navigationMenuItem", diff --git a/packages/twenty-shared/src/types/AssertUnreachable.type.ts b/packages/twenty-shared/src/types/AssertUnreachable.type.ts new file mode 100644 index 0000000000..f9237cf178 --- /dev/null +++ b/packages/twenty-shared/src/types/AssertUnreachable.type.ts @@ -0,0 +1,3 @@ +// Type level counterpart of assertUnreachable: fails to compile when a switch +// statement does not handle every case, without any runtime behavior +export type AssertUnreachable = T; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 07564353b4..02307eb900 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -15,6 +15,7 @@ export { AppBasePath } from './AppBasePath'; export { AppPath } from './AppPath'; export type { Arrayable } from './Arrayable'; export type { ArraySortDirection } from './ArraySortDirection'; +export type { AssertUnreachable } from './AssertUnreachable.type'; export { CalendarChannelContactAutoCreationPolicy } from './CalendarChannelContactAutoCreationPolicy'; export { CalendarChannelSyncStage } from './CalendarChannelSyncStage'; export { CalendarChannelSyncStatus } from './CalendarChannelSyncStatus';