Files
stack/packages/tasks/tests/tree.test.mjs
T
jason.woltjeandClaude Opus 5.5 390d437ba2 feat(tasks): the adapter reads a project tree (#1555, row 62)
With tracker.descendants on, the tracker project is the root of a tree.
Each reconcile reads the sync bot's project list (archived projects and
negative pseudo-project ids left out) and walks it by parent_project_id.
A child that passes the install checks is read through its board, and
one that doesn't is watch-only (bucket 0) and never refuses the
business. A tick reads each project in the tree. A child answering 403
or 404 leaves the tree only if GET /projects/<id> then answers 403 or
404 too, and its open tasks get the one-by-one read. Task refs carry
the project, so a task moved inside the tree shows under its new ref
and the old ref gets its tombstone. tracker.descendants reaches the adapter
through the business var and packages/cli/src/config.mjs (lead decision
87). Off, the adapter reads as v0 did.

Darkwing built it (agents/darkwing/work/queue-62), in three rounds.
Round 3 was approved by Filbert (27317) and Dewey (27338) on manifest
8b81e836, 13 paths. Their non-blocking notes and Darkwing's follow-ups
(two surviving mutants in test 18, the fake's view-404 code) go to a
follow-up row.

Sage's gate on 918a4a19, sequential and teed: node suites tasks 71,
business 60, bus 80, cli 110 and queue 150, 0 failed; webui 39 of 40,
then 40 of 40 on three reruns (row 62 touches no webui file; a timing
test under load 17 with Dewey's gate running beside it); test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98, all 0 failed (Docker on). edc5de71..HEAD
touches none of the 13 paths.

Live read, 2026-10-11T21:28:03Z, tasks.woltje.com v2.7.0, root #19,
descendants on, sync key only, GETs only (the startup scope probes not
sent): reconcile 15 GETs, 42 snapshots; tick 8 GETs, 0 snapshots.
Children 18, 20 and 21 are watch-only. A missing view id on #18 answers
404 (code 3014) while #18 reads 200, as the README says. Record:
agents/sage/work/vikunja-setup/2026-10-11_tick-62.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:29:13 -05:00

494 lines
25 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { world, sleep, SCOPES } from './world.mjs';
import { tasksAdapter } from '../src/index.mjs';
import { WATCH_ONLY } from '../src/sync.mjs';
// tracker.descendants: the tracker project is the root of a tree, read with its descendants.
// The sync token also needs the project list, projects read_all.
const TREE = {
tracker: { descendants: true },
scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } },
};
const behind = { now: () => Date.now() - 3600000 };
const state = (w) => w.adapter.status()[0];
const ready = async (w) => {
await w.adapter.start('demo');
assert.equal(state(w).state, 'ready', state(w).refused);
return w;
};
const ref = (project, task) => `vikunja:${project}/${task}`;
const add = async (w, project, title) => (await w.ui('POST', `/projects/${project}/tasks`, { title })).json.id;
const move = (w, project, bucket, task) =>
w.ui('PUT', `/projects/${project}/views/${w.fake.kanban(project).id}/buckets/${bucket}/tasks`, { task_id: task });
const open = (w) => w.broker.taskView('demo', 'open').map((r) => ({ ...r }));
const missing = (w) => w.events('task.missing').map((e) => [e.subject, e.body]);
// The requests one call makes, as "METHOD path".
const counted = async (w, fn) => {
const from = w.fake.requests.length;
await fn();
return w.fake.requests.slice(from).map((r) => `${r.method} ${r.path}`);
};
test('with descendants off the adapter reads no project list and no child, as in v0', async (t) => {
const w = await world(t);
const child = w.fake.project('Child', w.owner, { parent: w.project });
w.fake.install(child);
const task = await add(w, child, 'in the child');
await ready(w);
await sleep(20);
await w.adapter.tick('demo');
await w.adapter.reconcile('demo');
// GETs only: the POST that made the child's task is the test's own.
const paths = w.fake.requests.filter((r) => r.method === 'GET').map((r) => r.path);
assert.ok(!paths.includes('/projects'));
assert.ok(!paths.some((p) => p.startsWith(`/projects/${child}/`)));
assert.equal(w.snapshots(ref(child, task)).length, 0);
});
test('a three-level tree is read per project, and a project outside it is not', async (t) => {
const w = await world(t, { ...TREE, fake: behind });
const child = w.fake.project('Child', w.owner, { parent: w.project });
const childBoard = w.fake.install(child);
// The grandchild keeps Vikunja's default buckets, so it is watch-only.
const grandchild = w.fake.project('Grandchild', w.owner, { parent: child });
const outside = w.fake.project('Outside', w.owner);
w.fake.share(outside, w.bots.sync, 0);
const tasks = {};
for (const [name, p] of Object.entries({ root: w.project, child, grandchild, outside })) tasks[name] = await add(w, p, name);
await ready(w);
assert.deepEqual(w.snapshots(ref(w.project, tasks.root)).at(-1).fields.bucket, w.installed.buckets.todo);
assert.deepEqual(w.snapshots(ref(child, tasks.child)).at(-1).fields.bucket, childBoard.buckets.todo);
assert.deepEqual(w.snapshots(ref(grandchild, tasks.grandchild)).at(-1).fields.bucket, WATCH_ONLY);
assert.equal(w.snapshots(ref(outside, tasks.outside)).length, 0);
assert.deepEqual(
open(w).map((r) => r.task_ref),
[ref(w.project, tasks.root), ref(child, tasks.child), ref(grandchild, tasks.grandchild)].sort(),
);
// Access to the children comes from the root's share alone: the sync bot has no grant on them.
// A quiet tick costs two reads per project: the board (or the open list) and the cursor walk.
const tick = await counted(w, () => w.adapter.tick('demo'));
assert.equal(tick.length, 6);
assert.deepEqual(
tick.map((r) => r.replace(/\/views\/\d+\/buckets\/tasks$/, ' board').replace(/\/tasks$/, ' list')),
[
`GET /projects/${w.project} board`,
`GET /projects/${w.project} list`,
`GET /projects/${child} board`,
`GET /projects/${child} list`,
`GET /projects/${grandchild} list`,
`GET /projects/${grandchild} list`,
],
);
// A reconcile adds the project list and each child's views and buckets.
const reconcile = await counted(w, () => w.adapter.reconcile('demo'));
assert.equal(reconcile[0], 'GET /projects');
assert.equal(reconcile.length, 1 + 2 * 2 + 3 * 2);
});
test('a watch-only child keeps the business ready, and a failed child read refuses as transient', async (t) => {
const w = await world(t, TREE);
const child = w.fake.project('Child', w.owner, { parent: w.project });
w.fake.install(child);
// A second kanban view fails the install checks.
w.fake.addView(child, 'kanban');
const task = await add(w, child, 'watched');
await ready(w);
assert.equal(w.snapshots(ref(child, task)).at(-1).fields.bucket, WATCH_ONLY);
const down = await world(t, TREE);
const other = down.fake.project('Child', down.owner, { parent: down.project });
down.fake.fault('GET', new RegExp(`^/projects/${other}/views$`), 503);
await down.adapter.start('demo');
assert.equal(state(down).refused, 'tracker-unavailable');
});
test('a root without the installed buckets refuses with descendants on', async (t) => {
const w = await world(t, { ...TREE, install: false });
const child = w.fake.project('Child', w.owner, { parent: w.project });
w.fake.install(child);
await w.adapter.start('demo');
assert.equal(state(w).refused, 'tracker-install');
});
test('the project list refuses without read_all, and skips archived projects and saved filters', async (t) => {
const w = await world(t, { tracker: { descendants: true } });
await w.adapter.start('demo');
assert.equal(state(w).refused, 'tracker-unauthorized');
// Vikunja leaves archived projects out of the list; this fetch asks for them too, so the
// adapter's own is_archived check is what skips them.
let fake;
const v = await world(t, {
...TREE,
fetch: (url, init) => fake.fetch(/\/api\/v2\/projects\?/.test(url) ? url + '&is_archived=true' : url, init),
});
fake = v.fake;
const archived = v.fake.project('Archived', v.owner, { parent: v.project });
v.fake.install(archived);
const task = await add(v, archived, 'archived');
v.fake.archive(archived);
v.fake.pseudoProject(v.bots.sync);
await ready(v);
assert.equal(v.snapshots(ref(archived, task)).length, 0);
assert.ok(!v.fake.requests.some((r) => r.method === 'GET' && r.path.startsWith(`/projects/${archived}/`) || r.path.startsWith('/projects/-')));
});
test('descendants must be true or false', async (t) => {
const w = await world(t);
await assert.rejects(
tasksAdapter({ trackers: { demo: { baseUrl: 'http://vikunja.test', project: w.project, descendants: 'yes' } }, autostart: false })({
broker: w.broker,
credentials: w.credentials,
businesses: w.businesses,
}),
(e) => e.code === 'tracker-config',
);
});
test('a move inside the tree is a moved tombstone plus a snapshot under the new ref', async (t) => {
const w = await world(t, TREE);
const a = w.fake.project('A', w.owner, { parent: w.project });
const boardA = w.fake.install(a);
const b = w.fake.project('B', w.owner, { parent: a });
const outside = w.fake.project('Outside', w.owner);
w.fake.share(outside, w.bots.sync, 0);
const one = await add(w, w.project, 'root to child');
const two = await add(w, a, 'child to child');
const three = await add(w, a, 'child to outside');
await ready(w);
w.fake.moveToProject(one, a);
w.fake.moveToProject(two, b);
w.fake.moveToProject(three, outside);
await sleep(20);
await w.adapter.tick('demo');
assert.deepEqual(missing(w), [
[ref(w.project, one), { reason: 'moved', project: a }],
[ref(a, two), { reason: 'moved', project: b }],
[ref(a, three), { reason: 'moved', project: outside }],
]);
assert.deepEqual(w.snapshots(ref(w.project, one)).at(-1).fields, { gone: 'moved', project: a });
assert.equal(w.snapshots(ref(a, one)).at(-1).fields.bucket, boardA.buckets.todo);
assert.equal(w.snapshots(ref(b, two)).at(-1).fields.bucket, WATCH_ONLY);
assert.equal(w.snapshots(ref(outside, three)).length, 0);
assert.deepEqual(
open(w).map((r) => r.task_ref),
[ref(a, one), ref(b, two)].sort(),
);
await sleep(20);
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 });
});
test('a child made after startup joins the tree at the next reconcile', async (t) => {
const w = await ready(await world(t, TREE));
const child = w.fake.project('Late', w.owner, { parent: w.project });
const task = await add(w, child, 'late');
await sleep(20);
await w.adapter.tick('demo');
assert.equal(w.snapshots(ref(child, task)).length, 0);
await w.adapter.reconcile('demo');
assert.equal(w.snapshots(ref(child, task)).at(-1).via, 'reconcile');
await w.ui('PATCH', `/tasks/${task}`, { title: 'renamed' });
await sleep(20);
await w.adapter.tick('demo');
assert.equal(w.snapshots(ref(child, task)).at(-1).fields.title, 'renamed');
});
test('a child that leaves the tree has its tasks recorded missing, and the ledger keeps their history', async (t) => {
// The fake's clock runs ahead on demand, so an edit gets a new `updated` second.
let ahead = 0;
const w = await world(t, { ...TREE, fake: { now: () => Date.now() + ahead } });
const children = {};
for (const name of ['archived', 'reparented', 'hidden']) {
children[name] = w.fake.project(name, w.owner, { parent: w.project });
w.fake.install(children[name]);
}
const launchpad = w.fake.project('Launchpad', w.owner);
const tasks = {};
for (const name of Object.keys(children)) tasks[name] = await add(w, children[name], name);
await ready(w);
// Archived: Vikunja leaves it out of the list. Reparented: shared with the sync bot directly,
// now a top-level project outside the tree. Hidden: moved under a project the bot can't read.
w.fake.archive(children.archived);
w.fake.share(children.reparented, w.bots.sync, 0);
w.fake.reparent(children.reparented, 0);
w.fake.reparent(children.hidden, launchpad);
await sleep(20);
const refs = Object.keys(children).map((name) => ref(children[name], tasks[name]));
const [archived, reparented, hidden] = refs;
// A tick reads the tree it has, and the project list is read at reconcile. The hidden child's
// board answers 403, so the tick drops it at once; the other two still read until the reconcile.
await w.adapter.tick('demo');
assert.deepEqual(missing(w), [[hidden, { reason: 'no-access' }]]);
// An edit the reconcile reads after the project left keeps the tombstone's `updated` the last one
// the bus recorded.
ahead = 2000;
await w.ui('PATCH', `/tasks/${tasks.reparented}`, { title: 'edited outside' });
await w.adapter.reconcile('demo');
assert.deepEqual(
missing(w),
[hidden, archived, reparented].map((r) => [r, { reason: 'no-access' }]),
);
for (const r of refs) {
const s = w.snapshots(r);
assert.deepEqual(
s.map((x) => x.fields.gone ?? 'open'),
['open', 'no-access'],
);
assert.equal(s[1].updated, s[0].updated);
}
assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('Launchpad'));
assert.deepEqual(open(w), []);
await sleep(20);
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 });
});
test('a child deleted or hidden between reconciles drops out at the next tick, and the root still refuses', async (t) => {
const w = await world(t, TREE);
const deleted = w.fake.project('Deleted', w.owner, { parent: w.project });
w.fake.install(deleted);
// Watch-only, so its tick read is the task list, not a board.
const hidden = w.fake.project('Hidden', w.owner, { parent: w.project });
const kept = w.fake.project('Kept', w.owner, { parent: w.project });
const launchpad = w.fake.project('Launchpad', w.owner);
const tasks = {};
for (const [name, p] of Object.entries({ root: w.project, deleted, hidden, kept })) tasks[name] = await add(w, p, name);
await ready(w);
w.fake.deleteProject(deleted);
w.fake.reparent(hidden, launchpad);
await w.ui('PATCH', `/tasks/${tasks.root}`, { title: 'root renamed' });
await w.ui('PATCH', `/tasks/${tasks.kept}`, { title: 'kept renamed' });
await sleep(20);
await w.adapter.tick('demo');
assert.equal(state(w).state, 'ready');
assert.deepEqual(missing(w).sort(), [
[ref(deleted, tasks.deleted), { reason: 'not-found' }],
[ref(hidden, tasks.hidden), { reason: 'no-access' }],
]);
// The root and the sibling changed in the same tick, and both are recorded.
assert.equal(w.snapshots(ref(w.project, tasks.root)).at(-1).fields.title, 'root renamed');
assert.equal(w.snapshots(ref(kept, tasks.kept)).at(-1).fields.title, 'kept renamed');
// The next tick reads neither, and the reconcile finds neither.
const reads = await counted(w, () => w.adapter.tick('demo'));
assert.ok(!reads.some((r) => r.includes(`/projects/${deleted}/`) || r.includes(`/projects/${hidden}/`)), reads.join('\n'));
assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 });
// Any other failure on a child still refuses the tick, and the child stays.
w.fake.fault('GET', new RegExp(`^/projects/${kept}/tasks$`), 503);
await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-unavailable' });
await w.ui('PATCH', `/tasks/${tasks.kept}`, { title: 'kept again' });
await sleep(20);
await w.adapter.tick('demo');
assert.equal(w.snapshots(ref(kept, tasks.kept)).at(-1).fields.title, 'kept again');
// The root answering 403 or 404 still refuses the tick, and stays in the tree.
w.fake.unshare(w.project, w.bots.sync);
await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-forbidden' });
w.fake.share(w.project, w.bots.sync, 0);
await w.ui('PATCH', `/tasks/${tasks.root}`, { title: 'root again' });
await sleep(20);
await w.adapter.tick('demo');
assert.equal(w.snapshots(ref(w.project, tasks.root)).at(-1).fields.title, 'root again');
w.fake.deleteProject(w.project);
await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-not-found' });
});
test('a reconcile that fails keeps the previous tree', async (t) => {
const w = await world(t, TREE);
const first = w.fake.project('First', w.owner, { parent: w.project });
const second = w.fake.project('Second', w.owner, { parent: w.project });
w.fake.install(second);
const task = await add(w, second, 'second');
await ready(w);
// The first child's install check fails, so the walk stops before it reaches the second.
w.fake.fault('GET', new RegExp(`^/projects/${first}/views$`), 503);
await assert.rejects(w.adapter.reconcile('demo'), { code: 'tracker-unavailable' });
await w.ui('PATCH', `/tasks/${task}`, { title: 'renamed' });
await sleep(20);
await w.adapter.tick('demo');
assert.equal(w.snapshots(ref(second, task)).at(-1).fields.title, 'renamed');
assert.deepEqual(missing(w), []);
});
test("a closed task the cursor missed takes its own project's done bucket", async (t) => {
// An hour behind, so the cursor never returns the close and missing() reads the task.
const w = await world(t, { ...TREE, fake: behind });
const child = w.fake.project('Child', w.owner, { parent: w.project });
const childBoard = w.fake.install(child);
const watched = w.fake.project('Watched', w.owner, { parent: w.project });
const tasks = { child: await add(w, child, 'child'), watched: await add(w, watched, 'watched') };
await ready(w);
for (const id of Object.values(tasks)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
await sleep(20);
await w.adapter.tick('demo');
const child1 = w.snapshots(ref(child, tasks.child)).at(-1);
const watched1 = w.snapshots(ref(watched, tasks.watched)).at(-1);
assert.deepEqual([child1.via, child1.fields.done, child1.fields.bucket], ['task', true, childBoard.buckets.done]);
assert.deepEqual([watched1.via, watched1.fields.done, watched1.fields.bucket], ['task', true, WATCH_ONLY]);
assert.notEqual(childBoard.buckets.done, w.installed.buckets.done);
});
test("closed tasks read at startup take their own project's done bucket", async (t) => {
const w = await world(t, TREE);
const child = w.fake.project('Child', w.owner, { parent: w.project });
const childBoard = w.fake.install(child);
const watched = w.fake.project('Watched', w.owner, { parent: w.project });
const tasks = { child: await add(w, child, 'child'), watched: await add(w, watched, 'watched') };
for (const id of Object.values(tasks)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
await ready(w);
const child1 = w.snapshots(ref(child, tasks.child)).at(-1);
const watched1 = w.snapshots(ref(watched, tasks.watched)).at(-1);
assert.deepEqual([child1.via, child1.fields.done, child1.fields.bucket], ['reconcile', true, childBoard.buckets.done]);
assert.deepEqual([watched1.via, watched1.fields.done, watched1.fields.bucket], ['reconcile', true, WATCH_ONLY]);
});
test("a closed task the cursor returns takes its own project's done bucket", async (t) => {
// Two closes per project: one before the tick, so the open read leaves it out, and one between the
// open read and the cursor walk, so both reads return it. `race` closes the second kind.
const race = new Map();
const w = await world(t, {
...TREE,
fetch: async (url, init) => {
const r = await w.fake.fetch(url, init);
for (const [project, id] of race)
if (new URL(url).pathname.startsWith(`/api/v2/projects/${project}/`)) {
race.delete(project);
await w.ui('PATCH', `/tasks/${id}`, { done: true });
}
return r;
},
});
const child = w.fake.project('Child', w.owner, { parent: w.project });
const childBoard = w.fake.install(child);
const watched = w.fake.project('Watched', w.owner, { parent: w.project });
const before = { [child]: await add(w, child, 'child'), [watched]: await add(w, watched, 'watched') };
const during = { [child]: await add(w, child, 'child raced'), [watched]: await add(w, watched, 'watched raced') };
await ready(w);
for (const id of Object.values(before)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
for (const [project, id] of Object.entries(during)) race.set(+project, id);
await sleep(20);
await w.adapter.tick('demo');
assert.equal(race.size, 0);
for (const [project, bucket] of [[child, childBoard.buckets.done], [watched, WATCH_ONLY]])
for (const id of [before[project], during[project]]) {
const s = w.snapshots(ref(project, id)).at(-1);
assert.deepEqual([s.via, s.fields.done, s.fields.bucket], ['cursor', true, bucket]);
}
});
test('a task moved out of a child that leaves the tree at the same time is moved, not no-access', async (t) => {
const w = await world(t, TREE);
const child = w.fake.project('Child', w.owner, { parent: w.project });
w.fake.install(child);
const task = await add(w, child, 'leaving');
await ready(w);
w.fake.archive(child);
w.fake.moveToProject(task, w.project);
await sleep(20);
await w.adapter.reconcile('demo');
assert.deepEqual(missing(w), [[ref(child, task), { reason: 'moved', project: w.project }]]);
assert.equal(w.snapshots(ref(w.project, task)).at(-1).fields.title, 'leaving');
});
test('a task in a bucket outside the five keeps its raw bucket id', async (t) => {
const w = await ready(await world(t, TREE));
const backlog = w.fake.bucket(w.installed.view, 'Backlog');
const task = await add(w, w.project, 'parked');
await move(w, w.project, backlog, task);
await sleep(20);
await w.adapter.tick('demo');
assert.equal(w.snapshots(ref(w.project, task)).at(-1).fields.bucket, backlog);
assert.deepEqual(open(w), [{ task_ref: ref(w.project, task), bucket: backlog }]);
});
// Decision 87: bucket 0 on a watch-only snapshot. Nothing in sync.mjs maps 0 to a bucket or a state.
test('bucket 0 on a watch-only task never becomes a bucket or a state', async (t) => {
const w = await world(t, TREE);
const child = w.fake.project('Child', w.owner, { parent: w.project });
const buckets = w.fake.bucketIds(w.fake.kanban(child).id);
const task = await add(w, child, 'watched');
await ready(w);
const r = ref(child, task);
// A move between the child's open buckets changes nothing the adapter records.
await move(w, child, buckets.Doing, task);
await sleep(20);
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
// Closed through its done bucket, then reopened: done changes, the bucket stays 0.
await move(w, child, buckets.Done, task);
await sleep(20);
await w.adapter.tick('demo');
assert.deepEqual(open(w), []);
await move(w, child, buckets['To-Do'], task);
await sleep(20);
await w.adapter.tick('demo');
assert.deepEqual(open(w), [{ task_ref: r, bucket: WATCH_ONLY }]);
const changes = w.events('task.changed.external').filter((e) => e.subject === r);
assert.equal(changes[0].body.via, 'reconcile');
assert.deepEqual(
changes.slice(1).map((e) => e.body.changed),
[['done'], ['done']],
);
assert.ok(w.snapshots(r).every((s) => s.fields.bucket === WATCH_ONLY));
assert.deepEqual(w.events('task.state'), []);
assert.ok(changes.every((e) => !('state' in e.body)));
// A verb on a child task refuses as it does in v0.
await assert.rejects(
w.call(w.agent('pm'), 'task.assign', { task_ref: r, role: 'coder' }),
(e) => e.code === 'task-project',
);
// The source has no bucket-to-state table and no write path.
const source = readFileSync(new URL('../src/sync.mjs', import.meta.url), 'utf8');
assert.ok(!/\.titles\b|\.ids\[/.test(source));
assert.ok(!/'(PUT|POST|PATCH|DELETE)'/.test(source));
});
test('a child whose kanban view is deleted still refuses the tick, and the reconcile reads it watch-only', async (t) => {
const w = await world(t, TREE);
const child = w.fake.project('Child', w.owner, { parent: w.project });
w.fake.install(child);
const task = await add(w, child, 'child');
const rootTask = await add(w, w.project, 'root');
await ready(w);
const rows = () => w.store.get('SELECT (SELECT count(*) FROM events) + (SELECT count(*) FROM task_snapshots) n').n;
// The board answers 404 with code 13001, and the project still reads, so the child hasn't left
// the tree. The tick reads the project, fails, and writes nothing, not even the root's edit.
w.fake.deleteView(w.fake.kanban(child).id);
await w.ui('PATCH', `/tasks/${rootTask}`, { title: 'root renamed' });
await sleep(20);
const n = rows();
const reads = await counted(w, () => assert.rejects(w.adapter.tick('demo'), { code: 'tracker-not-found' }));
assert.ok(reads.includes(`GET /projects/${child}`), reads.join('\n'));
assert.equal(rows(), n);
assert.equal(state(w).state, 'ready');
// A 403 on a child the bot still reads fails the tick too, and the child stays.
w.fake.fault('GET', new RegExp(`^/projects/${child}/views/\\d+/buckets/tasks$`), 403);
await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-forbidden' });
// The reconcile finds no kanban view and reads the child watch-only. Nothing is missing.
await w.adapter.reconcile('demo');
assert.deepEqual(missing(w), []);
assert.equal(w.snapshots(ref(child, task)).at(-1).fields.bucket, WATCH_ONLY);
assert.equal(w.snapshots(ref(w.project, rootTask)).at(-1).fields.title, 'root renamed');
// A 401 on a child read after the root read succeeds fails the tick, whatever the project read says.
w.fake.fault('GET', new RegExp(`^/projects/${child}/tasks$`), 401);
w.fake.fault('GET', new RegExp(`^/projects/${child}$`), 404);
const unauthorized = await counted(w, () => assert.rejects(w.adapter.tick('demo'), { code: 'tracker-unauthorized' }));
assert.ok(!unauthorized.includes(`GET /projects/${child}`), unauthorized.join('\n'));
// The owner's read takes the 404 the tick didn't use.
assert.equal((await w.ui('GET', `/projects/${child}`)).status, 404);
// A 404 on its task list whose project read fails as well keeps the child: the tick refuses.
w.fake.fault('GET', new RegExp(`^/projects/${child}/tasks$`), 404);
w.fake.fault('GET', new RegExp(`^/projects/${child}$`), 503);
await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-not-found' });
await w.ui('PATCH', `/tasks/${task}`, { title: 'child renamed' });
await sleep(20);
await w.adapter.tick('demo');
assert.equal(w.snapshots(ref(child, task)).at(-1).fields.title, 'child renamed');
assert.deepEqual(missing(w), []);
});
test('a child dropped at a tick comes back at the next reconcile once the bot reads it again', async (t) => {
const w = await world(t, TREE);
const child = w.fake.project('Child', w.owner, { parent: w.project });
w.fake.install(child);
const launchpad = w.fake.project('Launchpad', w.owner);
const task = await add(w, child, 'child');
await ready(w);
const bucket = w.snapshots(ref(child, task)).at(-1).fields.bucket;
w.fake.reparent(child, launchpad);
await w.adapter.tick('demo');
assert.deepEqual(missing(w), [[ref(child, task), { reason: 'no-access' }]]);
// Back under the root: the tick still leaves it out, and the reconcile finds it again.
w.fake.reparent(child, w.project);
const reads = await counted(w, () => w.adapter.tick('demo'));
assert.ok(!reads.some((r) => r.includes(`/projects/${child}`)), reads.join('\n'));
await w.adapter.reconcile('demo');
assert.deepEqual(
w.snapshots(ref(child, task)).map((x) => x.fields.gone ?? x.fields.bucket),
[bucket, 'no-access', bucket],
);
assert.ok(open(w).some((r) => r.task_ref === ref(child, task)));
await w.ui('PATCH', `/tasks/${task}`, { title: 'renamed' });
await sleep(20);
await w.adapter.tick('demo');
assert.equal(w.snapshots(ref(child, task)).at(-1).fields.title, 'renamed');
});