mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
ai-canvas-expand was added to constants.ts and route files but never added to the landing page bento grid, causing all hardcoded counts to remain at 51. This updates all references across source, docs, i18n, and tests to reflect the correct count of 52 tools.
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for the docs route text generation functions.
|
|
*
|
|
* Tests the isPublic, generateLlmsTxt, and generateLlmsFullTxt helpers
|
|
* that produce llms.txt and llms-full.txt content from the OpenAPI spec.
|
|
*/
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
// ── Mocks ───────────────────────────────────────────────────────────────
|
|
|
|
vi.mock("../../../apps/api/src/db/index.js", () => ({
|
|
db: {},
|
|
schema: {},
|
|
}));
|
|
|
|
// ── Reproduce helper functions and types from docs.ts ───────────────────
|
|
|
|
interface PathOperation {
|
|
tags?: string[];
|
|
summary?: string;
|
|
description?: string;
|
|
security?: Array<Record<string, string[]>>;
|
|
parameters?: Array<{ name: string; in: string; required?: boolean; schema?: { type: string } }>;
|
|
requestBody?: { content: Record<string, { schema?: SchemaObject }> };
|
|
responses?: Record<string, { description?: string }>;
|
|
}
|
|
|
|
interface SchemaObject {
|
|
type?: string;
|
|
properties?: Record<string, SchemaObject>;
|
|
required?: string[];
|
|
description?: string;
|
|
}
|
|
|
|
interface OpenAPISpec {
|
|
info: { title: string; version: string; description?: string };
|
|
tags?: Array<{ name: string; description?: string }>;
|
|
paths: Record<string, Record<string, PathOperation>>;
|
|
}
|
|
|
|
function isPublic(op: PathOperation): boolean {
|
|
return Array.isArray(op.security) && op.security.length === 0;
|
|
}
|
|
|
|
function generateLlmsTxt(spec: OpenAPISpec): string {
|
|
const lines: string[] = [];
|
|
lines.push(`# ${spec.info.title}`);
|
|
lines.push("");
|
|
lines.push(
|
|
"> Self-hosted image processing API with 52 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
|
|
);
|
|
lines.push("");
|
|
lines.push("## Docs");
|
|
lines.push("- [Interactive API Reference](/api/docs): Full interactive API documentation");
|
|
lines.push("- [OpenAPI Spec](/api/v1/openapi.yaml): OpenAPI 3.1 specification (YAML)");
|
|
lines.push(
|
|
"- [Full API Docs (LLM-friendly)](/llms-full.txt): Complete API documentation in plain text",
|
|
);
|
|
lines.push("");
|
|
lines.push("## API Sections");
|
|
|
|
for (const tag of spec.tags || []) {
|
|
const count = Object.values(spec.paths).reduce((n, methods) => {
|
|
return n + Object.values(methods).filter((op) => op.tags?.[0] === tag.name).length;
|
|
}, 0);
|
|
lines.push(`- ${tag.name} (${count} endpoints): ${tag.description || ""}`);
|
|
}
|
|
|
|
lines.push("");
|
|
lines.push("## Authentication");
|
|
lines.push("- Session token via `POST /api/auth/login` -> `Authorization: Bearer <token>`");
|
|
lines.push("- API key (prefixed `si_`) -> `Authorization: Bearer si_...`");
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function generateLlmsFullTxt(spec: OpenAPISpec): string {
|
|
const lines: string[] = [];
|
|
lines.push(`# ${spec.info.title} v${spec.info.version}`);
|
|
lines.push("");
|
|
if (spec.info.description) {
|
|
lines.push(spec.info.description.trim());
|
|
lines.push("");
|
|
}
|
|
|
|
const tagGroups = new Map<string, Array<{ method: string; path: string; op: PathOperation }>>();
|
|
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
for (const [method, op] of Object.entries(methods)) {
|
|
const tag = op.tags?.[0] || "Other";
|
|
if (!tagGroups.has(tag)) tagGroups.set(tag, []);
|
|
tagGroups.get(tag)?.push({ method: method.toUpperCase(), path, op });
|
|
}
|
|
}
|
|
|
|
const tagOrder = (spec.tags || []).map((t) => t.name);
|
|
const allTags = [...new Set([...tagOrder, ...tagGroups.keys()])];
|
|
|
|
for (const tag of allTags) {
|
|
const endpoints = tagGroups.get(tag);
|
|
if (!endpoints) continue;
|
|
|
|
const tagInfo = spec.tags?.find((t) => t.name === tag);
|
|
lines.push(`## ${tag}`);
|
|
if (tagInfo?.description) lines.push(`${tagInfo.description}`);
|
|
lines.push("");
|
|
|
|
for (const { method, path, op } of endpoints) {
|
|
const auth = isPublic(op) ? "(public)" : "(auth required)";
|
|
lines.push(`### ${method} ${path} ${auth}`);
|
|
if (op.summary) lines.push(`**${op.summary}**`);
|
|
if (op.description) lines.push(op.description.trim());
|
|
lines.push("");
|
|
|
|
if (op.parameters?.length) {
|
|
lines.push("**Parameters:**");
|
|
for (const p of op.parameters) {
|
|
lines.push(
|
|
`- \`${p.name}\` (${p.in}${p.required ? ", required" : ""}) — ${p.schema?.type || "string"}`,
|
|
);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
if (op.requestBody) {
|
|
const contentType = Object.keys(op.requestBody.content)[0];
|
|
const schema = op.requestBody.content[contentType]?.schema;
|
|
lines.push(`**Request:** \`${contentType}\``);
|
|
if (schema?.properties) {
|
|
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
const required = schema.required?.includes(name) ? " (required)" : "";
|
|
const desc = prop.description ? ` — ${prop.description.split("\n")[0]}` : "";
|
|
lines.push(`- \`${name}\`${required}: ${prop.type || "string"}${desc}`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
if (op.responses) {
|
|
lines.push("**Responses:**");
|
|
for (const [code, res] of Object.entries(op.responses)) {
|
|
lines.push(`- \`${code}\` — ${res.description || ""}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────
|
|
|
|
describe("docs route logic", () => {
|
|
describe("isPublic", () => {
|
|
it("returns true for empty security array", () => {
|
|
expect(isPublic({ security: [] })).toBe(true);
|
|
});
|
|
|
|
it("returns false for non-empty security array", () => {
|
|
expect(isPublic({ security: [{ bearerAuth: [] }] })).toBe(false);
|
|
});
|
|
|
|
it("returns false for undefined security", () => {
|
|
expect(isPublic({})).toBe(false);
|
|
});
|
|
|
|
it("returns false for null-like security", () => {
|
|
expect(isPublic({ security: undefined })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("generateLlmsTxt", () => {
|
|
const minimalSpec: OpenAPISpec = {
|
|
info: { title: "SnapOtter API", version: "1.0.0" },
|
|
tags: [
|
|
{ name: "Tools", description: "Image processing tools" },
|
|
{ name: "Auth", description: "Authentication" },
|
|
],
|
|
paths: {
|
|
"/api/v1/tools/resize": {
|
|
post: { tags: ["Tools"], summary: "Resize image" },
|
|
},
|
|
"/api/auth/login": {
|
|
post: { tags: ["Auth"], summary: "Login", security: [] },
|
|
},
|
|
},
|
|
};
|
|
|
|
it("starts with the API title", () => {
|
|
const result = generateLlmsTxt(minimalSpec);
|
|
expect(result).toContain("# SnapOtter API");
|
|
});
|
|
|
|
it("includes the Docs section", () => {
|
|
const result = generateLlmsTxt(minimalSpec);
|
|
expect(result).toContain("## Docs");
|
|
expect(result).toContain("[Interactive API Reference]");
|
|
expect(result).toContain("[OpenAPI Spec]");
|
|
});
|
|
|
|
it("lists API sections with endpoint counts", () => {
|
|
const result = generateLlmsTxt(minimalSpec);
|
|
expect(result).toContain("Tools (1 endpoints): Image processing tools");
|
|
expect(result).toContain("Auth (1 endpoints): Authentication");
|
|
});
|
|
|
|
it("includes authentication section", () => {
|
|
const result = generateLlmsTxt(minimalSpec);
|
|
expect(result).toContain("## Authentication");
|
|
});
|
|
|
|
it("handles spec with no tags", () => {
|
|
const specNoTags: OpenAPISpec = {
|
|
info: { title: "Test API", version: "1.0.0" },
|
|
paths: {},
|
|
};
|
|
const result = generateLlmsTxt(specNoTags);
|
|
expect(result).toContain("# Test API");
|
|
expect(result).toContain("## API Sections");
|
|
});
|
|
|
|
it("handles spec with empty paths", () => {
|
|
const specEmpty: OpenAPISpec = {
|
|
info: { title: "Empty API", version: "0.1.0" },
|
|
tags: [{ name: "Tools", description: "desc" }],
|
|
paths: {},
|
|
};
|
|
const result = generateLlmsTxt(specEmpty);
|
|
expect(result).toContain("Tools (0 endpoints): desc");
|
|
});
|
|
});
|
|
|
|
describe("generateLlmsFullTxt", () => {
|
|
const fullSpec: OpenAPISpec = {
|
|
info: {
|
|
title: "SnapOtter API",
|
|
version: "2.0.0",
|
|
description: "Image processing API",
|
|
},
|
|
tags: [{ name: "Tools", description: "Processing tools" }],
|
|
paths: {
|
|
"/api/v1/tools/resize": {
|
|
post: {
|
|
tags: ["Tools"],
|
|
summary: "Resize an image",
|
|
description: "Resize to specified dimensions",
|
|
parameters: [
|
|
{ name: "width", in: "query", required: true, schema: { type: "integer" } },
|
|
],
|
|
requestBody: {
|
|
content: {
|
|
"multipart/form-data": {
|
|
schema: {
|
|
properties: {
|
|
file: { type: "string", description: "Image file to process" },
|
|
},
|
|
required: ["file"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
"200": { description: "Successful resize" },
|
|
"400": { description: "Invalid input" },
|
|
},
|
|
},
|
|
},
|
|
"/api/health": {
|
|
get: {
|
|
summary: "Health check",
|
|
security: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
it("includes title with version", () => {
|
|
const result = generateLlmsFullTxt(fullSpec);
|
|
expect(result).toContain("# SnapOtter API v2.0.0");
|
|
});
|
|
|
|
it("includes the description", () => {
|
|
const result = generateLlmsFullTxt(fullSpec);
|
|
expect(result).toContain("Image processing API");
|
|
});
|
|
|
|
it("groups endpoints by tag", () => {
|
|
const result = generateLlmsFullTxt(fullSpec);
|
|
expect(result).toContain("## Tools");
|
|
});
|
|
|
|
it("marks auth-required endpoints", () => {
|
|
const result = generateLlmsFullTxt(fullSpec);
|
|
expect(result).toContain("POST /api/v1/tools/resize (auth required)");
|
|
});
|
|
|
|
it("marks public endpoints", () => {
|
|
const result = generateLlmsFullTxt(fullSpec);
|
|
expect(result).toContain("GET /api/health (public)");
|
|
});
|
|
|
|
it("includes parameters section", () => {
|
|
const result = generateLlmsFullTxt(fullSpec);
|
|
expect(result).toContain("**Parameters:**");
|
|
expect(result).toContain("`width` (query, required)");
|
|
});
|
|
|
|
it("includes request body section", () => {
|
|
const result = generateLlmsFullTxt(fullSpec);
|
|
expect(result).toContain("**Request:** `multipart/form-data`");
|
|
});
|
|
|
|
it("includes responses section", () => {
|
|
const result = generateLlmsFullTxt(fullSpec);
|
|
expect(result).toContain("**Responses:**");
|
|
expect(result).toContain("`200`");
|
|
expect(result).toContain("`400`");
|
|
});
|
|
|
|
it("handles spec with no description", () => {
|
|
const specNoDesc: OpenAPISpec = {
|
|
info: { title: "No Desc API", version: "1.0.0" },
|
|
paths: {},
|
|
};
|
|
const result = generateLlmsFullTxt(specNoDesc);
|
|
expect(result).toContain("# No Desc API v1.0.0");
|
|
});
|
|
|
|
it("puts untagged endpoints under 'Other'", () => {
|
|
const specNoTag: OpenAPISpec = {
|
|
info: { title: "Test", version: "1.0.0" },
|
|
paths: {
|
|
"/health": { get: { summary: "Health" } },
|
|
},
|
|
};
|
|
const result = generateLlmsFullTxt(specNoTag);
|
|
expect(result).toContain("## Other");
|
|
});
|
|
});
|
|
});
|