diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-uninstall-application-logic-function-hook.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-uninstall-application-logic-function-hook.integration-spec.ts new file mode 100644 index 0000000000..cc7ed01d0b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-uninstall-application-logic-function-hook.integration-spec.ts @@ -0,0 +1,183 @@ +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 { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util'; +import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util'; +import { type Manifest } from 'twenty-shared/application'; +import { v4 as uuidv4 } from 'uuid'; + +import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; +import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; + +const buildManifestWithLogicFunction = ({ + appId, + roleId, + logicFunctionId, + withUninstallHook, +}: { + appId: string; + roleId: string; + logicFunctionId: string; + withUninstallHook: boolean; +}): Manifest => { + const baseManifest = buildBaseManifest({ appId, roleId }); + + return { + ...baseManifest, + application: { + ...baseManifest.application, + ...(withUninstallHook + ? { uninstallLogicFunction: { universalIdentifier: logicFunctionId } } + : {}), + }, + logicFunctions: [ + { + universalIdentifier: logicFunctionId, + name: 'Cleanup', + description: 'Uninstall cleanup logic function', + handlerName: 'handler', + sourceHandlerPath: 'src/cleanup.ts', + builtHandlerPath: 'dist/cleanup.mjs', + builtHandlerChecksum: 'checksum-cleanup', + httpRouteTriggerSettings: { + path: '/cleanup', + httpMethod: 'POST', + isAuthRequired: true, + }, + }, + ], + }; +}; + +describe('Uninstall application logic function hook', () => { + let appId: string; + let roleId: string; + let logicFunctionId: string; + let executeSpy: jest.SpyInstance; + + beforeEach(async () => { + appId = uuidv4(); + roleId = uuidv4(); + logicFunctionId = uuidv4(); + + await setupApplicationForSync({ + applicationUniversalIdentifier: appId, + name: 'Test Application', + description: 'App for testing the uninstall logic function hook', + sourcePath: 'test-uninstall-hook', + }); + + const logicFunctionExecutorService = + getAppProviderByClassName( + 'LogicFunctionExecutorService', + ); + + executeSpy = jest + .spyOn(logicFunctionExecutorService, 'execute') + .mockResolvedValue({ + data: {}, + duration: 1, + logs: '', + status: LogicFunctionExecutionStatus.SUCCESS, + }); + }, 60000); + + afterEach(async () => { + executeSpy.mockRestore(); + + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: appId, + }); + }); + + it('executes the uninstall hook before deleting the application', async () => { + await syncApplication({ + manifest: buildManifestWithLogicFunction({ + appId, + roleId, + logicFunctionId, + withUninstallHook: true, + }), + expectToFail: false, + }); + + const { data, errors } = await uninstallApplication({ + universalIdentifier: appId, + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(data?.uninstallApplication).toBe(true); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { version: '1.0.0' }, + logicFunctionId: expect.any(String), + }), + ); + }, 60000); + + it('does not execute any hook when the manifest declares no uninstall logic function', async () => { + await syncApplication({ + manifest: buildManifestWithLogicFunction({ + appId, + roleId, + logicFunctionId, + withUninstallHook: false, + }), + expectToFail: false, + }); + + const { data, errors } = await uninstallApplication({ + universalIdentifier: appId, + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(data?.uninstallApplication).toBe(true); + + expect(executeSpy).not.toHaveBeenCalled(); + }, 60000); + + it('uninstalls the application even when the uninstall hook returns an error (best-effort)', async () => { + executeSpy.mockResolvedValue({ + data: null, + duration: 1, + logs: '', + status: LogicFunctionExecutionStatus.ERROR, + error: { + errorType: 'UnhandledError', + errorMessage: 'Uninstall hook failed on purpose', + stackTrace: '', + }, + }); + + await syncApplication({ + manifest: buildManifestWithLogicFunction({ + appId, + roleId, + logicFunctionId, + withUninstallHook: true, + }), + expectToFail: false, + }); + + const { data, errors } = await uninstallApplication({ + universalIdentifier: appId, + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(data?.uninstallApplication).toBe(true); + expect(executeSpy).toHaveBeenCalledTimes(1); + + const applicationsAfterUninstall = await globalThis.testDataSource.query( + `SELECT id FROM core."application" WHERE "universalIdentifier" = $1`, + [appId], + ); + + expect(applicationsAfterUninstall).toHaveLength(0); + }, 60000); +});