--- r1/packages/tasks/tests/verbs.test.mjs +++ r2/packages/tasks/tests/verbs.test.mjs @@ -312,15 +312,15 @@ 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) => { +// 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', /^\/tasks\/1$/, 0); + fake.fault('GET', new RegExp(`^/tasks/${id}$`), 0); } return res; }; @@ -412,13 +412,13 @@ }); // 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. +// 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) => { - const w = await world(t, TREE); +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 }); @@ -568,3 +568,64 @@ 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); +}); --- r1/packages/tasks/README.md +++ r2/packages/tasks/README.md @@ -191,9 +191,10 @@ in, the root when left out. Any other project than an installed one of the tree refuses with `project-not-installed`, and a value that isn't a positive integer with `invalid-request`. The decision a create may - need is bound to no target, so it doesn't name the project. `task.created` is recorded even when a label or relation - write after the create fails. The task exists, and the verb then - refuses with that write's code. + need is bound to no target, so it doesn't name the project. + `task.created` is recorded even when a label or relation write after + the create fails. The task exists, and the verb then refuses with that + write's code. - `task.assign` refuses a task a bot already holds (`task-assigned`). `task.reassign` refuses one no bot holds (`task-unassigned`). Both leave people's assignments alone, and remove only bots the compare-read