diff --git a/lib/cron/index.js b/lib/cron/index.js index 03f7be2..bd4afcc 100644 --- a/lib/cron/index.js +++ b/lib/cron/index.js @@ -5,6 +5,7 @@ import { enqueue } from '../jobs/queue.js'; import { checkAll } from '../health/checker.js'; import * as statusRepo from '../db/repos/service_status.js'; import * as services from '../db/repos/monitored_services.js'; +import { runDeviceScanCycle } from '../infra/scan_cycle.js'; export function startCron() { // Daily at 03:00 local time @@ -35,5 +36,11 @@ export function startCron() { } catch (e) { log.error({ err: e }, 'health check failed'); } }); + // Hourly LAN device scan (staggered off the :00 speedtest) + cron.schedule('7 * * * *', async () => { + try { await runDeviceScanCycle(); } + catch (e) { log.error({ err: e }, 'device scan cycle failed'); } + }); + log.info('cron started'); } diff --git a/lib/infra/scan_cycle.js b/lib/infra/scan_cycle.js new file mode 100644 index 0000000..c55b697 --- /dev/null +++ b/lib/infra/scan_cycle.js @@ -0,0 +1,19 @@ +// One discovery cycle: scan → upsert → mark-absent → prune. Deps injected for +// tests. Prune only runs after a successful, non-empty scan, so a failed scan +// can never reap rows. +import { runScan } from './scan.js'; +import * as devices from '../db/repos/lan_devices.js'; +import { log } from '../log.js'; + +export async function runDeviceScanCycle({ scan = runScan, repo = devices } = {}) { + const rows = await scan(); + if (!rows.length) { + log.warn('device scan returned no hosts; skipping upsert/prune'); + return { seen: 0 }; + } + await repo.upsertScan(rows); + await repo.markAbsent(rows.map(r => r.mac)); + const pruned = await repo.prune(); + log.info({ seen: rows.length, pruned }, 'device scan cycle complete'); + return { seen: rows.length, pruned }; +} diff --git a/tests/infra/scan_cycle.test.js b/tests/infra/scan_cycle.test.js new file mode 100644 index 0000000..4993e8d --- /dev/null +++ b/tests/infra/scan_cycle.test.js @@ -0,0 +1,32 @@ +// tests/infra/scan_cycle.test.js +import { describe, it, expect, vi } from 'vitest'; +import { runDeviceScanCycle } from '../../lib/infra/scan_cycle.js'; + +function fakeRepo() { + return { + calls: [], + upsertScan: vi.fn(async r => r.length), + markAbsent: vi.fn(async () => 1), + prune: vi.fn(async () => 2) + }; +} + +describe('runDeviceScanCycle', () => { + it('scan→upsert→markAbsent→prune on a non-empty scan', async () => { + const repo = fakeRepo(); + const scan = vi.fn(async () => [{ mac: 'aa:bb:cc:dd:ee:ff', ip: '1.2.3.4', vendor: 'x', randomized: false }]); + const res = await runDeviceScanCycle({ scan, repo }); + expect(repo.upsertScan).toHaveBeenCalledOnce(); + expect(repo.markAbsent).toHaveBeenCalledWith(['aa:bb:cc:dd:ee:ff']); + expect(repo.prune).toHaveBeenCalledOnce(); + expect(res).toEqual({ seen: 1, pruned: 2 }); + }); + + it('skips upsert/prune when the scan returns nothing', async () => { + const repo = fakeRepo(); + const res = await runDeviceScanCycle({ scan: async () => [], repo }); + expect(repo.upsertScan).not.toHaveBeenCalled(); + expect(repo.prune).not.toHaveBeenCalled(); + expect(res).toEqual({ seen: 0 }); + }); +});