From f73ea41a6ca4598afaea287f4a2be1bc92f765be Mon Sep 17 00:00:00 2001 From: Sjoerd de Vries Date: Sat, 5 Sep 2026 15:17:42 +0200 Subject: [PATCH] feat: add web UI for managing products Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz --- src/app.js | 43 +++++++++++++++++++++++++++++ src/views.js | 63 ++++++++++++++++++++++++++++++++++++++++++ test/app.test.js | 71 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/app.js create mode 100644 src/views.js create mode 100644 test/app.test.js diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..746c055 --- /dev/null +++ b/src/app.js @@ -0,0 +1,43 @@ +const express = require('express'); +const basicAuth = require('express-basic-auth'); +const { renderIndex } = require('./views'); + +function createApp(deps) { + const { + listProducts, addProduct, deleteProduct, + checkAllProducts, authUser, authPass, + } = deps; + + const app = express(); + app.use(express.urlencoded({ extended: false })); + app.use(basicAuth({ users: { [authUser]: authPass }, challenge: true })); + + app.get('/', (req, res) => { + const products = listProducts(); + const message = req.query.checked ? 'Controle uitgevoerd.' : undefined; + res.send(renderIndex({ products, message })); + }); + + app.post('/products', (req, res) => { + const { name, url } = req.body; + if (name && url) { + addProduct({ name, url }); + } + res.redirect('/'); + }); + + app.post('/products/:id/delete', (req, res) => { + deleteProduct(Number(req.params.id)); + res.redirect('/'); + }); + + app.post('/check-now', async (req, res) => { + const products = listProducts(); + await checkAllProducts(products); + res.redirect('/?checked=1'); + }); + + return app; +} + +module.exports = { createApp }; diff --git a/src/views.js b/src/views.js new file mode 100644 index 0000000..c169b20 --- /dev/null +++ b/src/views.js @@ -0,0 +1,63 @@ +function escapeHtml(str) { + return String(str).replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +function formatPrice(price) { + return price == null ? '—' : `€${Number(price).toFixed(2).replace('.', ',')}`; +} + +function renderProductRow(product) { + return ` + + ${escapeHtml(product.name)} + bekijken + ${formatPrice(product.last_price)} + ${product.last_checked_at ? escapeHtml(product.last_checked_at) : '—'} + ${escapeHtml(product.last_check_status || '—')} + +
+ +
+ + `; +} + +function renderIndex({ products, message }) { + return ` + + + + Korting — prijstracker + + + +

Korting — prijstracker

+ ${message ? `

${escapeHtml(message)}

` : ''} +
+ + + +
+
+ +
+ + + + + + ${products.map(renderProductRow).join('')} + +
NaamURLPrijsLaatst gecontroleerdStatus
+ +`; +} + +module.exports = { renderIndex, escapeHtml, formatPrice }; diff --git a/test/app.test.js b/test/app.test.js new file mode 100644 index 0000000..a85b7a3 --- /dev/null +++ b/test/app.test.js @@ -0,0 +1,71 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const request = require('supertest'); +const { createApp } = require('../src/app'); + +function makeDeps(overrides = {}) { + let products = []; + let nextId = 1; + return { + listProducts: () => products, + addProduct: ({ name, url }) => { + const product = { id: nextId++, name, url, last_price: null, last_checked_at: null, last_check_status: null }; + products.push(product); + return product; + }, + deleteProduct: (id) => { products = products.filter((p) => p.id !== id); }, + checkAllProducts: async () => [], + authUser: 'admin', + authPass: 'secret', + ...overrides, + }; +} + +test('rejects requests without basic auth credentials', async () => { + const app = createApp(makeDeps()); + const res = await request(app).get('/'); + assert.equal(res.status, 401); +}); + +test('lists products for authenticated requests', async () => { + const deps = makeDeps(); + deps.addProduct({ name: 'Voorbeeld', url: 'https://example.com' }); + const app = createApp(deps); + const res = await request(app).get('/').auth('admin', 'secret'); + assert.equal(res.status, 200); + assert.match(res.text, /Voorbeeld/); +}); + +test('adding a product redirects to the list', async () => { + const deps = makeDeps(); + const app = createApp(deps); + const res = await request(app) + .post('/products') + .auth('admin', 'secret') + .type('form') + .send({ name: 'Nieuw product', url: 'https://shop.example/p' }); + assert.equal(res.status, 302); + assert.equal(res.headers.location, '/'); + const list = await request(app).get('/').auth('admin', 'secret'); + assert.match(list.text, /Nieuw product/); +}); + +test('deleting a product removes it from the list', async () => { + const deps = makeDeps(); + const product = deps.addProduct({ name: 'Weg ermee', url: 'https://shop.example/p2' }); + const app = createApp(deps); + const res = await request(app).post(`/products/${product.id}/delete`).auth('admin', 'secret'); + assert.equal(res.status, 302); + const list = await request(app).get('/').auth('admin', 'secret'); + assert.doesNotMatch(list.text, /Weg ermee/); +}); + +test('check-now triggers checkAllProducts and redirects with confirmation', async () => { + let called = false; + const deps = makeDeps({ checkAllProducts: async () => { called = true; return []; } }); + const app = createApp(deps); + const res = await request(app).post('/check-now').auth('admin', 'secret'); + assert.equal(res.status, 302); + assert.equal(res.headers.location, '/?checked=1'); + assert.equal(called, true); +});