Skip to content

Commit e53a1aa

Browse files
authored
test(core): add regression tests for incorrect pagination cursor handling (#11)
1 parent d082217 commit e53a1aa

File tree

2 files changed

+360
-4
lines changed

2 files changed

+360
-4
lines changed

tests/basic-behavior.spec.ts

Lines changed: 355 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { it, expect } from "vitest";
1+
import { it, expect, describe } from "vitest";
22
import { TestContext, describeSdkSuite } from "./helpers/test-context";
33

4-
// TODO: test basic error handling behavior
54
describeSdkSuite("Basic SDK Behavior", () => {
65
it("should include API key in Authorization header", async () => {
76
const ctx = new TestContext({ apiKey: "my-custom-api-key" });
@@ -159,4 +158,358 @@ describeSdkSuite("Basic SDK Behavior", () => {
159158
const requestWithoutDeleted = ctx.getLastRequest();
160159
expect(requestWithoutDeleted.path).toContain("include_deleted=false");
161160
});
161+
162+
describe("Pagination Behavior", () => {
163+
it("should iterate through multiple pages", async () => {
164+
const ctx = new TestContext();
165+
166+
// Mock 3 pages of results
167+
ctx.mockEndpoint({
168+
method: "GET",
169+
path: "/v1/ats/tags",
170+
response: {
171+
body: {
172+
status: "success",
173+
data: {
174+
results: [
175+
{
176+
id: "tag1",
177+
remote_id: null,
178+
name: "Tag 1",
179+
changed_at: "2024-01-01T00:00:00.000Z",
180+
remote_deleted_at: null,
181+
},
182+
{
183+
id: "tag2",
184+
remote_id: null,
185+
name: "Tag 2",
186+
changed_at: "2024-01-01T00:00:00.000Z",
187+
remote_deleted_at: null,
188+
},
189+
],
190+
next: "cursor_page2",
191+
},
192+
},
193+
},
194+
});
195+
196+
ctx.mockEndpoint({
197+
method: "GET",
198+
path: "/v1/ats/tags",
199+
response: {
200+
body: {
201+
status: "success",
202+
data: {
203+
results: [
204+
{
205+
id: "tag3",
206+
remote_id: null,
207+
name: "Tag 3",
208+
changed_at: "2024-01-01T00:00:00.000Z",
209+
remote_deleted_at: null,
210+
},
211+
{
212+
id: "tag4",
213+
remote_id: null,
214+
name: "Tag 4",
215+
changed_at: "2024-01-01T00:00:00.000Z",
216+
remote_deleted_at: null,
217+
},
218+
],
219+
next: "cursor_page3",
220+
},
221+
},
222+
},
223+
});
224+
225+
ctx.mockEndpoint({
226+
method: "GET",
227+
path: "/v1/ats/tags",
228+
response: {
229+
body: {
230+
status: "success",
231+
data: {
232+
results: [
233+
{
234+
id: "tag5",
235+
remote_id: null,
236+
name: "Tag 5",
237+
changed_at: "2024-01-01T00:00:00.000Z",
238+
remote_deleted_at: null,
239+
},
240+
],
241+
next: null,
242+
},
243+
},
244+
},
245+
});
246+
247+
const tags = await ctx.kombo.ats.getTags({});
248+
const allResults: unknown[] = [];
249+
250+
for await (const page of tags) {
251+
allResults.push(...page.result.data.results);
252+
}
253+
254+
// Verify all 5 tags were collected
255+
expect(allResults).toHaveLength(5);
256+
expect(allResults.map((r: any) => r.id)).toEqual([
257+
"tag1",
258+
"tag2",
259+
"tag3",
260+
"tag4",
261+
"tag5",
262+
]);
263+
264+
// Verify 3 HTTP requests were made
265+
const requests = ctx.getRequests();
266+
expect(requests).toHaveLength(3);
267+
});
268+
269+
it("should pass cursor parameter to subsequent requests", async () => {
270+
const ctx = new TestContext();
271+
272+
ctx.mockEndpoint({
273+
method: "GET",
274+
path: "/v1/ats/tags",
275+
response: {
276+
body: {
277+
status: "success",
278+
data: {
279+
results: [
280+
{
281+
id: "tag1",
282+
remote_id: null,
283+
name: "Tag 1",
284+
changed_at: "2024-01-01T00:00:00.000Z",
285+
remote_deleted_at: null,
286+
},
287+
],
288+
next: "test_cursor_abc123",
289+
},
290+
},
291+
},
292+
});
293+
294+
ctx.mockEndpoint({
295+
method: "GET",
296+
path: "/v1/ats/tags",
297+
response: {
298+
body: {
299+
status: "success",
300+
data: {
301+
results: [
302+
{
303+
id: "tag2",
304+
remote_id: null,
305+
name: "Tag 2",
306+
changed_at: "2024-01-01T00:00:00.000Z",
307+
remote_deleted_at: null,
308+
},
309+
],
310+
next: null,
311+
},
312+
},
313+
},
314+
});
315+
316+
const tags = await ctx.kombo.ats.getTags({});
317+
for await (const _page of tags) {
318+
// Iterate through all pages
319+
}
320+
321+
const requests = ctx.getRequests();
322+
expect(requests).toHaveLength(2);
323+
324+
// First request should NOT include cursor
325+
expect(requests[0].path).not.toContain("cursor=");
326+
327+
// Second request SHOULD include cursor
328+
expect(requests[1].path).toContain("cursor=test_cursor_abc123");
329+
});
330+
331+
it("should stop pagination when next is null", async () => {
332+
const ctx = new TestContext();
333+
334+
ctx.mockEndpoint({
335+
method: "GET",
336+
path: "/v1/ats/tags",
337+
response: {
338+
body: {
339+
status: "success",
340+
data: {
341+
results: [
342+
{
343+
id: "tag1",
344+
remote_id: null,
345+
name: "Tag 1",
346+
changed_at: "2024-01-01T00:00:00.000Z",
347+
remote_deleted_at: null,
348+
},
349+
{
350+
id: "tag2",
351+
remote_id: null,
352+
name: "Tag 2",
353+
changed_at: "2024-01-01T00:00:00.000Z",
354+
remote_deleted_at: null,
355+
},
356+
],
357+
next: null,
358+
},
359+
},
360+
},
361+
});
362+
363+
const tags = await ctx.kombo.ats.getTags({});
364+
const pageCount: number[] = [];
365+
366+
for await (const _page of tags) {
367+
pageCount.push(1);
368+
}
369+
370+
// Verify only 1 page was returned
371+
expect(pageCount).toHaveLength(1);
372+
373+
// Verify only 1 HTTP request was made
374+
const requests = ctx.getRequests();
375+
expect(requests).toHaveLength(1);
376+
});
377+
378+
it("should preserve query parameters across paginated requests", async () => {
379+
const ctx = new TestContext();
380+
381+
ctx.mockEndpoint({
382+
method: "GET",
383+
path: "/v1/ats/tags",
384+
response: {
385+
body: {
386+
status: "success",
387+
data: {
388+
results: [
389+
{
390+
id: "tag1",
391+
remote_id: null,
392+
name: "Tag 1",
393+
changed_at: "2024-01-01T00:00:00.000Z",
394+
remote_deleted_at: null,
395+
},
396+
],
397+
next: "cursor_for_page2",
398+
},
399+
},
400+
},
401+
});
402+
403+
ctx.mockEndpoint({
404+
method: "GET",
405+
path: "/v1/ats/tags",
406+
response: {
407+
body: {
408+
status: "success",
409+
data: {
410+
results: [
411+
{
412+
id: "tag2",
413+
remote_id: null,
414+
name: "Tag 2",
415+
changed_at: "2024-01-01T00:00:00.000Z",
416+
remote_deleted_at: null,
417+
},
418+
],
419+
next: null,
420+
},
421+
},
422+
},
423+
});
424+
425+
const tags = await ctx.kombo.ats.getTags({
426+
updated_after: new Date("2024-01-01T00:00:00.000Z"),
427+
});
428+
429+
for await (const _page of tags) {
430+
// Iterate through all pages
431+
}
432+
433+
const requests = ctx.getRequests();
434+
expect(requests).toHaveLength(2);
435+
436+
// Both requests should include the original query parameters
437+
// Check that updated_after parameter is present (URL encoded)
438+
expect(requests[0].path).toMatch(/updated_after=2024-01-01T00%3A00%3A00\.000Z/);
439+
expect(requests[0].path).not.toContain("cursor=");
440+
441+
expect(requests[1].path).toMatch(/updated_after=2024-01-01T00%3A00%3A00\.000Z/);
442+
expect(requests[1].path).toContain("cursor=cursor_for_page2");
443+
});
444+
445+
it("should support manual pagination with next()", async () => {
446+
const ctx = new TestContext();
447+
448+
ctx.mockEndpoint({
449+
method: "GET",
450+
path: "/v1/ats/tags",
451+
response: {
452+
body: {
453+
status: "success",
454+
data: {
455+
results: [
456+
{
457+
id: "tag1",
458+
remote_id: null,
459+
name: "Tag 1",
460+
changed_at: "2024-01-01T00:00:00.000Z",
461+
remote_deleted_at: null,
462+
},
463+
],
464+
next: "manual_cursor_xyz",
465+
},
466+
},
467+
},
468+
});
469+
470+
ctx.mockEndpoint({
471+
method: "GET",
472+
path: "/v1/ats/tags",
473+
response: {
474+
body: {
475+
status: "success",
476+
data: {
477+
results: [
478+
{
479+
id: "tag2",
480+
remote_id: null,
481+
name: "Tag 2",
482+
changed_at: "2024-01-01T00:00:00.000Z",
483+
remote_deleted_at: null,
484+
},
485+
],
486+
next: null,
487+
},
488+
},
489+
},
490+
});
491+
492+
const page1 = await ctx.kombo.ats.getTags({});
493+
494+
// Verify first page was fetched
495+
expect(page1.result.data.results).toBeDefined();
496+
expect(page1.result.data.results).toHaveLength(1);
497+
498+
// Manually call next()
499+
const page2Result = await page1.next();
500+
501+
// Verify second page was fetched (should not be null if cursor was read correctly)
502+
// This will fail if cursor extraction bug exists
503+
expect(page2Result).not.toBeNull();
504+
if (page2Result) {
505+
expect(page2Result.result.data.results).toHaveLength(1);
506+
expect(page2Result.result.data.results[0].id).toBe("tag2");
507+
}
508+
509+
// Verify 2 HTTP requests were made
510+
const requests = ctx.getRequests();
511+
expect(requests).toHaveLength(2);
512+
expect(requests[1].path).toContain("cursor=manual_cursor_xyz");
513+
});
514+
});
162515
});

tests/helpers/test-context.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, beforeAll, afterAll } from "vitest";
1+
import { describe, beforeAll, afterAll, afterEach } from "vitest";
22
import nock from "nock";
33
import { Kombo } from "../../src/index";
44

@@ -137,8 +137,11 @@ export function describeSdkSuite(name: string, fn: () => void) {
137137
nock.disableNetConnect();
138138
});
139139

140-
afterAll(() => {
140+
afterEach(() => {
141141
nock.cleanAll();
142+
});
143+
144+
afterAll(() => {
142145
nock.enableNetConnect();
143146
});
144147

0 commit comments

Comments
 (0)