Skip to content

Commit af17e6c

Browse files
feat(schedule-details): SLICE-2 — describeSchedule gRPC + GET route handler
Wire DescribeSchedule into the gRPC client and expose a typed GET handler: - Add DescribeScheduleRequest/Response imports + describeSchedule method to GRPCClusterMethods type and getClusterServicesMethods (scheduleService) - Add describeSchedule: jest.fn() to grpc-cluster-methods mock - Route handler describe-schedule.ts: GET /api/domains/[domain]/[cluster]/schedules/[scheduleId] calls grpcClusterMethods.describeSchedule, returns DTO; logs and maps GRPCErrors - Fixtures: running schedule, paused schedule (with pauseInfo.pausedBy + reason), no-next-run variant — covers chart gutter + banner (SLICE-8.3 / SLICE-9) test needs - Node tests: happy path, paused state, NOT_FOUND → 404, generic error → 500 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 15fc173 commit af17e6c

7 files changed

Lines changed: 300 additions & 0 deletions

File tree

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 { describeSchedule } from '@/route-handlers/describe-schedule/describe-schedule';
4+
import { type RouteParams } from '@/route-handlers/describe-schedule/describe-schedule.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 GET(
9+
request: NextRequest,
10+
options: { params: RouteParams }
11+
) {
12+
return routeHandlerWithMiddlewares(
13+
describeSchedule,
14+
request,
15+
options,
16+
routeHandlersDefaultMiddlewares
17+
);
18+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { type DescribeScheduleResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/DescribeScheduleResponse';
2+
3+
export function getMockDescribeScheduleResponse(
4+
overrides: Partial<DescribeScheduleResponse> = {}
5+
): DescribeScheduleResponse {
6+
return {
7+
spec: {
8+
cronExpression: '0 * * * *',
9+
startTime: null,
10+
endTime: null,
11+
jitter: null,
12+
},
13+
action: {
14+
startWorkflowAction: {
15+
workflowType: { name: 'mock-workflow-type' },
16+
taskList: { name: 'mock-task-list', kind: 'TASK_LIST_KIND_NORMAL' },
17+
input: null,
18+
workflowIdPrefix: 'mock-prefix',
19+
executionStartToCloseTimeout: { seconds: '3600', nanos: 0 },
20+
taskStartToCloseTimeout: { seconds: '10', nanos: 0 },
21+
retryPolicy: null,
22+
memo: null,
23+
searchAttributes: null,
24+
},
25+
},
26+
policies: {
27+
overlapPolicy: 'SCHEDULE_OVERLAP_POLICY_SKIP',
28+
catchUpPolicy: 'SCHEDULE_CATCH_UP_POLICY_UNLIMITED',
29+
catchUpWindow: null,
30+
pauseOnFailure: false,
31+
bufferLimit: 0,
32+
concurrencyLimit: 0,
33+
},
34+
state: {
35+
paused: false,
36+
pauseInfo: null,
37+
},
38+
info: {
39+
lastRunTime: { seconds: '1700000000', nanos: 0 },
40+
nextRunTime: { seconds: '1700003600', nanos: 0 },
41+
totalRuns: '42',
42+
createTime: { seconds: '1699000000', nanos: 0 },
43+
lastUpdateTime: { seconds: '1700000000', nanos: 0 },
44+
ongoingBackfills: [],
45+
},
46+
memo: null,
47+
searchAttributes: null,
48+
...overrides,
49+
};
50+
}
51+
52+
export const mockRunningDescribeScheduleResponse =
53+
getMockDescribeScheduleResponse();
54+
55+
export const mockPausedDescribeScheduleResponse =
56+
getMockDescribeScheduleResponse({
57+
state: {
58+
paused: true,
59+
pauseInfo: {
60+
reason: 'Paused for maintenance',
61+
pausedAt: { seconds: '1700001000', nanos: 0 },
62+
pausedBy: 'operator@example.com',
63+
},
64+
},
65+
});
66+
67+
export const mockDescribeScheduleResponseNoNextRun =
68+
getMockDescribeScheduleResponse({
69+
info: {
70+
lastRunTime: { seconds: '1700000000', nanos: 0 },
71+
nextRunTime: null,
72+
totalRuns: '10',
73+
createTime: { seconds: '1699000000', nanos: 0 },
74+
lastUpdateTime: { seconds: '1700000000', nanos: 0 },
75+
ongoingBackfills: [],
76+
},
77+
});
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 {
9+
mockRunningDescribeScheduleResponse,
10+
mockPausedDescribeScheduleResponse,
11+
} from '../__fixtures__/mock-describe-schedule-response';
12+
import { describeSchedule } from '../describe-schedule';
13+
import { type Context, type RequestParams } from '../describe-schedule.types';
14+
15+
jest.mock('@/utils/logger');
16+
17+
describe(describeSchedule.name, () => {
18+
beforeEach(() => {
19+
jest.resetAllMocks();
20+
});
21+
22+
it('calls describeSchedule and returns the response for a running schedule', async () => {
23+
const { res, mockDescribeSchedule } = await setup({
24+
response: mockRunningDescribeScheduleResponse,
25+
});
26+
27+
expect(mockDescribeSchedule).toHaveBeenCalledWith({
28+
domain: 'mock-domain',
29+
scheduleId: 'mock-schedule-id',
30+
});
31+
32+
expect(res.status).toEqual(200);
33+
const responseJson = await res.json();
34+
expect(responseJson.state.paused).toBe(false);
35+
expect(responseJson.info.nextRunTime).toBeTruthy();
36+
});
37+
38+
it('returns describe response for a paused schedule', async () => {
39+
const { res } = await setup({
40+
response: mockPausedDescribeScheduleResponse,
41+
});
42+
43+
expect(res.status).toEqual(200);
44+
const responseJson = await res.json();
45+
expect(responseJson.state.paused).toBe(true);
46+
expect(responseJson.state.pauseInfo.reason).toBe('Paused for maintenance');
47+
expect(responseJson.state.pauseInfo.pausedBy).toBe(
48+
'operator@example.com'
49+
);
50+
});
51+
52+
it('returns 404 when describeSchedule throws a NOT_FOUND GRPCError', async () => {
53+
const { res } = await setup({
54+
error: new GRPCError('schedule not found', {
55+
grpcStatusCode: status.NOT_FOUND,
56+
}),
57+
});
58+
59+
expect(res.status).toEqual(404);
60+
const responseJson = await res.json();
61+
expect(responseJson).toEqual(
62+
expect.objectContaining({ message: 'schedule not found' })
63+
);
64+
65+
expect(logger.error).toHaveBeenCalledWith(
66+
expect.objectContaining({
67+
requestParams: {
68+
domain: 'mock-domain',
69+
cluster: 'mock-cluster',
70+
scheduleId: 'mock-schedule-id',
71+
},
72+
error: expect.any(GRPCError),
73+
}),
74+
'Error describing schedule: schedule not found'
75+
);
76+
});
77+
78+
it('returns 500 when describeSchedule throws a generic error', async () => {
79+
const { res } = await setup({
80+
error: new Error('connection reset'),
81+
});
82+
83+
expect(res.status).toEqual(500);
84+
const responseJson = await res.json();
85+
expect(responseJson).toEqual(
86+
expect.objectContaining({ message: 'Error describing schedule' })
87+
);
88+
89+
expect(logger.error).toHaveBeenCalledWith(
90+
expect.objectContaining({
91+
requestParams: {
92+
domain: 'mock-domain',
93+
cluster: 'mock-cluster',
94+
scheduleId: 'mock-schedule-id',
95+
},
96+
error: expect.any(Error),
97+
}),
98+
'Error describing schedule'
99+
);
100+
});
101+
});
102+
103+
async function setup({
104+
response,
105+
error,
106+
}: {
107+
response?: Awaited<
108+
ReturnType<typeof mockGrpcClusterMethods.describeSchedule>
109+
>;
110+
error?: Error;
111+
} = {}) {
112+
const mockDescribeSchedule = jest
113+
.spyOn(mockGrpcClusterMethods, 'describeSchedule')
114+
.mockImplementationOnce(async () => {
115+
if (error) {
116+
throw error;
117+
}
118+
return response ?? mockRunningDescribeScheduleResponse;
119+
});
120+
121+
const res = await describeSchedule(
122+
new NextRequest('http://localhost'),
123+
{
124+
params: {
125+
domain: 'mock-domain',
126+
cluster: 'mock-cluster',
127+
scheduleId: 'mock-schedule-id',
128+
},
129+
} as RequestParams,
130+
{
131+
grpcClusterMethods: mockGrpcClusterMethods,
132+
} as Context
133+
);
134+
135+
return { res, mockDescribeSchedule };
136+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { type NextRequest, NextResponse } 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, type RouteParams } from './describe-schedule.types';
7+
8+
export async function describeSchedule(
9+
_request: NextRequest,
10+
requestParams: RequestParams,
11+
ctx: Context
12+
) {
13+
const params = requestParams.params as RouteParams;
14+
15+
try {
16+
const response = await ctx.grpcClusterMethods.describeSchedule({
17+
domain: params.domain,
18+
scheduleId: params.scheduleId,
19+
});
20+
21+
return NextResponse.json(response);
22+
} catch (e) {
23+
logger.error<RouteHandlerErrorPayload>(
24+
{ requestParams: params, error: e },
25+
'Error describing schedule' +
26+
(e instanceof GRPCError ? ': ' + e.message : '')
27+
);
28+
29+
return NextResponse.json(
30+
{
31+
message:
32+
e instanceof GRPCError ? e.message : 'Error describing schedule',
33+
cause: e,
34+
},
35+
{
36+
status: getHTTPStatusCode(e),
37+
}
38+
);
39+
}
40+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { type DescribeScheduleResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/DescribeScheduleResponse';
2+
import { type DefaultMiddlewaresContext } from '@/utils/route-handlers-middleware';
3+
4+
export type RouteParams = {
5+
domain: string;
6+
cluster: string;
7+
scheduleId: string;
8+
};
9+
10+
export type RequestParams = {
11+
params: RouteParams;
12+
};
13+
14+
export type DescribeScheduleDTO = DescribeScheduleResponse;
15+
16+
export type Context = DefaultMiddlewaresContext;

src/utils/grpc/grpc-client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { type CountWorkflowExecutionsRequest__Input } from '@/__generated__/prot
55
import { type CountWorkflowExecutionsResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/CountWorkflowExecutionsResponse';
66
import { type CreateScheduleRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/CreateScheduleRequest';
77
import { type CreateScheduleResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/CreateScheduleResponse';
8+
import { type DescribeScheduleRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/DescribeScheduleRequest';
9+
import { type DescribeScheduleResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/DescribeScheduleResponse';
810
import { type DescribeDomainRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/DescribeDomainRequest';
911
import { type DescribeDomainResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/DescribeDomainResponse';
1012
import { type DescribeTaskListRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/DescribeTaskListRequest';
@@ -80,6 +82,9 @@ export type GRPCClusterMethods = {
8082
createSchedule: (
8183
payload: CreateScheduleRequest__Input
8284
) => Promise<CreateScheduleResponse>;
85+
describeSchedule: (
86+
payload: DescribeScheduleRequest__Input
87+
) => Promise<DescribeScheduleResponse>;
8388
describeCluster: (
8489
payload: DescribeClusterRequest__Input
8590
) => Promise<DescribeClusterResponse>;
@@ -255,6 +260,13 @@ const getClusterServicesMethods = async (
255260
method: 'CreateSchedule',
256261
metadata: metadata,
257262
}),
263+
describeSchedule: scheduleService.request<
264+
DescribeScheduleRequest__Input,
265+
DescribeScheduleResponse
266+
>({
267+
method: 'DescribeSchedule',
268+
metadata: metadata,
269+
}),
258270
describeCluster: adminService.request<
259271
DescribeClusterRequest__Input,
260272
DescribeClusterResponse

src/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const mockGrpcClusterMethods: GRPCClusterMethods = {
55
closedWorkflows: jest.fn(),
66
countWorkflows: jest.fn(),
77
createSchedule: jest.fn(),
8+
describeSchedule: jest.fn(),
89
describeCluster: jest.fn(),
910
describeDomain: jest.fn(),
1011
updateDomain: jest.fn(),

0 commit comments

Comments
 (0)