From 50f2876682a8ccecef718c788cf6440f0c620e5d Mon Sep 17 00:00:00 2001 From: strandedturtle Date: Fri, 26 Jun 2026 07:09:07 +0000 Subject: [PATCH] Phase 3: scheduled background checks + Discord notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Diun's scheduled ping with our own. New modules: - scheduler.js — a setInterval-based background checker that runs the same registry check the button triggers, on a configurable interval; re-armed when settings change, overlap-guarded, started on boot / stopped on shutdown. - notify.js — formats a concise "updates available" Discord message and POSTs it to a webhook (pure payload builder + sender). Dedupe: a `notified` column on update_events (added via migration) so each update is announced once; new digests re-notify. Settings gain backgroundCheckEnabled, backgroundCheckIntervalHours, discordEnabled, discordWebhookUrl (env-seedable: BACKGROUND_CHECK_ENABLED, CHECK_INTERVAL_HOURS, DISCORD_WEBHOOK_URL). New POST /api/notify/test + get a "Send test message" button. Settings page gains a "Background checks & Discord" section. Docs + API_CONTRACT updated. Server tests 68/68; client builds clean. --- .env.example | 9 ++ API_CONTRACT.md | 26 ++++- README.md | 18 ++++ client/src/api.js | 4 + client/src/pages/SettingsPage.jsx | 168 ++++++++++++++++++++++++++---- client/src/styles/app.css | 29 ++++++ server/src/db.js | 26 +++++ server/src/index.js | 5 + server/src/notify.js | 74 +++++++++++++ server/src/routes/api.js | 19 ++++ server/src/scheduler.js | 104 ++++++++++++++++++ server/src/settings.js | 65 ++++++++++-- server/test/notify.test.js | 19 ++++ server/test/settings.test.js | 25 ++++- 14 files changed, 554 insertions(+), 37 deletions(-) create mode 100644 server/src/notify.js create mode 100644 server/src/scheduler.js create mode 100644 server/test/notify.test.js diff --git a/.env.example b/.env.example index e2d5248..538d983 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,15 @@ SESSION_TTL=604800 # If this starts with https, the login cookie is marked Secure. BASE_URL=http://localhost:5000 +# --- Background checks & notifications (all optional; also editable in the UI) --- +# Whether the server checks for updates on a schedule. Default: true. +# BACKGROUND_CHECK_ENABLED=true +# How often the background check runs, in hours (1-168). Default: 6. +# CHECK_INTERVAL_HOURS=6 +# Discord (or compatible) webhook URL to notify when updates are found. +# Leave unset to disable notifications. +# DISCORD_WEBHOOK_URL= + # Name of this app's OWN container. It is excluded from the dashboard so it # can't be told to update (and thereby restart) itself mid-update. Defaults to # "diun-updater" (the container_name in the shipped docker-compose.yml); change diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 8a3dc44..af8a619 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -145,20 +145,42 @@ separate section, but can still be updated by hand. - Auth: cookie. - Response: `200` — current settings, fully populated with defaults: ```json - { "defaultFilter": "updates", "autoCheckOnOpen": true } + { + "defaultFilter": "updates", + "autoCheckOnOpen": true, + "backgroundCheckEnabled": true, + "backgroundCheckIntervalHours": 6, + "discordEnabled": false, + "discordWebhookUrl": "" + } ``` - `defaultFilter` — `"updates"` or `"all"`; the view the dashboard opens in. - `autoCheckOnOpen` — whether the dashboard runs a check automatically on first open. + - `backgroundCheckEnabled` — whether the server runs a scheduled check. + - `backgroundCheckIntervalHours` — interval for that check (1–168). + - `discordEnabled` — whether to send Discord notifications on new updates. + - `discordWebhookUrl` — Discord (or compatible) webhook URL, or `""`. ### `PUT /api/settings` - Auth: cookie. - Body: a partial patch of the settings object, e.g. `{ "defaultFilter": "all" }`. Unknown keys are ignored; invalid values for known keys return - `400 { "error": "invalid_value" }`. + `400 { "error": "invalid_value" }`. Changing the interval/enable re-arms the + background scheduler immediately. - Response: `200` — the full, updated settings object. +### `POST /api/notify/test` + +- Auth: cookie. +- Body: `{ "url": "string" }` (optional) — a webhook URL to test; falls back to + the configured `discordWebhookUrl`. +- Sends a one-off test message to the webhook. +- Response: `200 { "ok": true }` on success; `400 { "error": "no_webhook" }` if + no URL is configured; `502 { "error": "webhook_failed" }` if the webhook + rejected the message. + ### `GET /api/health` - Auth: none. diff --git a/README.md b/README.md index dbf95b6..84324a4 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,21 @@ over the standard token flow — Docker Hub, GHCR, lscr.io, quay.io, etc. for public images. Private images that require credentials are skipped (counted under `errors`). +### Background checks & Discord notifications + +By default the server also checks on a schedule (every 6h) so badges stay fresh +even when the app is closed. Configure it under **Settings → Background checks & +Discord**: + +- **Background checks** on/off and interval. +- **Discord webhook URL** — paste a Discord channel webhook to get a message when + updates are found, then use **Send test message** to verify it. Each update is + announced once (no repeats on every check). + +These can also be seeded from the environment (`BACKGROUND_CHECK_ENABLED`, +`CHECK_INTERVAL_HOURS`, `DISCORD_WEBHOOK_URL`); the Settings UI overrides at +runtime. + --- ## Configuration reference @@ -312,6 +327,9 @@ All configuration is via environment variables (see `.env.example`). | `DATA_DIR` | `/data` | | SQLite (`app.db`) location; persist via a volume. | | `SESSION_TTL` | `604800` | | Login cookie lifetime in seconds (7 days). | | `BASE_URL` | `http://localhost:5000` | | Public URL; if `https`, the cookie is set `Secure`. | +| `DISCORD_WEBHOOK_URL` | — | | Discord webhook for update notifications (optional; also set in Settings). | +| `CHECK_INTERVAL_HOURS` | `6` | | Background check interval in hours (1–168). | +| `BACKGROUND_CHECK_ENABLED` | `true` | | Whether the scheduled background check runs. | | `SELF_CONTAINER_NAME` | `diun-updater` | | This app's own container name, excluded from the dashboard so it can't update itself. | The two required vars are enforced at startup — the server refuses to boot diff --git a/client/src/api.js b/client/src/api.js index 51e1701..cc58bb5 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -121,4 +121,8 @@ export function updateSettings(patch) { return request('PUT', '/settings', patch); } +export function testNotify(url) { + return post('/notify/test', url ? { url } : {}); +} + export { ApiError }; diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx index 50e327d..e3ecdf6 100644 --- a/client/src/pages/SettingsPage.jsx +++ b/client/src/pages/SettingsPage.jsx @@ -1,11 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { - get, - getPinned, - unpin, - getSettings, - updateSettings, -} from '../api.js'; +import { get, getPinned, unpin, getSettings, updateSettings, testNotify } from '../api.js'; import { useTheme } from '../hooks/useTheme.js'; export default function SettingsPage() { @@ -19,6 +13,11 @@ export default function SettingsPage() { const [settings, setSettings] = useState(null); const [settingsError, setSettingsError] = useState(''); + const [webhookDraft, setWebhookDraft] = useState(''); + const [webhookInit, setWebhookInit] = useState(false); + const [testing, setTesting] = useState(false); + const [testStatus, setTestStatus] = useState(''); + const [health, setHealth] = useState(null); // null = unknown, true/false once checked const loadPinned = useCallback(async () => { @@ -42,26 +41,48 @@ export default function SettingsPage() { .catch((err) => setSettingsError(err.message || 'Failed to load settings')); }, []); + // Seed the webhook input once settings arrive. + useEffect(() => { + if (settings && !webhookInit) { + setWebhookDraft(settings.discordWebhookUrl || ''); + setWebhookInit(true); + } + }, [settings, webhookInit]); + useEffect(() => { get('/health') .then((data) => setHealth(!!(data && data.ok))) .catch(() => setHealth(false)); }, []); - const saveSetting = useCallback( - async (patch) => { - // optimistic - setSettings((prev) => ({ ...prev, ...patch })); - setSettingsError(''); - try { - const updated = await updateSettings(patch); - setSettings(updated); - } catch (err) { - setSettingsError(err.message || 'Failed to save settings'); + const saveSetting = useCallback(async (patch) => { + setSettings((prev) => ({ ...prev, ...patch })); // optimistic + setSettingsError(''); + try { + const updated = await updateSettings(patch); + setSettings(updated); + return updated; + } catch (err) { + setSettingsError(err.message || 'Failed to save settings'); + throw err; + } + }, []); + + const runTest = useCallback(async () => { + setTesting(true); + setTestStatus(''); + try { + if (settings && webhookDraft !== settings.discordWebhookUrl) { + await saveSetting({ discordWebhookUrl: webhookDraft }); } - }, - [] - ); + await testNotify(webhookDraft || undefined); + setTestStatus('Sent — check your Discord channel.'); + } catch (err) { + setTestStatus(err.message || 'Test failed'); + } finally { + setTesting(false); + } + }, [webhookDraft, settings, saveSetting]); const handleUnpin = useCallback( async (ref) => { @@ -120,7 +141,7 @@ export default function SettingsPage() { + +
+
+ Check every + How often the background check runs. +
+ +
+
+
+ Discord webhook URL + + Get pinged when updates are found. Paste a Discord channel webhook URL. + +
+ setWebhookDraft(e.target.value)} + onBlur={() => { + if (settings && webhookDraft !== settings.discordWebhookUrl) { + saveSetting({ discordWebhookUrl: webhookDraft }).catch(() => {}); + } + }} + disabled={!settings} + /> +
+
+
+ Send notifications + Enable Discord notifications for background checks. +
+ +
+
+ + {testStatus && {testStatus}} +
+ +

