@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,394 @@
1
+ /**
2
+ * Contract System Integration Tests
3
+ * End-to-end testing of the Contract-first API workflow
4
+ */
5
+
6
+ import { describe, test, expect } from "bun:test";
7
+ import { z } from "zod";
8
+ import { createContract } from "./index";
9
+ import { ContractValidator } from "./validator";
10
+ import { zodToOpenAPISchema } from "../openapi/generator";
11
+ import { generateContractTemplate, generateContractTypeGlue } from "../generator/contract-glue";
12
+ import type { RouteSpec } from "../spec/schema";
13
+
14
+ describe("Contract System Integration", () => {
15
+ // Define a realistic User schema
16
+ const UserSchema = z.object({
17
+ id: z.string().uuid(),
18
+ email: z.string().email(),
19
+ name: z.string().min(2).max(100),
20
+ role: z.enum(["admin", "user", "guest"]),
21
+ createdAt: z.string().datetime(),
22
+ metadata: z
23
+ .object({
24
+ lastLogin: z.string().datetime().optional(),
25
+ loginCount: z.number().int().min(0).default(0),
26
+ })
27
+ .optional(),
28
+ });
29
+
30
+ const CreateUserInput = UserSchema.omit({ id: true, createdAt: true, metadata: true });
31
+
32
+ const UserListQuery = z.object({
33
+ page: z.coerce.number().int().min(1).default(1),
34
+ limit: z.coerce.number().int().min(1).max(100).default(10),
35
+ role: z.enum(["admin", "user", "guest"]).optional(),
36
+ search: z.string().optional(),
37
+ });
38
+
39
+ // Create the contract
40
+ const usersContract = createContract({
41
+ description: "User Management API",
42
+ tags: ["users", "admin"],
43
+
44
+ request: {
45
+ GET: {
46
+ query: UserListQuery,
47
+ },
48
+ POST: {
49
+ body: CreateUserInput,
50
+ },
51
+ PUT: {
52
+ params: z.object({ id: z.string().uuid() }),
53
+ body: CreateUserInput.partial(),
54
+ },
55
+ DELETE: {
56
+ params: z.object({ id: z.string().uuid() }),
57
+ },
58
+ },
59
+
60
+ response: {
61
+ 200: z.object({
62
+ data: z.union([UserSchema, z.array(UserSchema)]),
63
+ pagination: z
64
+ .object({
65
+ page: z.number(),
66
+ limit: z.number(),
67
+ total: z.number(),
68
+ })
69
+ .optional(),
70
+ }),
71
+ 201: z.object({ data: UserSchema }),
72
+ 204: z.void(),
73
+ 400: z.object({
74
+ error: z.string(),
75
+ details: z
76
+ .array(
77
+ z.object({
78
+ type: z.string(),
79
+ issues: z.array(z.object({ path: z.string(), message: z.string() })),
80
+ })
81
+ )
82
+ .optional(),
83
+ }),
84
+ 404: z.object({ error: z.string() }),
85
+ },
86
+ });
87
+
88
+ describe("Contract Creation", () => {
89
+ test("should create contract with all properties", () => {
90
+ expect(usersContract.description).toBe("User Management API");
91
+ expect(usersContract.tags).toEqual(["users", "admin"]);
92
+ expect(usersContract.request.GET).toBeDefined();
93
+ expect(usersContract.request.POST).toBeDefined();
94
+ expect(usersContract.request.PUT).toBeDefined();
95
+ expect(usersContract.request.DELETE).toBeDefined();
96
+ expect(usersContract.response[200]).toBeDefined();
97
+ expect(usersContract.response[201]).toBeDefined();
98
+ expect(usersContract.response[204]).toBeDefined();
99
+ expect(usersContract.response[400]).toBeDefined();
100
+ expect(usersContract.response[404]).toBeDefined();
101
+ });
102
+ });
103
+
104
+ describe("Request Validation Flow", () => {
105
+ const validator = new ContractValidator(usersContract);
106
+
107
+ test("should validate successful GET request", async () => {
108
+ const req = new Request("http://localhost/api/users?page=2&limit=20&role=admin");
109
+ const result = await validator.validateRequest(req, "GET");
110
+
111
+ expect(result.success).toBe(true);
112
+ });
113
+
114
+ test("should validate successful POST request", async () => {
115
+ const req = new Request("http://localhost/api/users", {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({
119
+ email: "newuser@example.com",
120
+ name: "New User",
121
+ role: "user",
122
+ }),
123
+ });
124
+ const result = await validator.validateRequest(req, "POST");
125
+
126
+ expect(result.success).toBe(true);
127
+ });
128
+
129
+ test("should fail POST with invalid email", async () => {
130
+ const req = new Request("http://localhost/api/users", {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({
134
+ email: "invalid-email",
135
+ name: "Test User",
136
+ role: "user",
137
+ }),
138
+ });
139
+ const result = await validator.validateRequest(req, "POST");
140
+
141
+ expect(result.success).toBe(false);
142
+ expect(result.errors![0].type).toBe("body");
143
+ expect(result.errors![0].issues.some((i) => i.path.includes("email"))).toBe(true);
144
+ });
145
+
146
+ test("should fail POST with invalid role", async () => {
147
+ const req = new Request("http://localhost/api/users", {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({
151
+ email: "test@example.com",
152
+ name: "Test User",
153
+ role: "superadmin", // Invalid
154
+ }),
155
+ });
156
+ const result = await validator.validateRequest(req, "POST");
157
+
158
+ expect(result.success).toBe(false);
159
+ });
160
+
161
+ test("should validate PUT with params and body", async () => {
162
+ const req = new Request("http://localhost/api/users/550e8400-e29b-41d4-a716-446655440000", {
163
+ method: "PUT",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({
166
+ name: "Updated Name",
167
+ }),
168
+ });
169
+ const result = await validator.validateRequest(req, "PUT", {
170
+ id: "550e8400-e29b-41d4-a716-446655440000",
171
+ });
172
+
173
+ expect(result.success).toBe(true);
174
+ });
175
+
176
+ test("should fail PUT with invalid UUID param", async () => {
177
+ const req = new Request("http://localhost/api/users/invalid-id", {
178
+ method: "PUT",
179
+ headers: { "Content-Type": "application/json" },
180
+ body: JSON.stringify({
181
+ name: "Updated Name",
182
+ }),
183
+ });
184
+ const result = await validator.validateRequest(req, "PUT", {
185
+ id: "invalid-id",
186
+ });
187
+
188
+ expect(result.success).toBe(false);
189
+ expect(result.errors![0].type).toBe("params");
190
+ });
191
+ });
192
+
193
+ describe("Response Validation Flow", () => {
194
+ const validator = new ContractValidator(usersContract);
195
+
196
+ test("should validate 200 response with user list", () => {
197
+ const response = {
198
+ data: [
199
+ {
200
+ id: "550e8400-e29b-41d4-a716-446655440000",
201
+ email: "user@example.com",
202
+ name: "Test User",
203
+ role: "user",
204
+ createdAt: "2024-01-15T10:30:00Z",
205
+ },
206
+ ],
207
+ pagination: {
208
+ page: 1,
209
+ limit: 10,
210
+ total: 100,
211
+ },
212
+ };
213
+ const result = validator.validateResponse(response, 200);
214
+
215
+ expect(result.success).toBe(true);
216
+ });
217
+
218
+ test("should validate 201 response with created user", () => {
219
+ const response = {
220
+ data: {
221
+ id: "550e8400-e29b-41d4-a716-446655440000",
222
+ email: "newuser@example.com",
223
+ name: "New User",
224
+ role: "user",
225
+ createdAt: "2024-01-15T10:30:00Z",
226
+ },
227
+ };
228
+ const result = validator.validateResponse(response, 201);
229
+
230
+ expect(result.success).toBe(true);
231
+ });
232
+
233
+ test("should validate 400 response with validation errors", () => {
234
+ const response = {
235
+ error: "Validation Error",
236
+ details: [
237
+ {
238
+ type: "body",
239
+ issues: [
240
+ { path: "email", message: "Invalid email format" },
241
+ { path: "name", message: "Name is required" },
242
+ ],
243
+ },
244
+ ],
245
+ };
246
+ const result = validator.validateResponse(response, 400);
247
+
248
+ expect(result.success).toBe(true);
249
+ });
250
+
251
+ test("should fail 200 response with invalid data", () => {
252
+ const response = {
253
+ data: [
254
+ {
255
+ id: "not-a-uuid",
256
+ email: "invalid",
257
+ name: "X", // Too short
258
+ role: "unknown", // Invalid
259
+ createdAt: "not-a-date",
260
+ },
261
+ ],
262
+ };
263
+ const result = validator.validateResponse(response, 200);
264
+
265
+ expect(result.success).toBe(false);
266
+ });
267
+ });
268
+
269
+ describe("OpenAPI Schema Generation", () => {
270
+ test("should convert UserSchema to OpenAPI", () => {
271
+ const openApiSchema = zodToOpenAPISchema(UserSchema);
272
+
273
+ expect(openApiSchema.type).toBe("object");
274
+ expect(openApiSchema.properties!.id.format).toBe("uuid");
275
+ expect(openApiSchema.properties!.email.format).toBe("email");
276
+ expect(openApiSchema.properties!.name.minLength).toBe(2);
277
+ expect(openApiSchema.properties!.name.maxLength).toBe(100);
278
+ expect(openApiSchema.properties!.role.enum).toEqual(["admin", "user", "guest"]);
279
+ expect(openApiSchema.required).toContain("id");
280
+ expect(openApiSchema.required).toContain("email");
281
+ expect(openApiSchema.required).toContain("name");
282
+ expect(openApiSchema.required).not.toContain("metadata");
283
+ });
284
+
285
+ test("should convert UserListQuery to OpenAPI", () => {
286
+ const openApiSchema = zodToOpenAPISchema(UserListQuery);
287
+
288
+ expect(openApiSchema.type).toBe("object");
289
+ expect(openApiSchema.properties!.page.type).toBe("integer");
290
+ expect(openApiSchema.properties!.page.default).toBe(1);
291
+ expect(openApiSchema.properties!.limit.maximum).toBe(100);
292
+ });
293
+ });
294
+
295
+ describe("Contract Template Generation", () => {
296
+ test("should generate complete contract template", () => {
297
+ const route: RouteSpec = {
298
+ id: "users",
299
+ pattern: "/api/users",
300
+ kind: "api",
301
+ module: "generated/routes/api/users.ts",
302
+ methods: ["GET", "POST", "PUT", "DELETE"],
303
+ };
304
+
305
+ const template = generateContractTemplate(route);
306
+
307
+ // Should include all methods
308
+ expect(template).toContain("GET: {");
309
+ expect(template).toContain("POST: {");
310
+ expect(template).toContain("PUT: {");
311
+ expect(template).toContain("DELETE: {");
312
+
313
+ // Should be valid TypeScript syntax (basic check)
314
+ expect(template).toContain('import { z } from "zod"');
315
+ expect(template).toContain("export default Mandu.contract({");
316
+ expect(template).toContain("});");
317
+ });
318
+ });
319
+
320
+ describe("Type Glue Generation", () => {
321
+ test("should generate type glue for route", () => {
322
+ const route: RouteSpec = {
323
+ id: "users",
324
+ pattern: "/api/users",
325
+ kind: "api",
326
+ module: "generated/routes/api/users.ts",
327
+ contractModule: "spec/contracts/users.contract.ts",
328
+ slotModule: "spec/slots/users.slot.ts",
329
+ };
330
+
331
+ const glue = generateContractTypeGlue(route);
332
+
333
+ expect(glue).toContain("import type { InferContract");
334
+ expect(glue).toContain("export type UsersContract");
335
+ expect(glue).toContain("export type UsersGetQuery");
336
+ expect(glue).toContain("export type UsersPostBody");
337
+ expect(glue).toContain("export type UsersResponse200");
338
+ });
339
+ });
340
+
341
+ describe("Full Workflow Simulation", () => {
342
+ test("should complete full contract-first workflow", async () => {
343
+ // Step 1: Create contract
344
+ const contract = createContract({
345
+ description: "Test API",
346
+ request: {
347
+ POST: {
348
+ body: z.object({
349
+ title: z.string().min(1),
350
+ content: z.string(),
351
+ }),
352
+ },
353
+ },
354
+ response: {
355
+ 201: z.object({ id: z.number(), title: z.string() }),
356
+ 400: z.object({ error: z.string() }),
357
+ },
358
+ });
359
+
360
+ expect(contract).toBeDefined();
361
+
362
+ // Step 2: Create validator
363
+ const validator = new ContractValidator(contract);
364
+
365
+ // Step 3: Validate valid request
366
+ const validReq = new Request("http://localhost/api/posts", {
367
+ method: "POST",
368
+ headers: { "Content-Type": "application/json" },
369
+ body: JSON.stringify({ title: "Hello", content: "World" }),
370
+ });
371
+ const validResult = await validator.validateRequest(validReq, "POST");
372
+ expect(validResult.success).toBe(true);
373
+
374
+ // Step 4: Validate invalid request
375
+ const invalidReq = new Request("http://localhost/api/posts", {
376
+ method: "POST",
377
+ headers: { "Content-Type": "application/json" },
378
+ body: JSON.stringify({ title: "", content: "World" }),
379
+ });
380
+ const invalidResult = await validator.validateRequest(invalidReq, "POST");
381
+ expect(invalidResult.success).toBe(false);
382
+
383
+ // Step 5: Validate response
384
+ const response = { id: 1, title: "Hello" };
385
+ const responseResult = validator.validateResponse(response, 201);
386
+ expect(responseResult.success).toBe(true);
387
+
388
+ // Step 6: Generate OpenAPI schema
389
+ const openApiSchema = zodToOpenAPISchema(contract.response[201]);
390
+ expect(openApiSchema.type).toBe("object");
391
+ expect(openApiSchema.properties!.id.type).toBe("number");
392
+ });
393
+ });
394
+ });
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Mandu Contract Validator
3
3
  * 런타임 요청/응답 검증
