Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

1388 lines
43 KiB
TypeScript

/**
* @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,
dbChainMock,
dbChainMockFns,
queueTableRows,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { afterAll, 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<T>(state: T): AppWorkflowState {
return state as unknown as AppWorkflowState
}
/**
* Type helper for converting test blocks to app block state record.
*/
function asAppBlocks<T>(blocks: T): Record<string, AppBlockState> {
return blocks as unknown as Record<string, AppBlockState>
}
/**
* 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<string, any>): any {
return subBlocks
}
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
const { mockSanitizeAgentToolsInBlocks } = vi.hoisted(() => ({
mockSanitizeAgentToolsInBlocks: vi.fn(),
}))
/**
* Default identity behavior for the mocked migration step. Re-applied in the
* outer `beforeEach` because `vi.clearAllMocks()` clears implementations set
* on the hoisted spy.
*/
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'
/**
* Queues the four table-routed result sets consumed by
* `loadWorkflowFromNormalizedTablesRaw` (blocks, edges, subflows, workflow row).
*/
function queueLoadFixtures(options: {
blocks: unknown[]
edges?: unknown[]
subflows?: unknown[]
workspaceId?: string
}) {
queueTableRows(schemaMock.workflowBlocks, options.blocks)
queueTableRows(schemaMock.workflowEdges, options.edges ?? [])
queueTableRows(schemaMock.workflowSubflows, options.subflows ?? [])
queueTableRows(schemaMock.workflow, [{ workspaceId: options.workspaceId ?? 'test-workspace-id' }])
}
/**
* Returns the row arrays passed to `insert(table).values(rows)` for the given
* schema table. Insert/values chains run sequentially in the code under test,
* so the two spies' call lists stay index-aligned.
*/
function insertedRowsFor(table: unknown): Record<string, unknown>[][] {
return dbChainMockFns.insert.mock.calls.flatMap(([calledTable], index) =>
calledTable === table && Array.isArray(dbChainMockFns.values.mock.calls[index]?.[0])
? [dbChainMockFns.values.mock.calls[index][0] as Record<string, unknown>[]]
: []
)
}
/**
* Converts a BlockState to a mock database block row format.
*/
function toDbBlock(block: ReturnType<typeof createBlock>, 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()
resetDbChainMock()
mockSanitizeAgentToolsInBlocks.mockImplementation(sanitizeIdentity)
})
afterAll(() => {
resetDbChainMock()
})
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 () => {
queueLoadFixtures({
blocks: mockBlocksFromDb,
edges: mockEdgesFromDb,
subflows: mockSubflowsFromDb,
})
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 () => {
const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)
expect(result).toBeNull()
})
it('should return null when database query fails', async () => {
dbChainMockFns.where.mockImplementationOnce(() =>
Promise.reject(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' },
},
]
queueLoadFixtures({
blocks: mockBlocksFromDb,
edges: mockEdgesFromDb,
subflows: subflowsWithUnknownType,
})
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
queueLoadFixtures({ blocks: malformedBlocks })
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'
dbChainMockFns.where.mockImplementationOnce(() => Promise.reject(connectionError))
const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)
expect(result).toBeNull()
})
})
describe('saveWorkflowToNormalizedTables', () => {
it('should successfully save workflow data to normalized tables', async () => {
const result = await dbHelpers.saveWorkflowToNormalizedTables(
mockWorkflowId,
asAppState(mockWorkflowState)
)
expect(result.success).toBe(true)
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
})
it('should handle empty workflow state gracefully', async () => {
const emptyWorkflowState = createWorkflowState()
const result = await dbHelpers.saveWorkflowToNormalizedTables(
mockWorkflowId,
asAppState(emptyWorkflowState)
)
expect(result.success).toBe(true)
})
it('should return error when transaction fails', async () => {
dbChainMockFns.transaction.mockRejectedValueOnce(new Error('Transaction failed'))
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'
dbChainMockFns.transaction.mockRejectedValueOnce(constraintError)
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 () => {
await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(mockWorkflowState))
const [capturedBlockInserts = []] = insertedRowsFor(schemaMock.workflowBlocks)
const [capturedEdgeInserts = []] = insertedRowsFor(schemaMock.workflowEdges)
const [capturedSubflowInserts = []] = insertedRowsFor(schemaMock.workflowSubflows)
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 () => {
const staleWorkflowState = structuredClone(mockWorkflowState)
staleWorkflowState.loops = {}
staleWorkflowState.parallels = {}
await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(staleWorkflowState))
const [capturedSubflowInserts = []] = insertedRowsFor(schemaMock.workflowSubflows)
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 () => {
queueTableRows(schemaMock.workflowBlocks, [{ 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 () => {
const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId)
expect(result).toBe(false)
})
it('should return false when database query fails', async () => {
dbChainMockFns.limit.mockImplementationOnce(() => Promise.reject(new Error('Database error')))
const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId)
expect(result).toBe(false)
})
})
describe('workflow row locking', () => {
it('returns an error when undeploy cannot lock a workflow row', async () => {
const result = await dbHelpers.undeployWorkflow({ workflowId: mockWorkflowId })
expect(result).toEqual({
success: false,
error: 'Workflow not found',
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
it('supersedes in-flight operations and releases path claims during undeploy', async () => {
queueTableRows(schemaMock.workflow, [{ id: mockWorkflowId }])
queueTableRows(schemaMock.workflowDeploymentVersion, [{ id: 'dv-1' }, { id: 'dv-2' }])
const onUndeployTransaction = vi.fn().mockResolvedValue(undefined)
const result = await dbHelpers.undeployWorkflow({
workflowId: mockWorkflowId,
onUndeployTransaction,
})
expect(result).toEqual({ success: true })
const setCalls = dbChainMockFns.set.mock.calls.map(([payload]) => payload)
expect(setCalls[0]).toEqual(expect.objectContaining({ status: 'superseded' }))
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ isActive: false }),
expect.objectContaining({ isDeployed: false, deployedAt: null }),
])
)
expect(dbChainMockFns.delete).toHaveBeenCalledTimes(2)
expect(onUndeployTransaction).toHaveBeenCalledWith(dbChainMock.db, {
deploymentVersionIds: ['dv-1', 'dv-2'],
})
})
})
describe('error handling and edge cases', () => {
it('should handle very large workflow data', async () => {
const blocks: Record<string, ReturnType<typeof createBlock>> = {}
const edges: ReturnType<typeof createEdge>[] = []
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 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
queueLoadFixtures({ blocks: testBlocks })
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
),
]
queueLoadFixtures({ blocks: blocksWithDefaultValues })
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
queueLoadFixtures({ blocks: [originalBlock, duplicatedBlock] })
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 saveResult = await dbHelpers.saveWorkflowToNormalizedTables(
mockWorkflowId,
workflowState
)
expect(saveResult.success).toBe(true)
expect(dbChainMockFns.transaction).toHaveBeenCalled()
const [blockInserts = []] = insertedRowsFor(schemaMock.workflowBlocks)
const savedOriginal = blockInserts.find((row) => row.id === 'agent-original')
const savedDuplicate = blockInserts.find((row) => row.id === 'agent-duplicate')
expect(savedOriginal?.advancedMode).toBe(true)
expect(savedDuplicate?.advancedMode).toBe(true)
})
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
queueLoadFixtures({ blocks: [basicBlock, advancedBlock] })
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 saveResult = await dbHelpers.saveWorkflowToNormalizedTables(
mockWorkflowId,
asAppState(testWorkflowState)
)
expect(saveResult.success).toBe(true)
queueLoadFixtures({
blocks: [
{
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,
},
],
})
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 },
}
}
/**
* Queues one active deployment-version row for the next active-version
* SELECT; call once per expected `loadDeployedWorkflowState` invocation.
* Tests assert SELECT counts on `dbChainMockFns.where`.
*/
function queueActiveVersion(versionId: string, state: unknown) {
queueTableRows(schemaMock.workflowDeploymentVersion, [
{ id: versionId, state, createdAt: new Date() },
])
}
beforeEach(() => {
dbHelpers.invalidateDeployedStateCache()
})
it('serves a cache HIT, skipping migrations on the second call for the same active version', async () => {
queueActiveVersion('dv-hit', buildDeployedState())
queueActiveVersion('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(dbChainMockFns.where).toHaveBeenCalledTimes(2)
})
it('still runs the active-version SELECT on every call so rollback/redeploy stays observable', async () => {
queueActiveVersion('dv-active', buildDeployedState())
queueActiveVersion('dv-active', buildDeployedState())
await dbHelpers.loadDeployedWorkflowState('wf-2', 'workspace-1')
await dbHelpers.loadDeployedWorkflowState('wf-2', 'workspace-1')
expect(dbChainMockFns.where).toHaveBeenCalledTimes(2)
})
it('deep-clones on read: mutating the first result does not corrupt the cached copy', async () => {
queueActiveVersion('dv-clone', buildDeployedState())
queueActiveVersion('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 () => {
queueActiveVersion('dv-old', buildDeployedState())
await dbHelpers.loadDeployedWorkflowState('wf-4', 'workspace-1')
expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(1)
queueActiveVersion('dv-new', buildDeployedState())
await dbHelpers.loadDeployedWorkflowState('wf-4', 'workspace-1')
expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(2)
})
it('loads an admitted immutable deployment version even after a later cutover', async () => {
const state = buildDeployedState()
queueTableRows(schemaMock.workflowDeploymentVersion, [{ id: 'dv-admitted', state }])
const result = await dbHelpers.loadWorkflowDeploymentVersionState(
'wf-admitted',
'dv-admitted',
'workspace-1'
)
expect(result.deploymentVersionId).toBe('dv-admitted')
expect(result.blocks).toEqual(state.blocks)
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('invalidateDeployedStateCache(id) forces a rebuild on the next call', async () => {
queueActiveVersion('dv-inv', buildDeployedState())
queueActiveVersion('dv-inv', buildDeployedState())
queueActiveVersion('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 () => {
await expect(dbHelpers.loadDeployedWorkflowState('wf-6', 'workspace-1')).rejects.toThrow(
'Workflow wf-6 has no active deployment'
)
expect(mockSanitizeAgentToolsInBlocks).not.toHaveBeenCalled()
})
})
})