306 lines
7.8 KiB
TypeScript
306 lines
7.8 KiB
TypeScript
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 { openApiDocument } from "../src/openapi";
|
|
|
|
let tempDir: string | null = null;
|
|
|
|
function createTestApp() {
|
|
tempDir = mkdtempSync(join(tmpdir(), "mock-task-api-http-"));
|
|
const db = reseedDatabase(join(tempDir, "test.sqlite"));
|
|
return createApp(db, { parseJson: false });
|
|
}
|
|
|
|
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(() => {
|
|
if (tempDir) {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
tempDir = null;
|
|
}
|
|
});
|
|
|
|
describe("mock task API", () => {
|
|
it("serves a health endpoint", async () => {
|
|
const app = createTestApp();
|
|
|
|
const response = await sendRequest(app, {
|
|
method: "GET",
|
|
url: "/health",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.status).toBe("ok");
|
|
});
|
|
|
|
it("lists organizations", async () => {
|
|
const app = createTestApp();
|
|
|
|
const response = await sendRequest(app, {
|
|
method: "GET",
|
|
url: "/orgs",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(response.body.organizations)).toBe(true);
|
|
expect(response.body.organizations).toHaveLength(3);
|
|
});
|
|
|
|
it("creates an organization through the HTTP contract", async () => {
|
|
const app = createTestApp();
|
|
|
|
const response = await sendRequest(app, {
|
|
method: "POST",
|
|
url: "/orgs",
|
|
body: {
|
|
slug: "globex-systems",
|
|
name: "Globex Systems",
|
|
plan: "growth",
|
|
industry: "Manufacturing",
|
|
},
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.organization.slug).toBe("globex-systems");
|
|
expect(response.body.organization.name).toBe("Globex Systems");
|
|
});
|
|
|
|
it("creates a user through the HTTP contract", async () => {
|
|
const app = createTestApp();
|
|
|
|
const response = await sendRequest(app, {
|
|
method: "POST",
|
|
url: "/users",
|
|
body: {
|
|
orgId: "org_acme",
|
|
fullName: "Iris Quinn",
|
|
email: "iris@acme.example",
|
|
role: "engineer",
|
|
timezone: "Europe/Dublin",
|
|
},
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.user.orgId).toBe("org_acme");
|
|
expect(response.body.user.email).toBe("iris@acme.example");
|
|
});
|
|
|
|
it("creates a project through the HTTP contract", async () => {
|
|
const app = createTestApp();
|
|
|
|
const response = await sendRequest(app, {
|
|
method: "POST",
|
|
url: "/projects",
|
|
body: {
|
|
orgId: "org_acme",
|
|
key: "BIZ",
|
|
name: "Business Ops Dashboard",
|
|
status: "planning",
|
|
ownerUserId: "user_acme_1",
|
|
dueDate: "2026-06-30",
|
|
},
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.project.orgId).toBe("org_acme");
|
|
expect(response.body.project.key).toBe("BIZ");
|
|
expect(response.body.project.ownerUserId).toBe("user_acme_1");
|
|
});
|
|
|
|
it("filters tasks by org and status", async () => {
|
|
const app = createTestApp();
|
|
|
|
const response = await sendRequest(app, {
|
|
method: "GET",
|
|
url: "/tasks",
|
|
query: {
|
|
orgId: "org_acme",
|
|
status: "blocked",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
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 app = createTestApp();
|
|
|
|
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(response.body.task.title).toBe("Run API smoke test");
|
|
});
|
|
|
|
it("updates a task through the HTTP contract", async () => {
|
|
const app = createTestApp();
|
|
|
|
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(response.body.task.status).toBe("done");
|
|
expect(response.body.task.assigneeUserId).toBeNull();
|
|
});
|
|
|
|
it("returns 400 for invalid create payloads", async () => {
|
|
const app = createTestApp();
|
|
|
|
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(response.body.error).toBe("Validation failed.");
|
|
expect(response.body.details).toBeDefined();
|
|
});
|
|
|
|
it("returns 404 when updating a missing task", async () => {
|
|
const app = createTestApp();
|
|
|
|
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(response.body.error).toBe("Task not found.");
|
|
});
|
|
|
|
it("returns 404 for unknown routes", async () => {
|
|
const app = createTestApp();
|
|
|
|
const response = await sendRequest(app, {
|
|
method: "GET",
|
|
url: "/unknown",
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.error).toBe("Route not found.");
|
|
});
|
|
|
|
it("returns a valid openapi document at the import path", async () => {
|
|
const app = createTestApp();
|
|
|
|
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(response.body.openapi).toBe("3.0.0");
|
|
expect(paths["/tasks"]).toBeDefined();
|
|
expect(paths["/orgs/{orgId}/tasks"]).toBeDefined();
|
|
});
|
|
|
|
it("serves the same generated document on all json spec aliases", async () => {
|
|
const app = createTestApp();
|
|
|
|
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("serves swagger ui at /api-docs", async () => {
|
|
const app = createTestApp();
|
|
|
|
const response = await sendRequest(app, {
|
|
method: "GET",
|
|
url: "/api-docs",
|
|
});
|
|
|
|
expect(response.status).toBe(301);
|
|
});
|
|
});
|