Files
twenty/packages/twenty-server/test/integration/metadata/suites/application/successful-uninstall-application-logic-function-hook.integration-spec.ts
T
martmull fdb8865933 test: cover uninstall logic function hook execution (#23249)
Follow-up to #23227 ([review
comment](https://github.com/twentyhq/twenty/pull/23227#pullrequestreview-4771629391)):
adds integration coverage for the `defineUninstallLogicFunction` hook
execution on uninstall.

## What

New integration suite
`successful-uninstall-application-logic-function-hook.integration-spec.ts`
that drives the real `syncApplication` / `uninstallApplication` GraphQL
flow and asserts on the executor wiring:

- When the synced manifest declares an `uninstallLogicFunction`,
uninstalling the application resolves the hook and calls
`LogicFunctionExecutorService.execute` exactly once, before deletion,
with the `{ version }` payload.
- When the manifest declares no uninstall hook, uninstalling the
application does not call the executor.

The executor is spied via the running app container
(`getAppProviderByClassName`) and stubbed to a success result, so the
test verifies the server-side resolution/trigger path deterministically
without depending on the local function runtime.

## Test plan

- `npx jest --config ./jest-integration.config.ts
successful-uninstall-application-logic-function-hook` passes (2/2).
- Typecheck green for twenty-server; oxlint and oxfmt clean on the new
file.


---
_Generated by [Claude
Code](https://claude.ai/code/session_016zJPggkVEw1V7SqnPSiUQx)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23249?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: martmull <martin@twenty.com>
2026-07-24 09:26:04 +00:00

184 lines
5.5 KiB
TypeScript

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>(
'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);
});