Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s
build-container / build (push) Successful in 4m6s
- Chores tab with repeats, points and a week/month board - Family page: calendar, chores, shopping, notes, chat and tasks on one start page - Genre search in the Store, Free Books and LibriVox - Child accounts: safe content, daily limit and bedtime, ask a parent - Search now covers notes, chat, contacts, chores, recipes and your books - Notifications center: bell with a per-person list of chat, reminders and announcements - Roadmap, README and tests updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf292e3bc7
commit
0383da87f5
38 files changed
+720
-79
No files matched your search
@@ -0,0 +1,39 @@
|
||||
// Chores: when each kind of chore is due, who it is given to, ticking off and undoing, who may tick what, and the points for the week.
|
||||
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-chores-'));
|
||||
const users = require('../users'), chores = require('../chores');
|
||||
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chores'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chores'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) };
|
||||
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chores.handle(req, res))).listen(0, async () => {
|
||||
const B = 'http://127.0.0.1:' + hub.address().port + '/api/chores', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
|
||||
const today = chores.ymd(new Date()), board = async (who, date = today) => (await j(await call('/?date=' + date, {}, who))).board;
|
||||
try {
|
||||
// when chores are due (pure rules, on known dates: 2026-10-26 is a Monday)
|
||||
const c = (o) => ({ active: true, ...o }), mon = '2026-10-26', sat = '2026-10-31', sun = '2026-11-01';
|
||||
assert.equal(chores.dueKey(c({ repeat: 'daily' }), sat), sat); assert.equal(chores.dueKey(c({ repeat: 'weekdays' }), sat), null); assert.equal(chores.dueKey(c({ repeat: 'weekdays' }), mon), mon);
|
||||
assert.equal(chores.dueKey(c({ repeat: 'weekly', days: [6] }), sat), sat); assert.equal(chores.dueKey(c({ repeat: 'weekly', days: [6] }), sun), null);
|
||||
assert.equal(chores.dueKey(c({ repeat: 'weekly', days: [] }), sun), mon, 'a weekly chore with no chosen day is due all week, filed under its Monday'); assert.equal(chores.dueKey(c({ repeat: 'weekly', days: [] }), '2026-11-02'), '2026-11-02');
|
||||
assert.equal(chores.dueKey(c({ repeat: 'monthly', dom: 15 }), '2026-10-14'), null); assert.equal(chores.dueKey(c({ repeat: 'monthly', dom: 15 }), '2026-10-20'), '2026-10', 'a monthly chore stays due after its day'); assert.equal(chores.dueKey(c({ repeat: 'monthly', dom: 31 }), '2026-11-30'), '2026-11', 'day 31 in a short month means the last day');
|
||||
assert.equal(chores.dueKey(c({ repeat: 'once', date: '2026-10-28' }), mon), null); assert.equal(chores.dueKey(c({ repeat: 'once', date: '2026-10-28' }), '2026-11-03'), 'once'); assert.equal(chores.dueKey(c({ repeat: 'daily', active: false }), mon), null); assert.equal(chores.dueKey(c({ repeat: 'daily', start: '2026-11-01' }), mon), null);
|
||||
// permissions and creating
|
||||
assert.equal((await post('/', { title: 'Dishes' }, 'b')).status, 403, 'only an admin makes chores'); assert.equal((await post('/', { title: ' ' })).status, 400); assert.equal((await post('/', { title: 'Once', repeat: 'once' })).status, 400, 'a one-off needs a day');
|
||||
const dishes = await j(await post('/', { title: 'Dishes', repeat: 'daily', assign: [P.b.id, P.c.id, P.d.id, 'ghost'], points: 3 })); assert.deepEqual(dishes.assign.sort(), [P.b.id, P.c.id].sort(), 'only people who have Chores');
|
||||
const trash = await j(await post('/', { title: 'Take out the trash', repeat: 'daily', assign: 'anyone', points: 5 })); const lawn = await j(await post('/', { title: 'Mow', repeat: 'weekly', days: [0, 6, 9, 'x'], assign: [P.b.id], points: 10 })); assert.deepEqual(lawn.days, [0, 6]); assert.equal((await j(await post('/', { id: lawn.id, title: 'Mow the lawn', repeat: 'weekly', days: [6], assign: [P.b.id], points: 10 }))).title, 'Mow the lawn', 'a chore can be changed');
|
||||
let bd = await board('b'); assert.equal(bd.filter(x => x.title === 'Dishes').length, 2, 'given to two people: each has their own'); assert.equal(bd.filter(x => x.title === 'Take out the trash').length, 1, 'an anyone chore is one');
|
||||
assert.ok(bd.find(x => x.title === 'Dishes' && x.user === P.b.id).canDo && !bd.find(x => x.title === 'Dishes' && x.user === P.c.id).canDo, 'you can tick your own, not another person\'s'); assert.ok((await board('a')).every(x => x.canDo), 'an admin can tick any');
|
||||
// ticking
|
||||
assert.equal((await post('/done', { chore: dishes.id, date: today, user: P.c.id }, 'b')).status, 403, 'not for someone else'); assert.equal((await post('/done', { chore: dishes.id, date: today }, 'b')).status, 200);
|
||||
bd = await board('c'); assert.equal(bd.find(x => x.title === 'Dishes' && x.user === P.b.id).done.byName, 'Bob'); assert.equal(bd.find(x => x.title === 'Dishes' && x.user === P.c.id).done, null, 'Cy still has his own to do'); assert.equal((await j(await post('/done', { chore: dishes.id, date: today }, 'b'))).already, true, 'ticking twice counts once');
|
||||
assert.equal((await post('/done', { chore: trash.id, date: today }, 'c')).status, 200); bd = await board('b'); assert.equal(bd.find(x => x.title === 'Take out the trash').done.byName, 'Cy', 'an anyone chore: whoever does it gets it'); assert.equal((await post('/done', { chore: trash.id, date: today, undo: true }, 'b')).status, 403, 'only who ticked it (or an admin) takes it back'); assert.equal((await post('/done', { chore: trash.id, date: today, undo: true }, 'c')).status, 200); assert.equal((await board('b')).find(x => x.title === 'Take out the trash').done, null);
|
||||
assert.equal((await post('/done', { chore: dishes.id, date: today, user: P.c.id }, 'a')).status, 200, 'an admin can tick for someone');
|
||||
assert.equal((await post('/done', { chore: dishes.id, date: '2020-01-01' }, 'b')).status, 400, 'not years back'); assert.equal((await post('/done', { chore: dishes.id, user: P.d.id }, 'a')).status, 400, 'only to people it is given to');
|
||||
// a one-off carries until done and is marked overdue
|
||||
const once = await j(await post('/', { title: 'Fix the gate', repeat: 'once', date: chores.addDays(today, -2), assign: 'anyone', points: 8 })); let ob = (await board('b')).find(x => x.title === 'Fix the gate'); assert.equal(ob.overdue, true); await post('/done', { chore: once.id, date: today }, 'b'); ob = (await board('b')).find(x => x.title === 'Fix the gate'); assert.equal(ob.done.byName, 'Bob'); assert.equal(ob.overdue, false);
|
||||
// points
|
||||
const sm = await j(await call('/summary?range=week', {}, 'a')); const bob = sm.people.find(x => x.name === 'Bob'), cy = sm.people.find(x => x.name === 'Cy'); assert.equal(bob.points, 3 + 8, 'Bob: dishes 3 and the gate 8'); assert.equal(cy.points, 3, 'Cy: dishes ticked by an admin counts for Cy'); assert.equal(sm.people[0].name, 'Bob', 'most points first'); assert.equal(bob.perDay.length, 7); assert.equal(bob.perDay.at(-1), 2);
|
||||
// delete removes its history
|
||||
assert.equal((await call('/' + once.id, { method: 'DELETE' }, 'b')).status, 403); assert.equal((await call('/' + once.id, { method: 'DELETE' })).status, 200); assert.equal((await j(await call('/summary', {}, 'a'))).people.find(x => x.name === 'Bob').points, 3);
|
||||
assert.equal((await call('/?date=nope')).status, 400);
|
||||
console.log('chores ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// Notifications center: entries from chat and announcements, counting, reading, clearing.
|
||||
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-inbox-'));
|
||||
const users = require('../users'), inbox = require('../inbox'), chat = require('../chat');
|
||||
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yg' }) };
|
||||
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => req.url.startsWith('/api/chat') ? chat.handle(req, res) : inbox.handle(req, res))).listen(0, async () => {
|
||||
const B = 'http://127.0.0.1:' + hub.address().port, call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
|
||||
try {
|
||||
const dm = (await j(await post('/api/chat/new', { type: 'direct', with: P.b.id }, 'a'))).conv; await post('/api/chat/send', { conv: dm.id, text: 'hello' }, 'a'); await post('/api/chat/send', { conv: dm.id, text: 'again' }, 'a');
|
||||
let r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.length, 1, 'one entry per conversation'); assert.equal(r.items[0].count, 2); assert.equal(r.items[0].body, 'again'); assert.equal(r.unread, 1);
|
||||
assert.equal((await j(await call('/api/inbox', {}, 'a'))).items.length, 0, 'the sender gets nothing');
|
||||
await post('/api/chat/announcement', { text: 'Pizza night' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items[0].kind, 'announce'); assert.equal(r.unread, 2);
|
||||
r = await j(await post('/api/inbox/read', { id: r.items[0].id }, 'b')); assert.equal(r.unread, 1);
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'new one' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 1, 'still unread: updated in place'); assert.equal(r.items.find(i => i.kind === 'chat').count, 3);
|
||||
r = await j(await post('/api/inbox/read', { all: true }, 'b')); assert.equal(r.unread, 0);
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'after reading' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 2, 'a read entry stays; a new one starts');
|
||||
await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0); console.log('inbox ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Child accounts: the daily limit counts time from pings, bedtime locks, locked accounts are refused (except for what they need to ask), parents give more time and are asked.
|
||||
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-kid-')); process.env.HUB_TZ = 'UTC';
|
||||
const users = require('../users'), kid = require('../kidlimits'), chat = require('../chat');
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), sam = users.create({ name: 'Sam', role: 'user', tabs: ['music'], handle: 'Yg' }), pat = users.create({ name: 'Pat', role: 'user', tabs: ['music'], handle: 'Yw' });
|
||||
const asAdmin = (u, fn) => users.runAs(u, fn);
|
||||
assert.throws(() => users.update(ann.id, { kid: { on: true } }), /admin cannot be a child/, 'an admin cannot be a child account');
|
||||
users.update(sam.id, { kid: { on: true, limitMin: 3, bedtime: { on: false, from: '20:30', to: '07:00' } } }); const s0 = users.get(sam.id).kid; assert.deepEqual(s0, { on: true, limitMin: 15, bedtime: { on: false, from: '20:30', to: '07:00' }, safe: true }, 'a limit is at least 15 minutes; safe search is on unless turned off');
|
||||
assert.equal(users.publicView(users.get(sam.id)).kid.limitMin, 15); assert.equal(users.publicView(users.get(pat.id)).kid, undefined, 'only child accounts carry the settings');
|
||||
assert.deepEqual(kid.status(users.get(pat.id)), { on: false }); assert.equal(kid.lockedFor(users.get(pat.id), '/api/music/x'), null);
|
||||
// time counts from pings (one a minute while the app is open)
|
||||
const real = Date.now; let t = real(); Date.now = () => t; let st; for (let i = 0; i < 14; i++) { st = kid.ping(users.get(sam.id)); t += 60e3; } assert.equal(st.used, 13, 'the first ping counts half a minute, the rest a minute each'); assert.equal(st.left, 2); assert.equal(st.locked, null);
|
||||
st = kid.ping(users.get(sam.id)); t += 60e3; st = kid.ping(users.get(sam.id)); t += 60e3; assert.equal(st.locked, 'time', 'time is up'); assert.ok(kid.lockedFor(users.get(sam.id), '/api/music/x'), 'so requests are refused');
|
||||
assert.equal(kid.lockedFor(users.get(sam.id), '/api/kid/status'), null, 'except to see the status'); assert.equal(kid.lockedFor(users.get(sam.id), '/api/me'), null); const used = kid.ping(users.get(sam.id)).used; t += 60e3; assert.equal(kid.ping(users.get(sam.id)).used, used, 'a locked account stops counting');
|
||||
assert.throws(() => kid.give(pat.id, 15), /not a child/); st = kid.give(sam.id, 20); assert.equal(st.locked, null, 'more time unlocks it'); assert.equal(st.limit, 35);
|
||||
// bedtime
|
||||
users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: true, from: '00:00', to: '23:59' } } }); assert.equal(kid.status(users.get(sam.id)).locked, 'bedtime'); users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: false } } }); assert.equal(kid.status(users.get(sam.id)).locked, null, 'no limit, no bedtime: never locked'); assert.equal(kid.status(users.get(sam.id)).left, null);
|
||||
Date.now = real;
|
||||
// over HTTP: who may do what, and asking a parent
|
||||
const hub = http.createServer((req, res) => { const who = { sam, ann, pat }[req.headers['x-user'] || 'sam']; users.runAs(users.get(who.id), () => kid.handle(req, res, users.get(who.id), { askParents: chat.askParents })); }).listen(0, async () => {
|
||||
const B = 'http://127.0.0.1:' + hub.address().port + '/api/kid', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
|
||||
try {
|
||||
assert.equal((await j(await call('/status'))).on, true); assert.equal((await post('/ping', {})).status, 200); assert.equal((await post('/extra', { user: sam.id, minutes: 10 }, 'sam')).status, 403, 'a child cannot give themselves time'); assert.equal((await post('/extra', { user: sam.id, minutes: 10 }, 'ann')).status, 200);
|
||||
assert.equal((await call('/usage?user=' + sam.id, {}, 'sam')).status, 403); assert.equal((await j(await call('/usage?user=' + sam.id, {}, 'ann'))).on, true);
|
||||
assert.equal((await post('/ask', { text: 'More time please?' }, 'pat')).status, 400, 'only child accounts ask'); const r = await j(await post('/ask', { text: 'Can I have 15 more minutes? I am nearly done.' })); assert.equal(r.told, 1, 'the one admin is told');
|
||||
const dm = 'd:' + [sam.id, ann.id].sort().join(':'); const conv = await asAdmin(users.get(ann.id), async () => { const out = {}; await chat.handle({ url: '/api/chat/conv/' + encodeURIComponent(dm), method: 'GET', headers: {} }, { writeHead() {}, end(b) { out.j = JSON.parse(b); }, headersSent: false }); return out.j; });
|
||||
assert.equal(conv.msgs[0].text, 'Can I have 15 more minutes? I am nearly done.'); assert.equal(conv.msgs[0].name, 'Sam', 'it arrives as a chat message from the child');
|
||||
console.log('kidlimits ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
// Free audiobooks: LibriVox search and book details, chapter audio is only ever sent to the recording hosts, each person's shelf and listening position.
|
||||
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-lv-'));
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-lv-')); process.env.LIBRIVOX_INDEX_PAGES = '2';
|
||||
const gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'), seen = []; let port;
|
||||
const fake = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p + u.search);
|
||||
const J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
||||
if (p === '/api/feed/audiobooks/' && u.searchParams.get('extended') === '1' && u.searchParams.get('offset') !== null) { const off = +u.searchParams.get('offset'); const mk = (id, title, gs) => ({ id: String(id), title, description: 'A story.', language: 'English', totaltimesecs: 600, authors: [{ first_name: 'A', last_name: 'Writer' }], coverart_jpg: `http://127.0.0.1:${port}/cover.gif`, genres: gs.map((name, i) => ({ id: String(i), name })) }); return J({ books: off === 0 ? [mk(901, 'The Hound Case', ['Crime & Mystery Fiction', 'Published 1900 onward']), mk(902, 'Dragons Abound', ['Fantastic Fiction'])] : [mk(903, 'Rhymes for Bed', ["Children's Fiction", 'Poetry']), mk(901, 'The Hound Case', ['Crime & Mystery Fiction'])] }); }
|
||||
if (p === '/api/feed/audiobooks/') {
|
||||
const book = { id: '55', title: 'Pride & Prejudice', description: '<p>A novel</p>', url_librivox: 'https://librivox.org/pp', language: 'English', copyright_year: '1813', totaltimesecs: 600, authors: [{ first_name: 'Jane', last_name: 'Austen' }], coverart_jpg: `http://127.0.0.1:${port}/cover.gif` };
|
||||
const full = { ...book, sections: [{ section_number: '1', title: 'Chapter 1', listen_url: `http://127.0.0.1:${port}/dl/1.mp3`, playtime: '300', readers: [{ display_name: 'Ann' }] }, { section_number: '2', title: 'Chapter 2', listen_url: 'https://evil.example.com/2.mp3', playtime: '300' }, { section_number: '3', title: 'Chapter 3', listen_url: `http://127.0.0.1:${port}/dl/3.mp3`, playtime: '300' }] };
|
||||
@@ -39,6 +40,13 @@ const fake = http.createServer((req, res) => {
|
||||
await post({ 'lv:55': { pos: 400, dur: 600, t: 10 } }); assert.equal((await post({ 'lv:55': { pos: 5, dur: 600, t: 3 } }))['lv:55'].pos, 400, 'the newest position wins');
|
||||
assert.deepEqual(await j(await call('/progress', { headers: { 'x-user': 'b' } })), {});
|
||||
await call('/shelf/55', { method: 'DELETE' }); assert.deepEqual(await j(await call('/shelf')), []);
|
||||
// genres: gathered once into an index, then searched locally
|
||||
let j2; r = await call('/search?genre=mystery'); j2 = await r.json(); assert.equal(j2.items.length, 0); assert.equal(j2.building, true, 'the first time it says the index is being built');
|
||||
for (let i = 0; i < 40 && !lv.loadIdx(); i++) await new Promise(ok => setTimeout(ok, 100)); assert.ok(lv.loadIdx(), 'the index is built and kept');
|
||||
j2 = await (await call('/search?genre=mystery')).json(); assert.deepEqual(j2.items.map(b => b.id), ['901'], 'a book in two pages is listed once'); assert.equal(j2.items[0].author, 'A Writer'); assert.match(j2.items[0].cover, /^\/api\/ab\/free\/cover\/901/);
|
||||
assert.deepEqual((await (await call('/search?genre=children')).json()).items.map(b => b.id), ['903']); assert.deepEqual((await (await call('/search?genre=poetry')).json()).items.map(b => b.id), ['903'], 'a book can be in several genres'); assert.deepEqual((await (await call('/search?genre=fantasy')).json()).items.map(b => b.id), ['902']);
|
||||
assert.deepEqual((await (await call('/search?genre=fiction&q=hound')).json()).items.map(b => b.id), ['901'], 'words narrow a genre'); assert.deepEqual((await (await call('/search?genre=fiction&q=zebra')).json()).items, []);
|
||||
const gl = await (await call('/genres')).json(); assert.equal(gl.ready, true); assert.equal(gl.genres.find(g => g.id === 'mystery').count, 1); assert.equal(gl.genres.find(g => g.id === 'cooking').count, 0); assert.deepEqual(await (await call('/search?genre=nope')).json(), { items: [], more: false });
|
||||
console.log('librivox test ok');
|
||||
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||
hub.close(); fake.close();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Search across the hub: notes, chat, contacts, chores and recipes (and what each person is allowed to see).
|
||||
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-search-')); process.env.MEALIE_URL = 'http://127.0.0.1:9'; delete process.env.MEALIE_TOKEN;
|
||||
const users = require('../users'), search = require('../search'), notes = require('../notes'), chat = require('../chat'), contacts = require('../contacts'), chores = require('../chores');
|
||||
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['search', 'notes', 'chat'], handle: 'Yg' }) };
|
||||
const wrap = (mod, base) => (req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => mod.handle(req, res));
|
||||
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => req.url.startsWith('/api/notes') ? notes.handle(req, res) : req.url.startsWith('/api/chat') ? chat.handle(req, res) : req.url.startsWith('/api/contacts') ? contacts.handle(req, res) : req.url.startsWith('/api/chores') ? chores.handle(req, res) : search.handle(req, res))).listen(0, async () => {
|
||||
const B = 'http://127.0.0.1:' + hub.address().port, call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json(), find = async (q, who) => j(await call('/api/search?q=' + encodeURIComponent(q), {}, who));
|
||||
try {
|
||||
await post('/api/notes', { type: 'list', title: 'Groceries', items: ['Sourdough starter', 'Eggs'], share: 'family' }, 'a'); await post('/api/notes', { type: 'note', title: 'Private', body: 'my sourdough secret', share: 'me' }, 'a'); await post('/api/contacts', { name: 'Sourdough Bakery', emails: [{ value: 'hi@bakery.test' }] }, 'a');
|
||||
await post('/api/chores', { title: 'Feed the sourdough', repeat: 'daily', assign: 'anyone', points: 2 }, 'a'); const dm = (await j(await post('/api/chat/new', { type: 'direct', with: P.b.id }, 'a'))).conv; await post('/api/chat/send', { conv: dm.id, text: 'Who has the sourdough recipe?' }, 'a');
|
||||
let r = await find('sourdough', 'a'); assert.deepEqual(r.notes.map(x => x.title).sort(), ['Groceries', 'Private'], 'the admin sees both notes (their own, and the family one)'); assert.equal(r.notes.find(x => x.title === 'Groceries').snippet.includes('Sourdough starter'), true, 'with where it matched');
|
||||
assert.equal(r.chat.length, 1); assert.equal(r.chat[0].convName, 'Bob'); assert.equal(r.contacts[0].name, 'Sourdough Bakery'); assert.equal(r.chores[0].title, 'Feed the sourdough'); assert.deepEqual(r.recipes, [], 'no Mealie: no recipes, and no error');
|
||||
r = await find('sourdough', 'b'); assert.deepEqual(r.notes.map(x => x.title), ['Groceries'], 'Bob does not see the private note'); assert.equal(r.chat.length, 1); assert.deepEqual(r.contacts, [], 'no Contacts tab: nothing from there'); assert.deepEqual(r.chores, [], 'and none from Chores');
|
||||
r = await find('s', 'a'); assert.deepEqual(r.notes, [], 'one letter is not a search'); console.log('search ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
});
|
||||
@@ -78,6 +78,21 @@ const fake = http.createServer((req, res) => {
|
||||
r = await post('/add', { source: 'archive', ref: 'moby1', title: 'Moby', author: 'Melville' }); assert.equal((await r.json()).title, 'Moby'); assert.ok(seen.includes('/ia/download/moby1/moby1.epub'), 'the EPUB is chosen over the PDF');
|
||||
const ol = (await (await call('/search?q=moby&source=openlibrary')).json()).items; assert.deepEqual(ol.map(i => i.ref), ['moby1'], 'only freely readable editions are listed'); assert.equal(ol[0].author, 'Melville'); assert.match(decodeURIComponent(ol[0].coverUrl), /covers\.openlibrary\.org\/b\/id\/7-M\.jpg/);
|
||||
r = await post('/add', { source: 'openlibrary', ref: 'moby1', title: 'Moby Dick' }); assert.equal(r.status, 200, 'an Open Library book is fetched from the Internet Archive');
|
||||
// genres: each source is asked in its own words
|
||||
assert.deepEqual((await (await call('/genres')).json()).slice(0, 3).map(g => g.id), ['fiction', 'mystery', 'fantasy']);
|
||||
const sawSince = (n, re) => seen.slice(n).some(x => re.test(decodeURIComponent(x)));
|
||||
let n0 = seen.length; await call('/search?genre=mystery&source=gutenberg'); assert.ok(sawSince(n0, /search\.opds\/\?query=s\.mystery/), 'Gutenberg gets a subject search'); n0 = seen.length; await call('/search?q=hound&genre=mystery&source=gutenberg'); assert.ok(sawSince(n0, /query=hound s\.mystery/), 'with words and a genre');
|
||||
n0 = seen.length; await call('/search?genre=fantasy&source=standardebooks'); assert.ok(sawSince(n0, /tags\[\]=Fantasy/), 'Standard Ebooks gets a tag'); n0 = seen.length; await call('/search?genre=classics&source=standardebooks'); assert.ok(!sawSince(n0, /\/ebooks\?/), 'a genre it has no tag for is not asked');
|
||||
n0 = seen.length; await call('/search?genre=history&source=archive'); assert.ok(sawSince(n0, /advancedsearch[^ ]*subject:\(history\)/), 'the Archive is asked by subject even with no words'); n0 = seen.length; await call('/search?genre=poetry&source=openlibrary'); assert.ok(sawSince(n0, /subject=poetry&has_fulltext=true/), 'Open Library by subject');
|
||||
n0 = seen.length; await call('/search?genre=mystery&source=smashwords'); assert.ok(sawSince(n0, /shelf\/mostdownloads\/879\//), 'Smashwords by its category number'); n0 = seen.length; j = await (await call('/search?genre=philosophy&source=smashwords')).json(); assert.deepEqual(j.items, [], 'a genre Smashwords has no category for gives nothing');
|
||||
j = await (await call('/search?genre=mystery')).json(); assert.ok(j.items.length > 0 && Object.keys(j.errors || {}).length === 0, 'asking every source at once works'); assert.deepEqual((await (await call('/search?genre=nope&source=gutenberg')).json()).errors, {}, 'an unknown genre is just ignored');
|
||||
// a child account sees a few kid-friendly sources and genres
|
||||
users.update(people.b.id, { kid: { on: true, limitMin: 0, bedtime: { on: false }, safe: true } }); const kb = { 'x-user': 'b' };
|
||||
assert.deepEqual((await (await call('/sources', { headers: kb })).json()).map(s => s.id), ['gutenberg', 'standardebooks'], 'only two sources');
|
||||
n0 = seen.length; j = await (await call('/search?q=oz&source=all', { headers: kb })).json(); assert.ok(sawSince(n0, /search\.opds\/\?query=oz s\.juvenile/), 'with no genre, it is the children\'s genre'); assert.ok(!sawSince(n0, /\/ia\/|\/ol\/|\/sw\//), 'and the other sources are not asked');
|
||||
n0 = seen.length; await call('/search?genre=mystery&source=gutenberg', { headers: kb }); assert.ok(sawSince(n0, /s\.juvenile/) && !sawSince(n0, /s\.mystery/), 'a genre outside the kid list becomes the children\'s one');
|
||||
assert.equal((await call('/search?source=smashwords', { headers: kb })).status, 403); assert.equal((await post('/add', { source: 'smashwords', ref: '101' }, { 'x-user': 'b' })).status, 403, 'and cannot add from the others');
|
||||
users.update(people.b.id, { kid: { on: false } });
|
||||
// Smashwords: the family filter, the sign-in, and the download
|
||||
r = await call('/search?q=&source=smashwords'); j = await r.json(); assert.deepEqual(j.items.map(i => i.ref), ['101', '105', '110'], 'adult, LGBTQ+, keyword-flagged, unlabelled romance and paid books are left out; a clean romance and a harmless name are kept'); assert.equal(j.items[0].author, 'Ann Author'); assert.match(j.items[0].cover, /^\/api\/read\/store\/cover\?s=smashwords/);
|
||||
j = await (await call('/search?q=pilgrim&source=smashwords')).json(); assert.deepEqual(j.items.map(i => i.ref), ['101'], 'searching looks through the free books it can see'); assert.deepEqual((await (await call('/search?q=gay&source=smashwords')).json()).items.map(i => i.ref), ['105']);
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
|
||||
r = await admin.call('/api/audit'); const evs = (r.json.events || []).map(e => e.event);
|
||||
ok('the admin sees the audit log: invite use, recovery codes and recovery use are recorded', ['recovery codes created', 'recovery code used', 'passkey added with invite'].every(e => evs.includes(e)), evs);
|
||||
ok('and it never contains a code', !JSON.stringify(r.json).includes(recCodes[0]) && !JSON.stringify(r.json).includes(samCode), 'code leaked');
|
||||
r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json);
|
||||
r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Family, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,family,home,search,settings,tasks', r.json);
|
||||
|
||||
// ---- private data
|
||||
r = await sam.call('/api/tasks?list=local:inbox'); ok("Sam starts with an empty task list (the admin's task is not visible)", r.status === 200 && r.json.length === 0, r.json);
|
||||
|
||||
Reference in new issue
Block a user