Skip to content

Commit 997de95

Browse files
feat(schedule-details): SLICE-3 — useDescribeSchedule hook with 10s polling
Add useDescribeSchedule and colocated query options under use-describe-schedule/: - getDescribeScheduleQueryKey — namespaced ['describeSchedule', {domain,cluster,scheduleId}] for future mutation invalidation - getDescribeScheduleQueryOptions — refetchInterval: 10_000 while data.state.paused===false; false when paused, before first success, or on error — no tight polling loops - useDescribeSchedule — thin useQuery wrapper; exposes data, error, isLoading, isFetching, isPending, refetch - Browser tests: happy path (running + paused), error state, isLoading flag - Unit tests for refetchInterval: running→10s, paused→false, undefined data→false; queryKey stability across same/different scheduleIds Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 57700a1 commit 997de95

5 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
mockRunningDescribeScheduleResponse,
3+
mockPausedDescribeScheduleResponse,
4+
} from '@/route-handlers/describe-schedule/__fixtures__/mock-describe-schedule-response';
5+
6+
import getDescribeScheduleQueryOptions, {
7+
getDescribeScheduleQueryKey,
8+
} from '../get-describe-schedule-query-options';
9+
import { type DescribeScheduleDTO } from '../use-describe-schedule.types';
10+
11+
const params = {
12+
domain: 'test-domain',
13+
cluster: 'test-cluster',
14+
scheduleId: 'test-schedule-id',
15+
};
16+
17+
describe(getDescribeScheduleQueryOptions.name, () => {
18+
it('returns namespaced queryKey including domain, cluster, and scheduleId', () => {
19+
const options = getDescribeScheduleQueryOptions(params);
20+
expect(options.queryKey).toEqual(['describeSchedule', params]);
21+
});
22+
23+
it('returns refetchInterval of 10s when schedule is running', () => {
24+
const options = getDescribeScheduleQueryOptions(params);
25+
const refetchInterval = options.refetchInterval;
26+
27+
if (typeof refetchInterval !== 'function') {
28+
throw new Error('Expected refetchInterval to be a function');
29+
}
30+
31+
const mockQuery = {
32+
state: { data: mockRunningDescribeScheduleResponse },
33+
} as Parameters<typeof refetchInterval>[0];
34+
35+
expect(refetchInterval(mockQuery)).toBe(10_000);
36+
});
37+
38+
it('returns refetchInterval false when schedule is paused', () => {
39+
const options = getDescribeScheduleQueryOptions(params);
40+
const refetchInterval = options.refetchInterval;
41+
42+
if (typeof refetchInterval !== 'function') {
43+
throw new Error('Expected refetchInterval to be a function');
44+
}
45+
46+
const mockQuery = {
47+
state: { data: mockPausedDescribeScheduleResponse },
48+
} as Parameters<typeof refetchInterval>[0];
49+
50+
expect(refetchInterval(mockQuery)).toBe(false);
51+
});
52+
53+
it('returns refetchInterval false when data is undefined (initial load)', () => {
54+
const options = getDescribeScheduleQueryOptions(params);
55+
const refetchInterval = options.refetchInterval;
56+
57+
if (typeof refetchInterval !== 'function') {
58+
throw new Error('Expected refetchInterval to be a function');
59+
}
60+
61+
const mockQuery = {
62+
state: { data: undefined as unknown as DescribeScheduleDTO },
63+
} as Parameters<typeof refetchInterval>[0];
64+
65+
expect(refetchInterval(mockQuery)).toBe(false);
66+
});
67+
68+
it('encodes domain, cluster, scheduleId in the query URL', () => {
69+
const encodedParams = {
70+
domain: 'my domain',
71+
cluster: 'my cluster',
72+
scheduleId: 'schedule/id',
73+
};
74+
const options = getDescribeScheduleQueryOptions(encodedParams);
75+
const queryFn = options.queryFn;
76+
77+
if (typeof queryFn !== 'function') {
78+
throw new Error('Expected queryFn to be a function');
79+
}
80+
81+
const mockFetch = jest.fn().mockReturnValue({ json: jest.fn() });
82+
jest.mock('@/utils/request', () => mockFetch);
83+
});
84+
});
85+
86+
describe(getDescribeScheduleQueryKey.name, () => {
87+
it('returns stable key for same params', () => {
88+
expect(getDescribeScheduleQueryKey(params)).toEqual(
89+
getDescribeScheduleQueryKey(params)
90+
);
91+
});
92+
93+
it('returns different keys for different scheduleIds', () => {
94+
const key1 = getDescribeScheduleQueryKey({ ...params, scheduleId: 'a' });
95+
const key2 = getDescribeScheduleQueryKey({ ...params, scheduleId: 'b' });
96+
expect(key1).not.toEqual(key2);
97+
});
98+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { HttpResponse } from 'msw';
2+
3+
import { renderHook, waitFor } from '@/test-utils/rtl';
4+
import {
5+
mockRunningDescribeScheduleResponse,
6+
mockPausedDescribeScheduleResponse,
7+
} from '@/route-handlers/describe-schedule/__fixtures__/mock-describe-schedule-response';
8+
9+
import useDescribeSchedule from '../use-describe-schedule';
10+
11+
describe(useDescribeSchedule.name, () => {
12+
it('returns describe data for a running schedule', async () => {
13+
const { result } = setup({
14+
response: mockRunningDescribeScheduleResponse,
15+
});
16+
17+
await waitFor(() => {
18+
expect(result.current.data).toBeDefined();
19+
});
20+
21+
expect(result.current.data?.state.paused).toBe(false);
22+
expect(result.current.data?.info.nextRunTime).toBeTruthy();
23+
});
24+
25+
it('returns describe data for a paused schedule', async () => {
26+
const { result } = setup({
27+
response: mockPausedDescribeScheduleResponse,
28+
});
29+
30+
await waitFor(() => {
31+
expect(result.current.data).toBeDefined();
32+
});
33+
34+
expect(result.current.data?.state.paused).toBe(true);
35+
expect(result.current.data?.state.pauseInfo?.pausedBy).toBe(
36+
'operator@example.com'
37+
);
38+
expect(result.current.data?.state.pauseInfo?.reason).toBe(
39+
'Paused for maintenance'
40+
);
41+
});
42+
43+
it('surfaces error when API fails', async () => {
44+
const { result } = setup({ error: true });
45+
46+
await waitFor(() => {
47+
expect(result.current.error).toBeDefined();
48+
});
49+
});
50+
51+
it('exposes isLoading during initial fetch', () => {
52+
const { result } = setup({
53+
response: mockRunningDescribeScheduleResponse,
54+
});
55+
56+
expect(result.current.isLoading).toBe(true);
57+
});
58+
});
59+
60+
function setup({
61+
response,
62+
error = false,
63+
}: {
64+
response?: typeof mockRunningDescribeScheduleResponse;
65+
error?: boolean;
66+
} = {}) {
67+
return renderHook(
68+
() =>
69+
useDescribeSchedule({
70+
domain: 'test-domain',
71+
cluster: 'test-cluster',
72+
scheduleId: 'test-schedule-id',
73+
}),
74+
{
75+
endpointsMocks: [
76+
{
77+
path: '/api/domains/:domain/:cluster/schedules/:scheduleId',
78+
httpMethod: 'GET',
79+
mockOnce: false,
80+
httpResolver: async () => {
81+
if (error) {
82+
return HttpResponse.json(
83+
{ message: 'Failed to describe schedule' },
84+
{ status: 500 }
85+
);
86+
}
87+
return HttpResponse.json(response ?? mockRunningDescribeScheduleResponse);
88+
},
89+
},
90+
],
91+
}
92+
);
93+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { type UseQueryOptions } from '@tanstack/react-query';
2+
3+
import request from '@/utils/request';
4+
import { type RequestError } from '@/utils/request/request-error';
5+
6+
import {
7+
type DescribeScheduleDTO,
8+
type DescribeScheduleQueryKey,
9+
type UseDescribeScheduleParams,
10+
} from './use-describe-schedule.types';
11+
12+
const DESCRIBE_SCHEDULE_REFETCH_INTERVAL_MS = 10_000;
13+
14+
function isScheduleRunning(data: DescribeScheduleDTO): boolean {
15+
return data.state?.paused === false;
16+
}
17+
18+
export function getDescribeScheduleQueryKey(
19+
params: UseDescribeScheduleParams
20+
): DescribeScheduleQueryKey {
21+
return ['describeSchedule', params];
22+
}
23+
24+
export default function getDescribeScheduleQueryOptions(
25+
params: UseDescribeScheduleParams
26+
): UseQueryOptions<
27+
DescribeScheduleDTO,
28+
RequestError,
29+
DescribeScheduleDTO,
30+
DescribeScheduleQueryKey
31+
> {
32+
const { domain, cluster, scheduleId } = params;
33+
return {
34+
queryKey: getDescribeScheduleQueryKey(params),
35+
queryFn: ({ queryKey: [_, p] }) =>
36+
request(
37+
`/api/domains/${encodeURIComponent(p.domain)}/${encodeURIComponent(p.cluster)}/schedules/${encodeURIComponent(p.scheduleId)}`
38+
).then((res) => res.json()),
39+
refetchInterval: (query) => {
40+
const data = query.state.data;
41+
if (data && isScheduleRunning(data)) {
42+
return DESCRIBE_SCHEDULE_REFETCH_INTERVAL_MS;
43+
}
44+
return false;
45+
},
46+
};
47+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use client';
2+
import { useQuery } from '@tanstack/react-query';
3+
4+
import getDescribeScheduleQueryOptions from './get-describe-schedule-query-options';
5+
import { type UseDescribeScheduleParams } from './use-describe-schedule.types';
6+
7+
export default function useDescribeSchedule(
8+
params: UseDescribeScheduleParams
9+
) {
10+
return useQuery(getDescribeScheduleQueryOptions(params));
11+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { type DescribeScheduleDTO } from '@/route-handlers/describe-schedule/describe-schedule.types';
2+
3+
export type UseDescribeScheduleParams = {
4+
domain: string;
5+
cluster: string;
6+
scheduleId: string;
7+
};
8+
9+
export type DescribeScheduleQueryKey = [
10+
'describeSchedule',
11+
UseDescribeScheduleParams,
12+
];
13+
14+
export type { DescribeScheduleDTO };

0 commit comments

Comments
 (0)