Files
stack/packages/bus/tests/decisions.test.mjs
T
jason.woltjeandClaude Opus 5.5 189410ed09 bus: decisions read follow-up tests, seen-then-resolve, list-before-503 and full rows (#1560)
Row 67 round 2 (rebase only), owner Filbert, candidate manifest 4387c8d5 on f38b7f46.
Approved by Dewey (27648) and Darkwing (27649); round 1 by Dewey (27452) and
Darkwing (27627). Sage's gate green 01:58-02:03Z.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 21:03:37 -05:00

260 lines
11 KiB
JavaScript

// Row 57 (#1546): `decisions`, the read behind the Inbox tabs "Routed to roles"
// and "Recent". Human and reader sessions read it; a role session can't.
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, RECENT } from '../src/broker.mjs';
const options = [
{ key: 'yes', text: 'Allow' },
{ key: 'no', text: 'Decline' },
];
const roles = {
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
};
const businesses = {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
other: { id: 'other', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
};
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-decisions-'));
const store = new Store(root);
const b = new Broker({ store, businesses });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const agent = (role, business = 'demo', run = `${business}-${role}-run`) => {
const cap = b.bindLaunch({ business, role, run, harness: 'pi', address: run });
b.request(cap, { verb: 'role.claim' });
return cap;
};
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = b.bindReader({ business: 'demo' });
return { b, store, agent, human, reader };
}
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
const raise = (b, cap, action, extra = {}) =>
call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
const list = (b, cap, which) => call(b, cap, 'decisions', { list: which });
// Open: one routed to cto, one to pm, one to the human. Closed: one within-role.
function seed(t) {
const x = setup(t);
const { b, agent } = x;
const coder = agent('coder');
const cto = agent('cto');
const pm = agent('pm');
const toCto = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1' });
const toPm = raise(b, coder, 'task.scope.change', { domain: 'delivery', target: 'vikunja:3/2' });
const toHuman = raise(b, coder, 'deploy', { target: 'release-1', task_ref: 'vikunja:3/3', blocking: true });
const within = raise(b, coder, 'message.send', { choice: 'yes' });
assert.deepEqual([toCto.route_to, toPm.route_to, toHuman.route_to, within.route_to], ['cto', 'pm', 'human', 'coder']);
return { ...x, coder, cto, pm, toCto, toPm, toHuman, within };
}
test('roles lists open decisions routed to a role, and never the human inbox', (t) => {
const { b, human, reader, toCto, toPm, toHuman } = seed(t);
for (const cap of [human, reader]) {
const rows = list(b, cap, 'roles');
assert.deepEqual(
rows.map((r) => [r.id, r.route_to]),
[
[toCto.id, 'cto'],
[toPm.id, 'pm'],
],
);
// The same view as inbox: options parsed, blocking a boolean, the authorization context.
assert.deepEqual(rows[0].options, options);
assert.equal(rows[0].blocking, false);
assert.deepEqual(rows[0].authorization, { action: 'task.scope.change', target: 'vikunja:3/1', approvalChoice: 'yes' });
assert.deepEqual(
call(b, cap, 'inbox').map((r) => r.id),
[toHuman.id],
);
}
});
test('recent lists closed decisions of every route, newest close first, with how each closed', (t) => {
const { b, store, human, reader, coder, cto, toCto, toPm, toHuman, within } = seed(t);
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no', note: 'not today' });
call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'yes' });
call(b, coder, 'decision.withdraw', { id: toPm.id });
const late = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/4' });
store.run(
"INSERT INTO decision_events(decision,at,op,by) VALUES(?,'2026-10-11T00:00:00.000Z','expired','broker')",
late.id,
);
for (const cap of [human, reader]) {
const rows = list(b, cap, 'recent');
assert.deepEqual(
rows.map((r) => [r.id, r.closed.op, r.closed.by, r.closed.choice, r.closed.via]),
[
[late.id, 'expired', 'broker', null, null],
[toPm.id, 'withdrawn', 'demo-coder-run', null, 'broker'],
[toCto.id, 'resolved', 'demo-cto-run', 'yes', 'broker'],
[toHuman.id, 'resolved', 'jason', 'no', 'cli'],
[within.id, 'resolved', 'demo-coder-run', 'yes', 'broker'],
],
);
assert.equal(rows[3].closed.note, 'not today');
assert.deepEqual(rows[3].options, options);
assert.equal(rows[3].blocking, true);
assert.deepEqual(list(b, cap, 'roles'), []);
}
});
// Row 67 (#1560): seen, then closed, the usual order. `closed` is the closing event, not the first.
test('recent gives the closing event as closed when the decision was seen first', (t) => {
const { b, store, human, reader, coder, cto, pm, toCto, toPm, toHuman, within } = seed(t);
call(b, human, 'decision.seen', { id: toHuman.id });
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' });
call(b, cto, 'decision.seen', { id: toCto.id });
call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'no' });
call(b, pm, 'decision.seen', { id: toPm.id });
call(b, coder, 'decision.withdraw', { id: toPm.id });
const events = (id) => store.all('SELECT seq, op FROM decision_events WHERE decision=? ORDER BY seq', id);
for (const cap of [human, reader]) {
const rows = list(b, cap, 'recent');
assert.deepEqual(
rows.map((r) => r.id),
[toPm.id, toCto.id, toHuman.id, within.id],
);
for (const [d, op, by] of [
[toHuman, 'resolved', 'jason'],
[toCto, 'resolved', 'demo-cto-run'],
[toPm, 'withdrawn', 'demo-coder-run'],
]) {
const ev = events(d.id);
assert.deepEqual(
ev.map((e) => e.op),
['seen', op],
'seen, then closed',
);
const { closed } = rows.find((r) => r.id === d.id);
assert.deepEqual([closed.op, closed.seq, closed.by], [op, ev[1].seq, by]);
}
}
});
test('recent stops at RECENT rows', (t) => {
const { b, reader, coder } = seed(t);
const ids = [];
for (let i = 0; i < RECENT; i++) ids.push(raise(b, coder, 'message.send', { choice: 'yes' }).id);
const rows = list(b, reader, 'recent');
assert.equal(rows.length, RECENT);
assert.deepEqual(
rows.map((r) => r.id),
ids.reverse(),
);
});
test('every row is what trail already gives the same session for that id', (t) => {
const { b, human, reader, coder, toHuman } = seed(t);
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' });
// Row 67 (#1560): fill the columns the seed leaves empty: project, requirement_ref, supersedes,
// and an approval choice other than the default.
const first = raise(b, coder, 'task.scope.change', {
domain: 'technical',
target: 'vikunja:3/5',
project: 'demo-web',
requirement_ref: 'REQ-DEC-1',
});
raise(b, coder, 'task.scope.change', {
domain: 'technical',
target: 'vikunja:3/5',
task_ref: 'vikunja:3/5',
blocking: true,
supersedes: first.id,
approvalChoice: 'no',
});
for (const cap of [human, reader])
for (const which of ['roles', 'recent'])
for (const row of list(b, cap, which)) {
const trail = call(b, cap, 'trail', { subject: row.id });
const found = trail.find((r) => r.table === 'decisions' && r.id === row.id);
assert.ok(found, row.id);
// The whole row: the view adds authorization (and closed on recent) and makes blocking
// a boolean. Every other column is trail's.
const { table, ...d } = found;
const { authorization, closed, ...rest } = row;
assert.deepEqual(rest, { ...d, blocking: !!d.blocking }, row.id);
const raised = trail.filter(
(r) =>
r.table === 'events' &&
r.kind === 'action.allowed' &&
r.body.operation === 'decision.raise' &&
r.body.decision === row.id,
);
assert.equal(raised.length, 1, row.id);
assert.deepEqual(authorization, {
action: d.action,
target: raised[0].body.target,
approvalChoice: raised[0].body.approvalChoice,
});
assert.equal(closed !== undefined, which === 'recent', row.id);
if (closed) {
const { table, ...event } = trail.find((r) => r.table === 'decision_events' && r.seq === closed.seq);
assert.equal(table, 'decision_events');
assert.deepEqual(event, { ...closed });
}
}
});
test("a role session can't list decisions, so it can't read another role's open decisions", (t) => {
const { b, store, agent, coder, cto, pm, toCto, toPm, toHuman } = seed(t);
const idle = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-unclaimed', harness: 'pi', address: 'x' });
const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n;
for (const cap of [cto, pm, coder, idle])
for (const which of ['roles', 'recent', 'bogus']) {
const before = refusals();
assert.throws(() => list(b, cap, which), /human-required/, which);
assert.equal(refusals(), before + 1, 'the refusal is recorded');
}
// Its own inbox stays its own: cto sees cto's, not pm's or the human's.
assert.deepEqual(
call(b, cto, 'inbox').map((r) => r.id),
[toCto.id],
);
assert.deepEqual(
call(b, pm, 'inbox').map((r) => r.id),
[toPm.id],
);
assert.ok(!call(b, coder, 'inbox').some((r) => r.id === toHuman.id));
// A role in another business reads none of demo's either.
assert.throws(() => list(b, agent('cto', 'other'), 'roles'), /human-required/);
});
test('decisions is a read: one business, strict arguments, nothing written', (t) => {
const { b, store, human, reader, agent } = seed(t);
const coder = agent('coder', 'other');
raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:9/1' });
raise(b, coder, 'message.send', { choice: 'yes' });
const otherReader = b.bindReader({ business: 'other' });
assert.equal(list(b, otherReader, 'roles').length, 1);
assert.equal(list(b, otherReader, 'recent').length, 1);
assert.ok(list(b, otherReader, 'roles').every((r) => r.business === 'other'));
assert.ok(list(b, reader, 'recent').every((r) => r.business === 'demo'));
const count = () =>
['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map(
(table) => store.get(`SELECT count(*) AS n FROM ${table}`).n,
);
const before = count();
for (const cap of [human, reader]) {
list(b, cap, 'roles');
list(b, cap, 'recent');
}
assert.deepEqual(count(), before, 'a successful read writes nothing');
for (const cap of [human, reader])
for (const args of [{}, { list: 'all' }, { list: 'roles', limit: 5 }, { list: ['roles'] }, { list: 'Recent' }])
assert.throws(() => call(b, cap, 'decisions', args), /invalid-request/, JSON.stringify(args));
// The reader's other limits stand.
assert.throws(() => call(b, reader, 'decision.seen', { id: list(b, reader, 'roles')[0].id }), /read-only/);
});