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'); });