From 6130974bb94598a6d3daa8c3ed4af9af0950cdf4 Mon Sep 17 00:00:00 2001 From: zagabi Date: Sat, 9 Aug 2025 12:11:07 +0900 Subject: [PATCH 01/14] fix: og_image is not working (#231) related #230 --- itdoc-doc/docusaurus.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/itdoc-doc/docusaurus.config.ts b/itdoc-doc/docusaurus.config.ts index 83dbcb0..3602783 100644 --- a/itdoc-doc/docusaurus.config.ts +++ b/itdoc-doc/docusaurus.config.ts @@ -59,7 +59,7 @@ const config: Config = { ], ], themeConfig: { - image: "img/logo.jpg", + image: "img/logo.png", navbar: { title: "itdoc", logo: { From ff22da59f86b26aa96de4d5748d6207b3f820a4c Mon Sep 17 00:00:00 2001 From: zagabi Date: Fri, 22 Aug 2025 00:10:57 +0900 Subject: [PATCH 02/14] fix: resolve issue causing LLM script to not run properly (#235) * fix: llm script related #234 * refactor: Remove logic to generate files for debug * refactor(llm): clean up script, add error handling, and update docs - Removed unnecessary parts in the LLM script to simplify logic - Added error handling to improve stability and prevent silent failures - Updated related documentation to reflect the revised implementation * chore: add sinon to project dependencies - Installed sinon as a regular dependency - Ensures sinon is available in runtime environment - Edited getItdocPrompt annotation * chore(lockfile): regenerate pnpm-lock.yaml to match package.json --- bin/index.ts | 21 +- examples/express/__tests__/expressApp.test.js | 108 ++--- .../express/__tests__/expressApp2.test.js | 58 --- examples/express/expected/oas.json | 123 +---- examples/express/expressApp.js | 137 ++---- itdoc-doc/docs/experiments/LLM.mdx | 60 +-- lib/dsl/generator/OpenAPIGenerator.ts | 5 +- package.json | 5 +- pnpm-lock.yaml | 401 +++-------------- script/llm/examples/index.ts | 48 ++ script/llm/index.ts | 334 +++++--------- script/llm/loader/index.ts | 74 +++- .../parser/analyzer/returnValueExtractor.ts | 194 -------- script/llm/parser/analyzer/routeAnalyzer.ts | 31 +- .../llm/parser/analyzer/variableAnalyzer.ts | 193 ++++++-- script/llm/parser/utils/extractValue.ts | 419 +++++++++++++++--- script/llm/prompt/index.ts | 163 ++++--- script/makedocs/index.ts | 2 - 18 files changed, 983 insertions(+), 1393 deletions(-) delete mode 100644 examples/express/__tests__/expressApp2.test.js delete mode 100644 script/llm/parser/analyzer/returnValueExtractor.ts diff --git a/bin/index.ts b/bin/index.ts index e55edac..9240706 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -55,22 +55,19 @@ Example: program .command("generate") .description("Generate ITDOC test code based on LLM.") - .option("-p, --path ", "Path to the markdown test spec file.") .option("-a, --app ", "Path to the Express root app file.") .option("-e, --env ", "Path to the .env file.") - .action((options: { path?: string; env?: string; app?: string }) => { + .action(async (options: { env?: string; app?: string }) => { const envPath = options.env ? path.isAbsolute(options.env) ? options.env : path.resolve(process.cwd(), options.env) : path.resolve(process.cwd(), ".env") - if (!options.path && !options.app) { + if (!options.app) { logger.error( - "Either a test spec path (-p) or an Express app path (-a) must be provided. By default, the OpenAI key (OPENAI_API_KEY in .env) is loaded from the root directory, but you can customize the path if needed", + "An Express app path (-a) must be provided. By default, the OpenAI key (OPENAI_API_KEY in .env) is loaded from the root directory, but you can customize the path with -e/--env if needed.", ) - logger.info("ex) itdoc generate -p ../md/testspec.md") - logger.info("ex) itdoc generate --path ../md/testspec.md") logger.info("ex) itdoc generate -a ../app.js") logger.info("ex) itdoc generate --app ../app.js") logger.info("ex) itdoc generate -a ../app.js -e ") @@ -80,12 +77,14 @@ program logger.box("ITDOC LLM START") if (options.app) { const appPath = resolvePath(options.app) + logger.info(`Running analysis based on Express app path: ${appPath}`) - generateByLLM("", appPath, envPath) - } else if (options.path) { - const specPath = resolvePath(options.path) - logger.info(`Running analysis based on test spec (MD) path: ${specPath}`) - generateByLLM(specPath, "", envPath) + try { + await generateByLLM(appPath, envPath) + } catch (err) { + logger.error(`LLM generation failed: ${(err as Error).message}`) + process.exit(1) + } } }) diff --git a/examples/express/__tests__/expressApp.test.js b/examples/express/__tests__/expressApp.test.js index 028179c..d88b119 100644 --- a/examples/express/__tests__/expressApp.test.js +++ b/examples/express/__tests__/expressApp.test.js @@ -1,7 +1,60 @@ const app = require("../expressApp.js") const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") -const targetApp = app +const targetApp = app +describeAPI( + HttpMethod.POST, + "signup", + { + summary: "회원 가입 API", + tag: "Auth", + description: "사용자로 부터 아이디와 패스워드를 받아 회원가입을 수행합니다.", + }, + targetApp, + (apiDoc) => { + itDoc("회원가입 성공", async () => { + await apiDoc + .test() + .prettyPrint() + .req() + .body({ + username: field("사용자 이름", "username"), + password: field("패스워드", "P@ssw0rd123!@#"), + }) + .res() + .status(HttpStatus.CREATED) + }) + + itDoc("아이디를 입력하지 않으면 회원가입 실패한다.", async () => { + await apiDoc + .test() + .req() + .body({ + password: field("패스워드", "P@ssw0rd123!@#"), + }) + .res() + .status(HttpStatus.BAD_REQUEST) + .body({ + error: field("에러 메시지", "username is required"), + }) + }) + + itDoc("패스워드가 8자 미만이면 회원가입 실패한다.", async () => { + await apiDoc + .test() + .req() + .body({ + username: field("아이디", "penekhun"), + password: field("패스워드", "1234567"), + }) + .res() + .status(HttpStatus.BAD_REQUEST) + .body({ + error: field("에러 메시지", "password must be at least 8 characters"), + }) + }) + }, +) describeAPI( HttpMethod.GET, @@ -419,7 +472,7 @@ describeAPI( .test() .req() .header({ - "If-None-Match": field("ETag 값", '"abc123"'), + "if-none-match": field("ETag 값", '"abc123"'), Accept: "application/json", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", }) @@ -440,57 +493,6 @@ describeAPI( }) }, ) - -describeAPI( - HttpMethod.POST, - "/validate", - { - summary: "데이터 유효성 검증 API", - tag: "Validation", - description: "다양한 형태의 데이터 유효성을 검증하고 상세한 오류 정보를 제공합니다.", - }, - targetApp, - (apiDoc) => { - itDoc("다양한 필드 유효성 오류", async () => { - await apiDoc - .test() - .req() - .body({ - username: field("잘못된 사용자명", "a"), - email: field("잘못된 이메일", "not-an-email"), - age: field("잘못된 나이", -5), - registrationDate: field("잘못된 날짜", "2023-13-45"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - success: false, - errors: field("오류 목록", [ - { - field: "username", - message: "Username must be at least 3 characters", - code: "MIN_LENGTH", - }, - { - field: "email", - message: "Invalid email format", - code: "INVALID_FORMAT", - }, - { - field: "age", - message: "Age must be a positive number", - code: "POSITIVE_NUMBER", - }, - { - field: "registrationDate", - message: "Invalid date format", - code: "INVALID_DATE", - }, - ]), - }) - }) - }, -) describeAPI( HttpMethod.GET, "/failed-test", diff --git a/examples/express/__tests__/expressApp2.test.js b/examples/express/__tests__/expressApp2.test.js deleted file mode 100644 index a0067ef..0000000 --- a/examples/express/__tests__/expressApp2.test.js +++ /dev/null @@ -1,58 +0,0 @@ -const app = require("../expressApp.js") -const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") - -const targetApp = app - -describeAPI( - HttpMethod.POST, - "signup", - { - summary: "회원 가입 API", - tag: "Auth", - description: "사용자로 부터 아이디와 패스워드를 받아 회원가입을 수행합니다.", - }, - targetApp, - (apiDoc) => { - itDoc("회원가입 성공", async () => { - await apiDoc - .test() - .prettyPrint() - .req() - .body({ - username: field("사용자 이름", "username"), - password: field("패스워드", "P@ssw0rd123!@#"), - }) - .res() - .status(HttpStatus.CREATED) - }) - - itDoc("아이디를 입력하지 않으면 회원가입 실패한다.", async () => { - await apiDoc - .test() - .req() - .body({ - password: field("패스워드", "P@ssw0rd123!@#"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - error: field("에러 메시지", "username is required"), - }) - }) - - itDoc("패스워드가 8자 미만이면 회원가입 실패한다.", async () => { - await apiDoc - .test() - .req() - .body({ - username: field("아이디", "penekhun"), - password: field("패스워드", "1234567"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - error: field("에러 메시지", "password must be at least 8 characters"), - }) - }) - }, -) \ No newline at end of file diff --git a/examples/express/expected/oas.json b/examples/express/expected/oas.json index 9a51277..d680ccc 100644 --- a/examples/express/expected/oas.json +++ b/examples/express/expected/oas.json @@ -1179,7 +1179,7 @@ "operationId": "getCached-data", "parameters": [ { - "name": "If-None-Match", + "name": "if-none-match", "in": "header", "schema": { "type": "string", @@ -1261,127 +1261,6 @@ } } }, - "/validate": { - "post": { - "summary": "데이터 유효성 검증 API", - "tags": ["Validation"], - "description": "다양한 형태의 데이터 유효성을 검증하고 상세한 오류 정보를 제공합니다.", - "operationId": "postValidate", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "type": "string", - "example": "a", - "description": "잘못된 사용자명" - }, - "email": { - "type": "string", - "example": "not-an-email", - "description": "잘못된 이메일" - }, - "age": { - "type": "integer", - "example": -5, - "description": "잘못된 나이" - }, - "registrationDate": { - "type": "string", - "format": "date", - "example": "2023-13-45", - "description": "잘못된 날짜" - } - }, - "required": ["username", "email", "age", "registrationDate"] - }, - "example": { - "username": "a", - "email": "not-an-email", - "age": -5, - "registrationDate": "2023-13-45" - } - } - }, - "required": true - }, - "security": [{}], - "responses": { - "400": { - "description": "다양한 필드 유효성 오류", - "content": { - "application/json; charset=utf-8": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "example": "username" - }, - "message": { - "type": "string", - "example": "Username must be at least 3 characters" - }, - "code": { - "type": "string", - "example": "MIN_LENGTH" - } - } - }, - "description": "오류 목록", - "example": [ - { - "field": "username", - "message": "Username must be at least 3 characters", - "code": "MIN_LENGTH" - }, - { - "field": "email", - "message": "Invalid email format", - "code": "INVALID_FORMAT" - }, - { - "field": "age", - "message": "Age must be a positive number", - "code": "POSITIVE_NUMBER" - }, - { - "field": "registrationDate", - "message": "Invalid date format", - "code": "INVALID_DATE" - } - ] - } - }, - "required": ["errors"] - }, - "examples": { - "다양한 필드 유효성 오류": { - "value": { - "error": { - "message": "다양한 필드 유효성 오류", - "code": "ERROR_400" - } - } - } - } - } - } - } - } - } - }, "/failed-test": { "get": { "summary": "테스트 실패 유도 API", diff --git a/examples/express/expressApp.js b/examples/express/expressApp.js index be30aa5..83a9f65 100644 --- a/examples/express/expressApp.js +++ b/examples/express/expressApp.js @@ -5,26 +5,32 @@ const app = express() app.use(express.json()) app.post("/signup", function (req, res) { - const { username, password } = req.body - - if (!username) { - return res.status(400).json({ - error: "username is required", + try { + const { username, password } = req.body + + if (!username) { + return res.status(400).json({ + error: "username is required", + }) + } + + if (!password) { + return res.status(400).json({ + error: "password is required", + }) + } + + if (password.length < 8) { + return res.status(400).json({ + error: "password must be at least 8 characters", + }) + } + return res.status(201).json() + } catch (err) { + return res.status(500).json({ + error: "Internal Server Error", }) } - - if (!password) { - return res.status(400).json({ - error: "password is required", - }) - } - if (password.length < 8) { - return res.status(400).json({ - error: "password must be at least 8 characters", - }) - } - - return res.status(201).json() }) app.get("/users/:userId", (req, res) => { @@ -39,6 +45,7 @@ app.get("/users/:userId", (req, res) => { username: "hun", email: "penekhun@gmail.com", friends: ["zagabi", "json"], + // fetchedAt: new Date().toISOString(), }) }) @@ -97,8 +104,6 @@ app.get("/users", (req, res) => { error: "size are required", }) } - - // sample pagination const pageNumber = parseInt(page) const sizeNumber = parseInt(size) const startIndex = (pageNumber - 1) * sizeNumber @@ -120,7 +125,7 @@ app.get("/secret", (req, res) => { } res.set({ "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", + "Content-Type": "application/json; charset=utf-8", "itdoc-custom-Header": "secret-header-value", Authorization: "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyMDI1MDQwNiIsIm5hbWUiOiJpdGRvYyIsImFkbWluIjp0cnVlLCJpYXQiOjE3NDM5MjQzNDEsImV4cCI6MTc0MzkyNzk0MX0.LXswgSAv_hjAH3KntMqnr-aLxO4ZytGeXk5q8lzzUM8", @@ -130,7 +135,6 @@ app.get("/secret", (req, res) => { }) }) -// PUT 요청으로 사용자 정보 수정 API app.put("/users/:userId", (req, res) => { const { userId } = req.params @@ -147,7 +151,6 @@ app.put("/users/:userId", (req, res) => { } }) -// PATCH 요청으로 사용자 부분 정보 수정 API app.patch("/users/:userId", (req, res) => { const { userId } = req.params const { email } = req.body @@ -166,36 +169,6 @@ app.patch("/users/:userId", (req, res) => { } }) -// 프로필 이미지 업로드 API -app.post("/users/:userId/profile-image", (req, res) => { - const { userId } = req.params - const contentType = req.headers["content-type"] - - if (!contentType || !contentType.includes("multipart/form-data")) { - return res.status(400).json({ - success: false, - message: "Content-Type must be multipart/form-data", - }) - } - - // 파일 확장자 확인 (테스트 목적) - const fileExtension = - req.body && req.body.image ? req.body.image.split(".").pop().toLowerCase() : "" - - if (["jpg", "jpeg", "png", "gif"].includes(fileExtension)) { - return res.status(200).json({ - success: true, - imageUrl: `https://example.com/images/${userId}.jpg`, - }) - } else { - return res.status(400).json({ - success: false, - message: "Unsupported file type. Only jpg, png, gif are allowed.", - }) - } -}) - -// 주문 생성 API app.post("/orders", (req, res) => { const { authorization } = req.headers @@ -206,7 +179,6 @@ app.post("/orders", (req, res) => { }) } - // 간단한 검증만 수행 const { customer, items } = req.body if (!customer || !items) { @@ -224,9 +196,7 @@ app.post("/orders", (req, res) => { }) }) -// 상품 검색 API app.get("/products", (req, res) => { - // 모든 쿼리 파라미터 제공되었다고 가정 return res.status(200).json({ products: [ { @@ -254,10 +224,9 @@ app.get("/products", (req, res) => { }) }) -// 캐시된 데이터 조회 API app.get("/cached-data", (req, res) => { const ifNoneMatch = req.headers["if-none-match"] - + if (ifNoneMatch === '"abc123"') { res.setHeader("ETag", '"abc123"') res.setHeader("Cache-Control", "max-age=3600") @@ -276,59 +245,7 @@ app.get("/cached-data", (req, res) => { } }) -// 데이터 유효성 검증 API -app.post("/validate", (req, res) => { - const { username, email, age, registrationDate } = req.body - const errors = [] - - if (!username || username.length < 3) { - errors.push({ - field: "username", - message: "Username must be at least 3 characters", - code: "MIN_LENGTH", - }) - } - - if (!email || !email.includes("@")) { - errors.push({ - field: "email", - message: "Invalid email format", - code: "INVALID_FORMAT", - }) - } - - if (typeof age !== "number" || age <= 0) { - errors.push({ - field: "age", - message: "Age must be a positive number", - code: "POSITIVE_NUMBER", - }) - } - - if (registrationDate && !Date.parse(registrationDate)) { - errors.push({ - field: "registrationDate", - message: "Invalid date format", - code: "INVALID_DATE", - }) - } - - if (errors.length > 0) { - return res.status(400).json({ - success: false, - errors, - }) - } - - return res.status(200).json({ - success: true, - message: "All fields are valid", - }) -}) - -// 의도적으로 실패하는 테스트를 위한 API 엔드포인트 app.get("/failed-test", (req, res) => { - // 테스트에서는 200(OK)을 기대하지만 404를 반환하여 의도적으로 실패 return res.status(404).json({ message: "This endpoint is designed to make tests fail", }) diff --git a/itdoc-doc/docs/experiments/LLM.mdx b/itdoc-doc/docs/experiments/LLM.mdx index 619e7f8..6101ea1 100644 --- a/itdoc-doc/docs/experiments/LLM.mdx +++ b/itdoc-doc/docs/experiments/LLM.mdx @@ -194,62 +194,4 @@ itdoc generate --app {YOUR_APP_FILE_PATH} |------------------------------------------------------------------------|--------------------------------| | --app (-a) | Root app source code file path | -When you run this command, it analyzes the Express application defined in `{YOUR_APP_FILE_PATH}` and automatically generates tests for the API endpoints in that application. - -:::info[itdoc does not send source code to external servers] -It creates an `API Spec Markdown` through its own AST analysis and sends this to ChatGPT to generate test cases. -Therefore, you can use it with confidence as your source code is not sent to external servers. -::: - -### Generating Tests from API Spec Markdown - -You can automatically generate tests based on an `API Spec Markdown` file with the following command: - -```bash -itdoc generate --p {API_Spec_Markdown_FILE_PATH} -itdoc generate --path {API_Spec_Markdown_FILE_PATH} -``` - -| Option | Description | -|-------------------------------------------------------------------------|-------------------------------| -| --path (-p) | `API Spec Markdown` file path | - -The document format should follow this structure: - -```markdown title="api-specs.md" {1-4} -`HTTP_METHOD` `ENDPOINT` -- Test Case: Test case title - - Request: Description of the request - - Response: Description of the response -``` - -Here are examples for various API cases: - - - - - ```markdown title="Authentication API Example" - GET /secret - - Test Case: Access secret message with proper authentication - - Request: Send GET request with valid authentication header - - Response: Status code 200, JSON response with secret message and specific headers - - Test Case: Unauthorized access - - Request: Send GET request with invalid authentication header - - Response: Returns status code 401 - ``` - - - - - ```markdown title="File Upload API Example" - POST /users/:userId/profile-image - - Test Case: Successfully upload profile image - - Request: Send POST request with valid image file and "multipart/form-data" content type - - Response: Status code 200, JSON response with image URL - - Test Case: Wrong content type - - Request: Send POST request without "multipart/form-data" content type - - Response: Status code 400, JSON response with error message - ``` - - - +When you run this command, it analyzes the Express application defined in `{YOUR_APP_FILE_PATH}` and automatically generates tests for the API endpoints in that application. \ No newline at end of file diff --git a/lib/dsl/generator/OpenAPIGenerator.ts b/lib/dsl/generator/OpenAPIGenerator.ts index c2f55ec..99b48b9 100644 --- a/lib/dsl/generator/OpenAPIGenerator.ts +++ b/lib/dsl/generator/OpenAPIGenerator.ts @@ -34,6 +34,9 @@ interface OpenAPIInfo { /** * OpenAPI Specification generator + * + * It operates in a Singleton pattern and collects test results + * Create a Specification document in OpenAPI 3.0.0 format. */ export class OpenAPIGenerator implements IOpenAPIGenerator { private testResults: TestResult[] = [] @@ -113,8 +116,6 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { groupedResults.get(path)!.get(method)!.get(statusCode)!.push(result) } - logger.info("Grouped test results:", groupedResults) - return groupedResults } diff --git a/package.json b/package.json index f6488af..8f42521 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,8 @@ "lodash": "^4.17.21", "openai": "^4.90.0", "supertest": "^7.0.0", - "widdershins": "^4.0.1" + "widdershins": "^4.0.1", + "sinon": "^20.0.0" }, "devDependencies": { "@eslint/js": "~9.17", @@ -126,7 +127,7 @@ "jest": "^29.0.0", "mocha": "^11.0.0" }, - "packageManager": "pnpm@10.5.2", + "packageManager": "pnpm@10.15.0", "engines": { "node": ">=20" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 870fd13..78d016a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@redocly/cli': specifier: ^1.34.0 - version: 1.34.0(ajv@5.5.2)(supports-color@10.0.0) + version: 1.34.0(ajv@8.17.1)(supports-color@10.0.0) '@redocly/openapi-core': specifier: ^1.34.2 version: 1.34.2(supports-color@10.0.0) @@ -47,12 +47,15 @@ importers: openai: specifier: ^4.90.0 version: 4.90.0(ws@8.18.1) + sinon: + specifier: ^20.0.0 + version: 20.0.0 supertest: specifier: ^7.0.0 version: 7.0.0(supports-color@10.0.0) widdershins: specifier: ^4.0.1 - version: 4.0.1(ajv@5.5.2)(mkdirp@0.5.6)(supports-color@10.0.0) + version: 4.0.1(ajv@8.17.1)(mkdirp@0.5.6)(supports-color@10.0.0) devDependencies: '@eslint/js': specifier: ~9.17 @@ -111,9 +114,6 @@ importers: rimraf: specifier: ^6.0.1 version: 6.0.1 - sinon: - specifier: ^20.0.0 - version: 20.0.0 sort-package-json: specifier: ^2.15.1 version: 2.15.1 @@ -159,7 +159,7 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) + version: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) jest-diff: specifier: ^29.7.0 version: 29.7.0 @@ -233,7 +233,7 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) examples/nestjs: dependencies: @@ -330,13 +330,13 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) mocha: specifier: ^10.3.0 version: 10.8.2 ts-jest: specifier: ^29.2.6 - version: 29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)))(typescript@5.7.3) itdoc-doc: dependencies: @@ -357,7 +357,7 @@ importers: version: 2.1.1 docusaurus: specifier: ^1.14.7 - version: 1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + version: 1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) prism-react-renderer: specifier: ^2.3.0 version: 2.4.1(react@19.0.0) @@ -10974,10 +10974,12 @@ packages: superagent@9.0.2: resolution: {integrity: sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==} engines: {node: '>=14.18.0'} + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net supertest@7.0.0: resolution: {integrity: sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==} engines: {node: '>=14.18.0'} + deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net supertest@7.1.4: resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} @@ -13592,7 +13594,7 @@ snapshots: react-dev-utils: 12.0.1(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) terser-webpack-plugin: 5.3.14(@swc/core@1.11.29)(esbuild@0.25.0)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) webpackbar: 6.0.1(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) transitivePeerDependencies: @@ -13717,7 +13719,7 @@ snapshots: tslib: 2.8.1 unified: 11.0.5 unist-util-visit: 5.0.0 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) vfile: 6.0.3 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) transitivePeerDependencies: @@ -14300,7 +14302,7 @@ snapshots: resolve-pathname: 3.0.0 shelljs: 0.8.5 tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) utility-types: 3.11.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) transitivePeerDependencies: @@ -14724,41 +14726,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0(supports-color@10.0.0) - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - '@types/node': 20.17.24 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0(supports-color@10.0.0) - jest-runner: 29.7.0(supports-color@10.0.0) - jest-runtime: 29.7.0(supports-color@10.0.0) - jest-snapshot: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3))': dependencies: '@jest/console': 29.7.0 @@ -14794,41 +14761,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0(supports-color@10.0.0) - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - '@types/node': 20.17.24 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0(supports-color@10.0.0) - jest-runner: 29.7.0(supports-color@10.0.0) - jest-runtime: 29.7.0(supports-color@10.0.0) - jest-snapshot: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3))': dependencies: '@jest/console': 30.0.5 @@ -15544,7 +15476,7 @@ snapshots: require-from-string: 2.0.2 uri-js-replace: 1.0.1 - '@redocly/cli@1.34.0(ajv@5.5.2)(supports-color@10.0.0)': + '@redocly/cli@1.34.0(ajv@8.17.1)(supports-color@10.0.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/exporter-trace-otlp-http': 0.53.0(@opentelemetry/api@1.9.0) @@ -15553,7 +15485,7 @@ snapshots: '@opentelemetry/semantic-conventions': 1.27.0 '@redocly/config': 0.22.1 '@redocly/openapi-core': 1.34.0(supports-color@10.0.0) - '@redocly/respect-core': 1.34.0(ajv@5.5.2)(supports-color@10.0.0) + '@redocly/respect-core': 1.34.0(ajv@8.17.1)(supports-color@10.0.0) abort-controller: 3.0.0 chokidar: 3.6.0 colorette: 1.4.0 @@ -15610,12 +15542,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@redocly/respect-core@1.34.0(ajv@5.5.2)(supports-color@10.0.0)': + '@redocly/respect-core@1.34.0(ajv@8.17.1)(supports-color@10.0.0)': dependencies: '@faker-js/faker': 7.6.0 '@redocly/ajv': 8.11.2 '@redocly/openapi-core': 1.34.0(supports-color@10.0.0) - better-ajv-errors: 1.2.0(ajv@5.5.2) + better-ajv-errors: 1.2.0(ajv@8.17.1) colorette: 2.0.20 concat-stream: 2.0.0 cookie: 0.7.2 @@ -17177,11 +17109,22 @@ snapshots: jsonpointer: 4.1.0 leven: 3.1.0 - better-ajv-errors@1.2.0(ajv@5.5.2): + better-ajv-errors@0.6.7(ajv@8.17.1): + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.27.0 + ajv: 8.17.1 + chalk: 2.4.2 + core-js: 3.41.0 + json-to-ast: 2.1.0 + jsonpointer: 4.1.0 + leven: 3.1.0 + + better-ajv-errors@1.2.0(ajv@8.17.1): dependencies: '@babel/code-frame': 7.26.2 '@humanwhocodes/momoa': 2.0.4 - ajv: 5.5.2 + ajv: 8.17.1 chalk: 4.1.2 jsonpointer: 5.0.1 leven: 3.1.0 @@ -17971,21 +17914,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/types': 29.6.3 @@ -18001,21 +17929,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-require@1.1.1: {} cross-env@7.0.3: @@ -18607,7 +18520,7 @@ snapshots: dependencies: '@leichtgewicht/ip-codec': 2.0.5 - docusaurus@1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + docusaurus@1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) @@ -18647,7 +18560,7 @@ snapshots: postcss: 7.0.39 prismjs: 1.30.0 react: 16.14.0 - react-dev-utils: 11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + react-dev-utils: 11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) react-dom: 16.14.0(react@16.14.0) remarkable: 2.0.1 request: 2.88.2 @@ -19584,6 +19497,13 @@ snapshots: schema-utils: 3.3.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) + optional: true + file-type@10.11.0: {} file-type@19.6.0: @@ -19765,7 +19685,7 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + fork-ts-checker-webpack-plugin@4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/code-frame': 7.26.2 chalk: 2.4.2 @@ -19774,7 +19694,7 @@ snapshots: semver: 5.7.2 tapable: 1.1.3 typescript: 5.6.3 - webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) worker-rpc: 0.1.1 optionalDependencies: eslint: 9.17.0(jiti@1.21.7)(supports-color@10.0.0) @@ -21277,25 +21197,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-cli@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) @@ -21315,25 +21216,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-cli@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@jest/core': 30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) @@ -21384,37 +21266,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.17.24 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) @@ -21446,37 +21297,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.17.24 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) @@ -21508,37 +21328,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.15.21 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@babel/core': 7.28.0(supports-color@10.0.0) @@ -22020,18 +21809,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) @@ -22044,18 +21821,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@jest/core': 30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) @@ -24807,7 +24572,7 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-dev-utils@11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + react-dev-utils@11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/code-frame': 7.10.4 address: 1.1.2 @@ -24818,7 +24583,7 @@ snapshots: escape-string-regexp: 2.0.0 filesize: 6.1.0 find-up: 4.1.0 - fork-ts-checker-webpack-plugin: 4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + fork-ts-checker-webpack-plugin: 4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) global-modules: 2.0.0 globby: 11.0.1 gzip-size: 5.1.1 @@ -24833,7 +24598,7 @@ snapshots: shell-quote: 1.7.2 strip-ansi: 6.0.0 text-table: 0.2.0 - webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) optionalDependencies: typescript: 5.6.3 transitivePeerDependencies: @@ -26329,9 +26094,9 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 - swagger2openapi@6.2.3(ajv@5.5.2): + swagger2openapi@6.2.3(ajv@8.17.1): dependencies: - better-ajv-errors: 0.6.7(ajv@5.5.2) + better-ajv-errors: 0.6.7(ajv@8.17.1) call-me-maybe: 1.0.2 node-fetch-h2: 2.3.0 node-readfiles: 0.2.0 @@ -26602,26 +26367,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0) esbuild: 0.25.0 - ts-jest@29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)))(typescript@5.8.3): - dependencies: - bs-logger: 0.2.6 - ejs: 3.1.10 - fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.1 - typescript: 5.8.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.28.0(supports-color@10.0.0) - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0) - esbuild: 0.25.0 - ts-jest@29.4.0(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@30.0.5(supports-color@10.0.0))(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest-util@30.0.5)(jest@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 @@ -26674,27 +26419,6 @@ snapshots: '@swc/core': 1.11.29 optional: true - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.17.24 - acorn: 8.14.1 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.8.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.11.29 - optional: true - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -26715,27 +26439,6 @@ snapshots: optionalDependencies: '@swc/core': 1.11.29 - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.15.21 - acorn: 8.14.1 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.8.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.11.29 - optional: true - tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -27065,14 +26768,14 @@ snapshots: urix@0.1.0: {} - url-loader@4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + url-loader@4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) optionalDependencies: - file-loader: 6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + file-loader: 6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) url-parse-lax@1.0.0: dependencies: @@ -27407,7 +27110,7 @@ snapshots: dependencies: isexe: 2.0.0 - widdershins@4.0.1(ajv@5.5.2)(mkdirp@0.5.6)(supports-color@10.0.0): + widdershins@4.0.1(ajv@8.17.1)(mkdirp@0.5.6)(supports-color@10.0.0): dependencies: dot: 1.1.3 fast-safe-stringify: 2.1.1 @@ -27421,7 +27124,7 @@ snapshots: oas-schema-walker: 1.1.5 openapi-sampler: 1.6.1 reftools: 1.1.9 - swagger2openapi: 6.2.3(ajv@5.5.2) + swagger2openapi: 6.2.3(ajv@8.17.1) urijs: 1.19.11 yaml: 1.10.2 yargs: 12.0.5 diff --git a/script/llm/examples/index.ts b/script/llm/examples/index.ts index 57c4737..17bdae0 100644 --- a/script/llm/examples/index.ts +++ b/script/llm/examples/index.ts @@ -1,3 +1,19 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export const itdocExampleJs = ` describeAPI( HttpMethod.POST, @@ -35,6 +51,24 @@ describeAPI( error: field("에러 메세지", "username is required"), }) }) + itDoc("에러가 발생할 경우, 500 응답을 반환한다.", async () => { + const layer = getRouteLayer(targetApp, "post", "/signup") + sandbox.stub(layer, "handle").callsFake((req, res, next) => { + return res.status(500).json({ error: "Internal Server Error" }) + }) + await apiDoc + .test() + .req() + .body({ + username: field("사용자 이름", "hun"), + password: field("패스워드(8자 이상)", "12345678"), + }) + .res() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body({ + error: field("에러 메시지", "Internal Server Error"), + }) + }) }, ) @@ -64,6 +98,20 @@ describeAPI( friends: field("유저의 친구", ["zagabi", "json"]), }) }) + + itDoc("유효한 헤더로 접근하면 200 응답을 반환한다.", async () => { + await apiDoc + .test() + .req() + .queryParam({ + token: field("인증 토큰A", 123456) + }) + .header({ + Authorization: field("인증 토큰B", "Bearer 123456"), + }) + .res() + .status(HttpStatus.OK) + }) }, ) ` diff --git a/script/llm/index.ts b/script/llm/index.ts index cbb9126..248380c 100644 --- a/script/llm/index.ts +++ b/script/llm/index.ts @@ -19,214 +19,116 @@ import _ from "lodash" import fs from "fs" import path from "path" import dotenv from "dotenv" -import { getItdocPrompt, getMDPrompt } from "./prompt/index" +import { getItdocPrompt } from "./prompt/index" import logger from "../../lib/config/logger" import { loadFile } from "./loader/index" import { getOutputPath } from "../../lib/config/getOutputPath" import { analyzeRoutes } from "./parser/index" -import { parseSpecFile } from "../../lib/utils/specParser" -import { resolvePath } from "../../lib/utils/pathResolver" import { RouteResult } from "./parser/type/interface" /** - * Split raw Markdown into individual test blocks. - * Each block starts with an HTTP method line and includes subsequent bullet lines. - * @param {string} markdown - The raw Markdown string containing test definitions. - * @returns {string[]} Array of trimmed test block strings. + * Extracts a path prefix for grouping tests (first two non-empty segments). + * @param {string} pathStr - Full request path (e.g. "/api/products/123"). + * @returns {string} Normalized prefix (e.g. "/api/products"). Empty -> "/". */ -function splitTestBlocks(markdown: string): string[] { - const blockRegex = - /(?:^|\n)(?:-?\s*(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+[^\n]+)(?:\n(?:- .+))*/g - const raw = markdown.match(blockRegex) || [] - return raw.map((b) => b.trim()) -} -/** - * Extract the API path prefix from a test block. - * Strips any leading dash, matches the HTTP method and path, then returns the top two segments. - * @param {string} mdBlock - A single test block string. - * @returns {string} The normalized prefix (e.g. "/api/products"). - */ -function getMarkdownPrefix(mdBlock: string): string { - const firstLine = mdBlock.split("\n")[0].trim().replace(/^-\s*/, "") // remove leading “- ” - const m = firstLine.match( - /^(?:테스트 이름:\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([^\s]+)/i, - ) - if (!m) return "/unknown" - const path = m[2] - const parts = path.split("/").filter(Boolean) +function getPathPrefix(pathStr: string): string { + const parts = pathStr.split("/").filter(Boolean) return "/" + parts.slice(0, 2).join("/") } + /** - * Group test blocks by their path prefix and chunk each group into arrays of limited size. - * @param {string} markdown - The raw Markdown containing test blocks. - * @param {number} [chunkSize] - Maximum number of blocks per chunk. - * @returns {string[][]} Array of chunks, each a list of test block strings. + * Groups routes by prefix and chunks each group. + * @param {RouteResult[]} routes - Parsed route specs. + * @param {number} [chunkSize] - Max routes per chunk. + * @returns {RouteResult[][]} Chunked groups of routes. */ -function groupAndChunkMarkdownTests(markdown: string, chunkSize: number = 5): string[][] { - const blocks = splitTestBlocks(markdown) - const byPrefix: Record = {} - for (const blk of blocks) { - const prefix = getMarkdownPrefix(blk) - ;(byPrefix[prefix] ||= []).push(blk) +function groupAndChunkSpecRoutes(routes: RouteResult[], chunkSize: number = 10): RouteResult[][] { + const by: Record = {} + for (const r of routes) { + const prefix = getPathPrefix(r.path || "/unknown") + ;(by[prefix] ||= []).push(r) } - - const allChunks: string[][] = [] - for (const group of Object.values(byPrefix)) { - const chunks = _.chunk(group, chunkSize) - for (const c of chunks) { - allChunks.push(c) - } + const out: RouteResult[][] = [] + for (const group of Object.values(by)) { + for (const c of _.chunk(group, chunkSize)) out.push(c) } - - return allChunks + return out } + /** - * Convert grouped Markdown test definitions into itdoc-formatted TypeScript, - * calling the OpenAI API for each chunk. - * @param {OpenAI} openai - An initialized OpenAI client instance. - * @param {string} rawMarkdown - The raw Markdown test spec. - * @param {boolean} isEn - Whether to generate prompts/output in English. - * @param {boolean} [isTypeScript] - Whether output should use TypeScript syntax. - * @returns {Promise} The concatenated itdoc output or null on error. + * Creates itdoc test code from analyzed route JSON using an LLM. + * + * - Groups routes by prefix into chunks. + * - For each chunk, builds a prompt and calls OpenAI Chat Completions. + * - Concatenates all generated tests into a single string. + * @param {OpenAI} openai - OpenAI client. + * @param {RouteResult[]} raw - Array of analyzed route specs. + * @param {boolean} isEn - Output in English (true) or Korean (false). + * @param {boolean} [isTypeScript] - Generate TS-flavored examples. + * @returns {Promise} Generated test code or null on failure. */ -async function makeitdocByMD( +export async function makeitdoc( openai: OpenAI, - rawMarkdown: string, + raw: RouteResult[], isEn: boolean, isTypeScript: boolean = false, ): Promise { try { const maxRetry = 5 let result = "" - const chunks = groupAndChunkMarkdownTests(rawMarkdown, 5) + const specChunks = groupAndChunkSpecRoutes(raw) let gptCallCount = 0 - - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex] - const chunkContent = chunk.join("\n\n") + for (let chunkIndex = 0; chunkIndex < specChunks.length; chunkIndex++) { + const routesChunk = specChunks[chunkIndex] let chunkResult = "" + for (let retry = 0; retry < maxRetry; retry++) { gptCallCount++ - logger.info(`[makeitdocByMD] Attempting GPT call : ${gptCallCount} times`) - const msg = getItdocPrompt(chunkContent, isEn, retry + 1, isTypeScript) - const response: any = await openai.chat.completions.create({ - model: "gpt-4o", + logger.info(`[makeitdoc] Attempting GPT call : ${gptCallCount} times`) + const msg = getItdocPrompt(routesChunk, isEn, retry + 1, isTypeScript) + + const response = await openai.chat.completions.create({ + model: "gpt-5", messages: [{ role: "user", content: msg }], - temperature: 0, - max_tokens: 10000, + max_completion_tokens: 10000, }) - const text = response.choices[0].message.content?.trim() ?? "" - const finishReason = response.choices[0].finish_reason - const cleaned = text - .replace(/```(?:json|javascript|typescript|markdown)?/g, "") - .replace(/```/g, "") - .replace(/\(.*?\/.*?\)/g, "") - .trim() - chunkResult += cleaned + "\n" - if (finishReason === "stop") break - await new Promise((res) => setTimeout(res, 500)) - } - result += chunkResult.trim() + "\n\n" - await new Promise((res) => setTimeout(res, 500)) - } - return result.trim() - } catch (error: unknown) { - logger.error(`makeitdocByMD() ERROR: ${error}`) - return null - } -} -/** - * Extracts the top two segments of a URL path to use as a grouping prefix. - * @param {string} path - The full request path (e.g. "/api/products/123"). - * @returns {string} The normalized prefix (e.g. "/api/products"). - */ -function getPathPrefix(path: string): string { - const parts = path.split("/").filter(Boolean) - return "/" + parts.slice(0, 2).join("/") -} -/** - * Groups an array of route objects by their path prefix and then chunks each group. - * @param {{ path: string }[]} content - Array of route objects with a `path` property. - * @param {number} [chunkSize] - Maximum number of routes per chunk. Defaults to 5. - * @returns {any[][]} A list of route chunks, each chunk is an array of route objects. - */ -function groupAndChunkRoutes(content: any[], chunkSize: number = 5): any[][] { - const grouped = _.groupBy(content, (item: { path: string }) => getPathPrefix(item.path)) - const chunkedGroups: any[][] = [] - for (const groupItems of Object.values(grouped)) { - const chunks = _.chunk(groupItems, chunkSize) - chunkedGroups.push(...chunks) - } - return chunkedGroups -} -/** - * Generates a Markdown specification by batching routes into chunks and querying the LLM. - * @param {OpenAI} openai - An initialized OpenAI client. - * @param {any[]} content - Array of route definitions to generate spec for. - * @returns {Promise} The concatenated Markdown spec, or null if an error occurred. - */ + const choice = response.choices?.[0] + const text = choice?.message?.content?.trim() ?? "" + const finishReason = choice?.finish_reason ?? null -/** - * Generates a Markdown specification by analyzing app routes using OpenAI. - * @param {OpenAI} openai - An initialized OpenAI client instance. - * @param {RouteResult[]} content - Array of route definitions to generate spec for. - * @returns {Promise} The concatenated Markdown spec, or null if an error occurred. - */ -async function makeMDByApp(openai: OpenAI, content: RouteResult[]): Promise { - try { - let cnt = 0 - const chunks = groupAndChunkRoutes(content, 4) - const maxRetry = 5 - let result = "" - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex] - let chunkResult = "" - for (let retry = 0; retry < maxRetry; retry++) { - logger.info(`[makeMDByApp] Attempting GPT API call : ${++cnt} times`) - const msg = getMDPrompt(chunk, retry + 1) - const response: any = await openai.chat.completions.create({ - model: "gpt-4o", - messages: [{ role: "user", content: msg }], - temperature: 0, - max_tokens: 10000, - }) - const text = response.choices[0].message.content?.trim() ?? "" - const finishReason = response.choices[0].finish_reason const cleaned = text .replace(/```(?:json|javascript|typescript|markdown)?/g, "") .replace(/```/g, "") - .replace(/`markdown/g, "") - .replace(/\(.*?\/.*?\)/g, "") .trim() chunkResult += cleaned + "\n" - if (finishReason === "stop") break + if (finishReason === "stop" && cleaned) break await new Promise((res) => setTimeout(res, 500)) } + result += chunkResult.trim() + "\n\n" + await new Promise((res) => setTimeout(res, 300)) } return result.trim() } catch (error: unknown) { - logger.error(`makeMDByApp() ERROR: ${error}`) + logger.error(`makeitdoc() ERROR: ${error}`) return null } } + /** - * Main entry point to generate both Markdown specs and itdoc TypeScript tests. - * - If `testspecPath` is provided, reads and processes that file. - * - Otherwise analyzes an Express app's routes to build the spec. - * @param {string} [testspecPath] - Optional path to an existing Markdown test spec file. - * @param {string} [appPath] - Path to the Express app entry file (overrides spec metadata). - * @param {string} [envPath] - Path to the .env file containing OPENAI_API_KEY. - * @returns {Promise} Exits the process on error, otherwise writes output files. + * CLI entrypoint that: + * - Loads environment variables. + * - Analyzes an Express app file into route specs. + * - Invokes LLM to generate itdoc tests. + * - Writes the resulting test file with prelude (imports/helpers). + * @param {string} [appPath] - Path to Express app entry. + * @param {string} [envPath] - Path to .env file containing OPENAI_API_KEY. + * @returns {Promise} Exits the process on unrecoverable errors. */ -export default async function generateByLLM( - testspecPath?: string, - appPath?: string, - envPath?: string, -): Promise { +export default async function generateByLLM(appPath?: string, envPath?: string): Promise { const actualEnv = loadFile("env", envPath, false) dotenv.config({ path: actualEnv }) if (!process.env.OPENAI_API_KEY) { @@ -241,32 +143,6 @@ export default async function generateByLLM( let isTypeScript = false let appImportPath = "" let resolvedAppPath = "" - let parsedSpecContent = "" - const originalAppPath = appPath - - if (testspecPath && !originalAppPath) { - if (!fs.existsSync(testspecPath)) { - logger.error(`Test spec file not found: ${testspecPath}`) - process.exit(1) - } - - const specContent = fs.readFileSync(testspecPath, "utf8") - const { metadata, content } = parseSpecFile(specContent) - parsedSpecContent = content - - if (metadata.app) { - appPath = resolvePath(metadata.app) - logger.info(`[generateByLLM] App path found in : ${metadata.app} -> ${appPath}`) - } else { - logger.error(` - [generateByLLM] App path is not defined in the test spec file. Please define it at the top of the test spec file like below: - --- - app:@/path/to/your/app.js - --- - `) - process.exit(1) - } - } if (!appPath) { logger.error("App path not provided. Please specify it with -a or --app option.") @@ -279,49 +155,20 @@ export default async function generateByLLM( const relativePath = path.relative(outputDir, resolvedAppPath).replace(/\\/g, "/") appImportPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}` - if (!testspecPath) { - let analyzedRoutes = await analyzeRoutes(resolvedAppPath) - - if (!analyzedRoutes) { - logger.error( - "AST analysis failed. Please ensure your routes use app.get() or router.post() format.", - ) - process.exit(1) - } - analyzedRoutes = analyzedRoutes.sort((a, b) => a.path.localeCompare(b.path)) - - const specFromApp = await makeMDByApp(openai, analyzedRoutes) - - if (!specFromApp) { - logger.error("Failed to generate markdown spec from app analysis.") - process.exit(1) - } - - const mdPath = path.join(outputDir, "output.md") - fs.writeFileSync(mdPath, specFromApp, "utf8") - logger.info(`Your APP Markdown spec analysis completed: ${mdPath}`) - - const doc = await makeitdocByMD(openai, specFromApp, false, isTypeScript) - if (!doc) { - logger.error("Failed to generate itdoc from markdown spec.") - process.exit(1) - } - result = doc - } else { - let specContent: string - if (parsedSpecContent) { - specContent = parsedSpecContent - } else { - specContent = loadFile("spec", testspecPath, true) - } + const analyzedRoutes = await analyzeRoutes(resolvedAppPath) + if (!analyzedRoutes) { + logger.error( + "AST analysis failed. Please ensure your routes use app.get() or router.post() format.", + ) + process.exit(1) + } - const doc = await makeitdocByMD(openai, specContent, false, isTypeScript) - if (!doc) { - logger.error("Failed to generate test code from markdown spec.") - process.exit(1) - } - result = doc + const doc = await makeitdoc(openai, analyzedRoutes, false, isTypeScript) + if (!doc) { + logger.error("Failed to generate itdoc from markdown spec.") + process.exit(1) } + result = doc if (!result) { logger.error("generateByLLM() did not return any result.") @@ -338,15 +185,48 @@ export default async function generateByLLM( let importStatement = "" if (isTypeScript) { importStatement = `import { app } from "${appImportPath}" -import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc"` +import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc" +import sinon from "sinon" +const sandbox = sinon.createSandbox() +function getRouteLayer(app, method, path) { + method = String(method).toLowerCase() + const stack = app && app._router && app._router.stack ? app._router.stack : [] + for (const layer of stack) { + if (!layer.route) continue + if (layer.route.path !== path) continue + if (!layer.route.methods || !layer.route.methods[method]) continue + const routeStack = layer.route.stack || [] + if (routeStack.length > 0) return routeStack[0] + } +} +afterEach(() => { + sandbox.restore() +}) +` } else { importStatement = `const app = require('${appImportPath}') const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") const targetApp = app +const sinon = require("sinon") +const sandbox = sinon.createSandbox() +function getRouteLayer(app, method, path) { + method = String(method).toLowerCase() + const stack = app && app._router && app._router.stack ? app._router.stack : [] + for (const layer of stack) { + if (!layer.route) continue + if (layer.route.path !== path) continue + if (!layer.route.methods || !layer.route.methods[method]) continue + const routeStack = layer.route.stack || [] + if (routeStack.length > 0) return routeStack[0] + } +} +afterEach(() => { + sandbox.restore() +}) ` } - result = importStatement + "\n\n" + result.trim() + result = importStatement + "\n\n" + result.trim() fs.writeFileSync(outPath, result, "utf8") logger.info(`[generateByLLM] itdoc LLM SCRIPT completed.`) } diff --git a/script/llm/loader/index.ts b/script/llm/loader/index.ts index 412ed21..f044ea5 100644 --- a/script/llm/loader/index.ts +++ b/script/llm/loader/index.ts @@ -18,18 +18,31 @@ import fs from "fs" import path from "path" import logger from "../../../lib/config/logger" -type FileType = "spec" | "app" | "env" +type FileType = "app" | "env" /** - * Checks the path according to the given type and returns the file path or content. - * @param {FileType} type "spec" | "app" | "env" - * @param {string} filePath Input path (relative or absolute) - * @param {boolean} readContent If true, returns file content as string; if false, returns only the path - * @returns {string} (file content or path) + * Load and optionally read a file depending on its {@link FileType}. + * + * Behavior by type: + * - **`"app"`** (required): If the file cannot be resolved, logs an error and **terminates the process** with `process.exit(1)`. + * - **`"env"`** (optional): If the file cannot be resolved, logs a warning and returns an empty string. + * + * Resolution rules: + * - If `filePath` is provided, it is resolved relative to `process.cwd()` when not absolute. + * - If `filePath` is omitted, the function searches a set of sensible defaults for the given type. + * @param {FileType} type + * The category of file to resolve (`"app"` or `"env"`). + * @param {string} [filePath] + * An explicit path to the file. If relative, it is resolved from `process.cwd()`. + * When omitted, a default search list is used (see implementation). + * @param {boolean} [readContent] + * When `true`, returns the UTF-8 file contents; when `false`, returns the resolved absolute path. + * @returns {string} + * The resolved absolute path (when `readContent === false`) or the UTF-8 contents (when `true`). + * For missing `"env"` files, an empty string is returned. */ export function loadFile(type: FileType, filePath?: string, readContent: boolean = false): string { - const defaultPaths: Record = { - spec: [path.resolve(process.cwd(), "md/testspec.md")], + const defaults: Record = { app: [ path.resolve(process.cwd(), "app.js"), path.resolve(process.cwd(), "app.ts"), @@ -41,26 +54,41 @@ export function loadFile(type: FileType, filePath?: string, readContent: boolean env: [path.resolve(process.cwd(), ".env")], } - let resolvedPath: string - + let resolved: string | undefined if (filePath) { - resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath) + resolved = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath) } else { - const foundPath = defaultPaths[type].find((p) => fs.existsSync(p)) - - if (!foundPath) { - logger.error( - `${type} 파일을 찾을 수 없습니다. 기본 경로: ${defaultPaths[type].join(", ")}`, - ) - process.exit(1) + resolved = defaults[type].find((p) => fs.existsSync(p)) + } + if (!resolved || !fs.existsSync(resolved)) { + if (type === "env") { + if (filePath) { + logger.warn( + `ENV file not found at provided path: ${filePath}. Continuing without it.`, + ) + } else { + logger.warn( + `ENV file not found at default locations: ${defaults.env.join(", ")}. Continuing without it.`, + ) + } + return readContent ? "" : "" } - - resolvedPath = foundPath + if (filePath) { + logger.error(`${type} file does not exist: ${filePath}`) + } else { + logger.error(`${type} file not found. Searched: ${defaults[type].join(", ")}`) + } + process.exit(1) } + if (!readContent) return resolved - if (!fs.existsSync(resolvedPath)) { - logger.error(`${type} 파일이 존재하지 않습니다: ${resolvedPath}`) + try { + return fs.readFileSync(resolved, "utf8") + } catch (err) { + logger.error(`Failed to read ${type} file: ${resolved}. ${(err as Error).message}`) + if (type === "env") { + return "" + } process.exit(1) } - return readContent ? fs.readFileSync(resolvedPath, "utf8") : resolvedPath } diff --git a/script/llm/parser/analyzer/returnValueExtractor.ts b/script/llm/parser/analyzer/returnValueExtractor.ts deleted file mode 100644 index c8a0c7b..0000000 --- a/script/llm/parser/analyzer/returnValueExtractor.ts +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NodePath } from "@babel/traverse" -import * as t from "@babel/types" -import { getProjectFiles, parseMultipleFiles } from "../utils/fileParser" -import { extractValue } from "../utils/extractValue" -import traversePkg from "@babel/traverse" -// @ts-expect-error - CommonJS/ES modules 호환성 이슈로 인한 타입 에러 무시 -const traverse = traversePkg.default - -/** - * Dynamically finds related service methods across the project and extracts actual return values. - * @param {string} methodName - Method name to find (e.g., getAllProducts) - * @param {string} projectRoot - Project root path - * @returns {any} Extracted actual return value or null - */ -export function extractActualReturnValue(methodName: string, projectRoot: string): any { - try { - const filePaths = getProjectFiles(projectRoot) - const parsedFiles = parseMultipleFiles(filePaths) - - for (const { ast } of parsedFiles) { - const result = extractReturnValueFromAST(ast, methodName) - - if (result && !hasPartialNullValues(result)) { - return result - } - } - - return null - } catch { - return null - } -} - -/** - * Checks if an object contains null values. - * @param {any} obj - Object to check - * @returns {boolean} Whether null values are included - */ -export function hasPartialNullValues(obj: any): boolean { - if (obj === null || obj === undefined) return true - if (typeof obj !== "object") return false - if (Array.isArray(obj)) { - return obj.some((item) => hasPartialNullValues(item)) - } - return Object.values(obj).some((value) => hasPartialNullValues(value)) -} - -/** - * Dynamically extracts return values of specific methods from AST. - * @param {t.File} ast - File AST - * @param {string} methodName - Method name - * @returns {any} Return value structure - */ -export function extractReturnValueFromAST(ast: t.File, methodName: string): any { - let returnValue: any = null - - traverse(ast, { - ClassMethod(methodPath: NodePath) { - if (t.isIdentifier(methodPath.node.key) && methodPath.node.key.name === methodName) { - returnValue = extractReturnFromFunction(methodPath.node, ast) - } - }, - ObjectMethod(methodPath: NodePath) { - if (t.isIdentifier(methodPath.node.key) && methodPath.node.key.name === methodName) { - returnValue = extractReturnFromFunction(methodPath.node, ast) - } - }, - VariableDeclarator(varPath: NodePath) { - if (t.isObjectExpression(varPath.node.init)) { - const objExpr = varPath.node.init - objExpr.properties.forEach((prop) => { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { - if (prop.key.name === methodName) { - if ( - t.isArrowFunctionExpression(prop.value) || - t.isFunctionExpression(prop.value) - ) { - returnValue = extractReturnFromFunction(prop.value, ast) - } - } - } - }) - } else if ( - t.isIdentifier(varPath.node.id) && - varPath.node.id.name === methodName && - (t.isArrowFunctionExpression(varPath.node.init) || - t.isFunctionExpression(varPath.node.init)) - ) { - returnValue = extractReturnFromFunction(varPath.node.init, ast) - } - }, - FunctionDeclaration(funcPath: NodePath) { - if (funcPath.node.id && funcPath.node.id.name === methodName) { - returnValue = extractReturnFromFunction(funcPath.node, ast) - } - }, - }) - - return returnValue -} - -/** - * Extracts return structure from function/method - * @param {t.Function} func - Function node - * @param {t.File} ast - Complete file AST (for variable finding) - * @returns {any} Return value structure - */ -export function extractReturnFromFunction(func: t.Function, ast?: t.File): any { - let returnValue: any = null - const visitedVariables = new Set() - - /** - * Recursively traverses nodes to find all ReturnStatements. - * @param {t.Node} node Node to traverse - * @returns {t.ReturnStatement[]} All ReturnStatements - */ - function findAllReturnStatements(node: t.Node): t.ReturnStatement[] { - const returns: t.ReturnStatement[] = [] - - if (t.isReturnStatement(node)) { - returns.push(node) - } - - if (t.isBlockStatement(node)) { - for (const stmt of node.body) { - returns.push(...findAllReturnStatements(stmt)) - } - } - - if (t.isIfStatement(node)) { - returns.push(...findAllReturnStatements(node.consequent)) - if (node.alternate) { - returns.push(...findAllReturnStatements(node.alternate)) - } - } - - return returns - } - - /** - * Selects the most meaningful ReturnStatement. - * Prioritizes actual values over undefined or null. - * @param {t.ReturnStatement[]} returnStatements Return statements - * @returns {t.ReturnStatement | null} The most meaningful ReturnStatement - */ - function selectBestReturnStatement( - returnStatements: t.ReturnStatement[], - ): t.ReturnStatement | null { - if (returnStatements.length === 0) return null - - for (const stmt of returnStatements) { - if (stmt.argument) { - if (t.isIdentifier(stmt.argument)) { - if (stmt.argument.name !== "undefined" && stmt.argument.name !== "null") { - return stmt - } - } else { - return stmt - } - } - } - - return returnStatements[0] - } - - if (func.body && t.isBlockStatement(func.body)) { - const allReturns = findAllReturnStatements(func.body) - const returnStmt = selectBestReturnStatement(allReturns) - - if (returnStmt?.argument) { - returnValue = extractValue(returnStmt.argument, {}, {}, ast, visitedVariables) - } - } else if (func.body && t.isExpression(func.body)) { - returnValue = extractValue(func.body, {}, {}, ast, visitedVariables) - } - - return returnValue -} diff --git a/script/llm/parser/analyzer/routeAnalyzer.ts b/script/llm/parser/analyzer/routeAnalyzer.ts index bbb370d..e34f1c8 100644 --- a/script/llm/parser/analyzer/routeAnalyzer.ts +++ b/script/llm/parser/analyzer/routeAnalyzer.ts @@ -159,14 +159,31 @@ function findFunctionDefinition( return foundFunction } - /** - * Analyzes the body of a given function node to collect request/response information. - * @param {t.FunctionExpression | t.ArrowFunctionExpression} functionNode - The function node to analyze. - * @param {string} source - The source code of the file. - * @param {any} ret - The object that collects analysis results. - * @param {NodePath} parentPath - The parent call expression node. - * @param {t.File} [ast] - The full AST of the file, used for nested function analysis. + * Analyze the body of a route handler function to extract request/response metadata. + * + * Walks the handler’s AST and delegates to specialized analyzers: + * - **VariableDeclarator** → `analyzeVariableDeclarator` + * Captures destructured `req` fields (`query`, `params`, `headers`, `body`), tracks identifiers, + * and records samples for local array literals (e.g., `const members = [...]`). + * - **CallExpression** → `analyzeResponseCall` + * Detects `res.status(...)`, `res.json(...)`, `res.send(...)`, and aggregates default/branch responses. + * - **MemberExpression** → `analyzeMemberExpression` + * Tracks usage like `req.headers.*`, `req.body.*`, and analyzes inline `res.json({ ... })` objects. + * + * The function **mutates** the provided accumulator `ret` in place (adds req field sets, response maps, etc.). + * @param {t.FunctionExpression | t.ArrowFunctionExpression} functionNode + * The route handler (function or arrow function) whose body will be traversed. + * @param {string} source + * Raw source code of the file; forwarded to sub-analyzers for context (e.g., snippet extraction). + * @param {any} ret + * Mutable accumulator object that will be enriched with analysis results + * (e.g., `reqHeaders`, `reqParams`, `reqQuery`, `bodyFields`, `defaultResponse`, `branchResponses`). + * @param {NodePath} parentPath + * The `CallExpression` path that registered the route (e.g., `app.get(...)`), used to inherit scope during traversal. + * @param {t.File} [ast] + * Optional full-file AST. When provided, sub-analyzers may use it to resolve identifiers across the file. + * @returns {void} Mutates `ret` in place; no value is returned. */ export function analyzeFunctionBody( functionNode: t.FunctionExpression | t.ArrowFunctionExpression, diff --git a/script/llm/parser/analyzer/variableAnalyzer.ts b/script/llm/parser/analyzer/variableAnalyzer.ts index fceb975..6e219b3 100644 --- a/script/llm/parser/analyzer/variableAnalyzer.ts +++ b/script/llm/parser/analyzer/variableAnalyzer.ts @@ -16,13 +16,60 @@ import { NodePath } from "@babel/traverse" import * as t from "@babel/types" -import { createFunctionIdentifier } from "../utils/extractValue" +import { extractValue } from "../utils/extractValue" /** - * Analyzes request parameters and function call results from variable declarations. - * @param {NodePath} varPath - Variable declaration node - * @param {any} ret - Analysis result storage object - * @param {Record} localArrays - Local array storage object + * Ensures that the accumulator object `ret` has a response json field map. + * @param {any} ret Mutable accumulator for analysis results. + * @returns {Record} The ensured `responseJsonFieldMap`. + */ +function ensureResMap(ret: any) { + if (!ret.responseJsonFieldMap) ret.responseJsonFieldMap = {} + return ret.responseJsonFieldMap as Record +} + +/** + * Analyze `res.json({ ... })` object literal and fill metadata for each property. + * @param {t.ObjectExpression} obj Object literal passed to `res.json`. + * @param {any} ret Mutable accumulator for analysis results (augments `responseJsonFieldMap`). + * @param {Record | undefined} variableMap Mapping of identifiers to previously-resolved descriptors. + * @param {Record} localArrays Map of local array identifiers (for value tracking). + * @param {t.File | undefined} ast Whole-file AST (optional). + * @returns {void} + */ +function analyzeJsonObjectFields( + obj: t.ObjectExpression, + ret: any, + variableMap: Record | undefined, + localArrays: Record, + ast?: t.File, +) { + const out = ensureResMap(ret) + + for (const p of obj.properties) { + if (!t.isObjectProperty(p)) continue + + let keyName: string | null = null + if (t.isIdentifier(p.key)) keyName = p.key.name + else if (t.isStringLiteral(p.key)) keyName = p.key.value + if (!keyName) continue + + const v = p.value + const extracted = extractValue(v as t.Node, localArrays, variableMap ?? {}, ast) + if (extracted !== null && extracted !== undefined) { + out[keyName] = extracted + } + } +} + +/** + * Analyze a variable declarator for: + * - Calls/constructors assigned to identifiers + * - Destructuring from `req.query|params|body|headers` and track field usage + * @param {NodePath} varPath Variable declarator path. + * @param {any} ret Mutable accumulator for analysis results (adds `variableMap`, `req*` sets). + * @param {Record} localArrays Map of local array identifiers (for value tracking). + * @returns {void} */ export function analyzeVariableDeclarator( varPath: NodePath, @@ -32,24 +79,27 @@ export function analyzeVariableDeclarator( const decl = varPath.node if (t.isIdentifier(decl.id) && t.isArrayExpression(decl.init)) { - localArrays[decl.id.name] = [] + const arrVal = extractValue( + decl.init, + localArrays, + ret.variableMap ?? {}, + ret.ast as t.File | undefined, + ) + localArrays[decl.id.name] = Array.isArray(arrVal) ? arrVal : [] return } if (t.isIdentifier(decl.id) && decl.init) { - let callExpression: t.CallExpression | null = null + if (!ret.variableMap) ret.variableMap = {} + const maybe = extractValue( + decl.init as t.Node, + localArrays, + ret.variableMap, + ret.ast as t.File | undefined, + ) - if (t.isAwaitExpression(decl.init) && t.isCallExpression(decl.init.argument)) { - callExpression = decl.init.argument - } else if (t.isCallExpression(decl.init)) { - callExpression = decl.init - } - - if (callExpression) { - if (!ret.variableMap) { - ret.variableMap = {} - } - ret.variableMap[decl.id.name] = createFunctionIdentifier(callExpression) + if (maybe !== null && maybe !== undefined) { + ret.variableMap[decl.id.name] = maybe } } @@ -57,25 +107,62 @@ export function analyzeVariableDeclarator( t.isObjectPattern(decl.id) && t.isMemberExpression(decl.init) && t.isIdentifier(decl.init.object, { name: "req" }) && - t.isIdentifier(decl.init.property) + (t.isIdentifier(decl.init.property) || t.isStringLiteral(decl.init.property)) ) { - const propName = decl.init.property.name + const propName = t.isIdentifier(decl.init.property) + ? decl.init.property.name + : decl.init.property.value + decl.id.properties.forEach((prop: any) => { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { - const fieldName = prop.key.name + if (t.isObjectProperty(prop)) { + const key = prop.key + const fieldName = t.isIdentifier(key) + ? key.name + : t.isStringLiteral(key) + ? key.value + : null + if (!fieldName) return switch (propName) { case "query": ret.reqQuery.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.query", + property: fieldName, + identifier: `req.query.${fieldName}`, + } break case "params": ret.reqParams.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.params", + property: fieldName, + identifier: `req.params.${fieldName}`, + } break case "headers": ret.reqHeaders.add(fieldName.toLowerCase()) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.headers", + property: fieldName.toLowerCase(), + identifier: `req.headers.${fieldName.toLowerCase()}`, + } break case "body": ret.bodyFields.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.body", + property: fieldName, + identifier: `req.body.${fieldName}`, + } break } } @@ -84,21 +171,37 @@ export function analyzeVariableDeclarator( } /** - * Analyzes request parameters from member expressions. - * @param {NodePath} memPath - Member expression node - * @param {any} ret - Analysis result storage object + * Inspect member-expressions to: + * - Track request field usage (`req.headers.*`, `req.body.*`) + * - Detect `res.json({ ... })` calls and analyze their object argument + * @param {NodePath} memPath Member expression path. + * @param {any} ret Mutable accumulator for analysis results. + * @returns {void} */ export function analyzeMemberExpression(memPath: NodePath, ret: any) { const mm = memPath.node + const parentObj = ((): t.MemberExpression | t.OptionalMemberExpression | null => { + if (t.isMemberExpression(mm.object) || t.isOptionalMemberExpression(mm.object)) { + return mm.object + } + return null + })() + if ( - t.isMemberExpression(mm.object) && - t.isIdentifier(mm.object.object, { name: "req" }) && - t.isIdentifier(mm.object.property) && - t.isIdentifier(mm.property) + parentObj && + t.isIdentifier(parentObj.object, { name: "req" }) && + (t.isIdentifier(parentObj.property) || t.isStringLiteral(parentObj.property)) ) { - const parent = mm.object.property.name - const child = mm.property.name + const parent = t.isIdentifier(parentObj.property) + ? parentObj.property.name + : parentObj.property.value + + let child: string | null = null + if (t.isIdentifier(mm.property)) child = mm.property.name + else if (t.isStringLiteral(mm.property)) child = mm.property.value + + if (!child) return if (parent === "headers") { ret.reqHeaders.add(child.toLowerCase()) @@ -106,4 +209,32 @@ export function analyzeMemberExpression(memPath: NodePath, r ret.bodyFields.add(child) } } + + const isJsonCallee = + (t.isIdentifier(mm.property, { name: "json" }) || + t.isStringLiteral(mm.property, { value: "json" })) && + (t.isIdentifier(mm.object, { name: "res" }) || + (t.isCallExpression(mm.object) && + (t.isMemberExpression(mm.object.callee) || + t.isOptionalMemberExpression(mm.object.callee)) && + (t.isIdentifier((mm.object.callee as t.MemberExpression).property, { + name: "status", + }) || + t.isStringLiteral((mm.object.callee as t.MemberExpression).property as any, { + value: "status", + })))) + + if (isJsonCallee && memPath.parentPath && memPath.parentPath.isCallExpression()) { + const call = memPath.parentPath.node + const firstArg = call.arguments[0] + if (firstArg && t.isObjectExpression(firstArg)) { + analyzeJsonObjectFields( + firstArg, + ret, + ret.variableMap, + ret.localArrays ?? {}, + ret.ast as t.File | undefined, + ) + } + } } diff --git a/script/llm/parser/utils/extractValue.ts b/script/llm/parser/utils/extractValue.ts index 2f0d683..bf32759 100644 --- a/script/llm/parser/utils/extractValue.ts +++ b/script/llm/parser/utils/extractValue.ts @@ -15,46 +15,249 @@ */ import * as t from "@babel/types" -import traversePkg from "@babel/traverse" -// @ts-expect-error - CommonJS/ES modules 호환성 이슈로 인한 타입 에러 무시 +import traversePkg, { NodePath } from "@babel/traverse" +// @ts-expect-error Interop between CJS/ESM default export of @babel/traverse const traverse = traversePkg.default -import { NodePath } from "@babel/traverse" /** - * Creates identifier information for function calls. - * @param {t.CallExpression} callExpression - Function call expression - * @returns {object} Identifier information + * Convert a call's argument list into a compact string signature. + * + * - Spreads become `...` + * - Argument placeholders become `?` + * - Other expressions are rendered via `exprToString` + * @param {(t.Expression | t.SpreadElement | t.ArgumentPlaceholder)[]} args Call arguments. + * @returns {string} Comma-separated argument signature. */ -export function createFunctionIdentifier(callExpression: t.CallExpression): object { - if (t.isMemberExpression(callExpression.callee)) { - const { object, property } = callExpression.callee - - if (t.isIdentifier(object) && t.isIdentifier(property)) { - return { - type: "function_call", - object: object.name, - method: property.name, - identifier: `${object.name}.${property.name}()`, - } +function argsToString(args: Array): string { + return args + .map((a) => { + if (t.isSpreadElement(a)) return `...${exprToString(a.argument as t.Node)}` + if (t.isArgumentPlaceholder(a)) return "?" + return exprToString(a as t.Node) + }) + .join(", ") +} + +/** + * Retrieve a representative array sample for an identifier that refers to an array. + * + * Looks up: + * 1) `localArrays[name]` for in-scope literals + * 2) `variableMap[name].sample` for previously captured samples + * @param {string | undefined} objName Identifier to resolve. + * @param {Record} localArrays Map of local array literals. + * @param {Record} variableMap Map of prior variable descriptors. + * @returns {any[] | undefined} An example array if known. + */ +function getArraySample( + objName: string | undefined, + localArrays: Record, + variableMap: Record, +): any[] | undefined { + if (!objName) return undefined + if (Array.isArray(localArrays[objName])) return localArrays[objName] + const v = variableMap[objName] + if (v && Array.isArray(v.sample)) return v.sample + return undefined +} + +/** + * Safely converts a value to a string. + * + * - If the value is already a string, it is returned as-is. + * - If the value is an object, it is serialized into a JSON string. + * @param {string | object} v - The value to be converted. + * @returns {string} The string representation of the input value. + */ +function toSig(v: string | object): string { + return typeof v === "string" ? v : JSON.stringify(v) +} +/** + * Create a normalized descriptor for a member access expression. + * + * Supports: + * - Optional chaining (`a?.b`) + * - Computed properties (`a["b"]`, `a?.[x]`) + * @param {t.MemberExpression | t.OptionalMemberExpression} mem Member expression node. + * @returns {{type: 'member_access', object: string, property: string, identifier: string}} Access metadata. + */ + +/** + * + * @param mem + */ +function createMemberAccessIdentifier(mem: t.MemberExpression | t.OptionalMemberExpression) { + const objStr = toSig(exprToString(mem.object)) // <- string | object → string + + let propName = "" + if (t.isIdentifier(mem.property)) propName = mem.property.name + else if (t.isStringLiteral(mem.property)) propName = mem.property.value + else propName = toSig(exprToString(mem.property as t.Node)) // <- 안전 변환 + + const sep = t.isOptionalMemberExpression(mem) ? "?." : "." + const accessor = mem.computed + ? `${sep}[${toSig(exprToString(mem.property as t.Node))}]` // <- 안전 변환 + : `${sep}${propName}` + + return { + type: "member_access" as const, + object: objStr, + property: propName, + identifier: `${objStr}${accessor}`, + } +} + +/** + * Render an AST expression as a readable string or structured object. + * + * - Literals → literal string + * - `new Ctor(args)` → `"new Ctor()"` + * - `a.b` / `a?.b` / computed forms → `"a.b"` / `"a?.[b]"` + * - Calls/optional calls → structured via `createInvocationIdentifier` + * @param {t.Node | null | undefined} node AST node to stringify. + * @param {Record} [localArrays] Local arrays for enrichment. + * @param {Record} [variableMap] Variable descriptors for enrichment. + * @returns {string | object} Human-readable signature or a call descriptor. + */ +function exprToString( + node: t.Node | null | undefined, + localArrays: Record = {}, + variableMap: Record = {}, +): string | object { + if (!node) return "" + if (t.isIdentifier(node)) return node.name + if (t.isThisExpression(node)) return "this" + if (t.isSuper(node)) return "super" + if (t.isStringLiteral(node)) return node.value + if (t.isNumericLiteral(node)) return String(node.value) + if (t.isBooleanLiteral(node)) return String(node.value) + if (t.isNullLiteral(node)) return "null" + + if (t.isNewExpression(node)) { + const ctor = exprToString(node.callee, localArrays, variableMap) + const argsSig = node.arguments ? argsToString(node.arguments as any) : "" + return `new ${ctor}(${argsSig})` + } + + if (t.isMemberExpression(node)) { + const obj = exprToString(node.object, localArrays, variableMap) + const prop = node.computed + ? `[${exprToString(node.property as t.Node, localArrays, variableMap)}]` + : `.${exprToString(node.property as t.Node, localArrays, variableMap)}` + return `${obj}${prop}` + } + + if (t.isOptionalMemberExpression(node)) { + const obj = exprToString(node.object, localArrays, variableMap) + const prop = node.computed + ? `?.[${exprToString(node.property as t.Node, localArrays, variableMap)}]` + : `?.${exprToString(node.property as t.Node, localArrays, variableMap)}` + return `${obj}${prop}` + } + + if (t.isOptionalCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sample = getArraySample(info.object, localArrays, variableMap) + if (sample) info.sample = sample } - } else if (t.isIdentifier(callExpression.callee)) { + return info + } + + if (t.isCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sample = getArraySample(info.object, localArrays, variableMap) + if (sample) info.sample = sample + } + return info + } + + return `<${node.type}>` +} + +/** + * Build a normalized descriptor for call and optional-call expressions. + * + * Shapes: + * - Member calls → `{ type: 'function_call', object, method, identifier }` + * - Identifier calls → `{ type: 'function_call', method, identifier }` + * - Complex callees → `{ type: 'function_call', identifier }` + * @param {t.CallExpression | t.OptionalCallExpression} inv Invocation node. + * @param {Record} [localArrays] Local array map for enrichment. + * @param {Record} [variableMap] Variable map for enrichment. + * @returns {object} Call descriptor. + */ +function createInvocationIdentifier( + inv: t.CallExpression | t.OptionalCallExpression, + localArrays: Record = {}, + variableMap: Record = {}, +): object { + const callee = inv.callee as t.Expression + const argsSig = argsToString(inv.arguments) + + if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) { + const objResult = exprToString(callee.object, localArrays, variableMap) + const objStr = typeof objResult === "string" ? objResult : JSON.stringify(objResult) + + let methodName = "" + if (t.isIdentifier(callee.property)) methodName = callee.property.name + else if (t.isStringLiteral(callee.property)) methodName = callee.property.value + else { + const propResult = exprToString(callee.property as t.Node, localArrays, variableMap) + methodName = typeof propResult === "string" ? propResult : JSON.stringify(propResult) + } + + const sep = t.isOptionalMemberExpression(callee) ? "?." : "." + const accessorForId = callee.computed + ? `${sep}[${exprToString(callee.property as t.Node, localArrays, variableMap)}]` + : `${sep}${methodName}` + + return { + type: "function_call", + object: objStr, + method: methodName, + identifier: `${objStr}${accessorForId}(${argsSig})`, + } + } + + if (t.isIdentifier(callee)) { return { type: "function_call", - method: callExpression.callee.name, - identifier: `${callExpression.callee.name}()`, + method: callee.name, + identifier: `${callee.name}(${argsSig})`, } } + const calleeResult = exprToString(callee, localArrays, variableMap) + const calleeStr = typeof calleeResult === "string" ? calleeResult : JSON.stringify(calleeResult) + return { type: "function_call", - identifier: "", + identifier: `${calleeStr}(${argsSig})`, } } /** - * Handles basic literal values - * @param {t.Node} node Node to extract value from - * @returns {any} Extracted actual value or null + * Create a function-call descriptor while preserving the existing signature, + * extended to support `OptionalCallExpression`. + * @param {t.CallExpression | t.OptionalCallExpression} callExpression Call or optional-call. + * @param {Record} [localArrays] Local arrays for enrichment. + * @param {Record} [variableMap] Variable map for enrichment. + * @returns {object} Call descriptor. + */ +export function createFunctionIdentifier( + callExpression: t.CallExpression | t.OptionalCallExpression, + localArrays: Record = {}, + variableMap: Record = {}, +): object { + return createInvocationIdentifier(callExpression, localArrays, variableMap) +} + +/** + * Extract a primitive value from a literal node. + * @param {t.Node} node Candidate literal node. + * @returns {any} JS value for string/number/boolean/null, or `undefined` if not a basic literal. */ function extractLiteralValue(node: t.Node): any { if (t.isStringLiteral(node)) return node.value @@ -64,13 +267,16 @@ function extractLiteralValue(node: t.Node): any { } /** - * Handles object expressions - * @param {t.ObjectExpression} node Object expression node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Extract a plain object from an `ObjectExpression`. + * + * - Each property value is recursively extracted via `extractValue`. + * - Spread properties are resolved via `resolveSpreadValue`. + * @param {t.ObjectExpression} node Object literal node. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @param {t.File} [ast] Whole-file AST, used to chase identifiers if needed. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Plain JS object representing the expression. */ function extractObjectValue( node: t.ObjectExpression, @@ -91,7 +297,6 @@ function extractObjectValue( ast, visitedVariables, ) - obj[key] = value !== null ? value : null } else if (t.isSpreadElement(prop)) { const resolved = resolveSpreadValue( @@ -133,13 +338,18 @@ function extractObjectValue( } /** - * Handles array expressions - * @param {t.ArrayExpression} node Array expression node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Extract an array from an `ArrayExpression`. + * + * - Elements are recursively extracted via `extractValue`. + * - Spread elements are resolved via `resolveSpreadValue`. + * @param {t.ArrayExpression} node Array literal node. + * @param {Record localArrays Local arrays map. + * @param {Record variableMap Variable descriptors. + * @param localArrays + * @param variableMap + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any[]} Array value. */ function extractArrayValue( node: t.ArrayExpression, @@ -180,13 +390,15 @@ function extractArrayValue( } /** - * Handles identifiers - * @param {t.Identifier} node Identifier node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Resolve the value of an identifier where possible. + * @param {t.Identifier} node Identifier node. + * @param {Record localArrays Local arrays map. + * @param {Record variableMap Variable descriptors. + * @param localArrays + * @param variableMap + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Resolved value or `null` when unknown. */ function extractIdentifierValue( node: t.Identifier, @@ -201,7 +413,9 @@ function extractIdentifierValue( if (variableMap[name]) { const mapping = variableMap[name] - return mapping.sample || mapping + if (Array.isArray(mapping?.samples)) return mapping.samples + if (Array.isArray(mapping?.sample)) return mapping.sample + return mapping } if (ast) { @@ -212,13 +426,22 @@ function extractIdentifierValue( } /** - * Value extraction function - * @param {t.Node} node - AST node to extract - * @param {Record} localArrays - Map of locally defined array variables - * @param {Record} variableMap - Variable name to data structure mapping - * @param {t.File} ast - Complete file AST (for variable tracking) - * @param {Set} visitedVariables - For preventing circular references - * @returns {any} Extracted actual value or identifier information + * Extract a best-effort JS value (or structured descriptor) from an arbitrary AST node. + * + * Order: + * - Literals → primitive + * - Object → plain object + * - Array → array + * - Identifier → resolved via maps/AST + * - MemberExpression/OptionalMemberExpression → member access descriptor + * - Call/OptionalCall → call descriptor + * - NewExpression → constructor descriptor + * @param {t.Node} node Any AST node to extract. + * @param {Record} localArrays Local arrays map. + * @param {Record} [variableMap] Variable descriptors. + * @param {t.File} [ast] Whole-file AST (optional). + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Extracted value or descriptor, or `null` if not resolvable. */ export function extractValue( node: t.Node, @@ -233,30 +456,83 @@ export function extractValue( if (t.isObjectExpression(node)) { return extractObjectValue(node, localArrays, variableMap, ast, visitedVariables) } - if (t.isArrayExpression(node)) { return extractArrayValue(node, localArrays, variableMap, ast, visitedVariables) } - if (t.isIdentifier(node)) { return extractIdentifierValue(node, localArrays, variableMap, ast, visitedVariables) } + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + return createMemberAccessIdentifier(node) + } + + if (t.isOptionalCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sampleArr = ((): any[] | undefined => { + const v = variableMap[info.object] + if (v && Array.isArray(v?.samples)) return v.samples + if (v && Array.isArray(v?.sample)) return v.sample + const fromLocal = localArrays[info.object] + if (Array.isArray(fromLocal)) return fromLocal + return undefined + })() + if (sampleArr) { + info.samples = sampleArr // 주 출력 + if (info.sample === undefined) { + info.sample = sampleArr // 하위호환(단수 키도 채움) + } + } + } + return info + } + if (t.isCallExpression(node)) { - return createFunctionIdentifier(node) + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sampleArr = ((): any[] | undefined => { + const v = variableMap[info.object] + if (v && Array.isArray(v?.samples)) return v.samples + if (v && Array.isArray(v?.sample)) return v.sample + const fromLocal = localArrays[info.object] + if (Array.isArray(fromLocal)) return fromLocal + return undefined + })() + if (sampleArr) { + info.samples = sampleArr + if (info.sample === undefined) { + info.sample = sampleArr + } + } + } + return info + } + if (t.isNewExpression(node)) { + const ctor = exprToString(node.callee, localArrays, variableMap) + const ctorStr = typeof ctor === "string" ? ctor : JSON.stringify(ctor) + const argsSig = node.arguments ? argsToString(node.arguments as any) : "" + return { + type: "constructor_call", + constructor: ctorStr, + identifier: `new ${ctorStr}(${argsSig})`, + } } return null } /** - * Resolves values referenced by spread operators - * @param {t.Node} node - Spread target node - * @param {Record} localArrays - Local array map - * @param {Record} variableMap - Variable map - * @param {t.File} ast - File AST - * @param {Set} visitedVariables - Visited variables - * @returns {any} Resolved value or null + * Resolve the value referenced by a spread argument (`...x`) used in object/array literals. + * + * - Directly returns from `localArrays` or `variableMap` when available. + * - Otherwise searches the AST for variable initializers/assignments. + * @param {t.Node} node Spread argument node. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Resolved spread value or `null` if not resolvable. */ function resolveSpreadValue( node: t.Node, @@ -338,13 +614,14 @@ function resolveSpreadValue( } /** - * Finds the actual value of a variable from AST - * @param {string} variableName - Variable name to find - * @param {t.File} ast - File AST - * @param {Set} visitedVariables - Visited variables (to prevent circular references) - * @param {Record} localArrays - Local arrays - * @param {Record} variableMap - Variable map - * @returns {any} Actual value of the variable or null + * Find the value assigned to a variable by scanning the AST for matching + * declarations and assignments. Prevents cycles via `visitedVariables`. + * @param {string} variableName Identifier to resolve. + * @param {t.File | undefined} ast Whole-file AST. + * @param {Set} visitedVariables Cycle guard set. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @returns {any} Resolved value or `null` when not found. */ function findVariableValue( variableName: string, diff --git a/script/llm/prompt/index.ts b/script/llm/prompt/index.ts index 08cd404..fe6fc91 100644 --- a/script/llm/prompt/index.ts +++ b/script/llm/prompt/index.ts @@ -15,21 +15,33 @@ */ import { itdocExampleJs, itdocExampleTs } from "../examples/index" +import { RouteResult } from "../parser/type/interface" + /** - * Returns a prompt message for generating itdoc functions to create API documentation - * and test cases based on the given test content and language settings. + * Generates an instruction prompt for creating `itdoc` test scripts + * based on analyzed route information and language settings. + * + * The function dynamically selects between JavaScript and TypeScript + * examples and returns a localized prompt (Korean or English) with + * strict formatting and output rules for test code generation. * - * This function reads specified example files (e.g., Express test files) and includes them - * as function examples, then appends additional messages according to the input test content - * and language settings. - * @param {string} content - String containing test content (description of test cases). - * @param {boolean} isEn - If true, sets additional messages to output in English; if false, in Korean. - * @param {number} part - Current part number when output is divided into multiple parts - * @param {boolean} isTypeScript - If true, outputs in TypeScript; if false, in JavaScript - * @returns {string} - Generated prompt message string. + * Rules include: + * - Output only test code lines (no comments, explanations, or prose). + * - Follow order and branching guides when generating tests. + * - Ensure field calls have exactly two arguments. + * - Properly quote HTTP headers with hyphens. + * - Do not generate redundant imports or initialization code. + * - Split long outputs into chunks when necessary. + * @param {RouteResult[]} routesChunk - A chunk of parsed route definitions + * used as the basis for test generation. + * @param {boolean} isEn - Whether to generate the prompt in English (true) or Korean (false). + * @param {number} part - The sequential part number of the response when the GPT call does not return the full output at once and multiple calls are made to retrieve the continuation. + * @param {boolean} [isTypeScript] - Whether to generate TypeScript-based test examples instead of JavaScript. + * @returns {string} A formatted prompt containing route information, language rules, + * and example code for generating `itdoc` tests. */ export function getItdocPrompt( - content: string, + routesChunk: RouteResult[], isEn: boolean, part: number, isTypeScript: boolean = false, @@ -41,84 +53,91 @@ export function getItdocPrompt( codeTypes: { js: "자바스크립트", ts: "타입스크립트" }, outputInstruction: "그리고 반드시 한글로 출력해야 합니다.", codeLabel: "코드", + noComments: + "설명, 주석(//, /* */), 백틱 코드블록(```), 여는/닫는 문구를 절대 포함하지 마세요.", + orderGuide: + "출력 순서는 경로(prefix)와 메서드 순으로 정렬하세요. 기본 응답 테스트 → 각 분기 테스트 순으로 묶어서 작성하세요.", + branchGuide: + "각 라우트에 정의된 기본 응답과 모든 branches(조건)를 각각 별도의 itdoc 테스트로 생성하세요.", + branchGuide2: + "만약 default.status의 값이 빈 배열이라면 해당 default 조건에 해당하는 테스트는 절대 작성하지 않습니다.", + fieldGuide: + '모든 field 호출은 반드시 field("설명", "예시값")처럼 2개의 매개변수를 포함해야 합니다(단일 인자 금지).', + fieldGuide2: + '만약 a : b 라고 했을 때 b가 객체인 경우 field는 field는("b설명", b) 이런식으로 객체 전체를 설명해야 합니다. 객체 안에 field가 들어가면 안됩니다.', + headerGuide: + 'HTTP 헤더 키에 하이픈(-)이 포함되어 있으면 반드시 큰따옴표로 감싸세요. 예: "Cache-Control", "Last-Modified"', + noPathImport: + "파일 경로 import/require(예: examples/express-*)는 출력하지 마세요. 테스트 러너/초기 설정 코드는 이미 주어졌습니다.", + initGiven: + '다음 초기 설정은 이미 포함되어 있으므로 생성하지 마세요: const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc")', + chunksGuideFirst: + "출력이 길어질 경우 다음 요청에서 이어받을 수 있도록 적절한 단위로 분할하여 출력하세요. 응답 마지막에 '...' 같은 기호는 넣지 마세요.", + chunksGuideNext: + "이전 출력의 이어지는 부분만 출력하세요. 이전 내용을 반복하지 마세요. 분할 제목(1/3 등)은 금지합니다.", + langOut: "출력은 반드시 한글로 작성하세요.", + codeOnly: "오직 테스트 코드 줄만 출력하세요.", + etc: "function_call로 되어있는 부분은 그대로 함수로 출력해야 합니다. ex) fetchedAt: field('조회 시각(ISO 8601 형식)', new Date().toISOString()) 또한, parseInt() 등으로 타입을 유추할 수 있는 경우 해당 타입으로 반환해야 합니다. ex)parseInt(page) 의 경우 1, 2 등의 int 타입으로 출력되어야 합니다. 그리고 describeAPI에는 반드시 summary, tag, description값이 들어가야 합니다. 존재하지 않는 엔드포인트나 파라미터/헤더/바디 필드는 만들지 마세요. 입력에 있는 정보만 사용하세요. res() 이후에 반드시 유효한 status()가 붙어야 합니다. responses에서 주어지는 json 값들은 임의로 바꾸지 않습니다.", }, en: { codeTypes: { js: "JavaScript", ts: "TypeScript" }, outputInstruction: "And the output must be in English.", codeLabel: "code", + noComments: + "Do NOT include explanations, comments (// or /* */), or fenced code blocks (```), or any opening/closing prose.", + orderGuide: + "Order tests by path prefix and HTTP method. For each route, output the default response test first, then branch tests.", + branchGuide: + "For every route, generate separate itdoc tests for the default response and for every branch condition.", + branchGuide2: + "If the default.status value is an empty array, never create a test that corresponds to that default condition.", + fieldGuide: + 'Every field call MUST have exactly two arguments: field("label", "example"). Single-argument calls are forbidden.', + fieldGuide2: + 'If b is an object when a : b, the field should describe the whole object like this ("b description", b). The field should not be inside the object.', + headerGuide: + 'If an HTTP header key contains a hyphen (-), it MUST be quoted, e.g., "Cache-Control", "Last-Modified".', + noPathImport: + "Do not output any file-path import/require lines (e.g., examples/express-*). The runner/bootstrap is already provided.", + initGiven: + 'Do not generate the following since it is already included: const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc")', + chunksGuideFirst: + "If output is long, split into reasonable parts that can be continued later. Do not append trailing ellipses like '...'.", + chunksGuideNext: + "Output only the continuation of the previous part. Do NOT repeat earlier content. Do NOT print part titles like (1/3).", + langOut: "The output must be in English.", + codeOnly: "Output only test code lines.", + etc: "The part with function_call must be output as a function as it is. ex) fetchedAt: field('Inquiry Time (ISO 8601)', new Date().toISOString()) Also, if the type can be inferred by pathInt(), etc., it must be returned to that type. ex) For pathInt(page), it must be output in int type such as 1, 2, etc. And describeAPI must have summary, tag, and description values. Do not create non-existent endpoints or parameter/header/body fields. Use only the information in the input. Be sure to have a valid status () after res().The json values given in responses are not arbitrarily changed.", }, } as const const lang = isEn ? LANGUAGE_TEXTS.en : LANGUAGE_TEXTS.ko const codeType = isTypeScript ? lang.codeTypes.ts : lang.codeTypes.js const codeMessage = `${codeType} ${lang.codeLabel}` - - const partGuide = - part > 1 - ? `이전 출력의 이어지는 ${part}번째 부분만 출력하세요. 이전 내용을 반복하지 마세요.` - : `출력이 길어질 경우 다음 요청에서 이어받을 수 있도록 적절한 단위로 분할하여 출력하세요. 응답 마지막에 '...' 같은 기호는 넣지 마세요.` + const partGuide = part > 1 ? lang.chunksGuideNext : lang.chunksGuideFirst return ` -다음 테스트 내용을 기반으로 itdoc 테스트 스크립트를 ${codeMessage}로 생성해주세요. -- 모든 라우터에 대한 테스트를 포함해야 합니다. -- field는 field("a", "b") 처럼 2개의 매개변수를 반드시 포함해야 합니다. field로만 나오면 안됩니다. -- 중복되는 설명은 없어야 합니다. -- HTTP 헤더와 같이 하이픈(-)이 포함된 키는 반드시 큰따옴표로 감싸야 합니다. - 올바른 예시: "Cache-Control", "Last-Modified" - 잘못된 예시: Cache-Control, Last-Modified (no) -- 코드 설명 없이 코드만 출력해야 하며, \`(1/10)\` 같은 자동 분할 제목은 넣지 마세요. -- 출력은 ${codeMessage}로만 구성되어야 하며, 백틱 블록(\`\`\`)도 사용하지 않습니다. +다음 테스트 설명을 기반으로 itdoc 테스트 스크립트를 ${codeMessage}로 생성하세요. + +필수 규칙: +- ${lang.noComments} +- ${lang.codeOnly} +- ${lang.orderGuide} +- ${lang.branchGuide} +- ${lang.branchGuide2} +- ${lang.fieldGuide} +- ${lang.fieldGuide2} +- ${lang.headerGuide} +- ${lang.noPathImport} +- ${lang.initGiven} +- ${lang.etc} - ${partGuide} -${lang.outputInstruction} +- ${lang.outputInstruction} -[테스트 설명 내용] -${content} +[테스트를 진행해야하는 라우트 분석 결과] +${JSON.stringify(routesChunk, null, 2)} [함수 예시] ${itdocExample} -- 경로에 해당하는 코드는 출력하지 말아야 합니다. -ex) import { app } from "examples/express-ts/index.ts" - 또는 - const app = require("examples/express/index.js") - -- 아래 초기 설정 코드는 이미 포함되어 있으니 해당부분은 생성하지 말아야 합니다. -const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") -- header() 메서드는 다음과 같이 객체가 포함되어야 합니다. -header({ - Authorization: field("인증 토큰", "Bearer 123456"), -}) `.trim() } - -/** - * Returns a prompt for creating JSON-based API specifications in Markdown format. - * @param {any} content - JSON object containing API definitions. - * @param {number} part - Current part number when output is divided into multiple parts - * @returns {string} - Prompt message for Markdown generation. - */ -export function getMDPrompt(content: any, part?: number): string { - const partNote = part ? ` (이 문서는 ${part}번째 요청입니다)` : "" - return `다음 JSON을 바탕으로 API 테스트 케이스만 Markdown 형식으로 작성하세요${partNote}. -입력 JSON: -${JSON.stringify(content, null, 2)} -출력 포맷 (각 항목만): -- 엔드포인트 (예: GET /api/products) -- 테스트 이름 (간결하게) -- 요청 조건 (필요 시 요청 바디·경로 매개변수 등 자세하게 표현할 것) -- 예상 응답 (상태 코드 및 반환되는 객체 또는 메시지 등 자세하게 표현할 것) - -예시) -PUT /api/products/:id -- 테스트 이름: 제품 업데이트 성공 -- 요청 조건: 유효한 제품 ID와 name, price, category 제공 -- 예상 응답: 상태 코드 500, "message"가 "Server is running"인 JSON 응답, "data"에 "timestamp", "uptime", "memoryUsage" 정보 포함 - -지켜야 할 사항: -1. 제목·개요·요약·결론 등의 부가 설명은 절대 포함하지 마세요. -2. 일반 지침 문구(“정상 흐름과 오류 흐름을 모두 포함” 등)도 쓰지 않습니다. -3. 마크다운 강조(**), 코드 블록 안의 별도 스타일링은 사용 금지입니다. -4. 출력이 길어지면 (1 / 3), (2 / 3)처럼 순서 표기하여 분할하세요. -5. 오직 테스트 케이스 목록만, 항목별로 구분해 나열합니다. -6. DELETE, PUT, GET, POST, PATCH 앞에 - 를 붙이지 않습니다. -` -} diff --git a/script/makedocs/index.ts b/script/makedocs/index.ts index 365f0cb..b1934c9 100644 --- a/script/makedocs/index.ts +++ b/script/makedocs/index.ts @@ -40,11 +40,9 @@ export async function generateDocs(oasOutputPath: string, outputDir: string): Pr logger.info(`HTML path: ${htmlPath}`) const config = await loadConfig({}) - logger.info("Step 1: Redocly configuration loaded") const bundleResult = await bundle({ ref: oasOutputPath, config }) const api = bundleResult.bundle.parsed - logger.info("Step 2: OpenAPI bundling completed") const widdershinsOpts = { headings: 2, summary: true } console.log = () => {} const markdown = await widdershins.convert(api, widdershinsOpts) From 8e2bc85a914005f57001431a875d47e13900d9e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:13:39 +0000 Subject: [PATCH 03/14] chore(main): release 0.4.1 --- .github/release-please/manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/release-please/manifest.json b/.github/release-please/manifest.json index 8efb275..0bbe194 100644 --- a/.github/release-please/manifest.json +++ b/.github/release-please/manifest.json @@ -1 +1 @@ -{".":"0.4.0"} +{".":"0.4.1"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e098b5..bef5930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.4.1](https://github.com/do-pa/itdoc/compare/v0.4.0...v0.4.1) (2025-08-21) + + +### 🩹 Fixes + +* og_image is not working ([#231](https://github.com/do-pa/itdoc/issues/231)) ([d1070ec](https://github.com/do-pa/itdoc/commit/d1070ec1064cf914b576e2eb9153378903ccaf89)), closes [#230](https://github.com/do-pa/itdoc/issues/230) +* resolve issue causing LLM script to not run properly ([#235](https://github.com/do-pa/itdoc/issues/235)) ([1fa8d90](https://github.com/do-pa/itdoc/commit/1fa8d90fe14c37031baaa58f375550785f055275)) + ## [0.4.0](https://github.com/do-pa/itdoc/compare/v0.3.0...v0.4.0) (2025-08-08) diff --git a/package.json b/package.json index 8f42521..1b7f7ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "itdoc", - "version": "0.4.0", + "version": "0.4.1", "description": "Test-driven documentation for RESTful services", "license": "Apache-2.0", "bin": { From 0acfa8fbae7143cd32cdac13439f834204c09a1a Mon Sep 17 00:00:00 2001 From: Penek Date: Sat, 4 Oct 2025 18:57:20 +0900 Subject: [PATCH 04/14] fix: ensure header keys are case-insensitive (#251) * fix: apply header key normalized to lowercase * update expected oas.json --- examples/express/expected/oas.json | 6 +++--- lib/dsl/generator/OpenAPIGenerator.ts | 2 +- .../generator/builders/operation/SecurityBuilder.ts | 4 ++-- lib/dsl/test-builders/RequestBuilder.ts | 10 ++++++++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/express/expected/oas.json b/examples/express/expected/oas.json index d680ccc..c98ba29 100644 --- a/examples/express/expected/oas.json +++ b/examples/express/expected/oas.json @@ -646,7 +646,7 @@ "operationId": "postOrders", "parameters": [ { - "name": "X-Request-ID", + "name": "x-request-id", "in": "header", "schema": { "type": "string", @@ -1188,7 +1188,7 @@ "required": false }, { - "name": "Accept", + "name": "accept", "in": "header", "schema": { "type": "string", @@ -1197,7 +1197,7 @@ "required": false }, { - "name": "Accept-Language", + "name": "accept-language", "in": "header", "schema": { "type": "string", diff --git a/lib/dsl/generator/OpenAPIGenerator.ts b/lib/dsl/generator/OpenAPIGenerator.ts index 99b48b9..1a2a38b 100644 --- a/lib/dsl/generator/OpenAPIGenerator.ts +++ b/lib/dsl/generator/OpenAPIGenerator.ts @@ -491,7 +491,7 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { */ private selectRepresentativeResult(results: TestResult[]): TestResult { const authTestCase = results.find( - (result) => result.request.headers && "Authorization" in result.request.headers, + (result) => result.request.headers && "authorization" in result.request.headers, ) if (authTestCase) { diff --git a/lib/dsl/generator/builders/operation/SecurityBuilder.ts b/lib/dsl/generator/builders/operation/SecurityBuilder.ts index 5bad9e3..f2d16d0 100644 --- a/lib/dsl/generator/builders/operation/SecurityBuilder.ts +++ b/lib/dsl/generator/builders/operation/SecurityBuilder.ts @@ -32,8 +32,8 @@ export class SecurityBuilder implements SecurityBuilderInterface { public extractSecurityRequirements(result: TestResult): Array> { const security: Array> = [] - if (result.request.headers && "Authorization" in result.request.headers) { - const authHeaderValue = result.request.headers["Authorization"] + if (result.request.headers && "authorization" in result.request.headers) { + const authHeaderValue = result.request.headers["authorization"] let authHeader = "" if (typeof authHeaderValue === "string") { diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 0cdf3c5..2a89b40 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -25,12 +25,18 @@ import { AbstractTestBuilder } from "./AbstractTestBuilder" */ export class RequestBuilder extends AbstractTestBuilder { /** - * Sets headers to be used in requests. + * Sets headers to be used in requests. Header names are normalized to lowercase. * @param {Record>} headers Headers to be used in requests * @returns {this} Request builder instance */ public header(headers: Record>): this { - this.config.requestHeaders = headers + const normalizedHeaders: Record> = {} + + Object.entries(headers).forEach(([headerName, headerValue]) => { + normalizedHeaders[headerName.toLowerCase()] = headerValue + }) + + this.config.requestHeaders = normalizedHeaders return this } From fe7d3a21167116d9d246969608fa5a634f873918 Mon Sep 17 00:00:00 2001 From: Penek Date: Sun, 5 Oct 2025 18:07:54 +0900 Subject: [PATCH 05/14] fix: resolve syntax error in generated openapi json (#253) --- examples/express/expected/oas.json | 4 +- .../unit/dsl/OpenAPIGenerator.test.ts | 38 +++++++++++++++++++ lib/dsl/generator/OpenAPIGenerator.ts | 36 ++++++++++++++++-- lib/dsl/test-builders/RequestBuilder.ts | 12 +++++- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/examples/express/expected/oas.json b/examples/express/expected/oas.json index c98ba29..22475a0 100644 --- a/examples/express/expected/oas.json +++ b/examples/express/expected/oas.json @@ -89,7 +89,7 @@ } } }, - "/users/:userId": { + "/users/{userId}": { "get": { "summary": "사용자 조회 API", "tags": ["User"], @@ -383,7 +383,7 @@ } } }, - "/users/:userId/friends/:friendName": { + "/users/{userId}/friends/{friendName}": { "delete": { "summary": "특정 사용자의 친구를 삭제합니다.", "tags": ["User"], diff --git a/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts b/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts index 26860f4..53d5c9f 100644 --- a/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts +++ b/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts @@ -231,4 +231,42 @@ describe("OpenAPIGenerator", () => { assert.isUndefined(spec.paths["/test/no-body-responses"].get.responses["400"].content) }) }) + + describe("normalizePathTemplate", () => { + it("should handle paths without parameters", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/users") + assert.strictEqual(normalized, "/users") + }) + + it("should handle mixed format paths", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/users/{userId}/posts/:postId") + assert.strictEqual(normalized, "/users/{userId}/posts/{postId}") + }) + + it("should handle parameters with underscores", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/items/:item_id") + assert.strictEqual(normalized, "/items/{item_id}") + }) + + it("should handle empty path", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("") + assert.strictEqual(normalized, "") + }) + + it("should handle root path", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/") + assert.strictEqual(normalized, "/") + }) + + it("should handle hyphenated parameter names", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/files/:file-name") + assert.strictEqual(normalized, "/files/{file-name}") + }) + }) }) diff --git a/lib/dsl/generator/OpenAPIGenerator.ts b/lib/dsl/generator/OpenAPIGenerator.ts index 1a2a38b..88a0afa 100644 --- a/lib/dsl/generator/OpenAPIGenerator.ts +++ b/lib/dsl/generator/OpenAPIGenerator.ts @@ -130,10 +130,15 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { const paths: Record> = {} for (const [path, methods] of groupedResults) { - paths[path] = {} + const normalizedPath = this.normalizePathTemplate(path) + paths[normalizedPath] = {} for (const [method, statusCodes] of methods) { - paths[path][method] = this.generateOperationObject(path, method, statusCodes) + paths[normalizedPath][method] = this.generateOperationObject( + normalizedPath, + method, + statusCodes, + ) } } @@ -533,8 +538,7 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { */ private validatePathParameters(paths: Record>): void { for (const [path, pathItem] of Object.entries(paths)) { - const pathParamMatches = path.match(/:([^/]+)/g) || [] - const pathParams = pathParamMatches.map((param) => param.slice(1)) + const pathParams = this.extractPathParameterNames(path) if (pathParams.length === 0) continue @@ -571,6 +575,30 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { } } + /** + * Converts colon-prefixed Express parameters to OpenAPI-compatible templates. + * @param {string} path Raw route path + * @returns {string} Normalized OpenAPI path + */ + private normalizePathTemplate(path: string): string { + return path.replace(/:([A-Za-z0-9_-]+)/g, "{$1}") + } + + /** + * Extracts parameter names from a normalized or raw path template. + * @param {string} path Path string potentially containing parameters + * @returns {string[]} Parameter names + */ + private extractPathParameterNames(path: string): string[] { + const braceMatches = path.match(/\{([^}]+)\}/g) || [] + if (braceMatches.length > 0) { + return braceMatches.map((param) => param.slice(1, -1)) + } + + const colonMatches = path.match(/:([^/]+)/g) || [] + return colonMatches.map((param) => param.slice(1)) + } + /** * Normalizes examples according to the schema. * @param {any} example Example value diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 2a89b40..8b4ae50 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -19,6 +19,7 @@ import { DSLField } from "../interface" import { ResponseBuilder } from "./ResponseBuilder" import { FIELD_TYPES } from "../interface/field" import { AbstractTestBuilder } from "./AbstractTestBuilder" +import logger from "../../config/logger" /** * Builder class for setting API request information. @@ -31,9 +32,18 @@ export class RequestBuilder extends AbstractTestBuilder { */ public header(headers: Record>): this { const normalizedHeaders: Record> = {} + const seen = new Set() Object.entries(headers).forEach(([headerName, headerValue]) => { - normalizedHeaders[headerName.toLowerCase()] = headerValue + const normalized = headerName.toLowerCase() + + if (seen.has(normalized)) { + logger.warn(`Duplicate header detected: "${headerName}" (already set)`) + return + } + + seen.add(normalized) + normalizedHeaders[normalized] = headerValue }) this.config.requestHeaders = normalizedHeaders From 30b9065f81caddaea3918678629e15d672dcfe9c Mon Sep 17 00:00:00 2001 From: Penek Date: Mon, 6 Oct 2025 16:53:17 +0900 Subject: [PATCH 06/14] fix: functional response validation failure (#246) --- lib/dsl/test-builders/validateResponse.ts | 37 +++++++++++++++++------ 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/lib/dsl/test-builders/validateResponse.ts b/lib/dsl/test-builders/validateResponse.ts index 60d3a72..724e177 100644 --- a/lib/dsl/test-builders/validateResponse.ts +++ b/lib/dsl/test-builders/validateResponse.ts @@ -25,18 +25,21 @@ import { isDSLField } from "../interface/field" * @see {@link import('../interface/field.ts').field} */ const validateDSLField = (expectedDSL: any, actualVal: any, path: string): void => { + const example = expectedDSL.example + if (example === undefined) { + throw new Error( + `The example value of the DSL field at response body[${path}] is undefined. Skipping validation for this field.`, + ) + } + // DSL Field의 example이 함수인 경우 if (typeof expectedDSL.example === "function") { - expectedDSL.example(actualVal) + validateFunction(expectedDSL.example, actualVal) return } // DSL Field의 example이 객체인 경우 - if ( - expectedDSL.example && - typeof expectedDSL.example === "object" && - expectedDSL.example !== null - ) { + if (example && typeof example === "object") { if (isDSLField(actualVal)) { validateResponse(expectedDSL.example, actualVal.example, path) } else { @@ -80,6 +83,20 @@ const validateArray = (expectedArr: any[], actualArr: any[], path: string): void }) } +const validateFunction = (func: (actualValue: any) => any, actualVal: any): void => { + const argsCount = func.length + if (argsCount > 1) { + throw new Error( + `Validator function should have at most one argument, but got ${argsCount}. + Please check the following function: + + ${func.toString()}`, + ) + } + + func(actualVal) +} + /** * Function that performs **actual validation** of API response values defined in `ResponseBuilder`. * Performs validation by branching for various types such as arrays, objects, etc. @@ -97,7 +114,7 @@ export const validateResponse = (expected: any, actual: any, path: string = ""): } // 객체인 경우 (null 제외) - if (expected && typeof expected === "object" && expected !== null) { + if (expected && typeof expected === "object") { for (const key in expected) { const currentPath = path ? `${path}.${key}` : key const expectedVal = expected[key] @@ -107,8 +124,10 @@ export const validateResponse = (expected: any, actual: any, path: string = ""): validateDSLField(expectedVal, actualVal, currentPath) } else if (Array.isArray(expectedVal)) { validateArray(expectedVal, actualVal, currentPath) - } else if (expectedVal && typeof expectedVal === "object" && expectedVal !== null) { - if (!actualVal || typeof actualVal !== "object" || actualVal === null) { + } else if (typeof expectedVal === "function") { + validateFunction(expectedVal, actualVal) + } else if (expectedVal && typeof expectedVal === "object") { + if (!actualVal || typeof actualVal !== "object") { throw new Error( `Expected response body[${currentPath}] to be an object but got ${actualVal}`, ) From 81879cebcbd6c3a2e893af0b9b8742ae9ba564b2 Mon Sep 17 00:00:00 2001 From: jaesong-blip Date: Sat, 4 Oct 2025 18:15:19 +0900 Subject: [PATCH 07/14] feat: add wrapper-based API testing approach (draft/WIP) Implement high-order function wrapper for Jest/Mocha to capture HTTP requests/responses automatically for API documentation generation. ## Implementation (Draft) ### Core Components - **wrapTest()**: Wraps Jest/Mocha 'it' function with capture context - **request()**: Proxy-based supertest wrapper for request interception - **CaptureContext**: AsyncLocalStorage for thread-safe context management ### Key Features - Automatic HTTP request/response capture during tests - Support for multipart/form-data file uploads (fixes #247) - Thread-safe test isolation using AsyncLocalStorage - Integration with existing TestEventManager/TestResultCollector - Coexists with existing DSL approach (describeAPI/itDoc) ### Test Coverage - Unit tests for CaptureContext, interceptedRequest, wrapTest - Integration tests for complete workflow - Real-world examples: user, product, upload APIs ### Files Added - lib/wrappers/core/CaptureContext.ts - lib/wrappers/core/interceptedRequest.ts - lib/wrappers/wrapTest.ts - lib/wrappers/types.ts - examples/express-ts/src/__tests__/*.wrapper.test.ts - examples/express-ts/src/routes/upload.routes.ts ### Known Issues & TODOs - Needs comprehensive code review - Requires additional edge case testing - Documentation needs improvement - Type definitions may need refinement - ESLint warnings to be addressed - AsyncLocalStorage context timing requires validation This is a draft implementation for issue #241. Further review and testing required before production use. Related: #241, #247 --- examples/express-ts/package.json | 1 + .../src/__tests__/product.wrapper.test.ts | 211 +++++++++ .../src/__tests__/upload.wrapper.test.ts | 118 +++++ .../src/__tests__/user.wrapper.test.ts | 140 ++++++ examples/express-ts/src/index.ts | 2 + .../express-ts/src/routes/upload.routes.ts | 63 +++ lib/dsl/index.ts | 4 + lib/wrappers/EXAMPLE.md | 436 ++++++++++++++++++ lib/wrappers/README.md | 300 ++++++++++++ lib/wrappers/__tests__/CaptureContext.test.ts | 188 ++++++++ lib/wrappers/__tests__/integration.test.ts | 133 ++++++ .../__tests__/interceptedRequest.test.ts | 252 ++++++++++ .../__tests__/wrapTest.integration.test.ts | 145 ++++++ lib/wrappers/core/CaptureContext.ts | 112 +++++ lib/wrappers/core/interceptedRequest.ts | 144 ++++++ lib/wrappers/index.ts | 57 +++ lib/wrappers/types.ts | 68 +++ lib/wrappers/wrapTest.ts | 129 ++++++ pnpm-lock.yaml | 14 +- 19 files changed, 2516 insertions(+), 1 deletion(-) create mode 100644 examples/express-ts/src/__tests__/product.wrapper.test.ts create mode 100644 examples/express-ts/src/__tests__/upload.wrapper.test.ts create mode 100644 examples/express-ts/src/__tests__/user.wrapper.test.ts create mode 100644 examples/express-ts/src/routes/upload.routes.ts create mode 100644 lib/wrappers/EXAMPLE.md create mode 100644 lib/wrappers/README.md create mode 100644 lib/wrappers/__tests__/CaptureContext.test.ts create mode 100644 lib/wrappers/__tests__/integration.test.ts create mode 100644 lib/wrappers/__tests__/interceptedRequest.test.ts create mode 100644 lib/wrappers/__tests__/wrapTest.integration.test.ts create mode 100644 lib/wrappers/core/CaptureContext.ts create mode 100644 lib/wrappers/core/interceptedRequest.ts create mode 100644 lib/wrappers/index.ts create mode 100644 lib/wrappers/types.ts create mode 100644 lib/wrappers/wrapTest.ts diff --git a/examples/express-ts/package.json b/examples/express-ts/package.json index 1f39839..6186b3e 100644 --- a/examples/express-ts/package.json +++ b/examples/express-ts/package.json @@ -20,6 +20,7 @@ "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/mocha": "^10.0.10", + "@types/multer": "^2.0.0", "@types/node": "^20.10.5", "@types/supertest": "^6.0.2", "itdoc": "workspace:*", diff --git a/examples/express-ts/src/__tests__/product.wrapper.test.ts b/examples/express-ts/src/__tests__/product.wrapper.test.ts new file mode 100644 index 0000000..9e59525 --- /dev/null +++ b/examples/express-ts/src/__tests__/product.wrapper.test.ts @@ -0,0 +1,211 @@ +/** + * Product API Tests using wrapTest wrapper approach + * + * This demonstrates the new high-order function wrapping method + * that automatically captures HTTP requests/responses + */ + +import { app } from "../index" +import { wrapTest, request } from "itdoc" + +// Create wrapped test function +const apiTest = wrapTest(it) + +describe("Product API - Wrapper Approach", () => { + describe("GET /api/products/:id", () => { + apiTest.withMeta({ + summary: "Get product by ID", + tags: ["Products"], + description: "Retrieves a specific product by its ID", + })("should return a specific product", async () => { + const response = await request(app).get("/api/products/1") + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("id", 1) + expect(response.body).toHaveProperty("name", "Laptop") + expect(response.body).toHaveProperty("price", 999.99) + expect(response.body).toHaveProperty("category", "Electronics") + }) + + apiTest("should return product with different ID", async () => { + const response = await request(app).get("/api/products/2") + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("id", 2) + expect(response.body).toHaveProperty("name", "Phone") + }) + }) + + describe("POST /api/products", () => { + apiTest.withMeta({ + summary: "Create new product", + tags: ["Products", "Create"], + description: "Creates a new product with the provided information", + })("should create a new product", async () => { + const response = await request(app).post("/api/products").send({ + name: "Test Product", + price: 99.99, + category: "Test Category", + }) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty("id", 3) + expect(response.body).toHaveProperty("name", "Test Product") + expect(response.body).toHaveProperty("price", 99.99) + expect(response.body).toHaveProperty("category", "Test Category") + }) + + apiTest.withMeta({ + summary: "Create product with different data", + tags: ["Products", "Create"], + })("should create another product", async () => { + const response = await request(app).post("/api/products").send({ + name: "Another Product", + price: 199.99, + category: "Another Category", + }) + + expect(response.status).toBe(201) + expect(response.body.name).toBe("Another Product") + }) + }) + + describe("PUT /api/products/:id", () => { + apiTest.withMeta({ + summary: "Update product", + tags: ["Products", "Update"], + description: "Updates an existing product with the provided information", + })("should update a product", async () => { + const response = await request(app).put("/api/products/1").send({ + name: "Updated Product", + price: 199.99, + category: "Updated Category", + }) + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("id", 1) + expect(response.body).toHaveProperty("name", "Updated Product") + expect(response.body).toHaveProperty("price", 199.99) + expect(response.body).toHaveProperty("category", "Updated Category") + }) + + apiTest("should update product with partial data", async () => { + const response = await request(app).put("/api/products/2").send({ + name: "Partially Updated", + price: 299.99, + category: "Electronics", + }) + + expect(response.status).toBe(200) + expect(response.body.name).toBe("Partially Updated") + }) + }) + + describe("DELETE /api/products/:id", () => { + apiTest.withMeta({ + summary: "Delete product", + tags: ["Products", "Delete"], + description: "Deletes a product by its ID", + })("should delete a product", async () => { + const response = await request(app).delete("/api/products/1") + + expect(response.status).toBe(204) + }) + + apiTest("should delete another product", async () => { + const response = await request(app).delete("/api/products/2") + + expect(response.status).toBe(204) + }) + }) + + describe("Complete product CRUD workflow", () => { + apiTest.withMeta({ + summary: "Product CRUD workflow", + tags: ["Products", "Workflow", "CRUD"], + description: "Complete create, read, update, delete workflow for products", + })("should perform complete CRUD operations", async () => { + // Step 1: Create a product + const createResponse = await request(app).post("/api/products").send({ + name: "Workflow Product", + price: 149.99, + category: "Test", + }) + + expect(createResponse.status).toBe(201) + const productId = createResponse.body.id + + // Step 2: Read the product + const getResponse = await request(app).get(`/api/products/${productId}`) + + expect(getResponse.status).toBe(200) + expect(getResponse.body.name).toBe("Workflow Product") + + // Step 3: Update the product + const updateResponse = await request(app).put(`/api/products/${productId}`).send({ + name: "Updated Workflow Product", + price: 179.99, + category: "Updated Test", + }) + + expect(updateResponse.status).toBe(200) + expect(updateResponse.body.name).toBe("Updated Workflow Product") + + // Step 4: Delete the product + const deleteResponse = await request(app).delete(`/api/products/${productId}`) + + expect(deleteResponse.status).toBe(204) + }) + }) + + describe("Product filtering and search", () => { + apiTest.withMeta({ + summary: "Filter products by category", + tags: ["Products", "Filter"], + })("should filter products with query params", async () => { + const response = await request(app) + .get("/api/products/1") + .query({ category: "Electronics", minPrice: 500 }) + + expect(response.status).toBe(200) + }) + + apiTest("should search products with multiple params", async () => { + const response = await request(app).get("/api/products/1").query({ + search: "laptop", + sortBy: "price", + order: "asc", + }) + + expect(response.status).toBe(200) + }) + }) + + describe("Product API with authentication", () => { + apiTest.withMeta({ + summary: "Create product with auth", + tags: ["Products", "Authentication"], + })("should create product with authorization header", async () => { + const response = await request(app) + .post("/api/products") + .set("Authorization", "Bearer fake-token-123") + .send({ + name: "Authenticated Product", + price: 299.99, + category: "Secure", + }) + + expect(response.status).toBe(201) + }) + + apiTest("should include custom headers", async () => { + const response = await request(app) + .get("/api/products/1") + .set("Authorization", "Bearer token") + .set("X-Client-ID", "test-client") + .set("Accept", "application/json") + + expect(response.status).toBe(200) + }) + }) +}) diff --git a/examples/express-ts/src/__tests__/upload.wrapper.test.ts b/examples/express-ts/src/__tests__/upload.wrapper.test.ts new file mode 100644 index 0000000..0a7c444 --- /dev/null +++ b/examples/express-ts/src/__tests__/upload.wrapper.test.ts @@ -0,0 +1,118 @@ +import { app } from "../index" +import { wrapTest, request } from "itdoc" +import path from "path" +import fs from "fs" + +const apiTest = wrapTest(it) + +describe("Upload API - Wrapper Approach", () => { + const testFilePath = path.join(__dirname, "test-file.txt") + const testImagePath = path.join(__dirname, "test-image.png") + + beforeAll(() => { + // Create test files + fs.writeFileSync(testFilePath, "This is a test file content") + + // Create a simple 1x1 PNG image + const pngBuffer = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, + 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82 // IEND chunk + ]) + fs.writeFileSync(testImagePath, pngBuffer) + }) + + afterAll(() => { + // Cleanup test files + if (fs.existsSync(testFilePath)) { + fs.unlinkSync(testFilePath) + } + if (fs.existsSync(testImagePath)) { + fs.unlinkSync(testImagePath) + } + }) + + apiTest.withMeta({ + summary: "Upload a single file", + tags: ["Upload"], + description: "Uploads a single file using multipart/form-data" + })("should upload a single file successfully", async () => { + const response = await request(app) + .post("/api/upload/single") + .attach("file", testFilePath) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty("message", "File uploaded successfully") + expect(response.body.file).toHaveProperty("originalname", "test-file.txt") + expect(response.body.file).toHaveProperty("mimetype", "text/plain") + }) + + apiTest.withMeta({ + summary: "Upload multiple files", + tags: ["Upload"], + description: "Uploads multiple files in a single request" + })("should upload multiple files successfully", async () => { + const response = await request(app) + .post("/api/upload/multiple") + .attach("files", testFilePath) + .attach("files", testImagePath) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty("message", "Files uploaded successfully") + expect(response.body.files).toHaveLength(2) + expect(response.body.files[0]).toHaveProperty("originalname", "test-file.txt") + expect(response.body.files[1]).toHaveProperty("originalname", "test-image.png") + }) + + apiTest.withMeta({ + summary: "Upload file with additional fields", + tags: ["Upload", "Documents"], + description: "Uploads a file along with additional form fields (title, description)" + })("should upload file with additional form fields", async () => { + const response = await request(app) + .post("/api/upload/with-fields") + .field("title", "Important Document") + .field("description", "This is a very important document") + .attach("document", testFilePath) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty("message", "Document uploaded successfully") + expect(response.body.document).toHaveProperty("title", "Important Document") + expect(response.body.document).toHaveProperty("description", "This is a very important document") + expect(response.body.document.file).toHaveProperty("originalname", "test-file.txt") + }) + + apiTest.withMeta({ + summary: "Handle missing file upload", + tags: ["Upload", "Error Handling"], + description: "Returns 400 error when no file is provided" + })("should return 400 when no file is uploaded", async () => { + const response = await request(app) + .post("/api/upload/single") + .send({}) + + expect(response.status).toBe(400) + expect(response.body).toHaveProperty("error", "No file uploaded") + }) + + apiTest.withMeta({ + summary: "Upload image file", + tags: ["Upload", "Images"], + description: "Uploads an image file (PNG)" + })("should upload an image file successfully", async () => { + const response = await request(app) + .post("/api/upload/single") + .attach("file", testImagePath) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty("message", "File uploaded successfully") + expect(response.body.file).toHaveProperty("originalname", "test-image.png") + expect(response.body.file).toHaveProperty("mimetype", "image/png") + }) +}) diff --git a/examples/express-ts/src/__tests__/user.wrapper.test.ts b/examples/express-ts/src/__tests__/user.wrapper.test.ts new file mode 100644 index 0000000..ae36b3d --- /dev/null +++ b/examples/express-ts/src/__tests__/user.wrapper.test.ts @@ -0,0 +1,140 @@ +/** + * User API Tests using wrapTest wrapper approach + * + * This demonstrates the new high-order function wrapping method + * that automatically captures HTTP requests/responses + */ + +import { app } from "../index" +import { wrapTest, request } from "itdoc" + +// Create wrapped test function +const apiTest = wrapTest(it) + +describe("User API - Wrapper Approach", () => { + describe("POST /api/user/register", () => { + apiTest.withMeta({ + summary: "Register new user", + tags: ["Users", "Authentication"], + description: "Registers a new user with username and password", + })("should register a new user successfully", async () => { + const response = await request(app).post("/api/user/register").send({ + username: "testuser", + password: "testpassword", + }) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty("message", "User registered successfully") + expect(response.body.user).toHaveProperty("username", "testuser") + }) + + apiTest.withMeta({ + summary: "Register user - missing username", + tags: ["Users", "Authentication", "Validation"], + })("should return error when username is missing", async () => { + const response = await request(app).post("/api/user/register").send({ + password: "testpassword", + }) + + expect(response.status).toBe(400) + expect(response.body).toHaveProperty("message", "Username and password are required.") + }) + + apiTest.withMeta({ + summary: "Register user - missing password", + tags: ["Users", "Authentication", "Validation"], + })("should return error when password is missing", async () => { + const response = await request(app).post("/api/user/register").send({ + username: "testuser", + }) + + expect(response.status).toBe(400) + expect(response.body).toHaveProperty("message", "Username and password are required.") + }) + }) + + describe("POST /api/user/login", () => { + apiTest.withMeta({ + summary: "User login", + tags: ["Users", "Authentication"], + description: "Authenticates a user with username and password", + })("should login successfully with valid credentials", async () => { + const response = await request(app).post("/api/user/login").send({ + username: "admin", + password: "admin", + }) + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("message", "Login successful") + expect(response.body).toHaveProperty("token", "fake-jwt-token") + }) + + apiTest.withMeta({ + summary: "User login - invalid credentials", + tags: ["Users", "Authentication", "Error"], + })("should return error with invalid credentials", async () => { + const response = await request(app).post("/api/user/login").send({ + username: "wronguser", + password: "wrongpassword", + }) + + expect(response.status).toBe(401) + expect(response.body).toHaveProperty("message", "Invalid credentials") + }) + }) + + describe("GET /api/user/:id", () => { + apiTest.withMeta({ + summary: "Get user by ID", + tags: ["Users"], + description: "Retrieves a specific user by their ID", + })("should return user information", async () => { + const response = await request(app).get("/api/user/123") + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("id", "123") + expect(response.body).toHaveProperty("username", "exampleUser") + expect(response.body).toHaveProperty("email", "user@example.com") + expect(response.body).toHaveProperty("profilePicture", null) + }) + + apiTest("should handle different user IDs", async () => { + const response = await request(app).get("/api/user/456") + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("id", "456") + }) + }) + + describe("Complete user workflow", () => { + apiTest.withMeta({ + summary: "User registration and login flow", + tags: ["Users", "Workflow"], + description: "Complete user registration and authentication workflow", + })("should register and login successfully", async () => { + // Step 1: Register new user + const registerResponse = await request(app).post("/api/user/register").send({ + username: "newuser", + password: "newpassword", + }) + + expect(registerResponse.status).toBe(201) + expect(registerResponse.body.user.username).toBe("newuser") + + // Step 2: Login with new credentials + const loginResponse = await request(app).post("/api/user/login").send({ + username: "admin", // Using admin for demo + password: "admin", + }) + + expect(loginResponse.status).toBe(200) + expect(loginResponse.body).toHaveProperty("token") + + // Step 3: Get user info + const userResponse = await request(app).get("/api/user/123") + + expect(userResponse.status).toBe(200) + expect(userResponse.body).toHaveProperty("username") + }) + }) +}) diff --git a/examples/express-ts/src/index.ts b/examples/express-ts/src/index.ts index 2023579..4294240 100644 --- a/examples/express-ts/src/index.ts +++ b/examples/express-ts/src/index.ts @@ -3,6 +3,7 @@ import cors from "cors" import dotenv from "dotenv" import { productRoutes } from "./routes/product.routes" import { userRoutes } from "./routes/user.routes" +import { uploadRoutes } from "./routes/upload.routes" dotenv.config() @@ -14,6 +15,7 @@ app.use(express.json()) app.use("/api/user", userRoutes) app.use("/api/products", productRoutes) +app.use("/api/upload", uploadRoutes) app.get("/health", (_req, res) => { const baseResponse = { diff --git a/examples/express-ts/src/routes/upload.routes.ts b/examples/express-ts/src/routes/upload.routes.ts new file mode 100644 index 0000000..f305fca --- /dev/null +++ b/examples/express-ts/src/routes/upload.routes.ts @@ -0,0 +1,63 @@ +import { Router } from "express" +import multer from "multer" + +const router = Router() +const upload = multer({ storage: multer.memoryStorage() }) + +// Single file upload +router.post("/single", upload.single("file") as any, (req: any, res: any) => { + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }) + } + + res.status(201).json({ + message: "File uploaded successfully", + file: { + originalname: req.file.originalname, + mimetype: req.file.mimetype, + size: req.file.size, + }, + }) +}) + +// Multiple files upload +router.post("/multiple", upload.array("files", 5) as any, (req: any, res: any) => { + const files = req.files as any[] + + if (!files || files.length === 0) { + return res.status(400).json({ error: "No files uploaded" }) + } + + res.status(201).json({ + message: "Files uploaded successfully", + files: files.map((f) => ({ + originalname: f.originalname, + mimetype: f.mimetype, + size: f.size, + })), + }) +}) + +// File upload with additional fields +router.post("/with-fields", upload.single("document") as any, (req: any, res: any) => { + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }) + } + + const { title, description } = req.body + + res.status(201).json({ + message: "Document uploaded successfully", + document: { + title: title || "Untitled", + description: description || "", + file: { + originalname: req.file.originalname, + mimetype: req.file.mimetype, + size: req.file.size, + }, + }, + }) +}) + +export { router as uploadRoutes } diff --git a/lib/dsl/index.ts b/lib/dsl/index.ts index 5692bb7..ab7d7dc 100644 --- a/lib/dsl/index.ts +++ b/lib/dsl/index.ts @@ -18,3 +18,7 @@ export { HttpMethod } from "./enums/HttpMethod" export { HttpStatus } from "./enums/HttpStatus" export { describeAPI, itDoc, field } from "./interface" export type { ApiDocOptions } from "./interface/ItdocBuilderEntry" + +// Wrapper-based API testing (new approach) +export { wrapTest, request } from "../wrappers" +export type { ApiDocMetadata, WrappedTestFunction } from "../wrappers" diff --git a/lib/wrappers/EXAMPLE.md b/lib/wrappers/EXAMPLE.md new file mode 100644 index 0000000..908d251 --- /dev/null +++ b/lib/wrappers/EXAMPLE.md @@ -0,0 +1,436 @@ +# Wrapper-based API Testing Examples + +이 문서는 `wrapTest`를 사용한 실전 예제를 제공합니다. + +## 📚 목차 + +- [기본 사용법](#기본-사용법) +- [인증 & 권한](#인증--권한) +- [복잡한 워크플로우](#복잡한-워크플로우) +- [에러 처리](#에러-처리) +- [메타데이터 활용](#메타데이터-활용) +- [기존 코드 마이그레이션](#기존-코드-마이그레이션) + +## 기본 사용법 + +### 1. 간단한 GET 요청 + +```typescript +import { wrapTest, request } from 'itdoc/wrappers' + +const apiTest = wrapTest(it) + +describe('Product API', () => { + apiTest('should get all products', async () => { + const response = await request(app) + .get('/api/products') + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty('products') + expect(Array.isArray(response.body.products)).toBe(true) + }) +}) +``` + +### 2. POST 요청으로 리소스 생성 + +```typescript +apiTest('should create new product', async () => { + const response = await request(app) + .post('/api/products') + .send({ + name: 'iPhone 15', + price: 999.99, + category: 'electronics' + }) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty('id') + expect(response.body.name).toBe('iPhone 15') +}) +``` + +### 3. 쿼리 파라미터 사용 + +```typescript +apiTest('should filter products by category', async () => { + const response = await request(app) + .get('/api/products') + .query({ + category: 'electronics', + minPrice: 500, + maxPrice: 2000 + }) + + expect(response.status).toBe(200) + expect(response.body.products.every(p => p.category === 'electronics')).toBe(true) +}) +``` + +## 인증 & 권한 + +### 4. JWT 토큰 인증 + +```typescript +describe('Auth API', () => { + apiTest('should login and get token', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + email: 'admin@example.com', + password: 'password123' + }) + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty('token') + }) +}) +``` + +### 5. 인증된 요청 + +```typescript +apiTest('should access protected route with token', async () => { + const response = await request(app) + .get('/api/admin/dashboard') + .set('Authorization', 'Bearer eyJhbGciOiJIUzI1NiIs...') + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty('stats') +}) +``` + +### 6. 권한 부족 에러 + +```typescript +apiTest('should reject unauthorized access', async () => { + const response = await request(app) + .get('/api/admin/users') + // No Authorization header + + expect(response.status).toBe(401) + expect(response.body.error).toBe('Unauthorized') +}) +``` + +## 복잡한 워크플로우 + +### 7. 전체 사용자 등록 플로우 + +```typescript +apiTest('should complete user registration flow', async () => { + // 1. 회원가입 + const signupRes = await request(app) + .post('/api/auth/signup') + .send({ + email: 'newuser@example.com', + password: 'secure123', + name: 'John Doe' + }) + + expect(signupRes.status).toBe(201) + const userId = signupRes.body.id + + // 2. 이메일 인증 (시뮬레이션) + const verifyRes = await request(app) + .post(`/api/auth/verify/${userId}`) + .send({ code: '123456' }) + + expect(verifyRes.status).toBe(200) + + // 3. 로그인 + const loginRes = await request(app) + .post('/api/auth/login') + .send({ + email: 'newuser@example.com', + password: 'secure123' + }) + + expect(loginRes.status).toBe(200) + expect(loginRes.body).toHaveProperty('token') +}) +``` + +### 8. 주문 생성 및 결제 + +```typescript +apiTest('should create order and process payment', async () => { + // 1. 장바구니에 상품 추가 + const cartRes = await request(app) + .post('/api/cart/items') + .set('Authorization', 'Bearer token') + .send({ + productId: 123, + quantity: 2 + }) + + expect(cartRes.status).toBe(200) + + // 2. 주문 생성 + const orderRes = await request(app) + .post('/api/orders') + .set('Authorization', 'Bearer token') + .send({ + shippingAddress: '123 Main St', + paymentMethod: 'credit_card' + }) + + expect(orderRes.status).toBe(201) + const orderId = orderRes.body.id + + // 3. 결제 처리 + const paymentRes = await request(app) + .post(`/api/orders/${orderId}/pay`) + .set('Authorization', 'Bearer token') + .send({ + cardNumber: '4242424242424242', + expiry: '12/25', + cvv: '123' + }) + + expect(paymentRes.status).toBe(200) + expect(paymentRes.body.status).toBe('paid') +}) +``` + +## 에러 처리 + +### 9. Validation 에러 + +```typescript +apiTest('should validate required fields', async () => { + const response = await request(app) + .post('/api/products') + .send({ + // name 누락 + price: 999.99 + }) + + expect(response.status).toBe(400) + expect(response.body.errors).toContain('name is required') +}) +``` + +### 10. Not Found 에러 + +```typescript +apiTest('should return 404 for non-existent resource', async () => { + const response = await request(app) + .get('/api/products/99999') + + expect(response.status).toBe(404) + expect(response.body.error).toBe('Product not found') +}) +``` + +### 11. Server 에러 + +```typescript +apiTest('should handle server errors gracefully', async () => { + const response = await request(app) + .post('/api/products/import') + .send({ file: 'invalid-data' }) + + expect(response.status).toBe(500) + expect(response.body).toHaveProperty('error') +}) +``` + +## 메타데이터 활용 + +### 12. 상세한 API 문서 메타데이터 + +```typescript +apiTest.withMeta({ + summary: 'Create Product', + description: 'Creates a new product in the inventory system', + tags: ['Products', 'Inventory'], +})('POST /api/products - Create product', async () => { + const response = await request(app) + .post('/api/products') + .send({ + name: 'MacBook Pro', + price: 2499.99, + category: 'computers' + }) + + expect(response.status).toBe(201) +}) +``` + +### 13. Deprecated API 표시 + +```typescript +apiTest.withMeta({ + summary: 'Legacy User List', + tags: ['Users', 'Legacy'], + deprecated: true, + description: 'This endpoint is deprecated. Use /api/v2/users instead.' +})('GET /api/users - List users (deprecated)', async () => { + const response = await request(app) + .get('/api/users') + + expect(response.status).toBe(200) +}) +``` + +### 14. 여러 태그로 분류 + +```typescript +apiTest.withMeta({ + summary: 'Export User Data (GDPR)', + tags: ['Users', 'Privacy', 'GDPR', 'Export'], + description: 'Exports all user data for GDPR compliance' +})('GET /api/users/:id/export - Export user data', async () => { + const response = await request(app) + .get('/api/users/123/export') + .set('Authorization', 'Bearer token') + + expect(response.status).toBe(200) + expect(response.headers['content-type']).toContain('application/json') +}) +``` + +## 기존 코드 마이그레이션 + +### 15. 기존 Supertest 코드 마이그레이션 + +**Before (원본):** +```typescript +import request from 'supertest' + +describe('User API', () => { + it('should create user', async () => { + const response = await request(app) + .post('/api/users') + .send({ name: 'John', email: 'john@test.com' }) + + expect(response.status).toBe(201) + }) +}) +``` + +**After (wrapTest 적용):** +```typescript +import { wrapTest, request } from 'itdoc/wrappers' + +const apiTest = wrapTest(it) + +describe('User API', () => { + apiTest('should create user', async () => { + const response = await request(app) + .post('/api/users') + .send({ name: 'John', email: 'john@test.com' }) + + expect(response.status).toBe(201) + }) +}) +``` + +**변경사항:** +1. ✅ Import 변경: `supertest` → `itdoc/wrappers` +2. ✅ `wrapTest(it)` 추가 +3. ✅ `it` → `apiTest` 사용 +4. ✅ 나머지 코드는 동일! + +### 16. Jest describe.each 패턴과 함께 사용 + +```typescript +const apiTest = wrapTest(it) + +describe.each([ + { role: 'admin', canDelete: true }, + { role: 'user', canDelete: false }, + { role: 'guest', canDelete: false }, +])('Authorization for $role', ({ role, canDelete }) => { + apiTest(`${role} should ${canDelete ? 'be able to' : 'not be able to'} delete users`, async () => { + const response = await request(app) + .delete('/api/users/123') + .set('Authorization', `Bearer ${role}-token`) + + expect(response.status).toBe(canDelete ? 200 : 403) + }) +}) +``` + +### 17. Mocha에서 사용 + +```typescript +import { wrapTest, request } from 'itdoc/wrappers' +import { expect } from 'chai' + +const apiTest = wrapTest(it) + +describe('Product API', function() { + apiTest('should get products', async function() { + const response = await request(app) + .get('/api/products') + + expect(response.status).to.equal(200) + expect(response.body).to.have.property('products') + }) +}) +``` + +## 🎯 Best Practices + +### ✅ DO + +```typescript +// ✅ 명확한 테스트 설명 +apiTest('should return 404 when product not found', async () => { + // ... +}) + +// ✅ 메타데이터로 문서 품질 향상 +apiTest.withMeta({ + summary: 'User Registration', + tags: ['Auth', 'Users'] +})('POST /auth/signup', async () => { + // ... +}) + +// ✅ 여러 API 호출을 워크플로우로 테스트 +apiTest('should complete checkout flow', async () => { + await request(app).post('/cart/add').send({...}) + await request(app).post('/orders').send({...}) + await request(app).post('/payment').send({...}) +}) +``` + +### ❌ DON'T + +```typescript +// ❌ 모호한 테스트 설명 +apiTest('test1', async () => { + // ... +}) + +// ❌ 문서화가 필요 없는 헬퍼 함수를 apiTest로 감싸기 +apiTest('helper function', async () => { + // 이건 일반 it()을 사용하세요 +}) + +// ❌ 너무 많은 API 호출 (10개 이상) +apiTest('complex flow', async () => { + // 10개 이상의 API 호출... + // 테스트를 분리하는 것이 좋습니다 +}) +``` + +## 📊 비교표 + +| 기능 | 기존 itDoc | wrapTest | +|------|-----------|----------| +| 사용 난이도 | 중간 (새 DSL 학습) | 쉬움 (기존 패턴 유지) | +| 코드 변경량 | 많음 | 최소 | +| 자동 캡처 | ❌ 수동 설정 | ✅ 자동 | +| 메타데이터 | 체이닝 방식 | `withMeta()` | +| 기존 코드 호환 | 낮음 | 높음 | +| 타입 안전성 | ✅ | ✅ | + +## 🔗 추가 리소스 + +- [README.md](./README.md) - 전체 문서 +- [기존 itDoc 문서](../../README.md) - 기존 방식 비교 +- [TypeScript 타입 정의](./types.ts) diff --git a/lib/wrappers/README.md b/lib/wrappers/README.md new file mode 100644 index 0000000..487ee9b --- /dev/null +++ b/lib/wrappers/README.md @@ -0,0 +1,300 @@ +# Wrapper-based API Testing + +고차함수 래핑 방식으로 기존 Jest/Mocha 테스트를 최소한으로 수정하여 자동으로 API 문서를 생성합니다. + +## 📋 개요 + +이 모듈은 기존 테스트 프레임워크의 `it` 함수를 감싸서 HTTP request/response를 자동으로 캡처하고, 테스트 성공 시 OpenAPI 문서를 생성합니다. + +### 핵심 특징 + +- ✅ **최소 변경**: `it` → `wrapTest(it)` 한 줄만 변경 +- ✅ **자동 캡처**: Proxy 기반 투명한 request/response 인터셉션 +- ✅ **프레임워크 중립**: Jest/Mocha 모두 지원 +- ✅ **기존 코드 보존**: 새로운 방식이므로 기존 `itDoc` 방식과 공존 가능 +- ✅ **타입 안전**: 완전한 TypeScript 지원 + +## 🚀 사용법 + +### Before (기존 테스트) + +```typescript +import request from 'supertest' + +describe('User API', () => { + it('should create user', async () => { + const response = await request(app) + .post('/users') + .send({ name: 'John', email: 'john@test.com' }) + + expect(response.status).toBe(201) + }) +}) +``` + +### After (itdoc wrappers 적용) + +```typescript +import { wrapTest, request } from 'itdoc/wrappers' + +const apiTest = wrapTest(it) // ← it 함수를 래핑 + +describe('User API', () => { + apiTest('should create user', async () => { // ← it 대신 apiTest 사용 + const response = await request(app) // ← itdoc의 request 사용 + .post('/users') + .send({ name: 'John', email: 'john@test.com' }) + + expect(response.status).toBe(201) + // ✅ 자동으로 request/response 캡처 & 문서 생성! + }) +}) +``` + +## 📝 주요 API + +### `wrapTest(it)` + +테스트 프레임워크의 `it` 함수를 래핑하여 자동 캡처 기능을 추가합니다. + +```typescript +import { wrapTest } from 'itdoc/wrappers' + +const apiTest = wrapTest(it) // Jest 또는 Mocha의 it +``` + +### `request(app)` + +Supertest를 기반으로 한 HTTP 클라이언트입니다. `CaptureContext`가 활성화된 상태에서는 자동으로 request/response를 캡처합니다. + +```typescript +import { request } from 'itdoc/wrappers' + +const response = await request(app) + .post('/users') + .send({ name: 'John' }) +``` + +### 메타데이터 추가 + +`withMeta()` 메서드로 API 문서 메타데이터를 추가할 수 있습니다. + +```typescript +apiTest.withMeta({ + summary: 'Create User', + tags: ['Users', 'Registration'], + description: 'Register a new user account', +})('should create user', async () => { + const response = await request(app) + .post('/users') + .send({ name: 'John' }) + + expect(response.status).toBe(201) +}) +``` + +## 💡 사용 예시 + +### 1. 기본 사용 + +```typescript +import { wrapTest, request } from 'itdoc/wrappers' + +const apiTest = wrapTest(it) + +describe('User API', () => { + apiTest('should get all users', async () => { + const response = await request(app).get('/users') + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty('users') + }) +}) +``` + +### 2. 인증 헤더 사용 + +```typescript +apiTest('should get user profile', async () => { + const response = await request(app) + .get('/users/me') + .set('Authorization', 'Bearer token123') + + expect(response.status).toBe(200) +}) +``` + +### 3. 쿼리 파라미터 + +```typescript +apiTest('should filter users', async () => { + const response = await request(app) + .get('/users') + .query({ role: 'admin', active: true }) + + expect(response.status).toBe(200) +}) +``` + +### 4. 여러 API 호출 (단일 테스트) + +```typescript +apiTest('should complete user workflow', async () => { + // 1. Create user + const createRes = await request(app) + .post('/users') + .send({ name: 'John' }) + + const userId = createRes.body.id + + // 2. Get user + const getRes = await request(app) + .get(`/users/${userId}`) + + expect(getRes.status).toBe(200) + expect(getRes.body.name).toBe('John') + + // ✅ 두 API 호출 모두 자동 캡처됨! +}) +``` + +### 5. 메타데이터와 함께 사용 + +```typescript +apiTest.withMeta({ + summary: 'User Registration API', + tags: ['Auth', 'Users'], + description: 'Register a new user with email and password', +})('POST /auth/signup - Register user', async () => { + const response = await request(app) + .post('/auth/signup') + .send({ + email: 'john@example.com', + password: 'secure123' + }) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty('token') +}) +``` + +## 🏗️ 아키텍처 + +### 핵심 컴포넌트 + +1. **CaptureContext** (`core/CaptureContext.ts`) + - AsyncLocalStorage 기반 컨텍스트 관리 + - 스레드 안전한 request/response 데이터 격리 + +2. **interceptedRequest** (`core/interceptedRequest.ts`) + - Proxy 기반 Supertest 래핑 + - 투명한 HTTP request/response 캡처 + +3. **wrapTest** (`wrapTest.ts`) + - 고차함수 래퍼 + - 테스트 라이프사이클 관리 및 문서 생성 + +### 동작 흐름 + +``` +1. wrapTest(it) → 래핑된 테스트 함수 반환 +2. apiTest(...) 호출 → AsyncLocalStorage 컨텍스트 생성 +3. 사용자 테스트 실행 → request(app) 감지 +4. Proxy로 감싸서 반환 → 메서드 호출 캡처 +5. .then() 호출 → response 캡처 +6. 테스트 성공 → TestResultCollector로 전달 +7. 모든 테스트 완료 → OpenAPI 문서 생성 +``` + +## ⚖️ 기존 방식과 비교 + +### 기존 `itDoc` 방식 + +```typescript +import { itDoc, req } from 'itdoc' + +itDoc('should create user', async () => { + const response = await req(app) + .post('/users') + .description('Create user') + .tag('Users') + .send({ name: 'John' }) + .expect(201) +}) +``` + +**특징:** +- DSL 방식으로 명시적 메타데이터 추가 +- 체이닝 기반 API +- 기존 코드 패턴 변경 필요 + +### 새로운 `wrapTest` 방식 + +```typescript +import { wrapTest, request } from 'itdoc/wrappers' + +const apiTest = wrapTest(it) + +apiTest('should create user', async () => { + const response = await request(app) + .post('/users') + .send({ name: 'John' }) + + expect(response.status).toBe(201) +}) +``` + +**특징:** +- 고차함수 래핑으로 자동 캡처 +- 기존 테스트 패턴 유지 +- 최소한의 코드 변경 + +**두 방식 모두 사용 가능하며, 프로젝트 요구사항에 따라 선택할 수 있습니다.** + +## 🧪 테스트 + +### Unit Tests + +```bash +pnpm test:unit -- --grep "CaptureContext" +pnpm test:unit -- --grep "interceptedRequest" +pnpm test:unit -- --grep "wrapTest" +``` + +### Integration Tests + +```bash +pnpm test:unit -- --grep "wrapTest integration" +``` + +## 📦 Export + +```typescript +// Public API +export { wrapTest } from './wrapTest' +export { request } from './core/interceptedRequest' +export type { ApiDocMetadata, WrappedTestFunction, TestFunction } from './types' +``` + +## 🔄 확장 가능성 + +### 다른 HTTP 클라이언트 지원 + +```typescript +// axios, fetch 등 다른 클라이언트도 추가 가능 +import { createAxiosInterceptor } from 'itdoc/wrappers/axios' +``` + +### 커스텀 훅 + +```typescript +// 향후 확장 가능 +wrapTest(it, { + beforeCapture: (req) => { /* 민감 데이터 마스킹 */ }, + afterCapture: (result) => { /* 커스텀 검증 */ } +}) +``` + +## 📄 라이선스 + +Apache License 2.0 diff --git a/lib/wrappers/__tests__/CaptureContext.test.ts b/lib/wrappers/__tests__/CaptureContext.test.ts new file mode 100644 index 0000000..fcbb4fd --- /dev/null +++ b/lib/wrappers/__tests__/CaptureContext.test.ts @@ -0,0 +1,188 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from "mocha" +import { expect } from "chai" +import { CaptureContext } from "../core/CaptureContext" + +describe("CaptureContext", () => { + describe("isActive", () => { + it("should return false when not in context", () => { + expect(CaptureContext.isActive()).to.be.false + }) + + it("should return true when in context", () => { + CaptureContext.run("test", undefined, () => { + expect(CaptureContext.isActive()).to.be.true + }) + }) + + it("should return false after context ends", () => { + CaptureContext.run("test", undefined, () => { + // inside context + }) + expect(CaptureContext.isActive()).to.be.false + }) + }) + + describe("getStore", () => { + it("should return undefined when not in context", () => { + expect(CaptureContext.getStore()).to.be.undefined + }) + + it("should return store when in context", () => { + CaptureContext.run("test description", { summary: "Test" }, () => { + const store = CaptureContext.getStore() + expect(store).to.not.be.undefined + expect(store?.description).to.equal("test description") + expect(store?.metadata?.summary).to.equal("Test") + expect(store?.capturedRequests).to.be.an("array").that.is.empty + }) + }) + }) + + describe("addRequest", () => { + it("should add request to store", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ + method: "POST", + url: "/users", + }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests).to.have.lengthOf(1) + expect(requests[0].method).to.equal("POST") + expect(requests[0].url).to.equal("/users") + }) + }) + + it("should add multiple requests", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ method: "GET", url: "/users" }) + CaptureContext.addRequest({ method: "POST", url: "/users" }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests).to.have.lengthOf(2) + }) + }) + + it("should not add request when not in context", () => { + CaptureContext.addRequest({ method: "POST", url: "/users" }) + expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + + describe("updateLastRequest", () => { + it("should update last request with additional data", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ method: "POST", url: "/users" }) + CaptureContext.updateLastRequest({ body: { name: "John" } }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests[0].body).to.deep.equal({ name: "John" }) + }) + }) + + it("should merge data with existing request", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ + method: "POST", + url: "/users", + headers: { "Content-Type": "application/json" }, + }) + CaptureContext.updateLastRequest({ + body: { name: "John" }, + }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests[0]).to.deep.include({ + method: "POST", + url: "/users", + body: { name: "John" }, + }) + expect(requests[0].headers).to.deep.equal({ + "Content-Type": "application/json", + }) + }) + }) + + it("should do nothing when no requests exist", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.updateLastRequest({ body: { name: "John" } }) + expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + + it("should do nothing when not in context", () => { + CaptureContext.updateLastRequest({ body: { name: "John" } }) + expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + + describe("clear", () => { + it("should clear all captured requests", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ method: "GET", url: "/users" }) + CaptureContext.addRequest({ method: "POST", url: "/users" }) + + expect(CaptureContext.getCapturedRequests()).to.have.lengthOf(2) + + CaptureContext.clear() + + expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + }) + + describe("nested contexts", () => { + it("should isolate contexts properly", () => { + CaptureContext.run("outer", undefined, () => { + CaptureContext.addRequest({ method: "GET", url: "/outer" }) + + CaptureContext.run("inner", undefined, () => { + CaptureContext.addRequest({ method: "POST", url: "/inner" }) + + const innerRequests = CaptureContext.getCapturedRequests() + expect(innerRequests).to.have.lengthOf(1) + expect(innerRequests[0].url).to.equal("/inner") + }) + + const outerRequests = CaptureContext.getCapturedRequests() + expect(outerRequests).to.have.lengthOf(1) + expect(outerRequests[0].url).to.equal("/outer") + }) + }) + }) + + describe("async support", () => { + it("should work with async functions", async () => { + await CaptureContext.run("test", undefined, async () => { + CaptureContext.addRequest({ method: "GET", url: "/users" }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + CaptureContext.updateLastRequest({ body: { name: "John" } }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests[0]).to.deep.include({ + method: "GET", + url: "/users", + body: { name: "John" }, + }) + }) + }) + }) +}) diff --git a/lib/wrappers/__tests__/integration.test.ts b/lib/wrappers/__tests__/integration.test.ts new file mode 100644 index 0000000..0bb6da2 --- /dev/null +++ b/lib/wrappers/__tests__/integration.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from "mocha" +import { expect } from "chai" +import express from "express" +import { wrapTest, request } from "../index" + +describe("wrapTest integration", () => { + let app: express.Application + + beforeEach(() => { + app = express() + app.use(express.json()) + + // Setup test routes + app.post("/auth/signup", (req, res) => { + const { username, password } = req.body + if (!username || !password) { + return res.status(400).json({ error: "Missing fields" }) + } + res.status(201).json({ id: 1, username }) + }) + + app.post("/auth/login", (req, res) => { + res.status(200).json({ token: "jwt-token-123" }) + }) + + app.get("/users/profile", (req, res) => { + const auth = req.headers.authorization + if (!auth) { + return res.status(401).json({ error: "Unauthorized" }) + } + res.status(200).json({ id: 1, username: "john" }) + }) + }) + + describe("real-world usage scenarios", () => { + const apiTest = wrapTest(it) + + apiTest("should register new user successfully", async () => { + const response = await request(app).post("/auth/signup").send({ + username: "john", + password: "password123", + }) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("id") + expect(response.body.username).to.equal("john") + }) + + apiTest("should handle validation errors", async () => { + const response = await request(app).post("/auth/signup").send({ + username: "john", + // missing password + }) + + expect(response.status).to.equal(400) + expect(response.body.error).to.equal("Missing fields") + }) + + apiTest("should login and get profile", async () => { + // First login + const loginRes = await request(app).post("/auth/login").send({ + username: "john", + password: "password123", + }) + + expect(loginRes.status).to.equal(200) + const token = loginRes.body.token + + // Then get profile with token + const profileRes = await request(app) + .get("/users/profile") + .set("Authorization", `Bearer ${token}`) + + expect(profileRes.status).to.equal(200) + expect(profileRes.body.username).to.equal("john") + }) + + apiTest.withMeta({ + summary: "User Registration", + tags: ["Auth", "Users"], + description: "Register a new user account", + })("should create user with metadata", async () => { + const response = await request(app).post("/auth/signup").send({ + username: "jane", + password: "secure123", + }) + + expect(response.status).to.equal(201) + }) + }) + + describe("comparison with existing itDoc", () => { + it("should show the difference in usage", () => { + // OLD WAY (with itDoc): + // itDoc('should create user', async () => { + // const response = await req(app) + // .post('/users') + // .description('Create user') + // .tag('Users') + // .send({ name: 'John' }) + // .expect(201) + // }) + + // NEW WAY (with wrapTest): + const apiTest = wrapTest(it) + // apiTest('should create user', async () => { + // const response = await request(app) + // .post('/users') + // .send({ name: 'John' }) + // + // expect(response.status).toBe(201) + // }) + + expect(true).to.be.true + }) + }) +}) diff --git a/lib/wrappers/__tests__/interceptedRequest.test.ts b/lib/wrappers/__tests__/interceptedRequest.test.ts new file mode 100644 index 0000000..9bffcc9 --- /dev/null +++ b/lib/wrappers/__tests__/interceptedRequest.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, beforeEach } from "mocha" +import { expect } from "chai" +import express from "express" +import { request } from "../core/interceptedRequest" +import { CaptureContext } from "../core/CaptureContext" + +describe("interceptedRequest", () => { + let app: express.Application + + beforeEach(() => { + app = express() + app.use(express.json()) + + // Test routes + app.get("/users", (req, res) => { + res.status(200).json({ users: [] }) + }) + + app.post("/users", (req, res) => { + res.status(201).json({ id: 1, ...req.body }) + }) + + app.get("/users/:id", (req, res) => { + res.status(200).json({ id: req.params.id, name: "John" }) + }) + }) + + describe("without capture context", () => { + it("should work as normal supertest", async () => { + const response = await request(app).get("/users") + + expect(response.status).to.equal(200) + expect(response.body).to.deep.equal({ users: [] }) + }) + + it("should not capture any data", async () => { + await request(app).get("/users") + + expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + + describe("with capture context", () => { + it("should capture GET request", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app).get("/users") + + const captured = CaptureContext.getCapturedRequests() + expect(captured).to.have.lengthOf(1) + expect(captured[0].method).to.equal("GET") + expect(captured[0].url).to.equal("/users") + }) + }) + + it("should capture POST request with body", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app).post("/users").send({ name: "John", email: "john@test.com" }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured).to.have.lengthOf(1) + expect(captured[0].method).to.equal("POST") + expect(captured[0].url).to.equal("/users") + expect(captured[0].body).to.deep.equal({ + name: "John", + email: "john@test.com", + }) + }) + }) + + it("should capture request headers", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app) + .get("/users") + .set("Authorization", "Bearer token123") + .set("Accept", "application/json") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].headers).to.include({ + Authorization: "Bearer token123", + Accept: "application/json", + }) + }) + }) + + it("should capture query parameters", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app).get("/users").query({ page: 1, limit: 10 }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].queryParams).to.deep.equal({ + page: 1, + limit: 10, + }) + }) + }) + + it("should capture response status and body", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app).get("/users") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].response?.status).to.equal(200) + expect(captured[0].response?.body).to.deep.equal({ users: [] }) + }) + }) + + it("should capture response headers", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app).get("/users") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].response?.headers).to.be.an("object") + expect(captured[0].response?.headers).to.have.property("content-type") + }) + }) + + it("should capture multiple requests in order", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app).post("/users").send({ name: "John" }) + await request(app).get("/users/1") + + const captured = CaptureContext.getCapturedRequests() + expect(captured).to.have.lengthOf(2) + expect(captured[0].method).to.equal("POST") + expect(captured[1].method).to.equal("GET") + }) + }) + + it("should handle request chain methods", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app) + .post("/users") + .set("Authorization", "Bearer token") + .send({ name: "John" }) + .expect(201) + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0]).to.deep.include({ + method: "POST", + url: "/users", + body: { name: "John" }, + }) + expect(captured[0].headers).to.include({ + Authorization: "Bearer token", + }) + }) + }) + + it("should capture PUT requests", async () => { + app.put("/users/:id", (req, res) => { + res.status(200).json({ id: req.params.id, ...req.body }) + }) + + await CaptureContext.run("test", undefined, async () => { + await request(app).put("/users/1").send({ name: "Jane" }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].method).to.equal("PUT") + expect(captured[0].url).to.equal("/users/1") + }) + }) + + it("should capture DELETE requests", async () => { + app.delete("/users/:id", (req, res) => { + res.status(204).send() + }) + + await CaptureContext.run("test", undefined, async () => { + await request(app).delete("/users/1") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].method).to.equal("DELETE") + expect(captured[0].response?.status).to.equal(204) + }) + }) + + it("should work with expect() assertions", async () => { + await CaptureContext.run("test", undefined, async () => { + const response = await request(app).get("/users").expect(200) + + expect(response.body).to.deep.equal({ users: [] }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured).to.have.lengthOf(1) + }) + }) + + it("should capture even when request fails", async () => { + app.get("/error", (req, res) => { + res.status(500).json({ error: "Server error" }) + }) + + await CaptureContext.run("test", undefined, async () => { + await request(app).get("/error") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].response?.status).to.equal(500) + expect(captured[0].response?.body).to.deep.equal({ + error: "Server error", + }) + }) + }) + }) + + describe("header setting variations", () => { + it("should capture headers set as object", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app).get("/users").set({ + Authorization: "Bearer token", + "X-Custom": "value", + }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].headers).to.include({ + Authorization: "Bearer token", + "X-Custom": "value", + }) + }) + }) + + it("should merge multiple set() calls", async () => { + await CaptureContext.run("test", undefined, async () => { + await request(app) + .get("/users") + .set("Authorization", "Bearer token") + .set("Accept", "application/json") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].headers).to.include({ + Authorization: "Bearer token", + Accept: "application/json", + }) + }) + }) + }) +}) diff --git a/lib/wrappers/__tests__/wrapTest.integration.test.ts b/lib/wrappers/__tests__/wrapTest.integration.test.ts new file mode 100644 index 0000000..aa71945 --- /dev/null +++ b/lib/wrappers/__tests__/wrapTest.integration.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from "mocha" +import { expect } from "chai" +import express from "express" +import { wrapTest, request } from "../index" + +describe("wrapTest integration", () => { + let app: express.Application + + beforeEach(() => { + app = express() + app.use(express.json()) + + app.get("/users", (req, res) => { + res.status(200).json({ users: [] }) + }) + + app.post("/users", (req, res) => { + res.status(201).json({ id: 1, ...req.body }) + }) + + app.get("/users/:id", (req, res) => { + res.status(200).json({ id: req.params.id, name: "John" }) + }) + }) + + describe("basic usage", () => { + const apiTest = wrapTest(it) + + apiTest("should make GET request successfully", async () => { + const response = await request(app).get("/users") + + expect(response.status).to.equal(200) + expect(response.body).to.deep.equal({ users: [] }) + }) + + apiTest("should make POST request with body", async () => { + const response = await request(app).post("/users").send({ + name: "John", + email: "john@test.com", + }) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("id") + expect(response.body.name).to.equal("John") + }) + + apiTest("should send headers", async () => { + const response = await request(app) + .get("/users") + .set("Authorization", "Bearer token123") + + expect(response.status).to.equal(200) + }) + + apiTest("should send query parameters", async () => { + const response = await request(app).get("/users").query({ page: 1, limit: 10 }) + + expect(response.status).to.equal(200) + }) + + apiTest("should handle multiple requests in one test", async () => { + const createRes = await request(app).post("/users").send({ name: "John" }) + expect(createRes.status).to.equal(201) + + const getRes = await request(app).get("/users/1") + expect(getRes.status).to.equal(200) + }) + }) + + describe("with metadata", () => { + const apiTest = wrapTest(it) + + apiTest.withMeta({ + summary: "Create User", + tags: ["Users", "Registration"], + })("should create user with metadata", async () => { + const response = await request(app).post("/users").send({ + name: "Jane", + email: "jane@test.com", + }) + + expect(response.status).to.equal(201) + }) + + apiTest.withMeta({ + description: "Custom description for API", + deprecated: false, + })("should use custom description", async () => { + const response = await request(app).get("/users") + + expect(response.status).to.equal(200) + }) + }) + + describe("error handling", () => { + const apiTest = wrapTest(it) + + apiTest("should handle successful requests", async () => { + const response = await request(app).get("/users") + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("users") + }) + }) + + describe("compatibility", () => { + const apiTest = wrapTest(it) + + apiTest("should work with chai expect", async () => { + const response = await request(app).get("/users") + + expect(response.status).to.equal(200) + expect(response.body).to.be.an("object") + expect(response.body).to.have.property("users") + }) + + apiTest("should work with supertest expect()", async () => { + await request(app).get("/users").expect(200).expect("Content-Type", /json/) + }) + }) + + describe("without capture (regular it)", () => { + it("should work as normal test", async () => { + const response = await request(app).get("/users") + + expect(response.status).to.equal(200) + }) + }) +}) diff --git a/lib/wrappers/core/CaptureContext.ts b/lib/wrappers/core/CaptureContext.ts new file mode 100644 index 0000000..f565781 --- /dev/null +++ b/lib/wrappers/core/CaptureContext.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AsyncLocalStorage } from "async_hooks" +import { ApiDocMetadata, CapturedRequest } from "../types" + +/** + * Store structure for AsyncLocalStorage + */ +export interface CaptureStore { + description: string + metadata?: ApiDocMetadata + capturedRequests: CapturedRequest[] +} + +/** + * Context manager for capturing HTTP requests/responses using AsyncLocalStorage + * This ensures thread-safe isolation of captured data across concurrent tests + */ +export class CaptureContext { + private static storage = new AsyncLocalStorage() + + /** + * Run a function within a capture context + * @param description Test description + * @param metadata Optional API documentation metadata + * @param fn Function to execute + * @returns Result of the function + */ + public static run( + description: string, + metadata: ApiDocMetadata | undefined, + fn: () => T | Promise, + ): T | Promise { + const store: CaptureStore = { + description, + metadata, + capturedRequests: [], + } + return this.storage.run(store, fn) + } + + /** + * Get the current capture store + * @returns Current store or undefined if not in capture context + */ + public static getStore(): CaptureStore | undefined { + return this.storage.getStore() + } + + /** + * Check if capture context is currently active + * @returns true if active, false otherwise + */ + public static isActive(): boolean { + return this.storage.getStore() !== undefined + } + + /** + * Add a new request to the capture store + * @param request Partial request data + */ + public static addRequest(request: Partial): void { + const store = this.getStore() + if (store) { + store.capturedRequests.push(request as CapturedRequest) + } + } + + /** + * Update the last captured request with additional data + * @param data Partial request data to merge + */ + public static updateLastRequest(data: Partial): void { + const store = this.getStore() + if (store && store.capturedRequests.length > 0) { + const last = store.capturedRequests[store.capturedRequests.length - 1] + Object.assign(last, data) + } + } + + /** + * Get all captured requests from current context + * @returns Array of captured requests + */ + public static getCapturedRequests(): CapturedRequest[] { + return this.getStore()?.capturedRequests || [] + } + + /** + * Clear all captured requests (useful for testing) + */ + public static clear(): void { + const store = this.getStore() + if (store) { + store.capturedRequests = [] + } + } +} diff --git a/lib/wrappers/core/interceptedRequest.ts b/lib/wrappers/core/interceptedRequest.ts new file mode 100644 index 0000000..b4eb21a --- /dev/null +++ b/lib/wrappers/core/interceptedRequest.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import originalRequest from "supertest" +import { CaptureContext } from "./CaptureContext" + +/** + * Create an intercepted request function that captures HTTP requests and responses + * When CaptureContext is active, this function wraps supertest to automatically + * capture all request/response data for documentation generation + * + * @param app Express/Fastify/NestJS app instance + * @returns Supertest Test instance (possibly wrapped in Proxy) + */ +export function request(app: any) { + // If context is not active, return original supertest + if (!CaptureContext.isActive()) { + return originalRequest(app) + } + + // Create supertest instance and wrap it with Proxy + const testInstance = originalRequest(app) + return createInterceptedTest(testInstance) +} + +/** + * Create a Proxy wrapper for the initial supertest Test object + * This intercepts HTTP method calls (get, post, put, etc.) + */ +function createInterceptedTest(testObj: any) { + return new Proxy(testObj, { + get(target, prop: string) { + const original = target[prop] + + // Intercept HTTP method calls + const httpMethods = ["get", "post", "put", "patch", "delete", "head", "options"] + + if (httpMethods.includes(prop)) { + return (url: string) => { + // Start new request capture + CaptureContext.addRequest({ + method: prop.toUpperCase(), + url, + }) + + // Continue with request chain proxy + return createRequestChainProxy(target[prop](url)) + } + } + + return original + }, + }) +} + +/** + * Create a Proxy wrapper for the supertest request chain + * This intercepts method calls like .send(), .set(), .query(), etc. + * and the final .then() to capture the response + */ +function createRequestChainProxy(chainObj: any): any { + return new Proxy(chainObj, { + get(target, prop: string) { + const original = target[prop] + + // Capture request body + if (prop === "send") { + return (body: any) => { + CaptureContext.updateLastRequest({ body }) + return createRequestChainProxy(target.send(body)) + } + } + + // Capture request headers + if (prop === "set") { + return (field: string | Record, val?: string) => { + const headers = typeof field === "string" ? { [field]: val as string } : field + + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + lastReq.headers = { ...lastReq.headers, ...headers } + } + + return createRequestChainProxy(target.set(field, val)) + } + } + + // Capture query parameters + if (prop === "query") { + return (params: any) => { + CaptureContext.updateLastRequest({ queryParams: params }) + return createRequestChainProxy(target.query(params)) + } + } + + // Capture response when promise resolves + if (prop === "then") { + return (onFulfilled?: any, onRejected?: any) => { + return target.then((res: any) => { + // Capture response data + CaptureContext.updateLastRequest({ + response: { + status: res.status, + body: res.body, + headers: res.headers, + }, + }) + + return onFulfilled?.(res) + }, onRejected) + } + } + + // For other methods, maintain the chain + if (typeof original === "function") { + return (...args: any[]) => { + const result = original.apply(target, args) + // If result is the Test object itself, keep proxying + return result === target || result?.constructor?.name === "Test" + ? createRequestChainProxy(result) + : result + } + } + + return original + }, + }) +} diff --git a/lib/wrappers/index.ts b/lib/wrappers/index.ts new file mode 100644 index 0000000..9f11e6c --- /dev/null +++ b/lib/wrappers/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Wrapper-based API testing module + * + * This module provides a high-order function approach to automatically capture + * HTTP requests and responses from your tests and generate API documentation. + * + * @example Basic usage + * ```typescript + * import { wrapTest, request } from 'itdoc/wrappers' + * + * const apiTest = wrapTest(it) + * + * describe('User API', () => { + * apiTest('should create user', async () => { + * const response = await request(app) + * .post('/users') + * .send({ name: 'John' }) + * + * expect(response.status).toBe(201) + * }) + * }) + * ``` + * + * @example With metadata + * ```typescript + * apiTest.withMeta({ + * summary: 'Create User', + * tags: ['Users', 'Registration'] + * })('should create user', async () => { + * const response = await request(app) + * .post('/users') + * .send({ name: 'John' }) + * + * expect(response.status).toBe(201) + * }) + * ``` + */ + +export { wrapTest } from "./wrapTest" +export { request } from "./core/interceptedRequest" +export type { ApiDocMetadata, WrappedTestFunction, TestFunction } from "./types" diff --git a/lib/wrappers/types.ts b/lib/wrappers/types.ts new file mode 100644 index 0000000..056f232 --- /dev/null +++ b/lib/wrappers/types.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * API documentation metadata that can be attached to tests + */ +export interface ApiDocMetadata { + summary?: string + description?: string + tags?: string[] + deprecated?: boolean + operationId?: string +} + +/** + * Captured HTTP request data + */ +export interface CapturedRequest { + method?: string + url?: string + body?: unknown + headers?: Record + queryParams?: Record + pathParams?: Record + response?: CapturedResponse +} + +/** + * Captured HTTP response data + */ +export interface CapturedResponse { + status: number + body?: unknown + headers?: Record +} + +/** + * Test function type + */ +export type TestFunction = () => void | Promise + +/** + * Test framework's 'it' function type + * Compatible with both Jest and Mocha + * Using any for maximum compatibility across test frameworks + */ +export type ItFunction = (description: string, fn: any) => any + +/** + * Wrapped test function with metadata support + */ +export interface WrappedTestFunction { + (description: string, testFn: TestFunction): void + withMeta: (metadata: ApiDocMetadata) => (description: string, testFn: TestFunction) => void +} diff --git a/lib/wrappers/wrapTest.ts b/lib/wrappers/wrapTest.ts new file mode 100644 index 0000000..6dabd57 --- /dev/null +++ b/lib/wrappers/wrapTest.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestEventManager } from "../dsl/generator/TestEventManager" +import { TestResultCollector } from "../dsl/generator/TestResultCollector" +import { CaptureContext } from "./core/CaptureContext" +import { TestResult } from "../dsl/generator/types/TestResult" +import { HttpMethod } from "../dsl/enums/HttpMethod" +import { ApiDocMetadata, ItFunction, TestFunction, WrappedTestFunction } from "./types" + +/** + * Wrap a test framework's 'it' function to automatically capture HTTP requests/responses + * and generate API documentation from successful tests + * + * @param originalIt - The test framework's 'it' function (from Jest or Mocha) + * @returns A wrapped test function with automatic capturing capabilities + * + * @example + * ```typescript + * import { wrapTest, request } from 'itdoc/wrappers' + * + * const apiTest = wrapTest(it) + * + * describe('User API', () => { + * apiTest('should create user', async () => { + * const res = await request(app) + * .post('/users') + * .send({ name: 'John' }) + * + * expect(res.status).toBe(201) + * }) + * }) + * ``` + */ +export function wrapTest(originalIt: ItFunction): WrappedTestFunction { + const testEventManager = TestEventManager.getInstance() + const collector = TestResultCollector.getInstance() + + /** + * Internal wrapper that executes the test with capturing + */ + const executeTest = ( + description: string, + testFn: TestFunction, + metadata?: ApiDocMetadata, + ): void => { + // Register test with event manager + testEventManager.registerTest() + + // Execute test with original 'it' function + originalIt(description, async () => { + let captured: any[] = [] + + try { + // Run test within capture context + await CaptureContext.run(description, metadata, async () => { + await testFn() + // IMPORTANT: Get captured requests INSIDE the context + captured = CaptureContext.getCapturedRequests() + }) + + for (const req of captured) { + // Only collect requests that have responses (successful requests) + if (req.response) { + const testResult: TestResult = { + method: req.method as HttpMethod, + url: req.url!, + options: { + summary: metadata?.summary, + description: metadata?.description || description, + tag: metadata?.tags?.[0], // Use first tag for compatibility with ApiDocOptions + }, + request: { + body: req.body, + headers: req.headers, + queryParams: req.queryParams, + pathParams: req.pathParams, + }, + response: { + status: req.response.status, + body: req.response.body, + headers: req.response.headers, + }, + testSuiteDescription: description, + } + + collector.collectResult(testResult) + } + } + + testEventManager.completeTestSuccess() + } catch (error) { + testEventManager.completeTestFailure() + throw error + } + }) + } + + /** + * Main wrapped test function + */ + const wrapped = (description: string, testFn: TestFunction): void => { + executeTest(description, testFn) + } + + /** + * Method to add metadata to the test + */ + wrapped.withMeta = (metadata: ApiDocMetadata) => { + return (description: string, testFn: TestFunction): void => { + executeTest(description, testFn, metadata) + } + } + + return wrapped as WrappedTestFunction +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78d016a..44f686b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@types/mocha': specifier: ^10.0.10 version: 10.0.10 + '@types/multer': + specifier: ^2.0.0 + version: 2.0.0 '@types/node': specifier: ~20 version: 20.17.24 @@ -3180,6 +3183,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.0.0': + resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/node-fetch@2.6.12': resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} @@ -8491,6 +8497,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} @@ -10690,6 +10697,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -16031,6 +16039,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@2.0.0': + dependencies: + '@types/express': 5.0.2 + '@types/node-fetch@2.6.12': dependencies: '@types/node': 20.17.24 @@ -16102,7 +16114,7 @@ snapshots: '@types/serve-index@1.9.4': dependencies: - '@types/express': 4.17.21 + '@types/express': 5.0.2 '@types/serve-static@1.15.7': dependencies: From 4d5e8ba2f79b93be3b2f6715666b9181c44f7a1b Mon Sep 17 00:00:00 2001 From: jaesong-blip Date: Sat, 4 Oct 2025 18:18:23 +0900 Subject: [PATCH 08/14] chore: pnpm install --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44f686b..348caa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,9 +69,6 @@ importers: '@types/mocha': specifier: ^10.0.10 version: 10.0.10 - '@types/multer': - specifier: ^2.0.0 - version: 2.0.0 '@types/node': specifier: ~20 version: 20.17.24 @@ -194,6 +191,9 @@ importers: '@types/mocha': specifier: ^10.0.10 version: 10.0.10 + '@types/multer': + specifier: ^2.0.0 + version: 2.0.0 '@types/node': specifier: ^20.10.5 version: 20.17.24 From 172be586ca0bd2d114c7720010a931742eb6f7a3 Mon Sep 17 00:00:00 2001 From: jaesong-blip Date: Sat, 4 Oct 2025 18:27:03 +0900 Subject: [PATCH 09/14] fix: resolve ESLint errors in wrapper tests Fix @typescript-eslint/no-unused-expressions errors by adding void keyword to Chai expect assertions and remove unused variable. Changes: - Add 'void' to all standalone expect() assertions in test files - Comment out unused 'apiTest' variable in integration.test.ts - All tests still pass with proper linting Resolves ESLint errors: - CaptureContext.test.ts: 10 expression errors - integration.test.ts: 2 errors (unused var + expression) - interceptedRequest.test.ts: 1 expression error --- lib/wrappers/__tests__/CaptureContext.test.ts | 26 +++++++++---------- lib/wrappers/__tests__/integration.test.ts | 5 ++-- .../__tests__/interceptedRequest.test.ts | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/wrappers/__tests__/CaptureContext.test.ts b/lib/wrappers/__tests__/CaptureContext.test.ts index fcbb4fd..30d56ce 100644 --- a/lib/wrappers/__tests__/CaptureContext.test.ts +++ b/lib/wrappers/__tests__/CaptureContext.test.ts @@ -21,12 +21,12 @@ import { CaptureContext } from "../core/CaptureContext" describe("CaptureContext", () => { describe("isActive", () => { it("should return false when not in context", () => { - expect(CaptureContext.isActive()).to.be.false + void expect(CaptureContext.isActive()).to.be.false }) it("should return true when in context", () => { CaptureContext.run("test", undefined, () => { - expect(CaptureContext.isActive()).to.be.true + void expect(CaptureContext.isActive()).to.be.true }) }) @@ -34,22 +34,22 @@ describe("CaptureContext", () => { CaptureContext.run("test", undefined, () => { // inside context }) - expect(CaptureContext.isActive()).to.be.false + void expect(CaptureContext.isActive()).to.be.false }) }) describe("getStore", () => { it("should return undefined when not in context", () => { - expect(CaptureContext.getStore()).to.be.undefined + void expect(CaptureContext.getStore()).to.be.undefined }) it("should return store when in context", () => { CaptureContext.run("test description", { summary: "Test" }, () => { const store = CaptureContext.getStore() - expect(store).to.not.be.undefined - expect(store?.description).to.equal("test description") - expect(store?.metadata?.summary).to.equal("Test") - expect(store?.capturedRequests).to.be.an("array").that.is.empty + void expect(store).to.not.be.undefined + void expect(store?.description).to.equal("test description") + void expect(store?.metadata?.summary).to.equal("Test") + void expect(store?.capturedRequests).to.be.an("array").that.is.empty }) }) }) @@ -81,7 +81,7 @@ describe("CaptureContext", () => { it("should not add request when not in context", () => { CaptureContext.addRequest({ method: "POST", url: "/users" }) - expect(CaptureContext.getCapturedRequests()).to.be.empty + void expect(CaptureContext.getCapturedRequests()).to.be.empty }) }) @@ -122,13 +122,13 @@ describe("CaptureContext", () => { it("should do nothing when no requests exist", () => { CaptureContext.run("test", undefined, () => { CaptureContext.updateLastRequest({ body: { name: "John" } }) - expect(CaptureContext.getCapturedRequests()).to.be.empty + void expect(CaptureContext.getCapturedRequests()).to.be.empty }) }) it("should do nothing when not in context", () => { CaptureContext.updateLastRequest({ body: { name: "John" } }) - expect(CaptureContext.getCapturedRequests()).to.be.empty + void expect(CaptureContext.getCapturedRequests()).to.be.empty }) }) @@ -138,11 +138,11 @@ describe("CaptureContext", () => { CaptureContext.addRequest({ method: "GET", url: "/users" }) CaptureContext.addRequest({ method: "POST", url: "/users" }) - expect(CaptureContext.getCapturedRequests()).to.have.lengthOf(2) + void expect(CaptureContext.getCapturedRequests()).to.have.lengthOf(2) CaptureContext.clear() - expect(CaptureContext.getCapturedRequests()).to.be.empty + void expect(CaptureContext.getCapturedRequests()).to.be.empty }) }) }) diff --git a/lib/wrappers/__tests__/integration.test.ts b/lib/wrappers/__tests__/integration.test.ts index 0bb6da2..62b2c52 100644 --- a/lib/wrappers/__tests__/integration.test.ts +++ b/lib/wrappers/__tests__/integration.test.ts @@ -118,7 +118,8 @@ describe("wrapTest integration", () => { // }) // NEW WAY (with wrapTest): - const apiTest = wrapTest(it) + // Example usage (commented out for now): + // const apiTest = wrapTest(it) // apiTest('should create user', async () => { // const response = await request(app) // .post('/users') @@ -127,7 +128,7 @@ describe("wrapTest integration", () => { // expect(response.status).toBe(201) // }) - expect(true).to.be.true + void expect(true).to.be.true }) }) }) diff --git a/lib/wrappers/__tests__/interceptedRequest.test.ts b/lib/wrappers/__tests__/interceptedRequest.test.ts index 9bffcc9..4387517 100644 --- a/lib/wrappers/__tests__/interceptedRequest.test.ts +++ b/lib/wrappers/__tests__/interceptedRequest.test.ts @@ -52,7 +52,7 @@ describe("interceptedRequest", () => { it("should not capture any data", async () => { await request(app).get("/users") - expect(CaptureContext.getCapturedRequests()).to.be.empty + void expect(CaptureContext.getCapturedRequests()).to.be.empty }) }) From 4ab2a0040d55fee05bed01b5793d46a32da82458 Mon Sep 17 00:00:00 2001 From: jaesong-blip Date: Wed, 8 Oct 2025 18:51:38 +0900 Subject: [PATCH 10/14] feat: add comprehensive documentation for wrapper-based API testing --- .serena/.gitignore | 1 + .serena/project.yml | 67 +++ examples/express-ts/jest.config.ts | 5 +- examples/express-ts/package.json | 2 +- .../product.wrapper.spec.ts} | 81 +-- .../upload.wrapper.spec.ts} | 54 +- .../user.wrapper.spec.ts} | 32 +- .../src/__tests__/{ => mocha}/product.test.ts | 2 +- .../__tests__/mocha/product.wrapper.test.ts | 214 ++++++++ .../__tests__/mocha/upload.wrapper.test.ts | 121 +++++ .../src/__tests__/{ => mocha}/user.test.ts | 2 +- .../src/__tests__/mocha/user.wrapper.test.ts | 139 ++++++ .../express-ts/src/services/productService.ts | 14 +- lib/dsl/index.ts | 3 +- lib/wrappers/EXAMPLE.md | 461 +++++++++--------- lib/wrappers/README.md | 246 +++++----- lib/wrappers/__tests__/integration.test.ts | 26 +- .../__tests__/interceptedRequest.test.ts | 51 +- .../__tests__/wrapTest.integration.test.ts | 37 +- lib/wrappers/adapters/axios/AxiosAdapter.ts | 239 +++++++++ lib/wrappers/adapters/fetch/FetchAdapter.ts | 238 +++++++++ lib/wrappers/adapters/index.ts | 87 ++++ .../adapters/supertest/SupertestAdapter.ts | 199 ++++++++ lib/wrappers/adapters/types.ts | 74 +++ lib/wrappers/core/interceptedRequest.ts | 144 ------ lib/wrappers/index.ts | 17 +- lib/wrappers/types.ts | 20 +- package.json | 17 +- pnpm-lock.yaml | 53 +- 29 files changed, 1976 insertions(+), 670 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml rename examples/express-ts/src/__tests__/{product.wrapper.test.ts => jest/product.wrapper.spec.ts} (80%) rename examples/express-ts/src/__tests__/{upload.wrapper.test.ts => jest/upload.wrapper.spec.ts} (71%) rename examples/express-ts/src/__tests__/{user.wrapper.test.ts => jest/user.wrapper.spec.ts} (80%) rename examples/express-ts/src/__tests__/{ => mocha}/product.test.ts (99%) create mode 100644 examples/express-ts/src/__tests__/mocha/product.wrapper.test.ts create mode 100644 examples/express-ts/src/__tests__/mocha/upload.wrapper.test.ts rename examples/express-ts/src/__tests__/{ => mocha}/user.test.ts (99%) create mode 100644 examples/express-ts/src/__tests__/mocha/user.wrapper.test.ts create mode 100644 lib/wrappers/adapters/axios/AxiosAdapter.ts create mode 100644 lib/wrappers/adapters/fetch/FetchAdapter.ts create mode 100644 lib/wrappers/adapters/index.ts create mode 100644 lib/wrappers/adapters/supertest/SupertestAdapter.ts create mode 100644 lib/wrappers/adapters/types.ts delete mode 100644 lib/wrappers/core/interceptedRequest.ts diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..39eb229 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,67 @@ +# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) +# * For C, use cpp +# * For JavaScript, use typescript +# Special requirements: +# * csharp: Requires the presence of a .sln file in the project folder. +language: typescript + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "itdoc" diff --git a/examples/express-ts/jest.config.ts b/examples/express-ts/jest.config.ts index f88ac49..0128f89 100644 --- a/examples/express-ts/jest.config.ts +++ b/examples/express-ts/jest.config.ts @@ -3,12 +3,15 @@ import type { Config } from "jest" const jestConfig: Config = { testEnvironment: "node", roots: ["/src"], - testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"], + testMatch: ["**/__tests__/jest/**/*.spec.ts"], transform: { "^.+\\.ts$": [ "ts-jest", { tsconfig: "tsconfig.json", + diagnostics: { + ignoreCodes: [18046], + }, }, ], }, diff --git a/examples/express-ts/package.json b/examples/express-ts/package.json index 6186b3e..f5bf20a 100644 --- a/examples/express-ts/package.json +++ b/examples/express-ts/package.json @@ -8,7 +8,7 @@ "start": "node dist/index.js", "test": "pnpm run test:jest && pnpm run test:mocha", "test:jest": "jest", - "test:mocha": "mocha --require tsx \"src/**/__tests__/**/*.test.ts\"" + "test:mocha": "mocha --require tsx \"src/__tests__/mocha/**/*.test.ts\"" }, "dependencies": { "cors": "^2.8.5", diff --git a/examples/express-ts/src/__tests__/product.wrapper.test.ts b/examples/express-ts/src/__tests__/jest/product.wrapper.spec.ts similarity index 80% rename from examples/express-ts/src/__tests__/product.wrapper.test.ts rename to examples/express-ts/src/__tests__/jest/product.wrapper.spec.ts index 9e59525..dd83505 100644 --- a/examples/express-ts/src/__tests__/product.wrapper.test.ts +++ b/examples/express-ts/src/__tests__/jest/product.wrapper.spec.ts @@ -5,20 +5,25 @@ * that automatically captures HTTP requests/responses */ -import { app } from "../index" -import { wrapTest, request } from "itdoc" +import { app } from "../../index" +import { wrapTest, createClient } from "itdoc" +import { ProductService } from "../../services/productService" -// Create wrapped test function const apiTest = wrapTest(it) +const request = createClient.supertest(app) + describe("Product API - Wrapper Approach", () => { + beforeEach(() => { + ProductService.resetProducts() + }) describe("GET /api/products/:id", () => { apiTest.withMeta({ summary: "Get product by ID", tags: ["Products"], description: "Retrieves a specific product by its ID", })("should return a specific product", async () => { - const response = await request(app).get("/api/products/1") + const response = await request.get("/api/products/1") expect(response.status).toBe(200) expect(response.body).toHaveProperty("id", 1) @@ -28,11 +33,11 @@ describe("Product API - Wrapper Approach", () => { }) apiTest("should return product with different ID", async () => { - const response = await request(app).get("/api/products/2") + const response = await request.get("/api/products/2") expect(response.status).toBe(200) expect(response.body).toHaveProperty("id", 2) - expect(response.body).toHaveProperty("name", "Phone") + expect(response.body).toHaveProperty("name", "Smartphone") }) }) @@ -42,7 +47,7 @@ describe("Product API - Wrapper Approach", () => { tags: ["Products", "Create"], description: "Creates a new product with the provided information", })("should create a new product", async () => { - const response = await request(app).post("/api/products").send({ + const response = await request.post("/api/products").send({ name: "Test Product", price: 99.99, category: "Test Category", @@ -59,7 +64,7 @@ describe("Product API - Wrapper Approach", () => { summary: "Create product with different data", tags: ["Products", "Create"], })("should create another product", async () => { - const response = await request(app).post("/api/products").send({ + const response = await request.post("/api/products").send({ name: "Another Product", price: 199.99, category: "Another Category", @@ -76,7 +81,7 @@ describe("Product API - Wrapper Approach", () => { tags: ["Products", "Update"], description: "Updates an existing product with the provided information", })("should update a product", async () => { - const response = await request(app).put("/api/products/1").send({ + const response = await request.put("/api/products/1").send({ name: "Updated Product", price: 199.99, category: "Updated Category", @@ -90,7 +95,7 @@ describe("Product API - Wrapper Approach", () => { }) apiTest("should update product with partial data", async () => { - const response = await request(app).put("/api/products/2").send({ + const response = await request.put("/api/products/2").send({ name: "Partially Updated", price: 299.99, category: "Electronics", @@ -101,32 +106,13 @@ describe("Product API - Wrapper Approach", () => { }) }) - describe("DELETE /api/products/:id", () => { - apiTest.withMeta({ - summary: "Delete product", - tags: ["Products", "Delete"], - description: "Deletes a product by its ID", - })("should delete a product", async () => { - const response = await request(app).delete("/api/products/1") - - expect(response.status).toBe(204) - }) - - apiTest("should delete another product", async () => { - const response = await request(app).delete("/api/products/2") - - expect(response.status).toBe(204) - }) - }) - describe("Complete product CRUD workflow", () => { apiTest.withMeta({ summary: "Product CRUD workflow", tags: ["Products", "Workflow", "CRUD"], description: "Complete create, read, update, delete workflow for products", })("should perform complete CRUD operations", async () => { - // Step 1: Create a product - const createResponse = await request(app).post("/api/products").send({ + const createResponse = await request.post("/api/products").send({ name: "Workflow Product", price: 149.99, category: "Test", @@ -135,14 +121,12 @@ describe("Product API - Wrapper Approach", () => { expect(createResponse.status).toBe(201) const productId = createResponse.body.id - // Step 2: Read the product - const getResponse = await request(app).get(`/api/products/${productId}`) + const getResponse = await request.get(`/api/products/${productId}`) expect(getResponse.status).toBe(200) expect(getResponse.body.name).toBe("Workflow Product") - // Step 3: Update the product - const updateResponse = await request(app).put(`/api/products/${productId}`).send({ + const updateResponse = await request.put(`/api/products/${productId}`).send({ name: "Updated Workflow Product", price: 179.99, category: "Updated Test", @@ -151,8 +135,7 @@ describe("Product API - Wrapper Approach", () => { expect(updateResponse.status).toBe(200) expect(updateResponse.body.name).toBe("Updated Workflow Product") - // Step 4: Delete the product - const deleteResponse = await request(app).delete(`/api/products/${productId}`) + const deleteResponse = await request.delete(`/api/products/${productId}`) expect(deleteResponse.status).toBe(204) }) @@ -163,7 +146,7 @@ describe("Product API - Wrapper Approach", () => { summary: "Filter products by category", tags: ["Products", "Filter"], })("should filter products with query params", async () => { - const response = await request(app) + const response = await request .get("/api/products/1") .query({ category: "Electronics", minPrice: 500 }) @@ -171,7 +154,7 @@ describe("Product API - Wrapper Approach", () => { }) apiTest("should search products with multiple params", async () => { - const response = await request(app).get("/api/products/1").query({ + const response = await request.get("/api/products/1").query({ search: "laptop", sortBy: "price", order: "asc", @@ -186,7 +169,7 @@ describe("Product API - Wrapper Approach", () => { summary: "Create product with auth", tags: ["Products", "Authentication"], })("should create product with authorization header", async () => { - const response = await request(app) + const response = await request .post("/api/products") .set("Authorization", "Bearer fake-token-123") .send({ @@ -199,7 +182,7 @@ describe("Product API - Wrapper Approach", () => { }) apiTest("should include custom headers", async () => { - const response = await request(app) + const response = await request .get("/api/products/1") .set("Authorization", "Bearer token") .set("X-Client-ID", "test-client") @@ -208,4 +191,22 @@ describe("Product API - Wrapper Approach", () => { expect(response.status).toBe(200) }) }) + + describe("DELETE /api/products/:id", () => { + apiTest.withMeta({ + summary: "Delete product", + tags: ["Products", "Delete"], + description: "Deletes a product by its ID", + })("should delete a product", async () => { + const response = await request.delete("/api/products/1") + + expect(response.status).toBe(204) + }) + + apiTest("should delete another product", async () => { + const response = await request.delete("/api/products/2") + + expect(response.status).toBe(204) + }) + }) }) diff --git a/examples/express-ts/src/__tests__/upload.wrapper.test.ts b/examples/express-ts/src/__tests__/jest/upload.wrapper.spec.ts similarity index 71% rename from examples/express-ts/src/__tests__/upload.wrapper.test.ts rename to examples/express-ts/src/__tests__/jest/upload.wrapper.spec.ts index 0a7c444..c9e63b5 100644 --- a/examples/express-ts/src/__tests__/upload.wrapper.test.ts +++ b/examples/express-ts/src/__tests__/jest/upload.wrapper.spec.ts @@ -1,35 +1,30 @@ -import { app } from "../index" -import { wrapTest, request } from "itdoc" +import { app } from "../../index" +import { wrapTest, createClient } from "itdoc" import path from "path" import fs from "fs" const apiTest = wrapTest(it) +const request = createClient.supertest(app) + describe("Upload API - Wrapper Approach", () => { const testFilePath = path.join(__dirname, "test-file.txt") const testImagePath = path.join(__dirname, "test-image.png") beforeAll(() => { - // Create test files fs.writeFileSync(testFilePath, "This is a test file content") - // Create a simple 1x1 PNG image const pngBuffer = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature - 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions - 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, - 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, - 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, - 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, - 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, - 0x44, 0xae, 0x42, 0x60, 0x82 // IEND chunk + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, + 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, + 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, + 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]) fs.writeFileSync(testImagePath, pngBuffer) }) afterAll(() => { - // Cleanup test files if (fs.existsSync(testFilePath)) { fs.unlinkSync(testFilePath) } @@ -41,11 +36,9 @@ describe("Upload API - Wrapper Approach", () => { apiTest.withMeta({ summary: "Upload a single file", tags: ["Upload"], - description: "Uploads a single file using multipart/form-data" + description: "Uploads a single file using multipart/form-data", })("should upload a single file successfully", async () => { - const response = await request(app) - .post("/api/upload/single") - .attach("file", testFilePath) + const response = await request.post("/api/upload/single").attach("file", testFilePath) expect(response.status).toBe(201) expect(response.body).toHaveProperty("message", "File uploaded successfully") @@ -56,9 +49,9 @@ describe("Upload API - Wrapper Approach", () => { apiTest.withMeta({ summary: "Upload multiple files", tags: ["Upload"], - description: "Uploads multiple files in a single request" + description: "Uploads multiple files in a single request", })("should upload multiple files successfully", async () => { - const response = await request(app) + const response = await request .post("/api/upload/multiple") .attach("files", testFilePath) .attach("files", testImagePath) @@ -73,9 +66,9 @@ describe("Upload API - Wrapper Approach", () => { apiTest.withMeta({ summary: "Upload file with additional fields", tags: ["Upload", "Documents"], - description: "Uploads a file along with additional form fields (title, description)" + description: "Uploads a file along with additional form fields (title, description)", })("should upload file with additional form fields", async () => { - const response = await request(app) + const response = await request .post("/api/upload/with-fields") .field("title", "Important Document") .field("description", "This is a very important document") @@ -84,18 +77,19 @@ describe("Upload API - Wrapper Approach", () => { expect(response.status).toBe(201) expect(response.body).toHaveProperty("message", "Document uploaded successfully") expect(response.body.document).toHaveProperty("title", "Important Document") - expect(response.body.document).toHaveProperty("description", "This is a very important document") + expect(response.body.document).toHaveProperty( + "description", + "This is a very important document", + ) expect(response.body.document.file).toHaveProperty("originalname", "test-file.txt") }) apiTest.withMeta({ summary: "Handle missing file upload", tags: ["Upload", "Error Handling"], - description: "Returns 400 error when no file is provided" + description: "Returns 400 error when no file is provided", })("should return 400 when no file is uploaded", async () => { - const response = await request(app) - .post("/api/upload/single") - .send({}) + const response = await request.post("/api/upload/single").send({}) expect(response.status).toBe(400) expect(response.body).toHaveProperty("error", "No file uploaded") @@ -104,11 +98,9 @@ describe("Upload API - Wrapper Approach", () => { apiTest.withMeta({ summary: "Upload image file", tags: ["Upload", "Images"], - description: "Uploads an image file (PNG)" + description: "Uploads an image file (PNG)", })("should upload an image file successfully", async () => { - const response = await request(app) - .post("/api/upload/single") - .attach("file", testImagePath) + const response = await request.post("/api/upload/single").attach("file", testImagePath) expect(response.status).toBe(201) expect(response.body).toHaveProperty("message", "File uploaded successfully") diff --git a/examples/express-ts/src/__tests__/user.wrapper.test.ts b/examples/express-ts/src/__tests__/jest/user.wrapper.spec.ts similarity index 80% rename from examples/express-ts/src/__tests__/user.wrapper.test.ts rename to examples/express-ts/src/__tests__/jest/user.wrapper.spec.ts index ae36b3d..45182b6 100644 --- a/examples/express-ts/src/__tests__/user.wrapper.test.ts +++ b/examples/express-ts/src/__tests__/jest/user.wrapper.spec.ts @@ -5,12 +5,13 @@ * that automatically captures HTTP requests/responses */ -import { app } from "../index" -import { wrapTest, request } from "itdoc" +import { app } from "../../index" +import { wrapTest, createClient } from "itdoc" -// Create wrapped test function const apiTest = wrapTest(it) +const request = createClient.supertest(app) + describe("User API - Wrapper Approach", () => { describe("POST /api/user/register", () => { apiTest.withMeta({ @@ -18,7 +19,7 @@ describe("User API - Wrapper Approach", () => { tags: ["Users", "Authentication"], description: "Registers a new user with username and password", })("should register a new user successfully", async () => { - const response = await request(app).post("/api/user/register").send({ + const response = await request.post("/api/user/register").send({ username: "testuser", password: "testpassword", }) @@ -32,7 +33,7 @@ describe("User API - Wrapper Approach", () => { summary: "Register user - missing username", tags: ["Users", "Authentication", "Validation"], })("should return error when username is missing", async () => { - const response = await request(app).post("/api/user/register").send({ + const response = await request.post("/api/user/register").send({ password: "testpassword", }) @@ -44,7 +45,7 @@ describe("User API - Wrapper Approach", () => { summary: "Register user - missing password", tags: ["Users", "Authentication", "Validation"], })("should return error when password is missing", async () => { - const response = await request(app).post("/api/user/register").send({ + const response = await request.post("/api/user/register").send({ username: "testuser", }) @@ -59,7 +60,7 @@ describe("User API - Wrapper Approach", () => { tags: ["Users", "Authentication"], description: "Authenticates a user with username and password", })("should login successfully with valid credentials", async () => { - const response = await request(app).post("/api/user/login").send({ + const response = await request.post("/api/user/login").send({ username: "admin", password: "admin", }) @@ -73,7 +74,7 @@ describe("User API - Wrapper Approach", () => { summary: "User login - invalid credentials", tags: ["Users", "Authentication", "Error"], })("should return error with invalid credentials", async () => { - const response = await request(app).post("/api/user/login").send({ + const response = await request.post("/api/user/login").send({ username: "wronguser", password: "wrongpassword", }) @@ -89,7 +90,7 @@ describe("User API - Wrapper Approach", () => { tags: ["Users"], description: "Retrieves a specific user by their ID", })("should return user information", async () => { - const response = await request(app).get("/api/user/123") + const response = await request.get("/api/user/123") expect(response.status).toBe(200) expect(response.body).toHaveProperty("id", "123") @@ -99,7 +100,7 @@ describe("User API - Wrapper Approach", () => { }) apiTest("should handle different user IDs", async () => { - const response = await request(app).get("/api/user/456") + const response = await request.get("/api/user/456") expect(response.status).toBe(200) expect(response.body).toHaveProperty("id", "456") @@ -112,8 +113,7 @@ describe("User API - Wrapper Approach", () => { tags: ["Users", "Workflow"], description: "Complete user registration and authentication workflow", })("should register and login successfully", async () => { - // Step 1: Register new user - const registerResponse = await request(app).post("/api/user/register").send({ + const registerResponse = await request.post("/api/user/register").send({ username: "newuser", password: "newpassword", }) @@ -121,17 +121,15 @@ describe("User API - Wrapper Approach", () => { expect(registerResponse.status).toBe(201) expect(registerResponse.body.user.username).toBe("newuser") - // Step 2: Login with new credentials - const loginResponse = await request(app).post("/api/user/login").send({ - username: "admin", // Using admin for demo + const loginResponse = await request.post("/api/user/login").send({ + username: "admin", password: "admin", }) expect(loginResponse.status).toBe(200) expect(loginResponse.body).toHaveProperty("token") - // Step 3: Get user info - const userResponse = await request(app).get("/api/user/123") + const userResponse = await request.get("/api/user/123") expect(userResponse.status).toBe(200) expect(userResponse.body).toHaveProperty("username") diff --git a/examples/express-ts/src/__tests__/product.test.ts b/examples/express-ts/src/__tests__/mocha/product.test.ts similarity index 99% rename from examples/express-ts/src/__tests__/product.test.ts rename to examples/express-ts/src/__tests__/mocha/product.test.ts index 9f2896d..d5963d1 100644 --- a/examples/express-ts/src/__tests__/product.test.ts +++ b/examples/express-ts/src/__tests__/mocha/product.test.ts @@ -1,4 +1,4 @@ -import { app } from "../index" +import { app } from "../../index" import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc" describeAPI( diff --git a/examples/express-ts/src/__tests__/mocha/product.wrapper.test.ts b/examples/express-ts/src/__tests__/mocha/product.wrapper.test.ts new file mode 100644 index 0000000..b804e58 --- /dev/null +++ b/examples/express-ts/src/__tests__/mocha/product.wrapper.test.ts @@ -0,0 +1,214 @@ +/** + * Product API Tests using wrapTest wrapper approach (Mocha version) + * + * This demonstrates the new high-order function wrapping method + * that automatically captures HTTP requests/responses + */ + +import { app } from "../../index" +import { wrapTest, createClient } from "itdoc" +import { ProductService } from "../../services/productService" +import { expect } from "chai" + +const apiTest = wrapTest(it) + +const request = createClient.supertest(app) + +describe("Product API - Wrapper Approach (Mocha)", () => { + beforeEach(() => { + ProductService.resetProducts() + }) + + describe("GET /api/products/:id", () => { + apiTest.withMeta({ + summary: "Get product by ID", + tags: ["Products"], + description: "Retrieves a specific product by its ID", + })("should return a specific product", async () => { + const response = await request.get("/api/products/1") + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("id", 1) + expect(response.body).to.have.property("name", "Laptop") + expect(response.body).to.have.property("price", 999.99) + expect(response.body).to.have.property("category", "Electronics") + }) + + apiTest("should return product with different ID", async () => { + const response = await request.get("/api/products/2") + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("id", 2) + expect(response.body).to.have.property("name", "Smartphone") + }) + }) + + describe("POST /api/products", () => { + apiTest.withMeta({ + summary: "Create new product", + tags: ["Products", "Create"], + description: "Creates a new product with the provided information", + })("should create a new product", async () => { + const response = await request.post("/api/products").send({ + name: "Test Product", + price: 99.99, + category: "Test Category", + }) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("id", 3) + expect(response.body).to.have.property("name", "Test Product") + expect(response.body).to.have.property("price", 99.99) + expect(response.body).to.have.property("category", "Test Category") + }) + + apiTest.withMeta({ + summary: "Create product with different data", + tags: ["Products", "Create"], + })("should create another product", async () => { + const response = await request.post("/api/products").send({ + name: "Another Product", + price: 199.99, + category: "Another Category", + }) + + expect(response.status).to.equal(201) + expect((response.body as any).name).to.equal("Another Product") + }) + }) + + describe("PUT /api/products/:id", () => { + apiTest.withMeta({ + summary: "Update product", + tags: ["Products", "Update"], + description: "Updates an existing product with the provided information", + })("should update a product", async () => { + const response = await request.put("/api/products/1").send({ + name: "Updated Product", + price: 199.99, + category: "Updated Category", + }) + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("id", 1) + expect(response.body).to.have.property("name", "Updated Product") + expect(response.body).to.have.property("price", 199.99) + expect(response.body).to.have.property("category", "Updated Category") + }) + + apiTest("should update product with partial data", async () => { + const response = await request.put("/api/products/2").send({ + name: "Partially Updated", + price: 299.99, + category: "Electronics", + }) + + expect(response.status).to.equal(200) + expect((response.body as any).name).to.equal("Partially Updated") + }) + }) + + describe("Complete product CRUD workflow", () => { + apiTest.withMeta({ + summary: "Product CRUD workflow", + tags: ["Products", "Workflow", "CRUD"], + description: "Complete create, read, update, delete workflow for products", + })("should perform complete CRUD operations", async () => { + const createResponse = await request.post("/api/products").send({ + name: "Workflow Product", + price: 149.99, + category: "Test", + }) + + expect(createResponse.status).to.equal(201) + const productId = (createResponse.body as any).id + + const getResponse = await request.get(`/api/products/${productId}`) + + expect(getResponse.status).to.equal(200) + expect((getResponse.body as any).name).to.equal("Workflow Product") + + const updateResponse = await request.put(`/api/products/${productId}`).send({ + name: "Updated Workflow Product", + price: 179.99, + category: "Updated Test", + }) + + expect(updateResponse.status).to.equal(200) + expect((updateResponse.body as any).name).to.equal("Updated Workflow Product") + + const deleteResponse = await request.delete(`/api/products/${productId}`) + + expect(deleteResponse.status).to.equal(204) + }) + }) + + describe("Product filtering and search", () => { + apiTest.withMeta({ + summary: "Filter products by category", + tags: ["Products", "Filter"], + })("should filter products with query params", async () => { + const response = await request + .get("/api/products/1") + .query({ category: "Electronics", minPrice: 500 }) + + expect(response.status).to.equal(200) + }) + + apiTest("should search products with multiple params", async () => { + const response = await request.get("/api/products/1").query({ + search: "laptop", + sortBy: "price", + order: "asc", + }) + + expect(response.status).to.equal(200) + }) + }) + + describe("Product API with authentication", () => { + apiTest.withMeta({ + summary: "Create product with auth", + tags: ["Products", "Authentication"], + })("should create product with authorization header", async () => { + const response = await request + .post("/api/products") + .set("Authorization", "Bearer fake-token-123") + .send({ + name: "Authenticated Product", + price: 299.99, + category: "Secure", + }) + + expect(response.status).to.equal(201) + }) + + apiTest("should include custom headers", async () => { + const response = await request + .get("/api/products/1") + .set("Authorization", "Bearer token") + .set("X-Client-ID", "test-client") + .set("Accept", "application/json") + + expect(response.status).to.equal(200) + }) + }) + + describe("DELETE /api/products/:id", () => { + apiTest.withMeta({ + summary: "Delete product", + tags: ["Products", "Delete"], + description: "Deletes a product by its ID", + })("should delete a product", async () => { + const response = await request.delete("/api/products/1") + + expect(response.status).to.equal(204) + }) + + apiTest("should delete another product", async () => { + const response = await request.delete("/api/products/2") + + expect(response.status).to.equal(204) + }) + }) +}) diff --git a/examples/express-ts/src/__tests__/mocha/upload.wrapper.test.ts b/examples/express-ts/src/__tests__/mocha/upload.wrapper.test.ts new file mode 100644 index 0000000..5979d7b --- /dev/null +++ b/examples/express-ts/src/__tests__/mocha/upload.wrapper.test.ts @@ -0,0 +1,121 @@ +/** + * Upload API Tests using wrapTest wrapper approach (Mocha version) + * + * This demonstrates the new high-order function wrapping method + * that automatically captures HTTP requests/responses + */ + +import { app } from "../../index" +import { wrapTest, createClient } from "itdoc" +import path from "path" +import fs from "fs" +import { expect } from "chai" + +const apiTest = wrapTest(it) + +const request = createClient.supertest(app) + +describe("Upload API - Wrapper Approach (Mocha)", () => { + const testFilePath = path.join(__dirname, "test-file.txt") + const testImagePath = path.join(__dirname, "test-image.png") + + before(() => { + fs.writeFileSync(testFilePath, "This is a test file content") + + const pngBuffer = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, + 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, + 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, + 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]) + fs.writeFileSync(testImagePath, pngBuffer) + }) + + after(() => { + if (fs.existsSync(testFilePath)) { + fs.unlinkSync(testFilePath) + } + if (fs.existsSync(testImagePath)) { + fs.unlinkSync(testImagePath) + } + }) + + apiTest.withMeta({ + summary: "Upload a single file", + tags: ["Upload"], + description: "Uploads a single file using multipart/form-data", + })("should upload a single file successfully", async () => { + const response = await request.post("/api/upload/single").attach("file", testFilePath) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("message", "File uploaded successfully") + expect((response.body as any).file).to.have.property("originalname", "test-file.txt") + expect((response.body as any).file).to.have.property("mimetype", "text/plain") + }) + + apiTest.withMeta({ + summary: "Upload multiple files", + tags: ["Upload"], + description: "Uploads multiple files in a single request", + })("should upload multiple files successfully", async () => { + const response = await request + .post("/api/upload/multiple") + .attach("files", testFilePath) + .attach("files", testImagePath) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("message", "Files uploaded successfully") + expect((response.body as any).files).to.have.lengthOf(2) + expect((response.body as any).files[0]).to.have.property("originalname", "test-file.txt") + expect((response.body as any).files[1]).to.have.property("originalname", "test-image.png") + }) + + apiTest.withMeta({ + summary: "Upload file with additional fields", + tags: ["Upload", "Documents"], + description: "Uploads a file along with additional form fields (title, description)", + })("should upload file with additional form fields", async () => { + const response = await request + .post("/api/upload/with-fields") + .field("title", "Important Document") + .field("description", "This is a very important document") + .attach("document", testFilePath) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("message", "Document uploaded successfully") + expect((response.body as any).document).to.have.property("title", "Important Document") + expect((response.body as any).document).to.have.property( + "description", + "This is a very important document", + ) + expect((response.body as any).document.file).to.have.property( + "originalname", + "test-file.txt", + ) + }) + + apiTest.withMeta({ + summary: "Handle missing file upload", + tags: ["Upload", "Error Handling"], + description: "Returns 400 error when no file is provided", + })("should return 400 when no file is uploaded", async () => { + const response = await request.post("/api/upload/single").send({}) + + expect(response.status).to.equal(400) + expect(response.body).to.have.property("error", "No file uploaded") + }) + + apiTest.withMeta({ + summary: "Upload image file", + tags: ["Upload", "Images"], + description: "Uploads an image file (PNG)", + })("should upload an image file successfully", async () => { + const response = await request.post("/api/upload/single").attach("file", testImagePath) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("message", "File uploaded successfully") + expect((response.body as any).file).to.have.property("originalname", "test-image.png") + expect((response.body as any).file).to.have.property("mimetype", "image/png") + }) +}) diff --git a/examples/express-ts/src/__tests__/user.test.ts b/examples/express-ts/src/__tests__/mocha/user.test.ts similarity index 99% rename from examples/express-ts/src/__tests__/user.test.ts rename to examples/express-ts/src/__tests__/mocha/user.test.ts index 381f305..242758d 100644 --- a/examples/express-ts/src/__tests__/user.test.ts +++ b/examples/express-ts/src/__tests__/mocha/user.test.ts @@ -1,4 +1,4 @@ -import { app } from "../index" +import { app } from "../../index" import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc" describeAPI( diff --git a/examples/express-ts/src/__tests__/mocha/user.wrapper.test.ts b/examples/express-ts/src/__tests__/mocha/user.wrapper.test.ts new file mode 100644 index 0000000..827e429 --- /dev/null +++ b/examples/express-ts/src/__tests__/mocha/user.wrapper.test.ts @@ -0,0 +1,139 @@ +/** + * User API Tests using wrapTest wrapper approach (Mocha version) + * + * This demonstrates the new high-order function wrapping method + * that automatically captures HTTP requests/responses + */ + +import { app } from "../../index" +import { wrapTest, createClient } from "itdoc" +import { expect } from "chai" + +const apiTest = wrapTest(it) + +const request = createClient.supertest(app) + +describe("User API - Wrapper Approach (Mocha)", () => { + describe("POST /api/user/register", () => { + apiTest.withMeta({ + summary: "Register new user", + tags: ["Users", "Authentication"], + description: "Registers a new user with username and password", + })("should register a new user successfully", async () => { + const response = await request.post("/api/user/register").send({ + username: "testuser", + password: "testpassword", + }) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("message", "User registered successfully") + expect((response.body as any).user).to.have.property("username", "testuser") + }) + + apiTest.withMeta({ + summary: "Register user - missing username", + tags: ["Users", "Authentication", "Validation"], + })("should return error when username is missing", async () => { + const response = await request.post("/api/user/register").send({ + password: "testpassword", + }) + + expect(response.status).to.equal(400) + expect(response.body).to.have.property("message", "Username and password are required.") + }) + + apiTest.withMeta({ + summary: "Register user - missing password", + tags: ["Users", "Authentication", "Validation"], + })("should return error when password is missing", async () => { + const response = await request.post("/api/user/register").send({ + username: "testuser", + }) + + expect(response.status).to.equal(400) + expect(response.body).to.have.property("message", "Username and password are required.") + }) + }) + + describe("POST /api/user/login", () => { + apiTest.withMeta({ + summary: "User login", + tags: ["Users", "Authentication"], + description: "Authenticates a user with username and password", + })("should login successfully with valid credentials", async () => { + const response = await request.post("/api/user/login").send({ + username: "admin", + password: "admin", + }) + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("message", "Login successful") + expect(response.body).to.have.property("token", "fake-jwt-token") + }) + + apiTest.withMeta({ + summary: "User login - invalid credentials", + tags: ["Users", "Authentication", "Error"], + })("should return error with invalid credentials", async () => { + const response = await request.post("/api/user/login").send({ + username: "wronguser", + password: "wrongpassword", + }) + + expect(response.status).to.equal(401) + expect(response.body).to.have.property("message", "Invalid credentials") + }) + }) + + describe("GET /api/user/:id", () => { + apiTest.withMeta({ + summary: "Get user by ID", + tags: ["Users"], + description: "Retrieves a specific user by their ID", + })("should return user information", async () => { + const response = await request.get("/api/user/123") + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("id", "123") + expect(response.body).to.have.property("username", "exampleUser") + expect(response.body).to.have.property("email", "user@example.com") + expect(response.body).to.have.property("profilePicture", null) + }) + + apiTest("should handle different user IDs", async () => { + const response = await request.get("/api/user/456") + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("id", "456") + }) + }) + + describe("Complete user workflow", () => { + apiTest.withMeta({ + summary: "User registration and login flow", + tags: ["Users", "Workflow"], + description: "Complete user registration and authentication workflow", + })("should register and login successfully", async () => { + const registerResponse = await request.post("/api/user/register").send({ + username: "newuser", + password: "newpassword", + }) + + expect(registerResponse.status).to.equal(201) + expect((registerResponse.body as any).user.username).to.equal("newuser") + + const loginResponse = await request.post("/api/user/login").send({ + username: "admin", // Using admin for demo + password: "admin", + }) + + expect(loginResponse.status).to.equal(200) + expect(loginResponse.body).to.have.property("token") + + const userResponse = await request.get("/api/user/123") + + expect(userResponse.status).to.equal(200) + expect(userResponse.body).to.have.property("username") + }) + }) +}) diff --git a/examples/express-ts/src/services/productService.ts b/examples/express-ts/src/services/productService.ts index 5563586..c07f6f4 100644 --- a/examples/express-ts/src/services/productService.ts +++ b/examples/express-ts/src/services/productService.ts @@ -5,11 +5,13 @@ export interface Product { category: string } -const products: Product[] = [ +let products: Product[] = [ { id: 1, name: "Laptop", price: 999.99, category: "Electronics" }, { id: 2, name: "Smartphone", price: 699.99, category: "Electronics" }, ] +let nextId = 3 + export const ProductService = { getAllProducts: async (): Promise => { return products @@ -21,7 +23,7 @@ export const ProductService = { createProduct: async (productData: Omit): Promise => { const newProduct = { - id: 3, + id: nextId++, ...productData, } products.push(newProduct) @@ -46,4 +48,12 @@ export const ProductService = { products.splice(index, 1) return true }, + + resetProducts: () => { + products = [ + { id: 1, name: "Laptop", price: 999.99, category: "Electronics" }, + { id: 2, name: "Smartphone", price: 699.99, category: "Electronics" }, + ] + nextId = 3 + }, } diff --git a/lib/dsl/index.ts b/lib/dsl/index.ts index ab7d7dc..0cbe109 100644 --- a/lib/dsl/index.ts +++ b/lib/dsl/index.ts @@ -19,6 +19,5 @@ export { HttpStatus } from "./enums/HttpStatus" export { describeAPI, itDoc, field } from "./interface" export type { ApiDocOptions } from "./interface/ItdocBuilderEntry" -// Wrapper-based API testing (new approach) -export { wrapTest, request } from "../wrappers" +export { wrapTest, createClient } from "../wrappers" export type { ApiDocMetadata, WrappedTestFunction } from "../wrappers" diff --git a/lib/wrappers/EXAMPLE.md b/lib/wrappers/EXAMPLE.md index 908d251..ee7c3e5 100644 --- a/lib/wrappers/EXAMPLE.md +++ b/lib/wrappers/EXAMPLE.md @@ -16,54 +16,49 @@ ### 1. 간단한 GET 요청 ```typescript -import { wrapTest, request } from 'itdoc/wrappers' +import { wrapTest, createClient } from "itdoc/wrappers" const apiTest = wrapTest(it) -describe('Product API', () => { - apiTest('should get all products', async () => { - const response = await request(app) - .get('/api/products') - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty('products') - expect(Array.isArray(response.body.products)).toBe(true) - }) +describe("Product API", () => { + apiTest("should get all products", async () => { + const response = await createClient.supertest(app).get("/api/products") + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("products") + expect(Array.isArray(response.body.products)).toBe(true) + }) }) ``` ### 2. POST 요청으로 리소스 생성 ```typescript -apiTest('should create new product', async () => { - const response = await request(app) - .post('/api/products') - .send({ - name: 'iPhone 15', - price: 999.99, - category: 'electronics' +apiTest("should create new product", async () => { + const response = await createClient.supertest(app).post("/api/products").send({ + name: "iPhone 15", + price: 999.99, + category: "electronics", }) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty('id') - expect(response.body.name).toBe('iPhone 15') + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty("id") + expect(response.body.name).toBe("iPhone 15") }) ``` ### 3. 쿼리 파라미터 사용 ```typescript -apiTest('should filter products by category', async () => { - const response = await request(app) - .get('/api/products') - .query({ - category: 'electronics', - minPrice: 500, - maxPrice: 2000 +apiTest("should filter products by category", async () => { + const response = await createClient.supertest(app).get("/api/products").query({ + category: "electronics", + minPrice: 500, + maxPrice: 2000, }) - - expect(response.status).toBe(200) - expect(response.body.products.every(p => p.category === 'electronics')).toBe(true) + + expect(response.status).toBe(200) + expect(response.body.products.every((p) => p.category === "electronics")).toBe(true) }) ``` @@ -72,44 +67,42 @@ apiTest('should filter products by category', async () => { ### 4. JWT 토큰 인증 ```typescript -describe('Auth API', () => { - apiTest('should login and get token', async () => { - const response = await request(app) - .post('/api/auth/login') - .send({ - email: 'admin@example.com', - password: 'password123' - }) - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty('token') - }) +describe("Auth API", () => { + apiTest("should login and get token", async () => { + const response = await createClient.supertest(app).post("/api/auth/login").send({ + email: "admin@example.com", + password: "password123", + }) + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("token") + }) }) ``` ### 5. 인증된 요청 ```typescript -apiTest('should access protected route with token', async () => { - const response = await request(app) - .get('/api/admin/dashboard') - .set('Authorization', 'Bearer eyJhbGciOiJIUzI1NiIs...') - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty('stats') +apiTest("should access protected route with token", async () => { + const response = await createClient + .supertest(app) + .get("/api/admin/dashboard") + .set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIs...") + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("stats") }) ``` ### 6. 권한 부족 에러 ```typescript -apiTest('should reject unauthorized access', async () => { - const response = await request(app) - .get('/api/admin/users') - // No Authorization header - - expect(response.status).toBe(401) - expect(response.body.error).toBe('Unauthorized') +apiTest("should reject unauthorized access", async () => { + const response = await createClient.supertest(app).get("/api/admin/users") + // No Authorization header + + expect(response.status).toBe(401) + expect(response.body.error).toBe("Unauthorized") }) ``` @@ -118,78 +111,78 @@ apiTest('should reject unauthorized access', async () => { ### 7. 전체 사용자 등록 플로우 ```typescript -apiTest('should complete user registration flow', async () => { - // 1. 회원가입 - const signupRes = await request(app) - .post('/api/auth/signup') - .send({ - email: 'newuser@example.com', - password: 'secure123', - name: 'John Doe' +apiTest("should complete user registration flow", async () => { + // 1. 회원가입 + const signupRes = await createClient.supertest(app).post("/api/auth/signup").send({ + email: "newuser@example.com", + password: "secure123", + name: "John Doe", }) - - expect(signupRes.status).toBe(201) - const userId = signupRes.body.id - - // 2. 이메일 인증 (시뮬레이션) - const verifyRes = await request(app) - .post(`/api/auth/verify/${userId}`) - .send({ code: '123456' }) - - expect(verifyRes.status).toBe(200) - - // 3. 로그인 - const loginRes = await request(app) - .post('/api/auth/login') - .send({ - email: 'newuser@example.com', - password: 'secure123' + + expect(signupRes.status).toBe(201) + const userId = signupRes.body.id + + // 2. 이메일 인증 (시뮬레이션) + const verifyRes = await createClient + .supertest(app) + .post(`/api/auth/verify/${userId}`) + .send({ code: "123456" }) + + expect(verifyRes.status).toBe(200) + + // 3. 로그인 + const loginRes = await createClient.supertest(app).post("/api/auth/login").send({ + email: "newuser@example.com", + password: "secure123", }) - - expect(loginRes.status).toBe(200) - expect(loginRes.body).toHaveProperty('token') + + expect(loginRes.status).toBe(200) + expect(loginRes.body).toHaveProperty("token") }) ``` ### 8. 주문 생성 및 결제 ```typescript -apiTest('should create order and process payment', async () => { - // 1. 장바구니에 상품 추가 - const cartRes = await request(app) - .post('/api/cart/items') - .set('Authorization', 'Bearer token') - .send({ - productId: 123, - quantity: 2 - }) - - expect(cartRes.status).toBe(200) - - // 2. 주문 생성 - const orderRes = await request(app) - .post('/api/orders') - .set('Authorization', 'Bearer token') - .send({ - shippingAddress: '123 Main St', - paymentMethod: 'credit_card' - }) - - expect(orderRes.status).toBe(201) - const orderId = orderRes.body.id - - // 3. 결제 처리 - const paymentRes = await request(app) - .post(`/api/orders/${orderId}/pay`) - .set('Authorization', 'Bearer token') - .send({ - cardNumber: '4242424242424242', - expiry: '12/25', - cvv: '123' - }) - - expect(paymentRes.status).toBe(200) - expect(paymentRes.body.status).toBe('paid') +apiTest("should create order and process payment", async () => { + // 1. 장바구니에 상품 추가 + const cartRes = await createClient + .supertest(app) + .post("/api/cart/items") + .set("Authorization", "Bearer token") + .send({ + productId: 123, + quantity: 2, + }) + + expect(cartRes.status).toBe(200) + + // 2. 주문 생성 + const orderRes = await createClient + .supertest(app) + .post("/api/orders") + .set("Authorization", "Bearer token") + .send({ + shippingAddress: "123 Main St", + paymentMethod: "credit_card", + }) + + expect(orderRes.status).toBe(201) + const orderId = orderRes.body.id + + // 3. 결제 처리 + const paymentRes = await createClient + .supertest(app) + .post(`/api/orders/${orderId}/pay`) + .set("Authorization", "Bearer token") + .send({ + cardNumber: "4242424242424242", + expiry: "12/25", + cvv: "123", + }) + + expect(paymentRes.status).toBe(200) + expect(paymentRes.body.status).toBe("paid") }) ``` @@ -198,41 +191,39 @@ apiTest('should create order and process payment', async () => { ### 9. Validation 에러 ```typescript -apiTest('should validate required fields', async () => { - const response = await request(app) - .post('/api/products') - .send({ - // name 누락 - price: 999.99 +apiTest("should validate required fields", async () => { + const response = await createClient.supertest(app).post("/api/products").send({ + // name 누락 + price: 999.99, }) - - expect(response.status).toBe(400) - expect(response.body.errors).toContain('name is required') + + expect(response.status).toBe(400) + expect(response.body.errors).toContain("name is required") }) ``` ### 10. Not Found 에러 ```typescript -apiTest('should return 404 for non-existent resource', async () => { - const response = await request(app) - .get('/api/products/99999') - - expect(response.status).toBe(404) - expect(response.body.error).toBe('Product not found') +apiTest("should return 404 for non-existent resource", async () => { + const response = await createClient.supertest(app).get("/api/products/99999") + + expect(response.status).toBe(404) + expect(response.body.error).toBe("Product not found") }) ``` ### 11. Server 에러 ```typescript -apiTest('should handle server errors gracefully', async () => { - const response = await request(app) - .post('/api/products/import') - .send({ file: 'invalid-data' }) - - expect(response.status).toBe(500) - expect(response.body).toHaveProperty('error') +apiTest("should handle server errors gracefully", async () => { + const response = await createClient + .supertest(app) + .post("/api/products/import") + .send({ file: "invalid-data" }) + + expect(response.status).toBe(500) + expect(response.body).toHaveProperty("error") }) ``` @@ -242,19 +233,17 @@ apiTest('should handle server errors gracefully', async () => { ```typescript apiTest.withMeta({ - summary: 'Create Product', - description: 'Creates a new product in the inventory system', - tags: ['Products', 'Inventory'], -})('POST /api/products - Create product', async () => { - const response = await request(app) - .post('/api/products') - .send({ - name: 'MacBook Pro', - price: 2499.99, - category: 'computers' + summary: "Create Product", + description: "Creates a new product in the inventory system", + tags: ["Products", "Inventory"], +})("POST /api/products - Create product", async () => { + const response = await createClient.supertest(app).post("/api/products").send({ + name: "MacBook Pro", + price: 2499.99, + category: "computers", }) - - expect(response.status).toBe(201) + + expect(response.status).toBe(201) }) ``` @@ -262,15 +251,14 @@ apiTest.withMeta({ ```typescript apiTest.withMeta({ - summary: 'Legacy User List', - tags: ['Users', 'Legacy'], - deprecated: true, - description: 'This endpoint is deprecated. Use /api/v2/users instead.' -})('GET /api/users - List users (deprecated)', async () => { - const response = await request(app) - .get('/api/users') - - expect(response.status).toBe(200) + summary: "Legacy User List", + tags: ["Users", "Legacy"], + deprecated: true, + description: "This endpoint is deprecated. Use /api/v2/users instead.", +})("GET /api/users - List users (deprecated)", async () => { + const response = await createClient.supertest(app).get("/api/users") + + expect(response.status).toBe(200) }) ``` @@ -278,16 +266,17 @@ apiTest.withMeta({ ```typescript apiTest.withMeta({ - summary: 'Export User Data (GDPR)', - tags: ['Users', 'Privacy', 'GDPR', 'Export'], - description: 'Exports all user data for GDPR compliance' -})('GET /api/users/:id/export - Export user data', async () => { - const response = await request(app) - .get('/api/users/123/export') - .set('Authorization', 'Bearer token') - - expect(response.status).toBe(200) - expect(response.headers['content-type']).toContain('application/json') + summary: "Export User Data (GDPR)", + tags: ["Users", "Privacy", "GDPR", "Export"], + description: "Exports all user data for GDPR compliance", +})("GET /api/users/:id/export - Export user data", async () => { + const response = await createClient + .supertest(app) + .get("/api/users/123/export") + .set("Authorization", "Bearer token") + + expect(response.status).toBe(200) + expect(response.headers["content-type"]).toContain("application/json") }) ``` @@ -296,38 +285,43 @@ apiTest.withMeta({ ### 15. 기존 Supertest 코드 마이그레이션 **Before (원본):** + ```typescript -import request from 'supertest' - -describe('User API', () => { - it('should create user', async () => { - const response = await request(app) - .post('/api/users') - .send({ name: 'John', email: 'john@test.com' }) - - expect(response.status).toBe(201) - }) +import request from "supertest" + +describe("User API", () => { + it("should create user", async () => { + const response = await createClient + .supertest(app) + .post("/api/users") + .send({ name: "John", email: "john@test.com" }) + + expect(response.status).toBe(201) + }) }) ``` **After (wrapTest 적용):** + ```typescript -import { wrapTest, request } from 'itdoc/wrappers' +import { wrapTest, createClient } from "itdoc/wrappers" const apiTest = wrapTest(it) -describe('User API', () => { - apiTest('should create user', async () => { - const response = await request(app) - .post('/api/users') - .send({ name: 'John', email: 'john@test.com' }) - - expect(response.status).toBe(201) - }) +describe("User API", () => { + apiTest("should create user", async () => { + const response = await createClient + .supertest(app) + .post("/api/users") + .send({ name: "John", email: "john@test.com" }) + + expect(response.status).toBe(201) + }) }) ``` **변경사항:** + 1. ✅ Import 변경: `supertest` → `itdoc/wrappers` 2. ✅ `wrapTest(it)` 추가 3. ✅ `it` → `apiTest` 사용 @@ -339,36 +333,39 @@ describe('User API', () => { const apiTest = wrapTest(it) describe.each([ - { role: 'admin', canDelete: true }, - { role: 'user', canDelete: false }, - { role: 'guest', canDelete: false }, -])('Authorization for $role', ({ role, canDelete }) => { - apiTest(`${role} should ${canDelete ? 'be able to' : 'not be able to'} delete users`, async () => { - const response = await request(app) - .delete('/api/users/123') - .set('Authorization', `Bearer ${role}-token`) - - expect(response.status).toBe(canDelete ? 200 : 403) - }) + { role: "admin", canDelete: true }, + { role: "user", canDelete: false }, + { role: "guest", canDelete: false }, +])("Authorization for $role", ({ role, canDelete }) => { + apiTest( + `${role} should ${canDelete ? "be able to" : "not be able to"} delete users`, + async () => { + const response = await createClient + .supertest(app) + .delete("/api/users/123") + .set("Authorization", `Bearer ${role}-token`) + + expect(response.status).toBe(canDelete ? 200 : 403) + }, + ) }) ``` ### 17. Mocha에서 사용 ```typescript -import { wrapTest, request } from 'itdoc/wrappers' -import { expect } from 'chai' +import { wrapTest, createClient } from "itdoc/wrappers" +import { expect } from "chai" const apiTest = wrapTest(it) -describe('Product API', function() { - apiTest('should get products', async function() { - const response = await request(app) - .get('/api/products') - - expect(response.status).to.equal(200) - expect(response.body).to.have.property('products') - }) +describe("Product API", function () { + apiTest("should get products", async function () { + const response = await createClient.supertest(app).get("/api/products") + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("products") + }) }) ``` @@ -392,9 +389,9 @@ apiTest.withMeta({ // ✅ 여러 API 호출을 워크플로우로 테스트 apiTest('should complete checkout flow', async () => { - await request(app).post('/cart/add').send({...}) - await request(app).post('/orders').send({...}) - await request(app).post('/payment').send({...}) + await createClient.supertest(app).post('/cart/add').send({...}) + await createClient.supertest(app).post('/orders').send({...}) + await createClient.supertest(app).post('/payment').send({...}) }) ``` @@ -402,32 +399,32 @@ apiTest('should complete checkout flow', async () => { ```typescript // ❌ 모호한 테스트 설명 -apiTest('test1', async () => { - // ... +apiTest("test1", async () => { + // ... }) // ❌ 문서화가 필요 없는 헬퍼 함수를 apiTest로 감싸기 -apiTest('helper function', async () => { - // 이건 일반 it()을 사용하세요 +apiTest("helper function", async () => { + // 이건 일반 it()을 사용하세요 }) // ❌ 너무 많은 API 호출 (10개 이상) -apiTest('complex flow', async () => { - // 10개 이상의 API 호출... - // 테스트를 분리하는 것이 좋습니다 +apiTest("complex flow", async () => { + // 10개 이상의 API 호출... + // 테스트를 분리하는 것이 좋습니다 }) ``` ## 📊 비교표 -| 기능 | 기존 itDoc | wrapTest | -|------|-----------|----------| -| 사용 난이도 | 중간 (새 DSL 학습) | 쉬움 (기존 패턴 유지) | -| 코드 변경량 | 많음 | 최소 | -| 자동 캡처 | ❌ 수동 설정 | ✅ 자동 | -| 메타데이터 | 체이닝 방식 | `withMeta()` | -| 기존 코드 호환 | 낮음 | 높음 | -| 타입 안전성 | ✅ | ✅ | +| 기능 | 기존 itDoc | wrapTest | +| -------------- | ------------------ | --------------------- | +| 사용 난이도 | 중간 (새 DSL 학습) | 쉬움 (기존 패턴 유지) | +| 코드 변경량 | 많음 | 최소 | +| 자동 캡처 | ❌ 수동 설정 | ✅ 자동 | +| 메타데이터 | 체이닝 방식 | `withMeta()` | +| 기존 코드 호환 | 낮음 | 높음 | +| 타입 안전성 | ✅ | ✅ | ## 🔗 추가 리소스 diff --git a/lib/wrappers/README.md b/lib/wrappers/README.md index 487ee9b..6437a1e 100644 --- a/lib/wrappers/README.md +++ b/lib/wrappers/README.md @@ -4,7 +4,8 @@ ## 📋 개요 -이 모듈은 기존 테스트 프레임워크의 `it` 함수를 감싸서 HTTP request/response를 자동으로 캡처하고, 테스트 성공 시 OpenAPI 문서를 생성합니다. +이 모듈은 기존 테스트 프레임워크의 `it` 함수를 감싸서 HTTP request/response를 자동으로 캡처하고, +테스트 성공 시 OpenAPI 문서를 생성합니다. ### 핵심 특징 @@ -19,35 +20,37 @@ ### Before (기존 테스트) ```typescript -import request from 'supertest' - -describe('User API', () => { - it('should create user', async () => { - const response = await request(app) - .post('/users') - .send({ name: 'John', email: 'john@test.com' }) - - expect(response.status).toBe(201) - }) +import request from "supertest" + +describe("User API", () => { + it("should create user", async () => { + const response = await request(app) + .post("/users") + .send({ name: "John", email: "john@test.com" }) + + expect(response.status).toBe(201) + }) }) ``` ### After (itdoc wrappers 적용) ```typescript -import { wrapTest, request } from 'itdoc/wrappers' +import { wrapTest, createClient } from "itdoc/wrappers" -const apiTest = wrapTest(it) // ← it 함수를 래핑 +const apiTest = wrapTest(it) // ← it 함수를 래핑 -describe('User API', () => { - apiTest('should create user', async () => { // ← it 대신 apiTest 사용 - const response = await request(app) // ← itdoc의 request 사용 - .post('/users') - .send({ name: 'John', email: 'john@test.com' }) - - expect(response.status).toBe(201) - // ✅ 자동으로 request/response 캡처 & 문서 생성! - }) +describe("User API", () => { + apiTest("should create user", async () => { + // ← it 대신 apiTest 사용 + const response = await createClient + .supertest(app) // ← createClient.supertest 사용 + .post("/users") + .send({ name: "John", email: "john@test.com" }) + + expect(response.status).toBe(201) + // ✅ 자동으로 request/response 캡처 & 문서 생성! + }) }) ``` @@ -58,21 +61,20 @@ describe('User API', () => { 테스트 프레임워크의 `it` 함수를 래핑하여 자동 캡처 기능을 추가합니다. ```typescript -import { wrapTest } from 'itdoc/wrappers' +import { wrapTest } from "itdoc/wrappers" -const apiTest = wrapTest(it) // Jest 또는 Mocha의 it +const apiTest = wrapTest(it) // Jest 또는 Mocha의 it ``` -### `request(app)` +### `createClient.supertest(app)` -Supertest를 기반으로 한 HTTP 클라이언트입니다. `CaptureContext`가 활성화된 상태에서는 자동으로 request/response를 캡처합니다. +Supertest를 기반으로 한 HTTP 클라이언트 어댑터입니다. `CaptureContext`가 활성화된 상태에서는 +자동으로 request/response를 캡처합니다. ```typescript -import { request } from 'itdoc/wrappers' +import { createClient } from "itdoc/wrappers" -const response = await request(app) - .post('/users') - .send({ name: 'John' }) +const response = await createClient.supertest(app).post("/users").send({ name: "John" }) ``` ### 메타데이터 추가 @@ -81,15 +83,13 @@ const response = await request(app) ```typescript apiTest.withMeta({ - summary: 'Create User', - tags: ['Users', 'Registration'], - description: 'Register a new user account', -})('should create user', async () => { - const response = await request(app) - .post('/users') - .send({ name: 'John' }) - - expect(response.status).toBe(201) + summary: "Create User", + tags: ["Users", "Registration"], + description: "Register a new user account", +})("should create user", async () => { + const response = await createClient.supertest(app).post("/users").send({ name: "John" }) + + expect(response.status).toBe(201) }) ``` @@ -98,63 +98,62 @@ apiTest.withMeta({ ### 1. 기본 사용 ```typescript -import { wrapTest, request } from 'itdoc/wrappers' +import { wrapTest, createClient } from "itdoc/wrappers" const apiTest = wrapTest(it) -describe('User API', () => { - apiTest('should get all users', async () => { - const response = await request(app).get('/users') - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty('users') - }) +describe("User API", () => { + apiTest("should get all users", async () => { + const response = await createClient.supertest(app).get("/users") + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty("users") + }) }) ``` ### 2. 인증 헤더 사용 ```typescript -apiTest('should get user profile', async () => { - const response = await request(app) - .get('/users/me') - .set('Authorization', 'Bearer token123') - - expect(response.status).toBe(200) +apiTest("should get user profile", async () => { + const response = await createClient + .supertest(app) + .get("/users/me") + .set("Authorization", "Bearer token123") + + expect(response.status).toBe(200) }) ``` ### 3. 쿼리 파라미터 ```typescript -apiTest('should filter users', async () => { - const response = await request(app) - .get('/users') - .query({ role: 'admin', active: true }) - - expect(response.status).toBe(200) +apiTest("should filter users", async () => { + const response = await createClient + .supertest(app) + .get("/users") + .query({ role: "admin", active: true }) + + expect(response.status).toBe(200) }) ``` ### 4. 여러 API 호출 (단일 테스트) ```typescript -apiTest('should complete user workflow', async () => { - // 1. Create user - const createRes = await request(app) - .post('/users') - .send({ name: 'John' }) - - const userId = createRes.body.id - - // 2. Get user - const getRes = await request(app) - .get(`/users/${userId}`) - - expect(getRes.status).toBe(200) - expect(getRes.body.name).toBe('John') - - // ✅ 두 API 호출 모두 자동 캡처됨! +apiTest("should complete user workflow", async () => { + // 1. Create user + const createRes = await createClient.supertest(app).post("/users").send({ name: "John" }) + + const userId = createRes.body.id + + // 2. Get user + const getRes = await createClient.supertest(app).get(`/users/${userId}`) + + expect(getRes.status).toBe(200) + expect(getRes.body.name).toBe("John") + + // ✅ 두 API 호출 모두 자동 캡처됨! }) ``` @@ -162,19 +161,17 @@ apiTest('should complete user workflow', async () => { ```typescript apiTest.withMeta({ - summary: 'User Registration API', - tags: ['Auth', 'Users'], - description: 'Register a new user with email and password', -})('POST /auth/signup - Register user', async () => { - const response = await request(app) - .post('/auth/signup') - .send({ - email: 'john@example.com', - password: 'secure123' + summary: "User Registration API", + tags: ["Auth", "Users"], + description: "Register a new user with email and password", +})("POST /auth/signup - Register user", async () => { + const response = await createClient.supertest(app).post("/auth/signup").send({ + email: "john@example.com", + password: "secure123", }) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty('token') + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty("token") }) ``` @@ -183,24 +180,26 @@ apiTest.withMeta({ ### 핵심 컴포넌트 1. **CaptureContext** (`core/CaptureContext.ts`) - - AsyncLocalStorage 기반 컨텍스트 관리 - - 스레드 안전한 request/response 데이터 격리 -2. **interceptedRequest** (`core/interceptedRequest.ts`) - - Proxy 기반 Supertest 래핑 - - 투명한 HTTP request/response 캡처 + - AsyncLocalStorage 기반 컨텍스트 관리 + - 스레드 안전한 request/response 데이터 격리 + +2. **HTTP Adapters** (`adapters/`) + + - Supertest, Axios, Fetch 등 다양한 HTTP 클라이언트 어댑터 + - 투명한 HTTP request/response 캡처 3. **wrapTest** (`wrapTest.ts`) - - 고차함수 래퍼 - - 테스트 라이프사이클 관리 및 문서 생성 + - 고차함수 래퍼 + - 테스트 라이프사이클 관리 및 문서 생성 ### 동작 흐름 ``` 1. wrapTest(it) → 래핑된 테스트 함수 반환 2. apiTest(...) 호출 → AsyncLocalStorage 컨텍스트 생성 -3. 사용자 테스트 실행 → request(app) 감지 -4. Proxy로 감싸서 반환 → 메서드 호출 캡처 +3. 사용자 테스트 실행 → createClient.supertest(app) 호출 +4. Adapter로 감싸서 반환 → 메서드 호출 캡처 5. .then() 호출 → response 캡처 6. 테스트 성공 → TestResultCollector로 전달 7. 모든 테스트 완료 → OpenAPI 문서 생성 @@ -211,19 +210,20 @@ apiTest.withMeta({ ### 기존 `itDoc` 방식 ```typescript -import { itDoc, req } from 'itdoc' - -itDoc('should create user', async () => { - const response = await req(app) - .post('/users') - .description('Create user') - .tag('Users') - .send({ name: 'John' }) - .expect(201) +import { itDoc, req } from "itdoc" + +itDoc("should create user", async () => { + const response = await req(app) + .post("/users") + .description("Create user") + .tag("Users") + .send({ name: "John" }) + .expect(201) }) ``` **특징:** + - DSL 방식으로 명시적 메타데이터 추가 - 체이닝 기반 API - 기존 코드 패턴 변경 필요 @@ -231,20 +231,19 @@ itDoc('should create user', async () => { ### 새로운 `wrapTest` 방식 ```typescript -import { wrapTest, request } from 'itdoc/wrappers' +import { wrapTest, createClient } from "itdoc/wrappers" const apiTest = wrapTest(it) -apiTest('should create user', async () => { - const response = await request(app) - .post('/users') - .send({ name: 'John' }) - - expect(response.status).toBe(201) +apiTest("should create user", async () => { + const response = await createClient.supertest(app).post("/users").send({ name: "John" }) + + expect(response.status).toBe(201) }) ``` **특징:** + - 고차함수 래핑으로 자동 캡처 - 기존 테스트 패턴 유지 - 최소한의 코드 변경 @@ -271,9 +270,10 @@ pnpm test:unit -- --grep "wrapTest integration" ```typescript // Public API -export { wrapTest } from './wrapTest' -export { request } from './core/interceptedRequest' -export type { ApiDocMetadata, WrappedTestFunction, TestFunction } from './types' +export { wrapTest } from "./wrapTest" +export { createClient } from "./adapters" +export type { ApiDocMetadata, WrappedTestFunction, TestFunction } from "./types" +export type { HttpClient, HttpAdapter, RequestBuilder } from "./adapters/types" ``` ## 🔄 확장 가능성 @@ -281,8 +281,12 @@ export type { ApiDocMetadata, WrappedTestFunction, TestFunction } from './types' ### 다른 HTTP 클라이언트 지원 ```typescript -// axios, fetch 등 다른 클라이언트도 추가 가능 -import { createAxiosInterceptor } from 'itdoc/wrappers/axios' +// Axios 어댑터 +import { createClient } from "itdoc/wrappers" +const response = await createClient.axios(axiosInstance).get("/users") + +// Fetch 어댑터 +const response = await createClient.fetch("http://localhost:3000").get("/users") ``` ### 커스텀 훅 @@ -290,8 +294,12 @@ import { createAxiosInterceptor } from 'itdoc/wrappers/axios' ```typescript // 향후 확장 가능 wrapTest(it, { - beforeCapture: (req) => { /* 민감 데이터 마스킹 */ }, - afterCapture: (result) => { /* 커스텀 검증 */ } + beforeCapture: (req) => { + /* 민감 데이터 마스킹 */ + }, + afterCapture: (result) => { + /* 커스텀 검증 */ + }, }) ``` diff --git a/lib/wrappers/__tests__/integration.test.ts b/lib/wrappers/__tests__/integration.test.ts index 62b2c52..9f0c2c4 100644 --- a/lib/wrappers/__tests__/integration.test.ts +++ b/lib/wrappers/__tests__/integration.test.ts @@ -17,7 +17,7 @@ import { describe, it } from "mocha" import { expect } from "chai" import express from "express" -import { wrapTest, request } from "../index" +import { wrapTest, createClient } from "../index" describe("wrapTest integration", () => { let app: express.Application @@ -26,23 +26,24 @@ describe("wrapTest integration", () => { app = express() app.use(express.json()) - // Setup test routes app.post("/auth/signup", (req, res) => { const { username, password } = req.body if (!username || !password) { - return res.status(400).json({ error: "Missing fields" }) + res.status(400).json({ error: "Missing fields" }) + return } res.status(201).json({ id: 1, username }) }) - app.post("/auth/login", (req, res) => { + app.post("/auth/login", (_req, res) => { res.status(200).json({ token: "jwt-token-123" }) }) app.get("/users/profile", (req, res) => { const auth = req.headers.authorization if (!auth) { - return res.status(401).json({ error: "Unauthorized" }) + res.status(401).json({ error: "Unauthorized" }) + return } res.status(200).json({ id: 1, username: "john" }) }) @@ -52,7 +53,7 @@ describe("wrapTest integration", () => { const apiTest = wrapTest(it) apiTest("should register new user successfully", async () => { - const response = await request(app).post("/auth/signup").send({ + const response = await createClient.supertest(app).post("/auth/signup").send({ username: "john", password: "password123", }) @@ -63,7 +64,7 @@ describe("wrapTest integration", () => { }) apiTest("should handle validation errors", async () => { - const response = await request(app).post("/auth/signup").send({ + const response = await createClient.supertest(app).post("/auth/signup").send({ username: "john", // missing password }) @@ -73,8 +74,7 @@ describe("wrapTest integration", () => { }) apiTest("should login and get profile", async () => { - // First login - const loginRes = await request(app).post("/auth/login").send({ + const loginRes = await createClient.supertest(app).post("/auth/login").send({ username: "john", password: "password123", }) @@ -82,8 +82,8 @@ describe("wrapTest integration", () => { expect(loginRes.status).to.equal(200) const token = loginRes.body.token - // Then get profile with token - const profileRes = await request(app) + const profileRes = await createClient + .supertest(app) .get("/users/profile") .set("Authorization", `Bearer ${token}`) @@ -96,7 +96,7 @@ describe("wrapTest integration", () => { tags: ["Auth", "Users"], description: "Register a new user account", })("should create user with metadata", async () => { - const response = await request(app).post("/auth/signup").send({ + const response = await createClient.supertest(app).post("/auth/signup").send({ username: "jane", password: "secure123", }) @@ -121,7 +121,7 @@ describe("wrapTest integration", () => { // Example usage (commented out for now): // const apiTest = wrapTest(it) // apiTest('should create user', async () => { - // const response = await request(app) + // const response = await createClient.supertest(app) // .post('/users') // .send({ name: 'John' }) // diff --git a/lib/wrappers/__tests__/interceptedRequest.test.ts b/lib/wrappers/__tests__/interceptedRequest.test.ts index 4387517..696ccbe 100644 --- a/lib/wrappers/__tests__/interceptedRequest.test.ts +++ b/lib/wrappers/__tests__/interceptedRequest.test.ts @@ -17,18 +17,17 @@ import { describe, it, beforeEach } from "mocha" import { expect } from "chai" import express from "express" -import { request } from "../core/interceptedRequest" +import { createClient } from "../adapters" import { CaptureContext } from "../core/CaptureContext" -describe("interceptedRequest", () => { +describe("createClient.supertest adapter", () => { let app: express.Application beforeEach(() => { app = express() app.use(express.json()) - // Test routes - app.get("/users", (req, res) => { + app.get("/users", (_req, res) => { res.status(200).json({ users: [] }) }) @@ -43,14 +42,14 @@ describe("interceptedRequest", () => { describe("without capture context", () => { it("should work as normal supertest", async () => { - const response = await request(app).get("/users") + const response = await createClient.supertest(app).get("/users") expect(response.status).to.equal(200) expect(response.body).to.deep.equal({ users: [] }) }) it("should not capture any data", async () => { - await request(app).get("/users") + await createClient.supertest(app).get("/users") void expect(CaptureContext.getCapturedRequests()).to.be.empty }) @@ -59,7 +58,7 @@ describe("interceptedRequest", () => { describe("with capture context", () => { it("should capture GET request", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app).get("/users") + await createClient.supertest(app).get("/users") const captured = CaptureContext.getCapturedRequests() expect(captured).to.have.lengthOf(1) @@ -70,7 +69,10 @@ describe("interceptedRequest", () => { it("should capture POST request with body", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app).post("/users").send({ name: "John", email: "john@test.com" }) + await createClient + .supertest(app) + .post("/users") + .send({ name: "John", email: "john@test.com" }) const captured = CaptureContext.getCapturedRequests() expect(captured).to.have.lengthOf(1) @@ -85,7 +87,8 @@ describe("interceptedRequest", () => { it("should capture request headers", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app) + await createClient + .supertest(app) .get("/users") .set("Authorization", "Bearer token123") .set("Accept", "application/json") @@ -100,7 +103,7 @@ describe("interceptedRequest", () => { it("should capture query parameters", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app).get("/users").query({ page: 1, limit: 10 }) + await createClient.supertest(app).get("/users").query({ page: 1, limit: 10 }) const captured = CaptureContext.getCapturedRequests() expect(captured[0].queryParams).to.deep.equal({ @@ -112,7 +115,7 @@ describe("interceptedRequest", () => { it("should capture response status and body", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app).get("/users") + await createClient.supertest(app).get("/users") const captured = CaptureContext.getCapturedRequests() expect(captured[0].response?.status).to.equal(200) @@ -122,7 +125,7 @@ describe("interceptedRequest", () => { it("should capture response headers", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app).get("/users") + await createClient.supertest(app).get("/users") const captured = CaptureContext.getCapturedRequests() expect(captured[0].response?.headers).to.be.an("object") @@ -132,8 +135,8 @@ describe("interceptedRequest", () => { it("should capture multiple requests in order", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app).post("/users").send({ name: "John" }) - await request(app).get("/users/1") + await createClient.supertest(app).post("/users").send({ name: "John" }) + await createClient.supertest(app).get("/users/1") const captured = CaptureContext.getCapturedRequests() expect(captured).to.have.lengthOf(2) @@ -144,7 +147,8 @@ describe("interceptedRequest", () => { it("should handle request chain methods", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app) + await createClient + .supertest(app) .post("/users") .set("Authorization", "Bearer token") .send({ name: "John" }) @@ -168,7 +172,7 @@ describe("interceptedRequest", () => { }) await CaptureContext.run("test", undefined, async () => { - await request(app).put("/users/1").send({ name: "Jane" }) + await createClient.supertest(app).put("/users/1").send({ name: "Jane" }) const captured = CaptureContext.getCapturedRequests() expect(captured[0].method).to.equal("PUT") @@ -177,12 +181,12 @@ describe("interceptedRequest", () => { }) it("should capture DELETE requests", async () => { - app.delete("/users/:id", (req, res) => { + app.delete("/users/:id", (_req, res) => { res.status(204).send() }) await CaptureContext.run("test", undefined, async () => { - await request(app).delete("/users/1") + await createClient.supertest(app).delete("/users/1") const captured = CaptureContext.getCapturedRequests() expect(captured[0].method).to.equal("DELETE") @@ -192,7 +196,7 @@ describe("interceptedRequest", () => { it("should work with expect() assertions", async () => { await CaptureContext.run("test", undefined, async () => { - const response = await request(app).get("/users").expect(200) + const response = await createClient.supertest(app).get("/users").expect(200) expect(response.body).to.deep.equal({ users: [] }) @@ -202,12 +206,12 @@ describe("interceptedRequest", () => { }) it("should capture even when request fails", async () => { - app.get("/error", (req, res) => { + app.get("/error", (_req, res) => { res.status(500).json({ error: "Server error" }) }) await CaptureContext.run("test", undefined, async () => { - await request(app).get("/error") + await createClient.supertest(app).get("/error") const captured = CaptureContext.getCapturedRequests() expect(captured[0].response?.status).to.equal(500) @@ -221,7 +225,7 @@ describe("interceptedRequest", () => { describe("header setting variations", () => { it("should capture headers set as object", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app).get("/users").set({ + await createClient.supertest(app).get("/users").set({ Authorization: "Bearer token", "X-Custom": "value", }) @@ -236,7 +240,8 @@ describe("interceptedRequest", () => { it("should merge multiple set() calls", async () => { await CaptureContext.run("test", undefined, async () => { - await request(app) + await createClient + .supertest(app) .get("/users") .set("Authorization", "Bearer token") .set("Accept", "application/json") diff --git a/lib/wrappers/__tests__/wrapTest.integration.test.ts b/lib/wrappers/__tests__/wrapTest.integration.test.ts index aa71945..9da52b1 100644 --- a/lib/wrappers/__tests__/wrapTest.integration.test.ts +++ b/lib/wrappers/__tests__/wrapTest.integration.test.ts @@ -17,7 +17,7 @@ import { describe, it } from "mocha" import { expect } from "chai" import express from "express" -import { wrapTest, request } from "../index" +import { wrapTest, createClient } from "../index" describe("wrapTest integration", () => { let app: express.Application @@ -43,14 +43,14 @@ describe("wrapTest integration", () => { const apiTest = wrapTest(it) apiTest("should make GET request successfully", async () => { - const response = await request(app).get("/users") + const response = await createClient.supertest(app).get("/users") expect(response.status).to.equal(200) expect(response.body).to.deep.equal({ users: [] }) }) apiTest("should make POST request with body", async () => { - const response = await request(app).post("/users").send({ + const response = await createClient.supertest(app).post("/users").send({ name: "John", email: "john@test.com", }) @@ -61,7 +61,8 @@ describe("wrapTest integration", () => { }) apiTest("should send headers", async () => { - const response = await request(app) + const response = await createClient + .supertest(app) .get("/users") .set("Authorization", "Bearer token123") @@ -69,16 +70,22 @@ describe("wrapTest integration", () => { }) apiTest("should send query parameters", async () => { - const response = await request(app).get("/users").query({ page: 1, limit: 10 }) + const response = await createClient + .supertest(app) + .get("/users") + .query({ page: 1, limit: 10 }) expect(response.status).to.equal(200) }) apiTest("should handle multiple requests in one test", async () => { - const createRes = await request(app).post("/users").send({ name: "John" }) + const createRes = await createClient + .supertest(app) + .post("/users") + .send({ name: "John" }) expect(createRes.status).to.equal(201) - const getRes = await request(app).get("/users/1") + const getRes = await createClient.supertest(app).get("/users/1") expect(getRes.status).to.equal(200) }) }) @@ -90,7 +97,7 @@ describe("wrapTest integration", () => { summary: "Create User", tags: ["Users", "Registration"], })("should create user with metadata", async () => { - const response = await request(app).post("/users").send({ + const response = await createClient.supertest(app).post("/users").send({ name: "Jane", email: "jane@test.com", }) @@ -102,7 +109,7 @@ describe("wrapTest integration", () => { description: "Custom description for API", deprecated: false, })("should use custom description", async () => { - const response = await request(app).get("/users") + const response = await createClient.supertest(app).get("/users") expect(response.status).to.equal(200) }) @@ -112,7 +119,7 @@ describe("wrapTest integration", () => { const apiTest = wrapTest(it) apiTest("should handle successful requests", async () => { - const response = await request(app).get("/users") + const response = await createClient.supertest(app).get("/users") expect(response.status).to.equal(200) expect(response.body).to.have.property("users") @@ -123,7 +130,7 @@ describe("wrapTest integration", () => { const apiTest = wrapTest(it) apiTest("should work with chai expect", async () => { - const response = await request(app).get("/users") + const response = await createClient.supertest(app).get("/users") expect(response.status).to.equal(200) expect(response.body).to.be.an("object") @@ -131,13 +138,17 @@ describe("wrapTest integration", () => { }) apiTest("should work with supertest expect()", async () => { - await request(app).get("/users").expect(200).expect("Content-Type", /json/) + await createClient + .supertest(app) + .get("/users") + .expect(200) + .expect("Content-Type", /json/) }) }) describe("without capture (regular it)", () => { it("should work as normal test", async () => { - const response = await request(app).get("/users") + const response = await createClient.supertest(app).get("/users") expect(response.status).to.equal(200) }) diff --git a/lib/wrappers/adapters/axios/AxiosAdapter.ts b/lib/wrappers/adapters/axios/AxiosAdapter.ts new file mode 100644 index 0000000..6e57fe2 --- /dev/null +++ b/lib/wrappers/adapters/axios/AxiosAdapter.ts @@ -0,0 +1,239 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { AxiosInstance, AxiosRequestConfig } from "axios" +import { HttpAdapter, HttpClient, RequestBuilder, CapturedResponse } from "../types" +import { CaptureContext } from "../../core/CaptureContext" + +export interface AxiosAdapterConfig { + baseURL?: string + timeout?: number + headers?: Record +} + +export class AxiosAdapter implements HttpAdapter { + public create(config: AxiosAdapterConfig): HttpClient { + const axios = require("axios") + const instance = axios.create(config) + return new AxiosClient(instance) + } +} + +class AxiosClient implements HttpClient { + public constructor(private axios: AxiosInstance) {} + + public get(url: string) { + return new AxiosRequestBuilder(this.axios, "GET", url) + } + public post(url: string) { + return new AxiosRequestBuilder(this.axios, "POST", url) + } + public put(url: string) { + return new AxiosRequestBuilder(this.axios, "PUT", url) + } + public patch(url: string) { + return new AxiosRequestBuilder(this.axios, "PATCH", url) + } + public delete(url: string) { + return new AxiosRequestBuilder(this.axios, "DELETE", url) + } + public head(url: string) { + return new AxiosRequestBuilder(this.axios, "HEAD", url) + } + public options(url: string) { + return new AxiosRequestBuilder(this.axios, "OPTIONS", url) + } +} + +class AxiosRequestBuilder implements RequestBuilder { + private config: AxiosRequestConfig = {} + private formDataObj?: any + + public constructor( + private axios: AxiosInstance, + private method: string, + private url: string, + ) { + if (CaptureContext.isActive()) { + CaptureContext.addRequest({ method, url }) + } + } + + public send(body: any): this { + this.config.data = body + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ body }) + } + return this + } + + public set(field: string | Record, value?: string): this { + if (!this.config.headers) this.config.headers = {} + + if (typeof field === "string") { + this.config.headers[field] = value! + } else { + Object.assign(this.config.headers, field) + } + + if (CaptureContext.isActive()) { + const headers = typeof field === "string" ? { [field]: value! } : field + this.updateHeaders(headers) + } + return this + } + + public query(params: Record): this { + this.config.params = params + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ queryParams: params }) + } + return this + } + + public attach(field: string, file: string | Buffer, filename?: string): this { + if (!this.formDataObj) { + const FormData = require("form-data") + this.formDataObj = new FormData() + } + + if (typeof file === "string") { + const fs = require("fs") + this.formDataObj.append(field, fs.createReadStream(file), filename || file) + } else { + this.formDataObj.append(field, file, filename || "file") + } + + this.config.data = this.formDataObj + + if (!this.config.headers) this.config.headers = {} + Object.assign(this.config.headers, this.formDataObj.getHeaders()) + + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.files.push({ + field, + filename: filename || (typeof file === "string" ? file : "file"), + }) + } + } + return this + } + + public field(name: string, value: string | number): this { + if (!this.formDataObj) { + const FormData = require("form-data") + this.formDataObj = new FormData() + } + this.formDataObj.append(name, String(value)) + this.config.data = this.formDataObj + + if (!this.config.headers) this.config.headers = {} + Object.assign(this.config.headers, this.formDataObj.getHeaders()) + + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.fields[name] = value + } + } + return this + } + + public auth(username: string, password: string): this { + this.config.auth = { username, password } + return this + } + + public bearer(token: string): this { + return this.set("Authorization", `Bearer ${token}`) + } + + public timeout(ms: number): this { + this.config.timeout = ms + return this + } + + public expect(_statusOrField: number | string, _value?: string | RegExp): this { + return this + } + + public async then( + onFulfilled?: (response: CapturedResponse) => T, + onRejected?: (error: any) => any, + ): Promise { + try { + const res = await this.axios.request({ + method: this.method, + url: this.url, + ...this.config, + }) + + const response: CapturedResponse = { + status: res.status, + statusText: res.statusText, + headers: res.headers as any, + body: res.data, + text: typeof res.data === "string" ? res.data : JSON.stringify(res.data), + } + + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ response }) + } + + return onFulfilled ? onFulfilled(response) : (response as any) + } catch (error: any) { + if (error.response && CaptureContext.isActive()) { + const response: CapturedResponse = { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + body: error.response.data, + } + CaptureContext.updateLastRequest({ response }) + } + + if (onRejected) return onRejected(error) + throw error + } + } + + public async end(): Promise { + return this.then() + } + + private updateHeaders(headers: Record): void { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + if (lastReq) { + lastReq.headers = { ...lastReq.headers, ...headers } + } + } +} diff --git a/lib/wrappers/adapters/fetch/FetchAdapter.ts b/lib/wrappers/adapters/fetch/FetchAdapter.ts new file mode 100644 index 0000000..c6c1770 --- /dev/null +++ b/lib/wrappers/adapters/fetch/FetchAdapter.ts @@ -0,0 +1,238 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HttpAdapter, HttpClient, RequestBuilder, CapturedResponse } from "../types" +import { CaptureContext } from "../../core/CaptureContext" + +export interface FetchAdapterConfig { + baseURL: string + headers?: Record +} + +export class FetchAdapter implements HttpAdapter { + public create(config: FetchAdapterConfig): HttpClient { + return new FetchClient(config) + } +} + +class FetchClient implements HttpClient { + public constructor(private config: FetchAdapterConfig) {} + + public get(url: string) { + return new FetchRequestBuilder(this.config, "GET", url) + } + public post(url: string) { + return new FetchRequestBuilder(this.config, "POST", url) + } + public put(url: string) { + return new FetchRequestBuilder(this.config, "PUT", url) + } + public patch(url: string) { + return new FetchRequestBuilder(this.config, "PATCH", url) + } + public delete(url: string) { + return new FetchRequestBuilder(this.config, "DELETE", url) + } + public head(url: string) { + return new FetchRequestBuilder(this.config, "HEAD", url) + } + public options(url: string) { + return new FetchRequestBuilder(this.config, "OPTIONS", url) + } +} + +class FetchRequestBuilder implements RequestBuilder { + private init: RequestInit = {} + private headers: Record = {} + private queryParams: Record = {} + private formDataObj?: any + + public constructor( + private config: FetchAdapterConfig, + method: string, + private url: string, + ) { + this.init.method = method + if (config.headers) { + this.headers = { ...config.headers } + } + + if (CaptureContext.isActive()) { + CaptureContext.addRequest({ method, url }) + } + } + + public send(body: any): this { + this.init.body = JSON.stringify(body) + this.headers["Content-Type"] = "application/json" + + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ body }) + } + return this + } + + public set(field: string | Record, value?: string): this { + if (typeof field === "string") { + this.headers[field] = value! + } else { + Object.assign(this.headers, field) + } + + if (CaptureContext.isActive()) { + const headers = typeof field === "string" ? { [field]: value! } : field + this.updateHeaders(headers) + } + return this + } + + public query(params: Record): this { + Object.assign(this.queryParams, params) + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ queryParams: params }) + } + return this + } + + public attach(field: string, file: string | Buffer, filename?: string): this { + if (!this.formDataObj) { + const FormData = require("form-data") + this.formDataObj = new FormData() + } + + if (typeof file === "string") { + const fs = require("fs") + this.formDataObj.append(field, fs.createReadStream(file), filename || file) + } else { + this.formDataObj.append(field, file, filename || "file") + } + + this.init.body = this.formDataObj as any + Object.assign(this.headers, this.formDataObj.getHeaders()) + + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.files.push({ + field, + filename: filename || (typeof file === "string" ? file : "file"), + }) + } + } + return this + } + + public field(name: string, value: string | number): this { + if (!this.formDataObj) { + const FormData = require("form-data") + this.formDataObj = new FormData() + } + this.formDataObj.append(name, String(value)) + this.init.body = this.formDataObj as any + Object.assign(this.headers, this.formDataObj.getHeaders()) + + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.fields[name] = value + } + } + return this + } + + public auth(username: string, password: string): this { + const token = Buffer.from(`${username}:${password}`).toString("base64") + return this.set("Authorization", `Basic ${token}`) + } + + public bearer(token: string): this { + return this.set("Authorization", `Bearer ${token}`) + } + + public timeout(_ms: number): this { + return this + } + + public expect(_statusOrField: number | string, _value?: string | RegExp): this { + return this + } + + public async then( + onFulfilled?: (response: CapturedResponse) => T, + onRejected?: (error: any) => any, + ): Promise { + try { + let fullURL = `${this.config.baseURL}${this.url}` + if (Object.keys(this.queryParams).length > 0) { + const queryString = new URLSearchParams(this.queryParams).toString() + fullURL += `?${queryString}` + } + + this.init.headers = this.headers + + const res = await fetch(fullURL, this.init) + const text = await res.text() + let body: any + try { + body = JSON.parse(text) + } catch { + body = text + } + + const response: CapturedResponse = { + status: res.status, + statusText: res.statusText, + headers: Object.fromEntries(res.headers.entries()), + body, + text, + } + + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ response }) + } + + return onFulfilled ? onFulfilled(response) : (response as any) + } catch (error) { + if (onRejected) return onRejected(error) + throw error + } + } + + public async end(): Promise { + return this.then() + } + + private updateHeaders(headers: Record) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + if (lastReq) { + lastReq.headers = { ...lastReq.headers, ...headers } + } + } +} diff --git a/lib/wrappers/adapters/index.ts b/lib/wrappers/adapters/index.ts new file mode 100644 index 0000000..099c3d1 --- /dev/null +++ b/lib/wrappers/adapters/index.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SupertestAdapter } from "./supertest/SupertestAdapter" +import { AxiosAdapter, AxiosAdapterConfig } from "./axios/AxiosAdapter" +import { FetchAdapter, FetchAdapterConfig } from "./fetch/FetchAdapter" +import { HttpClient } from "./types" + +/** + * Factory for creating HTTP clients with different adapters + * + * This allows you to use different HTTP clients (supertest, axios, fetch) + * while maintaining the same API and automatic request/response capture. + * @example Supertest + * ```typescript + * const client = createClient.supertest(app) + * await client.post('/api/users').send({ name: 'John' }) + * ``` + * @example Axios + * ```typescript + * const client = createClient.axios({ baseURL: 'http://localhost:3000' }) + * await client.post('/api/users').send({ name: 'John' }) + * ``` + * @example Fetch + * ```typescript + * const client = createClient.fetch({ baseURL: 'http://localhost:3000' }) + * await client.post('/api/users').send({ name: 'John' }) + * ``` + */ +export const createClient = { + /** + * Create a client using supertest (for Express/Fastify/NestJS apps) + * @param app - Express/Fastify app instance + * @returns HTTP client with automatic capture + */ + supertest(app: any): HttpClient { + return new SupertestAdapter().create(app) + }, + + /** + * Create a client using axios + * @param config - Axios configuration (baseURL, headers, etc.) + * @returns HTTP client with automatic capture + */ + axios(config: AxiosAdapterConfig): HttpClient { + return new AxiosAdapter().create(config) + }, + + /** + * Create a client using fetch API + * @param config - Fetch configuration (baseURL, headers) + * @returns HTTP client with automatic capture + */ + fetch(config: FetchAdapterConfig): HttpClient { + return new FetchAdapter().create(config) + }, +} + +/** + * Backward compatibility: keep `request` as alias to `createClient.supertest` + * @param app + * @deprecated Use createClient.supertest() instead + */ +export function request(app: any): HttpClient { + return createClient.supertest(app) +} + +// Export types +export type * from "./types" +export { SupertestAdapter } from "./supertest/SupertestAdapter" +export { AxiosAdapter } from "./axios/AxiosAdapter" +export type { AxiosAdapterConfig } from "./axios/AxiosAdapter" +export { FetchAdapter } from "./fetch/FetchAdapter" +export type { FetchAdapterConfig } from "./fetch/FetchAdapter" diff --git a/lib/wrappers/adapters/supertest/SupertestAdapter.ts b/lib/wrappers/adapters/supertest/SupertestAdapter.ts new file mode 100644 index 0000000..d48bcb1 --- /dev/null +++ b/lib/wrappers/adapters/supertest/SupertestAdapter.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import supertest from "supertest" +import { HttpAdapter, HttpClient, RequestBuilder, CapturedResponse } from "../types" +import { CaptureContext } from "../../core/CaptureContext" +import type { CapturedRequest } from "../../types" + +export class SupertestAdapter implements HttpAdapter { + public create(app: any): HttpClient { + return new SupertestClient(supertest(app) as any) + } +} + +class SupertestClient implements HttpClient { + public constructor(private agent: any) {} + + public get(url: string) { + return new SupertestRequestBuilder(this.agent.get(url), "GET", url) + } + public post(url: string) { + return new SupertestRequestBuilder(this.agent.post(url), "POST", url) + } + public put(url: string) { + return new SupertestRequestBuilder(this.agent.put(url), "PUT", url) + } + public patch(url: string) { + return new SupertestRequestBuilder(this.agent.patch(url), "PATCH", url) + } + public delete(url: string) { + return new SupertestRequestBuilder(this.agent.delete(url), "DELETE", url) + } + public head(url: string) { + return new SupertestRequestBuilder(this.agent.head(url), "HEAD", url) + } + public options(url: string) { + return new SupertestRequestBuilder(this.agent.options(url), "OPTIONS", url) + } +} + +class SupertestRequestBuilder implements RequestBuilder { + private capturedData: Partial = {} + + public constructor( + private test: any, + method: string, + url: string, + ) { + if (CaptureContext.isActive()) { + this.capturedData = { method, url } + CaptureContext.addRequest(this.capturedData as any) + } + } + + public send(body: any): this { + this.test.send(body) + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ body }) + } + return this + } + + public set(field: string | Record, value?: string): this { + if (typeof field === "string") { + this.test.set(field, value!) + if (CaptureContext.isActive()) { + const headers = { [field]: value! } + this.updateHeaders(headers) + } + } else { + this.test.set(field) + if (CaptureContext.isActive()) { + this.updateHeaders(field) + } + } + return this + } + + public query(params: Record): this { + this.test.query(params) + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ queryParams: params }) + } + return this + } + + public attach(field: string, file: string | Buffer, filename?: string): this { + this.test.attach(field, file, filename) + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.files.push({ + field, + filename: filename || (typeof file === "string" ? file : "file"), + }) + } + } + return this + } + + public field(name: string, value: string | number): this { + this.test.field(name, value) + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.fields[name] = value + } + } + return this + } + + public auth(username: string, password: string): this { + this.test.auth(username, password) + return this + } + + public bearer(token: string): this { + return this.set("Authorization", `Bearer ${token}`) + } + + public timeout(ms: number): this { + this.test.timeout(ms) + return this + } + + public expect(statusOrField: number | string, value?: string | RegExp): this { + if (typeof statusOrField === "number") { + this.test.expect(statusOrField) + } else if (value !== undefined) { + this.test.expect(statusOrField, value) + } + return this + } + + public then( + onFulfilled?: (response: CapturedResponse) => T, + onRejected?: (error: any) => any, + ): Promise { + return this.test.then( + (res: any) => { + const response: CapturedResponse = { + status: res.status, + statusText: res.statusType || `${res.status}`, + headers: res.headers, + body: res.body, + text: res.text, + } + + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ response }) + } + + return onFulfilled ? onFulfilled(response) : (response as any) + }, + (error: any) => { + if (onRejected) return onRejected(error) + throw error + }, + ) + } + + public async end(): Promise { + return this.then() + } + + private updateHeaders(headers: Record) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + if (lastReq) { + lastReq.headers = { ...lastReq.headers, ...headers } + } + } +} diff --git a/lib/wrappers/adapters/types.ts b/lib/wrappers/adapters/types.ts new file mode 100644 index 0000000..8d9e510 --- /dev/null +++ b/lib/wrappers/adapters/types.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CapturedResponse, CapturedRequest } from "../types" +export type { CapturedResponse, CapturedRequest } + +/** + * Builder interface for constructing HTTP requests + * All adapters must implement this interface + */ +export interface RequestBuilder { + send(body: any): this + + set(field: string, value: string): this + set(fields: Record): this + + query(params: Record): this + + attach(field: string, file: string | Buffer, filename?: string): this + field(name: string, value: string | number): this + + auth(username: string, password: string): this + bearer(token: string): this + + timeout(ms: number): this + + expect(status: number): this + expect(field: string, value: string | RegExp): this + + then( + onFulfilled?: (response: CapturedResponse) => T, + onRejected?: (error: any) => any, + ): Promise + + end(): Promise +} + +/** + * Main HTTP client interface + * All HTTP method calls return RequestBuilder + */ +export interface HttpClient { + get(url: string): RequestBuilder + post(url: string): RequestBuilder + put(url: string): RequestBuilder + patch(url: string): RequestBuilder + delete(url: string): RequestBuilder + head(url: string): RequestBuilder + options(url: string): RequestBuilder +} + +/** + * Adapter interface for creating HTTP clients + */ +export interface HttpAdapter { + /** + * Create a new HTTP client instance + * @param config - Configuration (app instance, base URL, etc.) + */ + create(config: TConfig): HttpClient +} diff --git a/lib/wrappers/core/interceptedRequest.ts b/lib/wrappers/core/interceptedRequest.ts deleted file mode 100644 index b4eb21a..0000000 --- a/lib/wrappers/core/interceptedRequest.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import originalRequest from "supertest" -import { CaptureContext } from "./CaptureContext" - -/** - * Create an intercepted request function that captures HTTP requests and responses - * When CaptureContext is active, this function wraps supertest to automatically - * capture all request/response data for documentation generation - * - * @param app Express/Fastify/NestJS app instance - * @returns Supertest Test instance (possibly wrapped in Proxy) - */ -export function request(app: any) { - // If context is not active, return original supertest - if (!CaptureContext.isActive()) { - return originalRequest(app) - } - - // Create supertest instance and wrap it with Proxy - const testInstance = originalRequest(app) - return createInterceptedTest(testInstance) -} - -/** - * Create a Proxy wrapper for the initial supertest Test object - * This intercepts HTTP method calls (get, post, put, etc.) - */ -function createInterceptedTest(testObj: any) { - return new Proxy(testObj, { - get(target, prop: string) { - const original = target[prop] - - // Intercept HTTP method calls - const httpMethods = ["get", "post", "put", "patch", "delete", "head", "options"] - - if (httpMethods.includes(prop)) { - return (url: string) => { - // Start new request capture - CaptureContext.addRequest({ - method: prop.toUpperCase(), - url, - }) - - // Continue with request chain proxy - return createRequestChainProxy(target[prop](url)) - } - } - - return original - }, - }) -} - -/** - * Create a Proxy wrapper for the supertest request chain - * This intercepts method calls like .send(), .set(), .query(), etc. - * and the final .then() to capture the response - */ -function createRequestChainProxy(chainObj: any): any { - return new Proxy(chainObj, { - get(target, prop: string) { - const original = target[prop] - - // Capture request body - if (prop === "send") { - return (body: any) => { - CaptureContext.updateLastRequest({ body }) - return createRequestChainProxy(target.send(body)) - } - } - - // Capture request headers - if (prop === "set") { - return (field: string | Record, val?: string) => { - const headers = typeof field === "string" ? { [field]: val as string } : field - - const store = CaptureContext.getStore() - const requests = store?.capturedRequests || [] - const lastReq = requests[requests.length - 1] - - if (lastReq) { - lastReq.headers = { ...lastReq.headers, ...headers } - } - - return createRequestChainProxy(target.set(field, val)) - } - } - - // Capture query parameters - if (prop === "query") { - return (params: any) => { - CaptureContext.updateLastRequest({ queryParams: params }) - return createRequestChainProxy(target.query(params)) - } - } - - // Capture response when promise resolves - if (prop === "then") { - return (onFulfilled?: any, onRejected?: any) => { - return target.then((res: any) => { - // Capture response data - CaptureContext.updateLastRequest({ - response: { - status: res.status, - body: res.body, - headers: res.headers, - }, - }) - - return onFulfilled?.(res) - }, onRejected) - } - } - - // For other methods, maintain the chain - if (typeof original === "function") { - return (...args: any[]) => { - const result = original.apply(target, args) - // If result is the Test object itself, keep proxying - return result === target || result?.constructor?.name === "Test" - ? createRequestChainProxy(result) - : result - } - } - - return original - }, - }) -} diff --git a/lib/wrappers/index.ts b/lib/wrappers/index.ts index 9f11e6c..8cf5318 100644 --- a/lib/wrappers/index.ts +++ b/lib/wrappers/index.ts @@ -19,16 +19,15 @@ * * This module provides a high-order function approach to automatically capture * HTTP requests and responses from your tests and generate API documentation. - * * @example Basic usage * ```typescript - * import { wrapTest, request } from 'itdoc/wrappers' + * import { wrapTest, createClient } from 'itdoc/wrappers' * * const apiTest = wrapTest(it) * * describe('User API', () => { * apiTest('should create user', async () => { - * const response = await request(app) + * const response = await createClient.supertest(app) * .post('/users') * .send({ name: 'John' }) * @@ -36,14 +35,13 @@ * }) * }) * ``` - * * @example With metadata * ```typescript * apiTest.withMeta({ * summary: 'Create User', * tags: ['Users', 'Registration'] * })('should create user', async () => { - * const response = await request(app) + * const response = await createClient.supertest(app) * .post('/users') * .send({ name: 'John' }) * @@ -53,5 +51,12 @@ */ export { wrapTest } from "./wrapTest" -export { request } from "./core/interceptedRequest" +export { createClient } from "./adapters" export type { ApiDocMetadata, WrappedTestFunction, TestFunction } from "./types" +export type { + HttpClient, + HttpAdapter, + RequestBuilder, + CapturedResponse, + CapturedRequest, +} from "./adapters/types" diff --git a/lib/wrappers/types.ts b/lib/wrappers/types.ts index 056f232..087620c 100644 --- a/lib/wrappers/types.ts +++ b/lib/wrappers/types.ts @@ -31,10 +31,18 @@ export interface ApiDocMetadata { export interface CapturedRequest { method?: string url?: string - body?: unknown + body?: any headers?: Record - queryParams?: Record - pathParams?: Record + queryParams?: Record + pathParams?: Record + formData?: { + fields: Record + files: Array<{ + field: string + filename: string + mimetype?: string + }> + } response?: CapturedResponse } @@ -43,8 +51,10 @@ export interface CapturedRequest { */ export interface CapturedResponse { status: number - body?: unknown - headers?: Record + statusText?: string + body?: any + headers?: Record + text?: string } /** diff --git a/package.json b/package.json index 1b7f7ba..c68d681 100644 --- a/package.json +++ b/package.json @@ -91,25 +91,28 @@ "fast-glob": "^3.3.3", "lodash": "^4.17.21", "openai": "^4.90.0", + "sinon": "^20.0.0", "supertest": "^7.0.0", - "widdershins": "^4.0.1", - "sinon": "^20.0.0" + "widdershins": "^4.0.1" }, "devDependencies": { "@eslint/js": "~9.17", "@types/chai": "^5.0.1", "@types/eslint__js": "~8.42", + "@types/lodash": "^4.17.20", "@types/mocha": "^10.0.10", "@types/node": "~20", "@types/sinon": "^17.0.2", "@types/supertest": "^6.0.2", "@typescript-eslint/parser": "~8.19", + "axios": "^1.12.2", "chai": "^5.2.0", "eslint": "~9.17", "eslint-config-prettier": "~9.1", "eslint-plugin-jsdoc": "^50.6.0", "eslint-plugin-license-header": "^0.8.0", "eslint-plugin-mocha": "^10.5.0", + "form-data": "^4.0.4", "globals": "~15.14", "husky": "^8.0.0", "lint-staged": "^15.4.3", @@ -124,9 +127,19 @@ "typescript-eslint": "~8.19" }, "peerDependencies": { + "axios": "^1.0.0", + "form-data": "^4.0.0", "jest": "^29.0.0", "mocha": "^11.0.0" }, + "peerDependenciesMeta": { + "axios": { + "optional": true + }, + "form-data": { + "optional": true + } + }, "packageManager": "pnpm@10.15.0", "engines": { "node": ">=20" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 348caa9..064d3c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@types/eslint__js': specifier: ~8.42 version: 8.42.3 + '@types/lodash': + specifier: ^4.17.20 + version: 4.17.20 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -81,6 +84,9 @@ importers: '@typescript-eslint/parser': specifier: ~8.19 version: 8.19.1(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.7.3) + axios: + specifier: ^1.12.2 + version: 1.12.2 chai: specifier: ^5.2.0 version: 5.2.0 @@ -99,6 +105,9 @@ importers: eslint-plugin-mocha: specifier: ^10.5.0 version: 10.5.0(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0)) + form-data: + specifier: ^4.0.4 + version: 4.0.4 globals: specifier: ~15.14 version: 15.14.0 @@ -3165,6 +3174,9 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3954,6 +3966,9 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -6106,10 +6121,6 @@ packages: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} - engines: {node: '>= 6'} - form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} @@ -9752,6 +9763,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} @@ -15499,7 +15513,7 @@ snapshots: colorette: 1.4.0 core-js: 3.41.0 dotenv: 16.4.7 - form-data: 4.0.2 + form-data: 4.0.4 get-port-please: 3.1.2 glob: 7.2.3 handlebars: 4.7.8 @@ -16025,6 +16039,8 @@ snapshots: dependencies: '@types/node': 20.17.24 + '@types/lodash@4.17.20': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -16046,7 +16062,7 @@ snapshots: '@types/node-fetch@2.6.12': dependencies: '@types/node': 20.17.24 - form-data: 4.0.2 + form-data: 4.0.4 '@types/node-forge@1.3.11': dependencies: @@ -16141,7 +16157,7 @@ snapshots: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 '@types/node': 20.17.24 - form-data: 4.0.2 + form-data: 4.0.4 '@types/supertest@6.0.2': dependencies: @@ -16916,6 +16932,14 @@ snapshots: aws4@1.13.2: {} + axios@1.12.2: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + b4a@1.6.7: {} babel-jest@29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0): @@ -17764,7 +17788,7 @@ snapshots: compressible@2.0.18: dependencies: - mime-db: 1.52.0 + mime-db: 1.54.0 compression@1.8.0(supports-color@10.0.0): dependencies: @@ -19318,7 +19342,7 @@ snapshots: ext-list@2.2.2: dependencies: - mime-db: 1.52.0 + mime-db: 1.54.0 ext-name@5.0.0: dependencies: @@ -19772,13 +19796,6 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 - form-data@4.0.2: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - mime-types: 2.1.35 - form-data@4.0.4: dependencies: asynckit: 0.4.0 @@ -24480,6 +24497,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + pseudomap@1.0.2: {} psl@1.15.0: @@ -26038,7 +26057,7 @@ snapshots: cookiejar: 2.1.4 debug: 4.4.0(supports-color@10.0.0) fast-safe-stringify: 2.1.1 - form-data: 4.0.2 + form-data: 4.0.4 formidable: 3.5.2 methods: 1.1.2 mime: 2.6.0 From 7f6535d1219a132a81b9fd85cf3d6b2ed89346bc Mon Sep 17 00:00:00 2001 From: jaesong-blip Date: Thu, 9 Oct 2025 09:23:38 +0900 Subject: [PATCH 11/14] fix: rollback --- examples/express-ts/jest.config.ts | 5 +- examples/express-ts/package.json | 3 +- .../__tests__/jest/product.wrapper.spec.ts | 212 --------- .../src/__tests__/jest/upload.wrapper.spec.ts | 110 ----- .../src/__tests__/jest/user.wrapper.spec.ts | 138 ------ .../__tests__/mocha/product.wrapper.test.ts | 214 --------- .../__tests__/mocha/upload.wrapper.test.ts | 121 ----- .../src/__tests__/mocha/user.wrapper.test.ts | 139 ------ .../src/__tests__/{mocha => }/product.test.ts | 2 +- .../src/__tests__/{mocha => }/user.test.ts | 2 +- examples/express-ts/src/index.ts | 2 - .../express-ts/src/routes/upload.routes.ts | 63 --- .../express-ts/src/services/productService.ts | 14 +- lib/wrappers/EXAMPLE.md | 433 ------------------ lib/wrappers/README.md | 308 ------------- 15 files changed, 6 insertions(+), 1760 deletions(-) delete mode 100644 examples/express-ts/src/__tests__/jest/product.wrapper.spec.ts delete mode 100644 examples/express-ts/src/__tests__/jest/upload.wrapper.spec.ts delete mode 100644 examples/express-ts/src/__tests__/jest/user.wrapper.spec.ts delete mode 100644 examples/express-ts/src/__tests__/mocha/product.wrapper.test.ts delete mode 100644 examples/express-ts/src/__tests__/mocha/upload.wrapper.test.ts delete mode 100644 examples/express-ts/src/__tests__/mocha/user.wrapper.test.ts rename examples/express-ts/src/__tests__/{mocha => }/product.test.ts (99%) rename examples/express-ts/src/__tests__/{mocha => }/user.test.ts (99%) delete mode 100644 examples/express-ts/src/routes/upload.routes.ts delete mode 100644 lib/wrappers/EXAMPLE.md delete mode 100644 lib/wrappers/README.md diff --git a/examples/express-ts/jest.config.ts b/examples/express-ts/jest.config.ts index 0128f89..f88ac49 100644 --- a/examples/express-ts/jest.config.ts +++ b/examples/express-ts/jest.config.ts @@ -3,15 +3,12 @@ import type { Config } from "jest" const jestConfig: Config = { testEnvironment: "node", roots: ["/src"], - testMatch: ["**/__tests__/jest/**/*.spec.ts"], + testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"], transform: { "^.+\\.ts$": [ "ts-jest", { tsconfig: "tsconfig.json", - diagnostics: { - ignoreCodes: [18046], - }, }, ], }, diff --git a/examples/express-ts/package.json b/examples/express-ts/package.json index f5bf20a..1f39839 100644 --- a/examples/express-ts/package.json +++ b/examples/express-ts/package.json @@ -8,7 +8,7 @@ "start": "node dist/index.js", "test": "pnpm run test:jest && pnpm run test:mocha", "test:jest": "jest", - "test:mocha": "mocha --require tsx \"src/__tests__/mocha/**/*.test.ts\"" + "test:mocha": "mocha --require tsx \"src/**/__tests__/**/*.test.ts\"" }, "dependencies": { "cors": "^2.8.5", @@ -20,7 +20,6 @@ "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/mocha": "^10.0.10", - "@types/multer": "^2.0.0", "@types/node": "^20.10.5", "@types/supertest": "^6.0.2", "itdoc": "workspace:*", diff --git a/examples/express-ts/src/__tests__/jest/product.wrapper.spec.ts b/examples/express-ts/src/__tests__/jest/product.wrapper.spec.ts deleted file mode 100644 index dd83505..0000000 --- a/examples/express-ts/src/__tests__/jest/product.wrapper.spec.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Product API Tests using wrapTest wrapper approach - * - * This demonstrates the new high-order function wrapping method - * that automatically captures HTTP requests/responses - */ - -import { app } from "../../index" -import { wrapTest, createClient } from "itdoc" -import { ProductService } from "../../services/productService" - -const apiTest = wrapTest(it) - -const request = createClient.supertest(app) - -describe("Product API - Wrapper Approach", () => { - beforeEach(() => { - ProductService.resetProducts() - }) - describe("GET /api/products/:id", () => { - apiTest.withMeta({ - summary: "Get product by ID", - tags: ["Products"], - description: "Retrieves a specific product by its ID", - })("should return a specific product", async () => { - const response = await request.get("/api/products/1") - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("id", 1) - expect(response.body).toHaveProperty("name", "Laptop") - expect(response.body).toHaveProperty("price", 999.99) - expect(response.body).toHaveProperty("category", "Electronics") - }) - - apiTest("should return product with different ID", async () => { - const response = await request.get("/api/products/2") - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("id", 2) - expect(response.body).toHaveProperty("name", "Smartphone") - }) - }) - - describe("POST /api/products", () => { - apiTest.withMeta({ - summary: "Create new product", - tags: ["Products", "Create"], - description: "Creates a new product with the provided information", - })("should create a new product", async () => { - const response = await request.post("/api/products").send({ - name: "Test Product", - price: 99.99, - category: "Test Category", - }) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty("id", 3) - expect(response.body).toHaveProperty("name", "Test Product") - expect(response.body).toHaveProperty("price", 99.99) - expect(response.body).toHaveProperty("category", "Test Category") - }) - - apiTest.withMeta({ - summary: "Create product with different data", - tags: ["Products", "Create"], - })("should create another product", async () => { - const response = await request.post("/api/products").send({ - name: "Another Product", - price: 199.99, - category: "Another Category", - }) - - expect(response.status).toBe(201) - expect(response.body.name).toBe("Another Product") - }) - }) - - describe("PUT /api/products/:id", () => { - apiTest.withMeta({ - summary: "Update product", - tags: ["Products", "Update"], - description: "Updates an existing product with the provided information", - })("should update a product", async () => { - const response = await request.put("/api/products/1").send({ - name: "Updated Product", - price: 199.99, - category: "Updated Category", - }) - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("id", 1) - expect(response.body).toHaveProperty("name", "Updated Product") - expect(response.body).toHaveProperty("price", 199.99) - expect(response.body).toHaveProperty("category", "Updated Category") - }) - - apiTest("should update product with partial data", async () => { - const response = await request.put("/api/products/2").send({ - name: "Partially Updated", - price: 299.99, - category: "Electronics", - }) - - expect(response.status).toBe(200) - expect(response.body.name).toBe("Partially Updated") - }) - }) - - describe("Complete product CRUD workflow", () => { - apiTest.withMeta({ - summary: "Product CRUD workflow", - tags: ["Products", "Workflow", "CRUD"], - description: "Complete create, read, update, delete workflow for products", - })("should perform complete CRUD operations", async () => { - const createResponse = await request.post("/api/products").send({ - name: "Workflow Product", - price: 149.99, - category: "Test", - }) - - expect(createResponse.status).toBe(201) - const productId = createResponse.body.id - - const getResponse = await request.get(`/api/products/${productId}`) - - expect(getResponse.status).toBe(200) - expect(getResponse.body.name).toBe("Workflow Product") - - const updateResponse = await request.put(`/api/products/${productId}`).send({ - name: "Updated Workflow Product", - price: 179.99, - category: "Updated Test", - }) - - expect(updateResponse.status).toBe(200) - expect(updateResponse.body.name).toBe("Updated Workflow Product") - - const deleteResponse = await request.delete(`/api/products/${productId}`) - - expect(deleteResponse.status).toBe(204) - }) - }) - - describe("Product filtering and search", () => { - apiTest.withMeta({ - summary: "Filter products by category", - tags: ["Products", "Filter"], - })("should filter products with query params", async () => { - const response = await request - .get("/api/products/1") - .query({ category: "Electronics", minPrice: 500 }) - - expect(response.status).toBe(200) - }) - - apiTest("should search products with multiple params", async () => { - const response = await request.get("/api/products/1").query({ - search: "laptop", - sortBy: "price", - order: "asc", - }) - - expect(response.status).toBe(200) - }) - }) - - describe("Product API with authentication", () => { - apiTest.withMeta({ - summary: "Create product with auth", - tags: ["Products", "Authentication"], - })("should create product with authorization header", async () => { - const response = await request - .post("/api/products") - .set("Authorization", "Bearer fake-token-123") - .send({ - name: "Authenticated Product", - price: 299.99, - category: "Secure", - }) - - expect(response.status).toBe(201) - }) - - apiTest("should include custom headers", async () => { - const response = await request - .get("/api/products/1") - .set("Authorization", "Bearer token") - .set("X-Client-ID", "test-client") - .set("Accept", "application/json") - - expect(response.status).toBe(200) - }) - }) - - describe("DELETE /api/products/:id", () => { - apiTest.withMeta({ - summary: "Delete product", - tags: ["Products", "Delete"], - description: "Deletes a product by its ID", - })("should delete a product", async () => { - const response = await request.delete("/api/products/1") - - expect(response.status).toBe(204) - }) - - apiTest("should delete another product", async () => { - const response = await request.delete("/api/products/2") - - expect(response.status).toBe(204) - }) - }) -}) diff --git a/examples/express-ts/src/__tests__/jest/upload.wrapper.spec.ts b/examples/express-ts/src/__tests__/jest/upload.wrapper.spec.ts deleted file mode 100644 index c9e63b5..0000000 --- a/examples/express-ts/src/__tests__/jest/upload.wrapper.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { app } from "../../index" -import { wrapTest, createClient } from "itdoc" -import path from "path" -import fs from "fs" - -const apiTest = wrapTest(it) - -const request = createClient.supertest(app) - -describe("Upload API - Wrapper Approach", () => { - const testFilePath = path.join(__dirname, "test-file.txt") - const testImagePath = path.join(__dirname, "test-image.png") - - beforeAll(() => { - fs.writeFileSync(testFilePath, "This is a test file content") - - const pngBuffer = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, - 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, - 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, - 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, - 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ]) - fs.writeFileSync(testImagePath, pngBuffer) - }) - - afterAll(() => { - if (fs.existsSync(testFilePath)) { - fs.unlinkSync(testFilePath) - } - if (fs.existsSync(testImagePath)) { - fs.unlinkSync(testImagePath) - } - }) - - apiTest.withMeta({ - summary: "Upload a single file", - tags: ["Upload"], - description: "Uploads a single file using multipart/form-data", - })("should upload a single file successfully", async () => { - const response = await request.post("/api/upload/single").attach("file", testFilePath) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty("message", "File uploaded successfully") - expect(response.body.file).toHaveProperty("originalname", "test-file.txt") - expect(response.body.file).toHaveProperty("mimetype", "text/plain") - }) - - apiTest.withMeta({ - summary: "Upload multiple files", - tags: ["Upload"], - description: "Uploads multiple files in a single request", - })("should upload multiple files successfully", async () => { - const response = await request - .post("/api/upload/multiple") - .attach("files", testFilePath) - .attach("files", testImagePath) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty("message", "Files uploaded successfully") - expect(response.body.files).toHaveLength(2) - expect(response.body.files[0]).toHaveProperty("originalname", "test-file.txt") - expect(response.body.files[1]).toHaveProperty("originalname", "test-image.png") - }) - - apiTest.withMeta({ - summary: "Upload file with additional fields", - tags: ["Upload", "Documents"], - description: "Uploads a file along with additional form fields (title, description)", - })("should upload file with additional form fields", async () => { - const response = await request - .post("/api/upload/with-fields") - .field("title", "Important Document") - .field("description", "This is a very important document") - .attach("document", testFilePath) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty("message", "Document uploaded successfully") - expect(response.body.document).toHaveProperty("title", "Important Document") - expect(response.body.document).toHaveProperty( - "description", - "This is a very important document", - ) - expect(response.body.document.file).toHaveProperty("originalname", "test-file.txt") - }) - - apiTest.withMeta({ - summary: "Handle missing file upload", - tags: ["Upload", "Error Handling"], - description: "Returns 400 error when no file is provided", - })("should return 400 when no file is uploaded", async () => { - const response = await request.post("/api/upload/single").send({}) - - expect(response.status).toBe(400) - expect(response.body).toHaveProperty("error", "No file uploaded") - }) - - apiTest.withMeta({ - summary: "Upload image file", - tags: ["Upload", "Images"], - description: "Uploads an image file (PNG)", - })("should upload an image file successfully", async () => { - const response = await request.post("/api/upload/single").attach("file", testImagePath) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty("message", "File uploaded successfully") - expect(response.body.file).toHaveProperty("originalname", "test-image.png") - expect(response.body.file).toHaveProperty("mimetype", "image/png") - }) -}) diff --git a/examples/express-ts/src/__tests__/jest/user.wrapper.spec.ts b/examples/express-ts/src/__tests__/jest/user.wrapper.spec.ts deleted file mode 100644 index 45182b6..0000000 --- a/examples/express-ts/src/__tests__/jest/user.wrapper.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * User API Tests using wrapTest wrapper approach - * - * This demonstrates the new high-order function wrapping method - * that automatically captures HTTP requests/responses - */ - -import { app } from "../../index" -import { wrapTest, createClient } from "itdoc" - -const apiTest = wrapTest(it) - -const request = createClient.supertest(app) - -describe("User API - Wrapper Approach", () => { - describe("POST /api/user/register", () => { - apiTest.withMeta({ - summary: "Register new user", - tags: ["Users", "Authentication"], - description: "Registers a new user with username and password", - })("should register a new user successfully", async () => { - const response = await request.post("/api/user/register").send({ - username: "testuser", - password: "testpassword", - }) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty("message", "User registered successfully") - expect(response.body.user).toHaveProperty("username", "testuser") - }) - - apiTest.withMeta({ - summary: "Register user - missing username", - tags: ["Users", "Authentication", "Validation"], - })("should return error when username is missing", async () => { - const response = await request.post("/api/user/register").send({ - password: "testpassword", - }) - - expect(response.status).toBe(400) - expect(response.body).toHaveProperty("message", "Username and password are required.") - }) - - apiTest.withMeta({ - summary: "Register user - missing password", - tags: ["Users", "Authentication", "Validation"], - })("should return error when password is missing", async () => { - const response = await request.post("/api/user/register").send({ - username: "testuser", - }) - - expect(response.status).toBe(400) - expect(response.body).toHaveProperty("message", "Username and password are required.") - }) - }) - - describe("POST /api/user/login", () => { - apiTest.withMeta({ - summary: "User login", - tags: ["Users", "Authentication"], - description: "Authenticates a user with username and password", - })("should login successfully with valid credentials", async () => { - const response = await request.post("/api/user/login").send({ - username: "admin", - password: "admin", - }) - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("message", "Login successful") - expect(response.body).toHaveProperty("token", "fake-jwt-token") - }) - - apiTest.withMeta({ - summary: "User login - invalid credentials", - tags: ["Users", "Authentication", "Error"], - })("should return error with invalid credentials", async () => { - const response = await request.post("/api/user/login").send({ - username: "wronguser", - password: "wrongpassword", - }) - - expect(response.status).toBe(401) - expect(response.body).toHaveProperty("message", "Invalid credentials") - }) - }) - - describe("GET /api/user/:id", () => { - apiTest.withMeta({ - summary: "Get user by ID", - tags: ["Users"], - description: "Retrieves a specific user by their ID", - })("should return user information", async () => { - const response = await request.get("/api/user/123") - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("id", "123") - expect(response.body).toHaveProperty("username", "exampleUser") - expect(response.body).toHaveProperty("email", "user@example.com") - expect(response.body).toHaveProperty("profilePicture", null) - }) - - apiTest("should handle different user IDs", async () => { - const response = await request.get("/api/user/456") - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("id", "456") - }) - }) - - describe("Complete user workflow", () => { - apiTest.withMeta({ - summary: "User registration and login flow", - tags: ["Users", "Workflow"], - description: "Complete user registration and authentication workflow", - })("should register and login successfully", async () => { - const registerResponse = await request.post("/api/user/register").send({ - username: "newuser", - password: "newpassword", - }) - - expect(registerResponse.status).toBe(201) - expect(registerResponse.body.user.username).toBe("newuser") - - const loginResponse = await request.post("/api/user/login").send({ - username: "admin", - password: "admin", - }) - - expect(loginResponse.status).toBe(200) - expect(loginResponse.body).toHaveProperty("token") - - const userResponse = await request.get("/api/user/123") - - expect(userResponse.status).toBe(200) - expect(userResponse.body).toHaveProperty("username") - }) - }) -}) diff --git a/examples/express-ts/src/__tests__/mocha/product.wrapper.test.ts b/examples/express-ts/src/__tests__/mocha/product.wrapper.test.ts deleted file mode 100644 index b804e58..0000000 --- a/examples/express-ts/src/__tests__/mocha/product.wrapper.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Product API Tests using wrapTest wrapper approach (Mocha version) - * - * This demonstrates the new high-order function wrapping method - * that automatically captures HTTP requests/responses - */ - -import { app } from "../../index" -import { wrapTest, createClient } from "itdoc" -import { ProductService } from "../../services/productService" -import { expect } from "chai" - -const apiTest = wrapTest(it) - -const request = createClient.supertest(app) - -describe("Product API - Wrapper Approach (Mocha)", () => { - beforeEach(() => { - ProductService.resetProducts() - }) - - describe("GET /api/products/:id", () => { - apiTest.withMeta({ - summary: "Get product by ID", - tags: ["Products"], - description: "Retrieves a specific product by its ID", - })("should return a specific product", async () => { - const response = await request.get("/api/products/1") - - expect(response.status).to.equal(200) - expect(response.body).to.have.property("id", 1) - expect(response.body).to.have.property("name", "Laptop") - expect(response.body).to.have.property("price", 999.99) - expect(response.body).to.have.property("category", "Electronics") - }) - - apiTest("should return product with different ID", async () => { - const response = await request.get("/api/products/2") - - expect(response.status).to.equal(200) - expect(response.body).to.have.property("id", 2) - expect(response.body).to.have.property("name", "Smartphone") - }) - }) - - describe("POST /api/products", () => { - apiTest.withMeta({ - summary: "Create new product", - tags: ["Products", "Create"], - description: "Creates a new product with the provided information", - })("should create a new product", async () => { - const response = await request.post("/api/products").send({ - name: "Test Product", - price: 99.99, - category: "Test Category", - }) - - expect(response.status).to.equal(201) - expect(response.body).to.have.property("id", 3) - expect(response.body).to.have.property("name", "Test Product") - expect(response.body).to.have.property("price", 99.99) - expect(response.body).to.have.property("category", "Test Category") - }) - - apiTest.withMeta({ - summary: "Create product with different data", - tags: ["Products", "Create"], - })("should create another product", async () => { - const response = await request.post("/api/products").send({ - name: "Another Product", - price: 199.99, - category: "Another Category", - }) - - expect(response.status).to.equal(201) - expect((response.body as any).name).to.equal("Another Product") - }) - }) - - describe("PUT /api/products/:id", () => { - apiTest.withMeta({ - summary: "Update product", - tags: ["Products", "Update"], - description: "Updates an existing product with the provided information", - })("should update a product", async () => { - const response = await request.put("/api/products/1").send({ - name: "Updated Product", - price: 199.99, - category: "Updated Category", - }) - - expect(response.status).to.equal(200) - expect(response.body).to.have.property("id", 1) - expect(response.body).to.have.property("name", "Updated Product") - expect(response.body).to.have.property("price", 199.99) - expect(response.body).to.have.property("category", "Updated Category") - }) - - apiTest("should update product with partial data", async () => { - const response = await request.put("/api/products/2").send({ - name: "Partially Updated", - price: 299.99, - category: "Electronics", - }) - - expect(response.status).to.equal(200) - expect((response.body as any).name).to.equal("Partially Updated") - }) - }) - - describe("Complete product CRUD workflow", () => { - apiTest.withMeta({ - summary: "Product CRUD workflow", - tags: ["Products", "Workflow", "CRUD"], - description: "Complete create, read, update, delete workflow for products", - })("should perform complete CRUD operations", async () => { - const createResponse = await request.post("/api/products").send({ - name: "Workflow Product", - price: 149.99, - category: "Test", - }) - - expect(createResponse.status).to.equal(201) - const productId = (createResponse.body as any).id - - const getResponse = await request.get(`/api/products/${productId}`) - - expect(getResponse.status).to.equal(200) - expect((getResponse.body as any).name).to.equal("Workflow Product") - - const updateResponse = await request.put(`/api/products/${productId}`).send({ - name: "Updated Workflow Product", - price: 179.99, - category: "Updated Test", - }) - - expect(updateResponse.status).to.equal(200) - expect((updateResponse.body as any).name).to.equal("Updated Workflow Product") - - const deleteResponse = await request.delete(`/api/products/${productId}`) - - expect(deleteResponse.status).to.equal(204) - }) - }) - - describe("Product filtering and search", () => { - apiTest.withMeta({ - summary: "Filter products by category", - tags: ["Products", "Filter"], - })("should filter products with query params", async () => { - const response = await request - .get("/api/products/1") - .query({ category: "Electronics", minPrice: 500 }) - - expect(response.status).to.equal(200) - }) - - apiTest("should search products with multiple params", async () => { - const response = await request.get("/api/products/1").query({ - search: "laptop", - sortBy: "price", - order: "asc", - }) - - expect(response.status).to.equal(200) - }) - }) - - describe("Product API with authentication", () => { - apiTest.withMeta({ - summary: "Create product with auth", - tags: ["Products", "Authentication"], - })("should create product with authorization header", async () => { - const response = await request - .post("/api/products") - .set("Authorization", "Bearer fake-token-123") - .send({ - name: "Authenticated Product", - price: 299.99, - category: "Secure", - }) - - expect(response.status).to.equal(201) - }) - - apiTest("should include custom headers", async () => { - const response = await request - .get("/api/products/1") - .set("Authorization", "Bearer token") - .set("X-Client-ID", "test-client") - .set("Accept", "application/json") - - expect(response.status).to.equal(200) - }) - }) - - describe("DELETE /api/products/:id", () => { - apiTest.withMeta({ - summary: "Delete product", - tags: ["Products", "Delete"], - description: "Deletes a product by its ID", - })("should delete a product", async () => { - const response = await request.delete("/api/products/1") - - expect(response.status).to.equal(204) - }) - - apiTest("should delete another product", async () => { - const response = await request.delete("/api/products/2") - - expect(response.status).to.equal(204) - }) - }) -}) diff --git a/examples/express-ts/src/__tests__/mocha/upload.wrapper.test.ts b/examples/express-ts/src/__tests__/mocha/upload.wrapper.test.ts deleted file mode 100644 index 5979d7b..0000000 --- a/examples/express-ts/src/__tests__/mocha/upload.wrapper.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Upload API Tests using wrapTest wrapper approach (Mocha version) - * - * This demonstrates the new high-order function wrapping method - * that automatically captures HTTP requests/responses - */ - -import { app } from "../../index" -import { wrapTest, createClient } from "itdoc" -import path from "path" -import fs from "fs" -import { expect } from "chai" - -const apiTest = wrapTest(it) - -const request = createClient.supertest(app) - -describe("Upload API - Wrapper Approach (Mocha)", () => { - const testFilePath = path.join(__dirname, "test-file.txt") - const testImagePath = path.join(__dirname, "test-image.png") - - before(() => { - fs.writeFileSync(testFilePath, "This is a test file content") - - const pngBuffer = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, - 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, - 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, - 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, - 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ]) - fs.writeFileSync(testImagePath, pngBuffer) - }) - - after(() => { - if (fs.existsSync(testFilePath)) { - fs.unlinkSync(testFilePath) - } - if (fs.existsSync(testImagePath)) { - fs.unlinkSync(testImagePath) - } - }) - - apiTest.withMeta({ - summary: "Upload a single file", - tags: ["Upload"], - description: "Uploads a single file using multipart/form-data", - })("should upload a single file successfully", async () => { - const response = await request.post("/api/upload/single").attach("file", testFilePath) - - expect(response.status).to.equal(201) - expect(response.body).to.have.property("message", "File uploaded successfully") - expect((response.body as any).file).to.have.property("originalname", "test-file.txt") - expect((response.body as any).file).to.have.property("mimetype", "text/plain") - }) - - apiTest.withMeta({ - summary: "Upload multiple files", - tags: ["Upload"], - description: "Uploads multiple files in a single request", - })("should upload multiple files successfully", async () => { - const response = await request - .post("/api/upload/multiple") - .attach("files", testFilePath) - .attach("files", testImagePath) - - expect(response.status).to.equal(201) - expect(response.body).to.have.property("message", "Files uploaded successfully") - expect((response.body as any).files).to.have.lengthOf(2) - expect((response.body as any).files[0]).to.have.property("originalname", "test-file.txt") - expect((response.body as any).files[1]).to.have.property("originalname", "test-image.png") - }) - - apiTest.withMeta({ - summary: "Upload file with additional fields", - tags: ["Upload", "Documents"], - description: "Uploads a file along with additional form fields (title, description)", - })("should upload file with additional form fields", async () => { - const response = await request - .post("/api/upload/with-fields") - .field("title", "Important Document") - .field("description", "This is a very important document") - .attach("document", testFilePath) - - expect(response.status).to.equal(201) - expect(response.body).to.have.property("message", "Document uploaded successfully") - expect((response.body as any).document).to.have.property("title", "Important Document") - expect((response.body as any).document).to.have.property( - "description", - "This is a very important document", - ) - expect((response.body as any).document.file).to.have.property( - "originalname", - "test-file.txt", - ) - }) - - apiTest.withMeta({ - summary: "Handle missing file upload", - tags: ["Upload", "Error Handling"], - description: "Returns 400 error when no file is provided", - })("should return 400 when no file is uploaded", async () => { - const response = await request.post("/api/upload/single").send({}) - - expect(response.status).to.equal(400) - expect(response.body).to.have.property("error", "No file uploaded") - }) - - apiTest.withMeta({ - summary: "Upload image file", - tags: ["Upload", "Images"], - description: "Uploads an image file (PNG)", - })("should upload an image file successfully", async () => { - const response = await request.post("/api/upload/single").attach("file", testImagePath) - - expect(response.status).to.equal(201) - expect(response.body).to.have.property("message", "File uploaded successfully") - expect((response.body as any).file).to.have.property("originalname", "test-image.png") - expect((response.body as any).file).to.have.property("mimetype", "image/png") - }) -}) diff --git a/examples/express-ts/src/__tests__/mocha/user.wrapper.test.ts b/examples/express-ts/src/__tests__/mocha/user.wrapper.test.ts deleted file mode 100644 index 827e429..0000000 --- a/examples/express-ts/src/__tests__/mocha/user.wrapper.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * User API Tests using wrapTest wrapper approach (Mocha version) - * - * This demonstrates the new high-order function wrapping method - * that automatically captures HTTP requests/responses - */ - -import { app } from "../../index" -import { wrapTest, createClient } from "itdoc" -import { expect } from "chai" - -const apiTest = wrapTest(it) - -const request = createClient.supertest(app) - -describe("User API - Wrapper Approach (Mocha)", () => { - describe("POST /api/user/register", () => { - apiTest.withMeta({ - summary: "Register new user", - tags: ["Users", "Authentication"], - description: "Registers a new user with username and password", - })("should register a new user successfully", async () => { - const response = await request.post("/api/user/register").send({ - username: "testuser", - password: "testpassword", - }) - - expect(response.status).to.equal(201) - expect(response.body).to.have.property("message", "User registered successfully") - expect((response.body as any).user).to.have.property("username", "testuser") - }) - - apiTest.withMeta({ - summary: "Register user - missing username", - tags: ["Users", "Authentication", "Validation"], - })("should return error when username is missing", async () => { - const response = await request.post("/api/user/register").send({ - password: "testpassword", - }) - - expect(response.status).to.equal(400) - expect(response.body).to.have.property("message", "Username and password are required.") - }) - - apiTest.withMeta({ - summary: "Register user - missing password", - tags: ["Users", "Authentication", "Validation"], - })("should return error when password is missing", async () => { - const response = await request.post("/api/user/register").send({ - username: "testuser", - }) - - expect(response.status).to.equal(400) - expect(response.body).to.have.property("message", "Username and password are required.") - }) - }) - - describe("POST /api/user/login", () => { - apiTest.withMeta({ - summary: "User login", - tags: ["Users", "Authentication"], - description: "Authenticates a user with username and password", - })("should login successfully with valid credentials", async () => { - const response = await request.post("/api/user/login").send({ - username: "admin", - password: "admin", - }) - - expect(response.status).to.equal(200) - expect(response.body).to.have.property("message", "Login successful") - expect(response.body).to.have.property("token", "fake-jwt-token") - }) - - apiTest.withMeta({ - summary: "User login - invalid credentials", - tags: ["Users", "Authentication", "Error"], - })("should return error with invalid credentials", async () => { - const response = await request.post("/api/user/login").send({ - username: "wronguser", - password: "wrongpassword", - }) - - expect(response.status).to.equal(401) - expect(response.body).to.have.property("message", "Invalid credentials") - }) - }) - - describe("GET /api/user/:id", () => { - apiTest.withMeta({ - summary: "Get user by ID", - tags: ["Users"], - description: "Retrieves a specific user by their ID", - })("should return user information", async () => { - const response = await request.get("/api/user/123") - - expect(response.status).to.equal(200) - expect(response.body).to.have.property("id", "123") - expect(response.body).to.have.property("username", "exampleUser") - expect(response.body).to.have.property("email", "user@example.com") - expect(response.body).to.have.property("profilePicture", null) - }) - - apiTest("should handle different user IDs", async () => { - const response = await request.get("/api/user/456") - - expect(response.status).to.equal(200) - expect(response.body).to.have.property("id", "456") - }) - }) - - describe("Complete user workflow", () => { - apiTest.withMeta({ - summary: "User registration and login flow", - tags: ["Users", "Workflow"], - description: "Complete user registration and authentication workflow", - })("should register and login successfully", async () => { - const registerResponse = await request.post("/api/user/register").send({ - username: "newuser", - password: "newpassword", - }) - - expect(registerResponse.status).to.equal(201) - expect((registerResponse.body as any).user.username).to.equal("newuser") - - const loginResponse = await request.post("/api/user/login").send({ - username: "admin", // Using admin for demo - password: "admin", - }) - - expect(loginResponse.status).to.equal(200) - expect(loginResponse.body).to.have.property("token") - - const userResponse = await request.get("/api/user/123") - - expect(userResponse.status).to.equal(200) - expect(userResponse.body).to.have.property("username") - }) - }) -}) diff --git a/examples/express-ts/src/__tests__/mocha/product.test.ts b/examples/express-ts/src/__tests__/product.test.ts similarity index 99% rename from examples/express-ts/src/__tests__/mocha/product.test.ts rename to examples/express-ts/src/__tests__/product.test.ts index d5963d1..9f2896d 100644 --- a/examples/express-ts/src/__tests__/mocha/product.test.ts +++ b/examples/express-ts/src/__tests__/product.test.ts @@ -1,4 +1,4 @@ -import { app } from "../../index" +import { app } from "../index" import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc" describeAPI( diff --git a/examples/express-ts/src/__tests__/mocha/user.test.ts b/examples/express-ts/src/__tests__/user.test.ts similarity index 99% rename from examples/express-ts/src/__tests__/mocha/user.test.ts rename to examples/express-ts/src/__tests__/user.test.ts index 242758d..381f305 100644 --- a/examples/express-ts/src/__tests__/mocha/user.test.ts +++ b/examples/express-ts/src/__tests__/user.test.ts @@ -1,4 +1,4 @@ -import { app } from "../../index" +import { app } from "../index" import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc" describeAPI( diff --git a/examples/express-ts/src/index.ts b/examples/express-ts/src/index.ts index 4294240..2023579 100644 --- a/examples/express-ts/src/index.ts +++ b/examples/express-ts/src/index.ts @@ -3,7 +3,6 @@ import cors from "cors" import dotenv from "dotenv" import { productRoutes } from "./routes/product.routes" import { userRoutes } from "./routes/user.routes" -import { uploadRoutes } from "./routes/upload.routes" dotenv.config() @@ -15,7 +14,6 @@ app.use(express.json()) app.use("/api/user", userRoutes) app.use("/api/products", productRoutes) -app.use("/api/upload", uploadRoutes) app.get("/health", (_req, res) => { const baseResponse = { diff --git a/examples/express-ts/src/routes/upload.routes.ts b/examples/express-ts/src/routes/upload.routes.ts deleted file mode 100644 index f305fca..0000000 --- a/examples/express-ts/src/routes/upload.routes.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Router } from "express" -import multer from "multer" - -const router = Router() -const upload = multer({ storage: multer.memoryStorage() }) - -// Single file upload -router.post("/single", upload.single("file") as any, (req: any, res: any) => { - if (!req.file) { - return res.status(400).json({ error: "No file uploaded" }) - } - - res.status(201).json({ - message: "File uploaded successfully", - file: { - originalname: req.file.originalname, - mimetype: req.file.mimetype, - size: req.file.size, - }, - }) -}) - -// Multiple files upload -router.post("/multiple", upload.array("files", 5) as any, (req: any, res: any) => { - const files = req.files as any[] - - if (!files || files.length === 0) { - return res.status(400).json({ error: "No files uploaded" }) - } - - res.status(201).json({ - message: "Files uploaded successfully", - files: files.map((f) => ({ - originalname: f.originalname, - mimetype: f.mimetype, - size: f.size, - })), - }) -}) - -// File upload with additional fields -router.post("/with-fields", upload.single("document") as any, (req: any, res: any) => { - if (!req.file) { - return res.status(400).json({ error: "No file uploaded" }) - } - - const { title, description } = req.body - - res.status(201).json({ - message: "Document uploaded successfully", - document: { - title: title || "Untitled", - description: description || "", - file: { - originalname: req.file.originalname, - mimetype: req.file.mimetype, - size: req.file.size, - }, - }, - }) -}) - -export { router as uploadRoutes } diff --git a/examples/express-ts/src/services/productService.ts b/examples/express-ts/src/services/productService.ts index c07f6f4..5563586 100644 --- a/examples/express-ts/src/services/productService.ts +++ b/examples/express-ts/src/services/productService.ts @@ -5,13 +5,11 @@ export interface Product { category: string } -let products: Product[] = [ +const products: Product[] = [ { id: 1, name: "Laptop", price: 999.99, category: "Electronics" }, { id: 2, name: "Smartphone", price: 699.99, category: "Electronics" }, ] -let nextId = 3 - export const ProductService = { getAllProducts: async (): Promise => { return products @@ -23,7 +21,7 @@ export const ProductService = { createProduct: async (productData: Omit): Promise => { const newProduct = { - id: nextId++, + id: 3, ...productData, } products.push(newProduct) @@ -48,12 +46,4 @@ export const ProductService = { products.splice(index, 1) return true }, - - resetProducts: () => { - products = [ - { id: 1, name: "Laptop", price: 999.99, category: "Electronics" }, - { id: 2, name: "Smartphone", price: 699.99, category: "Electronics" }, - ] - nextId = 3 - }, } diff --git a/lib/wrappers/EXAMPLE.md b/lib/wrappers/EXAMPLE.md deleted file mode 100644 index ee7c3e5..0000000 --- a/lib/wrappers/EXAMPLE.md +++ /dev/null @@ -1,433 +0,0 @@ -# Wrapper-based API Testing Examples - -이 문서는 `wrapTest`를 사용한 실전 예제를 제공합니다. - -## 📚 목차 - -- [기본 사용법](#기본-사용법) -- [인증 & 권한](#인증--권한) -- [복잡한 워크플로우](#복잡한-워크플로우) -- [에러 처리](#에러-처리) -- [메타데이터 활용](#메타데이터-활용) -- [기존 코드 마이그레이션](#기존-코드-마이그레이션) - -## 기본 사용법 - -### 1. 간단한 GET 요청 - -```typescript -import { wrapTest, createClient } from "itdoc/wrappers" - -const apiTest = wrapTest(it) - -describe("Product API", () => { - apiTest("should get all products", async () => { - const response = await createClient.supertest(app).get("/api/products") - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("products") - expect(Array.isArray(response.body.products)).toBe(true) - }) -}) -``` - -### 2. POST 요청으로 리소스 생성 - -```typescript -apiTest("should create new product", async () => { - const response = await createClient.supertest(app).post("/api/products").send({ - name: "iPhone 15", - price: 999.99, - category: "electronics", - }) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty("id") - expect(response.body.name).toBe("iPhone 15") -}) -``` - -### 3. 쿼리 파라미터 사용 - -```typescript -apiTest("should filter products by category", async () => { - const response = await createClient.supertest(app).get("/api/products").query({ - category: "electronics", - minPrice: 500, - maxPrice: 2000, - }) - - expect(response.status).toBe(200) - expect(response.body.products.every((p) => p.category === "electronics")).toBe(true) -}) -``` - -## 인증 & 권한 - -### 4. JWT 토큰 인증 - -```typescript -describe("Auth API", () => { - apiTest("should login and get token", async () => { - const response = await createClient.supertest(app).post("/api/auth/login").send({ - email: "admin@example.com", - password: "password123", - }) - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("token") - }) -}) -``` - -### 5. 인증된 요청 - -```typescript -apiTest("should access protected route with token", async () => { - const response = await createClient - .supertest(app) - .get("/api/admin/dashboard") - .set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIs...") - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("stats") -}) -``` - -### 6. 권한 부족 에러 - -```typescript -apiTest("should reject unauthorized access", async () => { - const response = await createClient.supertest(app).get("/api/admin/users") - // No Authorization header - - expect(response.status).toBe(401) - expect(response.body.error).toBe("Unauthorized") -}) -``` - -## 복잡한 워크플로우 - -### 7. 전체 사용자 등록 플로우 - -```typescript -apiTest("should complete user registration flow", async () => { - // 1. 회원가입 - const signupRes = await createClient.supertest(app).post("/api/auth/signup").send({ - email: "newuser@example.com", - password: "secure123", - name: "John Doe", - }) - - expect(signupRes.status).toBe(201) - const userId = signupRes.body.id - - // 2. 이메일 인증 (시뮬레이션) - const verifyRes = await createClient - .supertest(app) - .post(`/api/auth/verify/${userId}`) - .send({ code: "123456" }) - - expect(verifyRes.status).toBe(200) - - // 3. 로그인 - const loginRes = await createClient.supertest(app).post("/api/auth/login").send({ - email: "newuser@example.com", - password: "secure123", - }) - - expect(loginRes.status).toBe(200) - expect(loginRes.body).toHaveProperty("token") -}) -``` - -### 8. 주문 생성 및 결제 - -```typescript -apiTest("should create order and process payment", async () => { - // 1. 장바구니에 상품 추가 - const cartRes = await createClient - .supertest(app) - .post("/api/cart/items") - .set("Authorization", "Bearer token") - .send({ - productId: 123, - quantity: 2, - }) - - expect(cartRes.status).toBe(200) - - // 2. 주문 생성 - const orderRes = await createClient - .supertest(app) - .post("/api/orders") - .set("Authorization", "Bearer token") - .send({ - shippingAddress: "123 Main St", - paymentMethod: "credit_card", - }) - - expect(orderRes.status).toBe(201) - const orderId = orderRes.body.id - - // 3. 결제 처리 - const paymentRes = await createClient - .supertest(app) - .post(`/api/orders/${orderId}/pay`) - .set("Authorization", "Bearer token") - .send({ - cardNumber: "4242424242424242", - expiry: "12/25", - cvv: "123", - }) - - expect(paymentRes.status).toBe(200) - expect(paymentRes.body.status).toBe("paid") -}) -``` - -## 에러 처리 - -### 9. Validation 에러 - -```typescript -apiTest("should validate required fields", async () => { - const response = await createClient.supertest(app).post("/api/products").send({ - // name 누락 - price: 999.99, - }) - - expect(response.status).toBe(400) - expect(response.body.errors).toContain("name is required") -}) -``` - -### 10. Not Found 에러 - -```typescript -apiTest("should return 404 for non-existent resource", async () => { - const response = await createClient.supertest(app).get("/api/products/99999") - - expect(response.status).toBe(404) - expect(response.body.error).toBe("Product not found") -}) -``` - -### 11. Server 에러 - -```typescript -apiTest("should handle server errors gracefully", async () => { - const response = await createClient - .supertest(app) - .post("/api/products/import") - .send({ file: "invalid-data" }) - - expect(response.status).toBe(500) - expect(response.body).toHaveProperty("error") -}) -``` - -## 메타데이터 활용 - -### 12. 상세한 API 문서 메타데이터 - -```typescript -apiTest.withMeta({ - summary: "Create Product", - description: "Creates a new product in the inventory system", - tags: ["Products", "Inventory"], -})("POST /api/products - Create product", async () => { - const response = await createClient.supertest(app).post("/api/products").send({ - name: "MacBook Pro", - price: 2499.99, - category: "computers", - }) - - expect(response.status).toBe(201) -}) -``` - -### 13. Deprecated API 표시 - -```typescript -apiTest.withMeta({ - summary: "Legacy User List", - tags: ["Users", "Legacy"], - deprecated: true, - description: "This endpoint is deprecated. Use /api/v2/users instead.", -})("GET /api/users - List users (deprecated)", async () => { - const response = await createClient.supertest(app).get("/api/users") - - expect(response.status).toBe(200) -}) -``` - -### 14. 여러 태그로 분류 - -```typescript -apiTest.withMeta({ - summary: "Export User Data (GDPR)", - tags: ["Users", "Privacy", "GDPR", "Export"], - description: "Exports all user data for GDPR compliance", -})("GET /api/users/:id/export - Export user data", async () => { - const response = await createClient - .supertest(app) - .get("/api/users/123/export") - .set("Authorization", "Bearer token") - - expect(response.status).toBe(200) - expect(response.headers["content-type"]).toContain("application/json") -}) -``` - -## 기존 코드 마이그레이션 - -### 15. 기존 Supertest 코드 마이그레이션 - -**Before (원본):** - -```typescript -import request from "supertest" - -describe("User API", () => { - it("should create user", async () => { - const response = await createClient - .supertest(app) - .post("/api/users") - .send({ name: "John", email: "john@test.com" }) - - expect(response.status).toBe(201) - }) -}) -``` - -**After (wrapTest 적용):** - -```typescript -import { wrapTest, createClient } from "itdoc/wrappers" - -const apiTest = wrapTest(it) - -describe("User API", () => { - apiTest("should create user", async () => { - const response = await createClient - .supertest(app) - .post("/api/users") - .send({ name: "John", email: "john@test.com" }) - - expect(response.status).toBe(201) - }) -}) -``` - -**변경사항:** - -1. ✅ Import 변경: `supertest` → `itdoc/wrappers` -2. ✅ `wrapTest(it)` 추가 -3. ✅ `it` → `apiTest` 사용 -4. ✅ 나머지 코드는 동일! - -### 16. Jest describe.each 패턴과 함께 사용 - -```typescript -const apiTest = wrapTest(it) - -describe.each([ - { role: "admin", canDelete: true }, - { role: "user", canDelete: false }, - { role: "guest", canDelete: false }, -])("Authorization for $role", ({ role, canDelete }) => { - apiTest( - `${role} should ${canDelete ? "be able to" : "not be able to"} delete users`, - async () => { - const response = await createClient - .supertest(app) - .delete("/api/users/123") - .set("Authorization", `Bearer ${role}-token`) - - expect(response.status).toBe(canDelete ? 200 : 403) - }, - ) -}) -``` - -### 17. Mocha에서 사용 - -```typescript -import { wrapTest, createClient } from "itdoc/wrappers" -import { expect } from "chai" - -const apiTest = wrapTest(it) - -describe("Product API", function () { - apiTest("should get products", async function () { - const response = await createClient.supertest(app).get("/api/products") - - expect(response.status).to.equal(200) - expect(response.body).to.have.property("products") - }) -}) -``` - -## 🎯 Best Practices - -### ✅ DO - -```typescript -// ✅ 명확한 테스트 설명 -apiTest('should return 404 when product not found', async () => { - // ... -}) - -// ✅ 메타데이터로 문서 품질 향상 -apiTest.withMeta({ - summary: 'User Registration', - tags: ['Auth', 'Users'] -})('POST /auth/signup', async () => { - // ... -}) - -// ✅ 여러 API 호출을 워크플로우로 테스트 -apiTest('should complete checkout flow', async () => { - await createClient.supertest(app).post('/cart/add').send({...}) - await createClient.supertest(app).post('/orders').send({...}) - await createClient.supertest(app).post('/payment').send({...}) -}) -``` - -### ❌ DON'T - -```typescript -// ❌ 모호한 테스트 설명 -apiTest("test1", async () => { - // ... -}) - -// ❌ 문서화가 필요 없는 헬퍼 함수를 apiTest로 감싸기 -apiTest("helper function", async () => { - // 이건 일반 it()을 사용하세요 -}) - -// ❌ 너무 많은 API 호출 (10개 이상) -apiTest("complex flow", async () => { - // 10개 이상의 API 호출... - // 테스트를 분리하는 것이 좋습니다 -}) -``` - -## 📊 비교표 - -| 기능 | 기존 itDoc | wrapTest | -| -------------- | ------------------ | --------------------- | -| 사용 난이도 | 중간 (새 DSL 학습) | 쉬움 (기존 패턴 유지) | -| 코드 변경량 | 많음 | 최소 | -| 자동 캡처 | ❌ 수동 설정 | ✅ 자동 | -| 메타데이터 | 체이닝 방식 | `withMeta()` | -| 기존 코드 호환 | 낮음 | 높음 | -| 타입 안전성 | ✅ | ✅ | - -## 🔗 추가 리소스 - -- [README.md](./README.md) - 전체 문서 -- [기존 itDoc 문서](../../README.md) - 기존 방식 비교 -- [TypeScript 타입 정의](./types.ts) diff --git a/lib/wrappers/README.md b/lib/wrappers/README.md deleted file mode 100644 index 6437a1e..0000000 --- a/lib/wrappers/README.md +++ /dev/null @@ -1,308 +0,0 @@ -# Wrapper-based API Testing - -고차함수 래핑 방식으로 기존 Jest/Mocha 테스트를 최소한으로 수정하여 자동으로 API 문서를 생성합니다. - -## 📋 개요 - -이 모듈은 기존 테스트 프레임워크의 `it` 함수를 감싸서 HTTP request/response를 자동으로 캡처하고, -테스트 성공 시 OpenAPI 문서를 생성합니다. - -### 핵심 특징 - -- ✅ **최소 변경**: `it` → `wrapTest(it)` 한 줄만 변경 -- ✅ **자동 캡처**: Proxy 기반 투명한 request/response 인터셉션 -- ✅ **프레임워크 중립**: Jest/Mocha 모두 지원 -- ✅ **기존 코드 보존**: 새로운 방식이므로 기존 `itDoc` 방식과 공존 가능 -- ✅ **타입 안전**: 완전한 TypeScript 지원 - -## 🚀 사용법 - -### Before (기존 테스트) - -```typescript -import request from "supertest" - -describe("User API", () => { - it("should create user", async () => { - const response = await request(app) - .post("/users") - .send({ name: "John", email: "john@test.com" }) - - expect(response.status).toBe(201) - }) -}) -``` - -### After (itdoc wrappers 적용) - -```typescript -import { wrapTest, createClient } from "itdoc/wrappers" - -const apiTest = wrapTest(it) // ← it 함수를 래핑 - -describe("User API", () => { - apiTest("should create user", async () => { - // ← it 대신 apiTest 사용 - const response = await createClient - .supertest(app) // ← createClient.supertest 사용 - .post("/users") - .send({ name: "John", email: "john@test.com" }) - - expect(response.status).toBe(201) - // ✅ 자동으로 request/response 캡처 & 문서 생성! - }) -}) -``` - -## 📝 주요 API - -### `wrapTest(it)` - -테스트 프레임워크의 `it` 함수를 래핑하여 자동 캡처 기능을 추가합니다. - -```typescript -import { wrapTest } from "itdoc/wrappers" - -const apiTest = wrapTest(it) // Jest 또는 Mocha의 it -``` - -### `createClient.supertest(app)` - -Supertest를 기반으로 한 HTTP 클라이언트 어댑터입니다. `CaptureContext`가 활성화된 상태에서는 -자동으로 request/response를 캡처합니다. - -```typescript -import { createClient } from "itdoc/wrappers" - -const response = await createClient.supertest(app).post("/users").send({ name: "John" }) -``` - -### 메타데이터 추가 - -`withMeta()` 메서드로 API 문서 메타데이터를 추가할 수 있습니다. - -```typescript -apiTest.withMeta({ - summary: "Create User", - tags: ["Users", "Registration"], - description: "Register a new user account", -})("should create user", async () => { - const response = await createClient.supertest(app).post("/users").send({ name: "John" }) - - expect(response.status).toBe(201) -}) -``` - -## 💡 사용 예시 - -### 1. 기본 사용 - -```typescript -import { wrapTest, createClient } from "itdoc/wrappers" - -const apiTest = wrapTest(it) - -describe("User API", () => { - apiTest("should get all users", async () => { - const response = await createClient.supertest(app).get("/users") - - expect(response.status).toBe(200) - expect(response.body).toHaveProperty("users") - }) -}) -``` - -### 2. 인증 헤더 사용 - -```typescript -apiTest("should get user profile", async () => { - const response = await createClient - .supertest(app) - .get("/users/me") - .set("Authorization", "Bearer token123") - - expect(response.status).toBe(200) -}) -``` - -### 3. 쿼리 파라미터 - -```typescript -apiTest("should filter users", async () => { - const response = await createClient - .supertest(app) - .get("/users") - .query({ role: "admin", active: true }) - - expect(response.status).toBe(200) -}) -``` - -### 4. 여러 API 호출 (단일 테스트) - -```typescript -apiTest("should complete user workflow", async () => { - // 1. Create user - const createRes = await createClient.supertest(app).post("/users").send({ name: "John" }) - - const userId = createRes.body.id - - // 2. Get user - const getRes = await createClient.supertest(app).get(`/users/${userId}`) - - expect(getRes.status).toBe(200) - expect(getRes.body.name).toBe("John") - - // ✅ 두 API 호출 모두 자동 캡처됨! -}) -``` - -### 5. 메타데이터와 함께 사용 - -```typescript -apiTest.withMeta({ - summary: "User Registration API", - tags: ["Auth", "Users"], - description: "Register a new user with email and password", -})("POST /auth/signup - Register user", async () => { - const response = await createClient.supertest(app).post("/auth/signup").send({ - email: "john@example.com", - password: "secure123", - }) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty("token") -}) -``` - -## 🏗️ 아키텍처 - -### 핵심 컴포넌트 - -1. **CaptureContext** (`core/CaptureContext.ts`) - - - AsyncLocalStorage 기반 컨텍스트 관리 - - 스레드 안전한 request/response 데이터 격리 - -2. **HTTP Adapters** (`adapters/`) - - - Supertest, Axios, Fetch 등 다양한 HTTP 클라이언트 어댑터 - - 투명한 HTTP request/response 캡처 - -3. **wrapTest** (`wrapTest.ts`) - - 고차함수 래퍼 - - 테스트 라이프사이클 관리 및 문서 생성 - -### 동작 흐름 - -``` -1. wrapTest(it) → 래핑된 테스트 함수 반환 -2. apiTest(...) 호출 → AsyncLocalStorage 컨텍스트 생성 -3. 사용자 테스트 실행 → createClient.supertest(app) 호출 -4. Adapter로 감싸서 반환 → 메서드 호출 캡처 -5. .then() 호출 → response 캡처 -6. 테스트 성공 → TestResultCollector로 전달 -7. 모든 테스트 완료 → OpenAPI 문서 생성 -``` - -## ⚖️ 기존 방식과 비교 - -### 기존 `itDoc` 방식 - -```typescript -import { itDoc, req } from "itdoc" - -itDoc("should create user", async () => { - const response = await req(app) - .post("/users") - .description("Create user") - .tag("Users") - .send({ name: "John" }) - .expect(201) -}) -``` - -**특징:** - -- DSL 방식으로 명시적 메타데이터 추가 -- 체이닝 기반 API -- 기존 코드 패턴 변경 필요 - -### 새로운 `wrapTest` 방식 - -```typescript -import { wrapTest, createClient } from "itdoc/wrappers" - -const apiTest = wrapTest(it) - -apiTest("should create user", async () => { - const response = await createClient.supertest(app).post("/users").send({ name: "John" }) - - expect(response.status).toBe(201) -}) -``` - -**특징:** - -- 고차함수 래핑으로 자동 캡처 -- 기존 테스트 패턴 유지 -- 최소한의 코드 변경 - -**두 방식 모두 사용 가능하며, 프로젝트 요구사항에 따라 선택할 수 있습니다.** - -## 🧪 테스트 - -### Unit Tests - -```bash -pnpm test:unit -- --grep "CaptureContext" -pnpm test:unit -- --grep "interceptedRequest" -pnpm test:unit -- --grep "wrapTest" -``` - -### Integration Tests - -```bash -pnpm test:unit -- --grep "wrapTest integration" -``` - -## 📦 Export - -```typescript -// Public API -export { wrapTest } from "./wrapTest" -export { createClient } from "./adapters" -export type { ApiDocMetadata, WrappedTestFunction, TestFunction } from "./types" -export type { HttpClient, HttpAdapter, RequestBuilder } from "./adapters/types" -``` - -## 🔄 확장 가능성 - -### 다른 HTTP 클라이언트 지원 - -```typescript -// Axios 어댑터 -import { createClient } from "itdoc/wrappers" -const response = await createClient.axios(axiosInstance).get("/users") - -// Fetch 어댑터 -const response = await createClient.fetch("http://localhost:3000").get("/users") -``` - -### 커스텀 훅 - -```typescript -// 향후 확장 가능 -wrapTest(it, { - beforeCapture: (req) => { - /* 민감 데이터 마스킹 */ - }, - afterCapture: (result) => { - /* 커스텀 검증 */ - }, -}) -``` - -## 📄 라이선스 - -Apache License 2.0 From b360fd44696b166edb303bf730f7bc67c7466e6b Mon Sep 17 00:00:00 2001 From: json Date: Thu, 9 Oct 2025 09:25:04 +0900 Subject: [PATCH 12/14] Delete .serena/project.yml --- .serena/project.yml | 67 --------------------------------------------- 1 file changed, 67 deletions(-) delete mode 100644 .serena/project.yml diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index 39eb229..0000000 --- a/.serena/project.yml +++ /dev/null @@ -1,67 +0,0 @@ -# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) -# * For C, use cpp -# * For JavaScript, use typescript -# Special requirements: -# * csharp: Requires the presence of a .sln file in the project folder. -language: typescript - -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 -ignore_all_files_in_gitignore: true -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed) on 2025-04-07 -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. -excluded_tools: [] - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: "" - -project_name: "itdoc" From 98714d9252feede99419776d37244c9b9a279426 Mon Sep 17 00:00:00 2001 From: json Date: Thu, 9 Oct 2025 09:25:19 +0900 Subject: [PATCH 13/14] Delete .serena/.gitignore --- .serena/.gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .serena/.gitignore diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 14d86ad..0000000 --- a/.serena/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/cache From 5438398b2be6c347a9b1c0a1f6281a9bd4a1e010 Mon Sep 17 00:00:00 2001 From: jaesong-blip Date: Thu, 9 Oct 2025 09:28:47 +0900 Subject: [PATCH 14/14] fix: rollback --- pnpm-lock.yaml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 064d3c8..67c23ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,7 +168,7 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) + version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) jest-diff: specifier: ^29.7.0 version: 29.7.0 @@ -200,9 +200,6 @@ importers: '@types/mocha': specifier: ^10.0.10 version: 10.0.10 - '@types/multer': - specifier: ^2.0.0 - version: 2.0.0 '@types/node': specifier: ^20.10.5 version: 20.17.24 @@ -245,7 +242,7 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) + version: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) examples/nestjs: dependencies: @@ -3195,9 +3192,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/multer@2.0.0': - resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} - '@types/node-fetch@2.6.12': resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} @@ -16055,10 +16049,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/multer@2.0.0': - dependencies: - '@types/express': 5.0.2 - '@types/node-fetch@2.6.12': dependencies: '@types/node': 20.17.24