Files
hub/tools/cal-test.js

107 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
// NE Hub calendars: several per person, colors, private by default, shared view-only or edit by the owner's choice, subscribe links.
// Calls the calendar module directly as different people (no server needed).
// Run: node tools/cal-test.js
const fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-cal-'));
const users = require('../users'), cal = require('../cal');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const ann = users.create({ name: 'Ann', role: 'admin' }), bob = users.create({ name: 'Bob', tabs: ['calendar'] }), cy = users.create({ name: 'Cy', tabs: ['calendar'] });
async function call(u, p, body, method) {
return users.runAs(u, () => new Promise(done => {
const { EventEmitter } = require('events');
const req = new EventEmitter(); req.url = p; req.method = method || (body ? 'POST' : 'GET'); req.headers = { host: 'hub.test' };
const res = { code: 0, writeHead(c) { this.code = c; }, end(t) { let j = {}; try { j = JSON.parse(t); } catch {} done({ status: this.code, json: j, text: t }); } };
cal.handle(req, res);
process.nextTick(() => { if (body) req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end'); });
}));
}
const F = (title, d = '2026-11-03') => ({ title, location: '', notes: '', allDay: true, start: d, end: d, tz: 'UTC', repeat: 'none' });
const win = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
const titles = async u => (await call(u, win)).json.events.map(e => e.title).sort().join('|');
(async () => {
let r = await call(ann, '/api/cal/list');
ok('everyone starts with the one NE Hub calendar', r.json.length === 1 && r.json[0].id === 'local' && r.json[0].name === 'NE Hub' && r.json[0].color === '#4f7cff', r.json);
// legacy events (no calendar field) belong to the first calendar
fs.writeFileSync(path.join(users.dirOf(ann.id), 'events.json'), JSON.stringify([{ uid: 'old1', fields: F('Old event'), created: 1, updated: 1 }]));
ok('older events stay in the first calendar', (await call(ann, win)).json.events.find(e => e.title === 'Old event').calId === 'local');
// create, recolor, rename
r = await call(ann, '/api/cal/ncal', { name: ' Family ', color: '#d9453a' }); const fam = r.json.id;
ok('a person can make another calendar', r.status === 200 && fam && (await call(ann, '/api/cal/list')).json.length === 2);
r = await call(ann, '/api/cal/ncal', { name: ' ' }); ok('a calendar needs a name', r.status === 400, r);
r = await call(ann, '/api/cal/ncal/update', { id: fam, color: 'red' }); ok('colors must be real hex colors', r.status === 400, r);
await call(ann, '/api/cal/ncal/update', { id: fam, name: 'Family stuff', color: '#8a5cf0' });
const l = (await call(ann, '/api/cal/list')).json.find(c => c.id === fam); ok('rename and recolor stick', l.name === 'Family stuff' && l.color === '#8a5cf0', l);
// events go into the chosen calendar and carry its color
await call(ann, '/api/cal/event', { cal: fam, fields: F('Dinner') });
await call(ann, '/api/cal/event', { cal: 'local', fields: F('Work thing') });
const ev = (await call(ann, win)).json.events;
const dn = ev.find(e => e.title === 'Dinner'), wk = ev.find(e => e.title === 'Work thing');
ok('events use the color of their calendar', dn.color === '#8a5cf0' && dn.calId === fam && wk.color === '#4f7cff' && wk.calId === 'local', [dn, wk]);
ok('an event can be read back from its calendar', (await call(ann, `/api/cal/event?cal=${fam}&uid=${dn.uid}&tz=UTC`)).json.fields.title === 'Dinner');
ok('the same uid is not found in a different calendar', (await call(ann, `/api/cal/event?cal=local&uid=${dn.uid}&tz=UTC`)).status === 400);
// private by default
ok('other people see nothing of it by default', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Sneaky') }); ok('and cannot write to it', r.status === 400, r);
r = await call(bob, `/api/cal/event?cal=${ann.id}~${fam}&uid=${dn.uid}&tz=UTC`); ok('or read it', r.status === 400, r);
// sharing, with the owner's choice of view or edit per person
r = await call(ann, '/api/cal/people'); ok('the owner can see who it can be shared with (not themself)', r.json.map(p => p.name).sort().join() === 'Bob,Cy', r.json);
r = await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: bob.id, mode: 'view' }, { user: cy.id, mode: 'edit' }, { user: ann.id, mode: 'edit' }, { user: 'nobody', mode: 'edit' }] });
ok('sharing saves, ignoring yourself and unknown people', r.status === 200 && (await call(ann, '/api/cal/list')).json.find(c => c.id === fam).shares.length === 2);
ok('a view-only person sees the events', (await titles(bob)) === 'Dinner');
const bl = (await call(bob, '/api/cal/list')).json.find(c => c.shared);
ok('and the calendar in their list, marked shared with the owner and their access', bl && bl.mode === 'view' && bl.owner === 'Ann' && bl.name === 'Family stuff', bl);
const bev = (await call(bob, win)).json.events[0]; ok('shared events say they cannot be edited', bev.editable === false && bev.shared === true, bev);
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Bob adds') }); ok('view-only people cannot add', r.status === 400 && /view only/.test(r.json.error), r);
r = await call(bob, '/api/cal/event/delete', { cal: `${ann.id}~${fam}`, uid: dn.uid }); ok('or delete', r.status === 400, r);
ok('and still see exactly what they saw', (await titles(bob)) === 'Dinner');
r = await call(cy, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Cy adds') }); ok('an edit person can add', r.status === 200, r);
r = await call(cy, '/api/cal/event', { cal: `${ann.id}~${fam}`, uid: dn.uid, fields: F('Dinner at 7') }); ok('and change events', r.status === 200);
ok('the owner sees those changes', (await titles(ann)).includes('Cy adds') && (await titles(ann)).includes('Dinner at 7'));
ok('Cy saw it in the owner\'s calendar, not in their own', (await call(cy, '/api/cal/list')).json.filter(c => !c.shared).length === 1);
r = await call(cy, '/api/cal/ncal/update', { id: fam, shares: [] }); ok("a person can't change the sharing of someone else's calendar", r.status === 400, r);
r = await call(cy, '/api/cal/event/delete', { cal: `${ann.id}~${fam}`, uid: dn.uid }); ok('an edit person can delete events', r.status === 200 && !(await titles(ann)).includes('Dinner at 7'));
// everybody's own color for things shared with them / not theirs
await call(bob, '/api/cal/color', { id: `${ann.id}~${fam}`, color: '#2f9e6e' });
ok('a shared calendar can be given your own color', (await call(bob, win)).json.events[0].color === '#2f9e6e' && (await call(ann, win)).json.events.find(e => e.title === 'Cy adds').color === '#8a5cf0');
// stopping sharing
await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: cy.id, mode: 'edit' }] });
ok('removing someone hides it from them right away', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
// subscribe links
const feedOf = async (u, id, reset) => { const x = await call(u, '/api/cal/ncal/feed', { id, reset }); return x.json.url; };
const get = async url => { const res = { text: '', code: 0, writeHead(c) { this.code = c; }, end(t) { this.text = t; } }; cal.feed({ url: new URL(url).pathname }, res); return res; };
const u1 = await feedOf(ann, fam), u2 = await feedOf(ann, fam), u3 = await feedOf(ann, 'local');
ok('each calendar has its own stable subscribe link', u1 === u2 && u1 !== u3 && /^http:\/\/hub\.test\/cal\/feed\/[0-9a-f]{48}\.ics$/.test(u1), [u1, u3]);
const f1 = await get(u1), f3 = await get(u3);
ok('it shows only that calendar', f1.code === 200 && f1.text.includes('Cy adds') && !f1.text.includes('Work thing') && f3.text.includes('Work thing') && !f3.text.includes('Cy adds'));
const all = await get((await call(ann, '/api/cal/feed')).json.url);
ok('the person-wide link still shows all of their calendars', all.text.includes('Cy adds') && all.text.includes('Work thing') && all.text.includes('Old event'));
const u4 = await feedOf(ann, fam, true); const old = await get(u1);
ok('making a new link kills the old one', u4 !== u1 && old.code === 404 && (await get(u4)).code === 200);
// delete
r = await call(ann, '/api/cal/ncal/delete', { id: fam }); ok('deleting a calendar works', r.status === 200 && (await call(ann, '/api/cal/list')).json.length === 1);
ok('and removes its events and its share', !(await titles(ann)).includes('Cy adds') && (await titles(cy)) === '');
r = await call(ann, '/api/cal/ncal/delete', { id: 'local' }); ok('the last calendar cannot be deleted', r.status === 400, r);
ok('older events survived all that', (await titles(ann)).includes('Old event'));
// link-calendar colors
fs.writeFileSync(path.join(users.dirOf(bob.id), 'calendars.json'), JSON.stringify([{ id: 'lnk', name: 'Holidays', url: 'https://example.com/x.ics', color: '#e5a00d' }]));
await call(bob, '/api/cal/color', { id: 'lnk', color: '#112233' });
ok('a calendar link can be recolored', (await call(bob, '/api/cal/list')).json.find(c => c.id === 'lnk').color === '#112233');
r = await call(bob, '/api/cal/color', { id: 'lnk', color: 'blue' }); ok('with a real color only', r.status === 400);
console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0);
})();