From e1ee670ef7732b05c1b796ddb792ebae680c07bb Mon Sep 17 00:00:00 2001 From: ganeshwhere Date: Sun, 8 Mar 2026 05:36:48 +0530 Subject: [PATCH 1/2] feat(server): add request id middleware and structured observability logs --- apps/api/README.md | 31 ++++ apps/api/src/app.module.spec.ts | 18 +- apps/api/src/app.module.ts | 16 +- .../http-observability.interceptor.spec.ts | 91 ++++++++++ .../http-observability.interceptor.ts | 166 ++++++++++++++++++ .../interfaces/request-with-id.interface.ts | 8 + .../middleware/request-id.middleware.spec.ts | 51 ++++++ .../middleware/request-id.middleware.ts | 36 ++++ 8 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/observability/interceptors/http-observability.interceptor.spec.ts create mode 100644 apps/api/src/observability/interceptors/http-observability.interceptor.ts create mode 100644 apps/api/src/observability/interfaces/request-with-id.interface.ts create mode 100644 apps/api/src/observability/middleware/request-id.middleware.spec.ts create mode 100644 apps/api/src/observability/middleware/request-id.middleware.ts diff --git a/apps/api/README.md b/apps/api/README.md index f831cbb..14fe852 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -84,6 +84,37 @@ API default URL: `http://localhost:4000` - Tasks: `/projects/:projectId/tasks/...` - Project chat: `/projects/:projectId/chat/messages` +## Observability + +- Every request is assigned an `x-request-id`: + - if client sends a valid `x-request-id`, server reuses it + - otherwise server generates one and returns it in response headers +- Server emits structured JSON logs for: + - all requests (`http.request`) + - all write mutations (`http.mutation`) + - request failures (`http.error`) +- Error logs include request ID, route/method, actor context (when authenticated), and route params. + +### Local Debugging + +Run API and filter logs by request ID: + +```bash +pnpm --filter api dev +``` + +In another terminal: + +```bash +curl -i -H "x-request-id: local-debug-12345678" http://localhost:4000/teams +``` + +Then search logs: + +```bash +pnpm --filter api dev | rg "local-debug-12345678" +``` + ## Troubleshooting ### Prisma auth/database errors diff --git a/apps/api/src/app.module.spec.ts b/apps/api/src/app.module.spec.ts index d8ea595..402e3b8 100644 --- a/apps/api/src/app.module.spec.ts +++ b/apps/api/src/app.module.spec.ts @@ -1,8 +1,9 @@ -import { APP_GUARD } from "@nestjs/core"; +import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; import { MODULE_METADATA } from "@nestjs/common/constants"; import { RolesGuard } from "./auth/guards/roles.guard"; import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard"; +import { HttpObservabilityInterceptor } from "./observability/interceptors/http-observability.interceptor"; import { AppModule } from "./app.module"; import { AppThrottlerGuard } from "./security/guards/app-throttler.guard"; @@ -21,3 +22,18 @@ describe("AppModule guard registration", () => { expect(appGuards).not.toContain(RolesGuard); }); }); + +describe("AppModule interceptor registration", () => { + it("registers HttpObservabilityInterceptor as APP_INTERCEPTOR", () => { + const providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AppModule) ?? []) as Array<{ + provide?: unknown; + useClass?: unknown; + }>; + + const appInterceptors = providers + .filter((provider) => provider.provide === APP_INTERCEPTOR) + .map((provider) => provider.useClass); + + expect(appInterceptors).toEqual([HttpObservabilityInterceptor]); + }); +}); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 678aee7..38520c0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,10 +1,12 @@ -import { Module } from "@nestjs/common"; -import { APP_GUARD } from "@nestjs/core"; +import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; +import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; import { ThrottlerModule } from "@nestjs/throttler"; import { AuthModule } from "./auth/auth.module"; import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard"; import { MailModule } from "./mail/mail.module"; +import { HttpObservabilityInterceptor } from "./observability/interceptors/http-observability.interceptor"; +import { RequestIdMiddleware } from "./observability/middleware/request-id.middleware"; import { PrismaModule } from "./prisma/prisma.module"; import { ProjectChatModule } from "./project-chat/project-chat.module"; import { ProjectsModule } from "./projects/projects.module"; @@ -35,6 +37,14 @@ import { UsersModule } from "./users/users.module"; provide: APP_GUARD, useClass: AppThrottlerGuard, }, + { + provide: APP_INTERCEPTOR, + useClass: HttpObservabilityInterceptor, + }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(RequestIdMiddleware).forRoutes("*"); + } +} diff --git a/apps/api/src/observability/interceptors/http-observability.interceptor.spec.ts b/apps/api/src/observability/interceptors/http-observability.interceptor.spec.ts new file mode 100644 index 0000000..ccf05c7 --- /dev/null +++ b/apps/api/src/observability/interceptors/http-observability.interceptor.spec.ts @@ -0,0 +1,91 @@ +import { + HttpException, + HttpStatus, + Logger, + type CallHandler, + type ExecutionContext, +} from "@nestjs/common"; +import { lastValueFrom, of, throwError } from "rxjs"; + +import { HttpObservabilityInterceptor } from "./http-observability.interceptor"; + +function createHttpExecutionContext( + request: Record, + response: Record, +): ExecutionContext { + return { + getType: () => "http", + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => response, + }), + } as ExecutionContext; +} + +describe("HttpObservabilityInterceptor", () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it("emits structured mutation log with request and actor context", async () => { + const logSpy = jest.spyOn(Logger.prototype, "log").mockImplementation(); + const interceptor = new HttpObservabilityInterceptor(); + const request = { + requestId: "req-12345678", + method: "POST", + originalUrl: "/projects/proj_1/tasks", + params: { projectId: "proj_1" }, + user: { sub: "user_1", email: "member@teamflow.dev" }, + }; + const response = { statusCode: 201 }; + const context = createHttpExecutionContext(request, response); + const next: CallHandler = { + handle: () => of({ ok: true }), + }; + + await lastValueFrom(interceptor.intercept(context, next)); + + expect(logSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as Record; + + expect(payload.event).toBe("http.mutation"); + expect(payload.requestId).toBe("req-12345678"); + expect(payload.method).toBe("POST"); + expect(payload.path).toBe("/projects/proj_1/tasks"); + expect(payload.statusCode).toBe(201); + expect(payload.actorId).toBe("user_1"); + expect(payload.actorEmail).toBe("member@teamflow.dev"); + expect(payload.routeParams).toEqual({ projectId: "proj_1" }); + }); + + it("emits structured error log with status and request context", async () => { + const errorSpy = jest.spyOn(Logger.prototype, "error").mockImplementation(); + const interceptor = new HttpObservabilityInterceptor(); + const request = { + requestId: "req-err-87654321", + method: "PATCH", + originalUrl: "/projects/proj_1/tasks/task_1", + params: { projectId: "proj_1", id: "task_1" }, + user: { sub: "user_1", email: "member@teamflow.dev" }, + }; + const response = {}; + const context = createHttpExecutionContext(request, response); + const next: CallHandler = { + handle: () => throwError(() => new HttpException("Forbidden resource", HttpStatus.FORBIDDEN)), + }; + + await expect(lastValueFrom(interceptor.intercept(context, next))).rejects.toBeInstanceOf( + HttpException, + ); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string) as Record; + + expect(payload.event).toBe("http.error"); + expect(payload.requestId).toBe("req-err-87654321"); + expect(payload.statusCode).toBe(403); + expect(payload.errorName).toBe("HttpException"); + expect(payload.errorMessage).toBe("Forbidden resource"); + expect(payload.routeParams).toEqual({ projectId: "proj_1", id: "task_1" }); + }); +}); diff --git a/apps/api/src/observability/interceptors/http-observability.interceptor.ts b/apps/api/src/observability/interceptors/http-observability.interceptor.ts new file mode 100644 index 0000000..b2267a9 --- /dev/null +++ b/apps/api/src/observability/interceptors/http-observability.interceptor.ts @@ -0,0 +1,166 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + Injectable, + Logger, + NestInterceptor, +} from "@nestjs/common"; +import { Observable, catchError, tap, throwError } from "rxjs"; + +import type { RequestWithId } from "../interfaces/request-with-id.interface"; + +type ObservabilityEvent = "http.request" | "http.mutation" | "http.error"; + +type ObservabilityLog = { + actorEmail?: string; + actorId?: string; + durationMs: number; + errorMessage?: string; + errorName?: string; + event: ObservabilityEvent; + method: string; + path: string; + requestId: string; + routeParams?: Record; + statusCode: number; + timestamp: string; +}; + +@Injectable() +export class HttpObservabilityInterceptor implements NestInterceptor { + private readonly logger = new Logger(HttpObservabilityInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== "http") { + return next.handle(); + } + + const { request, response } = this.getRequestResponse(context); + const startedAt = performance.now(); + + return next.handle().pipe( + tap(() => { + const durationMs = Number((performance.now() - startedAt).toFixed(1)); + const statusCode = this.readStatusCode(response?.statusCode, 200); + const event: ObservabilityEvent = this.isMutation(request.method) + ? "http.mutation" + : "http.request"; + + this.logger.log(this.toJsonLog(this.buildBaseLog(request, event, statusCode, durationMs))); + }), + catchError((error: unknown) => { + const durationMs = Number((performance.now() - startedAt).toFixed(1)); + const statusCode = this.resolveErrorStatusCode(error); + const logPayload: ObservabilityLog = { + ...this.buildBaseLog(request, "http.error", statusCode, durationMs), + errorName: error instanceof Error ? error.name : "UnknownError", + errorMessage: error instanceof Error ? error.message : "Unhandled error", + }; + + this.logger.error(this.toJsonLog(logPayload)); + return throwError(() => error); + }), + ); + } + + private buildBaseLog( + request: RequestWithId, + event: ObservabilityEvent, + statusCode: number, + durationMs: number, + ): ObservabilityLog { + return { + timestamp: new Date().toISOString(), + event, + requestId: this.getRequestId(request), + method: (request.method ?? "UNKNOWN").toUpperCase(), + path: request.originalUrl ?? request.url ?? "unknown", + statusCode, + durationMs, + actorId: request.user?.sub, + actorEmail: request.user?.email, + routeParams: this.getRouteParams(request), + }; + } + + private getRouteParams(request: RequestWithId): Record | undefined { + const params = request.params; + if (!params || typeof params !== "object") { + return undefined; + } + + const pairs = Object.entries(params) + .map(([key, value]) => [key, typeof value === "string" ? value : String(value)] as const) + .filter(([, value]) => value.trim().length > 0); + + if (pairs.length === 0) { + return undefined; + } + + return Object.fromEntries(pairs); + } + + private getRequestId(request: RequestWithId): string { + const requestId = request.requestId?.trim(); + if (requestId && requestId.length > 0) { + return requestId; + } + + return "unknown"; + } + + private resolveErrorStatusCode(error: unknown): number { + if (error instanceof HttpException) { + return error.getStatus(); + } + + if ( + typeof error === "object" && + error !== null && + "status" in error && + typeof (error as { status: unknown }).status === "number" + ) { + return this.readStatusCode((error as { status: number }).status, 500); + } + + return 500; + } + + private readStatusCode(candidate: unknown, fallback: number): number { + if (typeof candidate !== "number" || !Number.isInteger(candidate) || candidate < 100) { + return fallback; + } + + return candidate; + } + + private isMutation(method?: string): boolean { + if (!method) { + return false; + } + + const normalizedMethod = method.toUpperCase(); + return ( + normalizedMethod === "POST" || + normalizedMethod === "PATCH" || + normalizedMethod === "PUT" || + normalizedMethod === "DELETE" + ); + } + + private toJsonLog(payload: ObservabilityLog): string { + return JSON.stringify(payload); + } + + private getRequestResponse(context: ExecutionContext): { + request: RequestWithId; + response: { statusCode?: number }; + } { + const http = context.switchToHttp(); + const request = http.getRequest(); + const response = http.getResponse<{ statusCode?: number }>(); + + return { request, response }; + } +} diff --git a/apps/api/src/observability/interfaces/request-with-id.interface.ts b/apps/api/src/observability/interfaces/request-with-id.interface.ts new file mode 100644 index 0000000..f17f979 --- /dev/null +++ b/apps/api/src/observability/interfaces/request-with-id.interface.ts @@ -0,0 +1,8 @@ +import type { Request } from "express"; + +import type { AuthUser } from "../../auth/interfaces/auth-user.interface"; + +export type RequestWithId = Request & { + requestId?: string; + user?: AuthUser; +}; diff --git a/apps/api/src/observability/middleware/request-id.middleware.spec.ts b/apps/api/src/observability/middleware/request-id.middleware.spec.ts new file mode 100644 index 0000000..8f2f55f --- /dev/null +++ b/apps/api/src/observability/middleware/request-id.middleware.spec.ts @@ -0,0 +1,51 @@ +import type { NextFunction, Response } from "express"; + +import type { RequestWithId } from "../interfaces/request-with-id.interface"; +import { RequestIdMiddleware } from "./request-id.middleware"; + +describe("RequestIdMiddleware", () => { + const next = jest.fn() as unknown as NextFunction; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + function createResponse(): Response { + return { + setHeader: jest.fn(), + } as unknown as Response; + } + + it("reuses incoming valid x-request-id header", () => { + const middleware = new RequestIdMiddleware(); + const request = { + headers: { + "x-request-id": "client-trace-12345", + }, + } as unknown as RequestWithId; + const response = createResponse(); + + middleware.use(request, response, next); + + expect(request.requestId).toBe("client-trace-12345"); + expect(response.setHeader).toHaveBeenCalledWith("x-request-id", "client-trace-12345"); + expect(next).toHaveBeenCalled(); + }); + + it("generates request id when header is missing or invalid", () => { + const middleware = new RequestIdMiddleware(); + const request = { + headers: { + "x-request-id": "bad", + }, + } as unknown as RequestWithId; + const response = createResponse(); + + middleware.use(request, response, next); + + expect(request.requestId).toBeDefined(); + expect(request.requestId?.length).toBeGreaterThanOrEqual(8); + expect(response.setHeader).toHaveBeenCalledWith("x-request-id", request.requestId); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/observability/middleware/request-id.middleware.ts b/apps/api/src/observability/middleware/request-id.middleware.ts new file mode 100644 index 0000000..ca2dd82 --- /dev/null +++ b/apps/api/src/observability/middleware/request-id.middleware.ts @@ -0,0 +1,36 @@ +import { Injectable, type NestMiddleware } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; +import type { NextFunction, Response } from "express"; + +import type { RequestWithId } from "../interfaces/request-with-id.interface"; + +const REQUEST_ID_HEADER = "x-request-id"; +const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/; + +function isValidRequestId(candidate?: string): candidate is string { + return Boolean(candidate && REQUEST_ID_PATTERN.test(candidate)); +} + +function readRequestIdHeader(request: RequestWithId): string | undefined { + const headerValue = request.headers[REQUEST_ID_HEADER]; + const rawValue = Array.isArray(headerValue) ? headerValue[0] : headerValue; + + if (typeof rawValue !== "string") { + return undefined; + } + + const trimmed = rawValue.trim(); + return isValidRequestId(trimmed) ? trimmed : undefined; +} + +@Injectable() +export class RequestIdMiddleware implements NestMiddleware { + use(request: RequestWithId, response: Response, next: NextFunction): void { + const incomingRequestId = readRequestIdHeader(request); + const requestId = incomingRequestId ?? randomUUID(); + + request.requestId = requestId; + response.setHeader(REQUEST_ID_HEADER, requestId); + next(); + } +} From b256927f2a24738d3b3b15df690fd7ac30914ef0 Mon Sep 17 00:00:00 2001 From: ganeshwhere Date: Sun, 8 Mar 2026 05:47:20 +0530 Subject: [PATCH 2/2] chore(ci): enforce coverage thresholds and upload test artifacts --- .github/workflows/ci.yml | 20 +++++++++- .gitignore | 1 + CONTRIBUTING.md | 11 ++++++ README.md | 6 +++ apps/api/jest.config.ts | 13 +++++- apps/api/package.json | 2 +- apps/web/package.json | 4 +- apps/web/vitest.config.ts | 33 +++++++++++++--- docs/testing/coverage-policy.md | 59 +++++++++++++++++++++++++++ pnpm-lock.yaml | 70 +++++++++++++++++++++++++++++++++ 10 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 docs/testing/coverage-policy.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 218ec83..8eee510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,24 @@ jobs: - name: Lint run: pnpm lint - - name: Test - run: pnpm test + - name: Test (server coverage) + run: pnpm --filter api test:cov + + - name: Test (client coverage) + run: pnpm --filter web test:cov - name: Build run: pnpm build + + - name: Upload Test Artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-test-artifacts-${{ github.run_id }} + path: | + apps/api/coverage + apps/api/test-results + apps/web/coverage + apps/web/test-results + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index ef6b5d3..ff12f78 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules .next dist coverage +test-results .env .env.local .DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22b61cf..acc64b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,6 +108,17 @@ pnpm --filter api test pnpm --filter web test ``` +### Running coverage locally + +```bash +pnpm --filter api test:cov +pnpm --filter web test:cov +``` + +Coverage thresholds are enforced in CI for both suites. See +[`docs/testing/coverage-policy.md`](./docs/testing/coverage-policy.md) +for thresholds, artifact details, and rollout policy. + ## Database and Prisma Rules When changing `apps/api/prisma/schema.prisma`: diff --git a/README.md b/README.md index 6d90fe2..b3b496f 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,12 @@ Package-level examples: - `pnpm --filter api test` - `pnpm --filter web test` +- `pnpm --filter api test:cov` +- `pnpm --filter web test:cov` + +Coverage policy and CI quality gate details: + +- [`docs/testing/coverage-policy.md`](./docs/testing/coverage-policy.md) ## API Route Overview diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index 0e18cff..eb15f4e 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -5,11 +5,20 @@ const config: Config = { rootDir: ".", testRegex: ".*\\.spec\\.ts$", transform: { - "^.+\\.(t|j)sx?$": "ts-jest" + "^.+\\.(t|j)sx?$": "ts-jest", }, collectCoverageFrom: ["src/**/*.(t|j)s"], coverageDirectory: "./coverage", - testEnvironment: "node" + coverageReporters: ["text-summary", "json-summary", "lcov"], + coverageThreshold: { + global: { + statements: 70, + branches: 55, + functions: 50, + lines: 70, + }, + }, + testEnvironment: "node", }; export default config; diff --git a/apps/api/package.json b/apps/api/package.json index 23d2c5a..28e6bb3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,7 +13,7 @@ "prisma:seed": "prisma db seed", "test": "jest", "test:watch": "jest --watch", - "test:cov": "jest --coverage" + "test:cov": "mkdir -p test-results && jest --coverage --ci --json --outputFile=./test-results/jest-results.json" }, "dependencies": { "@nestjs/common": "^10.4.5", diff --git a/apps/web/package.json b/apps/web/package.json index 175f7a6..1c5fd0a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,8 @@ "build": "next build", "dev": "next dev", "lint": "tsc --noEmit --project tsconfig.lint.json", - "test": "vitest run" + "test": "vitest run", + "test:cov": "mkdir -p test-results && vitest run --coverage" }, "dependencies": { "@auth/core": "^0.37.2", @@ -30,6 +31,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@vitest/coverage-v8": "^2.1.9", "@types/node": "^22.7.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 76f3e17..a15cd1a 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,17 +1,40 @@ import { defineConfig } from "vitest/config"; import { fileURLToPath } from "node:url"; +const isCI = Boolean(process.env.CI); + export default defineConfig({ esbuild: { - jsx: "automatic" + jsx: "automatic", }, resolve: { alias: { - "@": fileURLToPath(new URL(".", import.meta.url)) - } + "@": fileURLToPath(new URL(".", import.meta.url)), + }, }, test: { environment: "node", - include: ["**/*.spec.ts"] - } + include: ["**/*.spec.ts"], + reporters: isCI ? ["default", "junit"] : ["default"], + outputFile: isCI ? { junit: "./test-results/junit.xml" } : undefined, + coverage: { + provider: "v8", + reportsDirectory: "./coverage", + reporter: ["text-summary", "json-summary", "lcov"], + exclude: [ + "**/*.d.ts", + "**/*.spec.ts", + "**/test/**", + "**/.next/**", + "**/dist/**", + "**/node_modules/**", + ], + thresholds: { + statements: 7, + branches: 40, + functions: 30, + lines: 7, + }, + }, + }, }); diff --git a/docs/testing/coverage-policy.md b/docs/testing/coverage-policy.md new file mode 100644 index 0000000..675f682 --- /dev/null +++ b/docs/testing/coverage-policy.md @@ -0,0 +1,59 @@ +# Coverage Policy + +This repository enforces minimum automated test coverage for both server and client test suites in CI. + +## Current Baseline Thresholds + +## Server (`apps/api`, Jest) + +- Statements: `>= 70%` +- Branches: `>= 55%` +- Functions: `>= 50%` +- Lines: `>= 70%` + +Configured in: + +- `apps/api/jest.config.ts` + +## Client (`apps/web`, Vitest) + +- Statements: `>= 7%` +- Branches: `>= 40%` +- Functions: `>= 30%` +- Lines: `>= 7%` + +Configured in: + +- `apps/web/vitest.config.ts` + +## CI Enforcement + +CI runs coverage commands directly and fails pull requests if thresholds regress: + +- `pnpm --filter api test:cov` +- `pnpm --filter web test:cov` + +CI also uploads diagnostics artifacts on every run: + +- `apps/api/coverage` +- `apps/api/test-results` +- `apps/web/coverage` +- `apps/web/test-results` + +## Rollout Plan + +Coverage is ratcheted upward over time. Policy: + +1. Do not lower thresholds unless there is a documented emergency rollback approved by maintainers. +2. Raise thresholds after meaningful test additions, in small increments (for example, +2% to +5%). +3. Prefer improving weak modules first instead of raising only easy-to-cover areas. +4. Keep threshold changes in the same pull request as added tests when possible. + +## Local Verification + +Run these before opening a pull request: + +```bash +pnpm --filter api test:cov +pnpm --filter web test:cov +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d74fed..252e57e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,9 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.14) + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@22.19.13)(terser@5.46.0)) autoprefixer: specifier: ^10.4.20 version: 10.4.27(postcss@8.5.6) @@ -287,6 +290,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@angular-devkit/core@17.3.11': resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -2353,6 +2360,15 @@ packages: resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -3576,6 +3592,10 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -3912,6 +3932,9 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -4850,6 +4873,10 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -5297,6 +5324,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@angular-devkit/core@17.3.11(chokidar@3.6.0)': dependencies: ajv: 8.12.0 @@ -7459,6 +7491,24 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.19.13)(terser@5.46.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@22.19.13)(terser@5.46.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -8817,6 +8867,14 @@ snapshots: transitivePeerDependencies: - supports-color + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 @@ -9352,6 +9410,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -10327,6 +10391,12 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.4 + thenify-all@1.6.0: dependencies: thenify: 3.3.1