diff --git a/web/src/lib/api/admin.scan-schedule.test.ts b/web/src/lib/api/admin.scan-schedule.test.ts new file mode 100644 index 00000000..86001b5c --- /dev/null +++ b/web/src/lib/api/admin.scan-schedule.test.ts @@ -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).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).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).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); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 80ce50d3..b2412954 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -329,3 +329,40 @@ export function createCoverProvidersQuery() { 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 { + return api.get('/api/admin/scan/schedule'); +} + +export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise { + return api.patch('/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 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index e94e3b04..94cf64cf 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -35,6 +35,7 @@ export const qk = { adminQuarantineActions: (limit?: number) => ['adminQuarantineActions', { limit: limit ?? 50 }] as const, scanStatus: () => ['scanStatus'] as const, + scanSchedule: () => ['scanSchedule'] as const, coverage: () => ['coverage'] as const, coverProviders: () => ['coverProviders'] as const, suggestions: (limit?: number) =>