@mandujs/core 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * OpenAPI Generator Tests
3
+ */
4
+
5
+ import { describe, test, expect } from "bun:test";
6
+ import { z } from "zod";
7
+ import { zodToOpenAPISchema, openAPIToJSON } from "./generator";
8
+
9
+ describe("zodToOpenAPISchema", () => {
10
+ describe("primitive types", () => {
11
+ test("should convert ZodString", () => {
12
+ const schema = z.string();
13
+ const result = zodToOpenAPISchema(schema);
14
+
15
+ expect(result.type).toBe("string");
16
+ });
17
+
18
+ test("should convert ZodString with email format", () => {
19
+ const schema = z.string().email();
20
+ const result = zodToOpenAPISchema(schema);
21
+
22
+ expect(result.type).toBe("string");
23
+ expect(result.format).toBe("email");
24
+ });
25
+
26
+ test("should convert ZodString with uuid format", () => {
27
+ const schema = z.string().uuid();
28
+ const result = zodToOpenAPISchema(schema);
29
+
30
+ expect(result.type).toBe("string");
31
+ expect(result.format).toBe("uuid");
32
+ });
33
+
34
+ test("should convert ZodString with datetime format", () => {
35
+ const schema = z.string().datetime();
36
+ const result = zodToOpenAPISchema(schema);
37
+
38
+ expect(result.type).toBe("string");
39
+ expect(result.format).toBe("date-time");
40
+ });
41
+
42
+ test("should convert ZodString with min/max length", () => {
43
+ const schema = z.string().min(2).max(100);
44
+ const result = zodToOpenAPISchema(schema);
45
+
46
+ expect(result.type).toBe("string");
47
+ expect(result.minLength).toBe(2);
48
+ expect(result.maxLength).toBe(100);
49
+ });
50
+
51
+ test("should convert ZodNumber", () => {
52
+ const schema = z.number();
53
+ const result = zodToOpenAPISchema(schema);
54
+
55
+ expect(result.type).toBe("number");
56
+ });
57
+
58
+ test("should convert ZodNumber with int", () => {
59
+ const schema = z.number().int();
60
+ const result = zodToOpenAPISchema(schema);
61
+
62
+ expect(result.type).toBe("integer");
63
+ });
64
+
65
+ test("should convert ZodNumber with min/max", () => {
66
+ const schema = z.number().min(1).max(100);
67
+ const result = zodToOpenAPISchema(schema);
68
+
69
+ expect(result.type).toBe("number");
70
+ expect(result.minimum).toBe(1);
71
+ expect(result.maximum).toBe(100);
72
+ });
73
+
74
+ test("should convert ZodBoolean", () => {
75
+ const schema = z.boolean();
76
+ const result = zodToOpenAPISchema(schema);
77
+
78
+ expect(result.type).toBe("boolean");
79
+ });
80
+ });
81
+
82
+ describe("complex types", () => {
83
+ test("should convert ZodArray", () => {
84
+ const schema = z.array(z.string());
85
+ const result = zodToOpenAPISchema(schema);
86
+
87
+ expect(result.type).toBe("array");
88
+ expect(result.items).toEqual({ type: "string" });
89
+ });
90
+
91
+ test("should convert ZodObject", () => {
92
+ const schema = z.object({
93
+ name: z.string(),
94
+ age: z.number(),
95
+ });
96
+ const result = zodToOpenAPISchema(schema);
97
+
98
+ expect(result.type).toBe("object");
99
+ expect(result.properties).toBeDefined();
100
+ expect(result.properties!.name).toEqual({ type: "string" });
101
+ expect(result.properties!.age).toEqual({ type: "number" });
102
+ expect(result.required).toContain("name");
103
+ expect(result.required).toContain("age");
104
+ });
105
+
106
+ test("should convert ZodObject with optional fields", () => {
107
+ const schema = z.object({
108
+ name: z.string(),
109
+ nickname: z.string().optional(),
110
+ });
111
+ const result = zodToOpenAPISchema(schema);
112
+
113
+ expect(result.type).toBe("object");
114
+ expect(result.required).toContain("name");
115
+ expect(result.required).not.toContain("nickname");
116
+ });
117
+
118
+ test("should convert ZodEnum", () => {
119
+ const schema = z.enum(["admin", "user", "guest"]);
120
+ const result = zodToOpenAPISchema(schema);
121
+
122
+ expect(result.type).toBe("string");
123
+ expect(result.enum).toEqual(["admin", "user", "guest"]);
124
+ });
125
+
126
+ test("should convert ZodUnion", () => {
127
+ const schema = z.union([z.string(), z.number()]);
128
+ const result = zodToOpenAPISchema(schema);
129
+
130
+ expect(result.oneOf).toBeDefined();
131
+ expect(result.oneOf!.length).toBe(2);
132
+ expect(result.oneOf![0]).toEqual({ type: "string" });
133
+ expect(result.oneOf![1]).toEqual({ type: "number" });
134
+ });
135
+ });
136
+
137
+ describe("modifiers", () => {
138
+ test("should convert ZodOptional", () => {
139
+ const schema = z.string().optional();
140
+ const result = zodToOpenAPISchema(schema);
141
+
142
+ expect(result.type).toBe("string");
143
+ expect(result.nullable).toBe(true);
144
+ });
145
+
146
+ test("should convert ZodNullable", () => {
147
+ const schema = z.string().nullable();
148
+ const result = zodToOpenAPISchema(schema);
149
+
150
+ expect(result.type).toBe("string");
151
+ expect(result.nullable).toBe(true);
152
+ });
153
+
154
+ test("should convert ZodDefault", () => {
155
+ const schema = z.number().default(10);
156
+ const result = zodToOpenAPISchema(schema);
157
+
158
+ expect(result.type).toBe("number");
159
+ expect(result.default).toBe(10);
160
+ });
161
+
162
+ test("should handle coerce", () => {
163
+ const schema = z.coerce.number();
164
+ const result = zodToOpenAPISchema(schema);
165
+
166
+ expect(result.type).toBe("number");
167
+ });
168
+ });
169
+
170
+ describe("nested schemas", () => {
171
+ test("should convert nested object", () => {
172
+ const schema = z.object({
173
+ user: z.object({
174
+ name: z.string(),
175
+ email: z.string().email(),
176
+ }),
177
+ posts: z.array(
178
+ z.object({
179
+ id: z.number(),
180
+ title: z.string(),
181
+ })
182
+ ),
183
+ });
184
+ const result = zodToOpenAPISchema(schema);
185
+
186
+ expect(result.type).toBe("object");
187
+ expect(result.properties!.user.type).toBe("object");
188
+ expect(result.properties!.user.properties!.name.type).toBe("string");
189
+ expect(result.properties!.user.properties!.email.format).toBe("email");
190
+ expect(result.properties!.posts.type).toBe("array");
191
+ expect(result.properties!.posts.items!.type).toBe("object");
192
+ });
193
+ });
194
+ });
195
+
196
+ describe("openAPIToJSON", () => {
197
+ test("should convert OpenAPI document to JSON", () => {
198
+ const doc = {
199
+ openapi: "3.0.3" as const,
200
+ info: {
201
+ title: "Test API",
202
+ version: "1.0.0",
203
+ },
204
+ paths: {
205
+ "/users": {
206
+ get: {
207
+ summary: "List users",
208
+ responses: {
209
+ "200": {
210
+ description: "OK",
211
+ },
212
+ },
213
+ },
214
+ },
215
+ },
216
+ };
217
+
218
+ const json = openAPIToJSON(doc);
219
+ const parsed = JSON.parse(json);
220
+
221
+ expect(parsed.openapi).toBe("3.0.3");
222
+ expect(parsed.info.title).toBe("Test API");
223
+ expect(parsed.paths["/users"].get.summary).toBe("List users");
224
+ });
225
+ });
226
+
227
+ describe("Real-world contract conversion", () => {
228
+ test("should convert complex user contract", () => {
229
+ const UserSchema = z.object({
230
+ id: z.string().uuid(),
231
+ email: z.string().email(),
232
+ name: z.string().min(2).max(100),
233
+ role: z.enum(["admin", "user", "guest"]),
234
+ createdAt: z.string().datetime(),
235
+ metadata: z
236
+ .object({
237
+ lastLogin: z.string().datetime().optional(),
238
+ loginCount: z.number().int().min(0).default(0),
239
+ })
240
+ .optional(),
241
+ });
242
+
243
+ const result = zodToOpenAPISchema(UserSchema);
244
+
245
+ expect(result.type).toBe("object");
246
+ expect(result.properties!.id.format).toBe("uuid");
247
+ expect(result.properties!.email.format).toBe("email");
248
+ expect(result.properties!.name.minLength).toBe(2);
249
+ expect(result.properties!.name.maxLength).toBe(100);
250
+ expect(result.properties!.role.enum).toEqual(["admin", "user", "guest"]);
251
+ expect(result.properties!.createdAt.format).toBe("date-time");
252
+ expect(result.required).toContain("id");
253
+ expect(result.required).toContain("email");
254
+ expect(result.required).not.toContain("metadata");
255
+ });
256
+
257
+ test("should convert paginated response schema", () => {
258
+ const PaginatedSchema = z.object({
259
+ data: z.array(z.object({ id: z.number(), name: z.string() })),
260
+ pagination: z.object({
261
+ page: z.number().int().min(1),
262
+ limit: z.number().int().min(1).max(100),
263
+ total: z.number().int(),
264
+ totalPages: z.number().int(),
265
+ }),
266
+ });
267
+
268
+ const result = zodToOpenAPISchema(PaginatedSchema);
269
+
270
+ expect(result.type).toBe("object");
271
+ expect(result.properties!.data.type).toBe("array");
272
+ expect(result.properties!.data.items!.type).toBe("object");
273
+ expect(result.properties!.pagination.type).toBe("object");
274
+ expect(result.properties!.pagination.properties!.page.type).toBe("integer");
275
+ expect(result.properties!.pagination.properties!.page.minimum).toBe(1);
276
+ });
277
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Slot Validator Tests
3
+ * 개선된 Slot 검증 기능 테스트
4
+ */
5
+
6
+ import { describe, test, expect } from "bun:test";
7
+ import { validateSlotContent, summarizeValidationIssues } from "./validator";
8
+
9
+ describe("Slot Validator - 기본 검증", () => {
10
+ test("올바른 Slot 파일은 통과해야 함", () => {
11
+ const validSlot = `
12
+ import { Mandu } from "@mandujs/core";
13
+
14
+ export default Mandu.filling()
15
+ .get((ctx) => {
16
+ return ctx.ok({ data: [] });
17
+ })
18
+ .post(async (ctx) => {
19
+ const body = await ctx.body();
20
+ return ctx.created({ data: body });
21
+ });
22
+ `;
23
+
24
+ const result = validateSlotContent(validSlot);
25
+ expect(result.valid).toBe(true);
26
+ expect(result.issues.filter((i) => i.severity === "error")).toHaveLength(0);
27
+ });
28
+
29
+ test("Mandu import 누락 감지", () => {
30
+ const noImport = `
31
+ export default Mandu.filling()
32
+ .get((ctx) => ctx.ok({ data: [] }));
33
+ `;
34
+
35
+ const result = validateSlotContent(noImport);
36
+ expect(result.valid).toBe(false);
37
+ expect(result.issues.some((i) => i.code === "MISSING_MANDU_IMPORT")).toBe(true);
38
+ });
39
+
40
+ test("Mandu.filling() 패턴 누락 감지", () => {
41
+ const noFilling = `
42
+ import { Mandu } from "@mandujs/core";
43
+
44
+ export default {
45
+ get: (ctx) => ctx.ok({ data: [] })
46
+ };
47
+ `;
48
+
49
+ const result = validateSlotContent(noFilling);
50
+ expect(result.valid).toBe(false);
51
+ expect(result.issues.some((i) => i.code === "MISSING_FILLING_PATTERN")).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("Slot Validator - export default 검증 강화", () => {
56
+ test("export default 누락 감지 (변수에 할당만 한 경우)", () => {
57
+ const noExport = `
58
+ import { Mandu } from "@mandujs/core";
59
+
60
+ const myFilling = Mandu.filling()
61
+ .get((ctx) => {
62
+ return ctx.ok({ data: [] });
63
+ });
64
+
65
+ // export default 빠짐!
66
+ `;
67
+
68
+ const result = validateSlotContent(noExport);
69
+ expect(result.valid).toBe(false);
70
+ const exportError = result.issues.find((i) => i.code === "MISSING_DEFAULT_EXPORT");
71
+ expect(exportError).toBeDefined();
72
+ expect(exportError?.message).toContain("myFilling");
73
+ });
74
+
75
+ test("변수를 export default로 내보내면 통과", () => {
76
+ const withExport = `
77
+ import { Mandu } from "@mandujs/core";
78
+
79
+ const myFilling = Mandu.filling()
80
+ .get((ctx) => {
81
+ return ctx.ok({ data: [] });
82
+ });
83
+
84
+ export default myFilling;
85
+ `;
86
+
87
+ const result = validateSlotContent(withExport);
88
+ expect(result.valid).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("Slot Validator - 응답 패턴 검증 강화", () => {
93
+ test("ctx.ok() 등 응답 메서드 없으면 에러", () => {
94
+ const noResponse = `
95
+ import { Mandu } from "@mandujs/core";
96
+
97
+ export default Mandu.filling()
98
+ .get((ctx) => {
99
+ // 응답 메서드 호출 없음
100
+ });
101
+ `;
102
+
103
+ const result = validateSlotContent(noResponse);
104
+ expect(result.valid).toBe(false);
105
+ expect(result.issues.some((i) => i.code === "NO_RESPONSE_PATTERN")).toBe(true);
106
+ });
107
+
108
+ test("일반 객체 직접 반환 감지", () => {
109
+ const directObject = `
110
+ import { Mandu } from "@mandujs/core";
111
+
112
+ export default Mandu.filling()
113
+ .get((ctx) => {
114
+ return { data: [], status: "ok" };
115
+ });
116
+ `;
117
+
118
+ const result = validateSlotContent(directObject);
119
+ // 응답 패턴이 없으므로 에러
120
+ expect(result.issues.some((i) => i.code === "NO_RESPONSE_PATTERN")).toBe(true);
121
+ });
122
+
123
+ test("다양한 ctx 응답 메서드 허용", () => {
124
+ const validResponses = `
125
+ import { Mandu } from "@mandujs/core";
126
+
127
+ export default Mandu.filling()
128
+ .get((ctx) => ctx.ok({ data: [] }))
129
+ .post((ctx) => ctx.created({ data: {} }))
130
+ .put((ctx) => ctx.json({ updated: true }))
131
+ .delete((ctx) => ctx.noContent());
132
+ `;
133
+
134
+ const result = validateSlotContent(validResponses);
135
+ expect(result.valid).toBe(true);
136
+ });
137
+ });
138
+
139
+ describe("Slot Validator - 문법 검사", () => {
140
+ test("괄호 불균형 감지 - 중괄호", () => {
141
+ const unbalancedBraces = `
142
+ import { Mandu } from "@mandujs/core";
143
+
144
+ export default Mandu.filling()
145
+ .get((ctx) => {
146
+ return ctx.ok({ data: []; // 중괄호 } 누락
147
+ });
148
+ `;
149
+
150
+ const result = validateSlotContent(unbalancedBraces);
151
+ expect(result.valid).toBe(false);
152
+ expect(result.issues.some((i) => i.code === "UNBALANCED_BRACES")).toBe(true);
153
+ });
154
+
155
+ test("괄호 불균형 감지 - 소괄호", () => {
156
+ const unbalancedParens = `
157
+ import { Mandu } from "@mandujs/core";
158
+
159
+ export default Mandu.filling()
160
+ .get((ctx) => {
161
+ return ctx.ok({ data: [] }; // 소괄호 ) 누락
162
+ });
163
+ `;
164
+
165
+ const result = validateSlotContent(unbalancedParens);
166
+ expect(result.valid).toBe(false);
167
+ expect(result.issues.some((i) => i.code === "UNBALANCED_PARENTHESES")).toBe(true);
168
+ });
169
+
170
+ test("금지된 모듈 import 감지", () => {
171
+ const forbidden = `
172
+ import { Mandu } from "@mandujs/core";
173
+ import fs from "fs";
174
+
175
+ export default Mandu.filling()
176
+ .get((ctx) => {
177
+ return ctx.ok({ data: [] });
178
+ });
179
+ `;
180
+
181
+ const result = validateSlotContent(forbidden);
182
+ expect(result.valid).toBe(false);
183
+ expect(result.issues.some((i) => i.code === "FORBIDDEN_IMPORT")).toBe(true);
184
+ });
185
+ });
186
+
187
+ describe("Slot Validator - summarizeValidationIssues", () => {
188
+ test("에러와 경고 개수 요약", () => {
189
+ const issues = [
190
+ { code: "A", severity: "error" as const, message: "", suggestion: "", autoFixable: false },
191
+ { code: "B", severity: "error" as const, message: "", suggestion: "", autoFixable: false },
192
+ { code: "C", severity: "warning" as const, message: "", suggestion: "", autoFixable: false },
193
+ ];
194
+
195
+ const summary = summarizeValidationIssues(issues);
196
+ expect(summary).toBe("2개 에러, 1개 경고");
197
+ });
198
+
199
+ test("문제 없으면 '문제 없음' 반환", () => {
200
+ const summary = summarizeValidationIssues([]);
201
+ expect(summary).toBe("문제 없음");
202
+ });
203
+ });