import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../src/store.mjs'; import { Broker, TASK_PAGE, TASK_PAGE_MAX } from '../src/broker.mjs'; import { REPLY_CAP } from '../../harness/src/tools.mjs'; // Row 89 (#1583): `tasks` with arguments filters and pages a short form a session can read; with // none it is the whole board, as the Console and `mosaic tasks` read it. const authority = { withinRole: ['message.send', 'task.create'], crossRole: [] }; const roles = { pm: { authority, tracker: { bot: 'bot-pm', botId: 101 } }, coder: { authority, tracker: { bot: 'bot-coder', botId: 102 } }, reviewer: { authority, tracker: null }, }; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles } }; const at = '2026-10-12T12:00:00.000Z'; const digest = 'd'.repeat(64); const N = 500; const words = 'the board holds this task so a session can page through it without the description '.repeat(30); const ref = (i) => `vikunja:3/${i}`; // Task i: done when i % 5 == 0; the pm's bot on i % 3 == 0, the coder's on i % 4 == 0, a person (7) // on i % 2 == 0; label 9 on i % 7 == 0; tombstones at 13 and 26. const fieldsOf = (i) => ({ project_id: 3, title: `Task ${i}: a title of ordinary length for a tracker card`, description: `
${i} ${words}
`, done: i % 5 === 0, due_date: null, priority: i % 4, percent_done: 0, bucket: i % 5 === 0 ? 12 : 11, labels: i % 7 === 0 ? [9, 4] : [4], assignees: [...(i % 3 === 0 ? [101] : []), ...(i % 4 === 0 ? [102] : []), ...(i % 2 === 0 ? [7] : [])], }); const GONE = new Set([13, 26]); function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-task-page-')); const store = new Store(root); const b = new Broker({ store, businesses }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm', harness: 'claude-code' }); b.request(pm, { verb: 'role.claim' }); b.recordTask({ business: 'demo', snapshots: Array.from({ length: N }, (_, k) => k + 1).map((i) => ({ task_ref: ref(i), updated: at, digest, fields: GONE.has(i) ? { gone: 'no-access' } : fieldsOf(i), via: GONE.has(i) ? 'task' : 'board', read_at: at, })), }); // Task 2 and task 6 were created by the stack, under two requirements. for (const [i, requirement] of [[2, 'REQ-SLICE-1'], [6, 'REQ-SLICE-2']]) { const { request } = b.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }); b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: ref(i), updated: at, digest, fields: fieldsOf(i) }], events: [{ kind: 'task.created', subject: ref(i), body: { request, requirement } }], }); } const tasks = (args, cap = pm) => b.request(cap, { verb: 'tasks', args }); return { b, human, pm, reader: b.bindReader({ business: 'demo' }), tasks }; } const refusesWith = (code) => (e) => e.code === code; // Every task, in the broker's order (task_ref, as text). const ALL = Array.from({ length: N }, (_, k) => k + 1).sort((x, y) => (ref(x) < ref(y) ? -1 : 1)); const live = (keep) => ALL.filter((i) => !GONE.has(i) && keep(i)).map(ref); // Every page of a read, by following next. function pages(tasks, args) { const out = []; let after; for (;;) { const page = tasks(after === undefined ? args : { ...args, after }); out.push(page); if (page.next === null) return out; after = page.next; } } test('the session default fits under the reply cap and pages to the end with next', (t) => { const { tasks } = setup(t); const whole = tasks({}); assert.equal(whole.length, N); assert.ok(JSON.stringify(whole).length > 20 * REPLY_CAP, 'the whole board is far over the cap'); const read = pages(tasks, { done: false }); const open = live((i) => i % 5 !== 0); assert.equal(read.length, Math.ceil(open.length / TASK_PAGE)); for (const page of read) { assert.ok(JSON.stringify(page).length < REPLY_CAP, `${JSON.stringify(page).length}`); assert.equal(page.total, open.length); assert.ok(page.tasks.length <= TASK_PAGE); } assert.deepEqual(read.flatMap((p) => p.tasks.map((x) => x.task_ref)), open); assert.deepEqual(read.slice(0, -1).map((p) => p.next), read.slice(0, -1).map((p) => p.tasks.at(-1).task_ref)); assert.equal(read.at(-1).next, null); // limit at its most pages the same tasks in fewer pages. const big = pages(tasks, { done: false, limit: TASK_PAGE_MAX }); assert.equal(big.length, Math.ceil(open.length / TASK_PAGE_MAX)); assert.deepEqual(big.flatMap((p) => p.tasks.map((x) => x.task_ref)), open); }); test('the short form: no description, assignees by role, every listed key', (t) => { const { tasks } = setup(t); const whole = new Map(tasks({}).map((r) => [r.task_ref, r])); const page = tasks({ limit: TASK_PAGE_MAX }); for (const x of page.tasks) { const row = whole.get(x.task_ref); const i = Number(x.task_ref.split('/')[1]); assert.deepEqual(x, { task_ref: row.task_ref, title: row.fields.title, done: row.fields.done, bucket: row.fields.bucket, assignees: [...(i % 4 === 0 ? ['coder'] : []), ...(i % 3 === 0 ? ['pm'] : [])], labels: row.fields.labels, priority: row.priority, requirement: row.requirement, changed_at: row.at, }); } const two = page.tasks.find((x) => x.task_ref === ref(2)); assert.equal(two.requirement, 'REQ-SLICE-1'); assert.equal(JSON.stringify(page).includes('description'), false); // A tombstone is on the whole board, never in a filtered read. assert.deepEqual(whole.get(ref(13)).fields, { gone: 'no-access' }); const every = pages(tasks, { limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref)); assert.deepEqual(every, live(() => true)); }); test('each filter, alone and together, and total counts every match', (t) => { const { tasks } = setup(t); const refs = (args) => pages(tasks, { ...args, limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref)); assert.deepEqual(refs({ done: true }), live((i) => i % 5 === 0)); assert.deepEqual(refs({ done: false }), live((i) => i % 5 !== 0)); assert.deepEqual(refs({ assignee: 'pm' }), live((i) => i % 3 === 0)); assert.deepEqual(refs({ assignee: 'coder' }), live((i) => i % 4 === 0)); // A role with no tracker bot holds no task; a person's assignment is no role's. assert.deepEqual(refs({ assignee: 'reviewer' }), []); assert.deepEqual(refs({ label: 9 }), live((i) => i % 7 === 0)); assert.deepEqual(refs({ label: 4 }), live(() => true)); assert.deepEqual(refs({ label: 5 }), []); assert.deepEqual(refs({ requirement: 'REQ-SLICE-2' }), [ref(6)]); assert.deepEqual(refs({ requirement: 'REQ-NONE-1' }), []); assert.deepEqual(refs({ done: false, assignee: 'pm', label: 9 }), live((i) => i % 5 !== 0 && i % 3 === 0 && i % 7 === 0)); const page = tasks({ done: true, assignee: 'coder', limit: 3 }); assert.equal(page.total, live((i) => i % 5 === 0 && i % 4 === 0).length); assert.equal(page.tasks.length, 3); assert.equal(page.next, page.tasks[2].task_ref); // after needn't be a task on the board, or one the filters match: the page starts past it, in // text order (3/500 < 3/501 < 3/51), and total still counts every match. const past = tasks({ after: 'vikunja:3/501', limit: 2 }); assert.deepEqual(past.tasks.map((x) => x.task_ref), [ref(51), ref(52)]); assert.equal(past.total, live(() => true).length); assert.deepEqual(tasks({ done: true, after: ref(51), limit: 1 }).tasks.map((x) => x.task_ref), [ref(55)]); const last = tasks({ after: 'vikunja:9/1' }); assert.deepEqual(last, { tasks: [], total: live(() => true).length, next: null }); // A page that ends exactly on the last match has no next. const exact = tasks({ requirement: 'REQ-SLICE-1', limit: 1 }); assert.deepEqual([exact.tasks.length, exact.next], [1, null]); }); test('task_ref reads one task in full, as the whole board has it', (t) => { const { tasks, human, reader } = setup(t); const whole = tasks({}); for (const r of [ref(2), ref(13), ref(500)]) assert.deepEqual(tasks({ task_ref: r }), whole.find((x) => x.task_ref === r)); assert.ok(tasks({ task_ref: ref(2) }).fields.description.length > 2000); assert.deepEqual(tasks({ task_ref: ref(2) }, human), tasks({ task_ref: ref(2) }, reader)); assert.throws(() => tasks({ task_ref: 'vikunja:3/501' }), refusesWith('task-not-found')); assert.throws(() => tasks({ task_ref: 'vikunja:4/2' }), refusesWith('task-not-found')); }); test('no arguments is the read the Console and mosaic make, unchanged', (t) => { const { b, tasks, human, reader } = setup(t); const rows = b.taskView('demo', 'current'); const whole = tasks({}); assert.deepEqual(whole.map(({ priority, requirement, ...r }) => r), rows); assert.deepEqual(tasks({}, human), whole); assert.deepEqual(tasks({}, reader), whole); assert.deepEqual(b.request(reader, { verb: 'tasks' }), whole); }); test('an unknown argument or a bad value refuses invalid-request', (t) => { const { tasks, reader } = setup(t); const bad = [ { status: 'open' }, { description: 'x' }, { done: 'false' }, { done: 0 }, { done: null }, { assignee: 'cto' }, { assignee: 'toString' }, { assignee: 101 }, { label: 0 }, { label: -1 }, { label: 1.5 }, { label: '9' }, { requirement: 'REQ-slice-1' }, { requirement: 'REQ-SLICE-0' }, { requirement: 7 }, { limit: 0 }, { limit: TASK_PAGE_MAX + 1 }, { limit: 2.5 }, { limit: '50' }, { after: 'vikunja:3' }, { after: 3 }, { task_ref: 'vikunja:3/x' }, { task_ref: ref(2), done: false }, { task_ref: ref(2), limit: 1 }, ]; for (const args of bad) { assert.throws(() => tasks(args), refusesWith('invalid-request'), JSON.stringify(args)); assert.throws(() => tasks(args, reader), refusesWith('invalid-request'), JSON.stringify(args)); } assert.deepEqual([TASK_PAGE, TASK_PAGE_MAX], [50, 200]); });