packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).
Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).
Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
186 lines
8.8 KiB
JavaScript
186 lines
8.8 KiB
JavaScript
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 });
|
|
});
|