import test from 'node:test'; import assert from 'node:assert/strict'; import { world } from './world.mjs'; async function fixture(t) { let now = Date.now(); const w = await world(t, { fake: { now: () => now }, adapter: { clock: () => now } }); await w.adapter.start('demo'); const owner = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'watched' }); assert.equal(owner.status, 201); await w.adapter.reconcile('demo'); assert.equal(w.store.get('SELECT count(*) n FROM task_comment_cursors').n, 0); const events = () => w.events('task.changed.external').filter(e => e.body.comments); return { w, events, advance: () => { now += 3 * 3600000; } }; } test('stopped bus resumes an empty task cursor after a long outage, exactly once', async t => { const { w, events, advance } = await fixture(t); await w.stop(); advance(); assert.equal((await w.ui('POST', '/tasks/1/comments', { comment: 'while bus stopped' })).status, 201); advance(); await w.restart(); assert.equal(events().length, 1); assert.equal(events()[0].body.comments, 1); await w.adapter.tick('demo'); await w.adapter.reconcile('demo'); assert.equal(events().length, 1); }); test('persisted IDs avoid replay, exclude bots and bound rows to observed new comments', async t => { const { w, events, advance } = await fixture(t); await w.ui('POST', '/tasks/1/comments', { comment: 'before restart' }); await w.adapter.reconcile('demo'); assert.equal(events().length, 1); const rows = () => w.store.get('SELECT count(*) n FROM task_comment_cursors').n; assert.equal(rows(), 1); await w.adapter.reconcile('demo'); await w.adapter.tick('demo'); assert.equal(rows(), 1); await w.stop(); advance(); const r = await w.fake.fetch('http://vikunja.test/api/v2/tasks/1/comments', { method: 'POST', headers: { authorization: 'Bearer ' + w.tokens.coder, 'content-type': 'application/json' }, body: JSON.stringify({ comment: 'bot while stopped' }), }); assert.equal(r.status, 201); await w.ui('POST', '/tasks/1/comments', { comment: 'human while stopped' }); advance(); await w.restart(); assert.equal(events().length, 2); assert.equal(events()[1].body.comments, 1); assert.equal(rows(), 2); await w.stop(); await w.restart(); await w.adapter.tick('demo'); assert.equal(events().length, 2); assert.equal(rows(), 2); }); test('commit followed by crash before cache advance neither loses nor duplicates a comment', async t => { const { w, events } = await fixture(t); await w.ui('POST', '/tasks/1/comments', { comment: 'committed before crash' }); const record = w.broker.recordTask.bind(w.broker); w.broker.recordTask = args => { record(args); throw Error('simulated crash after commit'); }; await assert.rejects(w.adapter.reconcile('demo'), /simulated crash/); assert.equal(events().length, 1); await w.stop(); await w.restart(); assert.equal(events().length, 1); await w.adapter.tick('demo'); assert.equal(events().length, 1); }); test('failed event insert rolls back cursor and a retry still emits the comment once', async t => { const { w, events } = await fixture(t); await w.ui('POST', '/tasks/1/comments', { comment: 'retry me' }); w.store.run("CREATE TRIGGER fixture_fail BEFORE INSERT ON events WHEN NEW.kind='task.changed.external' BEGIN SELECT RAISE(ABORT,'fixture failure'); END"); await assert.rejects(w.adapter.reconcile('demo'), /fixture failure/); w.store.run('DROP TRIGGER fixture_fail'); await w.adapter.reconcile('demo'); assert.equal(events().length, 1); assert.equal(events()[0].body.comments, 1); }); // Removing the epoch margin loses a tracker comment thirty seconds behind host time. test('initial cursor accepts a comment within the stated one-minute clock skew margin', async t => { let trackerNow = Date.now() - 30000; const skewed = await world(t, { fake: { now: () => trackerNow } }); await skewed.adapter.start('demo'); await skewed.ui('POST', `/projects/${skewed.project}/tasks`, { title: 'clock skew' }); await skewed.adapter.reconcile('demo'); await skewed.ui('POST', '/tasks/1/comments', { comment: 'thirty seconds behind' }); await skewed.adapter.reconcile('demo'); const found = skewed.events('task.changed.external').filter(e => e.body.comments); assert.equal(found.length, 1); assert.equal(found[0].body.comments, 1); await skewed.adapter.reconcile('demo'); assert.equal(skewed.events('task.changed.external').filter(e => e.body.comments).length, 1); });