kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { describe, expect, it } from "vitest";
2import {
3 dedupeOperationIds,
4 ensureOperationSummaries,
5 markOptionalSchemaFieldsNullable,
6 mergeOpenApiSpecs,
7 normalizeApiServerUrl,
8 normalizeEmptyRequiredArrays,
9 normalizeNullableSchemasForOpenApi30,
10 normalizeOrganizationAuthOperations,
11} from "../../../apps/api/src/utils/openapi-spec";
12
13describe("openapi spec helpers", () => {
14 it("normalizes API server urls", () => {
15 expect(normalizeApiServerUrl("https://api.kaneo.app")).toBe(
16 "https://api.kaneo.app/api",
17 );
18 expect(normalizeApiServerUrl("https://api.kaneo.app/api/")).toBe(
19 "https://api.kaneo.app/api",
20 );
21 });
22
23 it("normalizes organization auth operations and prunes components", () => {
24 const authSpec = {
25 paths: {
26 "/organization/list-members": {
27 get: {
28 operationId: "oldId",
29 summary: "Old summary",
30 responses: {
31 200: {
32 content: {
33 "application/json": {
34 schema: { $ref: "#/components/schemas/MemberList" },
35 },
36 },
37 },
38 },
39 },
40 },
41 "/session/get": {
42 get: {
43 operationId: "ignored",
44 },
45 },
46 },
47 security: [{ bearerAuth: [] }],
48 components: {
49 securitySchemes: {
50 bearerAuth: { type: "http", scheme: "bearer" },
51 },
52 schemas: {
53 MemberList: {
54 type: "object",
55 properties: {
56 data: {
57 $ref: "#/components/schemas/Member",
58 },
59 },
60 },
61 Member: {
62 type: "object",
63 properties: {
64 id: { type: "string" },
65 },
66 },
67 Ignored: {
68 type: "object",
69 },
70 },
71 },
72 };
73
74 const normalized = normalizeOrganizationAuthOperations(authSpec);
75
76 expect(Object.keys(normalized.paths as Record<string, unknown>)).toEqual([
77 "/auth/organization/list-members",
78 ]);
79
80 const operation = (
81 normalized.paths as Record<
82 string,
83 Record<string, Record<string, unknown>>
84 >
85 )["/auth/organization/list-members"].get;
86
87 expect(operation.operationId).toBe("listOrganizationMembers");
88 expect(operation.summary).toBe("List Organization Members");
89 expect(operation.tags).toEqual(["Organization Management"]);
90
91 const schemaNames = Object.keys(
92 (
93 normalized.components as {
94 schemas?: Record<string, unknown>;
95 }
96 ).schemas || {},
97 );
98 expect(schemaNames).toEqual(["MemberList", "Member"]);
99 });
100
101 it("merges hono and auth specs", () => {
102 const merged = mergeOpenApiSpecs(
103 {
104 openapi: "3.1.0",
105 info: { title: "API" },
106 paths: { "/tasks": { get: { operationId: "getTasks" } } },
107 tags: [{ name: "Tasks" }],
108 components: {
109 schemas: { Task: { type: "object" } },
110 },
111 },
112 {
113 paths: { "/auth/session": { get: { operationId: "getSession" } } },
114 tags: [{ name: "Auth" }],
115 components: {
116 securitySchemes: { bearerAuth: { type: "http" } },
117 schemas: { Session: { type: "object" } },
118 },
119 },
120 );
121
122 expect(merged.openapi).toBe("3.1.0");
123 expect(Object.keys(merged.paths)).toEqual(["/tasks", "/auth/session"]);
124 expect(merged.tags).toEqual([{ name: "Tasks" }, { name: "Auth" }]);
125 expect(merged.components.schemas).toEqual({
126 Task: { type: "object" },
127 Session: { type: "object" },
128 });
129 expect(merged.components.securitySchemes).toEqual({
130 bearerAuth: { type: "http" },
131 });
132 });
133
134 it("dedupes operation ids using method and path", () => {
135 const spec = dedupeOperationIds({
136 paths: {
137 "/tasks": {
138 get: { operationId: "getTask" },
139 },
140 "/tasks/{id}": {
141 get: { operationId: "getTask" },
142 },
143 },
144 });
145
146 expect(
147 (spec.paths as Record<string, Record<string, { operationId: string }>>)[
148 "/tasks/{id}"
149 ].get.operationId,
150 ).toBe("getTask_get_tasks_id");
151 });
152
153 it("normalizes nullable schemas and empty required arrays", () => {
154 const spec = normalizeEmptyRequiredArrays(
155 normalizeNullableSchemasForOpenApi30({
156 components: {
157 schemas: {
158 Example: {
159 type: ["string", "null"],
160 required: [],
161 },
162 ExampleAnyOf: {
163 anyOf: [{ type: "null" }, { type: "number", minimum: 1 }],
164 },
165 },
166 },
167 }),
168 );
169
170 expect(
171 (
172 spec.components as {
173 schemas: Record<string, Record<string, unknown>>;
174 }
175 ).schemas.Example,
176 ).toEqual({
177 type: "string",
178 nullable: true,
179 });
180
181 expect(
182 (
183 spec.components as {
184 schemas: Record<string, Record<string, unknown>>;
185 }
186 ).schemas.ExampleAnyOf,
187 ).toEqual({
188 type: "number",
189 minimum: 1,
190 nullable: true,
191 });
192 });
193
194 it("marks optional schema fields nullable and fills missing summaries", () => {
195 const spec = ensureOperationSummaries(
196 markOptionalSchemaFieldsNullable({
197 paths: {
198 "/tasks": {
199 get: {
200 operationId: "listWorkspaceTasks",
201 },
202 },
203 },
204 components: {
205 schemas: {
206 Task: {
207 type: "object",
208 required: ["id"],
209 properties: {
210 id: { type: "string" },
211 title: { type: "string" },
212 estimate: { type: "number", nullable: true },
213 },
214 },
215 },
216 },
217 }),
218 );
219
220 expect(
221 (
222 spec.components as {
223 schemas: Record<
224 string,
225 { properties: Record<string, Record<string, unknown>> }
226 >;
227 }
228 ).schemas.Task.properties.title.nullable,
229 ).toBe(true);
230 expect(
231 (
232 spec.components as {
233 schemas: Record<
234 string,
235 { properties: Record<string, Record<string, unknown>> }
236 >;
237 }
238 ).schemas.Task.properties.id.nullable,
239 ).toBeUndefined();
240 expect(
241 (
242 spec.paths as Record<
243 string,
244 Record<string, { summary?: string; operationId: string }>
245 >
246 )["/tasks"].get.summary,
247 ).toBe("List Workspace Tasks");
248 });
249});