import { createTestMigrationContext, initDbUpToMigration, runSingleMigration, undoLastSingleMigration, type TestMigrationContext, } from '@n8n/backend-test-utils'; import { DbConnection } from '@n8n/db'; import { Container } from '@n8n/di'; import { DataSource } from '@n8n/typeorm'; import { nanoid } from 'nanoid'; const MIGRATION_NAME = 'ChangeWorkflowStatisticsFKToNoAction1767018516000'; /** * Generate parameter placeholders for a given context and count. * PostgreSQL uses $1, $2, ... while SQLite use ? */ function getParamPlaceholders(context: TestMigrationContext, count: number): string { if (context.isPostgres) { return Array.from({ length: count }, (_, i) => `$${i + 1}`).join(', '); } return Array.from({ length: count }, () => '?').join(', '); } /** * Generate a single parameter placeholder for WHERE clauses */ function getParamPlaceholder(context: TestMigrationContext, index = 1): string { return context.isPostgres ? `$${index}` : '?'; } describe('ChangeWorkflowStatisticsFKToNoAction Migration', () => { let dataSource: DataSource; beforeAll(async () => { // Initialize DB connection const dbConnection = Container.get(DbConnection); await dbConnection.init(); dataSource = Container.get(DataSource); }); beforeEach(async () => { // Clear database before each test to ensure isolation // Note: Migration tests must run sequentially (maxWorkers: 1) to avoid conflicts const context = createTestMigrationContext(dataSource); await context.queryRunner.clearDatabase(); await context.queryRunner.release(); // Run migrations up to (but not including) target migration await initDbUpToMigration(MIGRATION_NAME); }); afterAll(async () => { const dbConnection = Container.get(DbConnection); await dbConnection.close(); }); /** * Helper function to insert a test workflow */ async function insertTestWorkflow( context: TestMigrationContext, workflowId: string, workflowName = 'Test Workflow', ): Promise { const tableName = context.escape.tableName('workflow_entity'); const idColumn = context.escape.columnName('id'); const nameColumn = context.escape.columnName('name'); const activeColumn = context.escape.columnName('active'); const nodesColumn = context.escape.columnName('nodes'); const connectionsColumn = context.escape.columnName('connections'); const createdAtColumn = context.escape.columnName('createdAt'); const updatedAtColumn = context.escape.columnName('updatedAt'); const triggerCountColumn = context.escape.columnName('triggerCount'); const versionIdColumn = context.escape.columnName('versionId'); const versionId = nanoid(); await context.runQuery( `INSERT INTO ${tableName} (${idColumn}, ${nameColumn}, ${activeColumn}, ${nodesColumn}, ${connectionsColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${triggerCountColumn}, ${versionIdColumn}) VALUES (:id, :name, :active, :nodes, :connections, :createdAt, :updatedAt, :triggerCount, :versionId)`, { id: workflowId, name: workflowName, active: false, nodes: '[]', connections: '{}', createdAt: new Date(), updatedAt: new Date(), triggerCount: 0, versionId, }, ); } /** * Helper function to insert workflow statistics (before migration, without workflowName) */ async function insertWorkflowStatistics( context: TestMigrationContext, workflowId: string, name: string, count: number, ): Promise { const tableName = context.escape.tableName('workflow_statistics'); const workflowIdColumn = context.escape.columnName('workflowId'); const nameColumn = context.escape.columnName('name'); const countColumn = context.escape.columnName('count'); const latestEventColumn = context.escape.columnName('latestEvent'); const rootCountColumn = context.escape.columnName('rootCount'); await context.runQuery( `INSERT INTO ${tableName} (${workflowIdColumn}, ${nameColumn}, ${countColumn}, ${latestEventColumn}, ${rootCountColumn}) VALUES (:workflowId, :name, :count, :latestEvent, :rootCount)`, { workflowId, name, count, latestEvent: new Date(), rootCount: 0 }, ); } /** * Helper function to insert workflow statistics (after migration, with workflowName) */ async function insertWorkflowStatisticsWithName( context: TestMigrationContext, workflowId: string, name: string, count: number, workflowName: string, ): Promise { const tableName = context.escape.tableName('workflow_statistics'); const workflowIdColumn = context.escape.columnName('workflowId'); const nameColumn = context.escape.columnName('name'); const countColumn = context.escape.columnName('count'); const latestEventColumn = context.escape.columnName('latestEvent'); const rootCountColumn = context.escape.columnName('rootCount'); const workflowNameColumn = context.escape.columnName('workflowName'); await context.runQuery( `INSERT INTO ${tableName} (${workflowIdColumn}, ${nameColumn}, ${countColumn}, ${latestEventColumn}, ${rootCountColumn}, ${workflowNameColumn}) VALUES (:workflowId, :name, :count, :latestEvent, :rootCount, :workflowName)`, { workflowId, name, count, latestEvent: new Date(), rootCount: 0, workflowName }, ); } /** * Helper function to get workflow statistics by workflowId */ async function getWorkflowStatistics( context: TestMigrationContext, workflowId: string, ): Promise< Array<{ workflowId: string; name: string; count: number; workflowName?: string | null }> > { const tableName = context.escape.tableName('workflow_statistics'); const workflowIdColumn = context.escape.columnName('workflowId'); const nameColumn = context.escape.columnName('name'); const countColumn = context.escape.columnName('count'); // Cast count to integer for consistent return type const countSelect = context.isPostgres ? `${countColumn}::int` : countColumn; return await context.runQuery( `SELECT ${workflowIdColumn}, ${nameColumn}, ${countSelect} as ${countColumn} FROM ${tableName} WHERE ${workflowIdColumn} = :workflowId`, { workflowId }, ); } /** * Helper function to get workflow statistics by workflowId (after migration, includes workflowName) */ async function getWorkflowStatisticsWithName( context: TestMigrationContext, workflowId: string, ): Promise< Array<{ workflowId: string; name: string; count: number; workflowName: string | null }> > { const tableName = context.escape.tableName('workflow_statistics'); const workflowIdColumn = context.escape.columnName('workflowId'); const nameColumn = context.escape.columnName('name'); const countColumn = context.escape.columnName('count'); const workflowNameColumn = context.escape.columnName('workflowName'); // Cast count to integer for consistent return type const countSelect = context.isPostgres ? `${countColumn}::int` : countColumn; return await context.runQuery( `SELECT ${workflowIdColumn}, ${nameColumn}, ${countSelect} as ${countColumn}, ${workflowNameColumn} FROM ${tableName} WHERE ${workflowIdColumn} = :workflowId`, { workflowId }, ); } /** * Helper function to get workflow statistics by name (for finding orphaned rows) */ async function getWorkflowStatisticsByName( context: TestMigrationContext, name: string, ): Promise< Array<{ workflowId: string | null; name: string; count: number; workflowName: string | null }> > { const tableName = context.escape.tableName('workflow_statistics'); const workflowIdColumn = context.escape.columnName('workflowId'); const nameColumn = context.escape.columnName('name'); const countColumn = context.escape.columnName('count'); const workflowNameColumn = context.escape.columnName('workflowName'); // Cast count to integer for consistent return type const countSelect = context.isPostgres ? `${countColumn}::int` : countColumn; return await context.runQuery( `SELECT ${workflowIdColumn}, ${nameColumn}, ${countSelect} as ${countColumn}, ${workflowNameColumn} FROM ${tableName} WHERE ${nameColumn} = :name`, { name }, ); } /** * Helper function to delete a workflow */ async function deleteWorkflow(context: TestMigrationContext, workflowId: string): Promise { const tableName = context.escape.tableName('workflow_entity'); const idColumn = context.escape.columnName('id'); const placeholder = getParamPlaceholder(context); await context.queryRunner.query(`DELETE FROM ${tableName} WHERE ${idColumn} = ${placeholder}`, [ workflowId, ]); } it('should preserve statistics after workflow deletion (no FK constraint) and CASCADE after rollback', async () => { // Debug: Check schema BEFORE migration const contextBefore = createTestMigrationContext(dataSource); await contextBefore.queryRunner.release(); // Run the actual migration await runSingleMigration(MIGRATION_NAME); // Create context AFTER migration runs (since runSingleMigration reinitializes the connection) dataSource = Container.get(DataSource); let context = createTestMigrationContext(dataSource); // Test that statistics are preserved when workflow is deleted (no FK constraint behavior) const testWorkflowId = nanoid(); const uniqueStatName = `test_stat_${nanoid()}`; const testWorkflowName = 'My Test Workflow'; await insertTestWorkflow(context, testWorkflowId, testWorkflowName); await insertWorkflowStatisticsWithName( context, testWorkflowId, uniqueStatName, 3, testWorkflowName, ); // Verify statistics exist const beforeDelete = await getWorkflowStatistics(context, testWorkflowId); expect(beforeDelete).toHaveLength(1); expect(beforeDelete[0].workflowId).toBe(testWorkflowId); // Delete workflow - statistics should remain unchanged (no FK constraint) await deleteWorkflow(context, testWorkflowId); // Verify statistics still exist with the same workflowId (orphaned reference) // workflowName should be preserved for identifying the deleted workflow const afterDelete = await getWorkflowStatisticsByName(context, uniqueStatName); expect(afterDelete).toHaveLength(1); expect(afterDelete[0].workflowId).toBe(testWorkflowId); // workflowId is unchanged expect(afterDelete[0].count).toBe(3); expect(afterDelete[0].workflowName).toBe(testWorkflowName); // Cleanup statistics for this test const statsTable = context.escape.tableName('workflow_statistics'); const nameCol = context.escape.columnName('name'); const placeholder = getParamPlaceholder(context); await context.queryRunner.query(`DELETE FROM ${statsTable} WHERE ${nameCol} = ${placeholder}`, [ uniqueStatName, ]); await context.queryRunner.release(); // Rollback the migration await undoLastSingleMigration(); // Create new context after rollback dataSource = Container.get(DataSource); context = createTestMigrationContext(dataSource); // Test CASCADE behavior after rollback const cascadeWorkflowId = nanoid(); await insertTestWorkflow(context, cascadeWorkflowId); await insertWorkflowStatistics(context, cascadeWorkflowId, 'manual_success', 2); // Verify statistics exist const beforeCascadeDelete = await getWorkflowStatistics(context, cascadeWorkflowId); expect(beforeCascadeDelete).toHaveLength(1); // Delete workflow - should CASCADE await deleteWorkflow(context, cascadeWorkflowId); // Verify statistics were deleted const afterCascadeDelete = await getWorkflowStatistics(context, cascadeWorkflowId); expect(afterCascadeDelete).toHaveLength(0); await context.queryRunner.release(); }); it('should preserve statistics with different workflowIds after deleting multiple workflows', async () => { // Run the actual migration await runSingleMigration(MIGRATION_NAME); // Create context AFTER migration runs dataSource = Container.get(DataSource); const context = createTestMigrationContext(dataSource); // Create multiple workflows with the same statistic name but different workflow names const workflowId1 = nanoid(); const workflowId2 = nanoid(); const workflowId3 = nanoid(); const workflowName1 = 'Workflow Alpha'; const workflowName2 = 'Workflow Beta'; const workflowName3 = 'Workflow Gamma'; await insertTestWorkflow(context, workflowId1, workflowName1); await insertTestWorkflow(context, workflowId2, workflowName2); await insertTestWorkflow(context, workflowId3, workflowName3); // All workflows have 'manual_success' statistics (inserted after migration with workflowName) await insertWorkflowStatisticsWithName( context, workflowId1, 'manual_success', 10, workflowName1, ); await insertWorkflowStatisticsWithName( context, workflowId2, 'manual_success', 20, workflowName2, ); await insertWorkflowStatisticsWithName( context, workflowId3, 'manual_success', 30, workflowName3, ); // Delete all workflows - statistics should remain unchanged (no FK constraint) await deleteWorkflow(context, workflowId1); await deleteWorkflow(context, workflowId2); await deleteWorkflow(context, workflowId3); // Verify all statistics still exist with their original workflowId values (orphaned references) const orphanedStats = await getWorkflowStatisticsByName(context, 'manual_success'); expect(orphanedStats.length).toBeGreaterThanOrEqual(3); // Find our specific test statistics by workflowId const stat1 = orphanedStats.find((s) => s.workflowId === workflowId1); const stat2 = orphanedStats.find((s) => s.workflowId === workflowId2); const stat3 = orphanedStats.find((s) => s.workflowId === workflowId3); // Verify workflowIds are preserved (not set to NULL) expect(stat1).toBeDefined(); expect(stat1?.workflowId).toBe(workflowId1); expect(stat1?.count).toBe(10); expect(stat1?.workflowName).toBe(workflowName1); expect(stat2).toBeDefined(); expect(stat2?.workflowId).toBe(workflowId2); expect(stat2?.count).toBe(20); expect(stat2?.workflowName).toBe(workflowName2); expect(stat3).toBeDefined(); expect(stat3?.workflowId).toBe(workflowId3); expect(stat3?.count).toBe(30); expect(stat3?.workflowName).toBe(workflowName3); // Cleanup - delete our test statistics const statsTable = context.escape.tableName('workflow_statistics'); const workflowIdCol = context.escape.columnName('workflowId'); const placeholder = getParamPlaceholder(context); await context.queryRunner.query( `DELETE FROM ${statsTable} WHERE ${workflowIdCol} IN (${placeholder}, ${getParamPlaceholder(context, 2)}, ${getParamPlaceholder(context, 3)})`, [workflowId1, workflowId2, workflowId3], ); await context.queryRunner.release(); // Rollback for next test await undoLastSingleMigration(); }); it('should preserve existing workflow statistics data and populate workflowName during migration', async () => { // The database is already in pre-migration state from the previous test's rollback let context = createTestMigrationContext(dataSource); const workflowId = nanoid(); const testWorkflowName = 'Statistics Test Workflow'; // Create workflow and statistics before migration await insertTestWorkflow(context, workflowId, testWorkflowName); await insertWorkflowStatistics(context, workflowId, 'manual_success', 100); await insertWorkflowStatistics(context, workflowId, 'production_success', 200); await insertWorkflowStatistics(context, workflowId, 'production_error', 5); // Verify initial data (workflowName column doesn't exist yet) const beforeMigration = await getWorkflowStatistics(context, workflowId); expect(beforeMigration).toHaveLength(3); await context.queryRunner.release(); // Run the actual migration await runSingleMigration(MIGRATION_NAME); // Create new context after migration (since runSingleMigration reinitializes the connection) dataSource = Container.get(DataSource); context = createTestMigrationContext(dataSource); // Verify data is preserved after migration and workflowName is populated const afterMigration = await getWorkflowStatisticsWithName(context, workflowId); expect(afterMigration).toHaveLength(3); // Verify specific values including workflowName const manualSuccess = afterMigration.find((s) => s.name === 'manual_success'); const productionSuccess = afterMigration.find((s) => s.name === 'production_success'); const productionError = afterMigration.find((s) => s.name === 'production_error'); expect(manualSuccess?.count).toBe(100); expect(manualSuccess?.workflowName).toBe(testWorkflowName); expect(productionSuccess?.count).toBe(200); expect(productionSuccess?.workflowName).toBe(testWorkflowName); expect(productionError?.count).toBe(5); expect(productionError?.workflowName).toBe(testWorkflowName); // Cleanup - delete statistics first (they have FK), then workflow const statsTable = context.escape.tableName('workflow_statistics'); const workflowIdCol = context.escape.columnName('workflowId'); const placeholder = getParamPlaceholder(context); await context.queryRunner.query( `DELETE FROM ${statsTable} WHERE ${workflowIdCol} = ${placeholder}`, [workflowId], ); await deleteWorkflow(context, workflowId); await context.queryRunner.release(); }); it('should delete orphaned statistics during rollback before restoring FK constraint', async () => { // Run the migration first (beforeEach only runs up to, not including, the target migration) await runSingleMigration(MIGRATION_NAME); // Create context AFTER migration runs dataSource = Container.get(DataSource); let context = createTestMigrationContext(dataSource); // Create workflows with statistics using unique stat names to avoid conflicts const orphanedWorkflowId1 = nanoid(); const orphanedWorkflowId2 = nanoid(); const keepWorkflowId = nanoid(); const uniqueStatName1 = `orphan_stat_1_${nanoid()}`; const uniqueStatName2 = `orphan_stat_2_${nanoid()}`; const uniqueStatName3 = `keep_stat_${nanoid()}`; await insertTestWorkflow(context, orphanedWorkflowId1, 'Workflow to Delete 1'); await insertTestWorkflow(context, orphanedWorkflowId2, 'Workflow to Delete 2'); await insertTestWorkflow(context, keepWorkflowId, 'Workflow to Keep'); await insertWorkflowStatisticsWithName( context, orphanedWorkflowId1, uniqueStatName1, 100, 'Workflow to Delete 1', ); await insertWorkflowStatisticsWithName( context, orphanedWorkflowId2, uniqueStatName2, 200, 'Workflow to Delete 2', ); await insertWorkflowStatisticsWithName( context, keepWorkflowId, uniqueStatName3, 300, 'Workflow to Keep', ); // Delete some workflows to create orphaned statistics await deleteWorkflow(context, orphanedWorkflowId1); await deleteWorkflow(context, orphanedWorkflowId2); // Verify orphaned statistics still exist after workflow deletion (no FK constraint) const beforeRollback1 = await getWorkflowStatisticsByName(context, uniqueStatName1); expect(beforeRollback1).toHaveLength(1); expect(beforeRollback1[0].workflowId).toBe(orphanedWorkflowId1); const beforeRollback2 = await getWorkflowStatisticsByName(context, uniqueStatName2); expect(beforeRollback2).toHaveLength(1); expect(beforeRollback2[0].workflowId).toBe(orphanedWorkflowId2); // Verify non-orphaned statistics still exist const beforeRollbackKeep = await getWorkflowStatistics(context, keepWorkflowId); expect(beforeRollbackKeep).toHaveLength(1); await context.queryRunner.release(); // Rollback the migration - orphaned statistics should be deleted await undoLastSingleMigration(); // Create new context after rollback dataSource = Container.get(DataSource); context = createTestMigrationContext(dataSource); // Verify orphaned statistics were deleted during rollback (workflowName column no longer exists) const statsTable = context.escape.tableName('workflow_statistics'); const nameCol = context.escape.columnName('name'); const workflowIdCol = context.escape.columnName('workflowId'); const placeholder = getParamPlaceholder(context); const afterRollback1 = await context.queryRunner.query( `SELECT ${workflowIdCol} as "workflowId" FROM ${statsTable} WHERE ${nameCol} = ${placeholder}`, [uniqueStatName1], ); expect(afterRollback1).toHaveLength(0); const afterRollback2 = await context.queryRunner.query( `SELECT ${workflowIdCol} as "workflowId" FROM ${statsTable} WHERE ${nameCol} = ${placeholder}`, [uniqueStatName2], ); expect(afterRollback2).toHaveLength(0); // Verify non-orphaned statistics still exist after rollback const afterRollbackKeep = await getWorkflowStatistics(context, keepWorkflowId); expect(afterRollbackKeep).toHaveLength(1); expect(afterRollbackKeep[0].workflowId).toBe(keepWorkflowId); expect(afterRollbackKeep[0].count).toBe(300); // Cleanup - reuse the existing variable declarations await context.queryRunner.query( `DELETE FROM ${statsTable} WHERE ${workflowIdCol} = ${placeholder}`, [keepWorkflowId], ); await deleteWorkflow(context, keepWorkflowId); await context.queryRunner.release(); }); it('should reset overflowing values to 0 during rollback before converting BIGINT to INTEGER (PostgreSQL only)', async () => { // Run the migration to enable BIGINT columns await runSingleMigration(MIGRATION_NAME); // Create context AFTER migration runs dataSource = Container.get(DataSource); let context = createTestMigrationContext(dataSource); // Skip this test for SQLite - SQLite handles INTEGER overflow differently if (!context.isPostgres) { await context.queryRunner.release(); return; } const workflowId = nanoid(); const testWorkflowName = 'Overflow Test Workflow'; const uniqueStatName = `overflow_stat_${nanoid()}`; await insertTestWorkflow(context, workflowId, testWorkflowName); // Insert statistics with values exceeding INTEGER max (2147483647) const tableName = context.escape.tableName('workflow_statistics'); const workflowIdColumn = context.escape.columnName('workflowId'); const nameColumn = context.escape.columnName('name'); const countColumn = context.escape.columnName('count'); const rootCountColumn = context.escape.columnName('rootCount'); const latestEventColumn = context.escape.columnName('latestEvent'); const workflowNameColumn = context.escape.columnName('workflowName'); const overflowValue = '3000000000'; // Exceeds INTEGER max const placeholders = getParamPlaceholders(context, 6); await context.queryRunner.query( `INSERT INTO ${tableName} (${workflowIdColumn}, ${nameColumn}, ${countColumn}, ${rootCountColumn}, ${latestEventColumn}, ${workflowNameColumn}) VALUES (${placeholders})`, [workflowId, uniqueStatName, overflowValue, overflowValue, new Date(), testWorkflowName], ); // Verify the large values were inserted const placeholder = getParamPlaceholder(context); const beforeRollback = await context.queryRunner.query( `SELECT ${countColumn} as "count", ${rootCountColumn} as "rootCount" FROM ${tableName} WHERE ${nameColumn} = ${placeholder}`, [uniqueStatName], ); expect(beforeRollback).toHaveLength(1); expect(Number(beforeRollback[0].count)).toBeGreaterThan(2147483647); expect(Number(beforeRollback[0].rootCount)).toBeGreaterThan(2147483647); await context.queryRunner.release(); // Rollback the migration - should reset overflowing values to 0 on PostgreSQL await undoLastSingleMigration(); // Create new context after rollback dataSource = Container.get(DataSource); context = createTestMigrationContext(dataSource); // Verify values were reset to 0 on PostgreSQL (preventing overflow errors) const afterRollback = await context.queryRunner.query( `SELECT ${countColumn} as "count", ${rootCountColumn} as "rootCount" FROM ${tableName} WHERE ${nameColumn} = ${placeholder}`, [uniqueStatName], ); expect(afterRollback).toHaveLength(1); expect(afterRollback[0].count).toBe(0); expect(afterRollback[0].rootCount).toBe(0); // Cleanup await context.queryRunner.query( `DELETE FROM ${tableName} WHERE ${nameColumn} = ${placeholder}`, [uniqueStatName], ); await deleteWorkflow(context, workflowId); await context.queryRunner.release(); }); });