create-nexst-monorepo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1727 -0
- package/package.json +37 -0
- package/template/.agents/plugins/marketplace.json +21 -0
- package/template/.claude/settings.json +6 -0
- package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/template/.claude/skills/frontend-design/SKILL.md +154 -0
- package/template/.claude/skills/theme-factory/SKILL.md +95 -0
- package/template/.claude/skills/theme-factory/references/schema.md +104 -0
- package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
- package/template/.dockerignore +43 -0
- package/template/.editorconfig +10 -0
- package/template/.github/workflows/ci.yaml +289 -0
- package/template/.github/workflows/publish-app.yaml +66 -0
- package/template/.github/workflows/publish.yaml +148 -0
- package/template/.github/workflows/snyk.yaml +34 -0
- package/template/.vscode/settings.json +8 -0
- package/template/AGENTS.md +42 -0
- package/template/Dockerfile.base +9 -0
- package/template/README.md +54 -0
- package/template/_gitignore +10 -0
- package/template/apps/api/.env.example +32 -0
- package/template/apps/api/AGENTS.md +44 -0
- package/template/apps/api/Dockerfile +69 -0
- package/template/apps/api/_gitignore +8 -0
- package/template/apps/api/knip.config.ts +22 -0
- package/template/apps/api/kustomize/base/application.properties +16 -0
- package/template/apps/api/kustomize/base/deployment.yaml +94 -0
- package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/base/service.yaml +10 -0
- package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
- package/template/apps/api/kustomize/secret.keys +14 -0
- package/template/apps/api/oxfmt.config.ts +8 -0
- package/template/apps/api/oxlint.config.ts +107 -0
- package/template/apps/api/package.json +79 -0
- package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/schema.prisma +113 -0
- package/template/apps/api/prisma.config.ts +7 -0
- package/template/apps/api/scripts/auth.cli.ts +19 -0
- package/template/apps/api/scripts/seal-secret.sh +171 -0
- package/template/apps/api/sonar-project.properties +9 -0
- package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
- package/template/apps/api/src/app-info/app-info.module.ts +7 -0
- package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
- package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
- package/template/apps/api/src/app.module.spec.ts +93 -0
- package/template/apps/api/src/app.module.ts +77 -0
- package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
- package/template/apps/api/src/auth/auth.guard.ts +97 -0
- package/template/apps/api/src/auth/auth.ts +129 -0
- package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
- package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
- package/template/apps/api/src/auth/testing/make-session.ts +31 -0
- package/template/apps/api/src/auth/types/auth.types.ts +27 -0
- package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
- package/template/apps/api/src/bootstrap/security.ts +24 -0
- package/template/apps/api/src/bootstrap/swagger.ts +28 -0
- package/template/apps/api/src/config/app.config.spec.ts +22 -0
- package/template/apps/api/src/config/app.config.ts +122 -0
- package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
- package/template/apps/api/src/config/graphql.config.ts +77 -0
- package/template/apps/api/src/config/resources/app.yaml +29 -0
- package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
- package/template/apps/api/src/errors/app.exception.ts +47 -0
- package/template/apps/api/src/errors/error-code.enum.ts +54 -0
- package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
- package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
- package/template/apps/api/src/health/health.controller.spec.ts +82 -0
- package/template/apps/api/src/health/health.controller.ts +57 -0
- package/template/apps/api/src/health/health.module.ts +12 -0
- package/template/apps/api/src/logging/context-logger.ts +18 -0
- package/template/apps/api/src/logging/logger.config.ts +23 -0
- package/template/apps/api/src/logging/logging.module.ts +22 -0
- package/template/apps/api/src/mail/mail.module.ts +12 -0
- package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
- package/template/apps/api/src/mail/mail.service.ts +123 -0
- package/template/apps/api/src/main.ts +41 -0
- package/template/apps/api/src/prisma/prisma.module.ts +12 -0
- package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
- package/template/apps/api/src/prisma/prisma.service.ts +138 -0
- package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
- package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
- package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
- package/template/apps/api/src/queue/example-queue.service.ts +32 -0
- package/template/apps/api/src/queue/queue.module.ts +27 -0
- package/template/apps/api/src/r2/r2.module.ts +12 -0
- package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
- package/template/apps/api/src/r2/r2.service.ts +131 -0
- package/template/apps/api/src/user/dto/user.dto.ts +26 -0
- package/template/apps/api/src/user/user.module.ts +10 -0
- package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
- package/template/apps/api/src/user/user.resolver.ts +24 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
- package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
- package/template/apps/api/src/utils/error-handler.util.ts +12 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
- package/template/apps/api/src/utils/pagination.input.ts +20 -0
- package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
- package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
- package/template/apps/api/tests/app.e2e-spec.ts +35 -0
- package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
- package/template/apps/api/tests/helpers/api.ts +27 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/api/vitest.config.e2e.ts +16 -0
- package/template/apps/api/vitest.config.ts +40 -0
- package/template/apps/web/.env.example +7 -0
- package/template/apps/web/.storybook/decorators.tsx +38 -0
- package/template/apps/web/.storybook/main.ts +17 -0
- package/template/apps/web/.storybook/preview.ts +38 -0
- package/template/apps/web/.storybook/vitest.setup.ts +5 -0
- package/template/apps/web/AGENTS.md +29 -0
- package/template/apps/web/Dockerfile +49 -0
- package/template/apps/web/_gitignore +16 -0
- package/template/apps/web/knip.config.ts +15 -0
- package/template/apps/web/kustomize/base/application.properties +5 -0
- package/template/apps/web/kustomize/base/deployment.yaml +60 -0
- package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/web/kustomize/base/service.yaml +10 -0
- package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
- package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/web/messages/de.json +124 -0
- package/template/apps/web/messages/en.json +124 -0
- package/template/apps/web/next-env.d.ts +7 -0
- package/template/apps/web/next.config.ts +39 -0
- package/template/apps/web/oxfmt.config.ts +9 -0
- package/template/apps/web/oxlint.config.ts +36 -0
- package/template/apps/web/package.json +80 -0
- package/template/apps/web/playwright.config.ts +34 -0
- package/template/apps/web/postcss.config.mjs +7 -0
- package/template/apps/web/sonar-project.properties +9 -0
- package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
- package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
- package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
- package/template/apps/web/src/app/[locale]/page.tsx +23 -0
- package/template/apps/web/src/app/api/health/route.ts +3 -0
- package/template/apps/web/src/app/global-error.tsx +19 -0
- package/template/apps/web/src/app/globals.css +98 -0
- package/template/apps/web/src/app/icon.svg +1 -0
- package/template/apps/web/src/app/robots.ts +15 -0
- package/template/apps/web/src/app/sitemap.ts +24 -0
- package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
- package/template/apps/web/src/components/alert/alert.tsx +50 -0
- package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
- package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
- package/template/apps/web/src/components/button/button-variants.ts +28 -0
- package/template/apps/web/src/components/button/button.stories.tsx +33 -0
- package/template/apps/web/src/components/button/button.tsx +32 -0
- package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
- package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
- package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
- package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
- package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
- package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
- package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
- package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
- package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
- package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
- package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
- package/template/apps/web/src/components/empty-state.tsx +33 -0
- package/template/apps/web/src/components/field-group.stories.tsx +47 -0
- package/template/apps/web/src/components/field-group.tsx +20 -0
- package/template/apps/web/src/components/field-label.stories.tsx +34 -0
- package/template/apps/web/src/components/field-label.tsx +28 -0
- package/template/apps/web/src/components/icons.tsx +25 -0
- package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
- package/template/apps/web/src/components/input/input-otp.tsx +184 -0
- package/template/apps/web/src/components/input/input.stories.tsx +35 -0
- package/template/apps/web/src/components/input/input.tsx +180 -0
- package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
- package/template/apps/web/src/components/popover/popover.tsx +45 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
- package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
- package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
- package/template/apps/web/src/components/select/select.stories.tsx +293 -0
- package/template/apps/web/src/components/select/select.tsx +445 -0
- package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
- package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
- package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
- package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
- package/template/apps/web/src/components/switch/switch.tsx +35 -0
- package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
- package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
- package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
- package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
- package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
- package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
- package/template/apps/web/src/components/toast/toaster.tsx +38 -0
- package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
- package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
- package/template/apps/web/src/config/csp.ts +59 -0
- package/template/apps/web/src/config/routes.ts +14 -0
- package/template/apps/web/src/config/site.ts +5 -0
- package/template/apps/web/src/env.ts +15 -0
- package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
- package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
- package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
- package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
- package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
- package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
- package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
- package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
- package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
- package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
- package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
- package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
- package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
- package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
- package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
- package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
- package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
- package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
- package/template/apps/web/src/features/shared/provider.tsx +16 -0
- package/template/apps/web/src/features/shared/site-header.tsx +48 -0
- package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
- package/template/apps/web/src/i18n/navigation.ts +5 -0
- package/template/apps/web/src/i18n/request.ts +21 -0
- package/template/apps/web/src/i18n/routing.ts +6 -0
- package/template/apps/web/src/lib/auth-client.ts +19 -0
- package/template/apps/web/src/lib/auth-server.ts +46 -0
- package/template/apps/web/src/lib/auth.ts +8 -0
- package/template/apps/web/src/lib/codegen.ts +30 -0
- package/template/apps/web/src/lib/graphql-client.ts +29 -0
- package/template/apps/web/src/lib/graphql-server.ts +28 -0
- package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
- package/template/apps/web/src/lib/graphql-utils.ts +76 -0
- package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
- package/template/apps/web/src/lib/metadata.ts +17 -0
- package/template/apps/web/src/lib/network-error.test.ts +30 -0
- package/template/apps/web/src/lib/network-error.ts +17 -0
- package/template/apps/web/src/lib/proxy-utils.ts +42 -0
- package/template/apps/web/src/lib/query-client.ts +29 -0
- package/template/apps/web/src/lib/utils.ts +5 -0
- package/template/apps/web/src/proxy.ts +57 -0
- package/template/apps/web/test-results/.last-run.json +4 -0
- package/template/apps/web/tests/auth.spec.ts +17 -0
- package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
- package/template/apps/web/tests/home.spec.ts +25 -0
- package/template/apps/web/tsconfig.build.json +4 -0
- package/template/apps/web/tsconfig.json +28 -0
- package/template/apps/web/vitest.config.ts +40 -0
- package/template/apps/web/vitest.shims.d.ts +1 -0
- package/template/apps/worker/.env.example +4 -0
- package/template/apps/worker/AGENTS.md +11 -0
- package/template/apps/worker/Dockerfile +35 -0
- package/template/apps/worker/knip.config.ts +3 -0
- package/template/apps/worker/kustomize/base/application.properties +4 -0
- package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
- package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
- package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/worker/oxfmt.config.ts +8 -0
- package/template/apps/worker/oxlint.config.ts +15 -0
- package/template/apps/worker/package.json +33 -0
- package/template/apps/worker/sonar-project.properties +8 -0
- package/template/apps/worker/src/env.ts +13 -0
- package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
- package/template/apps/worker/src/lib/logger.ts +20 -0
- package/template/apps/worker/src/main.ts +17 -0
- package/template/apps/worker/src/processors/example-processor.ts +15 -0
- package/template/apps/worker/src/worker/redis.ts +19 -0
- package/template/apps/worker/src/worker/worker.ts +37 -0
- package/template/apps/worker/tests/example-processor.test.ts +13 -0
- package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
- package/template/apps/worker/tsconfig.json +4 -0
- package/template/apps/worker/vitest.config.ts +13 -0
- package/template/bun.lock +3053 -0
- package/template/bunfig.toml +2 -0
- package/template/compose.yaml +53 -0
- package/template/docker-bake.hcl +86 -0
- package/template/package.json +47 -0
- package/template/packages/lint/knip.ts +40 -0
- package/template/packages/lint/oxfmt.ts +8 -0
- package/template/packages/lint/oxlint.ts +136 -0
- package/template/packages/lint/package.json +13 -0
- package/template/packages/queues/oxfmt.config.ts +8 -0
- package/template/packages/queues/oxlint.config.ts +7 -0
- package/template/packages/queues/package.json +15 -0
- package/template/packages/queues/src/example-job.ts +11 -0
- package/template/packages/queues/src/index.ts +3 -0
- package/template/packages/queues/src/queue-names.ts +2 -0
- package/template/packages/queues/src/redis-connection.ts +27 -0
- package/template/packages/queues/tsconfig.json +4 -0
- package/template/packages/tsconfig/base.json +23 -0
- package/template/packages/tsconfig/node.json +11 -0
- package/template/packages/tsconfig/package.json +4 -0
- package/template/turbo.json +50 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { ArgumentsHost } from "@nestjs/common";
|
|
2
|
+
import { HttpException, HttpStatus } from "@nestjs/common";
|
|
3
|
+
import { HttpAdapterHost } from "@nestjs/core";
|
|
4
|
+
import type { TestingModule } from "@nestjs/testing";
|
|
5
|
+
import { Test } from "@nestjs/testing";
|
|
6
|
+
import { vi } from "vitest";
|
|
7
|
+
import type { Mock } from "vitest";
|
|
8
|
+
import { AppException } from "#src/errors/app.exception.js";
|
|
9
|
+
import { ErrorCode } from "#src/errors/error-code.enum.js";
|
|
10
|
+
import { ContextLogger } from "#src/logging/context-logger.js";
|
|
11
|
+
import { CatchEverythingFilter } from "#src/utils/catch-everything-exception.filter.js";
|
|
12
|
+
|
|
13
|
+
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
14
|
+
|
|
15
|
+
describe("catchEverythingFilter", () => {
|
|
16
|
+
let filter: CatchEverythingFilter;
|
|
17
|
+
let mockArgumentsHost: ArgumentsHost;
|
|
18
|
+
let mockHttpAdapter: {
|
|
19
|
+
getRequestUrl: Mock;
|
|
20
|
+
reply: Mock;
|
|
21
|
+
};
|
|
22
|
+
let mockRequest: object;
|
|
23
|
+
let mockResponse: object;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
mockHttpAdapter = {
|
|
27
|
+
getRequestUrl: vi.fn().mockReturnValue("/test-path"),
|
|
28
|
+
reply: vi.fn(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
mockRequest = {};
|
|
32
|
+
mockResponse = {};
|
|
33
|
+
|
|
34
|
+
const mockHttpArgumentsHost = {
|
|
35
|
+
getNext: vi.fn(),
|
|
36
|
+
getRequest: vi.fn().mockReturnValue(mockRequest),
|
|
37
|
+
getResponse: vi.fn().mockReturnValue(mockResponse),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
mockArgumentsHost = {
|
|
41
|
+
getArgByIndex: vi.fn(),
|
|
42
|
+
getArgs: vi.fn(),
|
|
43
|
+
getType: vi.fn().mockReturnValue("http"),
|
|
44
|
+
switchToHttp: vi.fn().mockReturnValue(mockHttpArgumentsHost),
|
|
45
|
+
switchToRpc: vi.fn(),
|
|
46
|
+
switchToWs: vi.fn(),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const mockHttpAdapterHost = {
|
|
50
|
+
httpAdapter: mockHttpAdapter,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const mockLogger = { debug: vi.fn(), error: vi.fn(), setContext: vi.fn() };
|
|
54
|
+
|
|
55
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
56
|
+
providers: [
|
|
57
|
+
CatchEverythingFilter,
|
|
58
|
+
{ provide: ContextLogger, useValue: mockLogger },
|
|
59
|
+
{
|
|
60
|
+
provide: HttpAdapterHost,
|
|
61
|
+
useValue: mockHttpAdapterHost,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}).compile();
|
|
65
|
+
|
|
66
|
+
filter = module.get<CatchEverythingFilter>(CatchEverythingFilter);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.clearAllMocks();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("catch", () => {
|
|
74
|
+
it("should fall back to a generic code for a plain HttpException with a string message", () => {
|
|
75
|
+
const exception = new HttpException("Test error message", HttpStatus.BAD_REQUEST);
|
|
76
|
+
|
|
77
|
+
filter.catch(exception, mockArgumentsHost);
|
|
78
|
+
|
|
79
|
+
expect(mockArgumentsHost.switchToHttp).toHaveBeenCalled();
|
|
80
|
+
expect(mockHttpAdapter.getRequestUrl).toHaveBeenCalledWith(mockRequest);
|
|
81
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
82
|
+
mockResponse,
|
|
83
|
+
{
|
|
84
|
+
code: ErrorCode.BAD_REQUEST,
|
|
85
|
+
path: "/test-path",
|
|
86
|
+
statusCode: HttpStatus.BAD_REQUEST,
|
|
87
|
+
timestamp: expect.any(String),
|
|
88
|
+
},
|
|
89
|
+
HttpStatus.BAD_REQUEST,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should fall back to a generic code for a plain HttpException with an object response", () => {
|
|
94
|
+
const exception = new HttpException(
|
|
95
|
+
{ errors: ["field1", "field2"], message: "Validation failed" },
|
|
96
|
+
HttpStatus.UNPROCESSABLE_ENTITY,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
filter.catch(exception, mockArgumentsHost);
|
|
100
|
+
|
|
101
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
102
|
+
mockResponse,
|
|
103
|
+
{
|
|
104
|
+
code: ErrorCode.UNPROCESSABLE_ENTITY,
|
|
105
|
+
path: "/test-path",
|
|
106
|
+
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
|
107
|
+
timestamp: expect.any(String),
|
|
108
|
+
},
|
|
109
|
+
HttpStatus.UNPROCESSABLE_ENTITY,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should handle generic errors as internal server error", () => {
|
|
114
|
+
const exception = new Error("Something went wrong");
|
|
115
|
+
|
|
116
|
+
filter.catch(exception, mockArgumentsHost);
|
|
117
|
+
|
|
118
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
119
|
+
mockResponse,
|
|
120
|
+
{
|
|
121
|
+
code: ErrorCode.INTERNAL_SERVER_ERROR,
|
|
122
|
+
path: "/test-path",
|
|
123
|
+
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
124
|
+
timestamp: expect.any(String),
|
|
125
|
+
},
|
|
126
|
+
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should handle unknown exceptions as internal server error", () => {
|
|
131
|
+
const exception = "Unknown error type";
|
|
132
|
+
|
|
133
|
+
filter.catch(exception, mockArgumentsHost);
|
|
134
|
+
|
|
135
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
136
|
+
mockResponse,
|
|
137
|
+
{
|
|
138
|
+
code: ErrorCode.INTERNAL_SERVER_ERROR,
|
|
139
|
+
path: "/test-path",
|
|
140
|
+
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
141
|
+
timestamp: expect.any(String),
|
|
142
|
+
},
|
|
143
|
+
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should pass an AppException's own code and params through directly", () => {
|
|
148
|
+
const exception = new AppException(ErrorCode.NOT_FOUND, HttpStatus.NOT_FOUND, {
|
|
149
|
+
userId: "user-1",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
filter.catch(exception, mockArgumentsHost);
|
|
153
|
+
|
|
154
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
155
|
+
mockResponse,
|
|
156
|
+
{
|
|
157
|
+
code: ErrorCode.NOT_FOUND,
|
|
158
|
+
params: { userId: "user-1" },
|
|
159
|
+
path: "/test-path",
|
|
160
|
+
statusCode: HttpStatus.NOT_FOUND,
|
|
161
|
+
timestamp: expect.any(String),
|
|
162
|
+
},
|
|
163
|
+
HttpStatus.NOT_FOUND,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should omit params for an AppException that carries none", () => {
|
|
168
|
+
const exception = new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST);
|
|
169
|
+
|
|
170
|
+
filter.catch(exception, mockArgumentsHost);
|
|
171
|
+
|
|
172
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
173
|
+
mockResponse,
|
|
174
|
+
{
|
|
175
|
+
code: ErrorCode.VALIDATION_FAILED,
|
|
176
|
+
path: "/test-path",
|
|
177
|
+
statusCode: HttpStatus.BAD_REQUEST,
|
|
178
|
+
timestamp: expect.any(String),
|
|
179
|
+
},
|
|
180
|
+
HttpStatus.BAD_REQUEST,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should format timestamp as ISO string", () => {
|
|
185
|
+
const exception = new HttpException("Test", HttpStatus.BAD_REQUEST);
|
|
186
|
+
const beforeCall = new Date().toISOString();
|
|
187
|
+
|
|
188
|
+
filter.catch(exception, mockArgumentsHost);
|
|
189
|
+
|
|
190
|
+
const responseBody = mockHttpAdapter.reply.mock.calls[0][1];
|
|
191
|
+
const afterCall = new Date().toISOString();
|
|
192
|
+
|
|
193
|
+
expect(responseBody.timestamp).toMatch(ISO_TIMESTAMP_RE);
|
|
194
|
+
expect(responseBody.timestamp >= beforeCall).toBe(true);
|
|
195
|
+
expect(responseBody.timestamp <= afterCall).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should throw exception when context type is not http", () => {
|
|
199
|
+
const exception = new Error("Test error");
|
|
200
|
+
const nonHttpHost = {
|
|
201
|
+
...mockArgumentsHost,
|
|
202
|
+
getType: vi.fn().mockReturnValue("rpc"),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
expect(() => {
|
|
206
|
+
filter.catch(exception, nonHttpHost);
|
|
207
|
+
}).toThrow(exception);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should handle NotFoundException correctly", () => {
|
|
211
|
+
const exception = new HttpException("Resource not found", HttpStatus.NOT_FOUND);
|
|
212
|
+
|
|
213
|
+
filter.catch(exception, mockArgumentsHost);
|
|
214
|
+
|
|
215
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
216
|
+
mockResponse,
|
|
217
|
+
{
|
|
218
|
+
code: ErrorCode.NOT_FOUND,
|
|
219
|
+
path: "/test-path",
|
|
220
|
+
statusCode: HttpStatus.NOT_FOUND,
|
|
221
|
+
timestamp: expect.any(String),
|
|
222
|
+
},
|
|
223
|
+
HttpStatus.NOT_FOUND,
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should handle ForbiddenException correctly", () => {
|
|
228
|
+
const exception = new HttpException("Access denied", HttpStatus.FORBIDDEN);
|
|
229
|
+
|
|
230
|
+
filter.catch(exception, mockArgumentsHost);
|
|
231
|
+
|
|
232
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
233
|
+
mockResponse,
|
|
234
|
+
{
|
|
235
|
+
code: ErrorCode.FORBIDDEN,
|
|
236
|
+
path: "/test-path",
|
|
237
|
+
statusCode: HttpStatus.FORBIDDEN,
|
|
238
|
+
timestamp: expect.any(String),
|
|
239
|
+
},
|
|
240
|
+
HttpStatus.FORBIDDEN,
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("should handle UnauthorizedException correctly", () => {
|
|
245
|
+
const exception = new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED);
|
|
246
|
+
|
|
247
|
+
filter.catch(exception, mockArgumentsHost);
|
|
248
|
+
|
|
249
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
250
|
+
mockResponse,
|
|
251
|
+
{
|
|
252
|
+
code: ErrorCode.UNAUTHORIZED,
|
|
253
|
+
path: "/test-path",
|
|
254
|
+
statusCode: HttpStatus.UNAUTHORIZED,
|
|
255
|
+
timestamp: expect.any(String),
|
|
256
|
+
},
|
|
257
|
+
HttpStatus.UNAUTHORIZED,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should map a 429 to TOO_MANY_REQUESTS rather than INTERNAL_SERVER_ERROR", () => {
|
|
262
|
+
const exception = new HttpException(
|
|
263
|
+
"ThrottlerException: Too Many Requests",
|
|
264
|
+
HttpStatus.TOO_MANY_REQUESTS,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
filter.catch(exception, mockArgumentsHost);
|
|
268
|
+
|
|
269
|
+
expect(mockHttpAdapter.reply).toHaveBeenCalledWith(
|
|
270
|
+
mockResponse,
|
|
271
|
+
{
|
|
272
|
+
code: ErrorCode.TOO_MANY_REQUESTS,
|
|
273
|
+
path: "/test-path",
|
|
274
|
+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
|
275
|
+
timestamp: expect.any(String),
|
|
276
|
+
},
|
|
277
|
+
HttpStatus.TOO_MANY_REQUESTS,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("should extract correct path from request", () => {
|
|
282
|
+
mockHttpAdapter.getRequestUrl.mockReturnValue("/api/v1/users/123");
|
|
283
|
+
const exception = new HttpException("Test", HttpStatus.OK);
|
|
284
|
+
|
|
285
|
+
filter.catch(exception, mockArgumentsHost);
|
|
286
|
+
|
|
287
|
+
const responseBody = mockHttpAdapter.reply.mock.calls[0][1];
|
|
288
|
+
|
|
289
|
+
expect(responseBody.path).toBe("/api/v1/users/123");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Catch, HttpException, HttpStatus } from "@nestjs/common";
|
|
2
|
+
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
|
3
|
+
import { HttpAdapterHost } from "@nestjs/core";
|
|
4
|
+
import { AppException } from "#src/errors/app.exception.js";
|
|
5
|
+
import { fallbackCodeForStatus } from "#src/errors/http-status-fallback-code.util.js";
|
|
6
|
+
import { ContextLogger } from "#src/logging/context-logger.js";
|
|
7
|
+
import { handleError } from "#src/utils/error-handler.util.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Global exception filter that catches all unhandled exceptions in HTTP contexts.
|
|
11
|
+
*
|
|
12
|
+
* This filter provides a consistent error response format for all exceptions
|
|
13
|
+
* that are not caught by more specific exception filters. It handles both
|
|
14
|
+
* NestJS HttpExceptions and unexpected errors.
|
|
15
|
+
*/
|
|
16
|
+
@Catch()
|
|
17
|
+
export class CatchEverythingFilter implements ExceptionFilter {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly httpAdapterHost: HttpAdapterHost,
|
|
20
|
+
private readonly logger: ContextLogger,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Catches and processes all exceptions.
|
|
25
|
+
*
|
|
26
|
+
* @param exception - The exception that was thrown (can be any type)
|
|
27
|
+
* @param host - The arguments host containing request/response context
|
|
28
|
+
*
|
|
29
|
+
* @throws Re-throws the exception if the context is not HTTP
|
|
30
|
+
*/
|
|
31
|
+
catch(exception: unknown, host: ArgumentsHost): void {
|
|
32
|
+
if (host.getType() !== "http") {
|
|
33
|
+
throw exception;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { httpAdapter } = this.httpAdapterHost;
|
|
37
|
+
|
|
38
|
+
const ctx = host.switchToHttp();
|
|
39
|
+
|
|
40
|
+
const httpStatus =
|
|
41
|
+
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
|
42
|
+
|
|
43
|
+
if (!(exception instanceof HttpException) || httpStatus >= HttpStatus.INTERNAL_SERVER_ERROR) {
|
|
44
|
+
const url = httpAdapter.getRequestUrl(ctx.getRequest());
|
|
45
|
+
this.logger.error(
|
|
46
|
+
{
|
|
47
|
+
error: handleError(exception),
|
|
48
|
+
stack: exception instanceof Error ? exception.stack : undefined,
|
|
49
|
+
url: String(url),
|
|
50
|
+
},
|
|
51
|
+
"Unhandled exception",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { code, params } =
|
|
56
|
+
exception instanceof AppException
|
|
57
|
+
? { code: exception.code, params: exception.params }
|
|
58
|
+
: { code: fallbackCodeForStatus(httpStatus), params: undefined };
|
|
59
|
+
|
|
60
|
+
const responseBody = {
|
|
61
|
+
code,
|
|
62
|
+
...(params ? { params } : {}),
|
|
63
|
+
path: httpAdapter.getRequestUrl(ctx.getRequest()),
|
|
64
|
+
statusCode: httpStatus,
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { handleError } from "#src/utils/error-handler.util.js";
|
|
2
|
+
|
|
3
|
+
describe("handleError", () => {
|
|
4
|
+
it("should return error message for Error instances", () => {
|
|
5
|
+
const error = new Error("Test error");
|
|
6
|
+
|
|
7
|
+
expect(handleError(error)).toBe("Test error");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("should convert non-Error objects to string", () => {
|
|
11
|
+
expect(handleError("string error")).toBe("string error");
|
|
12
|
+
expect(handleError(123)).toBe("123");
|
|
13
|
+
expect(handleError({ message: "object" })).toBe("[object Object]");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should handle null and undefined", () => {
|
|
17
|
+
expect(handleError(null)).toBe("null");
|
|
18
|
+
expect(handleError(undefined)).toBe("undefined");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts an unknown error to a string message.
|
|
3
|
+
*
|
|
4
|
+
* @param error - The error to convert
|
|
5
|
+
* @returns Error message string
|
|
6
|
+
*/
|
|
7
|
+
export function handleError(error: unknown): string {
|
|
8
|
+
if (error instanceof Error) {
|
|
9
|
+
return error.message;
|
|
10
|
+
}
|
|
11
|
+
return String(error);
|
|
12
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { escapeLikePattern } from "#src/utils/escape-like-pattern.util.js";
|
|
2
|
+
|
|
3
|
+
describe("escapeLikePattern", () => {
|
|
4
|
+
it("should escape percent signs", () => {
|
|
5
|
+
expect(escapeLikePattern("100%")).toBe("100\\%");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it("should escape underscores", () => {
|
|
9
|
+
expect(escapeLikePattern("TU_Berlin")).toBe("TU\\_Berlin");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should escape backslashes", () => {
|
|
13
|
+
expect(escapeLikePattern("a\\b")).toBe("a\\\\b");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should escape mixed metacharacters without double-escaping", () => {
|
|
17
|
+
expect(escapeLikePattern("%%%%")).toBe("\\%\\%\\%\\%");
|
|
18
|
+
expect(escapeLikePattern("____")).toBe("\\_\\_\\_\\_");
|
|
19
|
+
expect(escapeLikePattern("100%_off\\now")).toBe("100\\%\\_off\\\\now");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should leave plain strings unchanged", () => {
|
|
23
|
+
expect(escapeLikePattern("Karlsruhe")).toBe("Karlsruhe");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should handle an empty string", () => {
|
|
27
|
+
expect(escapeLikePattern("")).toBe("");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escapes Postgres LIKE/ILIKE wildcard characters (`%`, `_`, `\`) in raw user
|
|
3
|
+
* input so `contains`/`startsWith`/`endsWith` filters match them literally
|
|
4
|
+
* instead of as pattern wildcards.
|
|
5
|
+
*
|
|
6
|
+
* @param value - Raw user-supplied search string
|
|
7
|
+
* @returns The string with LIKE metacharacters backslash-escaped
|
|
8
|
+
*/
|
|
9
|
+
export function escapeLikePattern(value: string): string {
|
|
10
|
+
return value.replace(/[\\%_]/g, "\\$&");
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Field, InputType, Int } from "@nestjs/graphql";
|
|
2
|
+
import { IsInt, IsOptional, Max, Min } from "class-validator";
|
|
3
|
+
|
|
4
|
+
const MAX_PAGE_SIZE = 100;
|
|
5
|
+
|
|
6
|
+
@InputType()
|
|
7
|
+
export class PaginationInput {
|
|
8
|
+
@Field(() => Int, { nullable: true })
|
|
9
|
+
@IsInt()
|
|
10
|
+
@IsOptional()
|
|
11
|
+
@Min(0)
|
|
12
|
+
skip?: number;
|
|
13
|
+
|
|
14
|
+
@Field(() => Int, { nullable: true })
|
|
15
|
+
@IsInt()
|
|
16
|
+
@IsOptional()
|
|
17
|
+
@Max(MAX_PAGE_SIZE)
|
|
18
|
+
@Min(1)
|
|
19
|
+
take?: number;
|
|
20
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { ArgumentsHost } from "@nestjs/common";
|
|
2
|
+
import { HttpStatus } from "@nestjs/common";
|
|
3
|
+
import { HttpAdapterHost } from "@nestjs/core";
|
|
4
|
+
import type { TestingModule } from "@nestjs/testing";
|
|
5
|
+
import { Test } from "@nestjs/testing";
|
|
6
|
+
import { vi } from "vitest";
|
|
7
|
+
import type { Mock, MockInstance } from "vitest";
|
|
8
|
+
import { ErrorCode } from "#src/errors/error-code.enum.js";
|
|
9
|
+
import { Prisma } from "#src/generated/prisma/client.js";
|
|
10
|
+
import { ContextLogger } from "#src/logging/context-logger.js";
|
|
11
|
+
import { PrismaExceptionFilter } from "#src/utils/prisma-exception.filter.js";
|
|
12
|
+
|
|
13
|
+
describe("prismaExceptionFilter", () => {
|
|
14
|
+
let filter: PrismaExceptionFilter;
|
|
15
|
+
let mockArgumentsHost: ArgumentsHost;
|
|
16
|
+
let mockResponse: {
|
|
17
|
+
send: Mock;
|
|
18
|
+
status: Mock;
|
|
19
|
+
};
|
|
20
|
+
let loggerErrorSpy: MockInstance;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
mockResponse = {
|
|
24
|
+
send: vi.fn(),
|
|
25
|
+
status: vi.fn().mockReturnThis(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const mockHttpArgumentsHost = {
|
|
29
|
+
getNext: vi.fn(),
|
|
30
|
+
getRequest: vi.fn(),
|
|
31
|
+
getResponse: vi.fn().mockReturnValue(mockResponse),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
mockArgumentsHost = {
|
|
35
|
+
getArgByIndex: vi.fn(),
|
|
36
|
+
getArgs: vi.fn(),
|
|
37
|
+
getType: vi.fn().mockReturnValue("http"),
|
|
38
|
+
switchToHttp: vi.fn().mockReturnValue(mockHttpArgumentsHost),
|
|
39
|
+
switchToRpc: vi.fn(),
|
|
40
|
+
switchToWs: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const mockHttpAdapterHost = {
|
|
44
|
+
httpAdapter: {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const mockLogger = { debug: vi.fn(), error: vi.fn(), setContext: vi.fn() };
|
|
48
|
+
|
|
49
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
50
|
+
providers: [
|
|
51
|
+
PrismaExceptionFilter,
|
|
52
|
+
{ provide: ContextLogger, useValue: mockLogger },
|
|
53
|
+
{
|
|
54
|
+
provide: HttpAdapterHost,
|
|
55
|
+
useValue: mockHttpAdapterHost,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}).compile();
|
|
59
|
+
|
|
60
|
+
filter = module.get<PrismaExceptionFilter>(PrismaExceptionFilter);
|
|
61
|
+
loggerErrorSpy = mockLogger.error;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("catch", () => {
|
|
69
|
+
it("should handle P2002 (unique constraint violation) error", () => {
|
|
70
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
|
71
|
+
"Unique constraint failed on the fields: (`email`)",
|
|
72
|
+
{
|
|
73
|
+
clientVersion: "5.0.0",
|
|
74
|
+
code: "P2002",
|
|
75
|
+
meta: { target: ["email"] },
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
filter.catch(prismaError, mockArgumentsHost);
|
|
80
|
+
|
|
81
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
82
|
+
{ code: "P2002", error: prismaError.message },
|
|
83
|
+
"Prisma error",
|
|
84
|
+
);
|
|
85
|
+
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
|
|
86
|
+
expect(mockResponse.send).toHaveBeenCalledWith({
|
|
87
|
+
code: ErrorCode.CONFLICT,
|
|
88
|
+
params: { target: ["email"] },
|
|
89
|
+
statusCode: HttpStatus.CONFLICT,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should omit params when the constraint carries no target field", () => {
|
|
94
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
|
95
|
+
"Unique constraint failed on the fields: (`email`)",
|
|
96
|
+
{ clientVersion: "5.0.0", code: "P2002" },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
filter.catch(prismaError, mockArgumentsHost);
|
|
100
|
+
|
|
101
|
+
expect(mockResponse.send).toHaveBeenCalledWith({
|
|
102
|
+
code: ErrorCode.CONFLICT,
|
|
103
|
+
statusCode: HttpStatus.CONFLICT,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should log error for non-P2002 errors", () => {
|
|
108
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
|
109
|
+
clientVersion: "5.0.0",
|
|
110
|
+
code: "P2025",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(() => {
|
|
114
|
+
filter.catch(prismaError, mockArgumentsHost);
|
|
115
|
+
}).toThrow();
|
|
116
|
+
|
|
117
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
118
|
+
{ code: "P2025", error: prismaError.message },
|
|
119
|
+
"Prisma error",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should handle P2002 error with multiple fields", () => {
|
|
124
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
|
125
|
+
"Unique constraint failed on the fields: (`userId`, `postId`)",
|
|
126
|
+
{
|
|
127
|
+
clientVersion: "5.0.0",
|
|
128
|
+
code: "P2002",
|
|
129
|
+
meta: { target: ["userId", "postId"] },
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
filter.catch(prismaError, mockArgumentsHost);
|
|
134
|
+
|
|
135
|
+
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
|
|
136
|
+
expect(mockResponse.send).toHaveBeenCalledWith({
|
|
137
|
+
code: ErrorCode.CONFLICT,
|
|
138
|
+
params: { target: ["userId", "postId"] },
|
|
139
|
+
statusCode: HttpStatus.CONFLICT,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should log error message to console", () => {
|
|
144
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error message", {
|
|
145
|
+
clientVersion: "5.0.0",
|
|
146
|
+
code: "P2002",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
filter.catch(prismaError, mockArgumentsHost);
|
|
150
|
+
|
|
151
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
152
|
+
{ code: "P2002", error: "Test error message" },
|
|
153
|
+
"Prisma error",
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should log error for P2003 (foreign key constraint) error", () => {
|
|
158
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
|
159
|
+
"Foreign key constraint failed",
|
|
160
|
+
{
|
|
161
|
+
clientVersion: "5.0.0",
|
|
162
|
+
code: "P2003",
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(() => {
|
|
167
|
+
filter.catch(prismaError, mockArgumentsHost);
|
|
168
|
+
}).toThrow();
|
|
169
|
+
|
|
170
|
+
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should log error for P2001 (record not found) error", () => {
|
|
174
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
|
175
|
+
clientVersion: "5.0.0",
|
|
176
|
+
code: "P2001",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(() => {
|
|
180
|
+
filter.catch(prismaError, mockArgumentsHost);
|
|
181
|
+
}).toThrow();
|
|
182
|
+
|
|
183
|
+
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Catch, HttpStatus } from "@nestjs/common";
|
|
2
|
+
import type { ArgumentsHost } from "@nestjs/common";
|
|
3
|
+
import { BaseExceptionFilter } from "@nestjs/core";
|
|
4
|
+
import type { FastifyReply } from "fastify";
|
|
5
|
+
import { ErrorCode } from "#src/errors/error-code.enum.js";
|
|
6
|
+
import { Prisma } from "#src/generated/prisma/client.js";
|
|
7
|
+
import { ContextLogger } from "#src/logging/context-logger.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Exception filter for handling Prisma database errors.
|
|
11
|
+
*
|
|
12
|
+
* This filter intercepts Prisma client errors and transforms them into
|
|
13
|
+
* appropriate HTTP responses. It specifically handles known Prisma error
|
|
14
|
+
* codes and maps them to corresponding HTTP status codes.
|
|
15
|
+
*/
|
|
16
|
+
@Catch(Prisma.PrismaClientKnownRequestError)
|
|
17
|
+
export class PrismaExceptionFilter extends BaseExceptionFilter {
|
|
18
|
+
constructor(private readonly logger: ContextLogger) {
|
|
19
|
+
super();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Catches and processes Prisma client errors.
|
|
24
|
+
*
|
|
25
|
+
* @param exception - The Prisma client error that was thrown
|
|
26
|
+
* @param host - The arguments host containing request/response context
|
|
27
|
+
*/
|
|
28
|
+
override catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
|
|
29
|
+
this.logger.error({ code: exception.code, error: exception.message }, "Prisma error");
|
|
30
|
+
const ctx = host.switchToHttp();
|
|
31
|
+
const response = ctx.getResponse<FastifyReply>();
|
|
32
|
+
|
|
33
|
+
if (exception.code === "P2002") {
|
|
34
|
+
const status = HttpStatus.CONFLICT;
|
|
35
|
+
const target = Array.isArray(exception.meta?.target) ? exception.meta.target : undefined;
|
|
36
|
+
response.status(status).send({
|
|
37
|
+
code: ErrorCode.CONFLICT,
|
|
38
|
+
...(target ? { params: { target } } : {}),
|
|
39
|
+
statusCode: status,
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
super.catch(exception, host);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|