564 lines
36 KiB
JavaScript
564 lines
36 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { Store, schemaDigest } from '../src/store.mjs';
|
|
import { Broker, STORE_FIELDS, STORE_POSITION_STEP, STORE_VERB_FIELDS, storeNewTask } from '../src/broker.mjs';
|
|
import { startBroker } from '../src/runtime.mjs';
|
|
import { Client } from '../src/client.mjs';
|
|
import { storeAdapter } from '../../tasks/src/store.mjs';
|
|
import { loadFields } from '../../tasks/src/fields.mjs';
|
|
|
|
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1, REQ-WS-8): the task store built into each organization, the
|
|
// default task-tracker module. Roles write through the eight task verbs (packages/tasks/src/store.mjs
|
|
// over broker.storeTask), the person through tasks.create/edit/archive/unarchive/delete, and every
|
|
// read is the bus's `tasks` verb, the same shape whichever module holds the tasks.
|
|
|
|
const AUTHORITY = {
|
|
pm: {
|
|
withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'role.launch', 'message.send'],
|
|
crossRole: ['task.priority.change', 'task.scope.change'],
|
|
},
|
|
coder: { withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], crossRole: ['task.scope.change'] },
|
|
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
|
|
cto: { withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], crossRole: ['task.scope.change'] },
|
|
};
|
|
const roles = () => Object.fromEntries(Object.entries(AUTHORITY).map(([r, authority]) => [r, { definition: r, authority: structuredClone(authority) }]));
|
|
const business = (id, extra = {}) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: roles(), ...extra });
|
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
|
|
// One bus (one data root) holding the given organizations, with the store module over it.
|
|
async function org(t, ids = ['acme'], { taskTracker = null } = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'ts-'));
|
|
const businesses = Object.fromEntries(ids.map((id) => [id, business(id)]));
|
|
const store = new Store(root);
|
|
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
|
|
const adapter = await storeAdapter()({ broker, businesses });
|
|
let removed = false;
|
|
t.after(() => {
|
|
adapter.close();
|
|
if (!removed) {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
const caps = new Map(), humans = new Map();
|
|
const agent = (role, id = ids[0]) => {
|
|
const key = id + '/' + role;
|
|
if (!caps.has(key)) {
|
|
const cap = broker.bindLaunch({ business: id, role, run: role + '-run', harness: 'pi' });
|
|
broker.request(cap, { verb: 'role.claim' });
|
|
caps.set(key, cap);
|
|
}
|
|
return caps.get(key);
|
|
};
|
|
const human = (id = ids[0]) => {
|
|
if (!humans.has(id)) humans.set(id, broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true }));
|
|
return humans.get(id);
|
|
};
|
|
const instruction = (id = ids[0]) => broker.request(human(id), { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
|
|
const call = (role, verb, args, id = ids[0]) => broker.requestTask(agent(role, id), { verb, args }, adapter.handle);
|
|
const person = (verb, args, id = ids[0]) => broker.request(human(id), { verb, args });
|
|
const read = (args = {}, id = ids[0]) => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args });
|
|
const create = (title, extra = {}, id = ids[0]) => call('pm', 'task.create', { title, request: instruction(id), requirement: 'REQ-TASK-1', ...extra }, id);
|
|
// A cross-role verb's approval: the role raises it in the technical domain and the cto resolves it.
|
|
const approve = (role, action, target, id = ids[0]) => {
|
|
const d = broker.request(agent(role, id), {
|
|
verb: 'decision.raise',
|
|
args: { action, domain: 'technical', target, question: 'Allow?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false },
|
|
});
|
|
broker.request(agent('cto', id), { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
|
return d.id;
|
|
};
|
|
const remove = () => {
|
|
removed = true;
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
};
|
|
return { root, store, broker, agent, human, instruction, call, person, read, create, approve, remove };
|
|
}
|
|
const codeOf = async (p) => {
|
|
try {
|
|
await p;
|
|
} catch (e) {
|
|
return e.code;
|
|
}
|
|
return null;
|
|
};
|
|
const syncCode = (fn) => {
|
|
try {
|
|
fn();
|
|
} catch (e) {
|
|
return e.code;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
test('REQ-TASK-1 the store keeps the slice 1 field table: one field list per role verb, all of them store fields', () => {
|
|
assert.deepEqual(Object.keys(STORE_VERB_FIELDS).sort(), Object.keys(loadFields().verbs).sort());
|
|
for (const [verb, list] of Object.entries(STORE_VERB_FIELDS))
|
|
for (const k of list) assert.ok(STORE_FIELDS.includes(k), `${verb} names ${k}`);
|
|
});
|
|
|
|
test('REQ-TASK-1 every role verb writes the store through the module', async (t) => {
|
|
const f = await org(t);
|
|
const request = f.instruction();
|
|
const made = await f.call('pm', 'task.create', { title: 'Write the store', description: 'd', priority: 2, labels: [3, 1], group: 'build', request, requirement: 'REQ-TASK-1' });
|
|
assert.equal(made.task_ref, 'task:1');
|
|
assert.match(made.digest, DIGEST);
|
|
let row = f.read({ task_ref: 'task:1' });
|
|
assert.equal(row.source, 'store');
|
|
assert.equal(row.tracker, 'store');
|
|
assert.equal(row.role, 'pm');
|
|
assert.equal(row.verb, 'task.create');
|
|
assert.equal(row.revision, 1);
|
|
assert.equal(row.digest, made.digest);
|
|
assert.equal(row.archived, false);
|
|
assert.deepEqual(row.fields, { ...storeNewTask({ title: 'Write the store', request, requirement: 'REQ-TASK-1' }), description: 'd', priority: 2, labels: [1, 3], group: 'build', position: 0 });
|
|
// The row's event is the action.allowed the write recorded, on the task's subject.
|
|
const event = f.store.get('SELECT kind, subject, body FROM events WHERE id=?', row.event);
|
|
assert.equal(event.kind, 'action.allowed');
|
|
assert.equal(event.subject, 'task:1');
|
|
assert.equal(JSON.parse(event.body).action, 'task.create');
|
|
|
|
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
|
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'reviewer' })), 'task-assigned');
|
|
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'reviewer' });
|
|
assert.deepEqual(f.read({ task_ref: 'task:1' }).fields.assignees, ['reviewer']);
|
|
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'coder' });
|
|
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'nobody' })), 'unknown-role');
|
|
|
|
await f.call('pm', 'task.schedule', { task_ref: 'task:1', due_date: '2026-12-01T00:00:00.000Z', labels: { add: [5], remove: [1] } });
|
|
row = f.read({ task_ref: 'task:1' });
|
|
assert.equal(row.fields.due_date, '2026-12-01T00:00:00.000Z');
|
|
assert.deepEqual(row.fields.labels, [3, 5]);
|
|
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1' })), 'invalid-request');
|
|
|
|
await f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.5, state: 'in-progress', comment: 'started' });
|
|
row = f.read({ task_ref: 'task:1' });
|
|
assert.equal(row.role, 'coder');
|
|
assert.equal(row.fields.state, 'in-progress');
|
|
assert.equal(row.fields.bucket, 'in-progress');
|
|
assert.equal(row.fields.percent_done, 0.5);
|
|
assert.equal(row.fields.comments, 1);
|
|
assert.deepEqual(row.fields.comment, { text: 'started', by: 'coder' });
|
|
// done is task.close's, never the assignee's.
|
|
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', state: 'done' })), 'invalid-state');
|
|
|
|
// Cross-role verbs need a resolved decision, as they do on Vikunja, and spend it once.
|
|
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4 })), 'decision-required');
|
|
const d = f.approve('pm', 'task.priority.change', 'task:1');
|
|
await f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4, decision: d });
|
|
assert.equal(f.read({ task_ref: 'task:1' }).priority, 4);
|
|
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 5, decision: d })), 'decision-consumed');
|
|
const scope = f.approve('coder', 'task.scope.change', 'task:1');
|
|
await f.call('coder', 'task.scope.change', { task_ref: 'task:1', title: 'Write the store well', decision: scope });
|
|
assert.equal(f.read({ task_ref: 'task:1' }).fields.title, 'Write the store well');
|
|
|
|
const closed = await f.call('pm', 'task.close', { task_ref: 'task:1', verdict: 'review-1' });
|
|
row = f.read({ task_ref: 'task:1' });
|
|
assert.equal(row.digest, closed.digest);
|
|
assert.equal(row.fields.done, true);
|
|
assert.equal(row.fields.state, 'done');
|
|
assert.equal(row.fields.verdict, 'review-1');
|
|
// A done task takes no role write; reopening it is the person's.
|
|
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', labels: { add: [9] } })), 'task-done');
|
|
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 1 })), 'task-done');
|
|
// Nine writes; no refusal wrote a row.
|
|
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 9);
|
|
assert.deepEqual(f.store.all('SELECT revision FROM store_tasks ORDER BY seq').map((r) => r.revision), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
|
});
|
|
|
|
test('REQ-TASK-1 field writers: a role cannot write a field it does not own', async (t) => {
|
|
const f = await org(t);
|
|
await f.create('Owned fields');
|
|
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
|
// The module holds the pm verbs to the pm definition.
|
|
assert.equal(await codeOf(f.call('coder', 'task.create', { title: 'x', request: f.instruction(), requirement: 'REQ-TASK-1' })), 'field-writer');
|
|
assert.equal(await codeOf(f.call('coder', 'task.assign', { task_ref: 'task:1', role: 'coder' })), 'field-writer');
|
|
assert.equal(await codeOf(f.call('coder', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'field-writer');
|
|
assert.equal(await codeOf(f.call('reviewer', 'task.close', { task_ref: 'task:1', verdict: 'v' })), 'field-writer');
|
|
// task.update.assigned needs the role among the assignees.
|
|
assert.equal(await codeOf(f.call('reviewer', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.1 })), 'not-assigned');
|
|
// The broker holds every write to its verb's fields, whatever the module asks for.
|
|
const pm = f.agent('pm'), coder = f.agent('coder');
|
|
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.schedule', task_ref: 'task:1', change: (x) => ({ ...x, title: 'sneaky' }) })), 'field-writer');
|
|
assert.equal(syncCode(() => f.broker.storeTask(coder, { verb: 'task.update.assigned', task_ref: 'task:1', change: (x) => ({ ...x, priority: 5 }) })), 'field-writer');
|
|
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.close', task_ref: 'task:1', verdict: 'v', change: (x) => ({ ...x, done: true, state: 'done', bucket: 'done', assignees: [] }) })), 'field-writer');
|
|
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.assign', task_ref: 'task:1', change: (x) => ({ ...x, extra: 1 }) })), 'field-writer');
|
|
// A person's session is not a role here.
|
|
assert.equal(syncCode(() => f.broker.storeTask(f.human(), { verb: 'task.schedule', task_ref: 'task:1', change: (x) => x })), 'agent-required');
|
|
// Vikunja-only arguments refuse; nothing was written by any refusal.
|
|
for (const extra of [{ project: 1 }, { relations: [] }, { project_key: 'k' }, { requirement_snapshot: 's' }])
|
|
assert.equal(await codeOf(f.create('vikunja args', extra)), 'invalid-request', JSON.stringify(extra));
|
|
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 2);
|
|
// A refusal on a store task is filed on its trail.
|
|
const trail = f.person('trail', { subject: 'task:1' });
|
|
assert.ok(trail.some((r) => r.table === 'events' && r.kind === 'action.refused' && r.body.code === 'not-assigned'));
|
|
});
|
|
|
|
test('REQ-TASK-1 a person creates, edits, archives, unarchives and deletes; each write is a human.input event', async (t) => {
|
|
const f = await org(t);
|
|
const made = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Plan', group: 'todo', labels: [2] });
|
|
assert.equal(made.task_ref, 'task:1');
|
|
assert.equal(made.human, 'jason');
|
|
assert.equal(made.role, null);
|
|
assert.equal(made.fields.position, 0);
|
|
assert.equal(made.fields.request, null);
|
|
const event = f.store.get('SELECT kind, subject, actor_role, body FROM events WHERE id=?', made.event);
|
|
assert.equal(event.kind, 'human.input');
|
|
assert.equal(event.subject, 'task:1');
|
|
assert.equal(event.actor_role, null);
|
|
assert.deepEqual(JSON.parse(event.body), { kind: 'task', task_ref: 'task:1', tracker: 'store', op: 'create', verb: 'tasks.create' });
|
|
assert.equal(syncCode(() => f.person('tasks.create', {})), 'invalid-request');
|
|
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x', request: 'r' })), 'invalid-request');
|
|
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1' })), 'invalid-request');
|
|
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', state: 'shipped' })), 'invalid-state');
|
|
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', assignees: ['nobody'] })), 'unknown-role');
|
|
// expect is the digest the person last read; a later write refuses.
|
|
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', expect: 'a'.repeat(64), title: 'x' })), 'task-conflict');
|
|
const done = f.person('tasks.edit', { task_ref: 'task:1', expect: made.digest, state: 'done', assignees: ['coder'] });
|
|
assert.equal(done.fields.done, true);
|
|
assert.equal(done.fields.bucket, 'done');
|
|
// The person may reopen a done task.
|
|
assert.equal(f.person('tasks.edit', { task_ref: 'task:1', state: 'todo' }).fields.done, false);
|
|
// Agents and the reader don't reach the person's verbs.
|
|
assert.equal(syncCode(() => f.broker.request(f.agent('pm'), { verb: 'tasks.create', args: { title: 'x' } })), 'human-required');
|
|
assert.notEqual(syncCode(() => f.broker.request(f.broker.bindReader({ business: 'acme' }), { verb: 'tasks.create', args: { title: 'x' } })), null);
|
|
|
|
const other = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Kept' });
|
|
f.person('tasks.archive', { task_ref: 'task:1' });
|
|
assert.equal(syncCode(() => f.person('tasks.archive', { task_ref: 'task:1' })), 'task-already-archived');
|
|
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'x' })), 'task-archived');
|
|
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'task-archived');
|
|
// An archived task leaves the board read and the filtered read, unless archived: true asks.
|
|
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
|
|
assert.deepEqual(f.read({ limit: 10 }).tasks.map((r) => r.task_ref), [other.task_ref]);
|
|
const archived = f.read({ archived: true });
|
|
assert.deepEqual(archived.tasks.map((r) => [r.task_ref, r.archived]), [['task:1', true]]);
|
|
assert.equal(archived.total, 1);
|
|
assert.equal(f.read({ task_ref: 'task:1' }).archived, true);
|
|
f.person('tasks.unarchive', { task_ref: 'task:1' });
|
|
assert.equal(syncCode(() => f.person('tasks.unarchive', { task_ref: 'task:1' })), 'task-not-archived');
|
|
assert.equal(f.read({ task_ref: 'task:1' }).archived, false);
|
|
|
|
const gone = f.person('tasks.delete', { task_ref: 'task:1' });
|
|
assert.equal(gone.task_ref, 'task:1');
|
|
assert.equal(gone.deleted, true);
|
|
assert.equal(syncCode(() => f.read({ task_ref: 'task:1' })), 'task-not-found');
|
|
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
|
|
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'back' })), 'task-not-found');
|
|
assert.equal(syncCode(() => f.person('tasks.delete', { task_ref: 'task:1' })), 'task-not-found');
|
|
// The history stays in the organization's bus until the organization is removed.
|
|
const trail = f.person('trail', { subject: 'task:1' }).filter((r) => r.table === 'store_tasks');
|
|
assert.deepEqual(trail.map((r) => r.op), ['create', 'change', 'change', 'archive', 'unarchive', 'delete']);
|
|
assert.equal(trail[0].fields.title, 'Plan');
|
|
// Numbers are never reused.
|
|
assert.equal(f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Next' }).task_ref, 'task:3');
|
|
});
|
|
|
|
test('REQ-TASK-1 a role create cites the instruction; the trail carries it, and an op answers the same task again', async (t) => {
|
|
const f = await org(t);
|
|
const request = f.instruction();
|
|
const args = { title: 'Once', request, requirement: 'REQ-TASK-1', op: 'op-1' };
|
|
const first = await f.call('pm', 'task.create', args);
|
|
const again = await f.call('pm', 'task.create', args);
|
|
assert.deepEqual(again, first);
|
|
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 1);
|
|
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, title: 'Other' })), 'task-create-conflict');
|
|
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, op: undefined, request: 'not-an-instruction' })), 'request-not-found');
|
|
assert.equal(await codeOf(f.call('pm', 'task.create', { title: 'x', request, requirement: 'nope' })), 'invalid-request');
|
|
const trail = f.person('trail', { subject: 'task:1' });
|
|
assert.ok(trail.some((r) => r.table === 'events' && r.id === request && r.kind === 'human.input'));
|
|
assert.ok(trail.some((r) => r.table === 'messages'));
|
|
// A stale expect refuses a role write too.
|
|
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: 'a'.repeat(64) })), 'task-conflict');
|
|
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: first.digest });
|
|
});
|
|
|
|
test('REQ-TASK-1 ordering and grouping: positions step by 1024 inside a group, moves go to the end, reads in board order', async (t) => {
|
|
const f = await org(t);
|
|
for (const title of ['A', 'B', 'C']) await f.create(title);
|
|
await f.create('D', { group: 'review' });
|
|
await f.create('E', { group: 'build' });
|
|
const at = () => f.read().map((r) => [r.fields.title, r.fields.group, r.fields.position]);
|
|
assert.deepEqual(at(), [['A', null, 0], ['B', null, STORE_POSITION_STEP], ['C', null, 2 * STORE_POSITION_STEP], ['E', 'build', 0], ['D', 'review', 0]]);
|
|
// A move to another group with no position goes to its end; a position alone reorders in place.
|
|
await f.call('pm', 'task.schedule', { task_ref: 'task:2', group: 'review' });
|
|
await f.call('pm', 'task.schedule', { task_ref: 'task:3', position: 10 });
|
|
f.person('tasks.edit', { task_ref: 'task:1', position: 5000 });
|
|
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', 'build', 0], ['D', 'review', 0], ['B', 'review', STORE_POSITION_STEP]]);
|
|
// A person's move to a group with no position also goes to its end (after E, still in build then); ungrouping too.
|
|
f.person('tasks.edit', { task_ref: 'task:4', group: 'build' });
|
|
f.person('tasks.edit', { task_ref: 'task:5', group: null });
|
|
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', null, 6024], ['D', 'build', STORE_POSITION_STEP], ['B', 'review', STORE_POSITION_STEP]]);
|
|
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', group: ' ' })), 'invalid-request');
|
|
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', position: -1 })), 'invalid-request');
|
|
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', position: 1.5 })), 'invalid-request');
|
|
// The filtered short form pages in board order with the store's group, position and archived.
|
|
const one = f.read({ limit: 2 });
|
|
assert.deepEqual(one.tasks.map((r) => r.task_ref), ['task:3', 'task:1']);
|
|
assert.equal(one.total, 5);
|
|
assert.equal(one.next, 'task:1');
|
|
assert.deepEqual(Object.keys(one.tasks[0]).sort(), ['archived', 'assignees', 'bucket', 'changed_at', 'done', 'group', 'labels', 'position', 'priority', 'requirement', 'task_ref', 'title']);
|
|
const two = f.read({ limit: 2, after: one.next });
|
|
assert.deepEqual(two.tasks.map((r) => r.task_ref), ['task:5', 'task:4']);
|
|
const three = f.read({ limit: 2, after: two.next });
|
|
assert.deepEqual(three.tasks.map((r) => r.task_ref), ['task:2']);
|
|
assert.equal(three.next, null);
|
|
assert.equal(syncCode(() => f.read({ after: 'task:99' })), 'task-not-found');
|
|
// The other filters apply to store tasks as to Vikunja's.
|
|
await f.call('pm', 'task.assign', { task_ref: 'task:4', role: 'coder' });
|
|
assert.deepEqual(f.read({ assignee: 'coder' }).tasks.map((r) => r.task_ref), ['task:4']);
|
|
assert.deepEqual(f.read({ requirement: 'REQ-TASK-1', done: false }).total, 5);
|
|
assert.deepEqual(f.read({ project_key: 'k'.repeat(8) }).tasks, []);
|
|
});
|
|
|
|
test('REQ-TASK-1 two organizations keep their own tasks, and removing one removes only its tasks', async (t) => {
|
|
// Jason 11-07: each organization has its own bus and data root.
|
|
const a = await org(t, ['acme']), b = await org(t, ['beta']);
|
|
await a.create('acme task');
|
|
await b.create('beta task');
|
|
b.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'beta by hand' });
|
|
assert.deepEqual(a.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'acme task']]);
|
|
assert.deepEqual(b.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'beta task'], ['task:2', 'beta by hand']]);
|
|
// Removing an organization removes its data root, and its tasks with it.
|
|
a.remove();
|
|
assert.equal(existsSync(a.root), false);
|
|
assert.deepEqual(b.read().map((r) => r.fields.title), ['beta task', 'beta by hand']);
|
|
await b.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
|
// Within one bus, too, a business reads and writes only its own rows.
|
|
const both = await org(t, ['one', 'two']);
|
|
await both.create('one task', {}, 'one');
|
|
assert.deepEqual(both.read({}, 'two'), []);
|
|
assert.equal(syncCode(() => both.read({ task_ref: 'task:1' }, 'two')), 'task-not-found');
|
|
assert.equal(await codeOf(both.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }, 'two')), 'task-not-found');
|
|
assert.equal(syncCode(() => both.person('tasks.archive', { task_ref: 'task:1' }, 'two')), 'task-not-found');
|
|
assert.equal((await both.create('two task', {}, 'two')).task_ref, 'task:1');
|
|
assert.deepEqual(both.read({}, 'one').map((r) => r.fields.title), ['one task']);
|
|
});
|
|
|
|
test('REQ-PLUG-1 a business on another tracker module refuses the store verbs', async (t) => {
|
|
const f = await org(t, ['acme'], { taskTracker: () => 'vikunja' });
|
|
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x' })), 'tracker-not-store');
|
|
assert.equal(await codeOf(f.create('x')), 'tracker-not-store');
|
|
assert.deepEqual(f.read(), []);
|
|
assert.throws(() => new Broker({ store: f.store, businesses: { acme: business('acme') }, taskTracker: 'store' }), TypeError);
|
|
});
|
|
|
|
test('REQ-TASK-1 store_tasks guards: append-only, revisions in order, one create, nothing after a delete, archive changes state', async (t) => {
|
|
const f = await org(t);
|
|
await f.create('Guarded');
|
|
const event = f.store.get('SELECT event FROM store_tasks').event;
|
|
const fields = f.store.get('SELECT fields FROM store_tasks').fields;
|
|
const insert = (task, revision, op, by = ['pm', 'pm-run', null]) =>
|
|
f.store.run('INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
|
'2026-11-09T00:00:00.000Z', 'acme', task, revision, op, 'x', ...by, event, fields);
|
|
for (const [sql, ...args] of [["UPDATE store_tasks SET verb='y'"], ['DELETE FROM store_tasks'],
|
|
["INSERT OR REPLACE INTO store_tasks(seq,at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(1,'t','acme',1,2,'change','x','pm','pm-run',NULL,?,?)", event, fields]])
|
|
assert.throws(() => f.store.run(sql, ...args), /store_tasks is append-only/, sql);
|
|
assert.throws(() => insert(1, 3, 'change'), /a store task revision follows the last one/);
|
|
assert.throws(() => insert(1, 2, 'create'), /a store task is created once/);
|
|
assert.throws(() => insert(3, 1, 'create'), /with the next number/);
|
|
assert.throws(() => insert(1, 2, 'unarchive'), /a store task archive row changes state/);
|
|
assert.throws(() => insert(1, 2, 'change', ['pm', null, null]), /CHECK/);
|
|
assert.throws(() => insert(1, 2, 'change', ['pm', 'pm-run', 'jason']), /CHECK/);
|
|
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,?,'[]')", event), /CHECK/);
|
|
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,'no-such-event',?)", fields), /FOREIGN KEY/);
|
|
insert(1, 2, 'archive', [null, null, 'jason']);
|
|
assert.throws(() => insert(1, 3, 'archive', [null, null, 'jason']), /a store task archive row changes state/);
|
|
insert(1, 3, 'delete', [null, null, 'jason']);
|
|
assert.throws(() => insert(1, 4, 'change'), /a deleted store task takes no more rows/);
|
|
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 3);
|
|
});
|
|
|
|
// --- The 3u→3v migration: store_tasks is new and empty, and everything else stays. ---
|
|
const here = (p) => new URL(p, import.meta.url);
|
|
const stepSQL = readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8');
|
|
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
|
|
const master = (path) => {
|
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
try {
|
|
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
|
|
} finally {
|
|
db.close();
|
|
}
|
|
};
|
|
const version = (path) => {
|
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
try {
|
|
return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
};
|
|
|
|
test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every row stays, and the schema is the fresh one', (t) => {
|
|
assert.equal(currentDDL.split(stepSQL).length, 2, 'schema.sql holds the 3u→3v step exactly once');
|
|
assert.ok(currentDDL.endsWith(stepSQL), 'the 3u→3v step is the last in schema.sql');
|
|
const root = mkdtempSync(join(tmpdir(), 'ts-3u-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
|
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
|
db.exec(currentDDL.replace(stepSQL, ''));
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u');
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z');
|
|
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-09T00:00:00.000Z','acme','action.allowed','{}')").run();
|
|
db.close();
|
|
chmodSync(path, 0o600);
|
|
const was = master(path);
|
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
|
assert.deepEqual(master(path), was, 'the refused open changed nothing');
|
|
const store = new Store(root, { migrateSchema: true });
|
|
t.after(() => store.close());
|
|
assert.equal(version(path), '3v');
|
|
assert.equal(store.get('SELECT id FROM events').id, 'preserved');
|
|
assert.deepEqual(store.all('SELECT * FROM store_tasks'), []);
|
|
const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-'));
|
|
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
|
new Store(freshRoot).close();
|
|
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
|
|
});
|
|
|
|
// --- The bus runtime: the store is the default module; Vikunja stays the host's. ---
|
|
test('REQ-PLUG-1 the runtime gives a business with no tracker the store and routes a Vikunja business to the host adapter', async (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'ts-rt-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const businesses = {
|
|
acme: business('acme', { plugins: { harness: ['pi'] } }),
|
|
beta: business('beta', { plugins: { harness: ['pi'], 'task-tracker': 'vikunja' } }),
|
|
};
|
|
const seen = [];
|
|
let closed = 0;
|
|
const launch = (id) => ({ business: id, role: 'pm', run: id + '-pm', harness: 'pi', pid: process.pid, startTime: '1' });
|
|
const runtime = await startBroker({
|
|
dataRoot: root,
|
|
businesses,
|
|
launches: [launch('acme'), launch('beta')],
|
|
trackers: { beta: { baseUrl: 'http://127.0.0.1:9', project: 1 } },
|
|
tasks: async () => ({ handle: async (cap, verb) => (seen.push(verb), { routed: 'vikunja' }), timeout: 1000, close: async () => closed++ }),
|
|
});
|
|
try {
|
|
const [acme, beta] = runtime.launches.map((l) => new Client({ path: runtime.path, cap: l.cap }));
|
|
await acme.call('role.claim');
|
|
await beta.call('role.claim');
|
|
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
|
|
const made = await acme.call('task.create', { title: 'From the socket', request, requirement: 'REQ-TASK-1' });
|
|
assert.equal(made.task_ref, 'task:1');
|
|
assert.deepEqual(await beta.call('task.create', { title: 'x', request: 'r', requirement: 'REQ-TASK-1' }), { routed: 'vikunja' });
|
|
assert.deepEqual(seen, ['task.create']);
|
|
// The Console's read: the `tasks` verb on the organization's reader cap, the same rows either way.
|
|
const rows = runtime.broker.request(runtime.broker.bindReader({ business: 'acme' }), { verb: 'tasks' });
|
|
assert.deepEqual(rows.map((r) => [r.task_ref, r.source, r.fields.title]), [['task:1', 'store', 'From the socket']]);
|
|
assert.equal(syncCode(() => runtime.broker.request(runtime.broker.bindHuman({ business: 'beta', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'tasks.create', args: { title: 'x' } })), 'tracker-not-store');
|
|
} finally {
|
|
await runtime.close();
|
|
}
|
|
assert.equal(closed, 1);
|
|
// A business that names the store while the boot config also gives it a Vikunja tracker refuses the boot.
|
|
await assert.rejects(
|
|
startBroker({ dataRoot: root, businesses: { acme: business('acme', { plugins: { 'task-tracker': 'store' } }) }, trackers: { acme: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }),
|
|
(e) => e.code === 'tracker-config',
|
|
);
|
|
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
|
|
});
|
|
|
|
test('review: an unknown task-tracker module must refuse boot', async (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'unknown-'));
|
|
t.after(() => rmSync(root, {recursive:true, force:true}));
|
|
let runtime;
|
|
try {
|
|
await assert.rejects(async () => {
|
|
runtime = await startBroker({dataRoot:root, businesses:{acme:business('acme', {plugins:{harness:['pi'], 'task-tracker':'no-such-tracker'}})}});
|
|
}, (e) => e.code === 'tracker-config');
|
|
} finally { await runtime?.close(); }
|
|
// Startup cleanup releases the same store so a corrected configuration can start.
|
|
const corrected = await startBroker({dataRoot:root, businesses:{acme:business('acme')}});
|
|
await corrected.close();
|
|
});
|
|
|
|
test('review: a stale expect must reject a delete after an intervening archive', async (t) => {
|
|
const f=await org(t);
|
|
const old=f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Read before archiving'});
|
|
const archived=f.person('tasks.archive', {task_ref:old.task_ref});
|
|
assert.equal(archived.revision, old.revision+1);
|
|
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:old.digest})), 'task-conflict');
|
|
const unarchived = f.person('tasks.unarchive', {task_ref:old.task_ref});
|
|
assert.equal(syncCode(()=>f.person('tasks.edit', {task_ref:old.task_ref, title:'stale', expect:old.digest})), 'task-conflict');
|
|
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:archived.digest})), 'task-conflict');
|
|
assert.equal(f.person('tasks.edit', {task_ref:old.task_ref, title:'current', expect:unarchived.digest}).fields.title, 'current');
|
|
});
|
|
|
|
test('review: human tasks can cite the PRD-required requirement id', async (t) => {
|
|
const f=await org(t);
|
|
const made=f.person('tasks.create', { title:'Traceable',requirement:'REQ-TASK-1'});
|
|
assert.equal(made.fields.requirement,'REQ-TASK-1');
|
|
assert.equal(syncCode(() => f.person('tasks.create', {title:'Missing'})), 'invalid-request');
|
|
for (const requirement of [null, '', 'REQ-foo-1', 'REQ-TASK-0']) {
|
|
assert.equal(syncCode(() => f.person('tasks.create', {title:'Invalid', requirement})), 'invalid-request');
|
|
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:made.task_ref, requirement})), 'invalid-request');
|
|
}
|
|
const edited = f.person('tasks.edit', {task_ref:made.task_ref, requirement:'REQ-TASK-2'});
|
|
assert.equal(edited.fields.requirement, 'REQ-TASK-2');
|
|
assert.equal(f.store.get('SELECT kind FROM events WHERE id=?', edited.event).kind, 'human.input');
|
|
});
|
|
|
|
test('review: a newly hired assignee can use the store after the business reloads', async (t) => {
|
|
const f=await org(t);
|
|
const made=await f.create('For a new coder');
|
|
const next=business('acme');
|
|
next.roles.newcoder=structuredClone(next.roles.coder);
|
|
f.broker.reloadBusiness('acme',next);
|
|
f.person('tasks.edit',{task_ref:made.task_ref,assignees:['newcoder']});
|
|
await f.call('newcoder','task.update.assigned',{task_ref:made.task_ref,state:'in-progress'});
|
|
assert.equal(f.read({task_ref:made.task_ref}).fields.state,'in-progress');
|
|
});
|
|
|
|
test('review: deleting an assignee does not prevent archiving the existing task', async (t) => {
|
|
const f=await org(t);
|
|
const made=f.person('tasks.create',{requirement:'REQ-TASK-1',title:'Assigned history',assignees:['coder']});
|
|
const next=business('acme'); delete next.roles.coder; next.retired=['coder'];
|
|
f.broker.reloadBusiness('acme',next);
|
|
const edited=f.person('tasks.edit',{task_ref:made.task_ref,title:'Edited',assignees:['coder']});
|
|
assert.deepEqual(edited.fields.assignees,['coder']);
|
|
assert.equal(syncCode(() => f.person('tasks.edit',{task_ref:made.task_ref,assignees:['coder','missing']})), 'unknown-role');
|
|
const archived=f.person('tasks.archive',{task_ref:made.task_ref});
|
|
assert.equal(archived.archived,true);
|
|
assert.deepEqual(archived.fields.assignees,['coder']);
|
|
f.person('tasks.delete',{task_ref:made.task_ref});
|
|
const last=JSON.parse(f.store.get('SELECT fields FROM store_tasks ORDER BY seq DESC LIMIT 1').fields);
|
|
assert.deepEqual(last.assignees,['coder']);
|
|
});
|
|
|
|
test('review: saturated and adjacent positions refuse without a write', async (t) => {
|
|
const f = await org(t);
|
|
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Moved', group:'other'});
|
|
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Tail', group:'target', position:1_000_000_000});
|
|
const before = f.store.get('SELECT count(*) n FROM store_tasks').n;
|
|
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'target'})), 'task-position-full');
|
|
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before);
|
|
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'A', group:'adjacent', position:10});
|
|
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'B', group:'adjacent', position:11});
|
|
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
|
|
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
|
|
});
|
|
|
|
test('AU near cross-role task scope restores prior fields and refuses overwriting a later edit',async t=>{
|
|
const f=await org(t);f.broker.reloadBusiness('acme',business('acme',{autonomy:{'cross-role':'near the loop',notices:true}}));
|
|
const owner=f.broker.bindConsole({business:'acme',human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
|
|
const made=f.person('tasks.create',{title:'Before',requirement:'REQ-TASK-1'});
|
|
const d=f.approve('coder','task.scope.change',made.task_ref);
|
|
await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'After',decision:d});
|
|
const reversed=await f.broker.request(owner,{verb:'decision.reverse.console',args:{id:d}});
|
|
assert.equal(reversed.reversed,true);assert.equal(f.read()[0].fields.title,'Before');
|
|
const later=f.approve('coder','task.scope.change',made.task_ref);
|
|
await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'Second',decision:later});
|
|
f.person('tasks.edit',{task_ref:made.task_ref,title:'Owner edit'});
|
|
await assert.rejects(f.broker.request(owner,{verb:'decision.reverse.console',args:{id:later}}),{code:'decision-stale'});
|
|
assert.equal(f.read()[0].fields.title,'Owner edit');
|
|
});
|