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]>
494 lines
25 KiB
JavaScript
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');
|
|
});
|