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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion codegen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ generates:
plugins:
- typescript
- typescript-operations
- typed-document-node
config:
useTypeImports: true
skipTypename: true
avoidOptionals: true
avoidOptionals:
field: true
3 changes: 2 additions & 1 deletion e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import { print } from "graphql";
import { DELETE_PROGRAM_MUTATION } from "../src/shared/gqlQueries";
import {
AUTH_BYPASS_USER_ID,
Expand Down Expand Up @@ -45,7 +46,7 @@ export async function deleteProgramViaApi(
"x-session-id": TRIPWIRE_HEADER_VALUE,
},
data: {
query: DELETE_PROGRAM_MUTATION,
query: print(DELETE_PROGRAM_MUTATION),
variables: { id: programId },
},
});
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"drizzle-graphql": "^0.8.5",
"drizzle-orm": "^0.45.2",
"graphql": "^17.0.2",
"graphql-request": "^7.4.0",
"hono": "4.13.2",
"leven": "^4.1.0",
"react": "^19.2.8",
Expand Down
194 changes: 105 additions & 89 deletions src/react-app/api/graphQlClient.spec.ts
Original file line number Diff line number Diff line change
@@ -1,181 +1,197 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { graphqlFetch } from "./graphQlClient";
import {
GET_PROGRAMS_QUERY,
SUBMIT_GUESS_MUTATION,
} from "../../shared/gqlQueries";
import { graphqlRequest } from "./graphQlClient";

const mockFetch = vi.fn();

