Files
stack/packages/tasks/tests/verbs.test.mjs
T
jason.woltjeandClaude Opus 5.5 e918a915a8 tasks, bus, harness, seat: the approved requirement ids as data (#1584)
Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on
manifest 95611274 (21 paths, base b8539480). The approved PRD's
requirement ids live in packages/tasks/requirements.json. task_create
refuses an id outside it, and the PM session gets a list_requirements
tool. Round 2 adds requirements.mjs to the sandbox's RUNNER_FILES
(decision 127). Sage gate on c047d028 plus the patch, 23:50:16Z to
23:54:26Z: tasks 89, bus 111, harness 66, webui 101, cli 123, seat 33,
business 60, control-board 124, queue 150, conversation 182, discord 178,
ledger 78, runs 43, mosaic 69, all fail 0. Every scripts/test-*.sh
exited 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:55:49 -05:00

662 lines
36 KiB
JavaScript

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);
});