Files
stack/packages/bus/tests/request-attachment.test.mjs

144 lines
8.5 KiB
JavaScript

// Task 8a: shipped host and authority, real repositories/approval and tracker adapter.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, readdirSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs';
import { git } from '../../project/tests/prd-fixture.mjs';
import { requestFixture } from './request-fixture.mjs';
async function picker(t, text = 'Build a product.') {
const f = await scopedFixture(t), product = await f.product('timer');
const request = f.call('human', 'message.send', { to: 'pm', body: text }).request;
let state = await f.call('pm', 'request.open', { request });
state = await f.call('pm', 'request.propose', { request, revision: state.revision, op: 'existing-route',
source_project: 'stack', project: 'unused', name: 'Unused', tracker_project: f.child });
const choose = async (id, choice) => {
const preview = await f.call('console', 'decision.preview.console', { id });
await f.call('console', 'decision.resolve.console', { id, choice, digest: preview.digest });
return f.bus.requestLifecycle.resume('demo', request);
};
state = await choose(state.selection.decision, 'existing');
const rows = await f.call('console', 'request.projects.console', { request });
const pick = () => f.call('console', 'request.attach.console', { request, revision: state.revision, project: 'timer', op: 'pick' });
const propose = picked => f.call('pm', 'request.propose', { request, revision: picked.revision, op: 'attach-plan',
source_project: 'timer', project: 'timer', name: 'timer', tracker_project: f.child });
return { ...f, product, request, state, rows, pick, propose, choose };
}
test('attach-contract: the shipped owner picker includes an approved mapped product and its mapping revision', async t => {
const f = await picker(t);
assert.equal((await f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer' })).id, f.product.snapshot.id);
const row = f.rows.find(row => row.project === 'timer');
assert.ok(row, 'Task 8 needs the real host inventory, not a test-injected listProjects');
assert.match(row.mapping_revision, /^[a-f0-9]{64}$/);
assert.ok(f.rows.every(row => Object.hasOwn(f.config.businesses.demo.projects, row.project) || row.project === 'timer'));
assert.ok(!f.rows.some(row => row.project === 'unused'));
});
test('attachment adopts the matching approved snapshot, keeps paths and creates no project or approval', async t => {
const f = await picker(t), file = join(f.f.dataRoot, 'projects/demo/timer');
const before = { head: git(file, 'rev-parse', 'HEAD'), projects: readdirSync(join(f.f.dataRoot, 'products/demo')) };
const picked = await f.pick(), proposed = await f.propose(picked);
assert.equal(proposed.selection.bindings[0].plan.existing, true);
assert.equal(proposed.selection.bindings[0].plan.records_root, file);
const state = await f.choose(proposed.selection.decision, 'confirm');
assert.equal(state.phase, 'planning');
assert.equal(state.snapshot, f.product.snapshot.id);
assert.equal(state.candidate.records_commit, f.product.submission.candidate.records_commit);
assert.equal(state.mode, 'autonomous');
assert.equal(git(file, 'rev-parse', 'HEAD'), before.head);
assert.deepEqual(readdirSync(join(f.f.dataRoot, 'products/demo')), before.projects);
const task = await f.create({ ...f.product.args, request: f.request, op: 'attached-task' });
assert.match(task.task_ref, /^vikunja:2\//);
assert.equal(f.posts(), 1);
const again = await f.bus.requestLifecycle.resume('demo', f.request);
assert.equal(again.snapshot, state.snapshot);
assert.equal(again.revision, state.revision);
});
test('attachment with different intent enters discovery and never borrows the old snapshot', async t => {
const f = await picker(t, 'Add a keyboard shortcut to the timer.');
const picked = await f.pick(), proposed = await f.propose(picked);
const state = await f.choose(proposed.selection.decision, 'confirm');
assert.equal(state.project, 'timer');
assert.equal(state.phase, 'discovery');
assert.equal(state.snapshot, null);
assert.equal(state.wait.code, 'request-discovery-unavailable');
assert.equal(f.posts(), 0);
assert.deepEqual(readdirSync(join(f.f.dataRoot, 'products/demo')), ['timer']);
await assert.rejects(f.create({ ...f.product.args, request: f.request, op: 'borrow-old-snapshot' }), /requirement-snapshot-required/);
});
test('the Console picker retains its server mapping revision in the HTTP response', async () => {
const { requestHTTP } = await import('../../webui/src/request-http.mjs');
const { Readable } = await import('node:stream');
const req = Readable.from([]); req.method = 'GET'; req.headersDistinct = {};
const session = { name: 'jason', provider: 'fixture', iss: 'fixture', sub: 'owner' };
let body; const res = { writeHead() {}, end(value) { body = JSON.parse(value); } };
await requestHTTP(req, res, { path: '/api/requests/r1/projects', session, live: () => session, origin: 'https://console.test', log: () => {},
requestRead: async () => [{ project: 'timer', name: 'timer', mapping_revision: 'a'.repeat(64) }] });
assert.equal(body[0].mapping_revision, 'a'.repeat(64));
});
function changeMapping(f) {
const file = join(f.f.config, '..', 'businesses/demo.json');
const doc = JSON.parse(readFileSync(file, 'utf8'));
doc.projects.timer.remote = 'https://git.example/owner/changed';
writeFileSync(file, JSON.stringify(doc) + '\n');
}
test('a mapping change after the owner picker refuses the pick with no attachment', async t => {
const f = await picker(t);
changeMapping(f);
await assert.rejects(f.pick(), /request-existing-project-changed/);
assert.equal((await f.call('console', 'request.get.console', { request: f.request })).wait.code, 'request-existing-project');
});
test('a mapping change after the pick refuses preparation instead of silently rebinding', async t => {
const f = await picker(t), picked = await f.pick();
changeMapping(f);
const state = await f.propose(picked);
assert.equal(state.wait.code, 'request-existing-project-changed');
assert.equal(state.selection, null);
});
test('a mapping change after preparation cannot confirm the old attachment', async t => {
const f = await picker(t), proposed = await f.propose(await f.pick());
changeMapping(f);
const state = await f.choose(proposed.selection.decision, 'confirm');
assert.equal(state.project, null);
assert.match(state.wait.code, /request-(organization|existing-project)-changed/);
});
test('an attachment read refusal stays visible and resumes after the existing records are repaired', async t => {
const f = await picker(t), proposed = await f.propose(await f.pick());
const metadata = join(f.f.dataRoot, 'projects/demo/timer/.git');
renameSync(metadata, metadata + '-held');
const waiting = await f.choose(proposed.selection.decision, 'confirm');
assert.equal(waiting.phase, 'discovery');
assert.equal(waiting.snapshot, null);
assert.ok(waiting.wait.code);
renameSync(metadata + '-held', metadata);
const resumed = await f.bus.requestLifecycle.resume('demo', f.request);
assert.equal(resumed.phase, 'planning');
assert.equal(resumed.snapshot, f.product.snapshot.id);
});
test('owner answers on an assistant-origin request do not put a PM message in the assistant session', async t => {
const f = await requestFixture(t, 'guided', null, true);
let s = await f.call('pm', 'request.get', { request: f.request });
s = await f.call('pm', 'request.question', { request: f.request, revision: s.revision, op: 'thread-question', text: 'Offline?' });
const answered = await f.call('console', 'request.answer.console', { request: f.request, revision: s.revision, question: s.question.id, op: 'thread-answer', text: 'Yes.' });
assert.ok(answered.question.answer);
assert.equal(f.store.get("SELECT count(*) n FROM message_threads mt JOIN messages m ON m.id=mt.message WHERE mt.session=? AND m.to_role='pm' AND m.from_role='human'", s.session).n, 0);
});
test('cancel during guided PRD approval withdraws its exact pending decision', async t => {
const f = await requestFixture(t, 'guided', null, true), submitted = await f.submit();
const s = await f.call('pm', 'request.get', { request: f.request });
const cancelled = await f.call('console', 'request.cancel.console', { request: f.request, revision: s.revision, op: 'cancel-prd' });
assert.equal(cancelled.phase, 'cancelled');
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND op='withdrawn'", submitted.decision.id).n, 1);
});