Skip to content
Draft
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
44 changes: 44 additions & 0 deletions packages/thunderstore-api/src/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "../errors";

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error test

Insert ⏎

describe("ApiError", () => {
it("includes stale session guidance for unauthorized responses", async () => {
const response = new Response(
JSON.stringify({ detail: "Session token is no longer valid." }),
{
status: 401,
statusText: "Unauthorized",
headers: { "Content-Type": "application/json" },
}
);

const error = await ApiError.createFromResponse(response, {
sessionWasUsed: true,
});

expect(error.message).toContain(
"Your session has expired. Please sign in again."
);
expect(error.message).toContain("Session token is no longer valid.");
expect(error.message).toContain("(401 Unauthorized)");
});

it("surfaces validation issue details when available", async () => {
const response = new Response(
JSON.stringify({ errors: { field: ["Missing value", "Invalid state"] } }),
{
status: 422,
statusText: "Unprocessable Entity",
headers: { "Content-Type": "application/json" },
}
);

const error = await ApiError.createFromResponse(response);

expect(error.message).toContain(
"The server could not process the request due to validation errors."
);
expect(error.message).toContain("Missing value");
expect(error.message).toContain("(422 Unprocessable Entity)");
});
});
108 changes: 108 additions & 0 deletions packages/thunderstore-api/src/__tests__/userFacingError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error test

Insert ⏎
ApiError,
RequestBodyParseError,

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error test

Insert Error,⏎··UserFacing
createResourceNotFoundError,
mapApiErrorToUserFacingError,
UserFacingError,
Comment on lines +7 to +8

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error test

Delete UserFacingError,⏎··
} from "../index";

describe("mapApiErrorToUserFacingError", () => {
it("categorises stale session responses as auth errors", async () => {
const response = new Response(
JSON.stringify({ detail: "Session token is no longer valid." }),
{
status: 401,
statusText: "Unauthorized",
headers: { "Content-Type": "application/json" },
}
);

const apiError = await ApiError.createFromResponse(response, {
sessionWasUsed: true,
});

const userFacing = mapApiErrorToUserFacingError(apiError);

expect(userFacing).toBeInstanceOf(UserFacingError);
expect(userFacing.category).toBe("auth");
expect(userFacing.status).toBe(401);
expect(userFacing.headline).toContain("session has expired");
expect(userFacing.description).toContain(
"Session token is no longer valid."
);
expect(userFacing.context?.sessionWasUsed).toBe(true);
});

it("maps request validation issues to validation category", () => {
const schema = z.object({ field: z.string().min(1) });
const result = schema.safeParse({ field: "" });

expect(result.success).toBe(false);

const zodError = result.success ? undefined : result.error;
const validationError = new RequestBodyParseError(zodError!);

const userFacing = mapApiErrorToUserFacingError(validationError);

expect(userFacing.category).toBe("validation");
expect(userFacing.headline).toContain("Invalid request data");
});

it("falls back to network category for fetch issues", () => {
const networkError = new TypeError("Failed to fetch");

const userFacing = mapApiErrorToUserFacingError(networkError, {
fallbackHeadline: "Network issue detected.",
fallbackDescription: "Check your connection and retry.",
});

expect(userFacing.category).toBe("network");
expect(userFacing.headline).toBe("Network issue detected.");
expect(userFacing.description).toBe("Check your connection and retry.");
});

it("creates a consistent not found user-facing error", async () => {
const response = new Response(JSON.stringify({ detail: "Not found." }), {
status: 404,
statusText: "Not Found",
headers: { "Content-Type": "application/json" },
});

const apiError = await ApiError.createFromResponse(response);

const userFacing = createResourceNotFoundError({
resourceName: "team",
identifier: "alpha",
originalError: apiError,
});

expect(userFacing).toBeInstanceOf(UserFacingError);
expect(userFacing.headline).toBe("Team not found.");
expect(userFacing.description).toBe('We could not find the team "alpha".');
expect(userFacing.status).toBe(404);
expect(userFacing.category).toBe("not_found");
expect(userFacing.context?.resource).toBe("team");
expect(userFacing.context?.identifier).toBe("alpha");
});

it("falls back to generic copy when identifier absent", async () => {
const response = new Response("Not found.", {
status: 404,
statusText: "Not Found",
});

const apiError = await ApiError.createFromResponse(response);

const userFacing = createResourceNotFoundError({
resourceName: "team",
originalError: apiError,
});

expect(userFacing.description).toBe(
"We could not find the requested team."
);
expect(userFacing.context?.identifier).toBeUndefined();
});
});
Loading