Skip to content

Commit 492ce59

Browse files
mattvagniclaude
andauthored
Add test suite for SDK package and update schema (#18)
* Add test suite for SDK package and update schema Add Vitest tests covering error handling, query/mutation execution, lazy-loading relations, and connection pagination. Update GraphQL schema with new import sync types and threadBySlackPermalink query. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add test for GraphQL query structure and simplify root test script Add a query-execution test asserting the generated GraphQL query has the correct operation signature, fragment usage, scalar fields, timestamp representations, and lazy-loading relation pattern. Simplify root test script to use `pnpm -r test`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add GraphQL query structure assertion to mutation tests Verify the addLabels mutation sends the correct operation definition, fragment usage, scalar/timestamp fields, lazy-loading pattern, and error field selection — matching the pattern used in query tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use customers entity in connection pagination tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 03695c2 commit 492ce59

9 files changed

Lines changed: 712 additions & 2 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"scripts": {
55
"build": "pnpm --filter @team-plain/graphql-codegen-plugin build && pnpm --filter @team-plain/graphql codegen && pnpm --filter @team-plain/graphql build && pnpm --filter @team-plain/ui-components build && pnpm --filter @team-plain/webhooks build",
66
"typecheck": "pnpm --filter @team-plain/graphql-codegen-plugin exec tsc --noEmit && pnpm --filter @team-plain/graphql exec tsc --noEmit && pnpm --filter @team-plain/ui-components exec tsc --noEmit && pnpm --filter @team-plain/webhooks exec tsc --noEmit",
7-
"test": "pnpm --filter @team-plain/graphql-codegen-plugin test && pnpm --filter @team-plain/ui-components test && pnpm --filter @team-plain/webhooks test",
7+
"test": "pnpm -r test",
88
"changeset": "changeset",
99
"version-packages": "changeset version",
1010
"release": "pnpm build && changeset publish",

packages/graphql/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"download-schema": "tsx scripts/download-schema.ts",
2222
"codegen": "pnpm download-schema && tsx src/generate-documents.ts && graphql-codegen --config codegen.yml",
2323
"build": "tsc",
24-
"prepublishOnly": "npm run build"
24+
"prepublishOnly": "npm run build",
25+
"test": "vitest run"
2526
},
2627
"dependencies": {
2728
"@graphql-typed-document-node/core": "^3.2.0",
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { CustomerModel } from "../_generated_sdk.js";
3+
import { PlainClient } from "../client.js";
4+
import { PlainConnection } from "../connection.js";
5+
import { getRequestBody, graphqlResponse, mockFetch } from "./helpers.js";
6+
7+
describe("connection pagination", () => {
8+
let fetchMock: ReturnType<typeof mockFetch>;
9+
10+
afterEach(() => {
11+
vi.restoreAllMocks();
12+
});
13+
14+
function makeCustomersResponse(
15+
customers: Array<{ id: string; fullName: string; shortName: string }>,
16+
pageInfo: {
17+
hasNextPage: boolean;
18+
hasPreviousPage: boolean;
19+
startCursor: string | null;
20+
endCursor: string | null;
21+
},
22+
) {
23+
return {
24+
customers: {
25+
edges: customers.map((c) => ({
26+
cursor: `cursor_${c.id}`,
27+
node: {
28+
id: c.id,
29+
fullName: c.fullName,
30+
shortName: c.shortName,
31+
externalId: null,
32+
status: "ACTIVE",
33+
isAnonymous: false,
34+
avatarUrl: null,
35+
assignedAt: null,
36+
lastIdleAt: null,
37+
markedAsSpamAt: null,
38+
statusChangedAt: null,
39+
company: { id: "comp_1" },
40+
createdAt: { unixTimestamp: "1700000000", iso8601: "2023-11-14T22:13:20Z" },
41+
updatedAt: { unixTimestamp: "1700000001", iso8601: "2023-11-14T22:13:21Z" },
42+
},
43+
})),
44+
pageInfo,
45+
},
46+
};
47+
}
48+
49+
it("returns a PlainConnection with model instances", async () => {
50+
fetchMock = mockFetch();
51+
fetchMock.mockResolvedValueOnce(
52+
graphqlResponse(
53+
makeCustomersResponse(
54+
[
55+
{ id: "c_1", fullName: "Alice Smith", shortName: "Alice" },
56+
{ id: "c_2", fullName: "Bob Jones", shortName: "Bob" },
57+
],
58+
{
59+
hasNextPage: false,
60+
hasPreviousPage: false,
61+
startCursor: "cursor_c_1",
62+
endCursor: "cursor_c_2",
63+
},
64+
),
65+
),
66+
);
67+
const client = new PlainClient({ apiKey: "test-key" });
68+
69+
const connection = await client.query.customers({ first: 10 });
70+
71+
expect(connection).toBeInstanceOf(PlainConnection);
72+
expect(connection.nodes).toHaveLength(2);
73+
expect(connection.nodes[0]).toBeInstanceOf(CustomerModel);
74+
expect(connection.nodes[0].id).toBe("c_1");
75+
expect(connection.nodes[0].fullName).toBe("Alice Smith");
76+
expect(connection.nodes[1].id).toBe("c_2");
77+
});
78+
79+
it("populates pageInfo correctly", async () => {
80+
fetchMock = mockFetch();
81+
fetchMock.mockResolvedValueOnce(
82+
graphqlResponse(
83+
makeCustomersResponse([{ id: "c_1", fullName: "Alice Smith", shortName: "Alice" }], {
84+
hasNextPage: true,
85+
hasPreviousPage: false,
86+
startCursor: "cursor_start",
87+
endCursor: "cursor_end",
88+
}),
89+
),
90+
);
91+
const client = new PlainClient({ apiKey: "test-key" });
92+
93+
const connection = await client.query.customers({ first: 1 });
94+
95+
expect(connection.hasNextPage).toBe(true);
96+
expect(connection.hasPreviousPage).toBe(false);
97+
expect(connection.pageInfo.startCursor).toBe("cursor_start");
98+
expect(connection.pageInfo.endCursor).toBe("cursor_end");
99+
});
100+
101+
it("fetchNext sends request with after cursor", async () => {
102+
fetchMock = mockFetch();
103+
// First page
104+
fetchMock.mockResolvedValueOnce(
105+
graphqlResponse(
106+
makeCustomersResponse([{ id: "c_1", fullName: "Alice Smith", shortName: "Alice" }], {
107+
hasNextPage: true,
108+
hasPreviousPage: false,
109+
startCursor: "cursor_start",
110+
endCursor: "cursor_end",
111+
}),
112+
),
113+
);
114+
// Second page
115+
fetchMock.mockResolvedValueOnce(
116+
graphqlResponse(
117+
makeCustomersResponse([{ id: "c_2", fullName: "Bob Jones", shortName: "Bob" }], {
118+
hasNextPage: false,
119+
hasPreviousPage: true,
120+
startCursor: "cursor_c_2",
121+
endCursor: "cursor_c_2",
122+
}),
123+
),
124+
);
125+
126+
const client = new PlainClient({ apiKey: "test-key" });
127+
const firstPage = await client.query.customers({ first: 1 });
128+
const secondPage = await firstPage.fetchNext();
129+
130+
expect(secondPage).toBeInstanceOf(PlainConnection);
131+
expect(secondPage!.nodes).toHaveLength(1);
132+
expect(secondPage!.nodes[0].id).toBe("c_2");
133+
134+
const secondCallBody = getRequestBody(fetchMock, 1);
135+
expect(secondCallBody.variables.after).toBe("cursor_end");
136+
});
137+
138+
it("fetchNext returns undefined when hasNextPage is false", async () => {
139+
fetchMock = mockFetch();
140+
fetchMock.mockResolvedValueOnce(
141+
graphqlResponse(
142+
makeCustomersResponse([{ id: "c_1", fullName: "Alice Smith", shortName: "Alice" }], {
143+
hasNextPage: false,
144+
hasPreviousPage: false,
145+
startCursor: "cursor_start",
146+
endCursor: "cursor_end",
147+
}),
148+
),
149+
);
150+
const client = new PlainClient({ apiKey: "test-key" });
151+
152+
const connection = await client.query.customers({ first: 10 });
153+
const nextPage = await connection.fetchNext();
154+
155+
expect(nextPage).toBeUndefined();
156+
expect(fetchMock).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it("fetchPrevious sends request with before cursor", async () => {
160+
fetchMock = mockFetch();
161+
// First call returns a page with hasPreviousPage: true
162+
fetchMock.mockResolvedValueOnce(
163+
graphqlResponse(
164+
makeCustomersResponse([{ id: "c_2", fullName: "Bob Jones", shortName: "Bob" }], {
165+
hasNextPage: false,
166+
hasPreviousPage: true,
167+
startCursor: "cursor_start",
168+
endCursor: "cursor_end",
169+
}),
170+
),
171+
);
172+
// Second call: previous page
173+
fetchMock.mockResolvedValueOnce(
174+
graphqlResponse(
175+
makeCustomersResponse([{ id: "c_1", fullName: "Alice Smith", shortName: "Alice" }], {
176+
hasNextPage: true,
177+
hasPreviousPage: false,
178+
startCursor: "cursor_first",
179+
endCursor: "cursor_first",
180+
}),
181+
),
182+
);
183+
184+
const client = new PlainClient({ apiKey: "test-key" });
185+
const page = await client.query.customers({ first: 1 });
186+
const prevPage = await page.fetchPrevious();
187+
188+
expect(prevPage).toBeInstanceOf(PlainConnection);
189+
expect(prevPage!.nodes[0].id).toBe("c_1");
190+
191+
const secondCallBody = getRequestBody(fetchMock, 1);
192+
expect(secondCallBody.variables.before).toBe("cursor_start");
193+
});
194+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { PlainClient } from "../client.js";
3+
import {
4+
AuthenticationError,
5+
ForbiddenError,
6+
NetworkError,
7+
PlainGraphQLError,
8+
RateLimitError,
9+
} from "../error.js";
10+
import { errorResponse, graphqlErrorResponse, mockFetch } from "./helpers.js";
11+
12+
describe("error handling", () => {
13+
let fetchMock: ReturnType<typeof mockFetch>;
14+
15+
afterEach(() => {
16+
vi.restoreAllMocks();
17+
});
18+
19+
function createClient() {
20+
return new PlainClient({ apiKey: "test-api-key" });
21+
}
22+
23+
it("throws AuthenticationError on 401", async () => {
24+
fetchMock = mockFetch();
25+
fetchMock.mockResolvedValueOnce(errorResponse(401));
26+
const client = createClient();
27+
28+
await expect(client.query.customer({ customerId: "c_1" })).rejects.toThrow(AuthenticationError);
29+
});
30+
31+
it("throws ForbiddenError on 403", async () => {
32+
fetchMock = mockFetch();
33+
fetchMock.mockResolvedValueOnce(errorResponse(403));
34+
const client = createClient();
35+
36+
await expect(client.query.customer({ customerId: "c_1" })).rejects.toThrow(ForbiddenError);
37+
});
38+
39+
it("throws RateLimitError on 429", async () => {
40+
fetchMock = mockFetch();
41+
fetchMock.mockResolvedValueOnce(errorResponse(429));
42+
const client = createClient();
43+
44+
await expect(client.query.customer({ customerId: "c_1" })).rejects.toThrow(RateLimitError);
45+
});
46+
47+
it("throws NetworkError on 500", async () => {
48+
fetchMock = mockFetch();
49+
fetchMock.mockResolvedValueOnce(errorResponse(500));
50+
const client = createClient();
51+
52+
await expect(client.query.customer({ customerId: "c_1" })).rejects.toThrow(NetworkError);
53+
});
54+
55+
it("throws PlainGraphQLError on 200 with GraphQL errors", async () => {
56+
fetchMock = mockFetch();
57+
fetchMock.mockResolvedValueOnce(graphqlErrorResponse([{ message: "Something went wrong" }]));
58+
const client = createClient();
59+
60+
await expect(client.query.customer({ customerId: "c_1" })).rejects.toThrow(PlainGraphQLError);
61+
});
62+
63+
it("includes error message from JSON body in HTTP errors", async () => {
64+
fetchMock = mockFetch();
65+
fetchMock.mockResolvedValueOnce(
66+
errorResponse(401, { errors: [{ message: "Invalid API key" }] }),
67+
);
68+
const client = createClient();
69+
70+
await expect(client.query.customer({ customerId: "c_1" })).rejects.toThrow("Invalid API key");
71+
});
72+
73+
it("throws PlainGraphQLError with errors array", async () => {
74+
fetchMock = mockFetch();
75+
fetchMock.mockResolvedValueOnce(
76+
graphqlErrorResponse([{ message: "Field error" }, { message: "Another error" }]),
77+
);
78+
const client = createClient();
79+
80+
try {
81+
await client.query.customer({ customerId: "c_1" });
82+
expect.fail("Should have thrown");
83+
} catch (e) {
84+
expect(e).toBeInstanceOf(PlainGraphQLError);
85+
const err = e as PlainGraphQLError;
86+
expect(err.errors).toHaveLength(2);
87+
expect(err.errors[0].message).toBe("Field error");
88+
expect(err.errors[1].message).toBe("Another error");
89+
}
90+
});
91+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { vi } from "vitest";
2+
3+
export function mockFetch() {
4+
const fetchMock = vi.fn<typeof globalThis.fetch>();
5+
globalThis.fetch = fetchMock;
6+
return fetchMock;
7+
}
8+
9+
export function graphqlResponse<T>(data: T): Response {
10+
return new Response(JSON.stringify({ data }), {
11+
status: 200,
12+
headers: { "Content-Type": "application/json" },
13+
});
14+
}
15+
16+
export function graphqlErrorResponse(errors: Array<{ message: string }>): Response {
17+
return new Response(JSON.stringify({ errors }), {
18+
status: 200,
19+
headers: { "Content-Type": "application/json" },
20+
});
21+
}
22+
23+
export function errorResponse(status: number, body?: unknown): Response {
24+
return new Response(body ? JSON.stringify(body) : null, {
25+
status,
26+
statusText: "Error",
27+
headers: { "Content-Type": "application/json" },
28+
});
29+
}
30+
31+
export function getRequestBody(fetchMock: ReturnType<typeof mockFetch>, callIndex = 0) {
32+
const call = fetchMock.mock.calls[callIndex];
33+
return JSON.parse(call[1]!.body as string);
34+
}
35+
36+
export function getRequestHeaders(fetchMock: ReturnType<typeof mockFetch>, callIndex = 0) {
37+
const call = fetchMock.mock.calls[callIndex];
38+
return call[1]!.headers as Record<string, string>;
39+
}
40+
41+
export function getRequestUrl(fetchMock: ReturnType<typeof mockFetch>, callIndex = 0) {
42+
return fetchMock.mock.calls[callIndex][0];
43+
}

0 commit comments

Comments
 (0)