4
+ *
5
+ * 모드:
6
+ * - lenient (기본): 응답 검증 실패 시 경고만 출력
7
+ * - strict: 응답 검증 실패 시 에러 반환 (프로덕션 안전성)
4
8
  */
5
9
 
6
10
  import type { z } from "zod";
@@ -12,6 +16,21 @@ import type {
12
16
  MethodRequestSchema,
13
17
  } from "./schema";
14
18
 
19
+ /**
20
+ * Validator 옵션
21
+ */
22
+ export interface ContractValidatorOptions {
23
+ /**
24
+ * strict: 응답 검증 실패 시 에러 Response 반환
25
+ * lenient: 응답 검증 실패 시 경고만 출력 (기본값)
26
+ */
27
+ mode?: "strict" | "lenient";
28
+ /**
29
+ * 응답 검증 실패 시 커스텀 에러 핸들러
30
+ */
31
+ onResponseViolation?: (errors: ContractValidationError[], statusCode: number) => void;
32
+ }
33
+
15
34
  /**
16
35
  * Parse query string from URL
17
36
  */
@@ -53,7 +72,31 @@ function zodErrorToIssues(error: z.ZodError): ContractValidationIssue[] {
53
72
  * Validates requests and responses against contract schemas
54
73
  */
55
74
  export class ContractValidator {
56
- constructor(private contract: ContractSchema) {}
75
+ private options: Required<ContractValidatorOptions>;
76
+
77
+ constructor(
78
+ private contract: ContractSchema,
79
+ options: ContractValidatorOptions = {}
80
+ ) {
81
+ this.options = {
82
+ mode: options.mode ?? "lenient",
83
+ onResponseViolation: options.onResponseViolation ?? (() => {}),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * 현재 모드 확인
89
+ */
90
+ isStrictMode(): boolean {
91
+ return this.options.mode === "strict";
92
+ }
93
+
94
+ /**
95
+ * 모드 변경
96
+ */
97
+ setMode(mode: "strict" | "lenient"): void {
98
+ this.options.mode = mode;
99
+ }
57
100
 
58
101
  /**
59
102
  * Validate incoming request against contract
@@ -164,7 +207,7 @@ export class ContractValidator {
164
207
  }
165
208
 
166
209
  /**
167
- * Validate response against contract (development mode)
210
+ * Validate response against contract
168
211
  * @param responseBody - The response body (already parsed)
169
212
  * @param statusCode - HTTP status code
170
213
  */
@@ -177,20 +220,82 @@ export class ContractValidator {
177
220
 
178
221
  const result = responseSchema.safeParse(responseBody);
179
222
  if (!result.success) {
223
+ const errors: ContractValidationError[] = [
224
+ {
225
+ type: "response",
226
+ issues: zodErrorToIssues(result.error),
227
+ },
228
+ ];
229
+
230
+ // 커스텀 핸들러 호출
231
+ this.options.onResponseViolation(errors, statusCode);
232
+
180
233
  return {
181
234
  success: false,
182
- errors: [
183
- {
184
- type: "response",
185
- issues: zodErrorToIssues(result.error),
186
- },
187
- ],
235
+ errors,
188
236
  };
189
237
  }
190
238
 
191
239
  return { success: true, data: result.data };
192
240
  }
193
241
 
242
+ /**
243
+ * Validate response and return error Response in strict mode
244
+ * @param response - The original Response object
245
+ * @returns Original response or error response
246
+ */
247
+ async validateResponseStrict(response: Response): Promise<{
248
+ valid: boolean;
249
+ response: Response;
250
+ errors?: ContractValidationError[];
251
+ }> {
252
+ // Clone response to read body
253
+ const cloned = response.clone();
254
+ const contentType = response.headers.get("content-type") || "";
255
+
256
+ // Only validate JSON responses
257
+ if (!contentType.includes("application/json")) {
258
+ return { valid: true, response };
259
+ }
260
+
261
+ let body: unknown;
262
+ try {
263
+ body = await cloned.json();
264
+ } catch {
265
+ return { valid: true, response }; // Can't parse, skip validation
266
+ }
267
+
268
+ const result = this.validateResponse(body, response.status);
269
+
270
+ if (!result.success) {
271
+ if (this.options.mode === "strict") {
272
+ // strict 모드: 에러 응답 반환
273
+ const errorResponse = Response.json(
274
+ {
275
+ errorType: "CONTRACT_VIOLATION",
276
+ code: "MANDU_C001",
277
+ message: "Response does not match contract schema",
278
+ summary: "응답이 Contract 스키마와 일치하지 않습니다",
279
+ statusCode: response.status,
280
+ violations: result.errors,
281
+ timestamp: new Date().toISOString(),
282
+ },
283
+ { status: 500 }
284
+ );
285
+ return { valid: false, response: errorResponse, errors: result.errors };
286
+ } else {
287
+ // lenient 모드: 경고만 출력하고 원래 응답 반환
288
+ console.warn(
289
+ "\x1b[33m[Mandu] Contract violation in response:\x1b[0m",
290
+ result.errors
291
+ );
292
+ return { valid: false, response, errors: result.errors };
293
+ }
294
+ }
295
+
296
+ return { valid: true, response };
297
+ }
298
+
194
299
  /**
195
300
  * Get all defined methods in this contract
196
301
  */