144 lines
8.5 KiB
JavaScript
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);
|
|
});
|