tsoa
This commit is contained in:
@@ -1,21 +1,53 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import httpMocks from "node-mocks-http";
|
||||
import { createApp } from "../src/app";
|
||||
import { reseedDatabase } from "../src/database";
|
||||
import { createServer } from "../src/index";
|
||||
import { openApiDocument } from "../src/openapi";
|
||||
|
||||
let tempDir: string | null = null;
|
||||
|
||||
function createTestServer() {
|
||||
function createTestApp() {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "mock-task-api-http-"));
|
||||
const db = reseedDatabase(join(tempDir, "test.sqlite"));
|
||||
return createServer(db);
|
||||
return createApp(db, { parseJson: false });
|
||||
}
|
||||
|
||||
async function readJson(response: Response) {
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
async function sendRequest(
|
||||
app: ReturnType<typeof createApp>,
|
||||
options: {
|
||||
method: string;
|
||||
url: string;
|
||||
query?: Record<string, string>;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
) {
|
||||
const request = httpMocks.createRequest({
|
||||
method: options.method,
|
||||
url: options.url,
|
||||
query: options.query,
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
});
|
||||
const response = httpMocks.createResponse({ eventEmitter: EventEmitter });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
response.on("end", () => resolve());
|
||||
app.handle(request, response);
|
||||
});
|
||||
|
||||
const contentType = String(response.getHeader("content-type") ?? "");
|
||||
return {
|
||||
status: response.statusCode,
|
||||
headers: response._getHeaders(),
|
||||
body: contentType.includes("application/json")
|
||||
? response._getJSONData()
|
||||
: response._getData(),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -27,185 +59,177 @@ afterEach(() => {
|
||||
|
||||
describe("mock task API", () => {
|
||||
it("serves a health endpoint", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(new Request("http://mock.local/health"));
|
||||
const body = await readJson(response);
|
||||
const response = await sendRequest(app, {
|
||||
method: "GET",
|
||||
url: "/health",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.status).toBe("ok");
|
||||
expect(response.body.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("lists organizations", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(new Request("http://mock.local/orgs"));
|
||||
const body = await readJson(response);
|
||||
const response = await sendRequest(app, {
|
||||
method: "GET",
|
||||
url: "/orgs",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(body.organizations)).toBe(true);
|
||||
expect((body.organizations as unknown[]).length).toBe(3);
|
||||
expect(Array.isArray(response.body.organizations)).toBe(true);
|
||||
expect(response.body.organizations).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("filters tasks by org and status", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(
|
||||
new Request("http://mock.local/tasks?orgId=org_acme&status=blocked"),
|
||||
);
|
||||
const body = await readJson(response);
|
||||
const tasks = body.tasks as Array<Record<string, unknown>>;
|
||||
const response = await sendRequest(app, {
|
||||
method: "GET",
|
||||
url: "/tasks",
|
||||
query: {
|
||||
orgId: "org_acme",
|
||||
status: "blocked",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(tasks.length).toBe(1);
|
||||
expect(tasks[0]?.id).toBe("task_1003");
|
||||
expect(response.body.tasks).toHaveLength(1);
|
||||
expect(response.body.tasks[0]?.id).toBe("task_1003");
|
||||
});
|
||||
|
||||
it("creates a task through the HTTP contract", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(
|
||||
new Request("http://mock.local/tasks", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
orgId: "org_acme",
|
||||
projectId: "proj_acme_ops",
|
||||
title: "Run API smoke test",
|
||||
priority: "low",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const body = await readJson(response);
|
||||
const response = await sendRequest(app, {
|
||||
method: "POST",
|
||||
url: "/tasks",
|
||||
body: {
|
||||
orgId: "org_acme",
|
||||
projectId: "proj_acme_ops",
|
||||
title: "Run API smoke test",
|
||||
priority: "low",
|
||||
},
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect((body.task as Record<string, unknown>).title).toBe("Run API smoke test");
|
||||
expect(response.body.task.title).toBe("Run API smoke test");
|
||||
});
|
||||
|
||||
it("updates a task through the HTTP contract", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(
|
||||
new Request("http://mock.local/tasks/task_1001", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status: "done",
|
||||
assigneeUserId: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const body = await readJson(response);
|
||||
const response = await sendRequest(app, {
|
||||
method: "PATCH",
|
||||
url: "/tasks/task_1001",
|
||||
body: {
|
||||
status: "done",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((body.task as Record<string, unknown>).status).toBe("done");
|
||||
expect((body.task as Record<string, unknown>).assigneeUserId).toBeNull();
|
||||
expect(response.body.task.status).toBe("done");
|
||||
expect(response.body.task.assigneeUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid create payloads", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(
|
||||
new Request("http://mock.local/tasks", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
orgId: "org_acme",
|
||||
projectId: "proj_acme_ops",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const body = await readJson(response);
|
||||
const response = await sendRequest(app, {
|
||||
method: "POST",
|
||||
url: "/tasks",
|
||||
body: {
|
||||
orgId: "org_acme",
|
||||
projectId: "proj_acme_ops",
|
||||
},
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("title is required.");
|
||||
expect(response.body.error).toBe("Validation failed.");
|
||||
expect(response.body.details).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 404 when updating a missing task", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(
|
||||
new Request("http://mock.local/tasks/task_missing", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status: "done",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const body = await readJson(response);
|
||||
const response = await sendRequest(app, {
|
||||
method: "PATCH",
|
||||
url: "/tasks/task_missing",
|
||||
body: {
|
||||
status: "done",
|
||||
},
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe("Task not found.");
|
||||
expect(response.body.error).toBe("Task not found.");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown routes", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(new Request("http://mock.local/unknown"));
|
||||
const body = await readJson(response);
|
||||
const response = await sendRequest(app, {
|
||||
method: "GET",
|
||||
url: "/unknown",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe("Route not found.");
|
||||
expect(response.body.error).toBe("Route not found.");
|
||||
});
|
||||
|
||||
it("returns a valid openapi document at the import path", async () => {
|
||||
const server = createTestServer();
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(new Request("http://mock.local/openapi.json"));
|
||||
const body = await readJson(response);
|
||||
const paths = body.paths as Record<string, unknown>;
|
||||
const response = await sendRequest(app, {
|
||||
method: "GET",
|
||||
url: "/openapi.json",
|
||||
});
|
||||
const paths = response.body.paths as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.openapi).toBe("3.0.3");
|
||||
expect(response.body.openapi).toBe("3.0.0");
|
||||
expect(paths["/tasks"]).toBeDefined();
|
||||
expect(paths["/orgs/{orgId}/tasks"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("advertises every implemented route in the openapi document", () => {
|
||||
const operations = Object.entries(openApiDocument.paths).flatMap(([path, pathItem]) =>
|
||||
Object.keys(pathItem).map((method) => `${method.toUpperCase()} ${path}`),
|
||||
);
|
||||
it("serves the same generated document on all json spec aliases", async () => {
|
||||
const app = createTestApp();
|
||||
|
||||
expect(operations).toEqual([
|
||||
"GET /health",
|
||||
"GET /orgs",
|
||||
"GET /orgs/{orgId}",
|
||||
"GET /orgs/{orgId}/projects",
|
||||
"GET /orgs/{orgId}/tasks",
|
||||
"GET /users",
|
||||
"GET /projects",
|
||||
"GET /tasks",
|
||||
"POST /tasks",
|
||||
"GET /tasks/{taskId}",
|
||||
"PATCH /tasks/{taskId}",
|
||||
"GET /openapi.json",
|
||||
"GET /swagger.json",
|
||||
"GET /api-docs",
|
||||
]);
|
||||
const openApiResponse = await sendRequest(app, {
|
||||
method: "GET",
|
||||
url: "/openapi.json",
|
||||
});
|
||||
const swaggerResponse = await sendRequest(app, {
|
||||
method: "GET",
|
||||
url: "/swagger.json",
|
||||
});
|
||||
|
||||
expect(openApiResponse.body).toEqual(swaggerResponse.body);
|
||||
expect(openApiResponse.body).toEqual(openApiDocument);
|
||||
});
|
||||
|
||||
it("supports the swagger alias path used by import tools", async () => {
|
||||
const server = createTestServer();
|
||||
it("serves swagger ui at /api-docs", async () => {
|
||||
const app = createTestApp();
|
||||
|
||||
const response = await server.fetch(new Request("http://mock.local/swagger.json"));
|
||||
const response = await sendRequest(app, {
|
||||
method: "GET",
|
||||
url: "/api-docs",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("serves the same generated document on all openapi aliases", async () => {
|
||||
const server = createTestServer();
|
||||
|
||||
const openApiResponse = await server.fetch(new Request("http://mock.local/openapi.json"));
|
||||
const swaggerResponse = await server.fetch(new Request("http://mock.local/swagger.json"));
|
||||
const apiDocsResponse = await server.fetch(new Request("http://mock.local/api-docs"));
|
||||
|
||||
const openApiBody = await readJson(openApiResponse);
|
||||
const swaggerBody = await readJson(swaggerResponse);
|
||||
const apiDocsBody = await readJson(apiDocsResponse);
|
||||
|
||||
expect(openApiBody).toEqual(swaggerBody);
|
||||
expect(swaggerBody).toEqual(apiDocsBody);
|
||||
expect(openApiBody).toEqual(openApiDocument);
|
||||
expect(response.status).toBe(301);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,88 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createApp } from "../src/app";
|
||||
import { reseedDatabase } from "../src/database";
|
||||
import { openApiDocument } from "../src/openapi";
|
||||
import { matchRoute, routes } from "../src/routes";
|
||||
|
||||
describe("route registry", () => {
|
||||
it("matches concrete paths and extracts route params", () => {
|
||||
const matched = matchRoute("GET", "/orgs/org_acme/tasks");
|
||||
const swaggerAliasPaths = new Set(["/openapi.json", "/swagger.json"]);
|
||||
|
||||
expect(matched).not.toBeNull();
|
||||
expect(matched?.route.operationId).toBe("listOrganizationTasks");
|
||||
expect(matched?.params).toEqual({ orgId: "org_acme" });
|
||||
function normalizeOpenApiPath(path: string) {
|
||||
return path.replace(/:([^/]+)/g, "{$1}");
|
||||
}
|
||||
|
||||
function getDocumentedOperations() {
|
||||
return Object.entries(openApiDocument.paths).flatMap(([path, pathItem]) =>
|
||||
Object.keys(pathItem as Record<string, unknown>).map(
|
||||
(method) => `${method.toUpperCase()} ${normalizeOpenApiPath(path)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getRuntimeOperations() {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "mock-task-api-routes-"));
|
||||
|
||||
try {
|
||||
const db = reseedDatabase(join(tempDir, "test.sqlite"));
|
||||
const app = createApp(db, { parseJson: false });
|
||||
const stack = (app as { router: { stack: Array<{ route?: unknown }> } }).router.stack;
|
||||
|
||||
return stack.flatMap((layer) => {
|
||||
if (!layer.route) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const route = layer.route as { path: string; methods: Record<string, boolean> };
|
||||
if (swaggerAliasPaths.has(route.path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(route.methods)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([method]) => `${method.toUpperCase()} ${normalizeOpenApiPath(route.path)}`);
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("tsoa swagger document", () => {
|
||||
it("matches all runtime business routes registered in express", () => {
|
||||
const documentedOperations = getDocumentedOperations();
|
||||
const runtimeOperations = getRuntimeOperations();
|
||||
|
||||
expect(runtimeOperations.sort()).toEqual(documentedOperations.sort());
|
||||
});
|
||||
|
||||
it("returns null for unsupported methods and unknown paths", () => {
|
||||
expect(matchRoute("DELETE", "/tasks/task_1001")).toBeNull();
|
||||
expect(matchRoute("GET", "/does-not-exist")).toBeNull();
|
||||
it("documents the expected business endpoints generated from controllers", () => {
|
||||
const operations = getDocumentedOperations();
|
||||
|
||||
expect(operations.sort()).toEqual([
|
||||
"GET /health",
|
||||
"GET /orgs",
|
||||
"GET /orgs/{orgId}",
|
||||
"GET /orgs/{orgId}/projects",
|
||||
"GET /orgs/{orgId}/tasks",
|
||||
"GET /users",
|
||||
"GET /projects",
|
||||
"GET /tasks",
|
||||
"POST /tasks",
|
||||
"GET /tasks/{taskId}",
|
||||
"PATCH /tasks/{taskId}",
|
||||
].sort());
|
||||
});
|
||||
|
||||
it("builds an openapi path entry for every registered route", () => {
|
||||
const registeredOperations = routes.map(
|
||||
(route) => `${route.method} ${route.path}:${route.operationId}`,
|
||||
);
|
||||
const documentedOperations = Object.entries(openApiDocument.paths).flatMap(
|
||||
([path, pathItem]) =>
|
||||
Object.entries(pathItem).map(
|
||||
([method, operation]) =>
|
||||
`${method.toUpperCase()} ${path}:${(operation as { operationId?: string }).operationId ?? ""}`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(documentedOperations).toEqual(registeredOperations);
|
||||
it("does not include swagger hosting aliases as business operations", () => {
|
||||
expect(openApiDocument.paths["/openapi.json"]).toBeUndefined();
|
||||
expect(openApiDocument.paths["/swagger.json"]).toBeUndefined();
|
||||
expect(openApiDocument.paths["/api-docs"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps spec-serving routes explicit in the registry", () => {
|
||||
const openApiAliases = routes
|
||||
.filter((route) => route.servesOpenApiDocument)
|
||||
.map((route) => route.path);
|
||||
it("uses tsoa-generated schemas for the task mutation payloads", () => {
|
||||
const schemas = openApiDocument.components.schemas as Record<string, Record<string, unknown>>;
|
||||
|
||||
expect(openApiAliases).toEqual(["/openapi.json", "/swagger.json", "/api-docs"]);
|
||||
expect(schemas.CreateTaskRequest).toBeDefined();
|
||||
expect(schemas.UpdateTaskRequest).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user