feat(api): publish validated OpenAPI contract
CI / validate (push) Successful in 7m15s
Release / release (push) Successful in 11m32s

This commit is contained in:
dmg
2026-09-10 15:11:57 -04:00
parent c2ac2ad16b
commit 47782b3ccc
19 changed files with 2420 additions and 12 deletions
@@ -21,7 +21,13 @@ beforeEach(() => {
});
afterEach(() => { auth.session.mockReset(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
import { GET as get } from "./route";
import { assertResponse } from "@/test/openapi-contract";
import { GET } from "./route";
async function get(request: Request) {
const response = await GET(request);
await assertResponse("/api/admin/whoami", "get", response);
return response;
}
function request(authorization?: string) {
return new Request("https://portal.example/api/admin/whoami", { headers: authorization === undefined ? {} : { authorization } });
}
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { assertResponse } from "@/test/openapi-contract";
const auth = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
import * as list from "./route";
import * as detail from "./[id]/route";
import * as messages from "./[id]/messages/route";
const guild = "100000000000000001", forum = "100000000000000002", id = "100000000000000009";
const thread = { id, parent_id: forum, guild_id: guild, type: 11, name: "A garden", owner_id: "100000000000000003", applied_tags: [], message_count: 2, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-09-10T00:00:00.123456+00:00" } };
const message = { id, content: "", timestamp: "2026-09-10T00:00:00Z", edited_timestamp: null, author: { id: "100000000000000003", username: "Example" }, reactions: [{ emoji: { name: "👍" }, count: 1 }] };
const context = { params: Promise.resolve({ id }) };
const fetcher = vi.fn();
let generation = 0;
beforeEach(() => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
vi.stubEnv("DISCORD_BOT_TOKEN", `synthetic-fixture-${++generation}`);
vi.stubEnv("DISCORD_GUILD_ID", guild);
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forum);
vi.stubGlobal("fetch", fetcher);
fetcher.mockImplementation(async (url: string) => {
if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] });
if (url.includes("/threads/archived/public")) return Response.json({ threads: [thread], has_more: true });
if (url.includes("/threads/active")) return Response.json({ threads: [thread] });
if (url.endsWith(`/channels/${id}`)) return Response.json(thread);
if (url.endsWith(`/messages/${id}`)) return Response.json(message);
if (url.includes("/messages?")) return Response.json([message]);
throw new Error("Unexpected fixture request");
});
});
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
const routes = [
{ path: "/api/suggestions", route: list },
{ path: "/api/suggestions/{id}", route: detail },
{ path: "/api/suggestions/{id}/messages", route: messages },
];
it.each(routes)("validates populated $path response against its canonical schema", async ({ path, route }) => {
const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}?limit=1`), context);
expect(response.status).toBe(200);
await assertResponse(path, "get", response);
});
it("documents the nullable deleted starter and precise archived cursor", async () => {
fetcher.mockImplementationOnce(async () => Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] }))
.mockImplementationOnce(async () => Response.json(thread))
.mockImplementationOnce(async () => new Response(null, { status: 404 }));
const response = await detail.GET(new Request(`https://portal.example/api/suggestions/${id}`), context);
await assertResponse("/api/suggestions/{id}", "get", response);
expect((await response.json()).originalPost).toBeNull();
const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1"));
await assertResponse("/api/suggestions", "get", archived);
expect((await archived.json()).nextCursor).toBe("2026-09-10T00:00:00.123456Z");
});
it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => {
fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 }));
const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}`), context);
expect(response.status).toBe(503);
expect(response.headers.get("retry-after")).toBe("3");
await assertResponse(path, "get", response);
});
it.each(routes)("documents every explicitly rejected write method for $path", async ({ path, route }) => {
for (const method of ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
const response = await route[method](new Request(`https://portal.example${path.replace("{id}", id)}`, { method }));
expect(response.status).toBe(405);
await assertResponse(path, method, response);
}
});
+24 -3
View File
@@ -2,9 +2,30 @@ import { afterEach, expect, it, vi } from "vitest";
const auth = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
import { GET, POST } from "./route";
import { GET as detail } from "./[id]/route";
import { GET as messages } from "./[id]/messages/route";
import { assertResponse } from "@/test/openapi-contract";
import { GET as routeGet, POST as routePost } from "./route";
import { GET as routeDetail } from "./[id]/route";
import { GET as routeMessages } from "./[id]/messages/route";
async function GET(request: Request) {
const response = await routeGet(request);
await assertResponse("/api/suggestions", "get", response);
return response;
}
async function POST(request: Request) {
const response = await routePost(request);
await assertResponse("/api/suggestions", "post", response);
return response;
}
async function detail(request: Request, context: Parameters<typeof routeDetail>[1]) {
const response = await routeDetail(request, context);
await assertResponse("/api/suggestions/{id}", "get", response);
return response;
}
async function messages(request: Request, context: Parameters<typeof routeMessages>[1]) {
const response = await routeMessages(request, context);
await assertResponse("/api/suggestions/{id}/messages", "get", response);
return response;
}
const context = { params: Promise.resolve({ id: "100000000000000009" }) };
const request = () => new Request("https://portal.example/api/suggestions");
@@ -47,7 +47,13 @@ vi.mock("@/lib/ip-intelligence", () => ({
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
import { POST } from "./route";
import { assertResponse } from "@/test/openapi-contract";
import { POST as routePost } from "./route";
async function POST(request: Request) {
const response = await routePost(request);
await assertResponse("/api/velocity/access", "post", response);
return response;
}
const messages = {
registrationMessage: "Register {player} in {group}.",
@@ -1,9 +1,20 @@
import { describe, expect, it } from "vitest";
import { GET, POST } from "./route";
import { assertResponse } from "@/test/openapi-contract";
import { GET as routeGet, POST as routePost } from "./route";
async function GET(request: Request) {
const response = routeGet(request);
await assertResponse("/api/velocity/access", "get", response);
return response;
}
async function POST(request: Request) {
const response = await routePost(request);
await assertResponse("/api/velocity/access", "post", response);
return response;
}
describe("Velocity access API problems", () => {
it("returns RFC 9457 for unsupported methods", async () => {
const response = GET(new Request("http://localhost/api/velocity/access"));
const response = await GET(new Request("http://localhost/api/velocity/access"));
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("POST");
@@ -1,3 +1,4 @@
import { assertResponse } from "@/test/openapi-contract";
import { hashToken } from "@minecraft-account-manager/auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -38,7 +39,18 @@ vi.mock("@/lib/database", () => ({
},
}));
import { GET, POST } from "./route";
import { GET as routeGet, POST as routePost } from "./route";
async function GET(request: Request) {
const response = routeGet(request);
await assertResponse("/api/velocity/connection", "get", response);
return response;
}
async function POST(request: Request) {
const response = await routePost(request);
await assertResponse("/api/velocity/connection", "post", response);
return response;
}
function validRequest(overrides: Record<string, unknown> = {}) {
return new Request("http://localhost/api/velocity/connection", {
@@ -64,7 +76,7 @@ describe("Velocity connection reporting endpoint", () => {
});
it("rejects methods other than POST with Problem Details", async () => {
const response = GET(new Request("http://localhost/api/velocity/connection"));
const response = await GET(new Request("http://localhost/api/velocity/connection"));
expect(response.status).toBe(405);
expect(response.headers.get("content-type")).toContain("application/problem+json");
expect(response.headers.get("allow")).toBe("POST");