Skip to content

Commit ac6faf3

Browse files
authored
PLU-564: Duplicate step/branch and reorder step (#1225)
## TL;DR * Duplicate step(s) * Duplicate branch(es) * Reorder step(s)
2 parents 27ee0c3 + 3985b26 commit ac6faf3

File tree

39 files changed

+1769
-255
lines changed

39 files changed

+1769
-255
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { BadUserInputError } from '@/errors/graphql-errors'
4+
import updateStepPositions from '@/graphql/mutations/update-step-positions'
5+
import Step from '@/models/step'
6+
7+
const mockFlowId = '8c2a70d1-e78b-431e-9069-a4d8f97883f6'
8+
9+
const MOCK_STEPS = [
10+
{
11+
id: 'step-0',
12+
appKey: 'formsg' as const,
13+
key: 'newSubmission' as const,
14+
type: 'trigger',
15+
flowId: mockFlowId,
16+
position: 1,
17+
parameters: { testParam: 'value' },
18+
},
19+
{
20+
id: 'step-1',
21+
appKey: 'postman',
22+
key: 'sendTransactionalEmail' as const,
23+
type: 'action',
24+
flowId: mockFlowId,
25+
position: 2,
26+
parameters: { testParam: 'value' },
27+
},
28+
{
29+
id: 'step-2',
30+
appKey: 'tiles' as const,
31+
key: 'findSingleRow' as const,
32+
type: 'action',
33+
flowId: mockFlowId,
34+
position: 3,
35+
parameters: {},
36+
},
37+
{
38+
id: 'step-3',
39+
appKey: 'tiles' as const,
40+
key: 'findSingleRow' as const,
41+
type: 'action',
42+
flowId: mockFlowId,
43+
position: 4,
44+
parameters: {},
45+
},
46+
{
47+
id: 'step-4',
48+
appKey: 'slack' as const,
49+
key: 'findMessage' as const,
50+
type: 'action',
51+
flowId: mockFlowId,
52+
position: 5,
53+
parameters: {},
54+
},
55+
] as const
56+
57+
describe('updateStepPositions mutation', () => {
58+
let context: any
59+
let fakeSteps: any[]
60+
let fakeFlow: any
61+
let fakeQuery: any
62+
let stepFindByIdSpy: ReturnType<typeof vi.fn>
63+
let stepPatchSpy: ReturnType<typeof vi.fn>
64+
let flowPatchAndFetchSpy: ReturnType<typeof vi.fn>
65+
66+
beforeEach(() => {
67+
vi.resetAllMocks()
68+
69+
// Set up flow patch and fetch spy first
70+
flowPatchAndFetchSpy = vi.fn().mockReturnValue({
71+
withGraphFetched: vi.fn().mockReturnValue({
72+
orderBy: vi.fn().mockResolvedValue([]),
73+
}),
74+
})
75+
stepPatchSpy = vi.fn().mockResolvedValue({})
76+
77+
// Create fake flow object
78+
fakeFlow = {
79+
id: mockFlowId,
80+
active: false,
81+
$query: vi.fn().mockReturnValue({
82+
patchAndFetch: flowPatchAndFetchSpy,
83+
}),
84+
}
85+
86+
// Create fake steps with flow reference
87+
fakeSteps = MOCK_STEPS.map((step) => ({
88+
...step,
89+
flow: fakeFlow,
90+
}))
91+
92+
// Mock Step.transaction
93+
vi.spyOn(Step, 'transaction').mockImplementation(async (callback) => {
94+
const trx = {
95+
raw: vi.fn().mockResolvedValue({}),
96+
} as any
97+
return callback(trx)
98+
})
99+
100+
// Mock Step.query
101+
stepFindByIdSpy = vi.fn().mockReturnValue({
102+
patch: stepPatchSpy,
103+
})
104+
vi.spyOn(Step, 'query').mockReturnValue({
105+
findById: stepFindByIdSpy,
106+
} as any)
107+
108+
// Fake the chained query methods on context.currentUser.$relatedQuery('steps')
109+
fakeQuery = {
110+
withGraphFetched: vi.fn().mockReturnThis(),
111+
whereIn: vi.fn().mockReturnThis(),
112+
orderBy: vi.fn().mockReturnThis(),
113+
throwIfNotFound: vi.fn().mockResolvedValue(fakeSteps),
114+
}
115+
116+
context = {
117+
currentUser: {
118+
$relatedQuery: vi.fn().mockReturnValue(fakeQuery),
119+
},
120+
}
121+
})
122+
123+
it('should successfully update step positions', async () => {
124+
const input = {
125+
stepPositions: [
126+
{
127+
id: 'step-4',
128+
position: 2,
129+
type: 'action' as const,
130+
},
131+
{
132+
id: 'step-1',
133+
position: 3,
134+
type: 'action' as const,
135+
},
136+
{
137+
id: 'step-2',
138+
position: 4,
139+
type: 'action' as const,
140+
},
141+
],
142+
}
143+
144+
await updateStepPositions(null, { input }, context)
145+
146+
// should call and update the step positions
147+
expect(stepFindByIdSpy).toHaveBeenCalledTimes(3)
148+
expect(stepPatchSpy).toHaveBeenCalledTimes(3)
149+
expect(stepFindByIdSpy).toHaveBeenNthCalledWith(1, 'step-4')
150+
expect(stepPatchSpy).toHaveBeenNthCalledWith(1, { position: 2 })
151+
expect(stepFindByIdSpy).toHaveBeenNthCalledWith(2, 'step-1')
152+
expect(stepPatchSpy).toHaveBeenNthCalledWith(2, { position: 3 })
153+
expect(stepFindByIdSpy).toHaveBeenNthCalledWith(3, 'step-2')
154+
expect(stepPatchSpy).toHaveBeenNthCalledWith(3, { position: 4 })
155+
156+
// should update the flow updatedAt
157+
expect(flowPatchAndFetchSpy).toHaveBeenCalledWith({
158+
updatedAt: expect.any(String),
159+
})
160+
})
161+
162+
it('should throw an error if the step ids are not found', async () => {
163+
// Mock throwIfNotFound to throw an error for missing steps
164+
fakeQuery.throwIfNotFound.mockRejectedValue(new Error('Step not found'))
165+
166+
const input = {
167+
stepPositions: [
168+
{
169+
id: 'non-existent-id',
170+
position: 2,
171+
type: 'action' as const,
172+
},
173+
],
174+
} as any
175+
176+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
177+
'Step not found',
178+
)
179+
})
180+
181+
it('should throw an error if the steps are not action steps', async () => {
182+
const input = {
183+
stepPositions: [
184+
{
185+
id: 'step-0',
186+
position: 1,
187+
step: { id: 'step-0', type: 'trigger' as const },
188+
},
189+
{
190+
id: 'step-1',
191+
position: 2,
192+
step: { id: 'step-1', type: 'action' as const },
193+
},
194+
],
195+
} as any
196+
197+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
198+
BadUserInputError,
199+
)
200+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
201+
'Failed to update: must update contiguous action steps!',
202+
)
203+
})
204+
205+
it('should throw an error if the step positions are not contiguous', async () => {
206+
const input = {
207+
stepPositions: [
208+
{
209+
id: 'step-1',
210+
position: 1,
211+
step: { id: 'step-1', type: 'action' as const },
212+
},
213+
{
214+
id: 'step-2',
215+
position: 3,
216+
step: { id: 'step-2', type: 'action' as const },
217+
},
218+
],
219+
} as any
220+
221+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
222+
BadUserInputError,
223+
)
224+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
225+
'Failed to update: must update contiguous action steps!',
226+
)
227+
})
228+
229+
it('should throw an error if the step positions are out of bounds', async () => {
230+
const input = {
231+
stepPositions: [
232+
{
233+
id: 'step-1',
234+
position: 5,
235+
type: 'action' as const,
236+
},
237+
{
238+
id: 'step-2',
239+
position: 6,
240+
type: 'action' as const,
241+
},
242+
],
243+
} as any
244+
245+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
246+
BadUserInputError,
247+
)
248+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
249+
'Failed to update: step positions are out of bounds.',
250+
)
251+
})
252+
253+
it('should throw an error if the pipe is active', async () => {
254+
// Set the flow to active
255+
fakeSteps = fakeSteps.map((step) => ({
256+
...step,
257+
flow: {
258+
...step.flow,
259+
active: true,
260+
},
261+
}))
262+
fakeQuery.throwIfNotFound.mockResolvedValue(fakeSteps)
263+
264+
const input = {
265+
stepPositions: [
266+
{
267+
id: 'step-1',
268+
position: 2,
269+
type: 'action' as const,
270+
},
271+
{
272+
id: 'step-2',
273+
position: 3,
274+
type: 'action' as const,
275+
},
276+
],
277+
} as any
278+
279+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
280+
BadUserInputError,
281+
)
282+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
283+
'Pipe is active. Cannot update step in active pipe!',
284+
)
285+
})
286+
287+
it('should throw an error if steps are missing from database', async () => {
288+
// Mock steps to return only some of the requested steps
289+
const partialSteps = [fakeSteps[1]] // Only return step-1, missing step-2
290+
fakeQuery.throwIfNotFound.mockResolvedValue(partialSteps)
291+
292+
const input = {
293+
stepPositions: [
294+
{
295+
id: 'step-1',
296+
position: 2,
297+
type: 'action' as const,
298+
},
299+
{
300+
id: 'step-2',
301+
position: 3,
302+
type: 'action' as const,
303+
},
304+
],
305+
}
306+
307+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
308+
BadUserInputError,
309+
)
310+
await expect(updateStepPositions(null, { input }, context)).rejects.toThrow(
311+
'Failed to update: steps were not found',
312+
)
313+
})
314+
})

packages/backend/src/graphql/mutation-resolvers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import updateFlowConfig from './mutations/update-flow-config'
3030
import updateFlowStatus from './mutations/update-flow-status'
3131
import updateFlowTransferStatus from './mutations/update-flow-transfer-status'
3232
import updateStep from './mutations/update-step'
33+
import updateStepPositions from './mutations/update-step-positions'
3334
import verifyConnection from './mutations/verify-connection'
3435
import verifyOtp from './mutations/verify-otp'
3536

@@ -69,6 +70,7 @@ export default {
6970
deleteFlow,
7071
createStep,
7172
updateStep,
73+
updateStepPositions,
7274
deleteStep,
7375
requestOtp,
7476
verifyOtp,

packages/backend/src/graphql/mutations/create-step.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const createStep: MutationResolvers['createStep'] = async (
5454
position: previousStep.position + 1,
5555
parameters: input.parameters,
5656
connectionId: input.connection?.id,
57+
config: input.config,
5758
})
5859

5960
await step.patchFlowLastUpdated(trx)

0 commit comments

Comments
 (0)