Pinned versions

{pinnedLoading && ( diff --git a/client/src/styles/app.css b/client/src/styles/app.css index f9a5873..81255c6 100644 --- a/client/src/styles/app.css +++ b/client/src/styles/app.css @@ -1184,3 +1184,32 @@ a { color: var(--color-accent); background: var(--color-elevated); } + +/* ---------- Settings inputs (Phase 3) ---------- */ + +.settings-input, +.settings-select { + min-height: var(--touch-target); + padding: 8px 12px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text); + font-size: 0.9rem; +} + +.settings-input { + width: 100%; +} + +.settings-row-stack { + flex-direction: column; + align-items: stretch; + gap: 8px; +} + +.settings-test-status { + margin-left: 10px; + font-size: 0.82rem; + color: var(--color-text-muted); +} diff --git a/server/src/db.js b/server/src/db.js index 6ec933c..ba5866d 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -44,6 +44,14 @@ CREATE INDEX IF NOT EXISTS idx_events_ref ON update_events(normalized_ref, resol CREATE INDEX IF NOT EXISTS idx_history_created ON update_history(created_at DESC); `); +// Migration: add `notified` to update_events (dedupe Discord notifications). +{ + const cols = db.prepare('PRAGMA table_info(update_events)').all().map((col) => col.name); + if (!cols.includes('notified')) { + db.exec('ALTER TABLE update_events ADD COLUMN notified INTEGER DEFAULT 0'); + } +} + const stmts = { recordEvent: db.prepare(` INSERT INTO update_events (image, normalized_ref, status, digest, raw_json) @@ -96,6 +104,12 @@ const stmts = { INSERT INTO settings (key, value) VALUES (@key, @value) ON CONFLICT(key) DO UPDATE SET value = excluded.value `), + getUnnotifiedRefs: db.prepare(` + SELECT DISTINCT normalized_ref FROM update_events WHERE resolved = 0 AND notified = 0 + `), + markRefNotified: db.prepare(` + UPDATE update_events SET notified = 1 WHERE resolved = 0 AND normalized_ref = ? + `), }; export function recordEvent({ image, normalized_ref, status, digest, raw_json }) { @@ -167,4 +181,16 @@ export function setSetting(key, value) { return stmts.setSetting.run({ key, value: value == null ? null : String(value) }); } +export function getUnnotifiedRefs() { + return stmts.getUnnotifiedRefs.all().map((r) => r.normalized_ref); +} + +const markRefsNotifiedTxn = db.transaction((refs) => { + for (const ref of refs) stmts.markRefNotified.run(ref); +}); + +export function markRefsNotified(refs) { + if (Array.isArray(refs) && refs.length) markRefsNotifiedTxn(refs); +} + export default db; diff --git a/server/src/index.js b/server/src/index.js index fdaff67..6cf443d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -9,6 +9,7 @@ import db from './db.js'; import { authRouter, requireAuth } from './auth.js'; import { apiRouter } from './routes/api.js'; import { updateRouter } from './routes/update.js'; +import scheduler from './scheduler.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -65,6 +66,9 @@ const server = app.listen(config.PORT, () => { console.log(`Diun Updater server listening at ${config.BASE_URL} (port ${config.PORT})`); }); +// Background update checker (interval + Discord notify per settings). +scheduler.start(); + // Graceful shutdown: stop accepting connections and checkpoint/close SQLite // so a `docker stop` doesn't leave the WAL or an in-flight write half-done. let shuttingDown = false; @@ -72,6 +76,7 @@ function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; console.log(`Received ${signal}, shutting down…`); + scheduler.stop(); server.close(() => { try { db.close(); diff --git a/server/src/notify.js b/server/src/notify.js new file mode 100644 index 0000000..dbd13ff --- /dev/null +++ b/server/src/notify.js @@ -0,0 +1,74 @@ +/** + * Discord webhook notifier. Formats a concise "updates available" message and + * POSTs it to a Discord (or Discord-compatible) webhook URL. + * + * The payload builder is a pure function so it can be unit-tested without the + * network; `sendDiscord` does the actual POST. + */ + +const MAX_LISTED = 25; // keep the message from blowing past Discord's limits + +/** + * Build a Discord webhook JSON payload from a list of containers that have an + * available update. + * + * @param {Array<{ name: string, image: string, currentVersion?: string|null }>} items + * @returns {{ content: string }} + */ +export function buildDiscordPayload(items) { + const n = items.length; + const header = `🔔 **${n} container update${n === 1 ? '' : 's'} available**`; + const lines = items.slice(0, MAX_LISTED).map((i) => { + const ver = i.currentVersion ? ` (current: ${i.currentVersion})` : ''; + return `• **${i.name}** — \`${i.image}\`${ver}`; + }); + if (n > MAX_LISTED) { + lines.push(`…and ${n - MAX_LISTED} more.`); + } + return { content: [header, ...lines].join('\n') }; +} + +/** + * POST a payload to a webhook URL. Returns `{ ok, status }`; never throws for + * an HTTP error (only for a network failure / timeout, which the caller + * handles). Discord returns 204 on success. + * + * @param {string} url + * @param {object} payload + * @param {{ timeoutMs?: number }} [opts] + * @returns {Promise<{ ok: boolean, status: number }>} + */ +export async function postWebhook(url, payload, { timeoutMs = 10000 } = {}) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(timeoutMs), + }); + return { ok: res.ok, status: res.status }; +} + +/** + * Send an "updates available" notification for the given items. + * + * @param {string} url - Discord webhook URL. + * @param {Array} items + * @returns {Promise<{ ok: boolean, status: number }>} + */ +export function sendDiscordUpdates(url, items) { + return postWebhook(url, buildDiscordPayload(items)); +} + +/** + * Send a one-off test message so the user can confirm their webhook works. + * + * @param {string} url + * @returns {Promise<{ ok: boolean, status: number }>} + */ +export function sendDiscordTest(url) { + return postWebhook(url, { + content: '✅ Diun Updater test message — your Discord webhook is configured correctly.', + }); +} + +export default { buildDiscordPayload, postWebhook, sendDiscordUpdates, sendDiscordTest }; diff --git a/server/src/routes/api.js b/server/src/routes/api.js index d24df1f..7c010c8 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -14,6 +14,8 @@ import { normalizeRef } from '../reconcile.js'; import { runCheck } from '../checker.js'; import { subscribeGlobal, broadcastGlobal } from '../sse.js'; import { getSettings, updateSettings } from '../settings.js'; +import scheduler from '../scheduler.js'; +import { sendDiscordTest } from '../notify.js'; import * as db from '../db.js'; export const apiRouter = express.Router(); @@ -133,6 +135,7 @@ apiRouter.get('/api/settings', (req, res) => { apiRouter.put('/api/settings', (req, res) => { try { const updated = updateSettings(req.body || {}); + scheduler.reschedule(); return res.status(200).json(updated); } catch (err) { if (err.code === 'invalid_value') { @@ -142,4 +145,20 @@ apiRouter.put('/api/settings', (req, res) => { } }); +// Send a test Discord message to the configured (or supplied) webhook URL. +apiRouter.post('/api/notify/test', async (req, res) => { + const url = + (typeof req.body?.url === 'string' && req.body.url.trim()) || getSettings().discordWebhookUrl; + if (!url) { + return res.status(400).json({ error: 'no_webhook', message: 'No Discord webhook URL configured.' }); + } + try { + const result = await sendDiscordTest(url); + if (result.ok) return res.status(200).json({ ok: true }); + return res.status(502).json({ error: 'webhook_failed', status: result.status }); + } catch (err) { + return res.status(502).json({ error: 'webhook_failed', message: err.message }); + } +}); + export default apiRouter; diff --git a/server/src/scheduler.js b/server/src/scheduler.js new file mode 100644 index 0000000..16de434 --- /dev/null +++ b/server/src/scheduler.js @@ -0,0 +1,104 @@ +/** + * Background update checker. Periodically runs the same registry check the + * "Check for updates" button triggers, then (if a Discord webhook is + * configured) notifies about any newly-found updates — deduped so each update + * is announced once. + * + * Interval/enable are driven by settings; call `reschedule()` after settings + * change so the timer picks up the new values. A plain setInterval is enough — + * no cron dependency. + */ + +import { getSettings } from './settings.js'; +import { runCheck } from './checker.js'; +import { listContainers } from './docker.js'; +import { buildContainerItems } from './containers-service.js'; +import { normalizeRef } from './reconcile.js'; +import { sendDiscordUpdates } from './notify.js'; +import * as db from './db.js'; + +let timer = null; +let running = false; + +/** + * Run a check, then notify Discord about updates not yet announced. Resilient: + * any failure (no Docker daemon, registry error, webhook down) is logged, not + * thrown — the scheduler keeps ticking. + */ +export async function runScheduledCheck() { + await runCheck(); + + const settings = getSettings(); + if (!settings.discordEnabled || !settings.discordWebhookUrl) { + return; // checks still keep the dashboard fresh; just no notification + } + + const unnotified = new Set(db.getUnnotifiedRefs()); + if (unnotified.size === 0) return; + + const containers = await listContainers(); + const { items } = buildContainerItems({ + containers, + lookupEvent: db.latestUnresolvedEventForRef, + isPinned: (ref) => db.isPinned(ref), + }); + + const toNotify = items.filter((i) => { + if (!i.updateAvailable || i.pinned) return false; + try { + return unnotified.has(normalizeRef(i.image)); + } catch { + return false; + } + }); + if (toNotify.length === 0) return; + + const result = await sendDiscordUpdates(settings.discordWebhookUrl, toNotify); + if (result.ok) { + db.markRefsNotified(toNotify.map((i) => normalizeRef(i.image))); + } else { + console.warn(`scheduler: Discord webhook returned ${result.status}; will retry next run`); + } +} + +async function tick() { + if (running) return; // never overlap runs + running = true; + try { + await runScheduledCheck(); + } catch (err) { + console.warn(`scheduler: scheduled check failed: ${err.message}`); + } finally { + running = false; + } +} + +/** + * (Re)arm the interval from current settings. Clears any existing timer first. + * No-op timer when background checks are disabled. + */ +export function reschedule() { + if (timer) { + clearInterval(timer); + timer = null; + } + const s = getSettings(); + if (!s.backgroundCheckEnabled) return; + const hours = Math.min(Math.max(s.backgroundCheckIntervalHours || 6, 1), 168); + timer = setInterval(tick, hours * 3600 * 1000); + // setInterval keeps the event loop alive; that's fine for a long-running + // server, and graceful shutdown clears it. +} + +export function start() { + reschedule(); +} + +export function stop() { + if (timer) { + clearInterval(timer); + timer = null; + } +} + +export default { start, stop, reschedule, runScheduledCheck }; diff --git a/server/src/settings.js b/server/src/settings.js index 4b01424..9d23dad 100644 --- a/server/src/settings.js +++ b/server/src/settings.js @@ -1,11 +1,12 @@ /** * App settings: a small typed layer over the key/value `settings` table. * - * Each setting declares a default and a coercion from the stored string. New - * settings (e.g. the background scheduler / Discord webhook in a later phase) - * just get added to SPEC. `getSettings()` always returns a fully-populated, - * typed object (defaults merged over stored values); `updateSettings()` takes - * a partial patch, validates known keys, and persists them. + * Each setting declares a default and coercions from the stored string and + * from a client-supplied input. `getSettings()` always returns a fully + * populated, typed object (defaults merged over stored values); + * `updateSettings()` takes a partial patch, validates known keys, and persists + * them. Defaults can be seeded from env vars so ops can configure via the + * environment, with the Settings UI overriding at runtime. */ import * as db from './db.js'; @@ -20,21 +21,67 @@ function enumOf(allowed, fallback) { return (v) => (allowed.includes(v) ? v : fallback); } +function intInOrUndef(min, max) { + return (v) => { + const n = typeof v === 'number' ? v : parseInt(v, 10); + if (!Number.isFinite(n) || n < min || n > max) return undefined; + return Math.trunc(n); + }; +} + +function urlOrUndef(v) { + if (v === '') return ''; + if (typeof v === 'string' && /^https?:\/\//i.test(v.trim())) return v.trim(); + return undefined; +} + +// --- env-seeded defaults --- +const ENV_WEBHOOK = process.env.DISCORD_WEBHOOK_URL || ''; +const ENV_INTERVAL = intInOrUndef(1, 168)(process.env.CHECK_INTERVAL_HOURS) ?? 6; +const ENV_BG_ENABLED = bool(process.env.BACKGROUND_CHECK_ENABLED, true); + const SPEC = { defaultFilter: { default: 'updates', fromStore: enumOf(['updates', 'all'], 'updates'), - fromInput: enumOf(['updates', 'all'], undefined), // undefined -> rejected + fromInput: enumOf(['updates', 'all'], undefined), }, autoCheckOnOpen: { default: true, fromStore: (v) => bool(v, true), fromInput: (v) => (typeof v === 'boolean' ? v : undefined), }, + backgroundCheckEnabled: { + default: ENV_BG_ENABLED, + fromStore: (v) => bool(v, ENV_BG_ENABLED), + fromInput: (v) => (typeof v === 'boolean' ? v : undefined), + }, + backgroundCheckIntervalHours: { + default: ENV_INTERVAL, + fromStore: (v) => intInOrUndef(1, 168)(v) ?? ENV_INTERVAL, + fromInput: intInOrUndef(1, 168), + }, + discordEnabled: { + default: ENV_WEBHOOK !== '', + fromStore: (v) => bool(v, ENV_WEBHOOK !== ''), + fromInput: (v) => (typeof v === 'boolean' ? v : undefined), + }, + discordWebhookUrl: { + default: ENV_WEBHOOK, + fromStore: (v) => (typeof v === 'string' ? v : ENV_WEBHOOK), + fromInput: urlOrUndef, + }, }; /** - * @returns {{ defaultFilter: 'updates'|'all', autoCheckOnOpen: boolean }} + * @returns {{ + * defaultFilter: 'updates'|'all', + * autoCheckOnOpen: boolean, + * backgroundCheckEnabled: boolean, + * backgroundCheckIntervalHours: number, + * discordEnabled: boolean, + * discordWebhookUrl: string, + * }} */ export function getSettings() { const stored = db.getAllSettings(); @@ -51,7 +98,7 @@ export function getSettings() { * feedback). Returns the full, updated settings object. * * @param {Record} patch - * @returns {{ defaultFilter: string, autoCheckOnOpen: boolean }} + * @returns {object} the full, updated settings * @throws {Error} with `.code = 'invalid_value'` on a bad known value. */ export function updateSettings(patch) { @@ -69,7 +116,7 @@ export function updateSettings(patch) { err.code = 'invalid_value'; throw err; } - db.setSetting(key, typeof coerced === 'boolean' ? (coerced ? '1' : '0') : coerced); + db.setSetting(key, typeof coerced === 'boolean' ? (coerced ? '1' : '0') : String(coerced)); } return getSettings(); } diff --git a/server/test/notify.test.js b/server/test/notify.test.js new file mode 100644 index 0000000..0578974 --- /dev/null +++ b/server/test/notify.test.js @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildDiscordPayload } from '../src/notify.js'; + +test('buildDiscordPayload: header pluralizes and lists items', () => { + const p = buildDiscordPayload([ + { name: 'jellyfin', image: 'jellyfin/jellyfin:latest', currentVersion: '10.9.0' }, + { name: 'radarr', image: 'lscr.io/linuxserver/radarr:latest', currentVersion: null }, + ]); + assert.match(p.content, /2 container updates available/); + assert.match(p.content, /jellyfin/); + assert.match(p.content, /current: 10\.9\.0/); + assert.match(p.content, /radarr/); +}); + +test('buildDiscordPayload: singular for one item', () => { + const p = buildDiscordPayload([{ name: 'nginx', image: 'nginx:latest' }]); + assert.match(p.content, /1 container update available/); +}); diff --git a/server/test/settings.test.js b/server/test/settings.test.js index aa478d9..25fc5a4 100644 --- a/server/test/settings.test.js +++ b/server/test/settings.test.js @@ -12,14 +12,21 @@ process.env.DATA_DIR = tmp; const { getSettings, updateSettings } = await import('../src/settings.js'); test('settings: defaults when nothing stored', () => { - assert.deepEqual(getSettings(), { defaultFilter: 'updates', autoCheckOnOpen: true }); + assert.deepEqual(getSettings(), { + defaultFilter: 'updates', + autoCheckOnOpen: true, + backgroundCheckEnabled: true, + backgroundCheckIntervalHours: 6, + discordEnabled: false, + discordWebhookUrl: '', + }); }); -test('settings: updateSettings persists and coerces booleans', () => { +test('settings: persists and coerces booleans/filter', () => { const s = updateSettings({ defaultFilter: 'all', autoCheckOnOpen: false }); assert.equal(s.defaultFilter, 'all'); assert.equal(s.autoCheckOnOpen, false); - assert.deepEqual(getSettings(), { defaultFilter: 'all', autoCheckOnOpen: false }); + assert.equal(getSettings().defaultFilter, 'all'); }); test('settings: rejects invalid known values', () => { @@ -30,3 +37,15 @@ test('settings: ignores unknown keys', () => { assert.doesNotThrow(() => updateSettings({ somethingUnknown: 'x' })); }); +test('settings: interval bounds enforced', () => { + assert.equal(updateSettings({ backgroundCheckIntervalHours: 12 }).backgroundCheckIntervalHours, 12); + assert.throws(() => updateSettings({ backgroundCheckIntervalHours: 0 }), /invalid value/); + assert.throws(() => updateSettings({ backgroundCheckIntervalHours: 999 }), /invalid value/); +}); + +test('settings: webhook url validated, empty allowed', () => { + const s = updateSettings({ discordWebhookUrl: 'https://discord.com/api/webhooks/1/abc' }); + assert.equal(s.discordWebhookUrl, 'https://discord.com/api/webhooks/1/abc'); + assert.throws(() => updateSettings({ discordWebhookUrl: 'not-a-url' }), /invalid value/); + assert.equal(updateSettings({ discordWebhookUrl: '' }).discordWebhookUrl, ''); +});