diff --git a/packages/shared/src/fetch/error.ts b/packages/shared/src/fetch/error.ts index f6e2e41c1..51fe7dcd0 100644 --- a/packages/shared/src/fetch/error.ts +++ b/packages/shared/src/fetch/error.ts @@ -1,4 +1,9 @@ -import type { FetchContext, FetchOptions, FetchResponse, ResponseType } from "./types"; +import type { + FetchContext, + FetchOptions, + FetchResponse, + ResponseType, +} from "./types"; export class FetchError extends Error { readonly request?: RequestInfo; @@ -55,3 +60,16 @@ export class FetchError extends Error { return fetchError; } } + +export class FetchSchemaValidationError extends FetchError { + readonly issues?: unknown; + + constructor(message: string, opts?: { cause: unknown; issues?: unknown }) { + super(message, opts); + this.name = "FetchSchemaValidationError"; + + if (opts?.issues !== undefined) { + this.issues = opts.issues; + } + } +} diff --git a/packages/shared/src/fetch/fetch.ts b/packages/shared/src/fetch/fetch.ts index 796114f23..8d4dd4587 100644 --- a/packages/shared/src/fetch/fetch.ts +++ b/packages/shared/src/fetch/fetch.ts @@ -10,7 +10,7 @@ import type { } from "./types"; import { isMSWError } from "@luxass/msw-utils/runtime-guards"; import { safeJsonParse } from "../json"; -import { FetchError } from "./error"; +import { FetchError, FetchSchemaValidationError } from "./error"; import { detectResponseType, isJSONSerializable, @@ -82,8 +82,22 @@ function createCustomFetch(): CustomFetch { } } - // Throw normalized error - const error = FetchError.from(context); + // Throw normalized error. Preserve explicit FetchError subclasses (e.g., schema validation). + let error: FetchError; + if (context.error instanceof FetchError) { + error = context.error as FetchError; + // Ensure common fields are attached for consumers + Object.assign(error, { + request: (error as any).request ?? context.request, + options: (error as any).options ?? context.options, + response: (error as any).response ?? context.response, + data: (error as any).data ?? context.response?.data, + status: (error as any).status ?? context.response?.status, + statusText: (error as any).statusText ?? context.response?.statusText, + }); + } else { + error = FetchError.from(context); + } // Only available on V8 based runtimes (https://v8.dev/docs/stack-trace-api) if (Error.captureStackTrace) { @@ -205,12 +219,19 @@ function createCustomFetch(): CustomFetch { } // Validate response data with schema if provided - if (context.options.schema && context.response.data !== undefined) { + if ( + context.options.schema + && context.response.data !== undefined + && context.response.status < 400 + ) { const result = await context.options.schema.safeParseAsync(context.response.data); if (!result.success) { - context.error = new Error(`Response validation failed: ${result.error.message}`); - context.error.name = "ValidationError"; - (context.error as any).issues = result.error.issues; + // Wrap schema failures in a dedicated error with issues attached + context.error = new FetchSchemaValidationError( + `Response validation failed: ${result.error.message}`, + { cause: result.error, issues: result.error.issues }, + ); + return await handleError(context); } diff --git a/packages/shared/src/fetch/types.ts b/packages/shared/src/fetch/types.ts index 2f08b2d79..74896854d 100644 --- a/packages/shared/src/fetch/types.ts +++ b/packages/shared/src/fetch/types.ts @@ -1,5 +1,5 @@ import type { ZodType } from "zod"; -import type { FetchError } from "./error"; +import type { FetchError, FetchSchemaValidationError } from "./error"; export interface CustomFetch { ( @@ -28,7 +28,7 @@ export type MappedResponseType< export interface SafeFetchResponse { data: T | null; - error: FetchError | null; + error: FetchError | FetchSchemaValidationError | null; response?: FetchResponse; } diff --git a/packages/shared/test/fetch/fetch-schema.test.ts b/packages/shared/test/fetch/fetch-schema.test.ts index d232e28bf..576d6a491 100644 --- a/packages/shared/test/fetch/fetch-schema.test.ts +++ b/packages/shared/test/fetch/fetch-schema.test.ts @@ -2,14 +2,14 @@ import { HttpResponse, mockFetch } from "#test-utils/msw"; import { UCDJS_API_BASE_URL } from "@ucdjs/env"; import { beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { FetchError } from "../../src/fetch/error"; +import { FetchError, FetchSchemaValidationError } from "../../src/fetch/error"; import { customFetch } from "../../src/fetch/fetch"; describe("custom fetch - schema validation", () => { const UserSchema = z.object({ id: z.number(), name: z.string(), - email: z.string().email(), + email: z.email(), }); const UsersListSchema = z.array(UserSchema); @@ -75,6 +75,15 @@ describe("custom fetch - schema validation", () => { ["GET", `${UCDJS_API_BASE_URL}/null-response`, () => { return HttpResponse.json(null); }], + ["GET", `${UCDJS_API_BASE_URL}/server-error`, () => { + return HttpResponse.json({ message: "oops" }, { status: 500 }); + }], + ["GET", `${UCDJS_API_BASE_URL}/error-valid`, () => { + return HttpResponse.json({ error: "bad" }, { status: 400 }); + }], + ["GET", `${UCDJS_API_BASE_URL}/error-invalid`, () => { + return HttpResponse.json({ error: 123 }, { status: 400 }); + }], ]); }); @@ -108,7 +117,7 @@ describe("custom fetch - schema validation", () => { }); expect(result.data).toBeNull(); - expect(result.error).toBeInstanceOf(FetchError); + expect(result.error).toBeInstanceOf(FetchSchemaValidationError); expect(result.error?.message).toContain("Response validation failed"); }); @@ -118,8 +127,49 @@ describe("custom fetch - schema validation", () => { }); expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(FetchSchemaValidationError); expect(result.error?.cause).toBeDefined(); - expect((result.error?.cause as any)?.name).toBe("ValidationError"); + expect((result.error?.cause as any)?.name).toBe("ZodError"); + expect((result.error as FetchSchemaValidationError).issues).toBeDefined(); + }); + + it("should surface schema validation error even on error responses when payload mismatches schema", async () => { + await expect( + customFetch(`${UCDJS_API_BASE_URL}/server-error`, { + schema: UserSchema, + }), + ).rejects.toBeInstanceOf(FetchError); + }); + + it("should return schema validation error via safe fetch on error responses when payload mismatches schema", async () => { + const result = await customFetch.safe(`${UCDJS_API_BASE_URL}/server-error`, { + schema: UserSchema, + }); + + expect(result.data).toBeNull(); + expect(result.error).toBeInstanceOf(FetchError); + expect(result.error).not.toBeInstanceOf(FetchSchemaValidationError); + }); + + it("should validate error responses and still throw FetchError when schema passes", async () => { + const ErrorSchema = z.object({ error: z.string() }); + + await expect( + customFetch(`${UCDJS_API_BASE_URL}/error-valid`, { + schema: ErrorSchema, + }), + ).rejects.toBeInstanceOf(FetchError); + }); + + it("should emit FetchError when error response fails schema", async () => { + const ErrorSchema = z.object({ error: z.string() }); + + const result = await customFetch.safe(`${UCDJS_API_BASE_URL}/error-invalid`, { + schema: ErrorSchema, + }); + + expect(result.error).toBeInstanceOf(FetchError); + expect(result.error).not.toBeInstanceOf(FetchSchemaValidationError); }); });