import test from 'node:test'; import assert from 'node:assert/strict'; import { world } from './world.mjs'; import { FakeVikunja } from '../src/fake.mjs'; import { digest } from '../src/digest.mjs'; const journal = (w, operation) => w.events(operation.endsWith('.wait') ? 'action.refused' : 'action.allowed').filter(e => e.body.operation === operation); const resumePm = w => { w.broker.recoverLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); const cap = w.broker.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-resumed', harness: 'pi' }); w.broker.request(cap, { verb: 'role.claim' }); return (w.caps.pm = cap); }; const uncertain = e => e.code === 'task-create-uncertain'; async function fixture(t, edit = {}) { const w = await world(t, edit); await w.adapter.start('demo'); const pm = w.agent('pm'); const args = { title: 'recover me', description: 'original body', request: w.instruction(), requirement: 'REQ-TASK-1', op: 'create-5a' }; const create = (extra = {}, cap = pm) => w.call(cap, 'task.create', { ...args, ...extra }); const posts = () => w.fake.requests.filter(r => r.method === 'POST' && /^\/projects\/\d+\/tasks$/.test(r.path)).length; return { w, pm, args, create, posts }; } test('lost response after tracker create is adopted once with original attribution and bound Stack catalogue', async t => { const { w, args, create, posts } = await fixture(t); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true }); await assert.rejects(create(), uncertain); assert.equal(posts(), 1); assert.equal(w.events('task.created').length, 0); assert.equal(w.fake.task(1).description, `original body\n\nMosaic operation: create-5a; request: ${args.request}`); await w.stop(); await w.restart(); const [created] = w.events('task.created'); assert.equal(created.subject, `vikunja:${w.project}/1`); assert.equal(created.actor_role, 'pm'); assert.equal(created.actor_run, 'pm-run'); assert.equal(created.body.scope.project_key, 'stack'); assert.match(created.body.scope.requirement_snapshot, /^builtin-/); assert.equal(created.body.op, args.op); await w.adapter.reconcileCreates('demo'); const made = await create({}, resumePm(w)); assert.equal(made.task_ref, created.subject); assert.equal(w.events('task.created').length, 1); assert.equal(posts(), 1); }); test('restart before create receipt survives a real Store close and reopen', async t => { const { w, create, posts } = await fixture(t); w.store.run("CREATE TRIGGER crash_receipt BEFORE INSERT ON events WHEN NEW.kind='task.created' BEGIN SELECT RAISE(ABORT,'crash before receipt'); END"); await assert.rejects(create()); assert.equal(posts(), 1); assert.equal(journal(w, 'tracker.create.prepared').length, 1); assert.equal(w.events('task.created').length, 0); w.store.run('DROP TRIGGER crash_receipt'); await w.stop(); await w.restart(); assert.equal(w.events('task.created').length, 1); assert.equal(posts(), 1); }); test('duplicate PM calls share one create and changed operation bodies are refused', async t => { const { w, create, posts } = await fixture(t); const [a, b] = await Promise.all([create(), create()]); assert.equal(a.task_ref, b.task_ref); assert.equal(posts(), 1); assert.equal(w.events('task.created').length, 1); await assert.rejects(create({ title: 'changed' }), e => e.code === 'task-create-conflict'); await assert.rejects(create({ labels: [w.label] }), e => e.code === 'task-create-conflict'); assert.equal(posts(), 1); }); test('label and relation follow-up failures resume by read-before-write without another create', async t => { const { w, create, posts } = await fixture(t); const other = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'related' }); assert.equal(other.status, 201); const extras = { labels: [w.label], relations: [{ kind: 'blocking', task_ref: `vikunja:${w.project}/1` }] }; w.fake.fault('POST', /^\/tasks\/2\/labels$/, 500); await assert.rejects(create(extras)); assert.equal(w.events('task.created').length, 1); await w.stop(); await w.restart(); w.fake.fault('POST', /^\/tasks\/2\/relations$/, 500); const pm = resumePm(w); await assert.rejects(create(extras, pm)); const labels = () => w.fake.requests.filter(r => r.method === 'POST' && r.path === '/tasks/2/labels').length; assert.equal(labels(), 2); const r = await create(extras, pm); assert.equal(r.task_ref, `vikunja:${w.project}/2`); assert.equal(labels(), 2); assert.deepEqual(w.fake.task(2).relations, [{ kind: 'blocking', other: 1 }]); assert.equal(posts(), 2); // one UI task and one Mosaic task assert.equal(w.events('task.created').length, 1); }); test('assignment failure after recovery can resume on the adopted task', async t => { const { w, create, posts } = await fixture(t); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 502, { apply: true }); await assert.rejects(create(), uncertain); await w.adapter.reconcileCreates('demo'); const { task_ref } = await create(); w.fake.fault('POST', /^\/tasks\/1\/assignees$/, 500); await assert.rejects(w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' })); await w.stop(); await w.restart(); const same = await create({}, resumePm(w)); await w.call(w.caps.pm, 'task.assign', { task_ref: same.task_ref, role: 'coder' }); assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); assert.equal(posts(), 1); }); test('closed matching task is recovered across all pages including completed tasks', async t => { const { w, create, posts } = await fixture(t); for (let i = 0; i < 51; i++) await w.ui('POST', `/projects/${w.project}/tasks`, { title: `padding ${i}` }); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true }); await assert.rejects(create(), uncertain); await w.ui('PATCH', '/tasks/52', { done: true }); await w.stop(); await w.restart(); assert.equal(w.events('task.created')[0].subject, `vikunja:${w.project}/52`); assert.equal(w.snapshots(`vikunja:${w.project}/52`).at(-1).fields.done, true); assert.equal(posts(), 52); }); for (const change of ['edited marker', 'changed body', 'wrong creator', 'moved board', 'duplicate marker', 'missing marker', 'complete scan failure', 'partial scan failure']) { test(`${change} stays uncertain with a durable reconciliation wait and never posts again`, async t => { const { w, create, posts } = await fixture(t); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true }); await assert.rejects(create(), uncertain); if (change === 'edited marker') await w.ui('PATCH', '/tasks/1', { description: w.fake.task(1).description.replace('create-5a', 'edited') }); if (change === 'changed body') await w.ui('PATCH', '/tasks/1', { title: 'edited title' }); if (change === 'wrong creator') w.fake.task(1).by = w.owner; if (change === 'moved board') { const p = w.fake.project('Elsewhere', w.owner); w.fake.moveToProject(1, p); } if (change === 'duplicate marker') await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'recover me', description: w.fake.task(1).description }); if (change === 'missing marker') await w.ui('PATCH', '/tasks/1', { description: 'removed' }); if (change === 'partial scan failure') { for (let i = 0; i < 50; i++) await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'padding' }); } const before = posts(); if (change.includes('scan failure')) { // Recovery's scan is id-keyed. Fail every scan, including after the first page. if (change === 'partial scan failure') { const use = w.credentials.use.bind(w.credentials); w.credentials.use = (instance, service, fn) => use(instance, service, async token => { const result = await fn(token); if (instance === 'demo/@sync' && result.json?.items?.length === 50 && result.json.items[0]?.title === 'recover me') w.fake.fault('GET', /^\/projects\/\d+\/tasks$/, 500); return result; }); } else w.fake.fault('GET', /^\/projects\/\d+\/tasks$/, 500); } const results = await w.adapter.reconcileCreates('demo'); assert.equal(results[0].state, 'uncertain'); if (change === 'complete scan failure') w.fake.fault('GET', /^\/projects\/\d+\/tasks$/, 500); await assert.rejects(create(), uncertain); assert.equal(w.events('task.created').length, 0); assert.equal(posts(), before); const waits = journal(w, 'tracker.create.wait'); assert.ok(waits.length > 0); assert.equal(waits.at(-1).body.code, 'task-create-uncertain'); }); } test('negative scan after a prepared create never authorizes a second POST', async t => { const { w, create, posts } = await fixture(t); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0); await assert.rejects(create(), uncertain); await w.stop(); await w.restart(); await assert.rejects(create({}, resumePm(w)), uncertain); assert.equal(posts(), 1); assert.equal(w.fake.task(1), undefined); }); test('host recovery verifies prepared identity body and target and has no socket verb', async t => { const { w, pm, create } = await fixture(t); const made = await create(); const [receipt] = w.broker.taskCreateReceipts('demo'); const snapshot = { ...w.snapshots(made.task_ref).at(-1), creator: w.bots.pm }; const kept = Object.fromEntries(['task_ref', 'updated', 'etag', 'digest', 'fields', 'creator'].map(k => [k, snapshot[k]])); const recover = (r = receipt, s = kept, task_ref = made.task_ref) => w.broker.recordRecoveredCreate({ receipt: r, task_ref, snapshot: s }); assert.doesNotThrow(() => recover()); assert.equal(w.events('task.created').length, 1); assert.throws(() => recover({ ...receipt, actor_run: 'invented' })); assert.throws(() => recover(receipt, { ...kept, creator: w.owner })); assert.throws(() => recover(receipt, { ...kept, fields: { ...kept.fields, title: 'changed' } })); assert.throws(() => recover(receipt, kept, 'vikunja:999/1')); for (const change of [{ title: 'changed' }, { description: 'changed' }, { priority: 5 }, { due_date: '2026-12-01T00:00:00.000Z' }, { bucket: 999 }]) { const fields = { ...kept.fields, ...change }; assert.throws(() => recover(receipt, { ...kept, fields, digest: digest(fields) })); } assert.throws(() => recover({ ...receipt, body: { ...receipt.body, body_digest: '0'.repeat(64) } })); assert.throws(() => w.broker.request(pm, { verb: 'recordRecoveredCreate', args: {} }), e => e.code === 'unknown-verb'); const inspection = await w.adapter.inspectProject('demo', w.project); assert.equal(inspection.id, w.installed.view); }); test('prepared body digest and target are durable before the first network read or POST', async t => { const fake = new FakeVikunja(); let observe = () => {}; const { w, args, create, posts } = await fixture(t, { fake, fetch: (url, options) => { observe(url, options); return fake.fetch(url, options); } }); let reads = 0; const other = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'other' }); const extras = { relations: [{ kind: 'blocking', task_ref: `vikunja:${w.project}/${other.json.id}` }] }; observe = (url, options) => { const [event] = journal(w, 'tracker.create.prepared'); assert.ok(event, 'reservation precedes every create-path network effect'); assert.equal(event.actor_run, 'pm-run'); assert.equal(event.body.project, w.project); assert.match(event.body.body_digest, /^[a-f0-9]{64}$/); assert.equal(event.body.body.title, args.title); if (options.method === 'GET') reads++; }; await create(extras); assert.ok(reads > 0); assert.equal(posts(), 2); }); test('restart after reservation but before POST only reconciles and never creates', async t => { const { w, create, posts } = await fixture(t); const other = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'other' }); const extras = { relations: [{ kind: 'blocking', task_ref: `vikunja:${w.project}/${other.json.id}` }] }; w.fake.fault('GET', /^\/tasks\/1$/, 500); await assert.rejects(create(extras)); assert.equal(journal(w, 'tracker.create.prepared').length, 1); assert.equal(posts(), 1); await w.stop(); await w.restart(); await assert.rejects(create(extras, resumePm(w)), uncertain); assert.equal(posts(), 1); }); test('lost follow-up responses use existing reads to avoid duplicate label and relation writes', async t => { const { w, create, posts } = await fixture(t); await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'other' }); const extras = { labels: [w.label], relations: [{ kind: 'blocking', task_ref: `vikunja:${w.project}/1` }] }; w.fake.fault('POST', /^\/tasks\/2\/labels$/, 0, { apply: true }); w.fake.fault('POST', /^\/tasks\/2\/relations$/, 0, { apply: true }); await create(extras); await create(extras); assert.equal(w.fake.requests.filter(r => r.method === 'POST' && r.path === '/tasks/2/labels').length, 1); assert.equal(w.fake.requests.filter(r => r.method === 'POST' && r.path === '/tasks/2/relations').length, 1); assert.equal(posts(), 2); }); test('recovery works after the original PM session has ended and only reads as sync', async t => { const fake = new FakeVikunja(); const requests = []; const { w, create, posts } = await fixture(t, { fake, fetch: (url, options) => { requests.push({ url, method: options.method, authority: options.headers.Authorization }); return fake.fetch(url, options); } }); fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true }); await assert.rejects(create(), uncertain); w.broker.endLaunch({ business: 'demo', run: 'pm-run', reason: 'exited', exitCode: 1 }); const before = requests.length; await w.adapter.reconcileCreates('demo'); const recovery = requests.slice(before); assert.ok(recovery.length > 0); assert.ok(recovery.every(r => r.method === 'GET' && r.authority === `Bearer ${w.tokens.sync}`)); assert.equal(w.events('task.created')[0].actor_run, 'pm-run'); assert.equal(posts(), 1); }); test('a PM retry rechecks current task.create authority before resuming follow-up writes', async t => { const { w, create } = await fixture(t); const extras = { labels: [w.label] }; w.fake.fault('POST', /^\/tasks\/1\/labels$/, 500); await assert.rejects(create(extras)); w.businesses.demo.roles.pm.authority.withinRole = w.businesses.demo.roles.pm.authority.withinRole.filter(a => a !== 'task.create'); w.broker.reloadBusiness('demo', w.businesses.demo); const writes = () => w.fake.requests.filter(r => r.method === 'POST').length; const before = writes(); await assert.rejects(create(extras), e => e.code === 'decision-required'); assert.equal(writes(), before); });