feat(web/m7-recurring-scans): scan-schedule API client + query
Adds ScanSchedule type, getScanSchedule / updateScanSchedule against the new admin endpoints, createScanScheduleQuery (60s staleTime; refetches on focus + after PATCH invalidation — no polling). New qk.scanSchedule() key follows the existing tuple pattern. ScanScheduleUpdate uses optional fields so mode=off updates can omit per-mode fields entirely (server normalizes anyway). Vitest covers the GET path, PATCH with body, and the mode=off minimal-body case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { getScanSchedule, updateScanSchedule, type ScanSchedule } from './admin';
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
describe('admin scan-schedule API', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('getScanSchedule GETs the correct path', async () => {
|
||||||
|
const sample: ScanSchedule = {
|
||||||
|
mode: 'daily',
|
||||||
|
interval_hours: null,
|
||||||
|
time_of_day: '03:00',
|
||||||
|
weekly_day: null,
|
||||||
|
next_scheduled_at: '2026-05-07T03:00:00Z'
|
||||||
|
};
|
||||||
|
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||||
|
const got = await getScanSchedule();
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/admin/scan/schedule');
|
||||||
|
expect(got).toEqual(sample);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateScanSchedule PATCHes with the right body', async () => {
|
||||||
|
const sample: ScanSchedule = {
|
||||||
|
mode: 'interval',
|
||||||
|
interval_hours: 6,
|
||||||
|
time_of_day: null,
|
||||||
|
weekly_day: null,
|
||||||
|
next_scheduled_at: '2026-05-06T20:00:00Z'
|
||||||
|
};
|
||||||
|
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||||
|
const got = await updateScanSchedule({ mode: 'interval', interval_hours: 6 });
|
||||||
|
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', {
|
||||||
|
mode: 'interval',
|
||||||
|
interval_hours: 6
|
||||||
|
});
|
||||||
|
expect(got.mode).toBe('interval');
|
||||||
|
expect(got.interval_hours).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateScanSchedule with mode=off sends just mode', async () => {
|
||||||
|
const sample: ScanSchedule = {
|
||||||
|
mode: 'off',
|
||||||
|
interval_hours: null,
|
||||||
|
time_of_day: null,
|
||||||
|
weekly_day: null,
|
||||||
|
next_scheduled_at: null
|
||||||
|
};
|
||||||
|
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||||
|
const got = await updateScanSchedule({ mode: 'off' });
|
||||||
|
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', { mode: 'off' });
|
||||||
|
expect(got.next_scheduled_at).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -329,3 +329,40 @@ export function createCoverProvidersQuery() {
|
|||||||
staleTime: 60_000
|
staleTime: 60_000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan schedule -----------------------------------------------------------
|
||||||
|
|
||||||
|
export type ScanScheduleMode = 'off' | 'interval' | 'daily' | 'weekly';
|
||||||
|
|
||||||
|
export type ScanSchedule = {
|
||||||
|
mode: ScanScheduleMode;
|
||||||
|
interval_hours: number | null;
|
||||||
|
time_of_day: string | null; // 'HH:MM'
|
||||||
|
weekly_day: number | null; // 1..7 (Mon..Sun, ISO 8601)
|
||||||
|
next_scheduled_at: string | null; // RFC3339 ISO timestamp; null when mode='off'
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScanScheduleUpdate = {
|
||||||
|
mode: ScanScheduleMode;
|
||||||
|
interval_hours?: number | null;
|
||||||
|
time_of_day?: string | null;
|
||||||
|
weekly_day?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getScanSchedule(): Promise<ScanSchedule> {
|
||||||
|
return api.get<ScanSchedule>('/api/admin/scan/schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise<ScanSchedule> {
|
||||||
|
return api.patch<ScanSchedule>('/api/admin/scan/schedule', patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings rarely change in operator time. 60s staleTime; refetches
|
||||||
|
// on window focus + after any PATCH invalidation — no polling.
|
||||||
|
export function createScanScheduleQuery() {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.scanSchedule(),
|
||||||
|
queryFn: getScanSchedule,
|
||||||
|
staleTime: 60_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const qk = {
|
|||||||
adminQuarantineActions: (limit?: number) =>
|
adminQuarantineActions: (limit?: number) =>
|
||||||
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
|
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
|
||||||
scanStatus: () => ['scanStatus'] as const,
|
scanStatus: () => ['scanStatus'] as const,
|
||||||
|
scanSchedule: () => ['scanSchedule'] as const,
|
||||||
coverage: () => ['coverage'] as const,
|
coverage: () => ['coverage'] as const,
|
||||||
coverProviders: () => ['coverProviders'] as const,
|
coverProviders: () => ['coverProviders'] as const,
|
||||||
suggestions: (limit?: number) =>
|
suggestions: (limit?: number) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user