diff --git a/generators/typescript/sdk/generator/src/test-generator/TestGenerator.ts b/generators/typescript/sdk/generator/src/test-generator/TestGenerator.ts index 23f7bb6fc9a9..c217308f122d 100644 --- a/generators/typescript/sdk/generator/src/test-generator/TestGenerator.ts +++ b/generators/typescript/sdk/generator/src/test-generator/TestGenerator.ts @@ -16,7 +16,10 @@ import { IntermediateRepresentation, Name, OAuthTokenEndpoint, - ObjectPropertyAccess + ObjectPropertyAccess, + Pagination, + RequestProperty, + ResponseProperty } from "@fern-fern/ir-sdk/api"; import { DependencyManager, @@ -1158,6 +1161,20 @@ describe("${serviceName}", () => { // (via getItems), so we don't need to traverse the response path const pageName = "page"; const nextPageName = "nextPage"; + + // Calculate expected hasNextPage value based on pagination type and example data + const expectedHasNextPage = + endpoint.pagination !== undefined && endpoint.pagination.type !== "custom" + ? calculateExpectedHasNextPage({ + pagination: endpoint.pagination, + responseJson: + example.response.type === "ok" && example.response.value.type === "body" + ? example.response.value.value?.jsonExample + : undefined, + requestJson: example.request?.jsonExample + }) + : false; + const paginationBlock = endpoint.pagination !== undefined ? code` @@ -1167,7 +1184,7 @@ describe("${serviceName}", () => { endpoint.pagination.type !== "custom" ? code` expect(${expectedName}).toEqual(${pageName}.data); - expect(${pageName}.hasNextPage()).toBe(true); + expect(${pageName}.hasNextPage()).toBe(${expectedHasNextPage}); const nextPage = await ${pageName}.getNextPage(); expect(${expectedName}).toEqual(${nextPageName}.data); ` @@ -1789,6 +1806,133 @@ function isCodeEmptyObject(code: Code): boolean { return rawCode === "{}" || rawCode === "{ }"; } +/** + * Extracts a property value from a JSON object using a ResponseProperty path. + * The property path defines the nested path to traverse in the JSON object. + */ +function getPropertyValueFromJson({ json, property }: { json: unknown; property: ResponseProperty }): unknown { + if (json == null || typeof json !== "object") { + return undefined; + } + + let current: unknown = json; + + // Traverse the property path if it exists + if (property.propertyPath != null) { + for (const pathItem of property.propertyPath) { + if (current == null || typeof current !== "object") { + return undefined; + } + // Use originalName for Name types (wire format for nested path segments) + current = (current as Record)[pathItem.name.originalName]; + } + } + + // Get the final property value + if (current == null || typeof current !== "object") { + return undefined; + } + // Use wireValue for NameAndWireValue types (the actual wire name) + return (current as Record)[property.property.name.wireValue]; +} + +/** + * Extracts a property value from a JSON object using a RequestProperty path. + */ +function getRequestPropertyValueFromJson({ json, property }: { json: unknown; property: RequestProperty }): unknown { + if (json == null || typeof json !== "object") { + return undefined; + } + + let current: unknown = json; + + // Traverse the property path if it exists + if (property.propertyPath != null) { + for (const pathItem of property.propertyPath) { + if (current == null || typeof current !== "object") { + return undefined; + } + // Use originalName for Name types (wire format for nested path segments) + current = (current as Record)[pathItem.name.originalName]; + } + } + + // Get the final property value + if (current == null || typeof current !== "object") { + return undefined; + } + // Use wireValue for NameAndWireValue types (the actual wire name) + return (current as Record)[property.property.name.wireValue]; +} + +/** + * Calculates the expected hasNextPage value based on pagination type and example data. + * This mirrors the SDK's actual pagination logic: + * - Cursor pagination: hasNextPage = next != null && next !== "" + * - Offset pagination: hasNextPage = results.length >= step (or > 0 if no step) + */ +function calculateExpectedHasNextPage({ + pagination, + responseJson, + requestJson +}: { + pagination: Pagination; + responseJson: unknown; + requestJson: unknown; +}): boolean { + switch (pagination.type) { + case "cursor": { + // For cursor pagination, hasNextPage is true when next property is non-null and non-empty string + const nextValue = getPropertyValueFromJson({ + json: responseJson, + property: pagination.next + }); + if (nextValue == null) { + return false; + } + if (typeof nextValue === "string" && nextValue === "") { + return false; + } + return true; + } + case "offset": { + // For offset pagination, hasNextPage is true when results.length >= step + const resultsValue = getPropertyValueFromJson({ + json: responseJson, + property: pagination.results + }); + + if (!Array.isArray(resultsValue)) { + return false; + } + + const resultsLength = resultsValue.length; + + // If step is defined, try to get the step value from the request + if (pagination.step != null) { + const stepValue = getRequestPropertyValueFromJson({ + json: requestJson, + property: pagination.step + }); + // Only use the step comparison if we can actually find the step value + // (step may be in query params which aren't in jsonExample for GET requests) + if (typeof stepValue === "number") { + return resultsLength >= Math.floor(stepValue); + } + } + + // Fallback: if we can't determine the step value, use resultsLength > 0 + // This matches the SDK's behavior when there are results + return resultsLength > 0; + } + case "custom": + // Custom pagination doesn't use hasNextPage in tests + return false; + default: + assertNever(pagination); + } +} + /** * Determines the casing of the variable name when used in client constructor options */ diff --git a/generators/typescript/sdk/versions.yml b/generators/typescript/sdk/versions.yml index 4a772bbd191b..8a86af4dbbbb 100644 --- a/generators/typescript/sdk/versions.yml +++ b/generators/typescript/sdk/versions.yml @@ -1,4 +1,17 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 3.39.1 + changelogEntry: + - summary: | + Fix wire test generator to dynamically calculate expected `hasNextPage()` value based on pagination type and example data. + Previously, the test generator hardcoded `expect(page.hasNextPage()).toBe(true)` for all pagination tests, which caused + test failures when the mock response contained fewer items than the requested limit. + Now the expected value is calculated based on: + - Cursor pagination: `hasNextPage` is true when the `next` property is non-null and non-empty + - Offset pagination: `hasNextPage` is true when `results.length >= step` (or `> 0` if no step is defined) + type: fix + createdAt: "2025-12-10" + irVersion: 62 + - version: 3.39.0 changelogEntry: - summary: | diff --git a/seed/ts-sdk/pagination/no-custom-config/reference.md b/seed/ts-sdk/pagination/no-custom-config/reference.md index c573520b9a41..7ee01de14a26 100644 --- a/seed/ts-sdk/pagination/no-custom-config/reference.md +++ b/seed/ts-sdk/pagination/no-custom-config/reference.md @@ -919,10 +919,7 @@ const response = page.response; ```typescript const pageableResponse = await client.users.listWithCursorPagination({ - page: 1, - per_page: 1, - order: "asc", - starting_after: "starting_after" + starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" }); for await (const item of pageableResponse) { console.log(item); @@ -930,10 +927,7 @@ for await (const item of pageableResponse) { // Or you can manually iterate page-by-page let page = await client.users.listWithCursorPagination({ - page: 1, - per_page: 1, - order: "asc", - starting_after: "starting_after" + starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" }); while (page.hasNextPage()) { page = page.getNextPage(); diff --git a/seed/ts-sdk/pagination/no-custom-config/snippet.json b/seed/ts-sdk/pagination/no-custom-config/snippet.json index f5991dfc786d..8c79386d2f16 100644 --- a/seed/ts-sdk/pagination/no-custom-config/snippet.json +++ b/seed/ts-sdk/pagination/no-custom-config/snippet.json @@ -151,8 +151,9 @@ }, "snippet": { "type": "typescript", - "client": "import { SeedPaginationClient } from \"@fern/pagination\";\n\nconst client = new SeedPaginationClient({ environment: \"YOUR_BASE_URL\", token: \"YOUR_TOKEN\" });\nconst pageableResponse = await client.users.listWithCursorPagination({\n page: 1,\n per_page: 1,\n order: \"asc\",\n starting_after: \"starting_after\"\n});\nfor await (const item of pageableResponse) {\n console.log(item);\n}\n\n// Or you can manually iterate page-by-page\nlet page = await client.users.listWithCursorPagination({\n page: 1,\n per_page: 1,\n order: \"asc\",\n starting_after: \"starting_after\"\n});\nwhile (page.hasNextPage()) {\n page = page.getNextPage();\n}\n\n// You can also access the underlying response\nconst response = page.response;\n" - } + "client": "import { SeedPaginationClient } from \"@fern/pagination\";\n\nconst client = new SeedPaginationClient({ environment: \"YOUR_BASE_URL\", token: \"YOUR_TOKEN\" });\nconst pageableResponse = await client.users.listWithCursorPagination({\n starting_after: \"d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32\"\n});\nfor await (const item of pageableResponse) {\n console.log(item);\n}\n\n// Or you can manually iterate page-by-page\nlet page = await client.users.listWithCursorPagination({\n starting_after: \"d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32\"\n});\nwhile (page.hasNextPage()) {\n page = page.getNextPage();\n}\n\n// You can also access the underlying response\nconst response = page.response;\n" + }, + "example_identifier": "Last page" }, { "id": { diff --git a/seed/ts-sdk/pagination/no-custom-config/src/api/resources/users/client/Client.ts b/seed/ts-sdk/pagination/no-custom-config/src/api/resources/users/client/Client.ts index 8a27c354b80c..a5939e8d8776 100644 --- a/seed/ts-sdk/pagination/no-custom-config/src/api/resources/users/client/Client.ts +++ b/seed/ts-sdk/pagination/no-custom-config/src/api/resources/users/client/Client.ts @@ -27,10 +27,7 @@ export class UsersClient { * * @example * await client.users.listWithCursorPagination({ - * page: 1, - * per_page: 1, - * order: "asc", - * starting_after: "starting_after" + * starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" * }) */ public async listWithCursorPagination( diff --git a/seed/ts-sdk/pagination/no-custom-config/src/api/resources/users/client/requests/ListUsersCursorPaginationRequest.ts b/seed/ts-sdk/pagination/no-custom-config/src/api/resources/users/client/requests/ListUsersCursorPaginationRequest.ts index 323664014a85..d6f4140a56f2 100644 --- a/seed/ts-sdk/pagination/no-custom-config/src/api/resources/users/client/requests/ListUsersCursorPaginationRequest.ts +++ b/seed/ts-sdk/pagination/no-custom-config/src/api/resources/users/client/requests/ListUsersCursorPaginationRequest.ts @@ -5,10 +5,7 @@ import type * as SeedPagination from "../../../../index.js"; /** * @example * { - * page: 1, - * per_page: 1, - * order: "asc", - * starting_after: "starting_after" + * starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" * } */ export interface ListUsersCursorPaginationRequest { diff --git a/seed/ts-sdk/pagination/no-custom-config/tests/wire/users.test.ts b/seed/ts-sdk/pagination/no-custom-config/tests/wire/users.test.ts index fe7a10ce1cc9..ed1cbff67f2f 100644 --- a/seed/ts-sdk/pagination/no-custom-config/tests/wire/users.test.ts +++ b/seed/ts-sdk/pagination/no-custom-config/tests/wire/users.test.ts @@ -9,13 +9,10 @@ describe("UsersClient", () => { const client = new SeedPaginationClient({ maxRetries: 0, token: "test", environment: server.baseUrl }); const rawResponseBody = { - hasNextPage: true, - page: { page: 1, next: { page: 1, starting_after: "starting_after" }, per_page: 1, total_page: 1 }, + hasNextPage: false, + page: { page: 1, per_page: 1, total_page: 1 }, total_count: 1, - data: [ - { name: "name", id: 1 }, - { name: "name", id: 1 }, - ], + data: [{ name: "user", id: 1 }], }; server .mockEndpoint({ once: false }) @@ -26,37 +23,26 @@ describe("UsersClient", () => { .build(); const expected = { - hasNextPage: true, + hasNextPage: false, page: { page: 1, - next: { - page: 1, - starting_after: "starting_after", - }, per_page: 1, total_page: 1, }, total_count: 1, data: [ { - name: "name", - id: 1, - }, - { - name: "name", + name: "user", id: 1, }, ], }; const page = await client.users.listWithCursorPagination({ - page: 1, - per_page: 1, - order: "asc", - starting_after: "starting_after", + starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32", }); expect(expected.data).toEqual(page.data); - expect(page.hasNextPage()).toBe(true); + expect(page.hasNextPage()).toBe(false); const nextPage = await page.getNextPage(); expect(expected.data).toEqual(nextPage.data); }); diff --git a/seed/ts-sdk/pagination/page-index-semantics/reference.md b/seed/ts-sdk/pagination/page-index-semantics/reference.md index c573520b9a41..7ee01de14a26 100644 --- a/seed/ts-sdk/pagination/page-index-semantics/reference.md +++ b/seed/ts-sdk/pagination/page-index-semantics/reference.md @@ -919,10 +919,7 @@ const response = page.response; ```typescript const pageableResponse = await client.users.listWithCursorPagination({ - page: 1, - per_page: 1, - order: "asc", - starting_after: "starting_after" + starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" }); for await (const item of pageableResponse) { console.log(item); @@ -930,10 +927,7 @@ for await (const item of pageableResponse) { // Or you can manually iterate page-by-page let page = await client.users.listWithCursorPagination({ - page: 1, - per_page: 1, - order: "asc", - starting_after: "starting_after" + starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" }); while (page.hasNextPage()) { page = page.getNextPage(); diff --git a/seed/ts-sdk/pagination/page-index-semantics/snippet.json b/seed/ts-sdk/pagination/page-index-semantics/snippet.json index f5991dfc786d..8c79386d2f16 100644 --- a/seed/ts-sdk/pagination/page-index-semantics/snippet.json +++ b/seed/ts-sdk/pagination/page-index-semantics/snippet.json @@ -151,8 +151,9 @@ }, "snippet": { "type": "typescript", - "client": "import { SeedPaginationClient } from \"@fern/pagination\";\n\nconst client = new SeedPaginationClient({ environment: \"YOUR_BASE_URL\", token: \"YOUR_TOKEN\" });\nconst pageableResponse = await client.users.listWithCursorPagination({\n page: 1,\n per_page: 1,\n order: \"asc\",\n starting_after: \"starting_after\"\n});\nfor await (const item of pageableResponse) {\n console.log(item);\n}\n\n// Or you can manually iterate page-by-page\nlet page = await client.users.listWithCursorPagination({\n page: 1,\n per_page: 1,\n order: \"asc\",\n starting_after: \"starting_after\"\n});\nwhile (page.hasNextPage()) {\n page = page.getNextPage();\n}\n\n// You can also access the underlying response\nconst response = page.response;\n" - } + "client": "import { SeedPaginationClient } from \"@fern/pagination\";\n\nconst client = new SeedPaginationClient({ environment: \"YOUR_BASE_URL\", token: \"YOUR_TOKEN\" });\nconst pageableResponse = await client.users.listWithCursorPagination({\n starting_after: \"d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32\"\n});\nfor await (const item of pageableResponse) {\n console.log(item);\n}\n\n// Or you can manually iterate page-by-page\nlet page = await client.users.listWithCursorPagination({\n starting_after: \"d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32\"\n});\nwhile (page.hasNextPage()) {\n page = page.getNextPage();\n}\n\n// You can also access the underlying response\nconst response = page.response;\n" + }, + "example_identifier": "Last page" }, { "id": { diff --git a/seed/ts-sdk/pagination/page-index-semantics/src/api/resources/users/client/Client.ts b/seed/ts-sdk/pagination/page-index-semantics/src/api/resources/users/client/Client.ts index 9dbc526ad27f..ac4ae8331a43 100644 --- a/seed/ts-sdk/pagination/page-index-semantics/src/api/resources/users/client/Client.ts +++ b/seed/ts-sdk/pagination/page-index-semantics/src/api/resources/users/client/Client.ts @@ -27,10 +27,7 @@ export class UsersClient { * * @example * await client.users.listWithCursorPagination({ - * page: 1, - * per_page: 1, - * order: "asc", - * starting_after: "starting_after" + * starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" * }) */ public async listWithCursorPagination( diff --git a/seed/ts-sdk/pagination/page-index-semantics/src/api/resources/users/client/requests/ListUsersCursorPaginationRequest.ts b/seed/ts-sdk/pagination/page-index-semantics/src/api/resources/users/client/requests/ListUsersCursorPaginationRequest.ts index 323664014a85..d6f4140a56f2 100644 --- a/seed/ts-sdk/pagination/page-index-semantics/src/api/resources/users/client/requests/ListUsersCursorPaginationRequest.ts +++ b/seed/ts-sdk/pagination/page-index-semantics/src/api/resources/users/client/requests/ListUsersCursorPaginationRequest.ts @@ -5,10 +5,7 @@ import type * as SeedPagination from "../../../../index.js"; /** * @example * { - * page: 1, - * per_page: 1, - * order: "asc", - * starting_after: "starting_after" + * starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" * } */ export interface ListUsersCursorPaginationRequest { diff --git a/seed/ts-sdk/pagination/page-index-semantics/tests/wire/users.test.ts b/seed/ts-sdk/pagination/page-index-semantics/tests/wire/users.test.ts index fe7a10ce1cc9..ed1cbff67f2f 100644 --- a/seed/ts-sdk/pagination/page-index-semantics/tests/wire/users.test.ts +++ b/seed/ts-sdk/pagination/page-index-semantics/tests/wire/users.test.ts @@ -9,13 +9,10 @@ describe("UsersClient", () => { const client = new SeedPaginationClient({ maxRetries: 0, token: "test", environment: server.baseUrl }); const rawResponseBody = { - hasNextPage: true, - page: { page: 1, next: { page: 1, starting_after: "starting_after" }, per_page: 1, total_page: 1 }, + hasNextPage: false, + page: { page: 1, per_page: 1, total_page: 1 }, total_count: 1, - data: [ - { name: "name", id: 1 }, - { name: "name", id: 1 }, - ], + data: [{ name: "user", id: 1 }], }; server .mockEndpoint({ once: false }) @@ -26,37 +23,26 @@ describe("UsersClient", () => { .build(); const expected = { - hasNextPage: true, + hasNextPage: false, page: { page: 1, - next: { - page: 1, - starting_after: "starting_after", - }, per_page: 1, total_page: 1, }, total_count: 1, data: [ { - name: "name", - id: 1, - }, - { - name: "name", + name: "user", id: 1, }, ], }; const page = await client.users.listWithCursorPagination({ - page: 1, - per_page: 1, - order: "asc", - starting_after: "starting_after", + starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32", }); expect(expected.data).toEqual(page.data); - expect(page.hasNextPage()).toBe(true); + expect(page.hasNextPage()).toBe(false); const nextPage = await page.getNextPage(); expect(expected.data).toEqual(nextPage.data); }); diff --git a/test-definitions/fern/apis/pagination/definition/users.yml b/test-definitions/fern/apis/pagination/definition/users.yml index 404975f519b5..4961868a7310 100644 --- a/test-definitions/fern/apis/pagination/definition/users.yml +++ b/test-definitions/fern/apis/pagination/definition/users.yml @@ -113,6 +113,21 @@ service: The cursor used for pagination in order to fetch the next page of results. response: ListUsersPaginationResponse + examples: + - name: Last page + query-parameters: + starting_after: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32" + response: + body: + hasNextPage: false + page: + page: 1 + per_page: 1 + total_page: 1 + total_count: 1 + data: + - name: "user" + id: 1 listWithMixedTypeCursorPagination: pagination: