Files
stack/packages/webui/tests/workspace-tasks.test.mjs

384 lines
29 KiB
JavaScript

// U4b (#1873, PRD 1.20): the task board's server side. The write route POST /api/workspace/tasks
// (strict body, the hook fence, owner and --send, one org per write, the bus's refusals), the task
// rows the cross-organization read adds, and a Vikunja organization's tasks read through the real
// tasks adapter from a fake Vikunja listening on port 0. Every bus here is a real Broker over a Store
// in this test's own scratch, or a fake options object; no test reaches a live bus or a real Vikunja.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { storeAdapter } from '../../tasks/src/store.mjs';
import { FakeVikunja } from '../../tasks/src/fake.mjs';
import { world, sleep } from '../../tasks/tests/world.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { parseTaskWrite, TASK_WRITE_ROUTE } from '../src/workspace-tasks.mjs';
import { parseWorkspaceQuery, readWorkspace, WorkspaceQueryError, TASKS_PER_ORG } from '../src/workspace-read.mjs';
import { close } from './fixture.mjs';
const DIGEST = /^[0-9a-f]{64}$/;
const D = n => String(n).repeat(64);
const scratch = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
function call(target, { method = 'GET', path, headers = {}, body }) {
return new Promise((resolve, reject) => {
const all = { ...headers };
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
const req = request({ ...target, method, path, headers: all }, res => {
let raw = '';
res.on('data', c => { raw += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json }); });
});
req.on('error', reject);
req.end(body);
});
}
// One organization's bus: a real Broker over its own Store. `taskTracker` names the module the bus
// runtime chose (a Vikunja org's is not the store). `human` is the person's cap, `agent` a role's.
const AUTHORITY = { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] };
async function bus(t, id, { taskTracker = null } = {}) {
const root = scratch(t, 'tb-');
const businesses = { [id]: { id, human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: AUTHORITY }, reviewer: { definition: 'reviewer', authority: AUTHORITY } } } };
const store = new Store(root);
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
const adapter = await storeAdapter()({ broker, businesses });
t.after(() => { adapter.close(); store.close(); });
const human = broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true });
const agent = broker.bindLaunch({ business: id, role: 'coder', run: 'coder-run', harness: 'pi' });
broker.request(agent, { verb: 'role.claim' });
const read = () => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args: {} });
return { broker, human, agent, read };
}
// The registry the route resolves through: `resolve(id, session)` admits an org the session belongs
// to and answers its own transport. Every resolve and every bus call is recorded by org.
async function registry(t) {
const forge = await bus(t, 'forge');
const ddigital = await bus(t, 'ddigital', { taskTracker: () => 'vikunja' });
const robot = await bus(t, 'robot');
const resolved = [], sent = [];
const writer = (org, b, cap) => async (verb, args) => { sent.push([org, verb, args]); return b.broker.request(cap, { verb, args }); };
const orgs = {
forge: { busHealthy: true, taskWrite: writer('forge', forge, forge.human) },
ddigital: { busHealthy: true, taskWrite: writer('ddigital', ddigital, ddigital.human) },
// A Console started by an agent: the bus sees no person and answers human-required.
robot: { busHealthy: true, taskWrite: writer('robot', robot, robot.agent) },
home: { busHealthy: false, taskWrite: async (...a) => { sent.push(['home', ...a]); return {}; } },
lost: { busHealthy: true, taskWrite: async (...a) => { sent.push(['lost', ...a]); throw new Error('socket closed'); } },
odd: { busHealthy: true, taskWrite: async (...a) => { sent.push(['odd', ...a]); throw Object.assign(new Error('x'), { code: 'brand-new-code' }); } },
quiet: { busHealthy: true, taskWrite: null },
};
const organizations = {
list: async () => [], members: async () => [],
async resolve(id, session) {
resolved.push([id, session?.sub ?? null]);
if (id === 'old') throw Object.assign(new Error('archived'), { code: 'org-archived' });
if (id === 'broken') throw new Error('/srv/x/org.json: Unexpected token');
return orgs[id] ?? null;
},
};
return { organizations, resolved, sent, forge, ddigital, robot };
}
async function serve(t, { organizations, send = true, sso = createDevOwner() }) {
const dir = join(scratch(t, 'tw-'), 'console');
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, trail: actionTrail(dir), ...(send ? { send: async () => ({}) } : {}) });
t.after(() => close(server));
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
const post = (body, headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path: TASK_WRITE_ROUTE, body: typeof body === 'string' ? body : JSON.stringify(body),
headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
const trail = () => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
return { post, trail, origin };
}
test('parseTaskWrite takes the three ops strictly and refuses before any bus', () => {
assert.deepEqual(parseTaskWrite({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' }),
{ op: 'create', org: 'forge', verb: 'tasks.create', args: { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' } });
assert.deepEqual(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), state: 'in-review' }),
{ op: 'edit', org: 'forge', verb: 'tasks.edit', args: { task_ref: 'task:4', expect: D(1), state: 'in-review' } });
assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4', expect: D(1) }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4', expect: D(1) } });
assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), due_date: null }).args.due_date, null);
const refused = [
[{ op: 'delete', org: 'forge', task_ref: 'task:1' }, /^op takes create, edit, archive$/],
[{ op: 'unarchive', org: 'forge', task_ref: 'task:1' }, /^op takes/],
[{ op: 'edit', org: 'Forge!', task_ref: 'task:1', title: 'x' }, /^org takes/],
[{ op: 'edit', task_ref: 'task:1', title: 'x' }, /^org takes/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', description: 'x' }, /^edit does not take description$/],
[{ op: 'archive', org: 'forge', task_ref: 'task:1', title: 'x' }, /^archive does not take title$/],
[{ op: 'create', org: 'forge', title: 'x' }, /^create needs requirement$/],
[{ op: 'edit', org: 'forge', title: 'x' }, /^edit needs task_ref$/],
// An edit or archive without the digest the board read is refused before any bus (no silent overwrite).
[{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }, /^edit needs expect$/],
[{ op: 'archive', org: 'forge', task_ref: 'task:1' }, /^archive needs expect$/],
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(2) }, /^edit needs a field to change$/],
// A Vikunja task is never written from the board: its ref is refused here.
[{ op: 'edit', org: 'ddigital', expect: D(1), task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/],
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: 'abc', title: 'x' }, /^expect is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: ' ' }, /^title is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', state: 'doing' }, /^state is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 6 }, /^priority is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/],
[{ op: 'create', org: 'forge', title: 'x', requirement: 'req-1' }, /^requirement is not valid$/],
[{ op: 'create', org: 'forge', title: 'x', requirement: 'REQ-TASK-1', group: 'a\nb' }, /^group is not valid$/],
];
for (const [body, message] of refused) assert.throws(() => parseTaskWrite(body), e => e.status === 400 && message.test(e.message), JSON.stringify(body));
});
test('route: the fence, the owner and --send come before any org is resolved', async t => {
const r = await registry(t);
const { post } = await serve(t, { organizations: r.organizations });
const body = { op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(1), title: 'x' };
const fenced = [
[{ origin: null }, 'cross-origin request refused'],
[{ origin: 'http://evil.test' }, 'cross-origin request refused'],
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
];
for (const [headers, error] of fenced) {
const res = await post(body, headers);
assert.equal(res.status, 403, JSON.stringify(headers)); assert.equal(res.body.error, error);
}
// Body checks: content type, size and shape, each with sent:false.
const bad = [
[await post(JSON.stringify(body), { 'content-type': 'text/plain' }), 415],
[await post(JSON.stringify({ ...body, title: 'x'.repeat(17000) })), 413],
[await post('{not json'), 400],
[await post('[1]'), 400],
[await post({ ...body, extra: 1 }), 400],
];
for (const [res, status] of bad) { assert.equal(res.status, status); assert.equal(res.body.error, 'invalid-request'); assert.equal(res.body.sent, false); }
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
// Without --send the Console is read only.
const ro = await serve(t, { organizations: r.organizations, send: false });
const refused = await ro.post(body);
assert.equal(refused.status, 403); assert.equal(refused.body.error, 'read-only');
// A granted user's session is fenced as owner-only before the handler.
const granted = { ...createDevOwner(), session: () => ({ provider: 'local-dev', iss: 'local-dev', sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
const user = await serve(t, { organizations: r.organizations, sso: granted });
const other = await user.post(body);
assert.equal(other.status, 403); assert.equal(other.body.error, 'owner-only');
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
});
test('route: each write reaches only the org it names, through that org\'s real bus, with the person\'s verbs', async t => {
const r = await registry(t);
const { post, trail } = await serve(t, { organizations: r.organizations });
const created = await post({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 2 });
assert.equal(created.status, 200, JSON.stringify(created.body));
assert.equal(created.body.ok, true); assert.equal(created.body.org, 'forge'); assert.equal(created.body.op, 'create');
assert.equal(created.body.task_ref, 'task:1'); assert.match(created.body.digest, DIGEST);
assert.deepEqual(r.resolved, [['forge', 'local-dev-owner']]);
assert.deepEqual(r.sent.map(([org, verb]) => [org, verb]), [['forge', 'tasks.create']]);
const [task] = r.forge.read();
assert.equal(task.task_ref, 'task:1'); assert.equal(task.source, 'store'); assert.equal(task.fields.title, 'Rate limits');
assert.equal(task.digest, created.body.digest);
// No other org's bus holds anything.
assert.deepEqual(r.robot.read(), []);
// An edit with the digest the board read is written; a stale one is refused and changes nothing.
const moved = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, state: 'in-review' });
assert.equal(moved.status, 200); assert.notEqual(moved.body.digest, created.body.digest);
assert.equal(r.forge.read()[0].fields.state, 'in-review');
const stale = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, title: 'Lost update' });
assert.equal(stale.status, 409); assert.equal(stale.body.error, 'task-conflict'); assert.equal(stale.body.sent, false);
assert.match(stale.body.message, /Nothing was written/);
assert.equal(r.forge.read()[0].fields.title, 'Rate limits');
const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', expect: D(1), title: 'x' });
assert.equal(missing.status, 404); assert.equal(missing.body.error, 'task-not-found');
const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: moved.body.digest, assignees: ['nobody'] });
assert.equal(role.status, 400); assert.equal(role.body.error, 'unknown-role');
const archived = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: moved.body.digest });
assert.equal(archived.status, 200);
assert.deepEqual(r.forge.read(), [], 'an archived task leaves the read');
const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: archived.body.digest });
assert.equal(again.status, 409); assert.equal(again.body.error, 'task-already-archived');
// A Vikunja organization's bus refuses the store verbs; the board never writes to Vikunja.
const vik = await post({ op: 'create', org: 'ddigital', title: 'Launch post', requirement: 'REQ-TASK-1' });
assert.equal(vik.status, 409); assert.equal(vik.body.error, 'tracker-not-store'); assert.equal(vik.body.sent, false);
assert.match(vik.body.message, /keeps its tasks in Vikunja/);
assert.deepEqual(r.ddigital.read(), []);
// A Console started by an agent: the bus finds no person, so nothing is written.
const robot = await post({ op: 'create', org: 'robot', title: 'Sneaky', requirement: 'REQ-TASK-1' });
assert.equal(robot.status, 403); assert.equal(robot.body.error, 'human-required');
assert.match(robot.body.message, /only from a person/);
assert.deepEqual(r.robot.read(), []);
// An org outside the session, an archived org, an unreadable registry, a down bus, a Console
// without the write: each is refused before or without a bus call.
const before = r.sent.length;
const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(outside.status, 404); assert.deepEqual(outside.body, { error: 'org-unavailable', sent: false });
const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(old.status, 409); assert.equal(old.body.error, 'org-archived');
const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(broken.status, 404); assert.deepEqual(broken.body, { error: 'org-unavailable', sent: false });
const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(down.status, 503); assert.equal(down.body.error, 'no-bus-host'); assert.equal(down.body.sent, false);
const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(quiet.status, 403); assert.equal(quiet.body.error, 'read-only');
assert.equal(r.sent.length, before);
// A lost answer is not a refusal: the change may have been written, and the person is told so.
const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(lost.status, 503); assert.equal(lost.body.error, 'outcome-unknown'); assert.equal(lost.body.sent, 'unknown');
const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(odd.status, 502); assert.equal(odd.body.error, 'brand-new-code'); assert.equal(odd.body.sent, false);
// Every resolve named exactly the org in the body, and each bus call went to that org.
for (const [org, verb] of r.sent) assert.ok(['tasks.create', 'tasks.edit', 'tasks.archive'].includes(verb), `${org} ${verb}`);
assert.deepEqual([...new Set(r.resolved.map(([id]) => id))], ['forge', 'ddigital', 'robot', 'acme', 'old', 'broken', 'home', 'quiet', 'lost', 'odd']);
// The action trail notes each outcome with who, which org and which verb.
const notes = trail().map(e => [e.org, e.action, e.task ?? null, e.outcome]);
assert.deepEqual(notes, [
['forge', 'tasks.create', null, 'written'],
['forge', 'tasks.edit', 'task:1', 'written'],
['forge', 'tasks.edit', 'task:1', 'refused task-conflict'],
['forge', 'tasks.edit', 'task:9', 'refused task-not-found'],
['forge', 'tasks.edit', 'task:1', 'refused unknown-role'],
['forge', 'tasks.archive', 'task:1', 'written'],
['forge', 'tasks.archive', 'task:1', 'refused task-already-archived'],
['ddigital', 'tasks.create', null, 'refused tracker-not-store'],
['robot', 'tasks.create', null, 'refused human-required'],
['lost', 'tasks.edit', 'task:1', 'unknown'],
['odd', 'tasks.edit', 'task:1', 'refused brand-new-code'],
]);
assert.ok(trail().every(e => e.by?.sub === 'local-dev-owner' && e.by?.provider === 'local-dev'));
});
// One org's reads as the workspace read sees them (options().bus and options().chat).
function member(id, data, doc = {}) {
const value = name => Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
return {
id, doc, status: async () => ({ healthy: true, pid: 1, boot: null }),
options: () => ({
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
chat: { threads: () => value('threads'), projects: () => value('projects') },
}),
};
}
const SESSION = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const readTasks = (members, search = 'sections=tasks') => readWorkspace({ organizations: { members: async () => members }, session: SESSION, query: parseWorkspaceQuery(search), now: () => Date.parse('2026-11-10T12:00:00.000Z') });
test('read: a built-in store org\'s rows, from its real bus, carry state, project, roles and the digest a write sends back', async t => {
const forge = await bus(t, 'forge');
const p = (verb, args) => forge.broker.request(forge.human, { verb, args });
p('tasks.create', { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' });
p('tasks.create', { title: 'Invoice PDF', requirement: 'REQ-TASK-1', group: 'billing', state: 'blocked' });
p('tasks.create', { title: 'Loose end', requirement: 'REQ-TASK-1' });
const gone = p('tasks.create', { title: 'Old idea', requirement: 'REQ-TASK-1' });
p('tasks.archive', { task_ref: gone.task_ref });
const projects = [{ id: 'p-api', repo: 'api-gateway', name: 'API gateway', archived: false, at: '2026-11-01T00:00:00.000Z' }, { id: 'p-bill', repo: 'billing', name: 'Billing', archived: false, at: '2026-11-01T00:00:00.000Z' }];
const body = await readTasks([member('forge', { tasks: forge.read(), projects }, { roles: { coder: {}, reviewer: {} } })]);
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['forge', 'ok', 'built-in']]);
const rows = body.tasks.map(x => [x.ref, x.tracker, x.title, x.state, x.project?.key ?? null, x.group, x.assignees, x.priority, x.dueDate, x.writable, DIGEST.test(x.digest)]);
assert.deepEqual(rows, [
// A group matches a project by id or by key; a group naming neither has no project.
['task:1', 'built-in', 'Rate limits', 'todo', 'api-gateway', 'p-api', ['coder'], 3, '2026-11-20T00:00:00.000Z', true, true],
['task:2', 'built-in', 'Invoice PDF', 'blocked', 'billing', 'billing', [], 0, null, true, true],
['task:3', 'built-in', 'Loose end', 'todo', null, null, [], 0, null, true, true],
]);
// The digest is the bus's own, so an edit's `expect` matches the task the board shows.
assert.equal(body.tasks[0].digest, forge.read().find(x => x.task_ref === 'task:1').digest);
p('tasks.edit', { task_ref: 'task:1', expect: body.tasks[0].digest, state: 'done' });
const after = await readTasks([member('forge', { tasks: forge.read(), projects }, {})], 'sections=tasks&task-state=done');
assert.deepEqual(after.tasks.map(x => [x.ref, x.state, x.done]), [['task:1', 'done', true]]);
});
test('read: filters, the per-org cap, and rows the read refuses', async () => {
const store = (n, f = {}, extra = {}) => ({ task_ref: `task:${n}`, source: 'store', digest: D(n % 10), at: '2026-11-09T00:00:00.000Z', fields: { title: `T${n}`, state: 'todo', group: null, assignees: [], labels: [], ...f }, ...extra });
const tasks = [store(1, { state: 'in-review', assignees: ['coder'], labels: [4] }), store(2, { assignees: ['reviewer'] }), store(3, { state: 'nonsense' }), store(4, {}, { digest: 'not-a-digest' }), store(5, {}, { archived: true })];
const one = async search => (await readTasks([member('forge', { tasks })], `sections=tasks${search}`)).tasks.map(x => x.ref);
assert.deepEqual(await one(''), ['task:1', 'task:2', 'task:3', 'task:4']);
assert.deepEqual(await one('&task-state=in-review'), ['task:1']);
// An unknown state reads as null, which only `open` matches.
assert.deepEqual(await one('&task-state=open'), ['task:3']);
assert.deepEqual(await one('&assignee=reviewer,coder'), ['task:1', 'task:2']);
assert.deepEqual(await one('&label=4'), ['task:1']);
const all = (await readTasks([member('forge', { tasks })])).tasks;
assert.equal(all.find(x => x.ref === 'task:4').digest, null, 'a malformed digest is never sent back');
for (const search of ['task-state=doing', 'assignee=', 'assignee=a b', 'label=0', 'label=x', 'task-state=todo&task-state=done'])
assert.throws(() => parseWorkspaceQuery(search), WorkspaceQueryError, search);
// Past the cap the org keeps its TASKS_PER_ORG most recently changed rows and says how many
// matched; another org is untouched. Task n changed n minutes after midnight.
const minute = n => new Date(Date.parse('2026-11-09T00:00:00.000Z') + n * 60000).toISOString();
const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1, i + 1 > TASKS_PER_ORG ? { group: 'late', state: 'blocked' } : { group: 'p1' }, { at: minute(i + 1), project_key: 'k' }));
const projects = [{ id: 'p1', repo: 'one', name: 'One', archived: false, at: minute(0) }, { id: 'late', repo: 'late', name: 'Late', archived: false, at: minute(0) }];
const big = [member('big', { tasks: many, projects }), member('small', { tasks: [store(1)] })];
const capped = await readTasks(big, 'sections=tasks,projects,metrics');
const bigRows = capped.tasks.filter(x => x.org === 'big');
assert.equal(bigRows.length, TASKS_PER_ORG);
assert.equal(capped.orgs.find(o => o.org === 'big').tasksHeld, TASKS_PER_ORG + 7);
assert.equal(capped.orgs.find(o => o.org === 'small').tasksHeld, undefined);
assert.equal(bigRows.some(x => x.ref === 'task:7'), false, 'the oldest changes are the ones cut');
assert.equal(bigRows.some(x => x.ref === 'task:8'), true);
// Store refs sort by number, so task:10 follows task:9.
assert.deepEqual(bigRows.slice(1, 4).map(x => x.ref), ['task:9', 'task:10', 'task:11']);
// Projects and metrics count every task the org holds, not only the ones the answer carries.
const proj = Object.fromEntries(capped.projects.filter(x => x.org === 'big').map(x => [x.id, x]));
assert.equal(proj.p1.openTasks, TASKS_PER_ORG); assert.equal(proj.late.openTasks, 7);
assert.equal(capped.metrics.orgs.find(m => m.org === 'big').openTasks, TASKS_PER_ORG + 7);
// A filter runs before the cap, so it reaches tasks past row TASKS_PER_ORG.
for (const search of ['sections=tasks&project=big:late', 'sections=tasks&task-state=blocked']) {
const filtered = await readTasks(big, search);
assert.deepEqual(filtered.tasks.map(x => x.ref), Array.from({ length: 7 }, (_, i) => `task:${TASKS_PER_ORG + i + 1}`), search);
assert.equal(filtered.orgs.find(o => o.org === 'big').tasksHeld, undefined, search);
}
});
test('read: a Vikunja org\'s tasks come through the real tasks adapter from a fake Vikunja on port 0', async t => {
const fake = new FakeVikunja();
const server = await fake.listen();
t.after(server.close);
assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/);
const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } });
await w.adapter.start('demo');
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
const pm = w.agent('pm');
const a = await w.call(pm, 'task.create', { title: 'Launch post', request: w.instruction(), requirement: 'REQ-TASK-1' });
const b = await w.call(pm, 'task.create', { title: 'Press kit', request: w.instruction(), requirement: 'REQ-TASK-1' });
const id = ref => Number(ref.slice(ref.indexOf('/') + 1));
// The owner works in the Vikunja UI; the adapter's poll picks it up.
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/assignees`, { user_id: w.bots.coder })).status, 201);
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/labels`, { label_id: w.label })).status, 201);
assert.equal((await w.ui('PATCH', `/tasks/${id(a.task_ref)}`, { due_date: '2026-11-20T00:00:00Z', percent_done: 0.5, description: 'not on the board' })).status, 200);
assert.equal((await w.ui('PATCH', `/tasks/${id(b.task_ref)}`, { done: true })).status, 200);
await sleep(20);
await w.adapter.tick('demo');
assert.ok(fake.requests.length > 0, 'the adapter read the fake over HTTP');
const rows = w.broker.request(w.human, { verb: 'tasks', args: {} });
const doc = w.businesses.demo;
const body = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)]);
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['demo', 'ok', 'vikunja']]);
const [x, y] = body.tasks;
// Mapped: ref, title, done (the only state the bus names), bot ids as roles, labels, priority,
// due date and the numeric bucket. Left out: description, percent_done, relations, comments.
assert.deepEqual({ ref: x.ref, tracker: x.tracker, title: x.title, state: x.state, done: x.done, assignees: x.assignees, labels: x.labels, dueDate: x.dueDate, writable: x.writable, digest: x.digest },
{ ref: a.task_ref, tracker: 'vikunja', title: 'Launch post', state: null, done: false, assignees: ['coder'], labels: [w.label], dueDate: '2026-11-20T00:00:00.000Z', writable: false, digest: null });
assert.ok(Number.isSafeInteger(x.bucket) && x.bucket > 0, 'the bucket is the board\'s numeric id');
assert.equal(x.requirement, 'REQ-TASK-1');
assert.deepEqual([y.ref, y.state, y.done], [b.task_ref, 'done', true]);
for (const key of ['description', 'percent_done', 'percentDone', 'relations', 'comments']) assert.equal(Object.hasOwn(x, key), false, key);
// The board's filters work on a Vikunja org too.
const open = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)], 'sections=tasks&task-state=open&assignee=coder');
assert.deepEqual(open.tasks.map(t => t.ref), [a.task_ref]);
});