import test from 'node:test'; import assert from 'node:assert/strict'; import { world, sleep, SCOPES } from './world.mjs'; import { FakeVikunja } from '../src/fake.mjs'; import { loadRequirements } from '../src/requirements.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-TASK-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-TASK-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-TASK-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); }); // Row 90 (#1584): the ids are the approved PRD's, from requirements.json. test('task.create takes only an approved requirement id', async (t) => { const w = await ready(t); const pm = w.agent('pm'); const { requirements } = loadRequirements(); const args = { title: 'x', request: w.instruction() }; // REQ-QA-1 is the id P19 #229 and #230 were created under: the right form, not in the PRD. for (const requirement of ['REQ-QA-1', 'REQ-TASK-5', 'REQ-S-1']) await assert.rejects(w.call(pm, 'task.create', { ...args, requirement }), refusedWith('unknown-requirement'), requirement); // The form is checked first, as before. await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'REQ-task-1' }), refusedWith('invalid-request')); assert.equal(allowed(w, 'task.create').length, 0); assert.equal(w.events('task.created').length, 0); for (const { id } of [requirements[0], requirements.at(-1)]) await w.call(pm, 'task.create', { ...args, requirement: id }); assert.deepEqual(w.events('task.created').map((e) => e.body.requirement), [requirements[0].id, requirements.at(-1).id]); }); test("task.create cites the request a received message carries, not the message's id", async (t) => { const w = await ready(t); const pm = w.agent('pm'); const sent = w.broker.request(w.human, { verb: 'message.send', args: { to: 'pm', body: 'build the thing' } }); const [m] = w.broker.request(pm, { verb: 'message.receive' }); assert.equal(m.id, sent.id); assert.equal(m.request, sent.request); assert.notEqual(m.request, m.id); const args = { title: 'the thing', requirement: 'REQ-TASK-1' }; await assert.rejects(w.call(pm, 'task.create', { ...args, request: m.id }), refusedWith('request-not-found')); const r = await w.call(pm, 'task.create', { ...args, request: m.request }); assert.deepEqual(w.events('task.created').map((e) => [e.subject, e.body]), [[r.task_ref, { request: m.request, requirement: 'REQ-TASK-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); // Assignee stays pm-only (decision 90); the coder role holds no task.reassign grant, and the verb refuses it anyway. await assert.rejects(w.call(coder, 'task.reassign', { task_ref, role: 'reviewer' }), refusedWith('field-writer')); assert.equal(allowed(w, 'task.reassign').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 `id` fails. const thenFailRead = (trigger, id = 1) => { 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', new RegExp(`^/tasks/${id}$`), 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); }); // Row B: a tree read with its descendants, as in tree.test.mjs. The child is installed before // start, so the first reconcile reads it with its own board; the grandchild keeps Vikunja's // default buckets, so it is watch-only. tree()'s `edit` goes to world() with TREE. const TREE = { tracker: { descendants: true }, scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } }, }; const tree = async (t, edit = {}) => { const w = await world(t, { ...TREE, ...edit }); const child = w.fake.project('Child', w.owner, { parent: w.project }); const board = w.fake.install(child); const grandchild = w.fake.project('Grandchild', w.owner, { parent: child }); const watched = (await w.ui('POST', `/projects/${grandchild}/tasks`, { title: 'watched' })).json.id; await w.adapter.start('demo'); assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused); return { w, child, board, grandchild, watched: `vikunja:${grandchild}/${watched}` }; }; const writes = (w) => w.fake.requests.filter((r) => r.method !== 'GET').length; test('every verb acts in an installed child, on the child\'s own board', async (t) => { const { w, child, board } = await tree(t); const pm = w.agent('pm'); const coder = w.agent('coder'); const cto = w.agent('cto'); const root = await task(w); assert.equal(root.task_ref, `vikunja:${w.project}/2`); const made = await task(w, { project: child, relations: [{ kind: 'blocking', task_ref: root.task_ref }] }); const task_ref = `vikunja:${child}/3`; // A cross-role verb on the child's task, approved by the cto. The decision binds the child's ref. const approved = (cap, action) => { const d = w.broker.request(cap, { verb: 'decision.raise', args: { action, domain: 'technical', target: task_ref, task_ref, question: `${action}?`, options: [ { key: 'yes', text: 'yes' }, { key: 'no', text: 'no' }, ], recommendation: 'yes', blocking: false, }, }); w.broker.request(cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); return d.id; }; assert.equal(made.task_ref, task_ref); assert.equal(w.fake.task(3).project, child); assert.deepEqual(w.fake.task(3).relations, [{ kind: 'blocking', other: 2 }]); assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets.todo); assert.equal(w.fake.task(3).buckets.get(w.installed.view), undefined); assert.equal(w.snapshots(task_ref).at(-1).fields.project_id, child); assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.todo); assert.equal(w.events('task.created').at(-1).subject, task_ref); // An explicit root project is the default spelled out. assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/4`); await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); assert.deepEqual(w.fake.task(3).assignees, [w.bots.coder]); await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.5 }); assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets['in-progress']); assert.deepEqual(w.events('task.state').at(-1).body, { role: 'coder', state: 'in-progress', previous: 'todo', percent_done: 0.5, comment: false }); await w.call(coder, 'task.scope.change', { task_ref, description: 'in the child', decision: approved(coder, 'task.scope.change') }); assert.equal(w.fake.task(3).description, 'in the child'); await w.call(pm, 'task.reassign', { task_ref, role: 'reviewer' }); assert.deepEqual(w.fake.task(3).assignees, [w.bots.reviewer]); await w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-24T12:00:00.000Z', relations: { remove: [{ kind: 'blocking', task_ref: root.task_ref }] } }); assert.deepEqual(w.fake.task(3).relations, []); assert.equal(w.snapshots(task_ref).at(-1).fields.due_date, '2026-12-24T12:00:00.000Z'); // A relation from a root task to the child's task, the other way across the tree. await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref }] } }); assert.deepEqual(w.fake.task(2).relations, [{ kind: 'related', other: 3 }]); await w.call(pm, 'task.priority.change', { task_ref, priority: 4, decision: approved(pm, 'task.priority.change') }); assert.equal(w.fake.task(3).priority, 4); await w.call(pm, 'task.close', { task_ref, verdict: 'queue:63' }); assert.equal(w.fake.task(3).done, true); assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets.done); assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.done); assert.ok(!w.broker.taskView('demo', 'open').some((r) => r.task_ref === task_ref)); }); test('task.create refuses a project outside the tree, a watch-only one and one that is not an id', async (t) => { const { w, grandchild } = await tree(t); w.agent('pm'); const outside = w.fake.project('Outside', w.owner); const before = writes(w); await assert.rejects(task(w, { project: grandchild }), refusedWith('project-not-installed')); await assert.rejects(task(w, { project: outside }), refusedWith('project-not-installed')); await assert.rejects(task(w, { project: 999 }), refusedWith('project-not-installed')); for (const project of [null, 0, -1, 1.5, '1', [w.project]]) await assert.rejects(task(w, { project }), refusedWith('invalid-request')); assert.equal(writes(w), before); assert.equal(allowed(w, 'task.create').length, 0); }); test('with descendants off, task.create takes only the root project', async (t) => { const w = await world(t); const child = w.fake.project('Child', w.owner, { parent: w.project }); w.fake.install(child); await w.adapter.start('demo'); w.agent('pm'); await assert.rejects(task(w, { project: child }), refusedWith('project-not-installed')); assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/1`); }); test('a verb on a watch-only task, or a relation to one, refuses with project-not-installed', async (t) => { const { w, watched } = await tree(t); const pm = w.agent('pm'); const root = await task(w); const before = writes(w); await assert.rejects(w.call(pm, 'task.assign', { task_ref: watched, role: 'coder' }), refusedWith('project-not-installed')); await assert.rejects(w.call(pm, 'task.close', { task_ref: watched, verdict: 'queue:63' }), refusedWith('project-not-installed')); await assert.rejects( w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: watched }] } }), refusedWith('project-not-installed'), ); await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: watched }] }), refusedWith('project-not-installed')); // A project outside the tree is still not the business's. await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'vikunja:999/1', role: 'coder' }), refusedWith('task-project')); assert.equal(writes(w), before); assert.equal(allowed(w, 'task.assign').length + allowed(w, 'task.close').length + allowed(w, 'task.schedule').length, 0); }); test('a related task is read before authority: in another project than its ref names, task-moved; missing, task-not-found', async (t) => { const { w, child } = await tree(t); const pm = w.agent('pm'); const root = await task(w); const id = Number(root.task_ref.split('/')[1]); // The ref claims the child, but the task sits in the root. const claimed = `vikunja:${child}/${id}`; const before = writes(w); await assert.rejects( w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: claimed }] } }), refusedWith('task-moved'), ); await assert.rejects( w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { remove: [{ kind: 'related', task_ref: claimed }] } }), refusedWith('task-moved'), ); await assert.rejects(task(w, { project: child, relations: [{ kind: 'related', task_ref: claimed }] }), refusedWith('task-moved')); await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: `vikunja:${child}/77` }] }), refusedWith('task-not-found')); assert.equal(writes(w), before); assert.equal(allowed(w, 'task.schedule').length + allowed(w, 'task.create').length, 1); // The same relation with the right ref goes through. const other = await task(w, { project: child }); await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: other.task_ref }] } }); assert.deepEqual(w.fake.task(id).relations, [{ kind: 'related', other: Number(other.task_ref.split('/')[1]) }]); }); test('a task moved to another project of the tree refuses its old ref with task-moved and takes its new one', async (t) => { const { w, child, board } = await tree(t); const pm = w.agent('pm'); const { task_ref } = await task(w); const id = Number(task_ref.split('/')[1]); w.fake.moveToProject(id, child); const before = writes(w); await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-moved')); assert.equal(writes(w), before); await w.call(pm, 'task.assign', { task_ref: `vikunja:${child}/${id}`, role: 'coder' }); assert.deepEqual(w.fake.task(id).assignees, [w.bots.coder]); assert.equal(w.snapshots(`vikunja:${child}/${id}`).at(-1).fields.bucket, board.buckets.todo); }); // The child versions of the re-read and fallback paths the root's tests above cover (Dewey's // and Filbert's round 1 reviews): each fails if its path goes back to the root's project or view. test('a verb and a create in the child record the final read of the child\'s board', async (t) => { const { w, child } = await tree(t); const pm = w.agent('pm'); const made = await task(w, { project: child }); assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null); await w.call(pm, 'task.assign', { task_ref: made.task_ref, role: 'coder' }); assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null); }); test('a create in the child whose final read fails records the child\'s todo bucket', async (t) => { // The grandchild's task is 1, so the create makes task 2. const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p), 2); const { w, child, board } = await tree(t, f.edit); w.agent('pm'); f.arm(); const r = await task(w, { project: child }); assert.equal(r.task_ref, `vikunja:${child}/2`); assert.equal(w.events('task.created').length, 1); const s = w.snapshots(r.task_ref).at(-1); assert.equal(s.source, 'self'); assert.equal(s.etag, null); assert.equal(s.fields.bucket, board.buckets.todo); await sleep(20); await w.adapter.tick('demo'); // The start's reconcile recorded the grandchild's task as external; the create isn't. assert.deepEqual(w.events('task.changed.external').map((e) => e.subject), [`vikunja:${w.fake.task(1).project}/1`]); }); test('a lost answer in the child is settled by a re-read of the child\'s board', async (t) => { const { w, child, board } = await tree(t); const pm = w.agent('pm'); const coder = w.agent('coder'); const { task_ref } = await task(w, { project: child }); await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); // A state move and a close that landed but answered 500: each re-read shows it, so each 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(w.events('task.state').length, 1); assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets['in-progress']); w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500, { apply: true }); await w.call(pm, 'task.close', { task_ref, verdict: 'queue:63' }); assert.equal(w.events('task.closed').length, 1); assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.done); assert.equal(w.fake.requests.filter((r) => r.method === 'PUT').length, 2); }); test('every related task is read before authority, not only the first', async (t) => { const { w, child } = await tree(t); const pm = w.agent('pm'); const root = await task(w); const other = await task(w); const id = Number(other.task_ref.split('/')[1]); const before = writes(w); await assert.rejects( w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: other.task_ref }, { kind: 'blocking', task_ref: `vikunja:${child}/${id}` }] }, }), refusedWith('task-moved'), ); assert.equal(writes(w), before); });