roles/coder.json crossRole goes from ["task.reassign", "task.scope.change"] to ["task.scope.change"]. The broker refuses a non-pm task.reassign at the writer check before it looks at authority, so the grant could never be used. A coder's task.reassign now classifies as gated and goes to the human. Backed by lead decision 90. Tests: business role and resolve (the narrowing test moves to the pm, which still holds two cross-role actions), a bus test on the shipped role files, a harness test that a coder with a tracker gets no task_reassign, the tasks fixture, and a verbs line showing a coder's task.reassign refuses field-writer with no action.allowed. mutants.sh is Rocko's packet rerun script, already committed. Author: Rocko (packet agents/rocko/work/queue-73/, commit2137dd7f). Candidate manifest sha256 bf4e5ed6, base324c064b, no drift on the candidate paths through8a42e819. Approval: Dewey, round 1, comment 27506 on #1567. Gate (Sage, /tmp/sage-g73 on8a42e819, 23:42-23:48Z): tasks 71, bus 81, business 60, harness 59, cli 113, webui 61, seat 27 node tests, 0 failed; every scripts/test-*.sh exit 0, 0 failed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
413 lines
21 KiB
JavaScript
413 lines
21 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { world, sleep } from './world.mjs';
|
|
import { FakeVikunja } from '../src/fake.mjs';
|
|
const ready = async (t, edit) => {
|
|
const w = await world(t, edit);
|
|
await w.adapter.start('demo');
|
|
assert.equal(w.adapter.status()[0].state, 'ready');
|
|
return w;
|
|
};
|
|
const allowed = (w, action) => w.events('action.allowed').filter((e) => e.body.action === action);
|
|
const refusedWith = (code) => (e) => e.code === code;
|
|
// A pm-created task, returned with its ref and digest.
|
|
const task = async (w, args = {}) => {
|
|
const pm = w.caps.pm ?? w.agent('pm');
|
|
return w.call(pm, 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-S-1', ...args });
|
|
};
|
|
test('task.create needs a recorded human request and a requirement id', async (t) => {
|
|
const w = await ready(t);
|
|
const pm = w.agent('pm');
|
|
const args = { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' };
|
|
await assert.rejects(w.call(pm, 'task.create', { ...args, request: 'not-an-event' }), refusedWith('request-not-found'));
|
|
await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'S-1' }), refusedWith('invalid-request'));
|
|
await assert.rejects(w.call(pm, 'task.create', { title: 'x', request: args.request }), refusedWith('invalid-request'));
|
|
await assert.rejects(w.call(pm, 'task.create', { ...args, title: ' ' }), refusedWith('invalid-request'));
|
|
assert.equal(allowed(w, 'task.create').length, 0);
|
|
const r = await w.call(pm, 'task.create', { ...args, due_date: '2026-11-01T00:00:00.000Z', priority: 3, labels: [w.label] });
|
|
assert.equal(r.task_ref, `vikunja:${w.project}/1`);
|
|
const created = w.events('task.created');
|
|
assert.equal(created.length, 1);
|
|
assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-S-1' });
|
|
assert.equal(created[0].actor_role, 'pm');
|
|
const [s] = w.snapshots(r.task_ref);
|
|
assert.equal(s.source, 'self');
|
|
assert.equal(s.digest, r.digest);
|
|
assert.deepEqual(
|
|
{ ...s.fields },
|
|
{
|
|
project_id: w.project,
|
|
title: 'x',
|
|
description: '',
|
|
done: false,
|
|
due_date: '2026-11-01T00:00:00.000Z',
|
|
priority: 3,
|
|
percent_done: 0,
|
|
bucket: w.installed.buckets.todo,
|
|
labels: [w.label],
|
|
assignees: [],
|
|
},
|
|
);
|
|
assert.equal(allowed(w, 'task.create').length, 1);
|
|
});
|
|
test('only labels named in the business file can be written', async (t) => {
|
|
const w = await ready(t);
|
|
// The pm can see this label in Vikunja (its owner created it), but the business file doesn't list it.
|
|
const other = w.fake.label('launchpad', w.owner);
|
|
const pm = w.agent('pm');
|
|
const before = w.fake.requests.length;
|
|
await assert.rejects(task(w, { labels: [other] }), refusedWith('label-not-allowed'));
|
|
const { task_ref } = await task(w);
|
|
await assert.rejects(w.call(pm, 'task.schedule', { task_ref, labels: { add: [other] } }), refusedWith('label-not-allowed'));
|
|
assert.equal(w.fake.requests.slice(before).filter((r) => r.path.endsWith('/labels')).length, 0);
|
|
await w.call(pm, 'task.schedule', { task_ref, labels: { add: [w.label] } });
|
|
assert.deepEqual(w.fake.task(1).labels, [w.label]);
|
|
await w.call(pm, 'task.schedule', { task_ref, labels: { remove: [w.label] } });
|
|
assert.deepEqual(w.fake.task(1).labels, []);
|
|
});
|
|
test('task.schedule sets and clears a due date and relations', async (t) => {
|
|
const w = await ready(t);
|
|
const pm = w.agent('pm');
|
|
const a = await task(w);
|
|
const b = await task(w, { title: 'b', relations: [{ kind: 'blocking', task_ref: a.task_ref }] });
|
|
assert.deepEqual(w.fake.task(2).relations, [{ kind: 'blocking', other: 1 }]);
|
|
await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-24T12:00:00.000Z' });
|
|
assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-12-24T12:00:00.000Z');
|
|
await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: null });
|
|
assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, null);
|
|
assert.equal(w.fake.task(1).due, null);
|
|
await w.call(pm, 'task.schedule', { task_ref: b.task_ref, relations: { remove: [{ kind: 'blocking', task_ref: a.task_ref }] } });
|
|
assert.deepEqual(w.fake.task(2).relations, []);
|
|
await assert.rejects(w.call(pm, 'task.schedule', { task_ref: a.task_ref }), refusedWith('invalid-request'));
|
|
await assert.rejects(
|
|
w.call(pm, 'task.schedule', { task_ref: a.task_ref, relations: { add: [{ kind: 'blocking', task_ref: 'vikunja:99/1' }] } }),
|
|
refusedWith('task-project'),
|
|
);
|
|
await assert.rejects(w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-24' }), refusedWith('invalid-request'));
|
|
});
|
|
test('assign and reassign move the role bots and record task.assigned', async (t) => {
|
|
const w = await ready(t);
|
|
const pm = w.agent('pm');
|
|
const coder = w.agent('coder');
|
|
const { task_ref } = await task(w);
|
|
// The field-writer check runs before authority, so the refused call consumes nothing.
|
|
await assert.rejects(w.call(coder, 'task.assign', { task_ref, role: 'coder' }), refusedWith('field-writer'));
|
|
assert.equal(allowed(w, 'task.assign').length, 0);
|
|
// 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 1 fails.
|
|
const thenFailRead = (trigger) => {
|
|
const fake = new FakeVikunja();
|
|
let armed = false;
|
|
const fetch = async (url, init = {}) => {
|
|
const res = await fake.fetch(url, init);
|
|
if (armed && trigger(init.method ?? 'GET', new URL(url).pathname)) {
|
|
armed = false;
|
|
fake.fault('GET', /^\/tasks\/1$/, 0);
|
|
}
|
|
return res;
|
|
};
|
|
return { edit: { fake, fetch }, arm: () => (armed = true) };
|
|
};
|
|
test('every write landed and the final read failed: the verb succeeds and records what it wrote', async (t) => {
|
|
const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/assignees'));
|
|
const w = await ready(t, f.edit);
|
|
const pm = w.agent('pm');
|
|
const { task_ref } = await task(w);
|
|
f.arm();
|
|
const r = await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
|
assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]);
|
|
assert.equal(w.events('task.assigned').length, 1);
|
|
const s = w.snapshots(task_ref).at(-1);
|
|
assert.equal(s.source, 'self');
|
|
assert.deepEqual([...s.fields.assignees], [w.bots.coder]);
|
|
assert.equal(s.digest, r.digest);
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
assert.equal(w.events('task.changed.external').length, 0);
|
|
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-assigned'));
|
|
});
|
|
test('some writes landed and the final read failed: write-uncertain, and nothing is recorded', async (t) => {
|
|
const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/labels'));
|
|
const w = await ready(t, f.edit);
|
|
const pm = w.agent('pm');
|
|
const { task_ref } = await task(w);
|
|
const n = w.snapshots(task_ref).length;
|
|
w.fake.fault('POST', /^\/tasks\/1\/labels$/, 403);
|
|
f.arm();
|
|
await assert.rejects(
|
|
w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.000Z', labels: { add: [w.label] } }),
|
|
refusedWith('write-uncertain'),
|
|
);
|
|
assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z');
|
|
assert.equal(w.snapshots(task_ref).length, n);
|
|
});
|
|
test('a create whose final read fails succeeds and records task.created', async (t) => {
|
|
const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p));
|
|
const w = await ready(t, f.edit);
|
|
w.agent('pm');
|
|
f.arm();
|
|
const r = await task(w, { due_date: '2026-11-01T00:00:00.000Z' });
|
|
assert.equal(w.events('task.created').length, 1);
|
|
const [s] = w.snapshots(r.task_ref);
|
|
assert.equal(s.source, 'self');
|
|
assert.equal(s.digest, r.digest);
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
assert.equal(w.events('task.changed.external').length, 0);
|
|
});
|
|
// The success snapshot holds what the verb wrote, not what the final read saw, so an edit that lands
|
|
// between the last write and that read is still a person's edit on the next poll.
|
|
test('an edit between the last write and the final read shows as external on the next poll', async (t) => {
|
|
const fake = new FakeVikunja();
|
|
let w,
|
|
armed = false;
|
|
w = await ready(t, {
|
|
fake,
|
|
fetch: async (url, init = {}) => {
|
|
const res = await fake.fetch(url, init);
|
|
if (armed && init.method === 'POST' && new URL(url).pathname.endsWith('/tasks/1/assignees')) {
|
|
armed = false;
|
|
await w.ui('PATCH', '/tasks/1', { title: 'renamed in the ui' });
|
|
}
|
|
return res;
|
|
},
|
|
});
|
|
const pm = w.agent('pm');
|
|
const { task_ref } = await task(w);
|
|
armed = true;
|
|
await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
|
assert.equal(w.snapshots(task_ref).at(-1).fields.title, 'a task');
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
const ext = w.events('task.changed.external');
|
|
assert.equal(ext.length, 1);
|
|
assert.deepEqual(ext[0].body.changed, ['title']);
|
|
});
|
|
test('task.created is recorded when a later label write fails', async (t) => {
|
|
const w = await ready(t);
|
|
w.agent('pm');
|
|
w.fake.fault('POST', /^\/tasks\/\d+\/labels$/, 500);
|
|
await assert.rejects(task(w, { labels: [w.label] }), refusedWith('write-uncertain'));
|
|
assert.notEqual(w.fake.task(1), undefined);
|
|
assert.deepEqual(w.fake.task(1).labels, []);
|
|
assert.equal(w.events('task.created').length, 1);
|
|
});
|