Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 279 additions & 0 deletions packages/test-utils/src/matchers/response-matchers.ts
Original file line number Diff line number Diff line change
@@ -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<MatcherState, [ApiErrorOptions]> = 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<MatcherState, [number]> = 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<string, string | RegExp>;

/**
* 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<MatcherState, [ResponseMatcherOptions]> = 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<ApiError>,
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`,
};
};
48 changes: 48 additions & 0 deletions packages/test-utils/src/matchers/schema-matchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { MatcherState, RawMatcherFn } from "@vitest/expect";
import type z from "zod";

export interface SchemaMatcherOptions<TSchema extends z.ZodType> {
schema: TSchema;
success: boolean;
data?: Partial<z.infer<TSchema>>;
}

export const toMatchSchema: RawMatcherFn<MatcherState, [SchemaMatcherOptions<z.ZodType>]> = function <TSchema extends z.ZodType>(
this: MatcherState,
received: unknown,
options: SchemaMatcherOptions<TSchema>,
) {
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`,
};
};
8 changes: 8 additions & 0 deletions packages/test-utils/src/matchers/types.d.ts
Original file line number Diff line number Diff line change
@@ -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<R = unknown> {
toMatchError: (options: ErrorMatcherOptions) => R;
toMatchSchema: <TSchema extends z.ZodType>(
options: SchemaMatcherOptions<TSchema>,
) => R;
toBeApiError: (options: ApiErrorOptions) => Promise<R>;
toBeHeadError: (expectedStatus: number) => R;
toMatchResponse: (options: ResponseMatcherOptions) => Promise<R>;
}

declare module "vitest" {
Expand Down
10 changes: 10 additions & 0 deletions packages/test-utils/src/matchers/vitest-setup.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Loading