From 68cb8d0c08a64df8e0dcf59addee25525254137d Mon Sep 17 00:00:00 2001 From: Sjoerd de Vries Date: Sat, 5 Sep 2026 15:12:16 +0200 Subject: [PATCH] feat: add price-check orchestration with retry-safe error handling Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz --- src/checkProducts.js | 48 +++++++++++++++++ test/checkProducts.test.js | 103 +++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/checkProducts.js create mode 100644 test/checkProducts.test.js diff --git a/src/checkProducts.js b/src/checkProducts.js new file mode 100644 index 0000000..ac9a988 --- /dev/null +++ b/src/checkProducts.js @@ -0,0 +1,48 @@ +async function checkProduct(product, deps) { + const { fetchAndExtractPrice, sendMail, recordCheckSuccess, recordCheckFailure, now } = deps; + const result = await fetchAndExtractPrice(product.url); + + if (result.error) { + recordCheckFailure(product.id, 'fetch_error'); + return { status: 'fetch_error' }; + } + if (result.price == null) { + recordCheckFailure(product.id, 'not_found'); + return { status: 'not_found' }; + } + + const isBaseline = product.last_price == null; + const changed = !isBaseline && result.price !== product.last_price; + + if (changed) { + try { + await sendMail({ + name: product.name, + url: product.url, + oldPrice: product.last_price, + newPrice: result.price, + }); + } catch { + recordCheckFailure(product.id, 'mail_error'); + return { status: 'mail_error' }; + } + } + + recordCheckSuccess(product.id, { price: result.price, checkedAt: now() }); + return { status: 'ok', changed }; +} + +async function checkAllProducts(products, deps) { + const results = []; + for (const product of products) { + try { + const result = await checkProduct(product, deps); + results.push({ productId: product.id, ...result }); + } catch (err) { + results.push({ productId: product.id, status: 'error', error: err }); + } + } + return results; +} + +module.exports = { checkProduct, checkAllProducts }; diff --git a/test/checkProducts.test.js b/test/checkProducts.test.js new file mode 100644 index 0000000..1e7bd2b --- /dev/null +++ b/test/checkProducts.test.js @@ -0,0 +1,103 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { checkProduct, checkAllProducts } = require('../src/checkProducts'); + +function makeDeps(overrides = {}) { + const failures = []; + const successes = []; + const mailCalls = []; + const deps = { + fetchAndExtractPrice: async () => ({ price: 10, method: 'meta', error: null }), + sendMail: async (payload) => { mailCalls.push(payload); }, + recordCheckFailure: (id, status) => failures.push({ id, status }), + recordCheckSuccess: (id, data) => successes.push({ id, ...data }), + now: () => '2026-09-05T08:00:00.000Z', + ...overrides, + }; + return { deps, failures, successes, mailCalls }; +} + +test('checkProduct stores baseline price without sending mail', async () => { + const { deps, successes, mailCalls } = makeDeps(); + const product = { id: 1, name: 'X', url: 'https://x', last_price: null }; + const result = await checkProduct(product, deps); + assert.equal(result.status, 'ok'); + assert.equal(result.changed, false); + assert.equal(mailCalls.length, 0); + assert.deepEqual(successes, [{ id: 1, price: 10, checkedAt: '2026-09-05T08:00:00.000Z' }]); +}); + +test('checkProduct sends mail and updates price when price changed', async () => { + const { deps, successes, mailCalls } = makeDeps(); + const product = { id: 2, name: 'X', url: 'https://x', last_price: 15 }; + const result = await checkProduct(product, deps); + assert.equal(result.status, 'ok'); + assert.equal(result.changed, true); + assert.equal(mailCalls.length, 1); + assert.deepEqual(mailCalls[0], { name: 'X', url: 'https://x', oldPrice: 15, newPrice: 10 }); + assert.equal(successes.length, 1); +}); + +test('checkProduct does not send mail or update when price unchanged', async () => { + const { deps, successes, mailCalls } = makeDeps(); + const product = { id: 3, name: 'X', url: 'https://x', last_price: 10 }; + const result = await checkProduct(product, deps); + assert.equal(result.status, 'ok'); + assert.equal(result.changed, false); + assert.equal(mailCalls.length, 0); + assert.equal(successes.length, 1); +}); + +test('checkProduct records fetch_error and does not touch price on network failure', async () => { + const { deps, failures, successes } = makeDeps({ + fetchAndExtractPrice: async () => ({ price: null, method: null, error: new Error('down') }), + }); + const product = { id: 4, name: 'X', url: 'https://x', last_price: 10 }; + const result = await checkProduct(product, deps); + assert.equal(result.status, 'fetch_error'); + assert.deepEqual(failures, [{ id: 4, status: 'fetch_error' }]); + assert.equal(successes.length, 0); +}); + +test('checkProduct records not_found when no price could be extracted', async () => { + const { deps, failures, successes } = makeDeps({ + fetchAndExtractPrice: async () => ({ price: null, method: null, error: null }), + }); + const product = { id: 5, name: 'X', url: 'https://x', last_price: 10 }; + const result = await checkProduct(product, deps); + assert.equal(result.status, 'not_found'); + assert.deepEqual(failures, [{ id: 5, status: 'not_found' }]); + assert.equal(successes.length, 0); +}); + +test('checkProduct records mail_error and keeps old price when sending mail fails', async () => { + const { deps, failures, successes } = makeDeps({ + sendMail: async () => { throw new Error('smtp down'); }, + }); + const product = { id: 6, name: 'X', url: 'https://x', last_price: 15 }; + const result = await checkProduct(product, deps); + assert.equal(result.status, 'mail_error'); + assert.deepEqual(failures, [{ id: 6, status: 'mail_error' }]); + assert.equal(successes.length, 0); +}); + +test('checkAllProducts processes every product independently, isolating failures', async () => { + const { deps } = makeDeps(); + let calls = 0; + const throwingDeps = { + ...deps, + fetchAndExtractPrice: async () => { + calls += 1; + if (calls === 1) throw new Error('boom'); + return { price: 10, method: 'meta', error: null }; + }, + }; + const products = [ + { id: 1, name: 'A', url: 'https://a', last_price: null }, + { id: 2, name: 'B', url: 'https://b', last_price: null }, + ]; + const results = await checkAllProducts(products, throwingDeps); + assert.equal(results.length, 2); + assert.equal(results[0].status, 'error'); + assert.equal(results[1].status, 'ok'); +});