From 3b038b6818c1c6bd9673eb44b9c1ce1c1f7561b3 Mon Sep 17 00:00:00 2001 From: Chidubemkingsley Date: Sat, 26 Sep 2026 14:59:10 +0100 Subject: [PATCH] feat: add global exception filter, CORS_ORIGINS validation, and Swagger - Add GlobalExceptionFilter mapping Prisma errors (P2002, P2025) and unknown errors to HTTP responses - Add CORS_ORIGINS env variable with comma-separated origins parsing and validation - Reject wildcard (*) in CORS_ORIGINS for production environment - Add @nestjs/swagger with /docs endpoint in non-production environments - Add ApiPropertyOptional to PaginationQueryDto for OpenAPI documentation - Add tests for global exception filter and CORS validation - Lint/formatting fixes across codebase --- .env.example | 2 +- package-lock.json | 480 +++++++----------- package.json | 10 +- src/app.module.ts | 6 - src/audit/audit.service.spec.ts | 4 +- src/audit/audit.service.ts | 4 +- src/common/cache/memory-cache.store.ts | 2 +- src/common/dto/pagination-query.dto.ts | 7 + .../filters/global-exception.filter.spec.ts | 205 ++++++++ src/common/filters/global-exception.filter.ts | 81 +++ src/config/env.validation.spec.ts | 53 +- src/config/env.validation.ts | 12 +- src/events/events.service.spec.ts | 28 +- src/main.ts | 24 +- src/organizations/organizations.controller.ts | 10 +- .../organizations.service.spec.ts | 2 +- src/organizations/organizations.service.ts | 19 +- src/tickets/tickets.service.spec.ts | 37 +- src/tickets/tickets.service.ts | 8 +- test/events.e2e-spec.ts | 6 +- test/mocks/swagger.mock.ts | 99 ++++ test/purchase-concurrency.e2e-spec.ts | 10 +- test/resale.e2e-spec.ts | 16 +- test/validation-pipe.e2e-spec.ts | 17 +- 24 files changed, 757 insertions(+), 385 deletions(-) create mode 100644 src/common/filters/global-exception.filter.spec.ts create mode 100644 src/common/filters/global-exception.filter.ts create mode 100644 test/mocks/swagger.mock.ts diff --git a/.env.example b/.env.example index 526d009..f148e64 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ NODE_ENV=development PORT=3000 -APP_URL=http://localhost:3001 +CORS_ORIGINS=http://localhost:3001 # postgresql://user:password@host:5432/db DATABASE_URL= diff --git a/package-lock.json b/package-lock.json index 0e775d7..c4a2e96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^5.0.1", + "@nestjs/swagger": "^12.0.2", "@prisma/client": "6.19.3", "@stellar/stellar-sdk": "^16.2.0", "bcrypt": "^6.0.0", @@ -25,7 +26,8 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -2076,6 +2078,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.17.0.tgz", + "integrity": "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", @@ -2388,6 +2396,29 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-12.0.0.tgz", + "integrity": "sha512-aX7lxZcqXldtnSr0bgT2ZPQvpTpIwBMW1GMX29o4s3q02vmq6JoTYuPW0GwYXa8BiFMRX7WbGZVDA703pNqOMQ==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", @@ -2536,6 +2567,69 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/swagger": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-12.0.2.tgz", + "integrity": "sha512-/IDj6o2k1wqZybhIkMaJp58YNrAos/4TwTf3zBn/j1flKpMb5Tyfbj9sP6rpLucQ7PzHHu7n4ucmeHWEisfn6A==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.17.0", + "@nestjs/mapped-types": "12.0.0", + "@standard-schema/spec": "1.1.0", + "es-toolkit": "1.52.0", + "js-yaml": "5.4.2", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.33.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^12.0.0", + "@nestjs/core": "^12.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "typescript": "^5.5.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/@nestjs/testing": { "version": "11.1.28", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.28.tgz", @@ -2658,7 +2752,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -2671,14 +2765,14 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -2692,14 +2786,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -2711,12 +2805,19 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", @@ -2748,7 +2849,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@stellar/js-xdr": { @@ -4232,7 +4332,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -4650,7 +4749,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -4679,7 +4778,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4796,7 +4895,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -4838,7 +4937,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -5151,14 +5250,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -5347,7 +5446,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -5370,7 +5469,7 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -5395,7 +5494,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/detect-newline": { @@ -5508,7 +5607,7 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -5546,7 +5645,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -5650,6 +5749,18 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6047,14 +6158,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -6077,7 +6188,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -6569,7 +6680,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -7937,7 +8048,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -9003,174 +9114,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minimizer-webpack-plugin": { - "version": "5.12.0", - "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.12.0.tgz", - "integrity": "sha512-PCN1x1OzFeFB2GlP91s46q7Gz8Jf16Z7M45bzpoqrYZlh12QeY6tYVM5h299wbwy1DxnQ69VFmmh8Hb2Ff8mbw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.3", - "terser": "^5.51.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@minify-html/node": { - "optional": true - }, - "@napi-rs/image": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "@swc/css": { - "optional": true - }, - "@swc/html": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "cssnano": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "html-minifier-terser": { - "optional": true - }, - "imagemin": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "postcss": { - "optional": true - }, - "sharp": { - "optional": true - }, - "svgo": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/minimizer-webpack-plugin/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/minimizer-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/minimizer-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/minimizer-webpack-plugin/node_modules/schema-utils": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", - "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.15", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -9328,7 +9271,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/node-gyp-build": { @@ -9386,7 +9329,7 @@ "version": "0.6.9", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -9404,7 +9347,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/object-assign": { @@ -9432,7 +9375,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/on-finished": { @@ -9721,7 +9664,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/pause": { @@ -9733,7 +9676,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -9852,7 +9795,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -9942,7 +9885,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -10061,7 +10004,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -10102,7 +10045,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -10772,6 +10715,30 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.33.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.0.tgz", + "integrity": "sha512-wpdK+m6BU5yj6pmUdMskZVTSWYG4DLglAx3sIhylloY37i8O37IrH+YEpqdXNfpaTGxILRBFzUqLF2jKqbfI7A==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -11046,7 +11013,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -11388,7 +11355,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11652,48 +11619,6 @@ "defaults": "^1.0.3" } }, - "node_modules/webpack": { - "version": "5.111.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.111.1.tgz", - "integrity": "sha512-cNypaz0RP+S4cvQVi1M/3bRUkvNP0xZpL0TjFx+c6jg64VbxD+2weWDgY9UnjRI6dhZgtaj8DU76GGFcJ79xPQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.25.0", - "es-module-lexer": "^2.1.0", - "events": "^3.2.0", - "graceful-fs": "^4.2.11", - "mime-db": "^1.54.0", - "minimizer-webpack-plugin": "^5.7.0", - "schema-utils": "^4.5.0", - "tapable": "^2.3.0", - "watchpack": "^2.5.2", - "webpack-sources": "^3.5.1" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/webpack-node-externals": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", @@ -11714,67 +11639,6 @@ "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", - "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.15", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index d71f419..d81be6d 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,7 @@ "db:migrate": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy", "db:reset": "prisma migrate reset --force", - "db:seed": "prisma db seed", - "prepare": "husky" + "db:seed": "prisma db seed" }, "prisma": { "seed": "ts-node --transpile-only prisma/seed.ts" @@ -49,6 +48,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^5.0.1", + "@nestjs/swagger": "^12.0.2", "@prisma/client": "6.19.3", "@stellar/stellar-sdk": "^16.2.0", "bcrypt": "^6.0.0", @@ -58,7 +58,8 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -104,6 +105,9 @@ "transformIgnorePatterns": [ "/node_modules/(?!(@stellar/stellar-sdk|@noble|uint8array-extras)/)" ], + "moduleNameMapper": { + "^@nestjs/swagger$": "/../test/mocks/swagger.mock.ts" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/src/app.module.ts b/src/app.module.ts index 46cc9c6..3d26ea3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; -import { APP_FILTER } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { validate } from './config/env.validation'; @@ -25,7 +24,6 @@ import { SchedulerModule } from './scheduler/scheduler.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor'; import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor'; -import { DomainExceptionFilter } from './common/filters/domain-exception.filter'; @Module({ imports: [ @@ -61,10 +59,6 @@ import { DomainExceptionFilter } from './common/filters/domain-exception.filter' provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor, }, - { - provide: APP_FILTER, - useClass: DomainExceptionFilter, - }, ], }) export class AppModule {} diff --git a/src/audit/audit.service.spec.ts b/src/audit/audit.service.spec.ts index 8de7a16..5b6bd70 100644 --- a/src/audit/audit.service.spec.ts +++ b/src/audit/audit.service.spec.ts @@ -4,7 +4,7 @@ describe('AuditService (#209)', () => { it('stores actor, action, entity and timestamp via prisma.auditLog.create', async () => { const create = jest.fn().mockResolvedValue({ id: 'log-1' }); const prisma = { auditLog: { create } } as never; - const service = new AuditService(prisma as never); + const service = new AuditService(prisma); await service.record('user-1', 'ticket.revoke', 'Ticket', 'ticket-1', { eventId: 'event-1', @@ -26,7 +26,7 @@ describe('AuditService (#209)', () => { .fn() .mockRejectedValue(new Error('audit table unavailable')); const prisma = { auditLog: { create } } as never; - const service = new AuditService(prisma as never); + const service = new AuditService(prisma); await expect( service.record('user-1', 'event.create', 'Event', 'event-1'), diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts index 4dc1163..6ffce0a 100644 --- a/src/audit/audit.service.ts +++ b/src/audit/audit.service.ts @@ -21,9 +21,7 @@ export class AuditService { ): Promise { if (!this.prisma) return; const delegate = (this.prisma as unknown as Record) - .auditLog as - | { create: (args: unknown) => Promise } - | undefined; + .auditLog as { create: (args: unknown) => Promise } | undefined; if (!delegate) return; try { await delegate.create({ diff --git a/src/common/cache/memory-cache.store.ts b/src/common/cache/memory-cache.store.ts index d455912..9aa43c0 100644 --- a/src/common/cache/memory-cache.store.ts +++ b/src/common/cache/memory-cache.store.ts @@ -36,7 +36,7 @@ export class MemoryCacheStore implements CacheStore { json = serializeCacheValue(value); } catch (err) { // Reject rather than throw, so callers see the same async contract as Redis. - return Promise.reject(err as Error); + return Promise.reject(err); } // Re-insert so a refreshed key counts as the newest for eviction. diff --git a/src/common/dto/pagination-query.dto.ts b/src/common/dto/pagination-query.dto.ts index 4c5cf62..18e8a90 100644 --- a/src/common/dto/pagination-query.dto.ts +++ b/src/common/dto/pagination-query.dto.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer'; import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; export const DEFAULT_PAGE_LIMIT = 20; export const MAX_PAGE_LIMIT = 100; @@ -10,6 +11,7 @@ export const MAX_PAGE_LIMIT = 100; */ export class PaginationQueryDto { /** 1-based page number. */ + @ApiPropertyOptional({ minimum: 1, maximum: 100_000, default: 1 }) @IsOptional() @Type(() => Number) @IsInt() @@ -19,6 +21,11 @@ export class PaginationQueryDto { page?: number = 1; /** Items per page. */ + @ApiPropertyOptional({ + minimum: 1, + maximum: MAX_PAGE_LIMIT, + default: DEFAULT_PAGE_LIMIT, + }) @IsOptional() @Type(() => Number) @IsInt() diff --git a/src/common/filters/global-exception.filter.spec.ts b/src/common/filters/global-exception.filter.spec.ts new file mode 100644 index 0000000..a0b3e8e --- /dev/null +++ b/src/common/filters/global-exception.filter.spec.ts @@ -0,0 +1,205 @@ +import { HttpStatus } from '@nestjs/common'; +import type { ArgumentsHost } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { GlobalExceptionFilter } from './global-exception.filter'; +import { + ListingInactiveError, + TicketTypeSoldOutError, +} from '../errors/domain.error'; + +describe('GlobalExceptionFilter', () => { + const filter = new GlobalExceptionFilter(); + + function host() { + const response = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + const request = { + method: 'GET', + url: '/test', + }; + const argumentsHost = { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: () => request, + }), + } as unknown as ArgumentsHost; + return { argumentsHost, response }; + } + + function createPrismaError( + code: string, + meta?: Record, + ): Prisma.PrismaClientKnownRequestError { + return new Prisma.PrismaClientKnownRequestError('Test error', { + code, + clientVersion: '5.0.0', + meta, + }); + } + + describe('Prisma errors', () => { + it('maps P2002 to 409 Conflict', () => { + const { argumentsHost, response } = host(); + + filter.catch(createPrismaError('P2002'), argumentsHost); + + expect(response.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.CONFLICT, + code: 'PRISMA_P2002', + message: 'A record with this value already exists', + }); + }); + + it('maps P2025 to 404 Not Found', () => { + const { argumentsHost, response } = host(); + + filter.catch(createPrismaError('P2025'), argumentsHost); + + expect(response.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.NOT_FOUND, + code: 'PRISMA_P2025', + message: 'Record not found', + }); + }); + + it('maps unknown Prisma codes to 500', () => { + const { argumentsHost, response } = host(); + + filter.catch(createPrismaError('P2003'), argumentsHost); + + expect(response.status).toHaveBeenCalledWith( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + code: 'PRISMA_P2003', + message: 'Database operation failed', + }); + }); + + it('maps Prisma validation error to 400', () => { + const { argumentsHost, response } = host(); + + const error = new Prisma.PrismaClientValidationError('Invalid', { + clientVersion: '5.0.0', + }); + filter.catch(error, argumentsHost); + + expect(response.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.BAD_REQUEST, + code: 'PRISMA_VALIDATION_ERROR', + message: 'Invalid data provided', + }); + }); + + it('maps Prisma initialization error to 503', () => { + const { argumentsHost, response } = host(); + + filter.catch( + new Prisma.PrismaClientInitializationError( + 'Connection failed', + '5.0.0', + ), + argumentsHost, + ); + + expect(response.status).toHaveBeenCalledWith( + HttpStatus.SERVICE_UNAVAILABLE, + ); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + code: 'PRISMA_INITIALIZATION_ERROR', + message: 'Database connection failed', + }); + }); + }); + + describe('Domain errors', () => { + it('maps LISTING_INACTIVE to 400', () => { + const { argumentsHost, response } = host(); + + filter.catch(new ListingInactiveError(), argumentsHost); + + expect(response.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.BAD_REQUEST, + code: 'LISTING_INACTIVE', + message: 'This ticket is not listed for resale', + }); + }); + + it('maps TICKET_TYPE_SOLD_OUT to 409', () => { + const { argumentsHost, response } = host(); + + filter.catch(new TicketTypeSoldOutError(), argumentsHost); + + expect(response.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.CONFLICT, + code: 'TICKET_TYPE_SOLD_OUT', + message: 'This ticket type is sold out', + }); + }); + + it('maps unknown Error subclasses to 500 without leaking internals', () => { + const { argumentsHost, response } = host(); + + class UnknownError extends Error { + code = 'UNKNOWN_CODE'; + message = 'Unknown error'; + constructor() { + super('Unknown error'); + this.name = 'UnknownError'; + } + } + + filter.catch(new UnknownError(), argumentsHost); + + expect(response.status).toHaveBeenCalledWith( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + code: 'INTERNAL_ERROR', + message: 'Internal server error', + }); + }); + }); + + describe('Unknown errors', () => { + it('returns 500 without leaking internals', () => { + const { argumentsHost, response } = host(); + + filter.catch(new Error('Internal secret'), argumentsHost); + + expect(response.status).toHaveBeenCalledWith( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + code: 'INTERNAL_ERROR', + message: 'Internal server error', + }); + }); + + it('returns 500 for non-Error values', () => { + const { argumentsHost, response } = host(); + + filter.catch('string error', argumentsHost); + + expect(response.status).toHaveBeenCalledWith( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + expect(response.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + code: 'INTERNAL_ERROR', + message: 'Internal server error', + }); + }); + }); +}); diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts new file mode 100644 index 0000000..fe9c7dd --- /dev/null +++ b/src/common/filters/global-exception.filter.ts @@ -0,0 +1,81 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { DomainError } from '../errors/domain.error'; + +const PRISMA_CODE_TO_STATUS: Readonly> = { + P2002: HttpStatus.CONFLICT, + P2025: HttpStatus.NOT_FOUND, +}; + +const DOMAIN_CODE_TO_STATUS: Readonly> = { + LISTING_INACTIVE: HttpStatus.BAD_REQUEST, + TICKET_TYPE_SOLD_OUT: HttpStatus.CONFLICT, +}; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + let code = 'INTERNAL_ERROR'; + let message = 'Internal server error'; + + if (exception instanceof Prisma.PrismaClientKnownRequestError) { + statusCode = + PRISMA_CODE_TO_STATUS[exception.code] ?? + HttpStatus.INTERNAL_SERVER_ERROR; + code = `PRISMA_${exception.code}`; + message = this.getPrismaErrorMessage(exception); + } else if (exception instanceof DomainError) { + statusCode = + DOMAIN_CODE_TO_STATUS[exception.code] ?? + HttpStatus.INTERNAL_SERVER_ERROR; + code = exception.code; + message = exception.message; + } else if (exception instanceof Prisma.PrismaClientValidationError) { + statusCode = HttpStatus.BAD_REQUEST; + code = 'PRISMA_VALIDATION_ERROR'; + message = 'Invalid data provided'; + } else if (exception instanceof Prisma.PrismaClientInitializationError) { + statusCode = HttpStatus.SERVICE_UNAVAILABLE; + code = 'PRISMA_INITIALIZATION_ERROR'; + message = 'Database connection failed'; + } else if (exception instanceof Error) { + this.logger.error( + `Unhandled exception: ${exception.message}`, + exception.stack, + `${request.method} ${request.url}`, + ); + } + + response.status(statusCode).json({ + statusCode, + code, + message, + }); + } + + private getPrismaErrorMessage( + error: Prisma.PrismaClientKnownRequestError, + ): string { + switch (error.code) { + case 'P2002': + return 'A record with this value already exists'; + case 'P2025': + return 'Record not found'; + default: + return 'Database operation failed'; + } + } +} diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index 830f637..4d19ffd 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -7,7 +7,7 @@ function validConfig(overrides: Record = {}) { PORT: 3000, DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', JWT_SECRET: 'x'.repeat(32), - APP_URL: 'http://localhost:3001', + CORS_ORIGINS: 'http://localhost:3001,https://staging.example.com', SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', STELLAR_NETWORK: 'testnet', TICKETING_CONTRACT_ID: 'C'.repeat(56), @@ -155,4 +155,55 @@ describe('env.validate', () => { delete (config as Record).DATABASE_URL; expect(() => validate(config)).toThrow(); }); + + describe('CORS_ORIGINS', () => { + it('accepts comma-separated origins', () => { + expect(() => + validate( + validConfig({ + CORS_ORIGINS: 'https://app.example.com,https://staging.example.com', + }), + ), + ).not.toThrow(); + }); + + it('accepts single origin', () => { + expect(() => + validate(validConfig({ CORS_ORIGINS: 'https://app.example.com' })), + ).not.toThrow(); + }); + + it('rejects wildcard in production', () => { + expect(() => + validate( + validConfig({ + NODE_ENV: 'production', + CORS_ORIGINS: '*', + }), + ), + ).toThrow('wildcard (*) is not allowed in production'); + }); + + it('accepts wildcard in development', () => { + expect(() => + validate( + validConfig({ + NODE_ENV: 'development', + CORS_ORIGINS: '*', + }), + ), + ).not.toThrow(); + }); + + it('accepts wildcard in test', () => { + expect(() => + validate( + validConfig({ + NODE_ENV: 'test', + CORS_ORIGINS: '*', + }), + ), + ).not.toThrow(); + }); + }); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 85ccb39..c3081ec 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -86,10 +86,11 @@ class EnvironmentVariables { @MinLength(32) JWT_SECRET!: string; - /// Public origin this API is reached at, e.g. http://localhost:3001. Used - /// as the CORS allow-list and to build links in outbound email/webhooks. + /// Comma-separated list of allowed CORS origins (e.g. "https://app.example.com,https://staging.example.com"). + /// Wildcard (*) is NOT allowed in production. Used as the CORS allow-list + /// and to build links in outbound email/webhooks. @IsString() - APP_URL!: string; + CORS_ORIGINS!: string; /// Soroban RPC endpoint the StellarService submits contract calls through. @IsString() @@ -154,5 +155,10 @@ export function validate(config: Record) { throw new Error(`Invalid environment configuration: ${errors.toString()}`); } + const corsOrigins = validated.CORS_ORIGINS.split(',').map((o) => o.trim()); + if (validated.NODE_ENV === 'production' && corsOrigins.includes('*')) { + throw new Error('CORS_ORIGINS wildcard (*) is not allowed in production'); + } + return validated; } diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts index e9f2c7f..d129a02 100644 --- a/src/events/events.service.spec.ts +++ b/src/events/events.service.spec.ts @@ -71,7 +71,7 @@ describe('EventsService', () => { chainEventId: null, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); await expect( service.buildPublishTx('organizer-1', 'event-1'), @@ -88,7 +88,7 @@ describe('EventsService', () => { chainEventId: 99n, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); await expect( service.buildPublishTx('organizer-1', 'event-1'), @@ -104,7 +104,7 @@ describe('EventsService', () => { chainEventId: null, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.ticketType.count.mockResolvedValue(0); const attempt = service.buildPublishTx('organizer-1', 'event-1'); @@ -131,7 +131,7 @@ describe('EventsService', () => { royaltyBps: 500, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.event.update.mockResolvedValue({}); const { unsignedXdr } = await service.buildPublishTx( @@ -165,7 +165,7 @@ describe('EventsService', () => { chainEventId: null, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); await expect( service.confirmPublish('organizer-1', 'event-1', 'signed-xdr'), @@ -180,7 +180,7 @@ describe('EventsService', () => { chainEventId: 99n, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); stellar.getEvent.mockResolvedValue({ eventId: 100n, organizer: 'GOTHER', @@ -200,7 +200,7 @@ describe('EventsService', () => { chainEventId: 99n, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.event.update.mockResolvedValue( createEvent({ id: 'event-1', status: 'PUBLISHED' }), ); @@ -232,7 +232,7 @@ describe('EventsService', () => { status: 'PUBLISHED', }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.ticket.count.mockResolvedValue(0); prisma.event.update.mockResolvedValue( createEvent({ id: 'event-1', status: 'DRAFT' }), @@ -312,7 +312,11 @@ describe('EventsService', () => { expect(prisma.event.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { organizationId: 'org-1', deletedAt: null, status: 'PUBLISHED' }, + where: { + organizationId: 'org-1', + deletedAt: null, + status: 'PUBLISHED', + }, }), ); }); @@ -384,7 +388,7 @@ describe('EventsService', () => { ...createEvent({ id: 'event-1', organizationId: 'org-1' }), deletedAt: new Date('2026-09-26T00:00:00.000Z'), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); await expect(service.getWithOrg('event-1')).rejects.toMatchObject({ status: 404, @@ -396,7 +400,7 @@ describe('EventsService', () => { ...createEvent({ id: 'event-1', organizationId: 'org-1' }), deletedAt: null, organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.event.update.mockResolvedValue({ id: 'event-1' }); await service.softDelete('organizer-1', 'event-1'); @@ -412,7 +416,7 @@ describe('EventsService', () => { ...createEvent({ id: 'event-1', organizationId: 'org-1' }), deletedAt: new Date('2026-09-26T00:00:00.000Z'), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.event.update.mockResolvedValue({ id: 'event-1' }); await service.restore('organizer-1', 'event-1'); diff --git a/src/main.ts b/src/main.ts index 34a53eb..bfedf70 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,22 +1,31 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe, VersioningType } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; import { AppModule } from './app.module'; +import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); const config = app.get(ConfigService); app.use(helmet()); + + const corsOrigins = config + .getOrThrow('CORS_ORIGINS') + .split(',') + .map((o) => o.trim()); app.enableCors({ - origin: config.getOrThrow('APP_URL'), + origin: corsOrigins, credentials: true, }); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1', }); + app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -25,6 +34,19 @@ async function bootstrap() { }), ); + app.useGlobalFilters(new GlobalExceptionFilter()); + + if (config.get('NODE_ENV') !== 'production') { + const swaggerConfig = new DocumentBuilder() + .setTitle('Drips API') + .setDescription('API for Drips ticketing platform') + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('docs', app, document); + } + await app.listen(config.getOrThrow('PORT')); } void bootstrap(); diff --git a/src/organizations/organizations.controller.ts b/src/organizations/organizations.controller.ts index 3cdfea7..668bffd 100644 --- a/src/organizations/organizations.controller.ts +++ b/src/organizations/organizations.controller.ts @@ -37,18 +37,12 @@ export class OrganizationsController { } @Delete(':id') - softDelete( - @CurrentUser() user: CurrentUserPayload, - @Param('id') id: string, - ) { + softDelete(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) { return this.organizationsService.softDelete(user.userId, id); } @Post(':id/restore') - restore( - @CurrentUser() user: CurrentUserPayload, - @Param('id') id: string, - ) { + restore(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) { return this.organizationsService.restore(user.userId, id); } } diff --git a/src/organizations/organizations.service.spec.ts b/src/organizations/organizations.service.spec.ts index de10c7e..6cbfa78 100644 --- a/src/organizations/organizations.service.spec.ts +++ b/src/organizations/organizations.service.spec.ts @@ -105,7 +105,7 @@ describe('OrganizationsService', () => { describe('soft-delete (#207)', () => { it('soft-deletes by setting deletedAt instead of hard-deleting', async () => { prisma.organization.findUnique.mockResolvedValue( - createOrganization({ id: 'org-1', deletedAt: null } as never), + createOrganization({ id: 'org-1', deletedAt: null }), ); prisma.organizationMember.findUnique.mockResolvedValue({ id: 'membership-1', diff --git a/src/organizations/organizations.service.ts b/src/organizations/organizations.service.ts index a7e5fdf..5e55990 100644 --- a/src/organizations/organizations.service.ts +++ b/src/organizations/organizations.service.ts @@ -38,9 +38,15 @@ export class OrganizationsService { }); // #209 — audit org creation (best-effort, never blocks the response). - await this.audit?.record(userId, 'organization.create', 'Organization', org.id, { - slug: org.slug, - }); + await this.audit?.record( + userId, + 'organization.create', + 'Organization', + org.id, + { + slug: org.slug, + }, + ); return org; } @@ -84,7 +90,12 @@ export class OrganizationsService { where: { id }, data: { deletedAt: null }, }); - await this.audit?.record(userId, 'organization.restore', 'Organization', id); + await this.audit?.record( + userId, + 'organization.restore', + 'Organization', + id, + ); return restored; } diff --git a/src/tickets/tickets.service.spec.ts b/src/tickets/tickets.service.spec.ts index 43a6258..69fd1a5 100644 --- a/src/tickets/tickets.service.spec.ts +++ b/src/tickets/tickets.service.spec.ts @@ -346,7 +346,7 @@ describe('TicketsService', () => { organizationId: 'org-1', organization: createOrganization({ stellarAccount: 'GORG' }), }, - } as never); + }); await expect( service.buildTransferTx( @@ -369,7 +369,7 @@ describe('TicketsService', () => { organizationId: 'org-1', organization: createOrganization({ stellarAccount: 'GORG' }), }, - } as never); + }); prisma.user.findUnique .mockResolvedValueOnce( createUser({ id: 'owner-1', stellarPublicKey: 'GOWNER' }), @@ -562,10 +562,17 @@ describe('TicketsService', () => { }, ticketType: { price: 1_000n }, }); - prisma.resaleListing.findFirst.mockResolvedValueOnce({ id: 'existing-listing' }); + prisma.resaleListing.findFirst.mockResolvedValueOnce({ + id: 'existing-listing', + }); await expect( - service.confirmListForResale('owner-1', 'ticket-1', '1200', 'signed-xdr'), + service.confirmListForResale( + 'owner-1', + 'ticket-1', + '1200', + 'signed-xdr', + ), ).rejects.toBeInstanceOf(ConflictException); // Fail fast: the chain is never touched and no listing row is written. @@ -588,18 +595,20 @@ describe('TicketsService', () => { // Both transactions race past the pre-check; the DB partial unique // index rejects the loser with a P2002 on the active-listing index. prisma.$transaction.mockRejectedValueOnce( - new Prisma.PrismaClientKnownRequestError( - 'Unique constraint failed', - { - code: 'P2002', - clientVersion: 'test', - meta: { target: ['ticketId', 'ResaleListing_ticketId_active_key'] }, - }, - ), + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: 'test', + meta: { target: ['ticketId', 'ResaleListing_ticketId_active_key'] }, + }), ); await expect( - service.confirmListForResale('owner-1', 'ticket-1', '1200', 'signed-xdr'), + service.confirmListForResale( + 'owner-1', + 'ticket-1', + '1200', + 'signed-xdr', + ), ).rejects.toBeInstanceOf(ConflictException); }); @@ -802,7 +811,7 @@ describe('TicketsService', () => { it('filters by status when provided, using the (ownerId, status) index (#213)', async () => { prisma.ticket.findMany.mockResolvedValue([]); - await service.findMine('owner-1', 'VALID' as never); + await service.findMine('owner-1', 'VALID'); expect(prisma.ticket.findMany).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/tickets/tickets.service.ts b/src/tickets/tickets.service.ts index a6a501d..4ffd1a6 100644 --- a/src/tickets/tickets.service.ts +++ b/src/tickets/tickets.service.ts @@ -914,7 +914,9 @@ export class TicketsService { err.code === 'P2002' ) { const target = (err.meta as { target?: unknown } | undefined)?.target; - const targets = Array.isArray(target) ? target.join(',') : String(target ?? ''); + const targets = Array.isArray(target) + ? target.join(',') + : String(target ?? ''); if (targets.includes('seat') || targets.includes('Ticket_eventId_seat')) { throw new ConflictException( 'That seat has already been issued for this event', @@ -948,7 +950,9 @@ export class TicketsService { err.code === 'P2002' ) { const target = (err.meta as { target?: unknown } | undefined)?.target; - const targets = Array.isArray(target) ? target.join(',') : String(target ?? ''); + const targets = Array.isArray(target) + ? target.join(',') + : String(target ?? ''); if (targets.includes('ResaleListing_ticketId_active')) { throw new ConflictException( 'This ticket already has an active resale listing', diff --git a/test/events.e2e-spec.ts b/test/events.e2e-spec.ts index 7be0644..1fe94af 100644 --- a/test/events.e2e-spec.ts +++ b/test/events.e2e-spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { randomUUID } from 'node:crypto'; diff --git a/test/mocks/swagger.mock.ts b/test/mocks/swagger.mock.ts new file mode 100644 index 0000000..b10a706 --- /dev/null +++ b/test/mocks/swagger.mock.ts @@ -0,0 +1,99 @@ +export const ApiPropertyOptional = (): PropertyDecorator => { + return () => {}; +}; + +export const ApiProperty = (): PropertyDecorator => { + return () => {}; +}; + +export const ApiTags = (): ClassDecorator => { + return () => {}; +}; + +export const ApiBearerAuth = (): MethodDecorator => { + return () => {}; +}; + +export const ApiOperation = (): MethodDecorator => { + return () => {}; +}; + +export const ApiResponse = (): MethodDecorator => { + return () => {}; +}; + +export const ApiQuery = (): MethodDecorator => { + return () => {}; +}; + +export const ApiParam = (): MethodDecorator => { + return () => {}; +}; + +export const ApiBody = (): MethodDecorator => { + return () => {}; +}; + +export const ApiExtraModels = (): ClassDecorator => { + return () => {}; +}; + +export const ApiHeader = (): MethodDecorator => { + return () => {}; +}; + +export const ApiCookieAuth = (): MethodDecorator => { + return () => {}; +}; + +export const ApiOAuth2 = (): MethodDecorator => { + return () => {}; +}; + +export const ApiSecurity = (): MethodDecorator => { + return () => {}; +}; + +export const ApiDeprecated = (): MethodDecorator => { + return () => {}; +}; + +export const ApiExcludeEndpoint = (): MethodDecorator => { + return () => {}; +}; + +export const ApiExcludeController = (): ClassDecorator => { + return () => {}; +}; + +export const SwaggerModule = { + createDocument: jest.fn(), + setup: jest.fn(), +}; + +export const DocumentBuilder = class { + setTitle() { + return this; + } + setDescription() { + return this; + } + setVersion() { + return this; + } + addBearerAuth() { + return this; + } + addOAuth2() { + return this; + } + addApiKey() { + return this; + } + addCookieAuth() { + return this; + } + build() { + return {}; + } +}; diff --git a/test/purchase-concurrency.e2e-spec.ts b/test/purchase-concurrency.e2e-spec.ts index 4638a45..cebb39a 100644 --- a/test/purchase-concurrency.e2e-spec.ts +++ b/test/purchase-concurrency.e2e-spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { randomUUID } from 'node:crypto'; @@ -31,7 +35,9 @@ describe('Ticket purchase concurrency (e2e, #217)', () => { let userIndex: Map; const mockStellarService = { - buildPurchasePrimaryTx: jest.fn().mockResolvedValue('unsigned-purchase-xdr'), + buildPurchasePrimaryTx: jest + .fn() + .mockResolvedValue('unsigned-purchase-xdr'), submitSignedTransaction: jest.fn().mockImplementation(() => Promise.resolve({ result: BigInt(Math.floor(Math.random() * 1_000_000) + 1), diff --git a/test/resale.e2e-spec.ts b/test/resale.e2e-spec.ts index a03b536..edca4b0 100644 --- a/test/resale.e2e-spec.ts +++ b/test/resale.e2e-spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { randomUUID } from 'node:crypto'; @@ -33,10 +37,12 @@ describe('Resale flow (e2e)', () => { .mockResolvedValue({ txHash: 'b'.repeat(64) }), }; - async function seedTicket(overrides: { - price?: bigint; - maxResaleMultiplierBps?: number; - } = {}) { + async function seedTicket( + overrides: { + price?: bigint; + maxResaleMultiplierBps?: number; + } = {}, + ) { const seller = await prisma.user.create({ data: createUser({ id: randomUUID(), diff --git a/test/validation-pipe.e2e-spec.ts b/test/validation-pipe.e2e-spec.ts index 641b76e..c80b61f 100644 --- a/test/validation-pipe.e2e-spec.ts +++ b/test/validation-pipe.e2e-spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; @@ -51,18 +55,17 @@ describe('ValidationPipe Global Config (e2e)', () => { .expect(400) .expect((res) => { expect(res.body).toHaveProperty('message'); - expect(Array.isArray(res.body.message) || typeof res.body.message === 'string').toBe( - true, - ); + expect( + Array.isArray(res.body.message) || + typeof res.body.message === 'string', + ).toBe(true); }); }); }); describe('Whitespace trimming and validation', () => { it('should enforce validation on trimmed string fields', () => { - return request(app.getHttpServer()) - .get('/v1/industries') - .expect(200); + return request(app.getHttpServer()).get('/v1/industries').expect(200); }); }); });