build-container / build (push) Successful in 7m21s
- Store: Open Library search, books fetched from the Internet Archive (free editions only) - Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment - Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks - Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
66 lines
8.2 KiB
JavaScript
66 lines
8.2 KiB
JavaScript
// Recipes: reading recipes and the shopping list from Mealie, importing a link, adding a recipe to the list, and keeping the list in step with Tasks.
|
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-rcp-'));
|
|
const seen = []; let items = [{ id: 'abcdef01-1', note: 'milk', display: 'milk', checked: false, position: 1 }], nextId = 1, newApi = true;
|
|
const recipe = { id: '0123abcd-1', slug: 'bread', name: 'Bread', description: 'Simple', image: 'x', totalTime: 'PT1H30M', prepTime: '20 minutes', performTime: '1 hour', recipeServings: 4, orgURL: 'https://a.test/bread',
|
|
recipeIngredient: [{ display: '3 cups flour' }, { title: 'Glaze' }, { quantity: 1, unit: { name: 'tbsp' }, food: { name: 'butter' } }], recipeInstructions: [{ text: 'Mix.' }, { title: 'Bake', text: 'Bake it.' }], recipeCategory: [{ name: 'Baking' }], tags: [] };
|
|
const fake = http.createServer((req, res) => {
|
|
let b = ''; req.on('data', c => b += c); req.on('end', () => {
|
|
const u = new URL(req.url, 'http://x'), p = u.pathname, J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
|
seen.push(req.method + ' ' + p + (u.search || '')); if (req.headers.authorization !== 'Bearer tok') return J({ detail: 'Unauthorized' }, 401);
|
|
if (p === '/api/recipes' && req.method === 'GET') return J({ page: 1, total_pages: 2, items: [{ id: recipe.id, slug: 'bread', name: 'Bread', totalTime: '45 minutes', image: 'x', rating: 4 }] });
|
|
if (p === '/api/recipes/bread') return J(recipe);
|
|
if (p === '/api/recipes/create/url') { assert.equal(JSON.parse(b).url, 'https://a.test/new'); return J('new-one'); }
|
|
if (p === '/api/recipes/new-one') return J({ ...recipe, id: '0123abcd-2', slug: 'new-one', name: 'New One' });
|
|
if (p.startsWith('/api/media/recipes/0123abcd-1/images/')) { res.writeHead(200, { 'content-type': 'image/webp' }); return res.end('img'); }
|
|
const sh = p.startsWith('/api/households/shopping') ? p.slice('/api/households/shopping'.length) : null;
|
|
if (sh === null) return J({}, 404);
|
|
if (sh === '/lists' && req.method === 'GET') return J({ items: [{ id: '1111aaaa-1', name: 'Shopping List' }] });
|
|
if (sh === '/lists/1111aaaa-1' && req.method === 'GET') return J({ id: '1111aaaa-1', name: 'Shopping List', listItems: items });
|
|
if (sh === '/lists/1111aaaa-1/recipe/0123abcd-1' && req.method === 'POST') { if (!newApi) return J({}, 404); items.push({ id: 'abcdef01-' + (++nextId), note: '3 cups flour', display: '3 cups flour', checked: false, position: 2 }); return J({}); }
|
|
if (sh === '/lists/1111aaaa-1/recipe' && req.method === 'POST') { assert.equal(JSON.parse(b)[0].recipeId, '0123abcd-1'); items.push({ id: 'abcdef01-' + (++nextId), note: '3 cups flour', display: '3 cups flour', checked: false, position: 2 }); return J({}); }
|
|
if (sh === '/items' && req.method === 'POST') { const j = JSON.parse(b), it = { id: 'abcdef01-' + (++nextId), note: j.note, display: j.note, checked: false, position: 9 }; items.push(it); return J({ createdItems: [it] }); }
|
|
if (sh === '/items' && req.method === 'DELETE') { const ids = u.searchParams.getAll('ids'); items = items.filter(i => !ids.includes(i.id)); return J({}); }
|
|
let m; if ((m = /^\/items\/([\w-]+)$/.exec(sh)) && req.method === 'PUT') { const j = JSON.parse(b), it = items.find(i => i.id === m[1]); Object.assign(it, j); return J(it); }
|
|
J({}, 404);
|
|
});
|
|
}).listen(0, async () => {
|
|
const port = fake.address().port; process.env.MEALIE_URL = 'http://127.0.0.1:' + port; process.env.MEALIE_TOKEN = 'tok';
|
|
const recipes = require('../recipes'), users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' });
|
|
assert.equal(recipes.minutes('PT1H30M'), 90); assert.equal(recipes.minutes('1 hour 15 minutes'), 75); assert.equal(recipes.minutes('45 minutes'), 45); assert.equal(recipes.minutes(''), 0);
|
|
const hub = http.createServer((req, res) => users.runAs(me, () => recipes.handle(req, res))).listen(0, async () => {
|
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/recipes', get = p => fetch(base + p), post = (p, b = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(b) });
|
|
const tasks = () => JSON.parse(fs.readFileSync(path.join(users.dirOf(me.id), 'tasks.json'), 'utf8'));
|
|
try {
|
|
let j = await (await get('/?q=bre')).json(); assert.equal(j.items[0].name, 'Bread'); assert.equal(j.items[0].minutes, 45); assert.equal(j.more, true); assert.ok(seen.some(x => x.includes('search=bre')));
|
|
j = await (await get('/r/bread')).json(); assert.equal(j.total, 90); assert.equal(j.prep, 20); assert.equal(j.cook, 60); assert.deepEqual(j.ingredients.map(i => i.text || i.title), ['3 cups flour', 'Glaze', '1 tbsp butter']); assert.equal(j.steps[1].title, 'Bake');
|
|
assert.equal((await get('/r/..%2Fx')).status, 404, 'a recipe name cannot reach other Mealie pages');
|
|
let r = await get('/image/0123abcd-1'); assert.equal(r.headers.get('content-type'), 'image/webp'); assert.equal((await get('/image/not%20ok')).status, 404);
|
|
j = await (await post('/import', { url: 'https://a.test/new' })).json(); assert.equal(j.slug, 'new-one'); assert.equal((await post('/import', { url: 'javascript:1' })).status, 400);
|
|
// the shopping list
|
|
j = await (await get('/shopping')).json(); assert.deepEqual(j.items.map(i => i.text), ['milk']);
|
|
j = await (await post('/shopping/add-recipe', { slug: 'bread' })).json(); assert.equal(j.ok, true); assert.equal(j.added, 2, 'the whole list came across to Tasks the first time');
|
|
let t = tasks(), sl = t.lists.find(l => l.name === 'Shopping'); assert.ok(sl && sl.mealie); assert.deepEqual(t.tasks.filter(x => x.list === sl.id).map(x => x.title).sort(), ['3 cups flour', 'milk']);
|
|
// ticked in Tasks -> ticked in Mealie
|
|
t.tasks.find(x => x.title === 'milk').done = true; fs.writeFileSync(path.join(users.dirOf(me.id), 'tasks.json'), JSON.stringify(t));
|
|
j = await (await post('/shopping/sync')).json(); assert.equal(j.toMealie, 1); assert.equal(items.find(i => i.note === 'milk').checked, true, 'finishing a task ticks the item in Mealie');
|
|
// ticked in Mealie -> done in Tasks
|
|
items.find(i => i.note === '3 cups flour').checked = true; j = await (await post('/shopping/sync')).json(); assert.equal(j.toTasks, 1); assert.equal(tasks().tasks.find(x => x.title === '3 cups flour').done, true);
|
|
// typed into Tasks -> on the Mealie list; deleted in Tasks -> deleted in Mealie
|
|
t = tasks(); t.tasks.push({ id: 'zz', list: sl.id, title: 'eggs', done: false }); t.tasks = t.tasks.filter(x => x.title !== 'milk'); fs.writeFileSync(path.join(users.dirOf(me.id), 'tasks.json'), JSON.stringify(t));
|
|
j = await (await post('/shopping/sync')).json(); assert.equal(j.sent, 1); assert.ok(items.some(i => i.note === 'eggs')); assert.ok(!items.some(i => i.note === 'milk'), 'a task deleted in Tasks leaves Mealie too');
|
|
// typed in the hub -> a task; ticked in the hub -> ticked; removing ticked items
|
|
await post('/shopping/add', { text: 'butter' }); assert.ok(tasks().tasks.some(x => x.title === 'butter'));
|
|
const bid = items.find(i => i.note === 'butter').id; await post('/shopping/check', { id: bid, checked: true }); assert.equal(items.find(i => i.id === bid).checked, true); assert.equal(tasks().tasks.find(x => x.title === 'butter').done, true);
|
|
j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too');
|
|
// an older Mealie that only knows the list form of "add recipe"
|
|
newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe'));
|
|
// a wrong token is explained
|
|
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
|
|
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
|
console.log('recipes ok');
|
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
|
hub.close(); fake.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
|
});
|
|
});
|