Files
upstream-twenty/packages/twenty-server/test/integration/upgrade/suites/2-40-instance-command-slow-1789139070588-migrate-canvas-tabs-to-vertical-list.integration-spec.ts
upstream-mirror e7da97194a
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 3s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

494 lines
14 KiB
TypeScript

import { isDefined } from 'twenty-shared/utils';
import { DataSource, type QueryRunner } from 'typeorm';
import { v4 } from 'uuid';
import { MigrateCanvasTabsToVerticalListSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-40/2-40-instance-command-slow-1789139070588-migrate-canvas-tabs-to-vertical-list';
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
jest.useRealTimers();
type SeededTab = {
tabId: string;
widgetIds: string[];
};
type TabAndWidgetState = {
layoutMode: string;
overrides: Record<string, unknown> | null;
position: Record<string, unknown> | null;
};
describe('MigrateCanvasTabsToVerticalListSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let queryRunner: QueryRunner;
let command: MigrateCanvasTabsToVerticalListSlowInstanceCommand;
let workspaceId: string;
let applicationId: string;
let pageLayoutId: string;
const workspaceCacheService = { flush: jest.fn() };
const seedTab = async ({
layoutMode,
widgetIsActiveValues,
widgetOverrides = null,
widgetPosition = { layoutMode },
}: {
layoutMode: 'CANVAS' | 'VERTICAL_LIST';
widgetIsActiveValues: boolean[];
widgetOverrides?: Record<string, unknown> | null;
widgetPosition?: Record<string, unknown> | null;
}): Promise<SeededTab> => {
const tabId = v4();
await dataSource.query(
`INSERT INTO "core"."pageLayoutTab" (
"id",
"workspaceId",
"applicationId",
"universalIdentifier",
"pageLayoutId",
"title",
"position",
"layoutMode",
"isActive"
) VALUES ($1, $2, $3, $4, $5, 'Migration test tab', 0, $6, true)`,
[tabId, workspaceId, applicationId, v4(), pageLayoutId, layoutMode],
);
const widgetIds: string[] = [];
for (const [index, isActive] of widgetIsActiveValues.entries()) {
const widgetId = v4();
await dataSource.query(
`INSERT INTO "core"."pageLayoutWidget" (
"id",
"workspaceId",
"applicationId",
"universalIdentifier",
"pageLayoutTabId",
"title",
"type",
"gridPosition",
"position",
"overrides",
"configuration",
"isActive"
) VALUES (
$1,
$2,
$3,
$4,
$5,
'Migration test widget',
'VIEW',
$6,
$7,
$8,
$9,
$10
)`,
[
widgetId,
workspaceId,
applicationId,
v4(),
tabId,
JSON.stringify({ row: index, column: 0, rowSpan: 1, columnSpan: 12 }),
JSON.stringify(widgetPosition),
JSON.stringify(widgetOverrides),
JSON.stringify({ configurationType: 'VIEW' }),
isActive,
],
);
widgetIds.push(widgetId);
}
return { tabId, widgetIds };
};
const readTabAndWidgetState = async ({
tabId,
widgetId,
}: {
tabId: string;
widgetId: string;
}): Promise<TabAndWidgetState> => {
const [state] = (await dataSource.query(
`SELECT tab."layoutMode", widget."position", widget."overrides"
FROM "core"."pageLayoutTab" tab
JOIN "core"."pageLayoutWidget" widget
ON widget."pageLayoutTabId" = tab."id"
WHERE tab."id" = $1 AND widget."id" = $2`,
[tabId, widgetId],
)) as TabAndWidgetState[];
if (!isDefined(state)) {
throw new Error('Seeded page layout tab or widget was not found');
}
return state;
};
const readTabLayoutMode = async (tabId: string): Promise<string> => {
const [state] = (await dataSource.query(
`SELECT "layoutMode"
FROM "core"."pageLayoutTab"
WHERE "id" = $1`,
[tabId],
)) as { layoutMode: string }[];
if (!isDefined(state)) {
throw new Error('Seeded page layout tab was not found');
}
return state.layoutMode;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
command = new MigrateCanvasTabsToVerticalListSlowInstanceCommand(
workspaceCacheService as unknown as WorkspaceCacheService,
);
const [source] = (await dataSource.query(
`SELECT "id" AS "workspaceId", "workspaceCustomApplicationId" AS "applicationId"
FROM "core"."workspace"
WHERE "workspaceCustomApplicationId" IS NOT NULL
ORDER BY "id"
LIMIT 1`,
)) as { workspaceId: string; applicationId: string }[];
if (!isDefined(source)) {
throw new Error(
'No seeded workspace found; run database:reset before the integration suite.',
);
}
workspaceId = source.workspaceId;
applicationId = source.applicationId;
}, 30000);
beforeEach(async () => {
workspaceCacheService.flush.mockReset().mockResolvedValue(undefined);
queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
// Roll back changes to seeded layouts along with the test fixtures.
jest
.spyOn(dataSource, 'query')
.mockImplementation((query, parameters) =>
queryRunner.query(query, parameters),
);
pageLayoutId = v4();
await dataSource.query(
`INSERT INTO "core"."pageLayout" (
"id",
"workspaceId",
"applicationId",
"universalIdentifier",
"name",
"type"
) VALUES ($1, $2, $3, $4, 'Canvas migration integration test', 'RECORD_PAGE')`,
[pageLayoutId, workspaceId, applicationId, v4()],
);
});
afterEach(async () => {
jest.restoreAllMocks();
await queryRunner.rollbackTransaction();
await queryRunner.release();
});
afterAll(async () => {
await dataSource?.destroy();
});
it.each([
{ widgetPosition: { layoutMode: 'GRID', row: 2, column: 1 } },
{ widgetPosition: { layoutMode: 'VERTICAL_LIST', index: 4 } },
{
widgetOverrides: { position: { layoutMode: 'GRID', row: 2, column: 1 } },
},
{
widgetOverrides: { position: { layoutMode: 'VERTICAL_LIST', index: 4 } },
},
])('preserves authored non-Canvas positions %j', async (widgetOptions) => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
...widgetOptions,
});
const originalState = await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
});
await command.runDataMigration(dataSource);
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toEqual(originalState);
});
it.each([false, true])(
'preserves outgoing tab overrides with a position override: %s',
async (hasPositionOverride) => {
const destinationTab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
const sourceTab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
widgetOverrides: {
pageLayoutTabId: destinationTab.tabId,
...(hasPositionOverride
? { position: { layoutMode: 'CANVAS' } }
: {}),
},
});
const originalStates = await Promise.all(
[sourceTab, destinationTab].map((tab) =>
readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
),
);
await command.runDataMigration(dataSource);
for (const [index, tab] of [sourceTab, destinationTab].entries()) {
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toEqual(originalStates[index]);
}
},
);
it('retries cache invalidation after the rows have already been migrated', async () => {
const canvasTab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
const cacheError = new Error('Cache unavailable');
workspaceCacheService.flush.mockRejectedValueOnce(cacheError);
await expect(command.runDataMigration(dataSource)).rejects.toThrow(
cacheError,
);
expect(await readTabLayoutMode(canvasTab.tabId)).toBe('VERTICAL_LIST');
workspaceCacheService.flush.mockClear();
await command.runDataMigration(dataSource);
expect(workspaceCacheService.flush).toHaveBeenCalledWith(workspaceId, [
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
]);
expect(
await readTabAndWidgetState({
tabId: canvasTab.tabId,
widgetId: canvasTab.widgetIds[0],
}),
).toMatchObject({
layoutMode: 'VERTICAL_LIST',
position: {
layoutMode: 'VERTICAL_LIST',
index: 0,
heightBehavior: 'TAB_VIEWPORT',
},
});
});
it.each([
null,
{ position: null },
{ position: { layoutMode: 'CANVAS' }, title: 'Custom title' },
])(
'migrates a workspace Canvas tab and preserves unrelated overrides %j',
async (widgetOverrides) => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
widgetPosition: null,
widgetOverrides,
});
const position = {
layoutMode: 'VERTICAL_LIST',
index: 0,
heightBehavior: 'TAB_VIEWPORT',
};
await command.runDataMigration(dataSource);
await command.runDataMigration(dataSource);
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toEqual({
layoutMode: 'VERTICAL_LIST',
position,
overrides: isDefined(widgetOverrides)
? { ...widgetOverrides, position }
: null,
});
},
);
it.each(
[[], [false], [true, true], [true, false]].map((widgetIsActiveValues) => ({
widgetIsActiveValues,
})),
)(
'preserves Canvas tabs without exactly one active, undeleted widget: %j',
async ({ widgetIsActiveValues }) => {
const tab = await seedTab({ layoutMode: 'CANVAS', widgetIsActiveValues });
await command.runDataMigration(dataSource);
expect(await readTabLayoutMode(tab.tabId)).toBe('CANVAS');
},
);
it.each(['pageLayoutTab', 'pageLayoutWidget'] as const)(
'preserves manifest-owned %s and its Canvas position override',
async (entity) => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
widgetOverrides: { position: { layoutMode: 'CANVAS' } },
});
await dataSource.query(
`UPDATE "core"."${entity}" SET "applicationId" = (
SELECT "applicationId" FROM "core"."pageLayout"
WHERE "workspaceId" = $1 AND "applicationId" <> $2 LIMIT 1
) WHERE "id" = $3`,
[
workspaceId,
applicationId,
entity === 'pageLayoutTab' ? tab.tabId : tab.widgetIds[0],
],
);
const originalState = await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
});
await command.runDataMigration(dataSource);
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toEqual(originalState);
},
);
it('preserves edits to an already migrated vertical-list position on retry', async () => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
await command.runDataMigration(dataSource);
const editedPosition = {
layoutMode: 'VERTICAL_LIST',
index: 0,
heightBehavior: 'FIT_CONTENT',
};
await dataSource.query(
`UPDATE "core"."pageLayoutWidget" SET "position" = $1 WHERE "id" = $2`,
[editedPosition, tab.widgetIds[0]],
);
await command.runDataMigration(dataSource);
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toMatchObject({ position: editedPosition });
});
it('ignores deleted widgets when counting active Canvas contents', async () => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true, true],
});
await dataSource.query(
`UPDATE "core"."pageLayoutWidget" SET "deletedAt" = NOW() WHERE "id" = $1`,
[tab.widgetIds[1]],
);
await command.runDataMigration(dataSource);
expect(await readTabLayoutMode(tab.tabId)).toBe('VERTICAL_LIST');
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[1],
}),
).toMatchObject({ position: { layoutMode: 'CANVAS' } });
});
it('preserves deleted Canvas tabs', async () => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
await dataSource.query(
`UPDATE "core"."pageLayoutTab" SET "deletedAt" = NOW() WHERE "id" = $1`,
[tab.tabId],
);
await command.runDataMigration(dataSource);
expect(await readTabLayoutMode(tab.tabId)).toBe('CANVAS');
});
it('preserves tabs receiving an inactive widget through an override', async () => {
const destinationTab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [false],
widgetOverrides: { pageLayoutTabId: destinationTab.tabId },
});
await command.runDataMigration(dataSource);
expect(await readTabLayoutMode(destinationTab.tabId)).toBe('CANVAS');
});
});