Files
api-tools/tests/core/operations-compare-har.test.ts
zemion 98d1ff4afc
Verify / verify (push) Canceled after 0s
Release API Tools 0.2.0
2026-09-02 10:18:45 +02:00

258 lines
8.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { compareApiDescriptions, compareApis } from "../../src/core/compare";
import { validateHarAgainstOpenApi } from "../../src/core/contract";
import { inspectHar, inspectRawExchange } from "../../src/core/har";
import {
collectOperations,
operationExample,
securityInventory,
} from "../../src/core/operations";
import { parseApiDocument } from "../../src/core/parse";
import { createWorkspace } from "../../src/core/refs";
const SPEC = `openapi: 3.1.0
info: { title: T, version: '1' }
servers: [{ url: "https://api.example.test" }]
paths:
/items/{id}:
get:
operationId: readItem
parameters:
- { name: id, in: path, required: true, schema: { type: string, example: "a'b" } }
responses:
"200": { description: ok, content: { application/json: { schema: { type: object, properties: { id: {type: string} } } } } }
components:
securitySchemes: { key: { type: apiKey, in: header, name: X-Key } }
`;
describe("operation derivation, comparisons and saved exchanges", () => {
it("collects operations, schemes and safely quoted inert commands", () => {
const document = parseApiDocument(SPEC, "openapi.yaml");
const operations = collectOperations(document.value);
expect(operations.map((item) => item.key)).toEqual(["GET /items/{id}"]);
const example = operationExample(createWorkspace(document), operations[0]!);
expect(example.url).toContain("a'b");
expect(example.commands.curl).toContain("curl --request GET");
expect(example.commands.fetch).toContain("fetch(");
expect(securityInventory(document.value)).toEqual([
{ name: "key", type: "apiKey", detail: "header · X-Key" },
]);
});
it("inventories and compares OpenAPI 3.1 webhook receiver operations", () => {
const document = parseApiDocument(`openapi: 3.1.0
info: { title: Hooks, version: '1' }
paths: {}
webhooks:
orderChanged:
post:
operationId: receiveOrderChange
requestBody:
content:
application/json:
schema: { type: object, properties: { id: { type: string } } }
responses: { '204': { description: accepted } }
`);
const operations = collectOperations(document.value);
expect(operations).toEqual([
expect.objectContaining({
key: "WEBHOOK orderChanged POST",
kind: "webhook",
method: "POST",
path: "orderChanged",
}),
]);
const example = operationExample(createWorkspace(document), operations[0]!);
expect(example.url).toBe(
"https://receiver.example.test/webhooks/orderChanged",
);
expect(example.notices.join(" ")).toMatch(/placeholder/iu);
const removed = parseApiDocument(
"openapi: 3.1.0\ninfo: {title: Hooks, version: '2'}\npaths: {}\n",
);
expect(compareApis(document, removed)).toContainEqual(
expect.objectContaining({
level: "breaking",
path: "WEBHOOK orderChanged POST",
}),
);
});
it("reports a missing local reference without aborting generation", () => {
const document = parseApiDocument(
"openapi: 3.1.0\ninfo: {title: Missing, version: '1'}\npaths: {/x: {get: {responses: {'200': {description: ok, content: {application/json: {schema: {$ref: 'missing.yaml#/Result'}}}}}}}}",
"openapi.yaml",
);
const operation = collectOperations(document.value)[0]!;
const example = operationExample(createWorkspace(document), operation);
expect(example.response).toBeUndefined();
expect(example.notices.join(" ")).toMatch(/missing\.yaml/iu);
});
it("classifies removed operations and additions", () => {
const before = parseApiDocument(SPEC, "before.yaml");
const after = parseApiDocument(
SPEC.replace(" get:", " post:"),
"after.yaml",
);
expect(compareApis(before, after)).toEqual(
expect.arrayContaining([
expect.objectContaining({ level: "breaking", path: "GET /items/{id}" }),
expect.objectContaining({
level: "non-breaking",
path: "POST /items/{id}",
}),
]),
);
});
it("reports directional request and response schema regressions", () => {
const before = parseApiDocument(`openapi: 3.1.0
info: { title: Contract, version: '1' }
paths:
/items:
post:
requestBody:
content:
application/json:
schema: { type: object, properties: { kind: { type: string, enum: [a, b] } } }
responses:
'200':
description: ok
content:
application/json:
schema: { type: object, required: [id], properties: { id: { type: string } } }
`);
const after = parseApiDocument(`openapi: 3.1.0
info: { title: Contract, version: '2' }
paths:
/items:
post:
requestBody:
content:
application/json:
schema: { type: object, required: [kind], properties: { kind: { type: string, enum: [a] } } }
responses:
'200':
description: ok
content:
application/json:
schema: { type: object, properties: {} }
`);
const changes = compareApis(before, after);
expect(changes).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "breaking",
message: expect.stringContaining("Request enum removed"),
}),
expect.objectContaining({
level: "breaking",
message: expect.stringContaining("became required"),
}),
expect.objectContaining({
level: "breaking",
message: expect.stringContaining("Required response property"),
}),
]),
);
});
it("collects OpenAPI 3.2 QUERY and custom methods", () => {
const document = parseApiDocument(
`openapi: 3.2.0
info: { title: Extended, version: '1' }
paths:
/items:
query: { responses: { '200': { description: ok } } }
additionalOperations:
COPY: { responses: { '204': { description: copied } } }
`,
);
expect(collectOperations(document.value).map((item) => item.key)).toEqual([
"QUERY /items",
"COPY /items",
]);
});
it("compares AsyncAPI operations without treating them as HTTP paths", () => {
const before = parseApiDocument(
"asyncapi: 3.1.0\ninfo: {title: E, version: '1'}\nchannels: { c: {address: events, messages: {}} }\noperations: { send: {action: send, channel: {$ref: '#/channels/c'}} }",
);
const after = parseApiDocument(
"asyncapi: 3.1.0\ninfo: {title: E, version: '2'}\nchannels: { c: {address: events, messages: {}} }\noperations: {}",
);
expect(compareApiDescriptions(before, after)).toContainEqual(
expect.objectContaining({
level: "breaking",
message: "Operation was removed.",
}),
);
});
it("summarizes HAR and raw exchanges without exposing headers", () => {
const har = inspectHar(
'{"log":{"entries":[{"time":7,"request":{"method":"POST","url":"https://example.test/x","headers":[{"name":"Authorization","value":"secret"}],"postData":{"text":"abc"}},"response":{"status":201,"headers":[],"content":{"size":9,"mimeType":"application/json"}}}]}}',
);
expect(har[0]).toMatchObject({
method: "POST",
status: 201,
requestHeaders: 1,
requestBytes: 3,
responseBytes: 9,
});
expect(JSON.stringify(har)).not.toContain("secret");
expect(
inspectRawExchange(
"GET /x HTTP/1.1\r\nHost: example.test\r\n\r\n--- response ---\r\nHTTP/1.1 204 No Content\r\nX-Test: yes\r\n",
)[0],
).toMatchObject({ method: "GET", url: "/x", status: 204 });
});
it("matches HAR exchanges to operations and validates captured response JSON", () => {
const document = parseApiDocument(SPEC, "openapi.yaml");
const report = validateHarAgainstOpenApi(
JSON.stringify({
log: {
entries: [
{
request: {
method: "GET",
url: "https://api.example.test/items/item-1",
headers: [
{ name: "Authorization", value: "Bearer do-not-report" },
],
},
response: {
status: 200,
headers: [],
content: {
mimeType: "application/json",
text: JSON.stringify({ id: 42 }),
},
},
},
{
request: {
method: "POST",
url: "https://api.example.test/not-declared",
headers: [],
},
response: { status: 404, headers: [], content: {} },
},
],
},
}),
createWorkspace(document),
);
expect(report.coveredOperations).toBe(1);
expect(report.unmatchedExchanges).toBe(1);
expect(report.exchanges[0]?.operation).toBe("GET /items/{id}");
expect(report.diagnostics.map((item) => item.message).join("\n")).toMatch(
/expected string|does not match a declared operation/iu,
);
expect(JSON.stringify(report)).not.toContain("do-not-report");
});
});