Skip to content

Commit 4d67f19

Browse files
Assem-UberCopilotadhityamamallan
authored
Start workflow api (#1014)
* Start workflow api * move start workflow route * add start to grpc files * add start to grpc files * Add default task timeout * fix test case * Update src/route-handlers/start-workflow/schemas/start-workflow-request-body-schema.ts Co-authored-by: Copilot <[email protected]> * address comments * Update src/route-handlers/start-workflow/helpers/process-workflow-input.ts Fix typo Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Adhitya Mamallan <[email protected]>
1 parent 99a812b commit 4d67f19

File tree

12 files changed

+626
-0
lines changed

12 files changed

+626
-0
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { type NextRequest } from 'next/server';
2+
3+
import { startWorkflow } from '@/route-handlers/start-workflow/start-workflow';
4+
import { type StartWorkflowRequestParams } from '@/route-handlers/start-workflow/start-workflow.types';
5+
import { routeHandlerWithMiddlewares } from '@/utils/route-handlers-middleware';
6+
import routeHandlersDefaultMiddlewares from '@/utils/route-handlers-middleware/config/route-handlers-default-middlewares.config';
7+
8+
export async function POST(
9+
request: NextRequest,
10+
options: { params: StartWorkflowRequestParams }
11+
) {
12+
return routeHandlerWithMiddlewares(
13+
startWorkflow,
14+
request,
15+
options,
16+
routeHandlersDefaultMiddlewares
17+
);
18+
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import crypto from 'crypto';
2+
3+
import { GRPCError } from '@/utils/grpc/grpc-error';
4+
5+
import { startWorkflow } from '../start-workflow';
6+
import { type StartWorkflowRequestBody } from '../start-workflow.types';
7+
8+
const defaultRequestBody: StartWorkflowRequestBody = {
9+
workflowId: 'test-workflow-id',
10+
workflowType: {
11+
name: 'TestWorkflow',
12+
},
13+
taskList: {
14+
name: 'test-task-list',
15+
},
16+
input: ['test-input'],
17+
workerSDKLanguage: 'GO',
18+
executionStartToCloseTimeoutSeconds: 30,
19+
taskStartToCloseTimeoutSeconds: 10,
20+
};
21+
22+
describe(startWorkflow.name, () => {
23+
beforeEach(() => {
24+
jest.clearAllMocks();
25+
});
26+
27+
it('calls startWorkflow and returns valid response', async () => {
28+
const { res, mockStartWorkflow } = await setup({});
29+
30+
expect(mockStartWorkflow).toHaveBeenCalledWith({
31+
domain: 'test-domain',
32+
workflowId: 'test-workflow-id',
33+
workflowType: { name: 'TestWorkflow' },
34+
taskList: { name: 'test-task-list' },
35+
input: { data: Buffer.from('"test-input"', 'utf-8') },
36+
executionStartToCloseTimeout: { seconds: 30 },
37+
taskStartToCloseTimeout: { seconds: 10 },
38+
firstRunAt: undefined,
39+
cronSchedule: undefined,
40+
identity: 'test-user-id',
41+
requestId: expect.any(String),
42+
});
43+
44+
const responseData = await res.json();
45+
expect(responseData).toEqual({
46+
runId: 'test-run-id',
47+
workflowId: 'test-workflow-id',
48+
});
49+
});
50+
51+
it('handles missing optional fields correctly', async () => {
52+
const minimalRequestBody: StartWorkflowRequestBody = {
53+
workflowId: 'test-workflow-id',
54+
workflowType: {
55+
name: 'TestWorkflow',
56+
},
57+
taskList: {
58+
name: 'test-task-list',
59+
},
60+
workerSDKLanguage: 'GO',
61+
executionStartToCloseTimeoutSeconds: 30,
62+
};
63+
64+
const { res, mockStartWorkflow } = await setup({
65+
requestBody: minimalRequestBody,
66+
});
67+
68+
expect(mockStartWorkflow).toHaveBeenCalledWith({
69+
domain: 'test-domain',
70+
workflowId: 'test-workflow-id',
71+
workflowType: { name: 'TestWorkflow' },
72+
taskList: { name: 'test-task-list' },
73+
input: undefined,
74+
executionStartToCloseTimeout: { seconds: 30 },
75+
taskStartToCloseTimeout: { seconds: 10 },
76+
firstRunAt: undefined,
77+
cronSchedule: undefined,
78+
identity: 'test-user-id',
79+
requestId: expect.any(String),
80+
});
81+
82+
const responseData = await res.json();
83+
expect(responseData).toEqual({
84+
runId: 'test-run-id',
85+
workflowId: 'test-workflow-id',
86+
});
87+
});
88+
89+
it('returns error for invalid request body', async () => {
90+
const invalidRequestBody = {
91+
workflowType: {
92+
name: '', // Invalid: empty string
93+
},
94+
taskList: {
95+
name: '', // Invalid: empty string
96+
},
97+
workerSDKLanguage: 'GO' as const,
98+
executionStartToCloseTimeoutSeconds: 30,
99+
};
100+
101+
const { res, mockStartWorkflow } = await setup({
102+
requestBody: invalidRequestBody,
103+
});
104+
105+
expect(mockStartWorkflow).not.toHaveBeenCalled();
106+
expect(res.status).toBe(400);
107+
const responseData = await res.json();
108+
expect(responseData.message).toBe(
109+
'Invalid values provided for workflow start'
110+
);
111+
expect(responseData.validationErrors).toBeDefined();
112+
});
113+
114+
it('handles GRPC errors correctly', async () => {
115+
const { res, mockStartWorkflow } = await setup({
116+
error: 'Internal server error',
117+
});
118+
119+
expect(mockStartWorkflow).toHaveBeenCalled();
120+
expect(res.status).toBe(500);
121+
const responseData = await res.json();
122+
expect(responseData.message).toBe('Internal server error');
123+
expect(responseData.cause).toBeDefined();
124+
});
125+
126+
it('handles unknown errors correctly', async () => {
127+
const { res, mockStartWorkflow } = await setup({
128+
error: new Error('Unknown error'),
129+
});
130+
131+
expect(mockStartWorkflow).toHaveBeenCalled();
132+
expect(res.status).toBe(500);
133+
const responseData = await res.json();
134+
expect(responseData.message).toBe('Error starting workflow');
135+
expect(responseData.cause).toBeDefined();
136+
});
137+
138+
it('generates workflowId when not provided', async () => {
139+
const requestBodyWithoutWorkflowId: StartWorkflowRequestBody = {
140+
workflowType: {
141+
name: 'TestWorkflow',
142+
},
143+
taskList: {
144+
name: 'test-task-list',
145+
},
146+
workerSDKLanguage: 'GO',
147+
executionStartToCloseTimeoutSeconds: 30,
148+
};
149+
const generateWorkflowId = 'test-uuid-123-456-789-101-112';
150+
jest.spyOn(crypto, 'randomUUID').mockReturnValue(generateWorkflowId);
151+
152+
const { res, mockStartWorkflow } = await setup({
153+
requestBody: requestBodyWithoutWorkflowId,
154+
});
155+
156+
expect(mockStartWorkflow).toHaveBeenCalledWith(
157+
expect.objectContaining({
158+
workflowId: generateWorkflowId,
159+
})
160+
);
161+
162+
const responseData = await res.json();
163+
expect(typeof responseData.workflowId).toBe('string');
164+
});
165+
166+
it('handles firstRunAt field correctly', async () => {
167+
const requestBodyWithFirstRunAt: StartWorkflowRequestBody = {
168+
workflowId: 'test-workflow-id',
169+
workflowType: {
170+
name: 'TestWorkflow',
171+
},
172+
taskList: {
173+
name: 'test-task-list',
174+
},
175+
workerSDKLanguage: 'GO',
176+
executionStartToCloseTimeoutSeconds: 30,
177+
firstRunAt: '2024-01-01T10:00:00.000Z',
178+
};
179+
180+
const { mockStartWorkflow } = await setup({
181+
requestBody: requestBodyWithFirstRunAt,
182+
});
183+
184+
expect(mockStartWorkflow).toHaveBeenCalledWith(
185+
expect.objectContaining({
186+
firstRunAt: expect.objectContaining({
187+
seconds: 1704103200,
188+
nanos: 0,
189+
}),
190+
})
191+
);
192+
});
193+
});
194+
195+
async function setup({
196+
requestBody = defaultRequestBody,
197+
error,
198+
}: {
199+
requestBody?: StartWorkflowRequestBody;
200+
error?: string | Error;
201+
}) {
202+
const mockStartWorkflow = jest.fn() as jest.MockedFunction<any>;
203+
const mockContext = {
204+
grpcClusterMethods: {
205+
startWorkflow: mockStartWorkflow,
206+
},
207+
userInfo: {
208+
id: 'test-user-id',
209+
},
210+
};
211+
212+
const mockOptions = {
213+
params: {
214+
domain: 'test-domain',
215+
cluster: 'test-cluster',
216+
},
217+
};
218+
219+
const mockRequest = {
220+
json: jest.fn(),
221+
} as any;
222+
223+
mockRequest.json.mockResolvedValue(requestBody);
224+
225+
if (error) {
226+
if (typeof error === 'string') {
227+
mockStartWorkflow.mockRejectedValue(new GRPCError(error));
228+
} else {
229+
mockStartWorkflow.mockRejectedValue(error);
230+
}
231+
} else {
232+
mockStartWorkflow.mockResolvedValue({
233+
runId: 'test-run-id',
234+
});
235+
}
236+
237+
const res = await startWorkflow(mockRequest, mockOptions, mockContext as any);
238+
239+
return { res, mockStartWorkflow };
240+
}

0 commit comments

Comments
 (0)