// Automations: rules are checked when saved, run when their trigger happens (and only then), keep to a daily cap, and belong to one person. const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http'); process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-auto-')); const users = require('../users'), auto = require('../automations'); const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['tasks'], handle: 'ZP' }); const tasksOf = u => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'tasks.json'), 'utf8')).tasks; } catch { return []; } }; const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : ann, () => auto.handle(req, res))).listen(0, async () => { const base = 'http://127.0.0.1:' + hub.address().port + '/api/auto', j = r => r.json(), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} }), get = who => fetch(base + '/', { headers: who ? { 'x-user': who } : {} }); try { // validation assert.equal((await post('/', { when: { type: 'nope' }, then: { type: 'task', text: 'x' } })).status, 400); assert.equal((await post('/', { when: { type: 'time', at: '25:00' }, then: { type: 'task', text: 'x' } })).status, 400, 'a real time of day'); assert.equal((await post('/', { when: { type: 'mail.new', match: '' }, then: { type: 'notify', text: 'x' } })).status, 400, 'mail rules need something to match, so it is not every email'); assert.equal((await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: ' ' } })).status, 400, 'and something to do'); // a daily rule runs once a day, at its time let r = await j(await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: 'Water the plants' } })); assert.equal(r.rules.length, 1); const daily = r.rules[0]; await auto.tickUser(ann, { day: '2026-11-02', hm: '07:59' }); assert.equal(tasksOf(ann).length, 0, 'not before its time'); await auto.tickUser(ann, { day: '2026-11-02', hm: '08:00' }); assert.deepEqual(tasksOf(ann).map(t => t.title), ['Water the plants']); await auto.tickUser(ann, { day: '2026-11-02', hm: '08:01' }); assert.equal(tasksOf(ann).length, 1, 'once a day'); await auto.tickUser(ann, { day: '2026-11-03', hm: '08:00' }); assert.equal(tasksOf(ann).length, 2, 'and again tomorrow'); await auto.tickUser(ann, { day: '2026-11-04', hm: '15:00' }); assert.equal(tasksOf(ann).length, 2, 'a time that passed hours ago is not caught up'); // a finished task starts matching rules, with the title filled in; the shopping action goes to the Shopping list await post('/', { when: { type: 'task.done', match: 'milk' }, then: { type: 'task', text: 'Buy more after {title}' } }); await post('/', { when: { type: 'task.done', match: '' }, then: { type: 'notify', text: 'Nice, {title} is done' }, name: 'Cheer' }); await auto.fire(ann.id, 'task.done', { title: 'Buy bread' }); assert.ok(!tasksOf(ann).some(t => /Buy more/.test(t.title)), 'a rule that does not match does nothing'); await auto.fire(ann.id, 'task.done', { title: 'Buy milk' }); assert.ok(tasksOf(ann).some(t => t.title === 'Buy more after Buy milk')); const inbox = require('../inbox').load(ann.id).filter(x => x.title === 'Cheer'); assert.ok(inbox.length >= 1 && /Nice, Buy (bread|milk) is done/.test(inbox[0].body), 'notify lands in the Notifications list'); // mail await post('/', { when: { type: 'mail.new', match: 'invoice' }, then: { type: 'shopping', text: 'Pay {subject}' } }); await auto.fire(ann.id, 'mail.new', { from: 'Bob', subject: 'Lunch?' }); assert.ok(!tasksOf(ann).some(t => /^Pay /.test(t.title))); await auto.fire(ann.id, 'mail.new', { from: 'Acme', subject: 'Your invoice 42' }); assert.ok(tasksOf(ann).some(t => t.title === 'Pay Your invoice 42')); // off, private, capped r = await j(await get()); assert.equal(r.rules.length, 4); assert.equal((await j(await get('bo'))).rules.length, 0, 'rules are personal'); assert.equal((await post('/delete', { id: daily.id }, 'bo')).status, 404, 'and cannot be touched by someone else'); const cheer = r.rules.find(x => x.name === 'Cheer'); await post('/toggle', { id: cheer.id, on: false }); const n0 = tasksOf(ann).length; await auto.fire(ann.id, 'task.done', { title: 'x' }); assert.equal(tasksOf(ann).length, n0, 'a rule that is off does not run'); await post('/', { when: { type: 'task.done', match: 'spam' }, then: { type: 'task', text: 'Again' } }); for (let i = 0; i < 30; i++) await auto.fire(ann.id, 'task.done', { title: 'spam' }); assert.equal(tasksOf(ann).filter(t => t.title === 'Again').length, 20, 'a rule runs at most 20 times a day'); assert.equal((await j(await post('/delete', { id: daily.id }))).rules.some(x => x.id === daily.id), false); console.log('automations test ok'); } catch (e) { console.error(e); process.exitCode = 1; } hub.close(); });