384 lines
29 KiB
JavaScript
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]);
|
|
});
|