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..e8417b149 --- /dev/null +++ b/packages/test-utils/src/matchers/response-matchers.ts @@ -0,0 +1,279 @@ +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 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 = options.error.message instanceof RegExp + ? options.error.message.test(error.message) + : 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..664fd5d25 --- /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 of Object.keys(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..fcd6b7b6d 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, 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, });