Files
stack/packages/tasks/tests/create-recovery.test.mjs

285 lines
14 KiB
JavaScript

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);
});