// Notes and lists: private, family and chosen-people sharing, checklist items changed one at a time, conflict detection, and who may change what. 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-notes-')); const users = require('../users'), notes = require('../notes'); const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['notes'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['notes'], 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'], () => notes.handle(req, res))).listen(0, async () => { const B = 'http://127.0.0.1:' + hub.address().port + '/api/notes', 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(), list = async who => (await j(await call('/', {}, who))).notes; try { const n1 = await j(await post('/', { type: 'note', title: ' Private thoughts ', body: 'secret', color: 'blue', share: 'me' }, 'b')); assert.equal(n1.title, 'Private thoughts'); assert.equal(n1.share, 'me'); assert.equal((await list('a')).length, 0, 'a private note is only for its owner (even an admin does not see it in the list)'); assert.equal((await post('/', { id: n1.id, title: 'Peek' }, 'a')).status, 404, 'and cannot be opened'); const gro = await j(await post('/', { type: 'list', title: 'Groceries', items: ['Milk', ' ', 'Eggs'], share: 'family' }, 'b')); assert.deepEqual(gro.items.map(x => x.text), ['Milk', 'Eggs'], 'blank items are dropped'); assert.equal((await list('c')).length, 1, 'a family note is seen by everyone with Notes'); assert.equal((await list('d')).length, 0, 'but not by someone without the Notes tab'); // checklist: items change one at a time, so two people do not overwrite each other let g = await j(await post('/' + gro.id + '/items', { text: 'Bread' }, 'c')); assert.equal(g.items.length, 3); const milk = g.items[0].id, eggs = g.items[1].id; await post('/' + gro.id + '/items', { toggle: milk, done: true }, 'c'); g = await j(await post('/' + gro.id + '/items', { toggle: eggs, done: true }, 'a')); assert.deepEqual(g.items.map(x => [x.text, x.done, x.byName]), [['Milk', true, 'Cy'], ['Eggs', true, 'Ann'], ['Bread', false, undefined]], 'two people ticking at once both count, and it says who'); g = await j(await post('/' + gro.id + '/items', { toggle: milk, done: false }, 'b')); assert.equal(g.items[0].done, false); assert.equal(g.items[0].byName, undefined, 'unticking forgets who ticked it'); g = await j(await post('/' + gro.id + '/items', { edit: eggs, text: 'Free-range eggs' }, 'b')); assert.equal(g.items[1].text, 'Free-range eggs'); g = await j(await post('/' + gro.id + '/items', { clearDone: true }, 'b')); assert.deepEqual(g.items.map(x => x.text), ['Milk', 'Bread'], 'clear done removes the ticked ones'); g = await j(await post('/' + gro.id + '/items', { remove: milk }, 'c')); assert.deepEqual(g.items.map(x => x.text), ['Bread']); assert.equal((await post('/' + gro.id + '/items', { text: '' }, 'c')).status, 400); assert.equal((await post('/' + n1.id + '/items', { text: 'x' }, 'b')).status, 400, 'a plain note has no items'); assert.equal((await post('/' + gro.id + '/items', { text: 'x' }, 'd')).status, 404); // editing text: a stale edit is refused, not lost const n2 = await j(await post('/', { type: 'note', title: 'Plans', body: 'v1', share: 'family' }, 'a')); const e1 = await j(await post('/', { id: n2.id, body: 'v2 by Bob', rev: n2.rev }, 'b')); assert.equal(e1.body, 'v2 by Bob'); assert.equal(e1.updatedByName, 'Bob'); let r = await post('/', { id: n2.id, body: 'v2 by Ann', rev: n2.rev }, 'a'); assert.equal(r.status, 409); const conf = await j(r); assert.equal(conf.note.body, 'v2 by Bob', 'the other change comes back so nothing is lost'); assert.match(conf.error, /Someone else/); // sharing: only the owner (or an admin) changes it; chosen people assert.equal((await post('/', { id: n2.id, share: 'me' }, 'b')).status, 403); const sh = await j(await post('/', { id: n1.id, share: [P.c.id, P.d.id, P.b.id, 'nobody'] }, 'b')); assert.deepEqual(sh.share, [P.c.id], 'only other people with Notes, never yourself'); assert.equal((await list('c')).some(x => x.id === n1.id), true, 'a chosen person sees it'); assert.equal((await list('a')).some(x => x.id === n1.id), false); assert.equal((await j(await post('/', { id: n1.id, pinned: true, color: 'red', archived: true }, 'c'))).color, 'red', 'a person it is shared with can pin, colour and archive'); assert.equal((await list('b'))[0].id, n1.id, 'pinned notes come first'); // delete assert.equal((await call('/' + n1.id, { method: 'DELETE' }, 'c')).status, 403, 'only the owner or an admin deletes'); assert.equal((await call('/' + n1.id, { method: 'DELETE' }, 'b')).status, 200); assert.equal((await call('/' + n2.id, { method: 'DELETE' }, 'a')).status, 200); assert.equal((await list('b')).length, 1); assert.equal(JSON.parse(fs.readFileSync(path.join(process.env.DATA_DIR, 'notes.json'), 'utf8')).notes.length, 1, 'it is kept on disk'); console.log('notes ok'); } catch (e) { console.error(e); process.exitCode = 1; } hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); });