Skip to content

Commit 4d3c68c

Browse files
committed
feat(api): add create schedule POST route and tests
Add Zod request schema, gRPC payload mapping (start-workflow parity for nested action), POST /schedules, and node tests.
1 parent 4515e28 commit 4d3c68c

7 files changed

Lines changed: 458 additions & 0 deletions

File tree

src/app/api/domains/[domain]/[cluster]/schedules/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type NextRequest } from 'next/server';
22

3+
import { createSchedule } from '@/route-handlers/create-schedule/create-schedule';
34
import { listSchedules } from '@/route-handlers/list-schedules/list-schedules';
45
import type { RouteParams } from '@/route-handlers/list-schedules/list-schedules.types';
56
import { routeHandlerWithMiddlewares } from '@/utils/route-handlers-middleware';
@@ -16,3 +17,15 @@ export async function GET(
1617
routeHandlersDefaultMiddlewares
1718
);
1819
}
20+
21+
export async function POST(
22+
request: NextRequest,
23+
options: { params: RouteParams }
24+
) {
25+
return routeHandlerWithMiddlewares(
26+
createSchedule,
27+
request,
28+
options,
29+
routeHandlersDefaultMiddlewares
30+
);
31+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { status } from '@grpc/grpc-js';
2+
import { NextRequest } from 'next/server';
3+
4+
import { GRPCError } from '@/utils/grpc/grpc-error';
5+
import logger from '@/utils/logger';
6+
import { mockGrpcClusterMethods } from '@/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods';
7+
8+
import { createSchedule } from '../create-schedule';
9+
import { type Context, type RequestParams } from '../create-schedule.types';
10+
11+
jest.mock('@/utils/logger');
12+
13+
function getValidRequestBody() {
14+
return {
15+
scheduleId: 'my-schedule',
16+
spec: {
17+
cronExpression: '0 9 * * *',
18+
},
19+
action: {
20+
startWorkflow: {
21+
workflowType: { name: 'DemoWorkflow' },
22+
taskList: { name: 'demo-task-list' },
23+
workerSDKLanguage: 'GO' as const,
24+
workflowIdPrefix: 'scheduled-demo-',
25+
executionStartToCloseTimeoutSeconds: 3600,
26+
taskStartToCloseTimeoutSeconds: 30,
27+
},
28+
},
29+
};
30+
}
31+
32+
describe(createSchedule.name, () => {
33+
beforeEach(() => {
34+
jest.resetAllMocks();
35+
});
36+
37+
it('calls createSchedule and returns the gRPC response', async () => {
38+
const { res, mockCreateSchedule } = await setup({});
39+
40+
expect(mockCreateSchedule).toHaveBeenCalledTimes(1);
41+
expect(res.status).toEqual(200);
42+
const json = await res.json();
43+
expect(json).toEqual({ scheduleId: 'my-schedule' });
44+
});
45+
46+
it('returns validation error when body is invalid', async () => {
47+
const { res, mockCreateSchedule } = await setup({
48+
body: { scheduleId: '' },
49+
});
50+
51+
expect(mockCreateSchedule).not.toHaveBeenCalled();
52+
expect(res.status).toEqual(400);
53+
const json = await res.json();
54+
expect(json.message).toEqual('Invalid values provided for schedule create');
55+
expect(Array.isArray(json.validationErrors)).toBe(true);
56+
});
57+
58+
it('returns error when createSchedule throws a GRPCError', async () => {
59+
const { res, mockCreateSchedule } = await setup({
60+
error: new GRPCError('Schedule already exists', {
61+
grpcStatusCode: status.ALREADY_EXISTS,
62+
}),
63+
});
64+
65+
expect(mockCreateSchedule).toHaveBeenCalled();
66+
expect(res.status).toEqual(409);
67+
const json = await res.json();
68+
expect(json).toEqual(
69+
expect.objectContaining({
70+
message: 'Schedule already exists',
71+
})
72+
);
73+
74+
expect(logger.error).toHaveBeenCalledWith(
75+
expect.objectContaining({
76+
requestParams: { domain: 'mock-domain', cluster: 'mock-cluster' },
77+
error: expect.any(GRPCError),
78+
}),
79+
'Error creating schedule: Schedule already exists'
80+
);
81+
});
82+
83+
it('returns error when createSchedule throws a generic error', async () => {
84+
const { res, mockCreateSchedule } = await setup({
85+
error: new Error('Network error'),
86+
});
87+
88+
expect(mockCreateSchedule).toHaveBeenCalled();
89+
expect(res.status).toEqual(500);
90+
const json = await res.json();
91+
expect(json).toEqual(
92+
expect.objectContaining({
93+
message: 'Error creating schedule',
94+
})
95+
);
96+
97+
expect(logger.error).toHaveBeenCalledWith(
98+
expect.objectContaining({
99+
requestParams: { domain: 'mock-domain', cluster: 'mock-cluster' },
100+
error: expect.any(Error),
101+
}),
102+
'Error creating schedule'
103+
);
104+
});
105+
});
106+
107+
async function setup({ body, error }: { body?: unknown; error?: Error }) {
108+
const mockCreateSchedule = jest
109+
.spyOn(mockGrpcClusterMethods, 'createSchedule')
110+
.mockImplementationOnce(async () => {
111+
if (error) {
112+
throw error;
113+
}
114+
return { scheduleId: 'my-schedule' };
115+
});
116+
117+
const payload = body ?? getValidRequestBody();
118+
119+
const res = await createSchedule(
120+
new NextRequest('http://localhost', {
121+
method: 'POST',
122+
body: JSON.stringify(payload),
123+
}),
124+
{
125+
params: {
126+
domain: 'mock-domain',
127+
cluster: 'mock-cluster',
128+
},
129+
} as RequestParams,
130+
{
131+
grpcClusterMethods: mockGrpcClusterMethods,
132+
} as Context
133+
);
134+
135+
return { res, mockCreateSchedule };
136+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ScheduleCatchUpPolicy } from '@/__generated__/proto-ts/uber/cadence/api/v1/ScheduleCatchUpPolicy';
2+
import { ScheduleOverlapPolicy } from '@/__generated__/proto-ts/uber/cadence/api/v1/ScheduleOverlapPolicy';
3+
4+
import { WORKER_SDK_LANGUAGES } from '../start-workflow/start-workflow.constants';
5+
6+
export { WORKER_SDK_LANGUAGES };
7+
8+
export const SCHEDULE_OVERLAP_POLICIES = [
9+
ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_SKIP_NEW,
10+
ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_BUFFER,
11+
ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_CONCURRENT,
12+
ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_CANCEL_PREVIOUS,
13+
ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_TERMINATE_PREVIOUS,
14+
] as const;
15+
16+
export const SCHEDULE_CATCH_UP_POLICIES = [
17+
ScheduleCatchUpPolicy.SCHEDULE_CATCH_UP_POLICY_SKIP,
18+
ScheduleCatchUpPolicy.SCHEDULE_CATCH_UP_POLICY_ONE,
19+
ScheduleCatchUpPolicy.SCHEDULE_CATCH_UP_POLICY_ALL,
20+
] as const;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { NextResponse, type NextRequest } from 'next/server';
2+
3+
import { getHTTPStatusCode, GRPCError } from '@/utils/grpc/grpc-error';
4+
import logger, { type RouteHandlerErrorPayload } from '@/utils/logger';
5+
6+
import { type Context, type RequestParams } from './create-schedule.types';
7+
import transformCreateScheduleBodyToGrpcInput from './helpers/transform-create-schedule-body-to-grpc-input';
8+
import createScheduleRequestBodySchema from './schemas/create-schedule-request-body-schema';
9+
10+
export async function createSchedule(
11+
request: NextRequest,
12+
options: RequestParams,
13+
ctx: Context
14+
) {
15+
const params = options.params;
16+
const requestBody = await request.json();
17+
const { data, error } =
18+
createScheduleRequestBodySchema.safeParse(requestBody);
19+
20+
if (error) {
21+
return NextResponse.json(
22+
{
23+
message: 'Invalid values provided for schedule create',
24+
validationErrors: error.errors,
25+
},
26+
{ status: 400 }
27+
);
28+
}
29+
30+
const grpcPayload = transformCreateScheduleBodyToGrpcInput({
31+
domain: params.domain,
32+
body: data,
33+
});
34+
35+
try {
36+
const response = await ctx.grpcClusterMethods.createSchedule(grpcPayload);
37+
return NextResponse.json(response);
38+
} catch (e) {
39+
logger.error<RouteHandlerErrorPayload>(
40+
{ requestParams: params, error: e },
41+
'Error creating schedule' +
42+
(e instanceof GRPCError ? ': ' + e.message : '')
43+
);
44+
45+
return NextResponse.json(
46+
{
47+
message: e instanceof GRPCError ? e.message : 'Error creating schedule',
48+
cause: e,
49+
},
50+
{ status: getHTTPStatusCode(e) }
51+
);
52+
}
53+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { type z } from 'zod';
2+
3+
import { type CreateScheduleResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/CreateScheduleResponse';
4+
import { type DefaultMiddlewaresContext } from '@/utils/route-handlers-middleware';
5+
6+
import type createScheduleRequestBodySchema from './schemas/create-schedule-request-body-schema';
7+
8+
export type RouteParams = {
9+
domain: string;
10+
cluster: string;
11+
};
12+
13+
export type RequestParams = {
14+
params: RouteParams;
15+
};
16+
17+
export type CreateScheduleRequestBody = z.infer<
18+
typeof createScheduleRequestBodySchema
19+
>;
20+
21+
export type CreateScheduleResponseBody = CreateScheduleResponse;
22+
23+
export type Context = DefaultMiddlewaresContext;

0 commit comments

Comments
 (0)