// e2e + unit coverage for the S3 spec-driven HTTP operations driver (issue #157). // // The driver reads the booted app's OWN OpenAPI document and lets a test call operations by // `operationId` — never a hard-coded route. These tests boot a real Urban app with an `api` binding // (both a JSON and a YAML spec, since a real app like nano-workforce authors `openapi.yaml`), drive // its operations end-to-end (HTTP → operation delegate → engine + SQLite), and pin the driver's // pure enumeration/error surfaces. import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stringify as toYaml } from "yaml"; import { bootTestApp, resolveSpecPath } from "./boot-app.ts"; import { collectOperations, parseOpenApi } from "./openapi-driver.ts"; const ORDER_BPMN = ` `; const HANDLERS = `export const handlers = { "order.pack": async (job, app) => { // Project onto the business row the operation inserted before starting us (keyed by \`item\`), // stamping the process key. The WASM engine drives this worker synchronously within // createInstance, so the operation must insert the row FIRST — exactly as a real app does. await app.data.table("orders", "item").update(String(job.variables.item), { process_key: String(job.processInstanceKey), status: "packed", }); return { packed: true }; }, };`; const MIGRATION = `CREATE TABLE orders ( id INTEGER PRIMARY KEY, item TEXT, process_key TEXT, status TEXT );`; // A start operation: FLAT body (no engine "variables" envelope), inserts the business row FIRST, // then starts the \`order\` process — the ADR-0058/0059 "one door" pattern nano-workforce's // startConvergenceLoop uses (insert the aggregate row, then create the instance). Returns 202. const CREATE_ORDER = `export default async ({ body }, app) => { const item = String((body && body.item) || "").trim(); if (!item) return { status: 400, body: { error: "item is required" } }; await app.data.table("orders", "item").insert({ item, process_key: null, status: "active" }); const { processInstanceKey } = await app.engine.createInstance({ processDefinitionId: "order", variables: { item }, }); return { status: 202, body: { processInstanceKey: String(processInstanceKey), item } }; };`; // A read operation with a PATH parameter — exercises template filling. const GET_ORDER = `export default async ({ params }, app) => { const row = await app.data.table("orders", "item").findOne({ item: params.item }); if (!row) return { status: 404, body: { error: "not found" } }; return { status: 200, body: row }; };`; function openApiDoc(): unknown { const orderSchema = { type: "object", properties: { id: { type: "integer" }, process_key: { type: "string" }, item: { type: "string" }, status: { type: "string" }, }, }; return { openapi: "3.0.3", info: { title: "Testkit API Fixture", version: "1.0.0" }, paths: { "/orders": { post: { operationId: "createOrder", requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { item: { type: "string" } }, required: ["item"], }, }, }, }, responses: { "202": { description: "started", content: { "application/json": { schema: { type: "object", properties: { processInstanceKey: { type: "string" }, item: { type: "string" } }, }, }, }, }, "400": { description: "bad request" }, }, }, }, "/orders/{item}": { get: { operationId: "getOrder", parameters: [{ name: "item", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "the order", content: { "application/json": { schema: orderSchema } }, }, "404": { description: "not found" }, }, }, }, }, }; } /** Build a fixture app whose HTTP surface is an OpenAPI `api` binding. `specFile` selects the spec * format written to disk (`openapi.json` or `openapi.yaml`) so both parser paths are exercised. */ async function makeApiFixture(specFile: "openapi.json" | "openapi.yaml"): Promise { const dir = await mkdtemp(join(tmpdir(), "urban-testkit-api-")); await mkdir(join(dir, "processes"), { recursive: true }); await mkdir(join(dir, "workers"), { recursive: true }); await mkdir(join(dir, "operations"), { recursive: true }); await mkdir(join(dir, "db", "migrations"), { recursive: true }); await writeFile(join(dir, "processes", "order.bpmn"), ORDER_BPMN); await writeFile(join(dir, "workers", "handlers.ts"), HANDLERS); await writeFile(join(dir, "operations", "createOrder.ts"), CREATE_ORDER); await writeFile(join(dir, "operations", "getOrder.ts"), GET_ORDER); await writeFile(join(dir, "db", "migrations", "001_init.sql"), MIGRATION); const doc = openApiDoc(); await writeFile(join(dir, specFile), specFile.endsWith(".yaml") ? toYaml(doc) : JSON.stringify(doc, null, 2)); const manifest = { schemaVersion: 1, id: "testkit-api-fixture", name: "Testkit API Fixture", models: { processes: ["processes/*.bpmn"] }, data: { default: "app", sources: { app: { driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" } }, }, workers: [{ taskType: "order.pack", handler: "workers/handlers.ts" }], api: { spec: specFile, dir: "operations", validateResponses: "never" }, }; await writeFile(join(dir, "nano.app.json"), JSON.stringify(manifest, null, 2)); return dir; } test("api driver: enumerates the app's operations from its own spec", async () => { const dir = await makeApiFixture("openapi.json"); const app = await bootTestApp(dir); try { assert.ok(app.api, "an app with an `api` binding exposes the driver"); assert.deepEqual(app.api.operationIds().sort(), ["createOrder", "getOrder"]); const getOrder = app.api.operation("getOrder"); assert.equal(getOrder?.method, "get"); assert.deepEqual(getOrder?.pathParams, ["item"]); } finally { await app.stop(); await rm(dir, { recursive: true, force: true }); } }); test("api driver: call(operationId) drives HTTP → operation → engine + SQLite", async () => { const dir = await makeApiFixture("openapi.json"); const app = await bootTestApp(dir); try { assert.ok(app.api); // POST createOrder: JSON body is serialized, /app/api base + path are derived from the spec. // The operation inserts the row, then starts the process; the WASM engine drives the service // task's worker synchronously within createInstance, so the projection is already applied here. const created = await app.api.call<{ processInstanceKey: string; item: string }>("createOrder", { body: { item: "widget" }, }); assert.equal(created.status, 202); assert.equal(created.body.item, "widget"); const key = created.body.processInstanceKey; assert.ok(key, "the operation returned the started instance key"); // GET getOrder with a PATH parameter — the driver fills `{item}` from `params`. const fetched = await app.api.call<{ status: string; item: string; process_key: string }>( "getOrder", { params: { item: "widget" } }, ); assert.equal(fetched.status, 200); assert.equal(fetched.body.item, "widget"); assert.equal(fetched.body.status, "packed", "worker projection is visible through the operation"); assert.equal(fetched.body.process_key, key, "worker stamped the started instance's key"); } finally { await app.stop(); await rm(dir, { recursive: true, force: true }); } }); test("api driver: a request-validation failure surfaces as the operation's own error", async () => { const dir = await makeApiFixture("openapi.json"); const app = await bootTestApp(dir); try { assert.ok(app.api); // `item` is required by both the schema and the delegate; an empty item is a 400, not a throw. const res = await app.api.call<{ error: string }>("createOrder", { body: { item: "" } }); assert.ok(res.status === 400, `empty item rejected (status ${res.status})`); } finally { await app.stop(); await rm(dir, { recursive: true, force: true }); } }); test("api driver: unknown operationId and missing path param throw a test-bug error", async () => { const dir = await makeApiFixture("openapi.json"); const app = await bootTestApp(dir); try { assert.ok(app.api); await assert.rejects(() => app.api!.call("noSuchOp"), /unknown operationId "noSuchOp"/); await assert.rejects(() => app.api!.call("getOrder", {}), /missing path parameter "item"/); } finally { await app.stop(); await rm(dir, { recursive: true, force: true }); } }); test("api driver: parses a YAML spec (openapi.yaml), matching real-app authoring", async () => { const dir = await makeApiFixture("openapi.yaml"); const app = await bootTestApp(dir); try { assert.ok(app.api, "a YAML-spec app still exposes the driver"); assert.deepEqual(app.api.operationIds().sort(), ["createOrder", "getOrder"]); const created = await app.api.call<{ processInstanceKey: string }>("createOrder", { body: { item: "gadget" }, }); assert.equal(created.status, 202); assert.ok(created.body.processInstanceKey); } finally { await app.stop(); await rm(dir, { recursive: true, force: true }); } }); test("api driver: callRoute drives a raw path and parses the JSON response", async () => { const dir = await makeApiFixture("openapi.json"); const app = await bootTestApp(dir); try { // callRoute takes an EXACT path (no /app/api base added) — here the operation's full route. const res = await app.callRoute<{ error: string }>({ method: "POST", path: "/app/api/orders", headers: { "content-type": "application/json" }, body: JSON.stringify({ item: "" }), }); assert.equal(res.status, 400); assert.equal(res.body.error, "item is required"); } finally { await app.stop(); await rm(dir, { recursive: true, force: true }); } }); test("api driver: absent when the app declares no `api` binding, but callRoute still works", async () => { const dir = await mkdtemp(join(tmpdir(), "urban-testkit-noapi-")); await mkdir(join(dir, "processes"), { recursive: true }); await writeFile(join(dir, "processes", "order.bpmn"), ORDER_BPMN); const manifest = { schemaVersion: 1, id: "testkit-noapi-fixture", name: "No API", models: { processes: ["processes/*.bpmn"] }, triggers: [ { id: "h", type: "webhook", path: "/hooks/order", action: { start: "order", variables: "= body" } }, ], }; await writeFile(join(dir, "nano.app.json"), JSON.stringify(manifest, null, 2)); const app = await bootTestApp(dir); try { assert.equal(app.api, undefined, "no `api` binding → no driver"); const res = await app.callRoute({ method: "POST", path: "/hooks/order", headers: { "content-type": "application/json" }, body: JSON.stringify({ item: "x" }), }); assert.ok((res.status ?? 200) < 300, "callRoute still drives raw routes without an api binding"); } finally { await app.stop(); await rm(dir, { recursive: true, force: true }); } }); // --- pure-function coverage (no boot): the enumerator + parser, the driver's source of truth. --- test("collectOperations: enumerates operationId/method/path and path params, sorted", () => { const ops = collectOperations(openApiDoc()); assert.deepEqual( ops.map((o) => `${o.method} ${o.path} ${o.operationId}`), ["post /orders createOrder", "get /orders/{item} getOrder"], ); assert.deepEqual(ops.find((o) => o.operationId === "getOrder")?.pathParams, ["item"]); }); test("collectOperations: skips operations without an operationId and non-object paths", () => { const ops = collectOperations({ paths: { "/a": { get: { operationId: "a" }, post: { summary: "no id" } }, "/b": "not an object", }, }); assert.deepEqual(ops.map((o) => o.operationId), ["a"]); }); test("collectOperations: skips unsafe operationIds the runtime would never mount", () => { // An operationId names the delegate module file, so the runtime (isSafeOperationId) rejects path // separators and parent-dir traversal. The driver must mirror that or it would list/call an // operation that was never mounted — a drift from the surface it drives. const ops = collectOperations({ paths: { "/safe": { get: { operationId: "getSafe" } }, "/traversal": { get: { operationId: "../secret" } }, "/slash": { get: { operationId: "a/b" } }, "/backslash": { post: { operationId: "a\\b" } }, "/dot": { get: { operationId: "." } }, }, }); assert.deepEqual(ops.map((o) => o.operationId), ["getSafe"]); }); test("parseOpenApi: reads JSON and YAML, and throws a clear error on garbage", () => { const readOpenApiVersion = (doc: unknown): unknown => doc !== null && typeof doc === "object" ? Reflect.get(doc, "openapi") : undefined; assert.equal(readOpenApiVersion(parseOpenApi('{"openapi":"3.0.3"}')), "3.0.3"); assert.equal(readOpenApiVersion(parseOpenApi("openapi: 3.0.3")), "3.0.3"); assert.throws(() => parseOpenApi("{ this: is: not: valid"), /not valid JSON or YAML/); }); test("parseOpenApi: rejects a non-object root (matching runtime's parseSpec guard)", () => { // A scalar/array/null root would silently enumerate zero operations, surfacing later as a // confusing "unknown operationId"; fail fast here exactly as the runtime rejects the spec. assert.throws(() => parseOpenApi("42"), /must be an object/); assert.throws(() => parseOpenApi("[]"), /must be an object/); assert.throws(() => parseOpenApi("null"), /must be an object/); assert.throws(() => parseOpenApi('"just a string"'), /must be an object/); }); test("resolveSpecPath: mirrors runtime resolveAppPath (POSIX, Windows/UNC absolutes, no mixed separators)", () => { // Relative specs join onto the root using the root's own separator. assert.equal(resolveSpecPath("/srv/app", "openapi.yaml"), "/srv/app/openapi.yaml"); assert.equal(resolveSpecPath("/srv/app/", "api/openapi.yaml"), "/srv/app/api/openapi.yaml"); // A backslash root joins with backslash, and the relative segment is normalized to match (no mix). assert.equal(resolveSpecPath("C:\\app", "api/openapi.yaml"), "C:\\app\\api\\openapi.yaml"); assert.equal(resolveSpecPath("C:\\app\\", "openapi.yaml"), "C:\\app\\openapi.yaml"); // Absolute specs are returned as-is: POSIX root, drive-letter roots, UNC, and single-backslash root. assert.equal(resolveSpecPath("/srv/app", "/data/spec.yaml"), "/data/spec.yaml"); assert.equal(resolveSpecPath("/srv/app", "C:\\data\\spec.yaml"), "C:\\data\\spec.yaml"); assert.equal(resolveSpecPath("/srv/app", "c:/data/spec.yaml"), "c:/data/spec.yaml"); assert.equal(resolveSpecPath("/srv/app", "\\\\server\\share\\spec.yaml"), "\\\\server\\share\\spec.yaml"); assert.equal(resolveSpecPath("/srv/app", "\\data\\spec.yaml"), "\\data\\spec.yaml"); });