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)}
` : ''}
+
+
+
+
+ | Naam | URL | Prijs | Laatst gecontroleerd | Status | |
+
+
+ ${products.map(renderProductRow).join('')}
+
+
+
+`;
+}
+
+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);
+});