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
67 changes: 60 additions & 7 deletions src/hooks/useCursorEventsQuery.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchGraphQL, EVENTS_CONNECTION_QUERY } from "./useCursorEventsQuery";

global.fetch = vi.fn();
const originalFetch = globalThis.fetch;

// ── Mock OpenTelemetry so tests don't need a real tracer ────────────
vi.mock("@opentelemetry/api", () => {
const mockSpan = {
setStatus: vi.fn(),
recordException: vi.fn(),
end: vi.fn(),
};
return {
trace: {
getTracer: () => ({ startSpan: () => mockSpan }),
},
SpanStatusCode: { ERROR: 2 },
};
});

describe("useCursorEventsQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.fetch = vi.fn();
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it("fetchGraphQL posts query to /api/graphql and returns data", async () => {
Expand All @@ -28,13 +47,14 @@ describe("useCursorEventsQuery", () => {
},
};

(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: mockData }),
});

const result = await fetchGraphQL(EVENTS_CONNECTION_QUERY, { first: 1, after: undefined });

expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/graphql",
expect.objectContaining({
method: "POST",
Expand All @@ -47,13 +67,46 @@ describe("useCursorEventsQuery", () => {
expect(result).toEqual(mockData);
});

it("fetchGraphQL throws error when graphql endpoint returns errors", async () => {
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
it("fetchGraphQL throws error when graphql endpoint returns errors with no data", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
errors: [{ message: "GraphQL syntax error" }],
}),
});

await expect(fetchGraphQL(EVENTS_CONNECTION_QUERY)).rejects.toThrow("GraphQL syntax error");
});

it("fetchGraphQL returns partial data when both data and errors are present", async () => {
const partialData = {
events: {
edges: [],
nodes: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: 0,
},
};

(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
data: partialData,
errors: [
{
message: "Organizer resolver timeout",
path: ["events", "edges", 0, "node", "organizer"],
},
],
}),
});

const result = await fetchGraphQL(EVENTS_CONNECTION_QUERY, { first: 10 });
expect(result).toEqual(partialData);
});
});
19 changes: 1 addition & 18 deletions src/hooks/useCursorEventsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,24 +93,7 @@ export const EVENTS_CONNECTION_QUERY = /* GraphQL */ `
}
`;

export async function fetchGraphQL<TData, TVariables>(
query: string,
variables?: TVariables,
): Promise<TData> {
const res = await fetch("/api/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
});

const json = await res.json();
if (json.errors && json.errors.length > 0) {
throw new Error(json.errors[0].message);
}
return json.data;
}
export { fetchGraphQL } from "@/lib/graphql-client";

/**
* Hook to consume the GraphQL Relay-style cursor-paginated events connection API (`events(first: $first, after: $after)`).
Expand Down
149 changes: 149 additions & 0 deletions src/lib/graphql-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchGraphQL, GraphQLPartialError, isPartialNull } from "./graphql-client";

// ── Mock OpenTelemetry so tests don't need a real tracer ────────────
vi.mock("@opentelemetry/api", () => {
const mockSpan = {
setStatus: vi.fn(),
recordException: vi.fn(),
end: vi.fn(),
};
return {
trace: {
getTracer: () => ({ startSpan: () => mockSpan }),
},
SpanStatusCode: { ERROR: 2 },
};
});

// ── Test suite ──────────────────────────────────────────────────────
describe("fetchGraphQL", () => {
const originalFetch = globalThis.fetch;

beforeEach(() => {
// Reset fetch mock before each test
globalThis.fetch = vi.fn();
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it("returns data on a clean 200 response with no errors", async () => {
const mockData = { user: { id: "1", name: "Alice" } };

(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: mockData }),
});

const result = await fetchGraphQL("query { user { id name } }");
expect(result).toEqual(mockData);
});

it("throws Error when response has errors but no data (complete failure)", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
errors: [{ message: "Database unavailable" }],
}),
});

await expect(fetchGraphQL("query { user { id } }")).rejects.toThrow("Database unavailable");
});

it("returns partial data when both data and errors are present", async () => {
const partialData = { user: { id: "1", name: "Alice", recentPosts: null } };

(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: partialData,
errors: [{ message: "Posts resolver timeout", path: ["user", "recentPosts"] }],
}),
});

const result = await fetchGraphQL("query { user { id name recentPosts { title } } }");
expect(result).toEqual(partialData);
expect((result as typeof partialData).user.recentPosts).toBeNull();
});

it("throws on non-OK HTTP status", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
});

await expect(fetchGraphQL("query { user { id } }")).rejects.toThrow(
"GraphQL request failed: 500 Internal Server Error",
);
});

it("throws when response has neither data nor errors", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});

await expect(fetchGraphQL("query { user { id } }")).rejects.toThrow(
"GraphQL response contained neither data nor errors",
);
});

it("sends request to custom endpoint when provided", async () => {
const mockData = { ok: true };

(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: mockData }),
});

await fetchGraphQL("query { ok }", undefined, { endpoint: "/custom/graphql" });

expect(globalThis.fetch).toHaveBeenCalledWith(
"/custom/graphql",
expect.objectContaining({
method: "POST",
}),
);
});
});

describe("GraphQLPartialError", () => {
it("carries data and errors", () => {
const data = { user: { id: "1" } };
const errors = [{ message: "Timeout on posts" }];
const err = new GraphQLPartialError(errors, data);

expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("GraphQLPartialError");
expect(err.message).toBe("Timeout on posts");
expect(err.data).toEqual(data);
expect(err.graphQLErrors).toEqual(errors);
});
});

describe("isPartialNull", () => {
it("returns true for null", () => {
expect(isPartialNull(null)).toBe(true);
});

it("returns true for undefined", () => {
expect(isPartialNull(undefined)).toBe(true);
});

it("returns false for empty array", () => {
expect(isPartialNull([])).toBe(false);
});

it("returns false for 0", () => {
expect(isPartialNull(0)).toBe(false);
});

it("returns false for empty string", () => {
expect(isPartialNull("")).toBe(false);
});
});
Loading
Loading