chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { workflowAuthzMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing'
|
||||
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockAuthorizeWorkflowByWorkspacePermission =
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
|
||||
|
||||
const { mockDb } = vi.hoisted(() => ({
|
||||
mockDb: {
|
||||
transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
...drizzleOrmMock,
|
||||
min: vi.fn((field) => ({ type: 'min', field })),
|
||||
}))
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: mockDb,
|
||||
}))
|
||||
|
||||
import { duplicateWorkflow } from './duplicate'
|
||||
|
||||
function createMockTx(
|
||||
selectResults: unknown[],
|
||||
onWorkflowInsert?: (values: Record<string, unknown>) => void,
|
||||
onInsert?: (values: unknown) => void
|
||||
) {
|
||||
let selectCallCount = 0
|
||||
|
||||
const select = vi.fn().mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockImplementation(() => {
|
||||
const result = selectResults[selectCallCount++] ?? []
|
||||
if (selectCallCount === 1) {
|
||||
return {
|
||||
limit: vi.fn().mockResolvedValue(result),
|
||||
}
|
||||
}
|
||||
return Promise.resolve(result)
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const insert = vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
onWorkflowInsert?.(values)
|
||||
onInsert?.(values)
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
})
|
||||
|
||||
const update = vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
describe('duplicateWorkflow ordering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('new-workflow-id'),
|
||||
})
|
||||
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'source-workflow-id', workspaceId: 'workspace-123' },
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
workflowsUtilsMockFns.mockDeduplicateWorkflowName.mockImplementation(
|
||||
async (name: string) => name
|
||||
)
|
||||
})
|
||||
|
||||
it('uses mixed-sibling top insertion sort order', async () => {
|
||||
let insertedWorkflowValues: Record<string, unknown> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[{ minOrder: 5 }],
|
||||
[{ minOrder: 2 }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
(values) => {
|
||||
insertedWorkflowValues = values
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
const result = await duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
expect(result.sortOrder).toBe(1)
|
||||
expect(insertedWorkflowValues?.sortOrder).toBe(1)
|
||||
})
|
||||
|
||||
it('defaults to sortOrder 0 when target has no siblings', async () => {
|
||||
let insertedWorkflowValues: Record<string, unknown> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
(values) => {
|
||||
insertedWorkflowValues = values
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
const result = await duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-2',
|
||||
})
|
||||
|
||||
expect(result.sortOrder).toBe(0)
|
||||
expect(insertedWorkflowValues?.sortOrder).toBe(0)
|
||||
})
|
||||
|
||||
it('strips copied webhook runtime subblocks and remaps variable assignments', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
|
||||
variables: {
|
||||
'old-var-id': {
|
||||
id: 'old-var-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'source-block-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'generic_webhook',
|
||||
name: 'Webhook',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
triggerPath: { id: 'triggerPath', type: 'short-input', value: 'old-webhook-path' },
|
||||
webhookId: { id: 'webhookId', type: 'short-input', value: 'old-webhook-id' },
|
||||
webhookUrlDisplay: {
|
||||
id: 'webhookUrlDisplay',
|
||||
type: 'short-input',
|
||||
value: 'https://example.test/api/webhooks/trigger/old-webhook-path',
|
||||
},
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: JSON.stringify([
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'old-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: true,
|
||||
locked: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-3',
|
||||
})
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(copiedSubBlocks.triggerPath).toBeUndefined()
|
||||
expect(copiedSubBlocks.webhookId).toBeUndefined()
|
||||
expect(copiedSubBlocks.webhookUrlDisplay).toBeUndefined()
|
||||
expect(copiedSubBlocks.variables.value[0].variableId).not.toBe('old-var-id')
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
expect(insertedBlocks?.[0].locked).toBe(false)
|
||||
})
|
||||
|
||||
it('remaps variable assignments when duplicating an already-duplicated source (array value)', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
let insertedWorkflowValues: Record<string, unknown> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'first-copy-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'first copy',
|
||||
|
||||
variables: {
|
||||
'first-copy-var-id': {
|
||||
id: 'first-copy-var-id',
|
||||
workflowId: 'first-copy-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'first-copy-block-id',
|
||||
workflowId: 'first-copy-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: [
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'first-copy-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
(values) => {
|
||||
if (!insertedWorkflowValues && !Array.isArray(values)) {
|
||||
insertedWorkflowValues = values
|
||||
}
|
||||
},
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: 'first-copy-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated Again',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-second-copy',
|
||||
})
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(Array.isArray(copiedSubBlocks.variables.value)).toBe(true)
|
||||
expect(copiedSubBlocks.variables.value).toHaveLength(1)
|
||||
|
||||
const newVarIds = Object.keys(
|
||||
(insertedWorkflowValues?.variables as Record<string, unknown>) || {}
|
||||
)
|
||||
expect(newVarIds).toHaveLength(1)
|
||||
const remappedVarId = copiedSubBlocks.variables.value[0].variableId
|
||||
expect(remappedVarId).not.toBe('first-copy-var-id')
|
||||
expect(remappedVarId).toBe(newVarIds[0])
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
})
|
||||
|
||||
it('preserves remap when an edge references an unknown source block (drops the edge with a warning)', async () => {
|
||||
let insertedEdges: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'block-a',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent A',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'block-b',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent B',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'edge-valid',
|
||||
workflowId: 'source-workflow-id',
|
||||
sourceBlockId: 'block-a',
|
||||
targetBlockId: 'block-b',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'edge-orphan',
|
||||
workflowId: 'source-workflow-id',
|
||||
sourceBlockId: 'unknown-source-block',
|
||||
targetBlockId: 'block-b',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (
|
||||
Array.isArray(values) &&
|
||||
values.length > 0 &&
|
||||
(values[0] as Record<string, unknown>)?.sourceBlockId !== undefined
|
||||
) {
|
||||
insertedEdges = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-orphan-edge',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedEdges).toHaveLength(1)
|
||||
const onlyEdge = insertedEdges?.[0]
|
||||
expect(onlyEdge?.sourceBlockId).not.toBe('unknown-source-block')
|
||||
expect(onlyEdge?.sourceBlockId).toEqual(expect.any(String))
|
||||
expect(onlyEdge?.targetBlockId).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('preserves remap when a subflow references an unknown node (drops the node with a warning)', async () => {
|
||||
let insertedSubflows: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'loop-block',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'loop',
|
||||
name: 'Loop',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'known-node',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'loop-block',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'loop',
|
||||
config: {
|
||||
id: 'loop-block',
|
||||
nodes: ['known-node', 'unknown-node'],
|
||||
iterations: 1,
|
||||
loopType: 'for',
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (
|
||||
Array.isArray(values) &&
|
||||
values.length > 0 &&
|
||||
(values[0] as Record<string, unknown>)?.config !== undefined
|
||||
) {
|
||||
insertedSubflows = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-orphan-subflow',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedSubflows).toHaveLength(1)
|
||||
const remappedConfig = insertedSubflows?.[0].config as { nodes: string[] }
|
||||
expect(Array.isArray(remappedConfig.nodes)).toBe(true)
|
||||
expect(remappedConfig.nodes).toHaveLength(1)
|
||||
expect(remappedConfig.nodes[0]).not.toBe('unknown-node')
|
||||
expect(remappedConfig.nodes[0]).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('preserves stale variable references instead of failing the duplicate', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
|
||||
variables: {
|
||||
'live-var-id': {
|
||||
id: 'live-var-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'source-block-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: [
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'deleted-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-stale',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(copiedSubBlocks.variables.value[0].variableId).toBe('deleted-var-id')
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user