n8n/packages/cli/test/migration/1767018516000-change-workflow-statistics-fk-to-no-action.test.ts
2026-02-04 13:29:54 +00:00

620 lines
24 KiB
TypeScript

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