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
2 changes: 1 addition & 1 deletion src/sdk/requestTimeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function wrapFetchWithTimeout(
};
}

function combineAbortSignals(
export function combineAbortSignals(
timeoutSignal: AbortSignal,
upstreamSignal: AbortSignal | null | undefined,
): { cleanup: () => void; signal: AbortSignal } {
Expand Down
95 changes: 95 additions & 0 deletions test/requestTimeout.combineAbortSignals.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
82 changes: 82 additions & 0 deletions test/responseValidation.truncation.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof parseApiResponse>[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);
});
});