From 476da2d1568fda9acfeed3eb877079e7fdbc8d95 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 13 Mar 2026 08:31:58 -0700 Subject: [PATCH] feat: add CI workflow and unit tests for utility functions --- .github/workflows/ci.yml | 29 ++ apim-github-action-demo.code-workspace | 3 + dist/index.js | 36 +- package.json | 3 +- src/index.js | 12 +- src/openapi.js | 4 + src/utils.js | 14 + test/openapi.test.js | 640 +++++++++++++++++++++++++ test/utils.test.js | 60 +++ 9 files changed, 778 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/utils.js create mode 100644 test/openapi.test.js create mode 100644 test/utils.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ba6db9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build + run: npm run build \ No newline at end of file diff --git a/apim-github-action-demo.code-workspace b/apim-github-action-demo.code-workspace index 876a149..fbf7377 100644 --- a/apim-github-action-demo.code-workspace +++ b/apim-github-action-demo.code-workspace @@ -2,6 +2,9 @@ "folders": [ { "path": "." + }, + { + "path": "../apim-github-action-demo-demo" } ], "settings": {} diff --git a/dist/index.js b/dist/index.js index e679560..8654923 100644 --- a/dist/index.js +++ b/dist/index.js @@ -687,9 +687,33 @@ function resolvePointer(root, pointer) { module.exports = { buildProjectFromOpenApi, + normalizeKeyStr, + selectOperations, + pickBaseServer, + mergeProject, }; +/***/ }), + +/***/ 880: +/***/ ((module) => { + + + +function parseBoolean(value, defaultValue) { + if (value === "") return defaultValue; + return ["1", "true", "yes", "on"].includes(value.toLowerCase()); +} + +function summarizeDocument(document) { + if (!document || typeof document !== "object") return ""; + const topKeys = Object.keys(document).slice(0, 12); + return `keys=${topKeys.join(",")}`; +} + +module.exports = { parseBoolean, summarizeDocument }; + /***/ }) /******/ }); @@ -742,6 +766,7 @@ const os = __nccwpck_require__(37); const { execFile } = __nccwpck_require__(81); const { promisify } = __nccwpck_require__(837); const { buildProjectFromOpenApi } = __nccwpck_require__(465); +const { parseBoolean, summarizeDocument } = __nccwpck_require__(880); const execFileAsync = promisify(execFile); @@ -754,11 +779,6 @@ function getInput(name, options = {}) { return value || ""; } -function parseBoolean(value, defaultValue) { - if (value === "") return defaultValue; - return ["1", "true", "yes", "on"].includes(value.toLowerCase()); -} - function fileExists(filePath) { try { fs.accessSync(filePath, fs.constants.F_OK); @@ -961,12 +981,6 @@ async function uploadDocument(document, token, endpoint) { return response.text(); } -function summarizeDocument(document) { - if (!document || typeof document !== "object") return ""; - const topKeys = Object.keys(document).slice(0, 12); - return `keys=${topKeys.join(",")}`; -} - async function run() { const fileInput = getInput("file"); const templateInput = getInput("template"); diff --git a/package.json b/package.json index 14b8b50..632f293 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "type": "commonjs", "main": "dist/index.js", "scripts": { - "build": "ncc build src/index.js -o dist" + "build": "ncc build src/index.js -o dist", + "test": "node --test test/openapi.test.js test/utils.test.js" }, "devDependencies": { "@vercel/ncc": "0.38.1" diff --git a/src/index.js b/src/index.js index ca9a690..7845437 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ const os = require("os"); const { execFile } = require("child_process"); const { promisify } = require("util"); const { buildProjectFromOpenApi } = require("./openapi"); +const { parseBoolean, summarizeDocument } = require("./utils"); const execFileAsync = promisify(execFile); @@ -19,11 +20,6 @@ function getInput(name, options = {}) { return value || ""; } -function parseBoolean(value, defaultValue) { - if (value === "") return defaultValue; - return ["1", "true", "yes", "on"].includes(value.toLowerCase()); -} - function fileExists(filePath) { try { fs.accessSync(filePath, fs.constants.F_OK); @@ -226,12 +222,6 @@ async function uploadDocument(document, token, endpoint) { return response.text(); } -function summarizeDocument(document) { - if (!document || typeof document !== "object") return ""; - const topKeys = Object.keys(document).slice(0, 12); - return `keys=${topKeys.join(",")}`; -} - async function run() { const fileInput = getInput("file"); const templateInput = getInput("template"); diff --git a/src/openapi.js b/src/openapi.js index dd915b4..3987a2a 100644 --- a/src/openapi.js +++ b/src/openapi.js @@ -638,4 +638,8 @@ function resolvePointer(root, pointer) { module.exports = { buildProjectFromOpenApi, + normalizeKeyStr, + selectOperations, + pickBaseServer, + mergeProject, }; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..55fa53f --- /dev/null +++ b/src/utils.js @@ -0,0 +1,14 @@ +"use strict"; + +function parseBoolean(value, defaultValue) { + if (value === "") return defaultValue; + return ["1", "true", "yes", "on"].includes(value.toLowerCase()); +} + +function summarizeDocument(document) { + if (!document || typeof document !== "object") return ""; + const topKeys = Object.keys(document).slice(0, 12); + return `keys=${topKeys.join(",")}`; +} + +module.exports = { parseBoolean, summarizeDocument }; \ No newline at end of file diff --git a/test/openapi.test.js b/test/openapi.test.js new file mode 100644 index 0000000..a15fb02 --- /dev/null +++ b/test/openapi.test.js @@ -0,0 +1,640 @@ +"use strict"; + +const { describe, test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + buildProjectFromOpenApi, + normalizeKeyStr, + selectOperations, + pickBaseServer, + mergeProject, +} = require("../src/openapi"); + +function spec(paths = {}, extra = {}) { + return { + openapi: "3.0.3", + info: { title: "Test API", version: "1.0.0" }, + servers: [{ url: "https://api.example.com/v1" }], + paths, + ...extra, + }; +} + +function rawOp(method, path, operationId, extraOperation = {}, parameters = []) { + return { + method: method.toUpperCase(), + path, + operation: { operationId, ...extraOperation }, + parameters, + }; +} + +function baseDoc(calls = [], workflows = []) { + return { version: "2", project: { meta: {}, calls, workflows } }; +} + +function genCall(id, tag = "openapi-sync") { + return { + id, + meta: { name: id, description: null, tags: [tag] }, + request: { method: "GET", url: { scheme: "https", hostname: "example.com", port: null, path: "/" }, body: null }, + }; +} + +function genWorkflow(id, callIds, tag = "openapi-sync") { + return { + id, + meta: { name: id, description: null, tags: [tag] }, + workflow: { call_ids: callIds, stop_on_failure: true }, + }; +} + +describe("normalizeKeyStr", () => { + test("output matches key_str pattern", () => { + const inputs = ["get-/pets", "POST /users/{id}", "delete-/a/b/c", "PATCH /long/path"]; + for (const input of inputs) { + const id = normalizeKeyStr(input); + assert.match(id, /^[-a-z0-9_~]+$/, `bad chars for \"${input}\": got \"${id}\"`); + assert.ok(id.length >= 3, `ID too short for \"${input}\"`); + assert.ok(id.length <= 400, `ID too long for \"${input}\"`); + } + }); + + test("is deterministic", () => { + const a = normalizeKeyStr("get-/pets/{petId}"); + const b = normalizeKeyStr("get-/pets/{petId}"); + assert.equal(a, b); + }); + + test("different inputs produce different IDs", () => { + assert.notEqual(normalizeKeyStr("get-/pets"), normalizeKeyStr("post-/pets")); + assert.notEqual(normalizeKeyStr("get-/pets"), normalizeKeyStr("get-/cats")); + }); + + test("very short slug is padded with id- prefix", () => { + const id = normalizeKeyStr("x"); + assert.ok(id.startsWith("id-")); + assert.ok(id.length >= 3); + }); + + test("special characters are slugified", () => { + const id = normalizeKeyStr("GET /users/{id}/posts?include=comments"); + assert.match(id, /^[-a-z0-9_~]+$/); + }); +}); + +describe("buildProjectFromOpenApi - validation", () => { + test("throws if document is null", () => { + assert.throws(() => buildProjectFromOpenApi(null, {}), /must be an object/); + }); + + test("throws if document is a string", () => { + assert.throws(() => buildProjectFromOpenApi("string", {}), /must be an object/); + }); + + test("throws if neither openapi nor swagger key is present", () => { + assert.throws(() => buildProjectFromOpenApi({ paths: {} }, {}), /'openapi' or 'swagger'/); + }); + + test("throws if paths is missing", () => { + assert.throws(() => buildProjectFromOpenApi({ openapi: "3.0.3" }, {}), /valid 'paths' object/); + }); + + test("throws if mapping document is not an object", () => { + assert.throws( + () => buildProjectFromOpenApi({ openapi: "3.0.3", paths: {} }, "invalid"), + /mapping document must be an object/ + ); + }); + + test("accepts swagger 2.0 document", () => { + const result = buildProjectFromOpenApi({ swagger: "2.0", host: "api.example.com", paths: {} }, {}); + assert.equal(result.summary.selectedOperations, 0); + }); + + test("empty paths object produces zero calls", () => { + const result = buildProjectFromOpenApi(spec({}), {}); + assert.equal(result.summary.selectedOperations, 0); + assert.equal(result.document.project.calls.length, 0); + }); +}); + +describe("operation extraction", () => { + test("all seven HTTP methods are extracted", () => { + const paths = { + "/r": { + get: { operationId: "a", responses: {} }, + post: { operationId: "b", responses: {} }, + put: { operationId: "c", responses: {} }, + patch: { operationId: "d", responses: {} }, + delete: { operationId: "e", responses: {} }, + head: { operationId: "f", responses: {} }, + options: { operationId: "g", responses: {} }, + }, + }; + assert.equal(buildProjectFromOpenApi(spec(paths), {}).summary.selectedOperations, 7); + }); + + test("non-HTTP path item keys are ignored", () => { + const paths = { + "/pets": { + summary: "ignored", + description: "ignored", + parameters: [{ name: "q", in: "query", required: false }], + get: { operationId: "listPets", responses: {} }, + }, + }; + assert.equal(buildProjectFromOpenApi(spec(paths), {}).summary.selectedOperations, 1); + }); + + test("generated call IDs are sorted", () => { + const paths = { + "/z": { get: { operationId: "zGet", responses: {} } }, + "/a": { + post: { operationId: "aPost", responses: {} }, + get: { operationId: "aGet", responses: {} }, + }, + }; + const ids = buildProjectFromOpenApi(spec(paths), {}).summary.generatedCallIds; + assert.deepEqual(ids, [...ids].sort()); + }); + + test("path-level parameters are inherited", () => { + const paths = { + "/pets/{petId}": { + parameters: [{ name: "petId", in: "path", required: true, example: "abc" }], + get: { operationId: "getPet", responses: {} }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.url.path, "/v1/pets/abc"); + }); + + test("operation-level parameter overrides path-level parameter", () => { + const paths = { + "/pets/{petId}": { + parameters: [{ name: "petId", in: "path", required: true, example: "path-level" }], + get: { + operationId: "getPet", + parameters: [{ name: "petId", in: "path", required: true, example: "op-level" }], + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.url.path, "/v1/pets/op-level"); + }); + + test("component parameter refs are resolved", () => { + const s = spec( + { + "/pets": { + get: { + operationId: "listPets", + parameters: [{ $ref: "#/components/parameters/LimitParam" }], + responses: {}, + }, + }, + }, + { + components: { + parameters: { + LimitParam: { name: "limit", in: "query", required: false, example: 50 }, + }, + }, + } + ); + const call = buildProjectFromOpenApi(s, {}).document.project.calls[0]; + assert.equal(call.request.parameters.limit, "50"); + }); +}); + +describe("selectOperations", () => { + const ops = [ + rawOp("GET", "/pets", "listPets"), + rawOp("POST", "/pets", "createPet"), + rawOp("DELETE", "/pets/{id}", "deletePet"), + ]; + + test("no filters returns all operations", () => { + assert.equal(selectOperations(ops, {}).length, 3); + }); + + test("include by method/path string", () => { + const selected = selectOperations(ops, { include: ["GET /pets"] }); + assert.equal(selected.length, 1); + assert.equal(selected[0].operation.operationId, "listPets"); + }); + + test("include by operationId string", () => { + const selected = selectOperations(ops, { include: ["createPet"] }); + assert.equal(selected.length, 1); + assert.equal(selected[0].operation.operationId, "createPet"); + }); + + test("include by object operationId", () => { + const selected = selectOperations(ops, { include: [{ operationId: "deletePet" }] }); + assert.equal(selected.length, 1); + assert.equal(selected[0].operation.operationId, "deletePet"); + }); + + test("include by object method", () => { + const selected = selectOperations(ops, { include: [{ method: "GET" }] }); + assert.equal(selected.length, 1); + assert.equal(selected[0].method, "GET"); + }); + + test("include by object path", () => { + const selected = selectOperations(ops, { include: [{ path: "/pets" }] }); + assert.equal(selected.length, 2); + }); + + test("multiple include selectors are OR", () => { + const selected = selectOperations(ops, { include: ["GET /pets", "createPet"] }); + assert.equal(selected.length, 2); + }); + + test("exclude by method/path string", () => { + const selected = selectOperations(ops, { exclude: ["DELETE /pets/{id}"] }); + assert.equal(selected.length, 2); + assert.ok(!selected.find((o) => o.operation.operationId === "deletePet")); + }); + + test("exclude by operationId string", () => { + const selected = selectOperations(ops, { exclude: ["listPets"] }); + assert.ok(!selected.find((o) => o.operation.operationId === "listPets")); + }); + + test("include and overlapping exclude favors exclude", () => { + const selected = selectOperations(ops, { + include: ["GET /pets", "POST /pets"], + exclude: ["POST /pets"], + }); + assert.equal(selected.length, 1); + assert.equal(selected[0].operation.operationId, "listPets"); + }); + + test("operations include=false suppresses operation", () => { + const selected = selectOperations(ops, { + operations: [{ operationId: "createPet", include: false }], + }); + assert.ok(!selected.find((o) => o.operation.operationId === "createPet")); + }); + + test("unknown selector matches nothing", () => { + const selected = selectOperations(ops, { include: [{ operationId: "nonExistent" }] }); + assert.equal(selected.length, 0); + }); +}); + +describe("request value synthesis", () => { + test("query param uses operation example", () => { + const paths = { + "/search": { + get: { + operationId: "search", + parameters: [{ name: "q", in: "query", required: false, example: "hello" }], + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.parameters.q, "hello"); + }); + + test("query param falls back to schema.example", () => { + const paths = { + "/search": { + get: { + operationId: "search", + parameters: [{ name: "q", in: "query", required: false, schema: { type: "string", example: "world" } }], + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.parameters.q, "world"); + }); + + test("query param uses examples map", () => { + const paths = { + "/search": { + get: { + operationId: "search", + parameters: [{ + name: "q", in: "query", required: false, + examples: { ex1: { value: "from-examples-obj" } }, + }], + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.parameters.q, "from-examples-obj"); + }); + + test("required query param with no example gets placeholder", () => { + const paths = { + "/auth": { + get: { + operationId: "auth", + parameters: [{ name: "api_key", in: "query", required: true }], + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.parameters.api_key, "{{ api_key }}"); + }); + + test("optional query param with no example is omitted", () => { + const paths = { + "/search": { + get: { + operationId: "search", + parameters: [{ name: "optional", in: "query", required: false }], + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + const params = call.request.parameters || {}; + assert.ok(!Object.prototype.hasOwnProperty.call(params, "optional")); + }); + + test("path param interpolates into URL", () => { + const paths = { + "/users/{userId}": { + get: { + operationId: "getUser", + parameters: [{ name: "userId", in: "path", required: true, example: "user42" }], + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.url.path, "/v1/users/user42"); + }); + + test("required path param with no example gets placeholder", () => { + const paths = { + "/items/{id}": { + get: { + operationId: "getItem", + parameters: [{ name: "id", in: "path", required: true }], + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.url.path, "/v1/items/{{ id }}"); + }); + + test("body uses requestBody example", () => { + const paths = { + "/pets": { + post: { + operationId: "createPet", + requestBody: { + required: true, + content: { "application/json": { example: { name: "Fido", type: "dog" } } }, + }, + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.deepEqual(JSON.parse(call.request.body), { name: "Fido", type: "dog" }); + }); + + test("body is placeholder when requestBody required and no example", () => { + const paths = { + "/pets": { + post: { + operationId: "createPet", + requestBody: { required: true, content: { "application/json": {} } }, + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.body, "{{ body }}"); + }); + + test("body is null when requestBody not required and no example", () => { + const paths = { + "/pets": { + post: { + operationId: "createPet", + requestBody: { required: false, content: { "application/json": {} } }, + responses: {}, + }, + }, + }; + const call = buildProjectFromOpenApi(spec(paths), {}).document.project.calls[0]; + assert.equal(call.request.body, null); + }); + + test("auth_id and token_id from mapping override are attached", () => { + const paths = { "/secure": { get: { operationId: "secure", responses: {} } } }; + const mapping = { + operations: [{ operationId: "secure", request: { auth_id: "my-auth", token_id: "my-token" } }], + }; + const req = buildProjectFromOpenApi(spec(paths), mapping).document.project.calls[0].request; + assert.equal(req.auth_id, "my-auth"); + assert.equal(req.token_id, "my-token"); + }); + + test("defaults.headers apply to all calls", () => { + const paths = { + "/a": { get: { operationId: "a", responses: {} } }, + "/b": { get: { operationId: "b", responses: {} } }, + }; + const result = buildProjectFromOpenApi(spec(paths), { defaults: { headers: { "X-Common": "yes" } } }); + for (const call of result.document.project.calls) { + assert.equal(call.request.headers["X-Common"], "yes"); + } + }); + + test("operation header override beats defaults", () => { + const paths = { "/a": { get: { operationId: "a", responses: {} } } }; + const mapping = { + defaults: { headers: { "X-Header": "default" } }, + operations: [{ operationId: "a", request: { headers: { "X-Header": "override" } } }], + }; + const call = buildProjectFromOpenApi(spec(paths), mapping).document.project.calls[0]; + assert.equal(call.request.headers["X-Header"], "override"); + }); + + test("defaults.query applies to calls", () => { + const paths = { "/a": { get: { operationId: "a", responses: {} } } }; + const result = buildProjectFromOpenApi(spec(paths), { defaults: { query: { region: "us-east" } } }); + assert.equal(result.document.project.calls[0].request.parameters.region, "us-east"); + }); +}); + +describe("pickBaseServer", () => { + test("mapping server_url takes precedence", () => { + const server = pickBaseServer( + spec({}, { servers: [{ url: "https://other.example.com" }] }), + { server_url: "https://override.example.com/api" } + ); + assert.equal(server.hostname, "override.example.com"); + assert.equal(server.pathPrefix, "/api"); + }); + + test("uses first servers entry", () => { + const server = pickBaseServer(spec({}, { servers: [{ url: "https://api.example.com/v2" }] }), {}); + assert.equal(server.hostname, "api.example.com"); + assert.equal(server.pathPrefix, "/v2"); + assert.equal(server.scheme, "https"); + }); + + test("swagger 2 host and basePath are used", () => { + const server = pickBaseServer({ swagger: "2.0", host: "api.example.com", basePath: "/v1", paths: {} }, {}); + assert.equal(server.hostname, "api.example.com"); + assert.equal(server.pathPrefix, "/v1"); + }); + + test("http scheme is parsed", () => { + const server = pickBaseServer(spec({}, { servers: [{ url: "http://insecure.example.com" }] }), {}); + assert.equal(server.scheme, "http"); + }); + + test("port is extracted when present", () => { + const server = pickBaseServer(spec({}, { servers: [{ url: "https://api.example.com:8443/v1" }] }), {}); + assert.equal(server.port, 8443); + }); + + test("port is null when absent", () => { + const server = pickBaseServer(spec({}), {}); + assert.equal(server.port, null); + }); + + test("pathPrefix empty when no base path", () => { + const server = pickBaseServer(spec({}, { servers: [{ url: "https://api.example.com" }] }), {}); + assert.equal(server.pathPrefix, ""); + }); + + test("URL template variables do not throw", () => { + const server = pickBaseServer(spec({}, { servers: [{ url: "https://{tenant}.example.com/v1" }] }), {}); + assert.equal(server.pathPrefix, "/v1"); + }); +}); + +describe("mergeProject", () => { + const TAG = "openapi-sync"; + + test("non-generated calls are preserved", () => { + const manual = { id: "manual", meta: { name: "m", tags: ["custom"] }, request: { method: "GET", url: "https://x.com/", body: null } }; + const result = mergeProject(baseDoc([manual]), [genCall("gen-1")], genWorkflow("wf", ["gen-1"]), { generatedTag: TAG, cleanupGenerated: true }); + assert.ok(result.project.calls.find((c) => c.id === "manual")); + }); + + test("generated call is inserted", () => { + const result = mergeProject(baseDoc(), [genCall("gen-1")], genWorkflow("wf", ["gen-1"]), { generatedTag: TAG, cleanupGenerated: true }); + assert.ok(result.project.calls.find((c) => c.id === "gen-1")); + }); + + test("existing generated call updates with no duplicate", () => { + const old = { ...genCall("gen-1"), meta: { ...genCall("gen-1").meta, name: "Old Name" } }; + const updated = { ...genCall("gen-1"), meta: { ...genCall("gen-1").meta, name: "New Name" } }; + const result = mergeProject(baseDoc([old]), [updated], genWorkflow("wf", ["gen-1"]), { generatedTag: TAG, cleanupGenerated: true }); + const found = result.project.calls.filter((c) => c.id === "gen-1"); + assert.equal(found.length, 1); + assert.equal(found[0].meta.name, "New Name"); + }); + + test("stale generated call removed when cleanupGenerated=true", () => { + const result = mergeProject(baseDoc([genCall("stale")]), [], genWorkflow("wf", []), { generatedTag: TAG, cleanupGenerated: true }); + assert.ok(!result.project.calls.find((c) => c.id === "stale")); + }); + + test("stale generated call kept when cleanupGenerated=false", () => { + const result = mergeProject(baseDoc([genCall("stale")]), [], genWorkflow("wf", []), { generatedTag: TAG, cleanupGenerated: false }); + assert.ok(result.project.calls.find((c) => c.id === "stale")); + }); + + test("non-generated call not touched by cleanup", () => { + const manual = { id: "manual", meta: { name: "m", tags: ["custom"] }, request: { method: "GET", url: "https://x.com/", body: null } }; + const result = mergeProject(baseDoc([manual]), [], genWorkflow("wf", []), { generatedTag: TAG, cleanupGenerated: true }); + assert.ok(result.project.calls.find((c) => c.id === "manual")); + }); + + test("resulting calls are sorted by id", () => { + const result = mergeProject(baseDoc(), [genCall("zzz"), genCall("aaa")], genWorkflow("wf", []), { generatedTag: TAG, cleanupGenerated: true }); + const ids = result.project.calls.map((c) => c.id); + assert.deepEqual(ids, [...ids].sort()); + }); + + test("generated workflow is upserted", () => { + const result = mergeProject(baseDoc(), [genCall("g")], genWorkflow("my-wf", ["g"]), { generatedTag: TAG, cleanupGenerated: true }); + const wf = result.project.workflows.find((w) => w.id === "my-wf"); + assert.ok(wf); + assert.deepEqual(wf.workflow.call_ids, ["g"]); + }); + + test("generated workflow updates on second run", () => { + const existing = genWorkflow("my-wf", ["old-call"]); + const updated = genWorkflow("my-wf", ["new-call"]); + const result = mergeProject(baseDoc([], [existing]), [], updated, { generatedTag: TAG, cleanupGenerated: true }); + const wf = result.project.workflows.find((w) => w.id === "my-wf"); + assert.deepEqual(wf.workflow.call_ids, ["new-call"]); + }); + + test("non-generated workflows are preserved", () => { + const manualWf = { id: "manual-wf", meta: { name: "manual", tags: ["custom"] }, workflow: { call_ids: [], stop_on_failure: false } }; + const result = mergeProject(baseDoc([], [manualWf]), [], genWorkflow("gen-wf", []), { generatedTag: TAG, cleanupGenerated: true }); + assert.ok(result.project.workflows.find((w) => w.id === "manual-wf")); + }); + + test("base document is not mutated", () => { + const base = baseDoc([genCall("c")]); + const before = base.project.calls.length; + mergeProject(base, [genCall("c2")], genWorkflow("wf", []), { generatedTag: TAG, cleanupGenerated: true }); + assert.equal(base.project.calls.length, before); + }); +}); + +describe("idempotency", () => { + const paths = { + "/pets": { + get: { + operationId: "listPets", + parameters: [{ name: "limit", in: "query", required: false, example: 25 }], + responses: {}, + }, + }, + "/pets/{id}": { + get: { + operationId: "getPet", + parameters: [{ name: "id", in: "path", required: true, example: "1" }], + responses: {}, + }, + }, + }; + + test("identical runs produce same call IDs", () => { + const s = spec(paths); + assert.deepEqual( + buildProjectFromOpenApi(s, {}).summary.generatedCallIds, + buildProjectFromOpenApi(s, {}).summary.generatedCallIds + ); + }); + + test("identical runs produce equal documents", () => { + const s = spec(paths); + assert.deepEqual( + buildProjectFromOpenApi(s, {}).document, + buildProjectFromOpenApi(s, {}).document + ); + }); + + test("adding new operation keeps existing IDs", () => { + const paths1 = { "/pets": { get: { operationId: "listPets", responses: {} } } }; + const paths2 = { ...paths1, "/dogs": { get: { operationId: "listDogs", responses: {} } } }; + const id1 = buildProjectFromOpenApi(spec(paths1), {}).summary.generatedCallIds[0]; + const ids2 = buildProjectFromOpenApi(spec(paths2), {}).summary.generatedCallIds; + assert.ok(ids2.includes(id1), `expected \"${id1}\" to survive adding operations`); + }); +}); \ No newline at end of file diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..151703e --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,60 @@ +"use strict"; + +const { describe, test } = require("node:test"); +const assert = require("node:assert/strict"); +const { parseBoolean, summarizeDocument } = require("../src/utils"); + +describe("parseBoolean", () => { + test("empty string returns default true", () => { + assert.equal(parseBoolean("", true), true); + }); + + test("empty string returns default false", () => { + assert.equal(parseBoolean("", false), false); + }); + + const truthyValues = ["true", "1", "yes", "on", "TRUE", "YES", "ON"]; + for (const val of truthyValues) { + test(`\"${val}\" is truthy`, () => { + assert.equal(parseBoolean(val, false), true); + }); + } + + const falsyValues = ["false", "0", "no", "off", "FALSE", "OFF"]; + for (const val of falsyValues) { + test(`\"${val}\" is falsy`, () => { + assert.equal(parseBoolean(val, true), false); + }); + } + + test("unrecognized value is falsy", () => { + assert.equal(parseBoolean("maybe", false), false); + }); +}); + +describe("summarizeDocument", () => { + test("lists top-level keys", () => { + assert.equal(summarizeDocument({ a: 1, b: 2, c: 3 }), "keys=a,b,c"); + }); + + test("empty object produces keys= with empty suffix", () => { + assert.equal(summarizeDocument({}), "keys="); + }); + + test("returns for null", () => { + assert.equal(summarizeDocument(null), ""); + }); + + test("returns for strings", () => { + assert.equal(summarizeDocument("oops"), ""); + }); + + test("returns for numbers", () => { + assert.equal(summarizeDocument(42), ""); + }); + + test("truncates to 12 keys maximum", () => { + const obj = Object.fromEntries(Array.from({ length: 15 }, (_, i) => [`k${i}`, i])); + assert.equal(summarizeDocument(obj).split(",").length, 12); + }); +}); \ No newline at end of file