Initial Commit
This commit is contained in:
126
packages/api-stack/api/api-openapi/tests/dep-graph.test.ts
Normal file
126
packages/api-stack/api/api-openapi/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = new URL("..", import.meta.url).pathname;
|
||||
const PKG_JSON = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies: Record<string, string>;
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const RUNTIME_ALLOWLIST = new Set([
|
||||
"@hono/zod-openapi",
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/api-plugin",
|
||||
"@suaveplan/api-schemas",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"hono",
|
||||
"tslib",
|
||||
"yaml",
|
||||
"zod",
|
||||
]);
|
||||
|
||||
const SRC_ROOT = join(PKG_ROOT, "src");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) out.push(...walk(p));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FEATURE_MODULES = [
|
||||
"error-codes",
|
||||
"types",
|
||||
"registry",
|
||||
"route-factory",
|
||||
"schema-registry",
|
||||
"merger",
|
||||
"plugin-integration",
|
||||
"emitter",
|
||||
"namespacing",
|
||||
"strictness",
|
||||
"endpoints",
|
||||
];
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("dependencies match the allowlist", { timeout: 5000 }, () => {
|
||||
for (const dep of Object.keys(PKG_JSON.dependencies)) {
|
||||
expect(RUNTIME_ALLOWLIST.has(dep)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("subpath exports cover every feature module", { timeout: 5000 }, () => {
|
||||
const exports = PKG_JSON.exports;
|
||||
for (const m of FEATURE_MODULES) {
|
||||
expect(exports[`./${m}`]).toBeDefined();
|
||||
}
|
||||
expect(exports["."]).toBeDefined();
|
||||
});
|
||||
|
||||
it("every implementation .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC_ROOT).filter(
|
||||
(f) =>
|
||||
f.endsWith(".ts") &&
|
||||
!f.endsWith(".test.ts") &&
|
||||
!f.endsWith(".types.ts") &&
|
||||
!f.endsWith(".bench.ts") &&
|
||||
!f.endsWith(".spec-conformance.test.ts") &&
|
||||
!/[/\\]index\.ts$/.test(f) &&
|
||||
!/[/\\]src[/\\]index\.ts$/.test(f)
|
||||
);
|
||||
for (const f of files) {
|
||||
const md = f.replace(/\.ts$/, ".md");
|
||||
const has = (() => {
|
||||
try {
|
||||
statSync(md);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
expect({ file: f, hasMd: has }).toEqual({
|
||||
file: f,
|
||||
hasMd: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("no biome-ignore suppressions in src", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC_ROOT).filter((f) => f.endsWith(".ts"));
|
||||
for (const f of files) {
|
||||
const text = readFileSync(f, "utf8");
|
||||
expect(text.includes("biome-ignore")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("no direct vitest/bun:test imports", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC_ROOT)
|
||||
.concat(walk(join(PKG_ROOT, "tests")))
|
||||
.filter((f) => f.endsWith(".ts"));
|
||||
const banned =
|
||||
/from\s+['"](vitest|bun:test)['"]|require\(\s*['"](vitest|bun:test)['"]\s*\)/;
|
||||
for (const f of files) {
|
||||
const text = readFileSync(f, "utf8");
|
||||
if (banned.test(text)) {
|
||||
throw new Error(`${f} imports vitest or bun:test directly`);
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("no `export *` in feature barrels", { timeout: 5000 }, () => {
|
||||
for (const m of FEATURE_MODULES) {
|
||||
const idx = join(SRC_ROOT, m, "index.ts");
|
||||
const text = readFileSync(idx, "utf8");
|
||||
expect(text.includes("export *")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { OpenAPIHono, z } from "@hono/zod-openapi";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { definePluginRoutes } from "../../src/plugin-integration/plugin-integration.js";
|
||||
import {
|
||||
createOpenAPIRegistry,
|
||||
DEFAULT_SPEC_PATH,
|
||||
} from "../../src/registry/registry.js";
|
||||
import {
|
||||
GET,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "../../src/route-factory/route-factory.js";
|
||||
|
||||
describe("full doc roundtrip — real OpenAPIHono + real registry + 3 plugins", () => {
|
||||
it("registers across 3 plugins and the served doc matches buildDocument()", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "All-in-One", version: "1.0.0" },
|
||||
});
|
||||
|
||||
// Plugin: users
|
||||
const listUsers = GET("/users")
|
||||
.tags("users")
|
||||
.operationId("listUsers")
|
||||
.response(successResponse(z.array(z.object({ id: z.string() }))))
|
||||
.build();
|
||||
const createUser = POST("/users")
|
||||
.tags("users")
|
||||
.operationId("createUser")
|
||||
.body(z.object({ email: z.string().email() }))
|
||||
.response(
|
||||
successResponse(z.object({ id: z.string(), email: z.string() }))
|
||||
)
|
||||
.build();
|
||||
definePluginRoutes(
|
||||
[
|
||||
{
|
||||
route: listUsers,
|
||||
handler: (c) =>
|
||||
c.json({ success: true as const, data: [] }, 200),
|
||||
},
|
||||
{
|
||||
route: createUser,
|
||||
handler: (c) =>
|
||||
c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: { id: "1", email: "u@e.com" },
|
||||
},
|
||||
200
|
||||
),
|
||||
},
|
||||
],
|
||||
{ app, registry, pluginName: "users" }
|
||||
);
|
||||
|
||||
// Plugin: projects
|
||||
const listProjects = GET("/projects")
|
||||
.tags("projects")
|
||||
.operationId("listProjects")
|
||||
.response(successResponse(z.array(z.object({ id: z.string() }))))
|
||||
.build();
|
||||
definePluginRoutes(
|
||||
[
|
||||
{
|
||||
route: listProjects,
|
||||
handler: (c) =>
|
||||
c.json({ success: true as const, data: [] }, 200),
|
||||
},
|
||||
],
|
||||
{ app, registry, pluginName: "projects" }
|
||||
);
|
||||
|
||||
// Plugin: health
|
||||
const health = GET("/health")
|
||||
.tags("ops")
|
||||
.operationId("getHealth")
|
||||
.response(successResponse(z.object({ ok: z.boolean() })))
|
||||
.build();
|
||||
definePluginRoutes(
|
||||
[
|
||||
{
|
||||
route: health,
|
||||
handler: (c) =>
|
||||
c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: { ok: true },
|
||||
},
|
||||
200
|
||||
),
|
||||
},
|
||||
],
|
||||
{ app, registry, pluginName: "health" }
|
||||
);
|
||||
|
||||
// Connect the registry to the app
|
||||
registry.applyTo(app);
|
||||
|
||||
// Assert registry-built doc contains every route
|
||||
const built = registry.buildDocument();
|
||||
expect(Object.keys(built.paths).sort()).toEqual([
|
||||
"/health",
|
||||
"/projects",
|
||||
"/users",
|
||||
]);
|
||||
expect(built.paths["/users"]?.get?.operationId).toBe("listUsers");
|
||||
expect(built.paths["/users"]?.post?.operationId).toBe("createUser");
|
||||
|
||||
// Tag set carries through
|
||||
expect(built.tags?.map((t) => t.name).sort()).toEqual([
|
||||
"ops",
|
||||
"projects",
|
||||
"users",
|
||||
]);
|
||||
|
||||
// Plugin-name introspection
|
||||
expect(
|
||||
registry
|
||||
.getRoutesByPlugin("users")
|
||||
.map((r) => r.path)
|
||||
.sort()
|
||||
).toEqual(["/users", "/users"]);
|
||||
expect(
|
||||
registry.getRoutesByPlugin("projects").map((r) => r.path)
|
||||
).toEqual(["/projects"]);
|
||||
|
||||
// Served doc must equal buildDocument() byte-for-byte
|
||||
const res = await app.request(DEFAULT_SPEC_PATH);
|
||||
expect(res.status).toBe(200);
|
||||
const served = (await res.json()) as unknown;
|
||||
expect(served).toEqual(built);
|
||||
});
|
||||
});
|
||||
56
packages/api-stack/api/api-openapi/tests/property.test.ts
Normal file
56
packages/api-stack/api/api-openapi/tests/property.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { mergeOpenAPIDocuments } from "../src/merger/merger.js";
|
||||
import type { OpenAPIDocument } from "../src/types/openapi.js";
|
||||
|
||||
const operationArb = fc.record({
|
||||
responses: fc.constant({}),
|
||||
});
|
||||
|
||||
const pathItemArb = fc.dictionary(
|
||||
fc.constantFrom("get", "post", "put", "patch", "delete"),
|
||||
operationArb,
|
||||
{ minKeys: 1, maxKeys: 3 }
|
||||
);
|
||||
|
||||
const pathsArb = fc.dictionary(
|
||||
fc.string({ minLength: 1, maxLength: 10 }).map((s) => `/${s}`),
|
||||
pathItemArb,
|
||||
{ minKeys: 0, maxKeys: 5 }
|
||||
);
|
||||
|
||||
const docArb = pathsArb.map<OpenAPIDocument>((paths) => ({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "T", version: "1.0.0" },
|
||||
paths,
|
||||
}));
|
||||
|
||||
describe("mergeOpenAPIDocuments — associativity property", () => {
|
||||
it("merge(a, b, c) === merge(merge(a, b), c) for arbitrary path sets", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(docArb, docArb, docArb, (a, b, c) => {
|
||||
const left = mergeOpenAPIDocuments(a, b, c);
|
||||
const right = mergeOpenAPIDocuments(
|
||||
mergeOpenAPIDocuments(a, b),
|
||||
c
|
||||
);
|
||||
expect(right.paths).toEqual(left.paths);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("merging with empty extras is idempotent on paths", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(docArb, (a) => {
|
||||
const m = mergeOpenAPIDocuments(a, {}, {}, {});
|
||||
expect(m.paths).toEqual(a.paths);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
34
packages/api-stack/api/api-openapi/tests/setup/polyfills.ts
Normal file
34
packages/api-stack/api/api-openapi/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/api-openapi.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
|
||||
* module is imported. Tests that inspect emitted metrics can read them
|
||||
* from `testInMemoryMetricExporter`. Node ≥18 exposes `crypto.subtle`
|
||||
* and `crypto.getRandomValues` globally, so no crypto polyfill needed.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user