From bff3f2af4a3391c17fbb1f0602383fc3b63393d8 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 17:22:31 +0100 Subject: [PATCH 1/6] feat(test-utils): add custom response matchers for API error handling --- .../src/matchers/response-matchers.ts | 284 ++++++++++++++++++ .../src/matchers/schema-matchers.ts | 48 +++ packages/test-utils/src/matchers/types.d.ts | 8 + .../test-utils/src/matchers/vitest-setup.ts | 10 + 4 files changed, 350 insertions(+) create mode 100644 packages/test-utils/src/matchers/response-matchers.ts create mode 100644 packages/test-utils/src/matchers/schema-matchers.ts diff --git a/packages/test-utils/src/matchers/response-matchers.ts b/packages/test-utils/src/matchers/response-matchers.ts new file mode 100644 index 000000000..79cc4a6f4 --- /dev/null +++ b/packages/test-utils/src/matchers/response-matchers.ts @@ -0,0 +1,284 @@ +import type { ApiError } from "@ucdjs/schemas"; +import type { MatcherState, RawMatcherFn } from "@vitest/expect"; +import { tryOr } from "@ucdjs-internal/shared"; + +export interface ApiErrorOptions { + status: number; + message?: string | RegExp; +} + +export const toBeApiError: RawMatcherFn = async function ( + this: MatcherState, + received: Response, + options: ApiErrorOptions, +) { + const { isNot, equals } = this; + + if (!equals(received.status, options.status)) { + return { + pass: false, + message: () => `Expected response to${isNot ? " not" : ""} be an API error with status ${options.status}, but got ${received.status}`, + }; + } + + const contentType = received.headers.get("content-type"); + if (!contentType?.includes("application/json")) { + return { + pass: false, + message: () => `Expected response to${isNot ? " not" : ""} have application/json content-type`, + }; + } + + const error = await received.json() as ApiError; + + if (!error.status || !error.message || !error.timestamp) { + return { + pass: false, + message: () => `Expected response to${isNot ? " not" : ""} have status, message, and timestamp properties`, + }; + } + + if (options.message) { + const messageMatches = typeof options.message === "string" + ? error.message === options.message + : options.message.test(error.message); + + if (!messageMatches) { + const expectedMsg = typeof options.message === "string" + ? options.message + : options.message.source; + return { + pass: false, + message: () => `Expected error message to${isNot ? " not" : ""} match ${expectedMsg}, but got "${error.message}"`, + }; + } + } + + return { + pass: true, + message: () => `Expected response to${isNot ? " not" : ""} be an API error`, + }; +}; + +export interface HeadersOptions { + headers?: Record; + json?: boolean; + cache?: boolean; + cacheMaxAgePattern?: RegExp; +} + +export const toBeHeadError: RawMatcherFn = function ( + this: MatcherState, + received: Response, + expectedStatus: number, +) { + const { isNot, equals } = this; + + if (!equals(received.status, expectedStatus)) { + return { + pass: false, + message: () => `Expected HEAD response status to${isNot ? " not" : ""} be ${expectedStatus}, but got ${received.status}`, + }; + } + + const contentLength = received.headers.get("content-length"); + if (contentLength !== null && Number.parseInt(contentLength, 10) !== 0) { + return { + pass: false, + message: () => `Expected HEAD response to${isNot ? " not" : ""} have content-length of 0`, + }; + } + + return { + pass: true, + message: () => `Expected HEAD response to${isNot ? " not" : ""} have status ${expectedStatus}`, + }; +}; + +export interface ResponseMatcherOptions { + /** + * Expected HTTP status code + */ + status?: number; + + /** + * Expected response headers (supports exact match or regex pattern) + */ + headers?: Record; + + /** + * Whether to verify application/json content-type + */ + json?: boolean; + + /** + * Whether to verify cache-control header exists + */ + cache?: boolean; + + /** + * Regex pattern to match against cache-control max-age value + */ + cacheMaxAgePattern?: RegExp; + + /** + * For API error responses, validate error structure and message. + * When provided, ensures the response is JSON and contains status, message, and timestamp properties. + */ + error?: { + /** + * Expected error message (string for exact match, RegExp for pattern) + */ + message?: string | RegExp; + }; +} + +export const toMatchResponse: RawMatcherFn = async function ( + this: MatcherState, + received: Response, + options: ResponseMatcherOptions, +) { + const { isNot, equals } = this; + + // Check status code + if (options.status !== undefined && !equals(received.status, options.status)) { + return { + pass: false, + message: () => `Expected status to${isNot ? " not" : ""} be ${options.status}, but got ${received.status}`, + }; + } + + const contentType = received.headers.get("content-type"); + const isJson = contentType?.includes("application/json"); + + // Check if content-type is JSON. + if (options.json && !isJson) { + return { + pass: false, + message: () => `Expected response to${isNot ? " not" : ""} have application/json content-type`, + }; + } + + // Check cache headers if requested + if (options.cache) { + const cacheControl = received.headers.get("cache-control"); + if (!cacheControl) { + return { + pass: false, + message: () => `Expected response to${isNot ? " not" : ""} have cache-control header`, + }; + } + + if (options.cacheMaxAgePattern && !options.cacheMaxAgePattern.test(cacheControl)) { + return { + pass: false, + message: () => `Expected cache-control to${isNot ? " not" : ""} match ${options.cacheMaxAgePattern!.source}`, + }; + } + + if (!options.cacheMaxAgePattern && !/max-age=\d+/.test(cacheControl)) { + return { + pass: false, + message: () => `Expected cache-control to${isNot ? " not" : ""} have max-age`, + }; + } + } + + // Check custom headers + if (options.headers) { + for (const [key, value] of Object.entries(options.headers)) { + const headerValue = received.headers.get(key); + if (!headerValue) { + return { + pass: false, + message: () => `Expected response to${isNot ? " not" : ""} have ${key} header`, + }; + } + + const matches = typeof value === "string" + ? equals(headerValue, value) + : value.test(headerValue); + + if (!matches) { + const expected = typeof value === "string" ? value : value.source; + return { + pass: false, + message: () => `Expected ${key} header to${isNot ? " not" : ""} match ${expected}, but got "${headerValue}"`, + }; + } + } + } + + // Check error structure and message if requested + if (options.error) { + if (!isJson) { + return { + pass: false, + message: () => `Expected error response to${isNot ? " not" : ""} have application/json content-type`, + }; + } + + const error = await tryOr({ + try: async () => received.json() as Promise, + err(err) { + console.error("Failed to parse response JSON:", err); + return null; + }, + }); + + if (error == null) { + return { + pass: false, + message: () => `Expected response body to${isNot ? " not" : ""} be valid JSON`, + }; + } + + // Check required error properties + if (!error.status) { + return { + pass: false, + message: () => `Expected error to${isNot ? " not" : ""} have "status" property`, + }; + } + if (!error.message) { + return { + pass: false, + message: () => `Expected error to${isNot ? " not" : ""} have "message" property`, + }; + } + if (!error.timestamp) { + return { + pass: false, + message: () => `Expected error to${isNot ? " not" : ""} have "timestamp" property`, + }; + } + + // Check that error status matches response status if both are provided + if (options.status !== undefined && !equals(error.status, options.status)) { + return { + pass: false, + message: () => `Expected error.status to${isNot ? " not" : ""} be ${options.status}, but got ${error.status}`, + }; + } + + // Check error message if provided + if (options.error.message) { + const messageMatches = equals(error.message, options.error.message); + + if (!messageMatches) { + const expectedMsg = typeof options.error.message === "string" + ? options.error.message + : options.error.message.source; + return { + pass: false, + message: () => `Expected error.message to${isNot ? " not" : ""} match "${expectedMsg}", but got "${error.message}"`, + }; + } + } + } + + return { + pass: true, + message: () => `Expected response to${isNot ? " not" : ""} match the given criteria`, + }; +}; diff --git a/packages/test-utils/src/matchers/schema-matchers.ts b/packages/test-utils/src/matchers/schema-matchers.ts new file mode 100644 index 000000000..18af8eac5 --- /dev/null +++ b/packages/test-utils/src/matchers/schema-matchers.ts @@ -0,0 +1,48 @@ +import type { MatcherState, RawMatcherFn } from "@vitest/expect"; +import type z from "zod"; + +export interface SchemaMatcherOptions { + schema: TSchema; + success: boolean; + data?: Partial>; +} + +export const toMatchSchema: RawMatcherFn]> = function ( + this: MatcherState, + received: unknown, + options: SchemaMatcherOptions, +) { + const result = options.schema.safeParse(received); + const successMatches = result.success === options.success; + + if (!successMatches) { + const expectedStatus = options.success ? "succeed" : "fail"; + const actualStatus = result.success ? "succeeded" : "failed"; + const issues = result.error?.issues ? `\n${this.utils.printExpected(result.error.issues)}` : ""; + return { + pass: false, + message: () => `Expected schema validation to ${expectedStatus}, but it ${actualStatus}${issues}`, + }; + } + + // Check partial data properties if provided + if (options.data && result.success) { + for (const key in options.data) { + const expected = (options.data as any)[key]; + const received = (result.data as any)[key]; + + if (!this.equals(received, expected)) { + return { + pass: false, + message: () => + `Expected property "${key}" to equal ${this.utils.printExpected(expected)}, but received ${this.utils.printReceived(received)}`, + }; + } + } + } + + return { + pass: true, + message: () => `Expected schema validation to not match`, + }; +}; diff --git a/packages/test-utils/src/matchers/types.d.ts b/packages/test-utils/src/matchers/types.d.ts index e50bba0e6..dcd61659b 100644 --- a/packages/test-utils/src/matchers/types.d.ts +++ b/packages/test-utils/src/matchers/types.d.ts @@ -1,9 +1,17 @@ import type { ErrorMatcherOptions } from "./error-matchers"; +import type { ApiErrorOptions, HeadersOptions, ResponseMatcherOptions } from "./response-matchers"; +import type { SchemaMatcherOptions } from "./schema-matchers"; import "vitest"; interface CustomMatchers { toMatchError: (options: ErrorMatcherOptions) => R; + toMatchSchema: ( + options: SchemaMatcherOptions, + ) => R; + toBeApiError: (options: ApiErrorOptions) => Promise; + toBeHeadError: (expectedStatus: number) => R; + toMatchResponse: (options: ResponseMatcherOptions) => Promise; } declare module "vitest" { diff --git a/packages/test-utils/src/matchers/vitest-setup.ts b/packages/test-utils/src/matchers/vitest-setup.ts index dd6e64061..9ce1e3602 100644 --- a/packages/test-utils/src/matchers/vitest-setup.ts +++ b/packages/test-utils/src/matchers/vitest-setup.ts @@ -1,6 +1,16 @@ import { expect } from "vitest"; import { toMatchError } from "./error-matchers"; +import { + toBeApiError, + toBeHeadError, + toMatchResponse, +} from "./response-matchers"; +import { toMatchSchema } from "./schema-matchers"; expect.extend({ toMatchError, + toMatchSchema, + toBeApiError, + toBeHeadError, + toMatchResponse, }); From 76adf0e16af4be8245c68403770d8ae0a20b1e3d Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 17:26:49 +0100 Subject: [PATCH 2/6] chore: lint --- packages/test-utils/src/matchers/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/test-utils/src/matchers/types.d.ts b/packages/test-utils/src/matchers/types.d.ts index dcd61659b..fcd6b7b6d 100644 --- a/packages/test-utils/src/matchers/types.d.ts +++ b/packages/test-utils/src/matchers/types.d.ts @@ -1,5 +1,5 @@ import type { ErrorMatcherOptions } from "./error-matchers"; -import type { ApiErrorOptions, HeadersOptions, ResponseMatcherOptions } from "./response-matchers"; +import type { ApiErrorOptions, ResponseMatcherOptions } from "./response-matchers"; import type { SchemaMatcherOptions } from "./schema-matchers"; import "vitest"; From c926df37b2ecdace29a8c41907699c254add56be Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 17:29:57 +0100 Subject: [PATCH 3/6] feat(test-utils): enhance error message matching in response matcher --- packages/test-utils/src/matchers/response-matchers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/test-utils/src/matchers/response-matchers.ts b/packages/test-utils/src/matchers/response-matchers.ts index 79cc4a6f4..cbd2e2fc3 100644 --- a/packages/test-utils/src/matchers/response-matchers.ts +++ b/packages/test-utils/src/matchers/response-matchers.ts @@ -263,7 +263,9 @@ export const toMatchResponse: RawMatcherFn Date: Mon, 5 Jan 2026 17:32:56 +0100 Subject: [PATCH 4/6] refactor(test-utils): remove unused HeadersOptions interface --- packages/test-utils/src/matchers/response-matchers.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/test-utils/src/matchers/response-matchers.ts b/packages/test-utils/src/matchers/response-matchers.ts index cbd2e2fc3..f796b85a3 100644 --- a/packages/test-utils/src/matchers/response-matchers.ts +++ b/packages/test-utils/src/matchers/response-matchers.ts @@ -60,13 +60,6 @@ export const toBeApiError: RawMatcherFn = async }; }; -export interface HeadersOptions { - headers?: Record; - json?: boolean; - cache?: boolean; - cacheMaxAgePattern?: RegExp; -} - export const toBeHeadError: RawMatcherFn = function ( this: MatcherState, received: Response, From 0a2b79b25c77c9b3787d24423e88770d265ac818 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 17:33:34 +0100 Subject: [PATCH 5/6] refactor(test-utils): improve error message matching logic --- packages/test-utils/src/matchers/response-matchers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/test-utils/src/matchers/response-matchers.ts b/packages/test-utils/src/matchers/response-matchers.ts index f796b85a3..e8417b149 100644 --- a/packages/test-utils/src/matchers/response-matchers.ts +++ b/packages/test-utils/src/matchers/response-matchers.ts @@ -256,9 +256,9 @@ export const toMatchResponse: RawMatcherFn Date: Mon, 5 Jan 2026 17:34:55 +0100 Subject: [PATCH 6/6] refactor(test-utils): use Object.keys for data iteration --- packages/test-utils/src/matchers/schema-matchers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/test-utils/src/matchers/schema-matchers.ts b/packages/test-utils/src/matchers/schema-matchers.ts index 18af8eac5..664fd5d25 100644 --- a/packages/test-utils/src/matchers/schema-matchers.ts +++ b/packages/test-utils/src/matchers/schema-matchers.ts @@ -27,7 +27,7 @@ export const toMatchSchema: RawMatcherFn