import test from 'node:test'; import assert from 'node:assert/strict'; import { world, sleep } from './world.mjs'; import { FakeVikunja } from '../src/fake.mjs'; const ready = async (t, edit) => { const w = await world(t, edit); await w.adapter.start('demo'); assert.equal(w.adapter.status()[0].state, 'ready'); return w; }; const allowed = (w, action) => w.events('action.allowed').filter((e) => e.body.action === action); const refusedWith = (code) => (e) => e.code === code; // A pm-created task, returned with its ref and digest. const task = async (w, args = {}) => { const pm = w.caps.pm ?? w.agent('pm'); return w.call(pm, 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-S-1', ...args }); }; test('task.create needs a recorded human request and a requirement id', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const args = { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' }; await assert.rejects(w.call(pm, 'task.create', { ...args, request: 'not-an-event' }), refusedWith('request-not-found')); await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'S-1' }), refusedWith('invalid-request')); await assert.rejects(w.call(pm, 'task.create', { title: 'x', request: args.request }), refusedWith('invalid-request')); await assert.rejects(w.call(pm, 'task.create', { ...args, title: ' ' }), refusedWith('invalid-request')); assert.equal(allowed(w, 'task.create').length, 0); const r = await w.call(pm, 'task.create', { ...args, due_date: '2026-11-01T00:00:00.000Z', priority: 3, labels: [w.label] }); assert.equal(r.task_ref, `vikunja:${w.project}/1`); const created = w.events('task.created'); assert.equal(created.length, 1); assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-S-1' }); assert.equal(created[0].actor_role, 'pm'); const [s] = w.snapshots(r.task_ref); assert.equal(s.source, 'self'); assert.equal(s.digest, r.digest); assert.deepEqual( { ...s.fields }, { project_id: w.project, title: 'x', description: '', done: false, due_date: '2026-11-01T00:00:00.000Z', priority: 3, percent_done: 0, bucket: w.installed.buckets.todo, labels: [w.label], assignees: [], }, ); assert.equal(allowed(w, 'task.create').length, 1); }); test('only labels named in the business file can be written', async (t) => { const w = await ready(t); // The pm can see this label in Vikunja (its owner created it), but the business file doesn't list it. const other = w.fake.label('launchpad', w.owner); const pm = w.agent('pm'); const before = w.fake.requests.length; await assert.rejects(task(w, { labels: [other] }), refusedWith('label-not-allowed')); const { task_ref } = await task(w); await assert.rejects(w.call(pm, 'task.schedule', { task_ref, labels: { add: [other] } }), refusedWith('label-not-allowed')); assert.equal(w.fake.requests.slice(before).filter((r) => r.path.endsWith('/labels')).length, 0); await w.call(pm, 'task.schedule', { task_ref, labels: { add: [w.label] } }); assert.deepEqual(w.fake.task(1).labels, [w.label]); await w.call(pm, 'task.schedule', { task_ref, labels: { remove: [w.label] } }); assert.deepEqual(w.fake.task(1).labels, []); }); test('task.schedule sets and clears a due date and relations', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const a = await task(w); const b = await task(w, { title: 'b', relations: [{ kind: 'blocking', task_ref: a.task_ref }] }); assert.deepEqual(w.fake.task(2).relations, [{ kind: 'blocking', other: 1 }]); await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-24T12:00:00.000Z' }); assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-12-24T12:00:00.000Z'); await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: null }); assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, null); assert.equal(w.fake.task(1).due, null); await w.call(pm, 'task.schedule', { task_ref: b.task_ref, relations: { remove: [{ kind: 'blocking', task_ref: a.task_ref }] } }); assert.deepEqual(w.fake.task(2).relations, []); await assert.rejects(w.call(pm, 'task.schedule', { task_ref: a.task_ref }), refusedWith('invalid-request')); await assert.rejects( w.call(pm, 'task.schedule', { task_ref: a.task_ref, relations: { add: [{ kind: 'blocking', task_ref: 'vikunja:99/1' }] } }), refusedWith('task-project'), ); await assert.rejects(w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-24' }), refusedWith('invalid-request')); }); test('assign and reassign move the role bots and record task.assigned', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const coder = w.agent('coder'); const { task_ref } = await task(w); // The field-writer check runs before authority, so the refused call consumes nothing. await assert.rejects(w.call(coder, 'task.assign', { task_ref, role: 'coder' }), refusedWith('field-writer')); assert.equal(allowed(w, 'task.assign').length, 0); await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'cto' }), refusedWith('unknown-role')); await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'toString' }), refusedWith('unknown-role')); await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-unassigned')); await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'reviewer' }), refusedWith('task-assigned')); await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); // A person assigned in the UI stays assigned; only the role bots move. await w.ui('POST', '/tasks/1/assignees', { user_id: w.owner }); await sleep(20); await w.adapter.tick('demo'); await w.call(pm, 'task.reassign', { task_ref, role: 'reviewer' }); assert.deepEqual(w.fake.task(1).assignees.sort(), [w.owner, w.bots.reviewer].sort()); assert.deepEqual( w.events('task.assigned').map((e) => e.body), [ { role: 'coder', previous: [] }, { role: 'reviewer', previous: ['coder'] }, ], ); }); test('task.update.assigned is for the assignee and records task.state', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const coder = w.agent('coder'); const reviewer = w.agent('reviewer'); const { task_ref } = await task(w); await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress' }), refusedWith('not-assigned')); await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); await assert.rejects(w.call(reviewer, 'task.update.assigned', { task_ref, state: 'in-progress' }), refusedWith('not-assigned')); await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'done' }), refusedWith('invalid-state')); await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 2 }), refusedWith('invalid-request')); await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref }), refusedWith('invalid-request')); const r = await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.5, comment: 'started' }); assert.equal(w.fake.task(1).buckets.get(w.installed.view), w.installed.buckets['in-progress']); assert.equal(w.fake.task(1).percent, 0.5); assert.equal(w.fake.task(1).comments[0].comment, 'started'); const [state] = w.events('task.state'); assert.deepEqual(state.body, { role: 'coder', state: 'in-progress', previous: 'todo', percent_done: 0.5, comment: true }); assert.equal(state.actor_role, 'coder'); // The comment text stays in the tracker. assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('started')); assert.equal(w.snapshots(task_ref).at(-1).digest, r.digest); // The poll sees the same state and a bot's comment, so nothing is external. await sleep(20); await w.adapter.tick('demo'); assert.equal(w.events('task.changed.external').filter((e) => e.subject === task_ref).length, 0); }); test('a wrong expected digest records task.conflict and writes nothing', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const created = await task(w); await w.ui('PATCH', '/tasks/1', { title: 'renamed by a person' }); const before = w.fake.requests.length; await assert.rejects( w.call(pm, 'task.priority.change', { task_ref: created.task_ref, priority: 4, expect: created.digest }), refusedWith('task-conflict'), ); assert.equal(w.fake.requests.slice(before).filter((r) => r.method !== 'GET').length, 0); const [c] = w.events('task.conflict'); assert.equal(c.subject, created.task_ref); assert.equal(c.body.expected, created.digest); assert.notEqual(c.body.actual, created.digest); await assert.rejects(w.call(pm, 'task.assign', { task_ref: created.task_ref, role: 'coder', expect: 'x' }), refusedWith('invalid-request')); await w.call(pm, 'task.assign', { task_ref: created.task_ref, role: 'coder', expect: c.body.actual }); }); test('a cross-role verb needs a resolved decision, used once', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const cto = w.agent('cto'); const { task_ref } = await task(w); await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 5 }), refusedWith('decision-required')); const d = w.broker.request(pm, { verb: 'decision.raise', args: { action: 'task.priority.change', domain: 'technical', target: task_ref, task_ref, question: 'raise to 5?', options: [ { key: 'yes', text: 'yes' }, { key: 'no', text: 'no' }, ], recommendation: 'yes', blocking: false, }, }); await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 5, decision: d.id }), refusedWith('decision-not-approved')); w.broker.request(cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); await w.call(pm, 'task.priority.change', { task_ref, priority: 5, decision: d.id }); assert.equal(w.fake.task(1).priority, 5); await assert.rejects(w.call(pm, 'task.priority.change', { task_ref, priority: 1, decision: d.id }), refusedWith('decision-consumed')); // The scope change is cross-role for the pm too. await assert.rejects(w.call(pm, 'task.scope.change', { task_ref, title: 'new' }), refusedWith('decision-required')); }); test('task.close needs a verdict; after it every verb refuses with task-done', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const coder = w.agent('coder'); const { task_ref } = await task(w); await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); await assert.rejects(w.call(pm, 'task.close', { task_ref }), refusedWith('invalid-request')); await assert.rejects(w.call(coder, 'task.close', { task_ref, verdict: 'queue:38' }), refusedWith('field-writer')); await w.call(pm, 'task.close', { task_ref, verdict: 'queue:38' }); assert.equal(w.fake.task(1).done, true); assert.deepEqual(w.events('task.closed')[0].body, { verdict: 'queue:38' }); assert.deepEqual(w.broker.taskView('demo', 'open'), []); await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 1 }), refusedWith('task-done')); await assert.rejects(w.call(pm, 'task.close', { task_ref, verdict: 'again' }), refusedWith('task-done')); }); test('a lost answer is settled by a re-read and never retried', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const coder = w.agent('coder'); const { task_ref } = await task(w); await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); const moves = () => w.fake.requests.filter((r) => r.method === 'PUT').length; // The move landed but the answer was a 500: the re-read shows it, so the verb succeeds. w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500, { apply: true }); await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress' }); assert.equal(moves(), 1); assert.equal(w.events('task.state').length, 1); // The move didn't land: write-uncertain, a snapshot of what the tracker holds, and no event. w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500); await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, state: 'blocked' }), refusedWith('write-uncertain')); assert.equal(moves(), 2); assert.equal(w.events('task.state').length, 1); assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, w.installed.buckets['in-progress']); // A network error leaves the outcome unknown too. w.fake.fault('PATCH', /^\/tasks\/1$/, 0); await assert.rejects(w.call(coder, 'task.update.assigned', { task_ref, percent_done: 0.9 }), refusedWith('write-uncertain')); }); test('a create whose answer is lost is reported uncertain, and the poll finds the task', async (t) => { const w = await ready(t); w.agent('pm'); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0); await assert.rejects(task(w), refusedWith('write-uncertain')); assert.equal(w.fake.task(1), undefined); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 502, { apply: true }); await assert.rejects(task(w), refusedWith('write-uncertain')); assert.equal(w.events('task.created').length, 0); await sleep(20); await w.adapter.tick('demo'); const [e] = w.events('task.changed.external'); assert.equal(e.subject, `vikunja:${w.project}/1`); assert.equal(e.body.previous, null); }); test('a task the sync bot cannot read refuses and records nothing', async (t) => { const w = await ready(t); const pm = w.agent('pm'); await assert.rejects(w.call(pm, 'task.assign', { task_ref: `vikunja:${w.project}/77`, role: 'coder' }), refusedWith('task-not-found')); await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'vikunja:99/1', role: 'coder' }), refusedWith('task-project')); await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'nope', role: 'coder' }), refusedWith('invalid-request')); const other = w.fake.project('Launchpad', w.owner); await task(w); w.fake.moveToProject(1, other); await assert.rejects(w.call(pm, 'task.assign', { task_ref: `vikunja:${w.project}/1`, role: 'coder' }), refusedWith('tracker-forbidden')); assert.equal(allowed(w, 'task.assign').length, 0); }); test('verbs and polls for one business run one at a time', async (t) => { let inflight = 0, most = 0, w; w = await world(t, { fetch: async (...a) => { inflight++; most = Math.max(most, inflight); await sleep(2); try { return await w.fake.fetch(...a); } finally { inflight--; } }, }); await w.adapter.start('demo'); const pm = w.agent('pm'); const { task_ref } = await task(w); await Promise.all([ w.call(pm, 'task.schedule', { task_ref, labels: { add: [w.label] } }), w.call(pm, 'task.assign', { task_ref, role: 'coder' }), w.adapter.tick('demo'), ]); assert.equal(most, 1); assert.deepEqual(w.fake.task(1).labels, [w.label]); assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); }); // Vikunja keeps due dates to the second (review r1, B1). The adapter drops the milliseconds before it // writes, so its own snapshot, the digest it returns and the next poll all agree. test('a due date with milliseconds is written to the second', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const a = await task(w, { due_date: '2026-11-01T00:00:00.789Z' }); assert.equal(w.fake.task(1).due, '2026-11-01T00:00:00Z'); assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-11-01T00:00:00.000Z'); const r = await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' }); assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z'); const s = w.snapshots(a.task_ref).at(-1); assert.equal(s.fields.due_date, '2026-12-01T09:00:00.000Z'); assert.equal(s.digest, r.digest); await sleep(20); assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); const patches = () => w.fake.requests.filter((x) => x.method === 'PATCH').length; const n = patches(); await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' }); assert.equal(patches(), n); // The digest handed back is the one the next verb has to name. await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-02T09:00:00.456Z', expect: r.digest }); // A landed PATCH whose answer was lost settles on the re-read. w.fake.fault('PATCH', /^\/tasks\/1$/, 500, { apply: true }); await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-03T09:00:00.456Z' }); assert.equal(w.fake.task(1).due, '2026-12-03T09:00:00Z'); await sleep(20); assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); }); // Arms a fault on the final read: after `trigger` answers, the next GET of task 1 fails. const thenFailRead = (trigger) => { const fake = new FakeVikunja(); let armed = false; const fetch = async (url, init = {}) => { const res = await fake.fetch(url, init); if (armed && trigger(init.method ?? 'GET', new URL(url).pathname)) { armed = false; fake.fault('GET', /^\/tasks\/1$/, 0); } return res; }; return { edit: { fake, fetch }, arm: () => (armed = true) }; }; test('every write landed and the final read failed: the verb succeeds and records what it wrote', async (t) => { const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/assignees')); const w = await ready(t, f.edit); const pm = w.agent('pm'); const { task_ref } = await task(w); f.arm(); const r = await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); assert.equal(w.events('task.assigned').length, 1); const s = w.snapshots(task_ref).at(-1); assert.equal(s.source, 'self'); assert.deepEqual([...s.fields.assignees], [w.bots.coder]); assert.equal(s.digest, r.digest); await sleep(20); await w.adapter.tick('demo'); assert.equal(w.events('task.changed.external').length, 0); await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); }); test('some writes landed and the final read failed: write-uncertain, and nothing is recorded', async (t) => { const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/labels')); const w = await ready(t, f.edit); const pm = w.agent('pm'); const { task_ref } = await task(w); const n = w.snapshots(task_ref).length; w.fake.fault('POST', /^\/tasks\/1\/labels$/, 403); f.arm(); await assert.rejects( w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.000Z', labels: { add: [w.label] } }), refusedWith('write-uncertain'), ); assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z'); assert.equal(w.snapshots(task_ref).length, n); }); test('a create whose final read fails succeeds and records task.created', async (t) => { const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p)); const w = await ready(t, f.edit); w.agent('pm'); f.arm(); const r = await task(w, { due_date: '2026-11-01T00:00:00.000Z' }); assert.equal(w.events('task.created').length, 1); const [s] = w.snapshots(r.task_ref); assert.equal(s.source, 'self'); assert.equal(s.digest, r.digest); await sleep(20); await w.adapter.tick('demo'); assert.equal(w.events('task.changed.external').length, 0); }); // The success snapshot holds what the verb wrote, not what the final read saw, so an edit that lands // between the last write and that read is still a person's edit on the next poll. test('an edit between the last write and the final read shows as external on the next poll', async (t) => { const fake = new FakeVikunja(); let w, armed = false; w = await ready(t, { fake, fetch: async (url, init = {}) => { const res = await fake.fetch(url, init); if (armed && init.method === 'POST' && new URL(url).pathname.endsWith('/tasks/1/assignees')) { armed = false; await w.ui('PATCH', '/tasks/1', { title: 'renamed in the ui' }); } return res; }, }); const pm = w.agent('pm'); const { task_ref } = await task(w); armed = true; await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); assert.equal(w.snapshots(task_ref).at(-1).fields.title, 'a task'); await sleep(20); await w.adapter.tick('demo'); const ext = w.events('task.changed.external'); assert.equal(ext.length, 1); assert.deepEqual(ext[0].body.changed, ['title']); }); test('task.created is recorded when a later label write fails', async (t) => { const w = await ready(t); w.agent('pm'); w.fake.fault('POST', /^\/tasks\/\d+\/labels$/, 500); await assert.rejects(task(w, { labels: [w.label] }), refusedWith('write-uncertain')); assert.notEqual(w.fake.task(1), undefined); assert.deepEqual(w.fake.task(1).labels, []); assert.equal(w.events('task.created').length, 1); });