Files
simstudioai--sim/apps/sim/lib/workflows/persistence/utils.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1724 lines
52 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,
} 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<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
}
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: <T>(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<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()
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<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 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()
})
})
})