feat: log failures, validate env at startup, load .env (findings 3, 4, 7)

Finding 3: fetch_error, not_found, mail_error and de onverwachte-fout
branch loggen nu naam/url plus reden naar stderr; runCheck logt na elke
run een regel met de tellingen per status, zodat een cron-run zichtbaar
is in de container-logs. De recordCheckSuccess/recordCheckFailure-condities
en alle return-waarden zijn ongewijzigd.

Finding 4: server.js controleert bij het opstarten dat BASIC_AUTH_USER,
BASIC_AUTH_PASS, SMTP_FROM en NOTIFY_EMAIL gezet zijn en stopt anders met
exit code 1 in plaats van later opake 500's te geven.

Finding 7: dotenv toegevoegd en als eerste regel in server.js geladen,
zodat het gedocumenteerde `cp .env.example .env && npm start` echt werkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz
This commit is contained in:
Sjoerd de Vries
2026-09-05 15:52:57 +02:00
parent 52c9947267
commit fb3fc62b4b
6 changed files with 70 additions and 6 deletions
+4
View File
@@ -12,6 +12,10 @@ npm test
npm start npm start
``` ```
De app leest `.env` in via `dotenv`. `BASIC_AUTH_USER`, `BASIC_AUTH_PASS`,
`SMTP_FROM` en `NOTIFY_EMAIL` zijn verplicht; ontbreken ze, dan stopt de
app bij het opstarten met een melding.
De UI staat achter basic auth (`BASIC_AUTH_USER` / `BASIC_AUTH_PASS`) op De UI staat achter basic auth (`BASIC_AUTH_USER` / `BASIC_AUTH_PASS`) op
`http://localhost:3000`. `http://localhost:3000`.
+13
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"dotenv": "^17.4.2",
"express": "^4.19.2", "express": "^4.19.2",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
@@ -527,6 +528,18 @@
"url": "https://github.com/fb55/domutils?sponsor=1" "url": "https://github.com/fb55/domutils?sponsor=1"
} }
}, },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"dotenv": "^17.4.2",
"express": "^4.19.2", "express": "^4.19.2",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
+25 -1
View File
@@ -1,3 +1,7 @@
// Leest een .env-bestand in voor lokale ontwikkeling; in productie komen de
// env vars van Coolify. quiet houdt de opstartlogging schoon.
require('dotenv').config({ quiet: true });
const path = require('node:path'); const path = require('node:path');
const { const {
openDb, listProducts, addProduct, deleteProduct, openDb, listProducts, addProduct, deleteProduct,
@@ -9,12 +13,29 @@ const { checkAllProducts } = require('./src/checkProducts');
const { start: startScheduler } = require('./src/scheduler'); const { start: startScheduler } = require('./src/scheduler');
const { createApp } = require('./src/app'); const { createApp } = require('./src/app');
const REQUIRED_ENV = ['BASIC_AUTH_USER', 'BASIC_AUTH_PASS', 'SMTP_FROM', 'NOTIFY_EMAIL'];
const missing = REQUIRED_ENV.filter((name) => !process.env[name]);
if (missing.length > 0) {
console.error(`Ontbrekende verplichte environment variables: ${missing.join(', ')}`);
console.error('Zet ze (bijvoorbeeld in .env, zie .env.example) en start opnieuw.');
process.exit(1);
}
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'korting.db'); const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'korting.db');
const db = openDb(DB_PATH); const db = openDb(DB_PATH);
const transport = createTransport(process.env); const transport = createTransport(process.env);
function summarize(results) {
const counts = {};
for (const result of results) {
counts[result.status] = (counts[result.status] || 0) + 1;
}
const parts = Object.entries(counts).map(([status, count]) => `${status}=${count}`);
return parts.length > 0 ? parts.join(' ') : 'geen producten';
}
function runCheck() { function runCheck() {
const products = listProducts(db); const products = listProducts(db);
const deps = { const deps = {
@@ -28,7 +49,10 @@ function runCheck() {
recordCheckSuccess: (id, data) => recordCheckSuccess(db, id, data), recordCheckSuccess: (id, data) => recordCheckSuccess(db, id, data),
now: () => new Date().toISOString(), now: () => new Date().toISOString(),
}; };
return checkAllProducts(products, deps); return checkAllProducts(products, deps).then((results) => {
console.log(`[check] ${products.length} product(en) gecontroleerd: ${summarize(results)}`);
return results;
});
} }
startScheduler(runCheck); startScheduler(runCheck);
+5 -1
View File
@@ -3,10 +3,12 @@ async function checkProduct(product, deps) {
const result = await fetchAndExtractPrice(product.url); const result = await fetchAndExtractPrice(product.url);
if (result.error) { if (result.error) {
console.error(`[check] fetch_error "${product.name}" (${product.url}): ${result.error.message}`);
recordCheckFailure(product.id, 'fetch_error'); recordCheckFailure(product.id, 'fetch_error');
return { status: 'fetch_error' }; return { status: 'fetch_error' };
} }
if (result.price == null) { if (result.price == null) {
console.error(`[check] not_found "${product.name}" (${product.url}): geen prijs gevonden op de pagina`);
recordCheckFailure(product.id, 'not_found'); recordCheckFailure(product.id, 'not_found');
return { status: 'not_found' }; return { status: 'not_found' };
} }
@@ -22,7 +24,8 @@ async function checkProduct(product, deps) {
oldPrice: product.last_price, oldPrice: product.last_price,
newPrice: result.price, newPrice: result.price,
}); });
} catch { } catch (err) {
console.error(`[check] mail_error "${product.name}" (${product.url}): ${err && err.message}`);
recordCheckFailure(product.id, 'mail_error'); recordCheckFailure(product.id, 'mail_error');
return { status: 'mail_error' }; return { status: 'mail_error' };
} }
@@ -39,6 +42,7 @@ async function checkAllProducts(products, deps) {
const result = await checkProduct(product, deps); const result = await checkProduct(product, deps);
results.push({ productId: product.id, ...result }); results.push({ productId: product.id, ...result });
} catch (err) { } catch (err) {
console.error(`[check] error "${product.name}" (${product.url}): ${err && err.message}`);
results.push({ productId: product.id, status: 'error', error: err }); results.push({ productId: product.id, status: 'error', error: err });
} }
} }
+22 -4
View File
@@ -17,6 +17,14 @@ function makeDeps(overrides = {}) {
return { deps, failures, successes, mailCalls }; return { deps, failures, successes, mailCalls };
} }
// Failure paths log to stderr; capture it so the test output stays clean
// while still proving the logging happened.
function captureErrorLog(t) {
const logged = [];
t.mock.method(console, 'error', (...args) => { logged.push(args.join(' ')); });
return logged;
}
test('checkProduct stores baseline price without sending mail', async () => { test('checkProduct stores baseline price without sending mail', async () => {
const { deps, successes, mailCalls } = makeDeps(); const { deps, successes, mailCalls } = makeDeps();
const product = { id: 1, name: 'X', url: 'https://x', last_price: null }; const product = { id: 1, name: 'X', url: 'https://x', last_price: null };
@@ -48,7 +56,8 @@ test('checkProduct does not send mail or update when price unchanged', async ()
assert.equal(successes.length, 1); assert.equal(successes.length, 1);
}); });
test('checkProduct records fetch_error and does not touch price on network failure', async () => { test('checkProduct records fetch_error and does not touch price on network failure', async (t) => {
const logged = captureErrorLog(t);
const { deps, failures, successes } = makeDeps({ const { deps, failures, successes } = makeDeps({
fetchAndExtractPrice: async () => ({ price: null, method: null, error: new Error('down') }), fetchAndExtractPrice: async () => ({ price: null, method: null, error: new Error('down') }),
}); });
@@ -57,9 +66,12 @@ test('checkProduct records fetch_error and does not touch price on network failu
assert.equal(result.status, 'fetch_error'); assert.equal(result.status, 'fetch_error');
assert.deepEqual(failures, [{ id: 4, status: 'fetch_error' }]); assert.deepEqual(failures, [{ id: 4, status: 'fetch_error' }]);
assert.equal(successes.length, 0); assert.equal(successes.length, 0);
assert.equal(logged.length, 1);
assert.match(logged[0], /fetch_error.*https:\/\/x.*down/);
}); });
test('checkProduct records not_found when no price could be extracted', async () => { test('checkProduct records not_found when no price could be extracted', async (t) => {
const logged = captureErrorLog(t);
const { deps, failures, successes } = makeDeps({ const { deps, failures, successes } = makeDeps({
fetchAndExtractPrice: async () => ({ price: null, method: null, error: null }), fetchAndExtractPrice: async () => ({ price: null, method: null, error: null }),
}); });
@@ -68,9 +80,12 @@ test('checkProduct records not_found when no price could be extracted', async ()
assert.equal(result.status, 'not_found'); assert.equal(result.status, 'not_found');
assert.deepEqual(failures, [{ id: 5, status: 'not_found' }]); assert.deepEqual(failures, [{ id: 5, status: 'not_found' }]);
assert.equal(successes.length, 0); assert.equal(successes.length, 0);
assert.equal(logged.length, 1);
assert.match(logged[0], /not_found.*https:\/\/x/);
}); });
test('checkProduct records mail_error and keeps old price when sending mail fails', async () => { test('checkProduct records mail_error and keeps old price when sending mail fails', async (t) => {
const logged = captureErrorLog(t);
const { deps, failures, successes } = makeDeps({ const { deps, failures, successes } = makeDeps({
sendMail: async () => { throw new Error('smtp down'); }, sendMail: async () => { throw new Error('smtp down'); },
}); });
@@ -79,9 +94,12 @@ test('checkProduct records mail_error and keeps old price when sending mail fail
assert.equal(result.status, 'mail_error'); assert.equal(result.status, 'mail_error');
assert.deepEqual(failures, [{ id: 6, status: 'mail_error' }]); assert.deepEqual(failures, [{ id: 6, status: 'mail_error' }]);
assert.equal(successes.length, 0); assert.equal(successes.length, 0);
assert.equal(logged.length, 1);
assert.match(logged[0], /mail_error.*https:\/\/x.*smtp down/);
}); });
test('checkAllProducts processes every product independently, isolating failures', async () => { test('checkAllProducts processes every product independently, isolating failures', async (t) => {
captureErrorLog(t);
const { deps } = makeDeps(); const { deps } = makeDeps();
let calls = 0; let calls = 0;
const throwingDeps = { const throwingDeps = {