// Red/Green coverage for `assertThatUserTask` (the `usertask-assert` slice).
//
// Boots a real Urban app in-process via `bootTestApp` against the WASM engine + virtual
// clock, drives a small BPMN with a native user task carrying an assignee and candidate
// groups, and proves EVERY matcher both PASSES on its positive case and FAILS (throwing an
// intent-revealing AssertionError) on its negative case. Deterministic: no wall-clock, no
// polling — every read is a direct (awaited) read-model query.
import assert from "node:assert/strict";
import { AssertionError } from "node:assert";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { assertThatUserTask } from "./user-task.ts";
import { byKey, byProcessId } from "../index.ts";
import { bootTestApp, type TestApp } from "../boot-app.ts";
// A process that parks on a single native user task assigned to `alice` and offered to the
// `reviewers` / `managers` candidate groups.
const REVIEW_BPMN = `
`;
const MIGRATION = `CREATE TABLE marker (id INTEGER PRIMARY KEY);`;
/** Build a minimal Urban app on disk: one user-task process plus a SQLite source (a data
* layer is required by `bootTestApp`). */
async function makeFixture(): Promise {
const dir = await mkdtemp(join(tmpdir(), "urban-testkit-usertask-"));
await mkdir(join(dir, "processes"), { recursive: true });
await mkdir(join(dir, "db", "migrations"), { recursive: true });
await writeFile(join(dir, "processes", "review.bpmn"), REVIEW_BPMN);
await writeFile(join(dir, "db", "migrations", "001_init.sql"), MIGRATION);
const manifest = {
schemaVersion: 1,
id: "testkit-usertask-fixture",
name: "Testkit UserTask Fixture",
models: { processes: ["processes/*.bpmn"] },
data: {
default: "app",
sources: {
app: { driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" },
},
},
};
await writeFile(join(dir, "nano.app.json"), JSON.stringify(manifest, null, 2));
return dir;
}
/** Start a `review` instance (parks CREATED at the `approve` user task) and return its key. */
async function startReview(app: TestApp): Promise {
const { processInstanceKey } = await app.engine.createInstance({ processDefinitionId: "review" });
return processInstanceKey;
}
/** Boot a fresh fixture app, run `body`, and always tear down the app + temp dir — even if
* `bootTestApp` itself throws, so the fixture directory never leaks. The app is booted inside
* the outer `try` and stopped only once it exists, keeping teardown resilient to a partially
* initialized boot. */
async function withReviewApp(body: (app: TestApp) => Promise): Promise {
const dir = await makeFixture();
try {
const app = await bootTestApp(dir);
try {
await body(app);
} finally {
await app.stop();
}
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** Assert `fn` throws a `node:assert` AssertionError whose message contains each `needle`. */
async function rejectsWith(fn: () => Promise, ...needles: string[]): Promise {
await assert.rejects(fn, (err: unknown) => {
assert.ok(err instanceof AssertionError, `expected an AssertionError, got ${String(err)}`);
for (const needle of needles) {
assert.ok(
err.message.includes(needle),
`expected failure message to include ${JSON.stringify(needle)}, got:\n${err.message}`,
);
}
return true;
});
}
test("assertThatUserTask.isCreated: passes for an open task, fails for a completed one", async () => {
await withReviewApp(async (app) => {
const key = await startReview(app);
// GREEN: the task is open (CREATED).
await assertThatUserTask(app, { instance: key, elementId: "approve" }).isCreated();
// Complete the task; the instance completes and the task leaves the open set.
const open = await app.engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
assert.equal(open.length, 1);
await app.engine.completeUserTask(open[0].userTaskKey);
// RED: the completed task is no longer CREATED.
await rejectsWith(
() => assertThatUserTask(app, { instance: key, elementId: "approve" }).isCreated(),
"to be CREATED",
"state: COMPLETED",
);
});
});
test("assertThatUserTask.isCompleted: passes for a completed task, fails for an open one", async () => {
await withReviewApp(async (app) => {
const key = await startReview(app);
// RED: the task is still open — not COMPLETED.
await rejectsWith(
() => assertThatUserTask(app, { instance: key, elementId: "approve" }).isCompleted(),
"to be COMPLETED",
"state: CREATED",
);
// Complete it.
const open = await app.engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
assert.equal(open.length, 1, "expected exactly one open CREATED task to complete");
await app.engine.completeUserTask(open[0].userTaskKey);
// GREEN: now COMPLETED.
await assertThatUserTask(app, { instance: key, elementId: "approve" }).isCompleted();
});
});
test("assertThatUserTask.hasAssignee: passes for the real assignee, fails otherwise", async () => {
await withReviewApp(async (app) => {
const key = await startReview(app);
// GREEN: the task is assigned to alice.
await assertThatUserTask(app, { instance: key, elementId: "approve" }).hasAssignee("alice");
// RED: not assigned to bob — the message names bob and the actual matching task.
await rejectsWith(
() => assertThatUserTask(app, { instance: key, elementId: "approve" }).hasAssignee("bob"),
'"bob"',
"elementId: \"approve\"",
);
});
});
test("assertThatUserTask.hasCandidateGroup: passes for an offered group, fails otherwise", async () => {
await withReviewApp(async (app) => {
const key = await startReview(app);
// GREEN: both declared candidate groups match.
await assertThatUserTask(app, { instance: key, elementId: "approve" }).hasCandidateGroup(
"reviewers",
);
await assertThatUserTask(app, { instance: key, elementId: "approve" }).hasCandidateGroup(
"managers",
);
// RED: an undeclared group does not match.
await rejectsWith(
() =>
assertThatUserTask(app, { instance: key, elementId: "approve" }).hasCandidateGroup("nope"),
'"nope"',
"candidate group",
);
});
});
test("assertThatUserTask: selectors (byKey / byProcessId / elementId) and chaining", async () => {
await withReviewApp(async (app) => {
const key = await startReview(app);
// byKey selector resolves the same task as the bare key.
await assertThatUserTask(app, { instance: byKey(key), elementId: "approve" }).isCreated();
// byProcessId selector (single instance of `review` → unambiguous).
await assertThatUserTask(app, { instance: byProcessId("review"), elementId: "approve" })
.isCreated();
// elementId-only selector (no instance) matches across the app's single task.
await assertThatUserTask(app, { elementId: "approve" }).isCreated();
// Chaining: await each async matcher in turn on the same fluent object.
const a = assertThatUserTask(app, { instance: key, elementId: "approve" });
await (await (await a.isCreated()).hasAssignee("alice")).hasCandidateGroup("reviewers");
});
});
test("assertThatUserTask: an unresolvable selector fails clearly", async () => {
await withReviewApp(async (app) => {
await startReview(app);
// No task for element `ghost` → isCreated fails naming the selector.
await rejectsWith(
() => assertThatUserTask(app, { elementId: "ghost" }).isCreated(),
"elementId \"ghost\"",
"no user task matches",
);
// An instance key that does not exist → the shared resolver throws.
await rejectsWith(
() => assertThatUserTask(app, { instance: "does-not-exist" }).isCreated(),
"Could not resolve a process instance",
);
});
});