import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { world, SCOPES } from './world.mjs'; import { FakeVikunja } from '../src/fake.mjs'; import { loadFields, checkFields, FieldTableError, FIELDS_FILE } from '../src/fields.mjs'; import { TASK_VERBS } from '../../bus/src/broker.mjs'; import { loadRole } from '../../business/src/index.mjs'; // Row 61 (#1550): fields.json against what the verbs do. The writer layer is read from the table; // whether a write needs a decision, and who resolves it, from roles/*.json as the business loader // reads them (lead decision 113). A version 1 file, the researcher's, grants nothing. // Neither expectation is written out here, so a change to the table, a role file or the verbs // that leaves them disagreeing fails this file. const table = loadFields(); const ROLES = fileURLToPath(new URL('../../../roles/', import.meta.url)); const authority = (r) => loadRole(ROLES, r).authority; const raw = () => JSON.parse(readFileSync(FIELDS_FILE, 'utf8')); const tableError = (message) => (e) => e instanceof FieldTableError && (message === undefined || e.message === message); test('the committed table loads frozen; its verbs are the broker task verbs', () => { assert.deepEqual(Object.keys(table.verbs).sort(), [...TASK_VERBS].sort()); assert.ok(Object.isFrozen(table) && Object.isFrozen(table.fields[0].writes[0].calls)); assert.deepEqual(checkFields(raw()), raw()); }); test('the schema refuses any other shape', () => { const bad = { 'an unknown top key': (t) => (t.writers = {}), 'no source': (t) => delete t.source, 'a blank source': (t) => (t.source = ' '), 'a source that is not a string': (t) => (t.source = 1), 'a source over 256 characters': (t) => (t.source = 'x'.repeat(257)), 'a task verb missing': (t) => delete t.verbs['task.close'], 'a task verb missing with the field it writes': (t) => { delete t.verbs['task.close']; t.fields = t.fields.filter((f) => f.field !== 'done'); }, 'a verb the broker lacks': (t) => (t.verbs['task.delete'] = 'pm'), 'an unknown writer': (t) => (t.verbs['task.close'] = 'cto'), 'no fields': (t) => (t.fields = []), 'a field twice': (t) => t.fields.push(structuredClone(t.fields[0])), 'a field name with a space': (t) => (t.fields[0].field = 'due date'), 'an unknown field key': (t) => (t.fields[0].writer = 'pm'), 'no label': (t) => delete t.fields[0].label, 'a label over 80 characters': (t) => (t.fields[0].label = 'x'.repeat(81)), 'a note over 256 characters': (t) => (t.fields[0].note = 'x'.repeat(257)), 'a blank note': (t) => (t.fields[0].note = ' '), 'no writes': (t) => (t.fields[0].writes = []), 'a write to an unknown verb': (t) => (t.fields[0].writes[0].verb = 'task.delete'), 'one verb twice in a field': (t) => t.fields[0].writes.push(structuredClone(t.fields[0].writes[0])), 'an unknown write key': (t) => (t.fields[0].writes[0].when = 'at create'), 'no calls': (t) => (t.fields[0].writes[0].calls = []), 'a read': (t) => (t.fields[0].writes[0].calls = ['GET /tasks/{t}']), 'a call with a query': (t) => (t.fields[0].writes[0].calls = ['PATCH /tasks/{t}?x=1']), 'one call twice': (t) => t.fields[0].writes[0].calls.push(t.fields[0].writes[0].calls[0]), 'a verb that writes no field': (t) => (t.fields = t.fields.filter((f) => f.field !== 'done')), }; for (const [what, edit] of Object.entries(bad)) { const t = raw(); edit(t); assert.throws(() => checkFields(t), tableError(), what); } const dir = mkdtempSync(join(tmpdir(), 'fields-')); try { writeFileSync(join(dir, 'cut.json'), '{"source":'); assert.throws(() => loadFields(join(dir, 'cut.json')), tableError('fields.json: not JSON')); assert.throws(() => loadFields(join(dir, 'absent.json')), tableError('fields.json: ENOENT')); const t = raw(); t.verbs['task.close'] = 'any'; writeFileSync(join(dir, 'wide.json'), JSON.stringify(t)); assert.doesNotThrow(() => loadFields(join(dir, 'wide.json')), 'a wider writer is still a valid shape; the next test refuses it'); t.verbs['task.close'] = 'reviewer'; writeFileSync(join(dir, 'odd.json'), JSON.stringify(t)); assert.throws(() => loadFields(join(dir, 'odd.json')), tableError('verbs.task.close: writer must be pm, assigned, any')); } finally { rmSync(dir, { recursive: true, force: true }); } }); // A Vikunja write, under /api/v2, as the table names it, and the fields it writes. A bucket move // writes `done` when it moves into the done bucket and the bucket otherwise. const CALLS = [ [/^\/projects\/\d+\/tasks$/, '/projects/{p}/tasks', (r) => Object.keys(r.body)], [/^\/tasks\/\d+$/, '/tasks/{t}', (r) => Object.keys(r.body)], [/^\/projects\/\d+\/views\/\d+\/buckets\/(\d+)\/tasks$/, '/projects/{p}/views/{k}/buckets/{b}/tasks', (r, m, done) => [Number(m[1]) === done ? 'done' : 'bucket']], [/^\/tasks\/\d+\/assignees$/, '/tasks/{t}/assignees', () => ['assignees']], [/^\/tasks\/\d+\/assignees\/\d+$/, '/tasks/{t}/assignees/{u}', () => ['assignees']], [/^\/tasks\/\d+\/labels$/, '/tasks/{t}/labels', () => ['labels']], [/^\/tasks\/\d+\/labels\/\d+$/, '/tasks/{t}/labels/{l}', () => ['labels']], [/^\/tasks\/\d+\/relations$/, '/tasks/{t}/relations', () => ['relations']], [/^\/tasks\/\d+\/relations\/[a-z]+\/\d+$/, '/tasks/{t}/relations/{kind}/{other}', () => ['relations']], [/^\/tasks\/\d+\/comments$/, '/tasks/{t}/comments', () => ['comments']], ]; function writes(r, done) { for (const [re, path, fields] of CALLS) { const m = re.exec(r.path); if (m) return fields(r, m, done).map((f) => `${f} ${r.method} ${path}`); } assert.fail(`a write the table has no call for: ${r.method} ${r.path}`); } const expected = (verb) => table.fields.flatMap((f) => f.writes.filter((w) => w.verb === verb).flatMap((w) => w.calls.map((c) => `${f.field} ${c}`))).sort(); test('each verb refuses, asks for a decision and writes as the table and the role files say', async (t) => { const fake = new FakeVikunja(); const sent = []; const fetch = async (url, init = {}) => { if ((init.method ?? 'GET') !== 'GET') sent.push({ method: init.method, path: new URL(url).pathname.slice('/api/v2'.length), body: init.body === undefined ? undefined : JSON.parse(init.body) }); return fake.fetch(url, init); }; const dir = mkdtempSync(join(tmpdir(), 'fields-')); t.after(() => rmSync(dir, { recursive: true, force: true })); // The role files' authority, and a researcher seat. const w = await world(t, { fake, fetch, business: (b, { owner, project }) => { for (const r of ['pm', 'coder', 'reviewer', 'cto']) b.roles[r].authority = authority(r); const bot = fake.user('bot-demo-researcher', { owner }); fake.share(project, bot, 1); const file = join(dir, 'researcher.token'); writeFileSync(file, fake.token(bot, SCOPES.worker) + '\n'); chmodSync(file, 0o600); b.roles.researcher = { definition: 'researcher', authority: authority('researcher'), tracker: { bot: 'bot-demo-researcher', botId: bot }, credentials: { vikunja: { file, expires: '2099-01-01' } }, }; }, }); assert.deepEqual(w.businesses.demo.roles.researcher.authority, { withinRole: [], crossRole: [] }); await w.adapter.start('demo'); assert.equal(w.adapter.status()[0].state, 'ready'); const ROLE_NAMES = ['pm', 'coder', 'reviewer', 'researcher']; const caps = Object.fromEntries(ROLE_NAMES.map((r) => [r, w.agent(r)])); const cto = w.agent('cto'); const botId = (r) => w.businesses.demo.roles[r].tracker.botId; const done = w.installed.buckets.done; const seen = Object.fromEntries(TASK_VERBS.map((v) => [v, new Set()])); const code = (p) => p.then(() => 'ok', (e) => e.code ?? e.message); // One call, checked layer by layer. Returns the verb's answer, or null when it refused. async function attempt(role, verb, args) { const writer = table.verbs[verb]; const before = sent.length; const refused = async (want) => { assert.equal(await code(w.call(caps[role], verb, args)), want, `${role} ${verb}`); assert.equal(sent.length, before, `${role} ${verb} refused but wrote`); return null; }; if (writer === 'pm' && w.businesses.demo.roles[role].definition !== 'pm') return refused('field-writer'); if (writer === 'assigned' && !fake.task(Number(args.task_ref.split('/')[1])).assignees.includes(botId(role))) return refused('not-assigned'); const a = authority(role); let decision; if (!a.withinRole.includes(verb)) { await refused('decision-required'); const d = w.broker.request(caps[role], { verb: 'decision.raise', args: { action: verb, domain: 'technical', target: args.task_ref ?? null, ...(args.task_ref ? { task_ref: args.task_ref } : {}), question: `${role} ${verb}?`, options: [ { key: 'yes', text: 'yes' }, { key: 'no', text: 'no' }, ], recommendation: 'yes', blocking: false, }, }); // Cross-role goes to the technical arbiter; anything the role file doesn't list, to the human. const route = a.crossRole.includes(verb) ? w.businesses.demo.arbiters.technical : 'human'; assert.equal(w.store.get('SELECT route_to FROM decisions WHERE id=?', d.id).route_to, route, `${role} ${verb} route`); w.broker.request(route === 'human' ? w.human : cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); decision = d.id; } const r = await w.call(caps[role], verb, decision ? { ...args, decision } : args); assert.ok(sent.length > before, `${role} ${verb} wrote nothing`); for (const x of sent.slice(before)) for (const s of writes(x, done)) seen[verb].add(s); return r; } const others = ROLE_NAMES.filter((r) => r !== 'pm'); const create = { title: 'b', description: 'what', due_date: '2026-11-01T00:00:00.000Z', priority: 2, labels: [w.label] }; for (const r of others) await attempt(r, 'task.create', { ...create, request: w.instruction(), requirement: 'REQ-S-1' }); const a = await attempt('pm', 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' }); const relation = { kind: 'related', task_ref: a.task_ref }; const { task_ref } = await attempt('pm', 'task.create', { ...create, relations: [relation], request: w.instruction(), requirement: 'REQ-S-1' }); for (const r of others) { await attempt(r, 'task.assign', { task_ref, role: 'coder' }); await attempt(r, 'task.reassign', { task_ref, role: 'coder' }); await attempt(r, 'task.schedule', { task_ref, due_date: null }); await attempt(r, 'task.priority.change', { task_ref, priority: 4 }); await attempt(r, 'task.close', { task_ref, verdict: 'row 61 r1' }); } for (const r of ROLE_NAMES) await attempt(r, 'task.update.assigned', { task_ref, percent_done: 0.1 }); await attempt('pm', 'task.assign', { task_ref, role: 'coder' }); await attempt('pm', 'task.reassign', { task_ref, role: 'reviewer' }); await attempt('pm', 'task.schedule', { task_ref, due_date: null, labels: { remove: [w.label] }, relations: { remove: [relation] } }); await attempt('pm', 'task.schedule', { task_ref, labels: { add: [w.label] }, relations: { add: [relation] } }); await attempt('pm', 'task.priority.change', { task_ref, priority: 4 }); for (const [i, r] of ROLE_NAMES.entries()) await attempt(r, 'task.scope.change', { task_ref, title: `b${i}`, description: `what ${i}` }); for (const r of ['pm', 'coder', 'researcher']) await attempt(r, 'task.update.assigned', { task_ref, state: 'in-progress' }); await attempt('reviewer', 'task.update.assigned', { task_ref, percent_done: 0.5, state: 'in-progress', comment: 'started' }); // People add the coder and the researcher in Vikunja; each may then write as the assigned role. const id = Number(task_ref.split('/')[1]); for (const r of ['coder', 'researcher']) assert.equal((await w.ui('POST', `/tasks/${id}/assignees`, { user_id: botId(r) })).status, 201); await attempt('coder', 'task.update.assigned', { task_ref, percent_done: 0.7 }); await attempt('researcher', 'task.update.assigned', { task_ref, state: 'in-review' }); await attempt('pm', 'task.close', { task_ref, verdict: 'row 61 r1' }); assert.equal(fake.task(id).done, true); for (const v of TASK_VERBS) assert.deepEqual([...seen[v]].sort(), expected(v), v); });