beforeEach(() => {
globalThis.fetch = mockFetch;
vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const okJsonResponse = (data: unknown) =>
({ ok: true, status: 200, json: async () => data }) as unknown as Response;

const statusResponse = (status: number, data?: unknown) =>
const jsonResponse = (payload: unknown, status = 200) =>
({
ok: false,
ok: status >= 200 && status < 300,
status,
json: async () => data,
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
text: async () => JSON.stringify(payload),
}) as unknown as Response;

describe("graphqlFetch", () => {
it("returns data on successful fetch", async () => {
mockFetch.mockResolvedValueOnce(okJsonResponse({ data: { token: "abc" } }));
describe("graphqlRequest", () => {
it("returns data on successful request", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({
data: { programs: [{ id: "p1", name: "Program 1" }] },
}),
);

const result = await graphqlFetch<{ token: string }>("query { token }");
const result = await graphqlRequest(GET_PROGRAMS_QUERY);

expect(result).toEqual({ token: "abc" });
expect(result).toEqual({
programs: [{ id: "p1", name: "Program 1" }],
});
});

it("sends correct request shape", async () => {
mockFetch.mockResolvedValueOnce(
okJsonResponse({ data: { success: true } }),
jsonResponse({
data: {
submitGuess: {
success: true,
message: "ok",
canRequestClue: false,
nextGate: null,
},
},
}),
);

await graphqlFetch("mutation { doThing }", { input: "foo" });

expect(mockFetch).toHaveBeenCalledWith("/api/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-session-id": "terminal-quiz",
},
body: JSON.stringify({
query: "mutation { doThing }",
variables: { input: "foo" },
}),
await graphqlRequest(SUBMIT_GUESS_MUTATION, {
programId: "prog-1",
gateId: "gate-1",
guess: "my answer",
});

const [url, options] = mockFetch.mock.calls[0] as [Request, RequestInit];
expect(new URL(url as unknown as string).pathname).toBe("/api/graphql");
expect(options.method).toBe("POST");
const headers = new Headers(options.headers);
expect(headers.get("Content-Type")).toBe("application/json");
const body = JSON.parse(options.body as string);
expect(body.query).toContain("SubmitGuess");
expect(body.variables).toEqual({
programId: "prog-1",
gateId: "gate-1",
guess: "my answer",
});
});

it("sends query without variables when none provided", async () => {
mockFetch.mockResolvedValueOnce(okJsonResponse({ data: { ok: true } }));
it("omits variables when none provided", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ data: { programs: [] } }));

await graphqlFetch("{ test }");
await graphqlRequest(GET_PROGRAMS_QUERY);

const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
const callBody = JSON.parse(options.body as string);
expect(callBody.query).toBe("{ test }");
expect(callBody.variables).toBeUndefined();
const [, options] = mockFetch.mock.calls[0] as [Request, RequestInit];
const body = JSON.parse(options.body as string);
expect(body.query).toContain("GetPrograms");
expect(body.variables).toBeUndefined();
});

it("includes the constant x-session-id tripwire header", async () => {
mockFetch.mockResolvedValueOnce(okJsonResponse({ data: { ok: true } }));
mockFetch.mockResolvedValueOnce(jsonResponse({ data: { programs: [] } }));

await graphqlFetch("{ test }");
await graphqlRequest(GET_PROGRAMS_QUERY);

const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
const headers = options.headers as Record<string, string>;
expect(headers["x-session-id"]).toBe("terminal-quiz");
const [, options] = mockFetch.mock.calls[0] as [Request, RequestInit];
const headers = new Headers(options.headers);
expect(headers.get("x-session-id")).toBe("terminal-quiz");
});

it("throws fallback message on HTTP 500 without body", async () => {
mockFetch.mockResolvedValueOnce(statusResponse(500));
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
headers: { get: () => "application/json" },
text: async () => "",
} as unknown as Response);

await expect(graphqlFetch("{ test }")).rejects.toThrow(
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toThrow(
"GraphQL request failed with HTTP 500.",
);
});

it("throws error message from GraphQL error body on HTTP 500", async () => {
mockFetch.mockResolvedValueOnce(
statusResponse(500, {
data: null,
errors: [{ message: "Internal server error" }],
}),
jsonResponse(
{ data: null, errors: [{ message: "Internal server error" }] },
500,
),
);

await expect(graphqlFetch("{ test }")).rejects.toThrow(
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toThrow(
"Internal server error",
);
});

it("throws first error message when HTTP is ok but response has errors", async () => {
mockFetch.mockResolvedValueOnce(
okJsonResponse({
data: null,
errors: [{ message: "Not found" }],
}),
jsonResponse({ data: null, errors: [{ message: "Not found" }] }),
);

await expect(graphqlFetch("{ test }")).rejects.toThrow("Not found");
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toThrow(
"Not found",
);
});

it("uses first error when multiple errors present", async () => {
mockFetch.mockResolvedValueOnce(
okJsonResponse({
jsonResponse({
data: null,
errors: [{ message: "First error" }, { message: "Second error" }],
}),
);

await expect(graphqlFetch("{ test }")).rejects.toThrow("First error");
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toThrow(
"First error",
);
});

it("falls back to generic message when error has no message", async () => {
mockFetch.mockResolvedValueOnce(
okJsonResponse({
data: null,
errors: [{}],
}),
);
mockFetch.mockResolvedValueOnce(jsonResponse({ data: null, errors: [{}] }));

await expect(graphqlFetch("{ test }")).rejects.toThrow(
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toThrow(
"GraphQL request failed with HTTP 200.",
);
});

it("returns data when errors array is empty but data is present", async () => {
mockFetch.mockResolvedValueOnce(
okJsonResponse({
data: { valid: true },
errors: [],
}),
jsonResponse({ data: { programs: [] }, errors: [] }),
);

const result = await graphqlFetch<{ valid: boolean }>("{ test }");
expect(result).toEqual({ valid: true });
const result = await graphqlRequest(GET_PROGRAMS_QUERY);
expect(result).toEqual({ programs: [] });
});

it("throws when data field is missing from response", async () => {
mockFetch.mockResolvedValueOnce(okJsonResponse({}));
mockFetch.mockResolvedValueOnce(jsonResponse({}));

await expect(graphqlFetch("{ test }")).rejects.toThrow(
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toThrow(
"GraphQL response did not include data.",
);
});

it("throws on malformed JSON response", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response);

await expect(graphqlFetch("{ test }")).rejects.toThrow(
"GraphQL request failed with HTTP 200.",
);
});

it("throws on null response body", async () => {
mockFetch.mockResolvedValueOnce(okJsonResponse(null));
it("throws when data is null with an empty errors array", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ data: null, errors: [] }));

await expect(graphqlFetch("{ test }")).rejects.toThrow(
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toThrow(
"GraphQL response did not include data.",
);
});

it("throws on array response body", async () => {
mockFetch.mockResolvedValueOnce(okJsonResponse([]));
it("propagates JSON parse errors unchanged", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => "application/json" },
text: async () => "<html>",
} as unknown as Response);

await expect(graphqlFetch("{ test }")).rejects.toThrow(
"GraphQL response did not include data.",
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toBeInstanceOf(
SyntaxError,
);
});

it("propagates network error when fetch fails", async () => {
mockFetch.mockRejectedValueOnce(new TypeError("Failed to fetch"));

await expect(graphqlFetch("{ test }")).rejects.toThrow("Failed to fetch");
await expect(graphqlRequest(GET_PROGRAMS_QUERY)).rejects.toThrow(
"Failed to fetch",
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
Loading
Loading