From 0cc3d6255dc74e75a888696360ea2106a5f5ceaa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 6 May 2026 22:28:22 -0400 Subject: [PATCH] feat(web/m7-recurring-scans): scan-schedule API client + query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/lib/api/admin.scan-schedule.test.ts | 58 +++++++++++++++++++++ web/src/lib/api/admin.ts | 37 +++++++++++++ web/src/lib/api/queries.ts | 1 + 3 files changed, 96 insertions(+) create mode 100644 web/src/lib/api/admin.scan-schedule.test.ts 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) =>