feat: add price-check orchestration with retry-safe error handling
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz
This commit is contained in:
@@ -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 };
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user