diff --git a/lib/db/repos/lan_devices.js b/lib/db/repos/lan_devices.js new file mode 100644 index 0000000..d0ea987 --- /dev/null +++ b/lib/db/repos/lan_devices.js @@ -0,0 +1,73 @@ +import { pool } from '../pool.js'; + +const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present'; + +export async function listKnown() { + const { rows } = await pool.query( + `SELECT ${COLS} FROM lan_devices WHERE status='known' ORDER BY grp, name NULLS LAST, ip`); + return rows; +} + +export async function listDiscovered() { + const { rows } = await pool.query( + `SELECT ${COLS} FROM lan_devices WHERE status='new' ORDER BY last_seen DESC`); + return rows; +} + +export async function get(mac) { + const { rows: [r] } = await pool.query(`SELECT ${COLS} FROM lan_devices WHERE mac=$1`, [mac]); + return r || null; +} + +// Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present +// WITHOUT touching owner-curated name/grp/status/flagged. +export async function upsertScan(rows) { + for (const r of rows) { + await pool.query( + `INSERT INTO lan_devices (mac, ip, vendor, randomized, status, present, first_seen, last_seen) + VALUES ($1,$2,$3,$4,'new',true,now(),now()) + ON CONFLICT (mac) DO UPDATE SET + ip = EXCLUDED.ip, + vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor), + last_seen = now(), present = true`, + [r.mac, r.ip ?? null, r.vendor ?? null, !!r.randomized]); + } + return rows.length; +} + +// Mark devices not in the latest scan as absent. Empty input is a no-op so a +// failed/empty scan can never blanket-mark everything offline. +export async function markAbsent(seenMacs) { + if (!seenMacs || !seenMacs.length) return 0; + const { rowCount } = await pool.query( + `UPDATE lan_devices SET present=false WHERE present=true AND NOT (mac = ANY($1::text[]))`, + [seenMacs]); + return rowCount; +} + +// Reap unreviewed + absent rows past their TTL. Never touches known/ignored. +export async function prune() { + const { rowCount } = await pool.query( + `DELETE FROM lan_devices WHERE status='new' AND present=false AND ( + (randomized AND last_seen < now() - interval '24 hours') OR + (NOT randomized AND last_seen < now() - interval '14 days'))`); + return rowCount; +} + +const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged']; +export async function update(mac, patch) { + const sets = [], vals = []; + for (const k of PATCHABLE) { + if (patch[k] !== undefined) { vals.push(patch[k]); sets.push(`${k}=$${vals.length}`); } + } + if (!sets.length) return get(mac); + vals.push(mac); + const { rows: [r] } = await pool.query( + `UPDATE lan_devices SET ${sets.join(', ')} WHERE mac=$${vals.length} RETURNING ${COLS}`, vals); + return r || null; +} + +export async function remove(mac) { + const { rowCount } = await pool.query(`DELETE FROM lan_devices WHERE mac=$1`, [mac]); + return rowCount > 0; +} diff --git a/tests/repos/lan_devices.test.js b/tests/repos/lan_devices.test.js new file mode 100644 index 0000000..3399d82 --- /dev/null +++ b/tests/repos/lan_devices.test.js @@ -0,0 +1,63 @@ +// tests/repos/lan_devices.test.js +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { pool } from '../../lib/db/pool.js'; +import * as repo from '../../lib/db/repos/lan_devices.js'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('lan_devices repo', () => { + it('seed: 17 known, 1 discovered (ASUS)', async () => { + expect(await repo.listKnown()).toHaveLength(17); + const disc = await repo.listDiscovered(); + expect(disc).toHaveLength(1); + expect(disc[0].mac).toBe('24:4b:fe:8e:09:a4'); + expect(disc[0].flagged).toBe(true); + }); + + it('upsertScan inserts unseen as new, updates known IP without clobbering name', async () => { + await repo.upsertScan([ + { mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: 'NewCo', randomized: false }, // new + { mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.77', vendor: 'Netgear', randomized: false } // known Orbi, IP changed + ]); + const orbi = await repo.get('bc:a5:11:3e:06:88'); + expect(orbi.ip).toBe('192.168.1.77'); // ip updated + expect(orbi.name).toBe('Orbi Satellite'); // name preserved + expect(orbi.status).toBe('known'); // status preserved + expect(orbi.present).toBe(true); + const fresh = await repo.get('aa:bb:cc:dd:ee:ff'); + expect(fresh.status).toBe('new'); + }); + + it('markAbsent flips present for unseen; empty list is a no-op', async () => { + await repo.upsertScan([{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: '', randomized: false }]); + await repo.markAbsent(['aa:bb:cc:dd:ee:ff']); // only this one seen + expect((await repo.get('bc:a5:11:3e:06:88')).present).toBe(false); // seeded device now absent + expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(true); + const before = (await repo.get('aa:bb:cc:dd:ee:ff')).present; + expect(await repo.markAbsent([])).toBe(0); // guard: no-op + expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(before); + }); + + it('prune deletes stale new+absent (randomized >24h, others >14d); keeps known', async () => { + await pool.query(`INSERT INTO lan_devices (mac, status, randomized, present, last_seen) + VALUES ('11:11:11:11:11:11','new',true,false, now()-interval '2 days'), + ('22:22:22:22:22:22','new',false,false, now()-interval '20 days'), + ('33:33:33:33:33:33','new',true,false, now()-interval '1 hour'), + ('44:44:44:44:44:44','known',true,false, now()-interval '99 days')`); + const n = await repo.prune(); + expect(n).toBe(2); // the two stale 'new' + expect(await repo.get('33:33:33:33:33:33')).not.toBeNull(); // recent kept + expect(await repo.get('44:44:44:44:44:44')).not.toBeNull(); // known kept + }); + + it('update promotes + names a discovered device', async () => { + await repo.update('24:4b:fe:8e:09:a4', { name: 'ASUS RT-AX88U', grp: 'Network', status: 'known', flagged: false }); + expect(await repo.listDiscovered()).toHaveLength(0); + const d = await repo.get('24:4b:fe:8e:09:a4'); + expect(d.name).toBe('ASUS RT-AX88U'); + expect(d.status).toBe('known'); + }); +});