import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { world, sleep } from './world.mjs'; import { taskFields } from '../src/digest.mjs'; import { FakeVikunja } from '../src/fake.mjs'; const recorded = JSON.parse(readFileSync(new URL('./fixtures/v2-shapes.json', import.meta.url))); delete recorded.$comment; // Each recording, by the fake's route name and status. 401 bodies come from the auth layer on any route. const KEY = { 'GET /projects/{p}/views': 'views 200', 'GET /projects/{p}/views/{k}/buckets': 'buckets 200', 'GET /labels (pm)': 'labels 200', 'GET board (sync)': 'board 200', 'GET /tasks/{missing}': 'read 404', '401 scope': '* 401', '401 expired': '* 401', 'POST /tasks/{t}/comments': 'comment 201', 'GET /tasks/{t}/comments': 'comments 200', 'POST /tasks/{t}/relations': 'relate 201', 'DELETE unassigned assignee': 'unassign 204', 'DELETE absent label': 'labelRemove 403', 'GET /projects/{p}': 'project 200', 'POST /projects/{p}/tasks': 'create 201', 'PATCH /tasks/{t}': 'patch 200', 'PATCH bucket_id': 'patch 200', 'PUT move': 'move 200', 'PUT move into done': 'move 200', 'GET /tasks/{t}': 'read 200', 'GET cursor': 'list 200', 'GET reconcile': 'list 200', 'GET task moved away': 'read 403', 'GET deleted task': 'read 404', 'PATCH bucket_id (b)': 'patch 200', 'POST /tasks/{t}/assignees': 'assign 201', 'DELETE /tasks/{t}/assignees/{u}': 'unassign 500', 'POST /tasks/{t}/labels': 'labelAdd 201', 'DELETE /tasks/{t}/labels/{l}': 'labelRemove 204', 'DELETE /tasks/{t}/relations': 'unrelate 404', 'GET /tasks/{t} 403': 'read 403', 'GET /projects/{p} 403': 'project 403', 'GET /tasks/{t} related': 'read 200', 'GET cursor related': 'list 200', 'GET board related': 'board 200', }; // The scenario below does not produce these; a test elsewhere or the probe notes cover each. const UNPRODUCED = new Set([ // A 500 on removing an assignee is a recorded Vikunja fault the fake raises only through fault(). 'unassign 500', // S3 never removes a relation that is absent. 'unrelate 404', ]); const samples = {}; for (const [name, r] of Object.entries(recorded)) { assert.ok(KEY[name], `recording ${name} has no route`); assert.equal(String(r.status), KEY[name].split(' ')[1], name); (samples[KEY[name]] ??= []).push(r.body); } // A user or task object has one shape whatever the route, though one recording may hold a null // where another holds a value: Vikunja leaves bot_owner_id out for a person, and sends null for a // task with no assignees. So users and tasks are checked against every one any recording sent. // The cost: a key one route adds, like comment_count or max_permission, passes on any route. const isUser = (v) => 'username' in v; const isTask = (v) => 'project_id' in v && 'done' in v && 'percent_done' in v; const users = [], tasks = []; const collect = (v) => { if (Array.isArray(v)) v.forEach(collect); else if (v && typeof v === 'object') { if (isUser(v)) users.push(v); if (isTask(v)) tasks.push(v); Object.values(v).forEach(collect); } }; collect(Object.values(recorded).map((r) => r.body)); // Every key the fake sends must be one Vikunja sent at that place, with the same JSON type. The fake // may leave keys out, and may send null where Vikunja sent a value: Vikunja sends null for empty lists. function compare(fake, real, at, problems) { if (fake === null || fake === undefined) return; if (typeof fake === 'object' && !Array.isArray(fake)) { if (isUser(fake)) real = users; else if (isTask(fake)) real = tasks; } const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v); const seen = real.filter((v) => v !== null && v !== undefined); if (!seen.length) return problems.push(`${at}: Vikunja sent no value here`); if (!seen.some((v) => kind(v) === kind(fake))) return problems.push(`${at}: ${kind(fake)}, Vikunja sent ${kind(seen[0])}`); if (Array.isArray(fake)) { const items = seen.filter(Array.isArray).flat(); for (const x of fake) compare(x, items.length ? items : [null], at + '[]', problems); } else if (typeof fake === 'object') { const objects = seen.filter((v) => kind(v) === 'object'); for (const [k, v] of Object.entries(fake)) { if (k === '$schema') continue; if (!objects.some((o) => k in o)) problems.push(`${at}.${k}: Vikunja sent no such key`); else compare(v, objects.map((o) => o[k]), `${at}.${k}`, problems); } } } test('the fake answers each route with the statuses and shapes Vikunja v2.7.0 sent', async (t) => { const seen = []; let w; const capture = async (url, init) => { const r = await w.fake.fetch(url, init); const route = w.fake.requests.at(-1); const text = await r.clone().text(); seen.push({ route, body: text ? JSON.parse(text) : null }); return r; }; w = await world(t, { fetch: capture }); // The route name, from the fake's table, for each recorded request. const name = (r) => w.fake.routeName(r.method, r.path); await w.adapter.start('demo'); const pm = w.agent('pm'), coder = w.agent('coder'); const a = await w.call(pm, 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' }); const b = await w.call(pm, 'task.create', { title: 'b', request: w.instruction(), requirement: 'REQ-S-1', labels: [w.label], relations: [{ kind: 'blocked', task_ref: a.task_ref }], }); await w.call(pm, 'task.assign', { task_ref: a.task_ref, role: 'coder' }); await w.call(coder, 'task.update.assigned', { task_ref: a.task_ref, state: 'in-progress', percent_done: 0.5, comment: 'c' }); await w.call(pm, 'task.schedule', { task_ref: b.task_ref, labels: { remove: [w.label] }, due_date: '2026-11-01T00:00:00.000Z' }); await w.call(pm, 'task.reassign', { task_ref: a.task_ref, role: 'reviewer' }); await w.call(pm, 'task.close', { task_ref: b.task_ref, verdict: 'v' }); await w.ui('POST', '/tasks/1/comments', { comment: 'person' }); await sleep(20); await w.adapter.tick('demo'); await w.adapter.reconcile('demo'); // The label probe's 403 and the sync bot's view of a task in a project not shared with it. await capture(`http://vikunja.test/api/v2/tasks/1/labels/${w.label}`, { method: 'DELETE', headers: { authorization: 'Bearer ' + w.tokens.pm }, }); const hidden = w.fake.project('Launchpad', w.owner); w.fake.moveToProject(1, hidden); await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: null }).catch(() => {}); await capture(`http://vikunja.test/api/v2/projects/${hidden}`, { headers: { authorization: 'Bearer ' + w.tokens.sync } }); assert.ok(seen.length > 0); const key = (r) => `${r.status === 401 ? '*' : name(r)} ${r.status}`; for (const { route, body } of seen) { const k = key(route); // The startup version read has no recording to compare against. if (!samples[k] && k.startsWith('info ')) continue; assert.ok(samples[k], `the fake answered ${route.method} ${route.path} with ${route.status}, which Vikunja never sent`); const problems = []; compare(body, samples[k], k, problems); assert.deepEqual(problems, [], `${route.method} ${route.path}`); } const covered = new Set(seen.map((s) => key(s.route))); for (const key of new Set(Object.values(KEY))) if (!UNPRODUCED.has(key)) assert.ok(covered.has(key), `the scenario never produced ${key}`); }); test('the recorded task bodies pass the checks S3 applies to every read', () => { const tasks = []; for (const [name, r] of Object.entries(recorded)) { const b = r.body; if (r.status >= 400 || !b || typeof b !== 'object') continue; if ('project_id' in b && 'done' in b) tasks.push([name, b]); if (b.task) tasks.push([name + ' .task', b.task]); for (const x of b.items ?? []) { if ('done' in x) tasks.push([name + ' item', x]); for (const y of x.tasks ?? []) tasks.push([name + ' board task', y]); } } assert.ok(tasks.length >= 8, `${tasks.length} task bodies`); for (const [name, task] of tasks) assert.doesNotThrow(() => taskFields(task, 1), name); assert.ok(tasks.some(([, x]) => Number.isSafeInteger(x.comment_count)), 'reconcile reads comment_count'); }); test('the client works against the fake over real HTTP with the platform fetch', async (t) => { const fake = new FakeVikunja(); const server = await fake.listen(); t.after(server.close); const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } }); await w.adapter.start('demo'); assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused); const r = await w.call(w.agent('pm'), 'task.create', { title: 'over http', request: w.instruction(), requirement: 'REQ-S-1' }); assert.equal(r.task_ref, `vikunja:${w.project}/1`); await w.ui('PATCH', '/tasks/1', { title: 'edited' }); await sleep(20); assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 1, events: 1 }); });