import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, appendFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { world, sleep } from './world.mjs'; import { tasksAdapter, TIMEOUT } from '../src/index.mjs'; const day = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10); const credentialEvents = (w) => w.store .all("SELECT kind, body FROM events WHERE kind LIKE 'credential.%' ORDER BY seq") .map((e) => [e.kind, JSON.parse(e.body)]); // Timers the test fires by hand. const manual = () => { const set = []; return { set, setInterval: (fn, ms) => { const t = { fn, ms, cleared: false, unref() {} }; set.push(t); return t; }, clearInterval: (t) => { t.cleared = true; }, }; }; test('the boot config is checked before anything starts', async (t) => { const w = await world(t); const make = (tracker, edit = (b) => b) => tasksAdapter({ trackers: { demo: tracker }, fetch: w.fake.fetch, autostart: false })({ broker: w.broker, credentials: w.credentials, businesses: { demo: edit(structuredClone(w.businesses.demo)) }, }); const good = { baseUrl: 'http://vikunja.test', project: w.project }; for (const bad of [ { ...good, baseUrl: 'http://vikunja.test/api/v2' }, { ...good, baseUrl: 'ftp://vikunja.test' }, { ...good, baseUrl: 'http://user:pw@vikunja.test' }, { ...good, project: 0 }, { ...good, project: '1' }, { ...good, pollSeconds: 5 }, { ...good, reconcileMinutes: 0 }, null, ]) await assert.rejects(make(bad), (e) => e.code === 'tracker-config', JSON.stringify(bad)); for (const edit of [ (b) => ((b.tracker.sync.botId = 0), b), (b) => ((b.roles.coder.definition = 'pm'), b), (b) => (delete b.roles.pm.tracker, b), (b) => ((b.roles.coder.tracker.botId = -1), b), (b) => ((b.tracker.labels.bad = 0), b), ]) await assert.rejects(make(good, edit), (e) => e.code === 'tracker-config'); await assert.rejects( tasksAdapter({ trackers: { other: good } })({ broker: w.broker, credentials: w.credentials, businesses: w.businesses }), (e) => e.code === 'tracker-config', ); const a = await make({ ...good, pollSeconds: 10 }); assert.equal(a.timeout, TIMEOUT); assert.deepEqual(a.status(), [ { business: 'demo', state: 'starting', refused: null, version: null, untested: null, lastPoll: null, lastReconcile: null }, ]); await a.close(); }); test('a business with no tracker entry refuses task verbs', async (t) => { const w = await world(t); const a = await tasksAdapter({ trackers: {}, autostart: false })({ broker: w.broker, credentials: w.credentials, businesses: w.businesses, }); await assert.rejects( w.broker.requestTask(w.agent('pm'), { verb: 'task.close', args: { task_ref: 'vikunja:1/1', verdict: 'v' } }, a.handle), (e) => e.code === 'tracker-unconfigured', ); assert.deepEqual(a.status(), []); }); test('credential.expiring and .expired are recorded once per instance', async (t) => { const w = await world(t, { expires: { coder: day(3), reviewer: day(10) } }); await w.adapter.start('demo'); await w.adapter.notify('demo'); assert.deepEqual(credentialEvents(w), [['credential.expiring', { service: 'vikunja', instance: 'demo/coder', date: day(3) }]]); const gone = await world(t, { expires: { pm: day(-1) } }); await gone.adapter.start('demo'); assert.equal(gone.adapter.status()[0].refused, 'credential-expired'); await gone.adapter.notify('demo'); assert.deepEqual(credentialEvents(gone), [['credential.expired', { service: 'vikunja', instance: 'demo/pm', date: day(-1) }]]); }); test('a token file that changes on disk records credential.changed', async (t) => { const dir = mkdtempSync(join(tmpdir(), 'tasks-cred-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const file = join(dir, 'pm.token'); const w = await world(t, { files: { pm: file } }); await w.adapter.start('demo'); assert.equal(w.adapter.status()[0].state, 'ready'); await w.adapter.notify('demo'); assert.deepEqual(credentialEvents(w), []); appendFileSync(file, '\n'); await w.adapter.notify('demo'); await w.adapter.notify('demo'); assert.deepEqual(credentialEvents(w), [['credential.changed', { service: 'vikunja', instance: 'demo/pm' }]]); // The file's path and contents stay out of the event. assert.ok(!JSON.stringify(credentialEvents(w)).includes(dir)); }); test('autostart polls, reconciles and retries a startup the tracker was down for', async (t) => { const timers = manual(); let down = true; let w; w = await world(t, { adapter: { autostart: true, timers }, tracker: { pollSeconds: 15, reconcileMinutes: 30 }, fetch: (...a) => (down ? Promise.reject(new TypeError('fetch failed')) : w.fake.fetch(...a)), }); await w.adapter.ready; assert.equal(w.adapter.status()[0].refused, 'tracker-unavailable'); assert.deepEqual( timers.set.map((x) => x.ms), [15000, 1800000], ); const [poll, hourly] = timers.set; down = false; poll.fn(); await w.adapter.notify('demo'); assert.equal(w.adapter.status()[0].state, 'ready'); await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'made in the UI' }); // Startup's reconcile recorded nothing yet; this tick finds the new task. await sleep(20); poll.fn(); await w.adapter.notify('demo'); assert.equal(w.events('task.changed.external').length, 1); hourly.fn(); await w.adapter.notify('demo'); assert.notEqual(w.adapter.status()[0].lastReconcile, null); await w.adapter.close(); assert.ok(timers.set.every((x) => x.cleared)); }); test('a refusal a restart must clear is not retried by the poll', async (t) => { const timers = manual(); const w = await world(t, { adapter: { autostart: true, timers }, scopes: { sync: '*' } }); await w.adapter.ready; assert.equal(w.adapter.status()[0].refused, 'scope-too-broad'); const before = w.fake.requests.length; timers.set[0].fn(); timers.set[1].fn(); await w.adapter.notify('demo'); assert.equal(w.fake.requests.length, before); assert.deepEqual(w.log, ['tasks demo startup scope-too-broad']); }); test('a poll that fires while two are queued is dropped', async (t) => { const timers = manual(); const w = await world(t, { adapter: { autostart: true, timers } }); await w.adapter.ready; const boards = () => w.fake.requests.filter((r) => r.method === 'GET' && r.path.endsWith('/buckets/tasks')).length; let before = boards(); await w.adapter.tick('demo'); const perTick = boards() - before; assert.ok(perTick > 0); before = boards(); // One tick runs and one waits; the other three are dropped. for (let i = 0; i < 5; i++) timers.set[0].fn(); await w.adapter.notify('demo'); assert.equal(boards() - before, 2 * perTick); assert.deepEqual(w.log, []); }); test('close waits for a running verb and refuses one that has not started', async (t) => { let hold, entered; const reached = new Promise((ok) => (entered = ok)); let w; w = await world(t, { fetch: async (url, init) => { if (hold && init?.method === 'POST' && url.endsWith(`/projects/${w.project}/tasks`)) { entered(); await hold; } return w.fake.fetch(url, init); }, }); await w.adapter.start('demo'); const pm = w.agent('pm'); let release; hold = new Promise((ok) => (release = ok)); const running = w.call(pm, 'task.create', { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' }); await reached; const queued = w.call(pm, 'task.create', { title: 'y', request: w.instruction(), requirement: 'REQ-S-1' }); let done = false; const closing = w.adapter.close().then(() => (done = true)); await sleep(20); assert.equal(done, false); release(); await closing; assert.ok((await running).task_ref); await assert.rejects(queued, (e) => e.code === 'tracker-closed'); await assert.rejects( w.call(pm, 'task.create', { title: 'z', request: w.instruction(), requirement: 'REQ-S-1' }), (e) => e.code === 'tracker-closed', ); assert.equal(w.events('task.created').length, 1); assert.deepEqual(w.log, []); });