Commits move as v2 bundles over bus-host/git.sock (decision 135). The bus
host keeps one bare working repository per business and project, seeds main
from the project root under a per-repository lock, fetches a pushed bundle
with fsck into a temp ref, requires history shared with main, and moves the
task's work ref only as a fast-forward. Host and runner git run with no
system or global config, hooks at /dev/null and local transports only. The
socket binds only for roles holding git.push.working or review.verdict.
The broker records a push as the action.allowed for git.push.working with
its commit, takes review.request for the latest push and one review.verdict
per request, and refuses task.close on a pushed task without an approve
verdict for the latest push. recordEvent reserves both shapes, so agents
can't forge either.
Reviewed by Dewey (28196) and Sage (28197), both approve. Gate on 7a729aef:
14 node suites and 9 scripts green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
694 lines
37 KiB
JavaScript
694 lines
37 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 task with pushed work closes only citing an approve verdict for its latest push, and a refusal writes nothing', async (t) => {
|
|
// Row 93 (#1587): the verb hands its verdict to the broker's check.
|
|
const w = await ready(t, { business: (b) => (b.projects = { stack: { root: '/nonexistent' } }) });
|
|
const pm = w.agent('pm');
|
|
const coder = w.agent('coder');
|
|
const reviewer = w.agent('reviewer');
|
|
const { task_ref } = await task(w);
|
|
const push = (commit, previous) => {
|
|
w.broker.checkPush(coder, task_ref);
|
|
w.broker.recordPush(coder, { task_ref, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-1', commit, previous });
|
|
};
|
|
const verdict = (commit, v) => {
|
|
const r = w.broker.request(coder, { verb: 'review.request', args: { task_ref, commit, to: 'reviewer' } });
|
|
return w.broker.request(reviewer, { verb: 'review.verdict', args: { task_ref, request: r.request, verdict: v, body: v } }).verdict;
|
|
};
|
|
const A = 'a'.repeat(40);
|
|
const B = 'b'.repeat(40);
|
|
push(A, null);
|
|
const writes = () => w.fake.requests.filter((r) => r.method !== 'GET').length;
|
|
const before = writes();
|
|
await assert.rejects(w.call(pm, 'task.close', { task_ref, verdict: 'queue:38' }), refusedWith('verdict-not-found'));
|
|
await assert.rejects(w.call(pm, 'task.close', { task_ref, verdict: verdict(A, 'changes') }), refusedWith('verdict-not-approve'));
|
|
const stale = verdict(A, 'approve');
|
|
push(B, A);
|
|
await assert.rejects(w.call(pm, 'task.close', { task_ref, verdict: stale }), refusedWith('verdict-stale'));
|
|
assert.equal(writes(), before, 'no refused close reached the tracker');
|
|
assert.equal(w.fake.task(1).done, false);
|
|
const fresh = verdict(B, 'approve');
|
|
await w.call(pm, 'task.close', { task_ref, verdict: fresh });
|
|
assert.equal(w.fake.task(1).done, true);
|
|
assert.deepEqual(w.events('task.closed')[0].body, { verdict: fresh });
|
|
});
|
|
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);
|
|
});
|