diff --git a/src/sdk/requestTimeout.ts b/src/sdk/requestTimeout.ts index 87f28f0..f408aa2 100644 --- a/src/sdk/requestTimeout.ts +++ b/src/sdk/requestTimeout.ts @@ -97,7 +97,7 @@ export function wrapFetchWithTimeout( }; } -function combineAbortSignals( +export function combineAbortSignals( timeoutSignal: AbortSignal, upstreamSignal: AbortSignal | null | undefined, ): { cleanup: () => void; signal: AbortSignal } { diff --git a/test/requestTimeout.combineAbortSignals.test.ts b/test/requestTimeout.combineAbortSignals.test.ts new file mode 100644 index 0000000..14d0334 --- /dev/null +++ b/test/requestTimeout.combineAbortSignals.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { combineAbortSignals } from "../src/sdk/requestTimeout.js"; + +describe("combineAbortSignals", () => { + it("returns timeout signal directly when upstream is undefined", () => { + const tc = new AbortController(); + const { signal, cleanup } = combineAbortSignals(tc.signal, undefined); + + expect(signal).toBe(tc.signal); + cleanup(); + }); + + it("returns timeout signal directly when upstream is null", () => { + const tc = new AbortController(); + const { signal, cleanup } = combineAbortSignals(tc.signal, null); + + expect(signal).toBe(tc.signal); + cleanup(); + }); + + it("returns already-aborted signal when timeout is pre-aborted", () => { + const tc = new AbortController(); + const up = new AbortController(); + tc.abort("timeout-reason"); + + const { signal, cleanup } = combineAbortSignals(tc.signal, up.signal); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe("timeout-reason"); + cleanup(); + }); + + it("returns already-aborted signal when upstream is pre-aborted", () => { + const tc = new AbortController(); + const up = new AbortController(); + up.abort("upstream-reason"); + + const { signal, cleanup } = combineAbortSignals(tc.signal, up.signal); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe("upstream-reason"); + cleanup(); + }); + + it("aborts combined signal when timeout fires", () => { + const tc = new AbortController(); + const up = new AbortController(); + const { signal, cleanup } = combineAbortSignals(tc.signal, up.signal); + + expect(signal.aborted).toBe(false); + tc.abort("timed-out"); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe("timed-out"); + cleanup(); + }); + + it("aborts combined signal when upstream fires", () => { + const tc = new AbortController(); + const up = new AbortController(); + const { signal, cleanup } = combineAbortSignals(tc.signal, up.signal); + + expect(signal.aborted).toBe(false); + up.abort("user-cancelled"); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe("user-cancelled"); + cleanup(); + }); + + it("does not abort combined signal after cleanup removes listeners", () => { + const tc = new AbortController(); + const up = new AbortController(); + const { signal, cleanup } = combineAbortSignals(tc.signal, up.signal); + + cleanup(); + tc.abort("late"); + + expect(signal.aborted).toBe(false); + }); + + it("does not double-abort when both signals fire after first", () => { + const tc = new AbortController(); + const up = new AbortController(); + const { signal, cleanup } = combineAbortSignals(tc.signal, up.signal); + + tc.abort("first"); + up.abort("second"); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe("first"); + cleanup(); + }); +}); diff --git a/test/responseValidation.truncation.test.ts b/test/responseValidation.truncation.test.ts new file mode 100644 index 0000000..71634d7 --- /dev/null +++ b/test/responseValidation.truncation.test.ts @@ -0,0 +1,82 @@ +import * as v from "valibot"; +import { describe, expect, it } from "vitest"; + +import { LibrusResponseValidationError } from "../src/sdk/models/errors.js"; +import { parseApiResponse } from "../src/sdk/validation/responseValidation.js"; + +const ENDPOINT = "https://api.librus.pl/3.0/Test"; + +const fiveFieldSchema = v.object({ + a: v.string(), + b: v.string(), + c: v.string(), + d: v.string(), + e: v.string(), +}); + +const twoFieldSchema = v.object({ + x: v.string(), + y: v.string(), +}); + +function throwingParse( + schema: Parameters[0], + payload: unknown, +): LibrusResponseValidationError { + try { + parseApiResponse(schema, payload, ENDPOINT); + } catch (err) { + if (err instanceof LibrusResponseValidationError) { + return err; + } + } + throw new Error( + "expected parseApiResponse to throw LibrusResponseValidationError", + ); +} + +describe("parseApiResponse — issue truncation", () => { + it("truncates to 3 issues when schema produces more", () => { + const err = throwingParse(fiveFieldSchema, {}); + + expect(err.details?.issues).toHaveLength(3); + }); + + it("includes all issues when schema produces fewer than 3", () => { + const err = throwingParse(twoFieldSchema, {}); + + expect(err.details?.issues).toHaveLength(2); + }); + + it("formats field issues as 'path: message'", () => { + const err = throwingParse(twoFieldSchema, {}); + const issues = err.details?.issues ?? []; + + expect(issues[0]).toMatch(/^x: /); + expect(issues[1]).toMatch(/^y: /); + }); + + it("formats root-level issues without a path prefix", () => { + const err = throwingParse(v.string(), 42); + const issues = err.details?.issues ?? []; + + expect(issues).toHaveLength(1); + expect(issues[0]).not.toMatch(/^\w+: /); + }); + + it("returns parsed output for a valid payload", () => { + const result = parseApiResponse( + twoFieldSchema, + { x: "hello", y: "world" }, + ENDPOINT, + ); + + expect(result).toEqual({ x: "hello", y: "world" }); + }); + + it("includes endpoint in error details", () => { + const err = throwingParse(twoFieldSchema, {}); + + expect(err.details?.endpoint).toBe(ENDPOINT); + }); +});