Files
hub/tools/tasks-test.js
T

63 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
// Tasks: repeating tasks roll forward, subtasks nest one level and go away with their parent.
const { spawn } = require('child_process');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..'), dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-tasks-'));
const port = 20000 + Math.floor(Math.random() * 20000), base = `http://127.0.0.1:${port}`;
let failed = 0;
const ok = (name, cond, extra) => { console.log((cond ? 'PASS ' : 'FAIL ') + name + (cond ? '' : ' -> ' + JSON.stringify(extra))); if (!cond) failed++; };
const SECRET = 'tasks-'.repeat(8), UID = 'utasks';
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] }));
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET } });
const exp = Date.now() + 864e5, cookie = `hub_session=${UID}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url')}`;
const call = async (p, body, method) => { const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie, 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); return { status: r.status, json: await r.json().catch(() => ({})) }; };
const ymd = d => d.toISOString().slice(0, 10), plus = n => { const d = new Date(); d.setUTCDate(d.getUTCDate() + n); return ymd(d); };
const wait = ms => new Promise(r => setTimeout(r, ms));
(async () => {
for (let i = 0; i < 50; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const L = 'local:inbox', list = async () => (await call('/api/tasks?list=' + L)).json;
await call('/api/tasks', { list: L, fields: { title: 'Water plants', notes: '', due: plus(-3), done: false, repeat: 'daily' } });
await call('/api/tasks', { list: L, fields: { title: 'Plain', notes: '', due: '', done: false } });
let ts = await list(), rep = ts.find(t => t.title === 'Water plants'), plain = ts.find(t => t.title === 'Plain');
ok('a task remembers that it repeats', rep.repeat === 'daily' && plain.repeat === 'none', ts);
let r = await call('/api/tasks/toggle', { list: L, id: rep.id, done: true });
ts = await list(); rep = ts.find(t => t.title === 'Water plants');
ok('completing a repeating task moves it to the next date (after today) and keeps it open', r.json.rolled === plus(1) && rep.due === plus(1) && !rep.done, [r.json, rep]);
r = await call('/api/tasks/toggle', { list: L, id: plain.id, done: true }); ts = await list();
ok('a plain task is simply completed', ts.find(t => t.title === 'Plain').done === true && !r.json.rolled, r.json);
r = await call('/api/tasks', { list: L, id: rep.id, fields: { title: 'Water plants', notes: '', due: rep.due, done: false, repeat: 'bogus' } }); ts = await list();
ok('an unknown repeat is treated as none', ts.find(t => t.id === rep.id).repeat === 'none', ts);
await call('/api/tasks', { list: L, fields: { title: 'Sub 1', notes: '', due: '', done: false, parent: rep.id } });
ts = await list(); const sub = ts.find(t => t.title === 'Sub 1');
ok('a subtask is stored under its parent', sub && sub.parent === rep.id, ts);
r = await call('/api/tasks', { list: L, fields: { title: 'Deep', notes: '', due: '', done: false, parent: sub.id } });
ok('a subtask cannot have subtasks of its own', r.status === 400, r);
r = await call('/api/tasks', { list: L, fields: { title: 'Orphan', notes: '', due: '', done: false, parent: 'nope' } });
ok('a missing parent is refused', r.status === 400, r);
r = await call('/api/tasks/delete', { list: L, id: rep.id });
ts = await list(); ok('deleting the parent deletes its subtasks', !ts.some(t => t.id === rep.id || t.id === sub.id), ts);
// tasks due in a date range, for the calendar
await call('/api/tasks', { list: L, fields: { title: 'Due soon', notes: '', due: plus(2), done: false, repeat: 'weekly' } });
await call('/api/tasks', { list: L, fields: { title: 'Due later', notes: '', due: plus(40), done: false } });
await call('/api/tasks', { list: L, fields: { title: 'Done already', notes: '', due: plus(2), done: true } });
ts = await list(); const soonT = ts.find(t => t.title === 'Due soon');
await call('/api/tasks', { list: L, fields: { title: 'A subtask', notes: '', due: plus(2), done: false, parent: soonT.id } });
r = await call(`/api/tasks/due?from=${plus(0)}&to=${plus(7)}`);
ok('the calendar gets open tasks due in the range (not done ones, subtasks or other dates)', r.status === 200 && r.json.length === 1 && r.json[0].title === 'Due soon' && r.json[0].repeat === 'weekly' && r.json[0].listName, r.json);
r = await call('/api/tasks/due?from=bad&to=worse'); ok('a bad date range is refused', r.status === 400, r.status);
// a due time rides along with the due date (and only with one)
await call('/api/tasks', { list: L, fields: { title: 'At noon', notes: '', due: plus(1), dueTime: '12:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Time but no date', notes: '', due: '', dueTime: '09:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Bad time', notes: '', due: plus(1), dueTime: '25:99', done: false } });
ts = await list(); ok('a due time is kept with its date', ts.find(t => t.title === 'At noon').dueTime === '12:00', ts.find(t => t.title === 'At noon')); ok('a time with no date is dropped', ts.find(t => t.title === 'Time but no date').dueTime === ''); ok('and a time that is not a time', ts.find(t => t.title === 'Bad time').dueTime === '');
// subtasks on iCloud lists: a VTODO that points at its parent (RELATED-TO), kept through edits and ticking
{ const ics = require('../ics'), { parseTodos } = require('../tasks');
const main = ics.buildTodoCalendar([{ uid: 'p1@x', fields: { title: 'Trip', notes: '', due: '', done: false } }, { uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]);
let ts = parseTodos(main); ok('a subtask is written with its parent and read back with it', ts.length === 2 && ts[0].parent === '' && ts[1].parent === 'p1@x', ts);
const edited = ics.patchTodo(ics.buildTodoCalendar([{ uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]), { title: 'Book flights now', notes: '', due: '', done: true });
ok('ticking or editing it keeps its parent', parseTodos(edited)[0].parent === 'p1@x' && parseTodos(edited)[0].done === true, parseTodos(edited));
const moved = ics.patchTodo(edited, { title: 'x', notes: '', due: '', done: false, parent: '' }); ok('and an empty parent makes it a main task again', parseTodos(moved)[0].parent === '', parseTodos(moved));
const foreign = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:k@x\r\nSUMMARY:Kid\r\nRELATED-TO;RELTYPE=CHILD:z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; ok('a link that points down (CHILD) is not a parent', parseTodos(foreign)[0].parent === ''); }
ok('the server is still running', child.exitCode === null);
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);
})();