diff --git a/src/lib/scheduler.test.ts b/src/lib/scheduler.test.ts index d993e16..20f3bce 100644 --- a/src/lib/scheduler.test.ts +++ b/src/lib/scheduler.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { schedulerConfigFromEnv, runJob, startScheduler, type SchedulerConfig, type SchedulerDeps } from "./scheduler"; +import { schedulerConfigFromEnv, schedulerHealthCheck, runJob, startScheduler, type SchedulerConfig, type SchedulerDeps } from "./scheduler"; function fakeDeps(overrides: Partial = {}): SchedulerDeps & { logs: Array<[string, unknown]> } { const logs: Array<[string, unknown]> = []; @@ -120,7 +120,41 @@ describe("startScheduler", () => { // fire the startup timer -> it runs once and arms the interval const startupCb = (deps.setTimeout as ReturnType).mock.calls[0][0] as () => void; startupCb(); - expect(deps.fetch).toHaveBeenCalledTimes(1); + expect(deps.fetch).toHaveBeenCalledTimes(2); // health check + job expect(deps.setInterval).toHaveBeenCalledWith(expect.any(Function), 900000); }); }); + +describe("schedulerHealthCheck", () => { + it("returns true when health endpoint responds with 200", async () => { + const mockFetch = vi.fn(async () => new Response(null, { status: 200 })) as unknown as typeof globalThis.fetch; + const deps = fakeDeps({ fetch: mockFetch }); + const result = await schedulerHealthCheck(CONFIG, deps); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith("http://127.0.0.1:3000/api/health", { + headers: { Authorization: "Bearer tok" }, + }); + }); + + it("returns false and logs when health endpoint returns non-ok status", async () => { + const mockFetch = vi.fn(async () => new Response(null, { status: 503 })) as unknown as typeof globalThis.fetch; + const log = vi.fn(); + const deps = fakeDeps({ fetch: mockFetch, log }); + const result = await schedulerHealthCheck(CONFIG, deps); + + expect(result).toBe(false); + expect(log).toHaveBeenCalledWith("scheduler health check failed — HTTP 503"); + }); + + it("returns false and logs when fetch throws", async () => { + const mockFetch = vi.fn(async () => { throw new Error("ECONNREFUSED"); }) as unknown as typeof globalThis.fetch; + const log = vi.fn(); + const deps = fakeDeps({ fetch: mockFetch, log }); + const result = await schedulerHealthCheck(CONFIG, deps); + + expect(result).toBe(false); + expect(log).toHaveBeenCalledWith("scheduler health check failed: ECONNREFUSED", expect.any(Error)); + }); +}); diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index 4b001db..117e83a 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -109,6 +109,31 @@ export function schedulerConfigFromEnv(env: Record): }; } +/** + * Startup health check: verify the scheduler can reach its own endpoints. + * Returns true on HTTP 200, false otherwise. Does not prevent the scheduler + * from running — provides visibility into misconfiguration without blocking + * periodic work. + */ +export async function schedulerHealthCheck(config: SchedulerConfig, deps: SchedulerDeps): Promise { + try { + const res = await deps.fetch(`${config.baseUrl}/api/health`, { + headers: { + Authorization: `Bearer ${config.token}`, + }, + }); + if (!res.ok) { + deps.log(`scheduler health check failed — HTTP ${res.status}`); + return false; + } + return true; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + deps.log(`scheduler health check failed: ${msg}`, error); + return false; + } +} + /** Fire one job. Never throws — a transient failure must not kill the interval. */ export async function runJob(job: ScheduledJob, config: SchedulerConfig, deps: SchedulerDeps): Promise { try { @@ -148,6 +173,7 @@ export function startScheduler(config: SchedulerConfig, deps: SchedulerDeps): un for (const job of config.jobs) { deps.log(`scheduling "${job.name}" every ${job.intervalMs}ms -> ${job.path}`); deps.setTimeout(() => { + void schedulerHealthCheck(config, deps); void runJob(job, config, deps); const handle = deps.setInterval(() => void runJob(job, config, deps), job.intervalMs); handles.push(handle);