Skip to content

Commit 701b865

Browse files
committed
feat(schedules): add useCreateSchedule mutation hook with invalidation
PR03: useMutation for POST /api/domains/{domain}/{cluster}/schedules, invalidates schedules infinite-query on success, exposes isPending. Includes browser tests for invalidation and pending state. Commands run: npm run typecheck, npm run test:unit:browser
1 parent 4d3c68c commit 701b865

3 files changed

Lines changed: 305 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { act } from '@testing-library/react';
2+
3+
import { useQueryClient } from '@tanstack/react-query';
4+
import { HttpResponse } from 'msw';
5+
6+
import { renderHook, waitFor } from '@/test-utils/rtl';
7+
8+
import useCreateSchedule from '../use-create-schedule';
9+
import { type UseCreateScheduleVariables } from '../use-create-schedule.types';
10+
11+
jest.mock('@tanstack/react-query', () => ({
12+
...jest.requireActual('@tanstack/react-query'),
13+
useQueryClient: jest.fn(),
14+
}));
15+
16+
const MOCK_DOMAIN = 'test-domain';
17+
const MOCK_CLUSTER = 'test-cluster';
18+
19+
const VALID_CREATE_SCHEDULE_BODY: UseCreateScheduleVariables = {
20+
scheduleId: 'my-schedule',
21+
spec: {
22+
cronExpression: '0 9 * * *',
23+
},
24+
action: {
25+
startWorkflow: {
26+
workflowType: { name: 'DemoWorkflow' },
27+
taskList: { name: 'demo-task-list' },
28+
workerSDKLanguage: 'GO',
29+
workflowIdPrefix: 'scheduled-demo-',
30+
executionStartToCloseTimeoutSeconds: 3600,
31+
},
32+
},
33+
};
34+
35+
describe(useCreateSchedule.name, () => {
36+
let mockInvalidateQueries: jest.Mock;
37+
38+
beforeEach(() => {
39+
mockInvalidateQueries = jest.fn();
40+
(useQueryClient as jest.Mock).mockReturnValue({
41+
invalidateQueries: mockInvalidateQueries,
42+
});
43+
});
44+
45+
it('exposes isPending as false initially', () => {
46+
const { result } = setup({});
47+
48+
expect(result.current.isPending).toBe(false);
49+
});
50+
51+
it('sets isPending to true while mutation is in-flight', async () => {
52+
let resolveRequest: (() => void) | undefined;
53+
const blockingPromise = new Promise<void>((resolve) => {
54+
resolveRequest = resolve;
55+
});
56+
57+
const { result } = setup({
58+
httpResolver: async () => {
59+
await blockingPromise;
60+
return HttpResponse.json({});
61+
},
62+
});
63+
64+
act(() => {
65+
result.current.mutate(VALID_CREATE_SCHEDULE_BODY);
66+
});
67+
68+
await waitFor(() => {
69+
expect(result.current.isPending).toBe(true);
70+
});
71+
72+
act(() => {
73+
resolveRequest?.();
74+
});
75+
76+
await waitFor(() => {
77+
expect(result.current.isPending).toBe(false);
78+
});
79+
});
80+
81+
it('invalidates listSchedules queries on success', async () => {
82+
const { result } = setup({});
83+
84+
await act(async () => {
85+
await result.current.mutateAsync(VALID_CREATE_SCHEDULE_BODY);
86+
});
87+
88+
expect(mockInvalidateQueries).toHaveBeenCalledWith(
89+
expect.objectContaining({
90+
queryKey: [
91+
'listSchedules',
92+
{ domain: MOCK_DOMAIN, cluster: MOCK_CLUSTER },
93+
],
94+
})
95+
);
96+
});
97+
98+
it('sets isSuccess to true after successful mutation', async () => {
99+
const { result } = setup({});
100+
101+
await act(async () => {
102+
await result.current.mutateAsync(VALID_CREATE_SCHEDULE_BODY);
103+
});
104+
105+
expect(result.current.isSuccess).toBe(true);
106+
});
107+
108+
it('surfaces error from API on 400 failure', async () => {
109+
const { result } = setup({
110+
httpResolver: async () =>
111+
HttpResponse.json(
112+
{
113+
message: 'Invalid values provided for schedule create',
114+
validationErrors: [
115+
{ code: 'too_small', path: ['scheduleId'], message: 'Too small' },
116+
],
117+
},
118+
{ status: 400 }
119+
),
120+
});
121+
122+
await act(async () => {
123+
try {
124+
await result.current.mutateAsync(VALID_CREATE_SCHEDULE_BODY);
125+
} catch {
126+
// expected to throw
127+
}
128+
});
129+
130+
await waitFor(() => {
131+
expect(result.current.error).not.toBeNull();
132+
});
133+
134+
expect(result.current.error?.status).toBe(400);
135+
expect(result.current.error?.message).toBe(
136+
'Invalid values provided for schedule create'
137+
);
138+
expect(result.current.error?.validationErrors).toHaveLength(1);
139+
});
140+
141+
it('surfaces error from API on 409 conflict', async () => {
142+
const { result } = setup({
143+
httpResolver: async () =>
144+
HttpResponse.json(
145+
{ message: 'Schedule already exists' },
146+
{ status: 409 }
147+
),
148+
});
149+
150+
await act(async () => {
151+
try {
152+
await result.current.mutateAsync(VALID_CREATE_SCHEDULE_BODY);
153+
} catch {
154+
// expected to throw
155+
}
156+
});
157+
158+
await waitFor(() => {
159+
expect(result.current.error).not.toBeNull();
160+
});
161+
162+
expect(result.current.error?.status).toBe(409);
163+
expect(result.current.error?.message).toBe('Schedule already exists');
164+
});
165+
166+
it('resets mutation state after calling reset()', async () => {
167+
const { result } = setup({
168+
httpResolver: async () =>
169+
HttpResponse.json(
170+
{ message: 'Schedule already exists' },
171+
{ status: 409 }
172+
),
173+
});
174+
175+
await act(async () => {
176+
try {
177+
await result.current.mutateAsync(VALID_CREATE_SCHEDULE_BODY);
178+
} catch {
179+
// expected to throw
180+
}
181+
});
182+
183+
await waitFor(() => {
184+
expect(result.current.error).not.toBeNull();
185+
});
186+
187+
await act(async () => {
188+
result.current.reset();
189+
});
190+
191+
await waitFor(() => {
192+
expect(result.current.error).toBeNull();
193+
});
194+
expect(result.current.isSuccess).toBe(false);
195+
});
196+
197+
it('does not invalidate queries on failure', async () => {
198+
const { result } = setup({
199+
httpResolver: async () =>
200+
HttpResponse.json(
201+
{ message: 'Schedule already exists' },
202+
{ status: 409 }
203+
),
204+
});
205+
206+
await act(async () => {
207+
try {
208+
await result.current.mutateAsync(VALID_CREATE_SCHEDULE_BODY);
209+
} catch {
210+
// expected to throw
211+
}
212+
});
213+
214+
await waitFor(() => {
215+
expect(result.current.error).not.toBeNull();
216+
});
217+
218+
expect(mockInvalidateQueries).not.toHaveBeenCalled();
219+
});
220+
});
221+
222+
function setup({
223+
httpResolver,
224+
}: {
225+
httpResolver?: () => ReturnType<typeof HttpResponse.json> | Promise<ReturnType<typeof HttpResponse.json>>;
226+
} = {}) {
227+
const { result } = renderHook(
228+
() => useCreateSchedule({ domain: MOCK_DOMAIN, cluster: MOCK_CLUSTER }),
229+
{
230+
endpointsMocks: [
231+
{
232+
path: `/api/domains/${MOCK_DOMAIN}/${MOCK_CLUSTER}/schedules`,
233+
httpMethod: 'POST',
234+
mockOnce: false,
235+
httpResolver: httpResolver ?? (async () => HttpResponse.json({})),
236+
},
237+
],
238+
}
239+
);
240+
241+
return { result };
242+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use client';
2+
3+
import { useMutation, useQueryClient } from '@tanstack/react-query';
4+
5+
import request from '@/utils/request';
6+
import { type RequestError } from '@/utils/request/request-error';
7+
8+
import {
9+
type UseCreateScheduleParams,
10+
type UseCreateScheduleResult,
11+
type UseCreateScheduleVariables,
12+
} from './use-create-schedule.types';
13+
14+
export default function useCreateSchedule({
15+
domain,
16+
cluster,
17+
}: UseCreateScheduleParams): UseCreateScheduleResult {
18+
const queryClient = useQueryClient();
19+
20+
const { mutate, mutateAsync, isPending, error, isSuccess, reset } =
21+
useMutation<Record<string, never>, RequestError, UseCreateScheduleVariables>(
22+
{
23+
mutationFn: (variables) =>
24+
request(
25+
`/api/domains/${encodeURIComponent(domain)}/${encodeURIComponent(cluster)}/schedules`,
26+
{
27+
method: 'POST',
28+
body: JSON.stringify(variables),
29+
headers: {
30+
'Content-Type': 'application/json',
31+
},
32+
}
33+
).then((res) => res.json()),
34+
onSuccess: () => {
35+
queryClient.invalidateQueries({
36+
queryKey: ['listSchedules', { domain, cluster }],
37+
});
38+
},
39+
}
40+
);
41+
42+
return { mutate, mutateAsync, isPending, error, isSuccess, reset };
43+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { type CreateScheduleRequestBody } from '@/route-handlers/create-schedule/create-schedule.types';
2+
import { type RequestError } from '@/utils/request/request-error';
3+
4+
export type UseCreateScheduleParams = {
5+
domain: string;
6+
cluster: string;
7+
};
8+
9+
export type UseCreateScheduleVariables = CreateScheduleRequestBody;
10+
11+
export type UseCreateScheduleResult = {
12+
mutate: (variables: UseCreateScheduleVariables) => void;
13+
mutateAsync: (
14+
variables: UseCreateScheduleVariables
15+
) => Promise<Record<string, never>>;
16+
isPending: boolean;
17+
error: RequestError | null;
18+
isSuccess: boolean;
19+
reset: () => void;
20+
};

0 commit comments

Comments
 (0)