/** * @vitest-environment node * * Database Helpers Unit Tests * * Tests for normalized table operations including loading, saving, and migrating * workflow data between JSON blob format and normalized database tables. */ import { createAgentBlock, createApiBlock, createBlock, createEdge, createLoopBlock, createParallelBlock, createStarterBlock, createWorkflowState, } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BlockState as AppBlockState, WorkflowState as AppWorkflowState, } from '@/stores/workflows/workflow/types' /** * Type helper for converting test workflow state to app workflow state. * This is needed because the testing package has slightly different types * for migration testing purposes. */ function asAppState(state: T): AppWorkflowState { return state as unknown as AppWorkflowState } /** * Type helper for converting test blocks to app block state record. */ function asAppBlocks(blocks: T): Record { return blocks as unknown as Record } /** * Type helper for creating subBlocks with legacy types for migration tests. * These tests intentionally use old SubBlockTypes (textarea, select, messages-input, input) * to verify the migration logic converts them to new types. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function legacySubBlocks(subBlocks: Record): any { return subBlocks } const { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows } = vi.hoisted(() => { const mockDb = { select: vi.fn(), insert: vi.fn(), delete: vi.fn(), transaction: vi.fn(), } const mockWorkflowBlocks = { workflowId: 'workflowId', id: 'id', type: 'type', name: 'name', positionX: 'positionX', positionY: 'positionY', enabled: 'enabled', horizontalHandles: 'horizontalHandles', height: 'height', subBlocks: 'subBlocks', outputs: 'outputs', data: 'data', parentId: 'parentId', extent: 'extent', } const mockWorkflowEdges = { workflowId: 'workflowId', id: 'id', sourceBlockId: 'sourceBlockId', targetBlockId: 'targetBlockId', sourceHandle: 'sourceHandle', targetHandle: 'targetHandle', } const mockWorkflowSubflows = { workflowId: 'workflowId', id: 'id', type: 'type', config: 'config', } return { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows } }) vi.mock('@sim/db', () => ({ db: mockDb, runOutsideTransactionContext: (fn: () => T): T => fn(), workflowBlocks: mockWorkflowBlocks, workflowEdges: mockWorkflowEdges, workflowSubflows: mockWorkflowSubflows, workflowDeploymentVersion: { id: 'id', workflowId: 'workflowId', version: 'version', state: 'state', isActive: 'isActive', createdAt: 'createdAt', createdBy: 'createdBy', deployedBy: 'deployedBy', }, workflow: {}, webhook: {}, })) const { mockSanitizeAgentToolsInBlocks } = vi.hoisted(() => ({ mockSanitizeAgentToolsInBlocks: vi.fn(), })) /** * Default identity behavior for the mocked migration step. Re-applied in the * cache describe block's `beforeEach` because the outer `afterEach` calls * `vi.resetAllMocks()`, which clears implementations. */ const sanitizeIdentity = (blocks: unknown) => ({ blocks }) mockSanitizeAgentToolsInBlocks.mockImplementation(sanitizeIdentity) vi.mock('@/lib/workflows/sanitization/validation', () => ({ sanitizeAgentToolsInBlocks: mockSanitizeAgentToolsInBlocks, })) import * as dbHelpers from '@/lib/workflows/persistence/utils' const mockWorkflowId = 'test-workflow-123' /** * Converts a BlockState to a mock database block row format. */ function toDbBlock(block: ReturnType, workflowId: string) { return { id: block.id, workflowId, type: block.type, name: block.name, positionX: block.position.x, positionY: block.position.y, enabled: block.enabled, horizontalHandles: block.horizontalHandles, advancedMode: block.advancedMode ?? false, triggerMode: block.triggerMode ?? false, height: block.height ?? 150, subBlocks: block.subBlocks ?? {}, outputs: block.outputs ?? {}, data: block.data ?? {}, parentId: block.data?.parentId ?? null, extent: block.data?.extent ?? null, } } const mockBlocksFromDb = [ toDbBlock( createStarterBlock({ id: 'block-1', name: 'Start Block', position: { x: 100, y: 100 }, height: 150, subBlocks: { input: { id: 'input', type: 'short-input' as const, value: 'test' } }, outputs: { result: { type: 'string' } }, data: { parentId: undefined, extent: undefined, width: 350 }, }), mockWorkflowId ), toDbBlock( createApiBlock({ id: 'block-2', name: 'API Block', position: { x: 300, y: 100 }, height: 200, parentId: 'loop-1', }), mockWorkflowId ), toDbBlock( createLoopBlock({ id: 'loop-1', name: 'Loop Container', position: { x: 50, y: 50 }, height: 250, data: { width: 500, height: 300, loopType: 'for', count: 5 }, }), mockWorkflowId ), toDbBlock( createParallelBlock({ id: 'parallel-1', name: 'Parallel Container', position: { x: 600, y: 50 }, height: 250, count: 3, data: { width: 500, height: 300, parallelType: 'count', count: 3 }, }), mockWorkflowId ), toDbBlock( createApiBlock({ id: 'block-3', name: 'Parallel Child', position: { x: 650, y: 150 }, height: 200, parentId: 'parallel-1', }), mockWorkflowId ), ] const mockEdgesFromDb = [ { id: 'edge-1', workflowId: mockWorkflowId, sourceBlockId: 'block-1', targetBlockId: 'block-2', sourceHandle: 'output', targetHandle: 'input', }, ] const mockSubflowsFromDb = [ { id: 'loop-1', workflowId: mockWorkflowId, type: 'loop', config: { id: 'loop-1', nodes: ['block-2'], iterations: 5, loopType: 'for', }, }, { id: 'parallel-1', workflowId: mockWorkflowId, type: 'parallel', config: { id: 'parallel-1', nodes: ['block-3'], count: 5, distribution: ['item1', 'item2'], parallelType: 'count', batchSize: 1, }, }, ] const mockWorkflowState = createWorkflowState({ blocks: { 'block-1': createStarterBlock({ id: 'block-1', name: 'Start Block', position: { x: 100, y: 100 }, height: 150, subBlocks: { input: { id: 'input', type: 'short-input' as const, value: 'test' } }, outputs: { result: { type: 'string' } }, data: { width: 350 }, }), 'block-2': createApiBlock({ id: 'block-2', name: 'API Block', position: { x: 300, y: 100 }, height: 200, data: { parentId: 'loop-1', extent: 'parent' }, }), 'loop-1': createLoopBlock({ id: 'loop-1', name: 'Loop Container', position: { x: 200, y: 50 }, height: 250, data: { width: 500, height: 300, count: 5, loopType: 'for' }, }), 'parallel-1': createParallelBlock({ id: 'parallel-1', name: 'Parallel Container', position: { x: 600, y: 50 }, height: 250, count: 3, data: { width: 500, height: 300, parallelType: 'count', count: 3, batchSize: 1 }, }), 'block-3': createApiBlock({ id: 'block-3', name: 'Parallel Child', position: { x: 650, y: 150 }, height: 180, data: { parentId: 'parallel-1', extent: 'parent' }, }), }, edges: [ createEdge({ id: 'edge-1', source: 'block-1', target: 'block-2', sourceHandle: 'output', targetHandle: 'input', }), ], loops: { 'loop-1': { id: 'loop-1', nodes: ['block-2'], iterations: 5, loopType: 'for', }, }, parallels: { 'parallel-1': { id: 'parallel-1', nodes: ['block-3'], distribution: ['item1', 'item2'], parallelType: 'count', batchSize: 1, }, }, }) describe('Database Helpers', () => { beforeEach(() => { vi.clearAllMocks() mockSanitizeAgentToolsInBlocks.mockImplementation(sanitizeIdentity) }) afterEach(() => { vi.resetAllMocks() }) describe('buildWorkflowDeploymentSnapshot', () => { it('combines normalized workflow state with persisted variables', () => { const snapshot = dbHelpers.buildWorkflowDeploymentSnapshot( { blocks: asAppBlocks({ block: createStarterBlock({ id: 'block' }) }), edges: [], loops: {}, parallels: {}, isFromNormalizedTables: true, }, { variable: { id: 'variable', name: 'threshold', type: 'number', value: 5, }, } ) expect(snapshot.blocks.block).toBeDefined() expect(snapshot.edges).toEqual([]) expect(snapshot.loops).toEqual({}) expect(snapshot.parallels).toEqual({}) expect(snapshot.variables).toEqual({ variable: { id: 'variable', name: 'threshold', type: 'number', value: 5, }, }) expect(snapshot.lastSaved).toEqual(expect.any(Number)) }) }) describe('loadWorkflowFromNormalizedTables', () => { it('should successfully load workflow data from normalized tables', async () => { vi.clearAllMocks() let callCount = 0 mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) { return Promise.resolve(mockBlocksFromDb) } if (callCount === 2) { return Promise.resolve(mockEdgesFromDb) } if (callCount === 3) { return Promise.resolve(mockSubflowsFromDb) } if (callCount === 4) { return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } } return Promise.resolve([]) }), }), })) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeDefined() expect(result?.isFromNormalizedTables).toBe(true) expect(result?.blocks).toBeDefined() expect(result?.edges).toBeDefined() expect(result?.loops).toBeDefined() expect(result?.parallels).toBeDefined() expect(result?.blocks['block-1']).toEqual({ id: 'block-1', type: 'starter', name: 'Start Block', position: { x: 100, y: 100 }, enabled: true, horizontalHandles: true, height: 150, subBlocks: { input: { id: 'input', type: 'short-input' as const, value: 'test' } }, outputs: { result: { type: 'string' } }, data: { parentId: undefined, extent: undefined, width: 350 }, advancedMode: false, triggerMode: false, }) expect(result?.edges[0]).toEqual({ id: 'edge-1', source: 'block-1', target: 'block-2', sourceHandle: 'output', targetHandle: 'input', type: 'default', data: {}, }) expect(result?.loops['loop-1']).toEqual({ id: 'loop-1', nodes: ['block-2'], iterations: 5, loopType: 'for', forEachItems: '', doWhileCondition: '', whileCondition: '', enabled: true, }) expect(result?.parallels['parallel-1']).toEqual({ id: 'parallel-1', nodes: ['block-3'], count: 5, distribution: ['item1', 'item2'], parallelType: 'count', batchSize: 1, enabled: true, }) expect(result?.blocks['parallel-1'].data).toEqual( expect.objectContaining({ count: 5, parallelType: 'count', batchSize: 1, }) ) }) it('should return null when no blocks are found', async () => { mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeNull() }) it('should return null when database query fails', async () => { mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockRejectedValue(new Error('Database connection failed')), }), }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeNull() }) it('should handle unknown subflow types gracefully', async () => { const subflowsWithUnknownType = [ { id: 'unknown-1', workflowId: mockWorkflowId, type: 'unknown-type', config: { id: 'unknown-1' }, }, ] let callCount = 0 mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve(mockBlocksFromDb) if (callCount === 2) return Promise.resolve(mockEdgesFromDb) if (callCount === 3) return Promise.resolve(subflowsWithUnknownType) if (callCount === 4) return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeDefined() expect(result?.loops).toEqual({}) expect(result?.parallels).toEqual({}) expect(result?.blocks).toBeDefined() expect(result?.edges).toBeDefined() }) it('should handle malformed database responses', async () => { const malformedBlocks = [ toDbBlock( createBlock({ id: 'block-1', type: null as any, name: null as any, position: { x: 0, y: 0 }, height: 0, }), mockWorkflowId ), ] malformedBlocks[0].type = null as any malformedBlocks[0].name = null as any let callCount = 0 mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve(malformedBlocks) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) if (callCount === 4) return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeDefined() expect(result?.blocks['block-1']).toBeDefined() expect(result?.blocks['block-1'].type).toBeNull() expect(result?.blocks['block-1'].name).toBeNull() }) it('should handle database connection errors gracefully', async () => { const connectionError = new Error('Connection refused') ;(connectionError as any).code = 'ECONNREFUSED' mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockRejectedValue(connectionError), }), }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeNull() }) }) describe('saveWorkflowToNormalizedTables', () => { it('should successfully save workflow data to normalized tables', async () => { const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue([]), }), } return await callback(tx) }) mockDb.transaction = mockTransaction const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(mockWorkflowState) ) expect(result.success).toBe(true) expect(mockTransaction).toHaveBeenCalledTimes(1) }) it('should handle empty workflow state gracefully', async () => { const emptyWorkflowState = createWorkflowState() const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue([]), }), } return await callback(tx) }) mockDb.transaction = mockTransaction const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(emptyWorkflowState) ) expect(result.success).toBe(true) }) it('should return error when transaction fails', async () => { const mockTransaction = vi.fn().mockRejectedValue(new Error('Transaction failed')) mockDb.transaction = mockTransaction const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(mockWorkflowState) ) expect(result.success).toBe(false) expect(result.error).toBe('Transaction failed') }) it('should handle database constraint errors', async () => { const constraintError = new Error('Unique constraint violation') ;(constraintError as any).code = '23505' const mockTransaction = vi.fn().mockRejectedValue(constraintError) mockDb.transaction = mockTransaction const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(mockWorkflowState) ) expect(result.success).toBe(false) expect(result.error).toBe('Unique constraint violation') }) it('should properly format block data for database insertion', async () => { let capturedBlockInserts: any[] = [] let capturedEdgeInserts: any[] = [] let capturedSubflowInserts: any[] = [] const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockImplementation((data) => { if (data.length > 0) { if (data[0].positionX !== undefined) { capturedBlockInserts = data } else if (data[0].sourceBlockId !== undefined) { capturedEdgeInserts = data } else if (data[0].type === 'loop' || data[0].type === 'parallel') { capturedSubflowInserts = data } } return Promise.resolve([]) }), }), } return await callback(tx) }) mockDb.transaction = mockTransaction await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(mockWorkflowState)) expect(capturedBlockInserts).toHaveLength(5) expect(capturedBlockInserts).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'block-1', workflowId: mockWorkflowId, type: 'starter', name: 'Start Block', positionX: '100', positionY: '100', enabled: true, horizontalHandles: true, height: '150', parentId: null, extent: null, }), expect.objectContaining({ id: 'loop-1', workflowId: mockWorkflowId, type: 'loop', parentId: null, }), expect.objectContaining({ id: 'parallel-1', workflowId: mockWorkflowId, type: 'parallel', parentId: null, }), ]) ) expect(capturedEdgeInserts).toHaveLength(1) expect(capturedEdgeInserts[0]).toMatchObject({ id: 'edge-1', workflowId: mockWorkflowId, sourceBlockId: 'block-1', targetBlockId: 'block-2', sourceHandle: 'output', targetHandle: 'input', }) expect(capturedSubflowInserts).toHaveLength(2) expect(capturedSubflowInserts[0]).toMatchObject({ id: 'loop-1', workflowId: mockWorkflowId, type: 'loop', }) expect(capturedSubflowInserts).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'parallel-1', workflowId: mockWorkflowId, type: 'parallel', config: expect.objectContaining({ count: 3, parallelType: 'count', batchSize: 1, }), }), ]) ) }) it('should regenerate missing loop and parallel definitions from block data', async () => { let capturedSubflowInserts: any[] = [] const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockImplementation((data) => { if (data.length > 0 && (data[0].type === 'loop' || data[0].type === 'parallel')) { capturedSubflowInserts = data } return Promise.resolve([]) }), }), } return await callback(tx) }) mockDb.transaction = mockTransaction const staleWorkflowState = structuredClone(mockWorkflowState) staleWorkflowState.loops = {} staleWorkflowState.parallels = {} await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(staleWorkflowState)) expect(capturedSubflowInserts).toHaveLength(2) expect(capturedSubflowInserts).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'loop-1', type: 'loop' }), expect.objectContaining({ id: 'parallel-1', type: 'parallel', config: expect.objectContaining({ batchSize: 1 }), }), ]) ) }) }) describe('workflowExistsInNormalizedTables', () => { it('should return true when workflow exists in normalized tables', async () => { mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: 'block-1' }]), }), }), }) const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) expect(result).toBe(true) }) it('should return false when workflow does not exist in normalized tables', async () => { mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), }) const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) expect(result).toBe(false) }) it('should return false when database query fails', async () => { mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockRejectedValue(new Error('Database error')), }), }), }) const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) expect(result).toBe(false) }) }) describe('workflow row locking', () => { function createMissingWorkflowTx() { const lockFor = vi.fn().mockResolvedValue([]) const limit = vi.fn(() => ({ for: lockFor })) const where = vi.fn(() => ({ limit })) const from = vi.fn(() => ({ where })) const select = vi.fn(() => ({ from })) const update = vi.fn() return { tx: { execute: vi.fn().mockResolvedValue([{ id: mockWorkflowId }]), select, update, }, lockFor, update, } } it('returns not_found when deploy cannot lock a workflow row', async () => { const { tx, lockFor } = createMissingWorkflowTx() mockDb.transaction = vi.fn().mockImplementation(async (callback) => callback(tx)) const result = await dbHelpers.deployWorkflow({ workflowId: mockWorkflowId, deployedBy: 'user-123', }) expect(result).toEqual({ success: false, error: 'Workflow not found', errorCode: 'not_found', }) expect(lockFor).toHaveBeenCalledWith('update') expect(tx.execute).not.toHaveBeenCalled() }) it('returns an error when undeploy cannot lock a workflow row', async () => { const { tx, update } = createMissingWorkflowTx() mockDb.transaction = vi.fn().mockImplementation(async (callback) => callback(tx)) const result = await dbHelpers.undeployWorkflow({ workflowId: mockWorkflowId }) expect(result).toEqual({ success: false, error: 'Workflow not found', }) expect(update).not.toHaveBeenCalled() }) }) describe('error handling and edge cases', () => { it('should handle very large workflow data', async () => { const blocks: Record> = {} const edges: ReturnType[] = [] for (let i = 0; i < 1000; i++) { blocks[`block-${i}`] = createApiBlock({ id: `block-${i}`, name: `Block ${i}`, position: { x: i * 100, y: i * 100 }, }) } for (let i = 0; i < 999; i++) { edges.push( createEdge({ id: `edge-${i}`, source: `block-${i}`, target: `block-${i + 1}`, }) ) } const largeWorkflowState = createWorkflowState({ blocks, edges }) const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue([]), }), } return await callback(tx) }) mockDb.transaction = mockTransaction const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(largeWorkflowState) ) expect(result.success).toBe(true) }) }) describe('advancedMode persistence', () => { it('should load advancedMode property from database', async () => { const testBlocks = [ toDbBlock( createAgentBlock({ id: 'block-advanced', name: 'Advanced Block', position: { x: 100, y: 100 }, height: 200, advancedMode: true, }), mockWorkflowId ), toDbBlock( createAgentBlock({ id: 'block-basic', name: 'Basic Block', position: { x: 200, y: 100 }, height: 150, advancedMode: false, }), mockWorkflowId ), ] testBlocks[0].advancedMode = true testBlocks[1].advancedMode = false vi.clearAllMocks() let callCount = 0 mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve(testBlocks) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) if (callCount === 4) return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), })) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeDefined() const advancedBlock = result?.blocks['block-advanced'] expect(advancedBlock?.advancedMode).toBe(true) const basicBlock = result?.blocks['block-basic'] expect(basicBlock?.advancedMode).toBe(false) }) it('should handle default values for boolean fields consistently', async () => { const blocksWithDefaultValues = [ toDbBlock( createAgentBlock({ id: 'block-with-defaults', name: 'Block with default values', position: { x: 100, y: 100 }, height: 150, }), mockWorkflowId ), ] vi.clearAllMocks() let callCount = 0 mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve(blocksWithDefaultValues) if (callCount === 4) return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), })) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeDefined() const defaultsBlock = result?.blocks['block-with-defaults'] expect(defaultsBlock?.advancedMode).toBe(false) expect(defaultsBlock?.triggerMode).toBe(false) }) }) describe('end-to-end advancedMode persistence verification', () => { it('should persist advancedMode through complete duplication and save cycle', async () => { const originalBlock = toDbBlock( createAgentBlock({ id: 'agent-original', name: 'Agent 1', position: { x: 100, y: 100 }, height: 200, advancedMode: true, subBlocks: { systemPrompt: { id: 'systemPrompt', type: 'long-input', value: 'You are a helpful assistant', }, userPrompt: { id: 'userPrompt', type: 'long-input', value: 'Help the user' }, model: { id: 'model', type: 'dropdown', value: 'gpt-4o' }, }, }), mockWorkflowId ) originalBlock.advancedMode = true const duplicatedBlock = toDbBlock( createAgentBlock({ id: 'agent-duplicate', name: 'Agent 2', position: { x: 200, y: 100 }, height: 200, advancedMode: true, subBlocks: { systemPrompt: { id: 'systemPrompt', type: 'long-input', value: 'You are a helpful assistant', }, userPrompt: { id: 'userPrompt', type: 'long-input', value: 'Help the user' }, model: { id: 'model', type: 'dropdown', value: 'gpt-4o' }, }, }), mockWorkflowId ) duplicatedBlock.advancedMode = true vi.clearAllMocks() let callCount = 0 mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve([originalBlock, duplicatedBlock]) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) if (callCount === 4) return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), })) const loadedState = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(loadedState).toBeDefined() expect(loadedState?.blocks['agent-original'].advancedMode).toBe(true) expect(loadedState?.blocks['agent-duplicate'].advancedMode).toBe(true) const workflowState = { blocks: loadedState!.blocks, edges: loadedState!.edges, loops: {}, parallels: {}, } const mockTransaction = vi.fn().mockImplementation(async (callback) => { const mockTx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), insert: vi.fn().mockImplementation((_table) => ({ values: vi.fn().mockImplementation((values) => { if (Array.isArray(values)) { values.forEach((blockInsert) => { if (blockInsert.id === 'agent-original') { expect(blockInsert.advancedMode).toBe(true) } if (blockInsert.id === 'agent-duplicate') { expect(blockInsert.advancedMode).toBe(true) } }) } return Promise.resolve() }), })), } return await callback(mockTx) }) mockDb.transaction = mockTransaction const saveResult = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, workflowState ) expect(saveResult.success).toBe(true) expect(mockTransaction).toHaveBeenCalled() }) it('should handle mixed advancedMode states correctly', async () => { const basicBlock = toDbBlock( createAgentBlock({ id: 'agent-basic', name: 'Basic Agent', position: { x: 100, y: 100 }, height: 150, advancedMode: false, subBlocks: legacySubBlocks({ model: { id: 'model', type: 'select', value: 'gpt-4o' } }), }), mockWorkflowId ) const advancedBlock = toDbBlock( createAgentBlock({ id: 'agent-advanced', name: 'Advanced Agent', position: { x: 200, y: 100 }, height: 200, advancedMode: true, subBlocks: legacySubBlocks({ systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'System prompt' }, userPrompt: { id: 'userPrompt', type: 'textarea', value: 'User prompt' }, model: { id: 'model', type: 'select', value: 'gpt-4o' }, }), }), mockWorkflowId ) advancedBlock.advancedMode = true vi.clearAllMocks() let callCount = 0 mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve([basicBlock, advancedBlock]) if (callCount === 4) return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), })) const loadedState = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(loadedState).toBeDefined() expect(loadedState?.blocks['agent-basic'].advancedMode).toBe(false) expect(loadedState?.blocks['agent-advanced'].advancedMode).toBe(true) }) it('should preserve advancedMode during workflow state round-trip', async () => { const testWorkflowState = createWorkflowState({ blocks: { 'block-1': createAgentBlock({ id: 'block-1', name: 'Test Agent', position: { x: 100, y: 100 }, height: 200, advancedMode: true, subBlocks: { systemPrompt: { id: 'systemPrompt', type: 'long-input' as const, value: 'System' }, model: { id: 'model', type: 'dropdown' as const, value: 'gpt-4o' }, }, }), }, }) const mockTransaction = vi.fn().mockImplementation(async (callback) => { const mockTx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined), }), } return await callback(mockTx) }) mockDb.transaction = mockTransaction const saveResult = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(testWorkflowState) ) expect(saveResult.success).toBe(true) vi.clearAllMocks() let callCount = 0 mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) { return Promise.resolve([ { id: 'block-1', workflowId: mockWorkflowId, type: 'agent', name: 'Test Agent', positionX: 100, positionY: 100, enabled: true, horizontalHandles: true, advancedMode: true, height: 200, subBlocks: { systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'System' }, model: { id: 'model', type: 'select', value: 'gpt-4o' }, }, outputs: {}, data: {}, parentId: null, extent: null, }, ]) } if (callCount === 4) return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), })) const loadedState = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(loadedState).toBeDefined() expect(loadedState?.blocks['block-1'].advancedMode).toBe(true) }) }) describe('migrateAgentBlocksToMessagesFormat', () => { it('should migrate agent block with both systemPrompt and userPrompt', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', name: 'Test Agent', subBlocks: legacySubBlocks({ systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'You are a helpful assistant', }, userPrompt: { id: 'userPrompt', type: 'textarea', value: 'Hello world', }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages).toBeDefined() expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([ { role: 'system', content: 'You are a helpful assistant' }, { role: 'user', content: 'Hello world' }, ]) expect(migrated['agent-1'].subBlocks.systemPrompt).toBeDefined() expect(migrated['agent-1'].subBlocks.userPrompt).toBeDefined() }) it('should migrate agent block with only systemPrompt', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'You are helpful', }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([ { role: 'system', content: 'You are helpful' }, ]) }) it('should migrate agent block with only userPrompt', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ userPrompt: { id: 'userPrompt', type: 'textarea', value: 'Hello', }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([ { role: 'user', content: 'Hello' }, ]) }) it('should handle userPrompt as object with input field', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ userPrompt: { id: 'userPrompt', type: 'textarea', value: { input: 'Hello from object' }, }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([ { role: 'user', content: 'Hello from object' }, ]) }) it('should stringify userPrompt object without input field', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ userPrompt: { id: 'userPrompt', type: 'textarea', value: { foo: 'bar', baz: 123 }, }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([ { role: 'user', content: '{"foo":"bar","baz":123}' }, ]) }) it('should not migrate if messages array already exists', () => { const existingMessages = [{ role: 'user', content: 'Existing message' }] const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'Old system', }, userPrompt: { id: 'userPrompt', type: 'textarea', value: 'Old user', }, messages: { id: 'messages', type: 'messages-input', value: existingMessages, }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages?.value).toEqual(existingMessages) }) it('should not migrate if no old format prompts exist', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ model: { id: 'model', type: 'select', value: 'gpt-4o', }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages).toBeUndefined() }) it('should handle non-agent blocks without modification', () => { const blocks = { 'api-1': createApiBlock({ id: 'api-1', subBlocks: legacySubBlocks({ url: { id: 'url', type: 'input', value: 'https://example.com', }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['api-1']).toEqual(blocks['api-1']) expect(migrated['api-1'].subBlocks.messages).toBeUndefined() }) it('should handle multiple blocks with mixed types', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'System 1' }, }), }), 'api-1': createApiBlock({ id: 'api-1', }), 'agent-2': createAgentBlock({ id: 'agent-2', subBlocks: legacySubBlocks({ userPrompt: { id: 'userPrompt', type: 'textarea', value: 'User 2' }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([ { role: 'system', content: 'System 1' }, ]) expect(migrated['api-1']).toEqual(blocks['api-1']) expect(migrated['agent-2'].subBlocks.messages?.value).toEqual([ { role: 'user', content: 'User 2' }, ]) }) it('should handle empty string prompts by not migrating', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ systemPrompt: { id: 'systemPrompt', type: 'textarea', value: '' }, userPrompt: { id: 'userPrompt', type: 'textarea', value: '' }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages).toBeUndefined() }) it('should handle numeric prompt values by converting to string', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 123 }, }), }), } const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([ { role: 'system', content: '123' }, ]) }) it('should be idempotent - running twice should not double migrate', () => { const blocks = { 'agent-1': createAgentBlock({ id: 'agent-1', subBlocks: legacySubBlocks({ systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'System' }, }), }), } const migrated1 = dbHelpers.migrateAgentBlocksToMessagesFormat(asAppBlocks(blocks)) const messages1 = migrated1['agent-1'].subBlocks.messages?.value const migrated2 = dbHelpers.migrateAgentBlocksToMessagesFormat(migrated1) const messages2 = migrated2['agent-1'].subBlocks.messages?.value expect(messages2).toEqual(messages1) expect(messages2).toEqual([{ role: 'system', content: 'System' }]) }) }) describe('loadDeployedWorkflowState deployed-state cache', () => { /** * Minimal but realistic deployed state: a couple of plain (non-agent, * credential-free) blocks plus an edge. Plain blocks make the real * downstream migration steps (agent-message, subblock-id, credential, * canonical-mode) no-ops, so the only observable "heavy work" is the * mocked `sanitizeAgentToolsInBlocks` first step, which we use as the * migration call counter. */ function buildDeployedState() { return { blocks: { 'block-1': { id: 'block-1', type: 'api', name: 'API Block', position: { x: 0, y: 0 }, enabled: true, subBlocks: { url: { id: 'url', type: 'short-input', value: 'https://example.com' } }, outputs: {}, data: {}, }, 'block-2': { id: 'block-2', type: 'function', name: 'Function Block', position: { x: 100, y: 0 }, enabled: true, subBlocks: { code: { id: 'code', type: 'code', value: 'return 1' } }, outputs: {}, data: {}, }, }, edges: [ { id: 'edge-1', source: 'block-1', target: 'block-2', sourceHandle: 'output', targetHandle: 'input', }, ], loops: {}, parallels: {}, variables: { threshold: 5 }, } } /** * Wires `db.select` to return a single active deployment-version row for the * given id. Returns the inner `where` spy so tests can assert how many times * the active-version SELECT ran. */ function mockActiveVersionSelect(versionId: string, state: unknown) { const where = vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: versionId, state, createdAt: new Date() }]), }), }) mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where }), }) return where } beforeEach(() => { vi.clearAllMocks() mockSanitizeAgentToolsInBlocks.mockImplementation(sanitizeIdentity) dbHelpers.invalidateDeployedStateCache() }) it('serves a cache HIT, skipping migrations on the second call for the same active version', async () => { const where = mockActiveVersionSelect('dv-hit', buildDeployedState()) const first = await dbHelpers.loadDeployedWorkflowState('wf-1', 'workspace-1') const second = await dbHelpers.loadDeployedWorkflowState('wf-1', 'workspace-1') expect(first).toBeDefined() expect(second).toBeDefined() expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(1) expect(where).toHaveBeenCalledTimes(2) }) it('still runs the active-version SELECT on every call so rollback/redeploy stays observable', async () => { const where = mockActiveVersionSelect('dv-active', buildDeployedState()) await dbHelpers.loadDeployedWorkflowState('wf-2', 'workspace-1') await dbHelpers.loadDeployedWorkflowState('wf-2', 'workspace-1') expect(where).toHaveBeenCalledTimes(2) }) it('deep-clones on read: mutating the first result does not corrupt the cached copy', async () => { mockActiveVersionSelect('dv-clone', buildDeployedState()) const first = await dbHelpers.loadDeployedWorkflowState('wf-3', 'workspace-1') ;(first.blocks['block-1'] as any).name = 'MUTATED' ;(first.blocks['block-1'].subBlocks.url as any).value = 'https://hacked.example' first.edges.push({ id: 'edge-injected', source: 'block-2', target: 'block-1', } as any) const second = await dbHelpers.loadDeployedWorkflowState('wf-3', 'workspace-1') expect(second.blocks['block-1'].name).toBe('API Block') expect(second.blocks['block-1'].subBlocks.url.value).toBe('https://example.com') expect(second.edges).toHaveLength(1) expect(second.blocks).toEqual(buildDeployedState().blocks) }) it('keys the cache by deploymentVersionId: a different active id triggers a fresh build', async () => { mockActiveVersionSelect('dv-old', buildDeployedState()) await dbHelpers.loadDeployedWorkflowState('wf-4', 'workspace-1') expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(1) mockActiveVersionSelect('dv-new', buildDeployedState()) await dbHelpers.loadDeployedWorkflowState('wf-4', 'workspace-1') expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(2) }) it('invalidateDeployedStateCache(id) forces a rebuild on the next call', async () => { mockActiveVersionSelect('dv-inv', buildDeployedState()) await dbHelpers.loadDeployedWorkflowState('wf-5', 'workspace-1') await dbHelpers.loadDeployedWorkflowState('wf-5', 'workspace-1') expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(1) dbHelpers.invalidateDeployedStateCache('dv-inv') await dbHelpers.loadDeployedWorkflowState('wf-5', 'workspace-1') expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(2) }) it('throws when there is no active deployment and does not cache the failure', async () => { const where = vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }) mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where }), }) await expect(dbHelpers.loadDeployedWorkflowState('wf-6', 'workspace-1')).rejects.toThrow( 'Workflow wf-6 has no active deployment' ) expect(mockSanitizeAgentToolsInBlocks).not.toHaveBeenCalled() }) }) })