json-rest-api 1.0.29 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +10 -670
- package/LICENSE-GPL-3.0 +674 -0
- package/LICENSE-MIT +21 -0
- package/README.md +39 -30
- package/docs/API.md +744 -1273
- package/docs/GUIDE/01-getting-started.md +338 -0
- package/docs/GUIDE/02-resources-and-relationships.md +33 -0
- package/docs/GUIDE/03-running-example.md +87 -0
- package/docs/GUIDE/04-creating-and-querying.md +181 -0
- package/docs/GUIDE/05-belongs-to.md +177 -0
- package/docs/GUIDE/06-has-many.md +184 -0
- package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
- package/docs/GUIDE/08-many-to-many.md +184 -0
- package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
- package/docs/GUIDE/10-put-and-patch.md +174 -0
- package/docs/GUIDE/11-relationship-endpoints.md +174 -0
- package/docs/GUIDE/12-field-transformations.md +287 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
- package/docs/GUIDE/14-custom-resource-methods.md +125 -0
- package/docs/GUIDE/15-query-projections.md +266 -0
- package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
- package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
- package/docs/GUIDE/18-labels.md +86 -0
- package/docs/GUIDE/19-managed-transactions.md +179 -0
- package/docs/GUIDE/20-transaction-outcomes.md +207 -0
- package/docs/GUIDE/21-schema-and-migrations.md +634 -0
- package/docs/GUIDE/22-version-field-migrations.md +208 -0
- package/docs/GUIDE/23-fastify.md +179 -0
- package/docs/GUIDE/24-cors.md +172 -0
- package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
- package/docs/GUIDE/26-file-uploads.md +278 -0
- package/docs/GUIDE/27-bulk-operations.md +277 -0
- package/docs/GUIDE/28-socketio.md +325 -0
- package/docs/GUIDE/29-writing-plugins.md +278 -0
- package/docs/GUIDE/30-backend-capabilities.md +100 -0
- package/docs/GUIDE/31-positioning.md +181 -0
- package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
- package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
- package/docs/GUIDE/index.md +51 -59
- package/docs/QUICKSTART.md +120 -287
- package/docs/architecture.md +75 -0
- package/docs/contributing.md +178 -0
- package/index.d.ts +36 -0
- package/index.js +1 -22
- package/lib/enhanced-logger.js +91 -85
- package/lib/error-context.js +445 -5
- package/lib/error-formatter.js +219 -59
- package/lib/knex-transaction.js +64 -0
- package/lib/logger-types.d.ts +22 -0
- package/lib/missing-package.js +4 -0
- package/lib/rest-api-errors.js +103 -8
- package/lib/runtime/json-rest-api.js +172 -0
- package/lib/transaction-types.d.ts +59 -0
- package/package.json +97 -27
- package/plugins/core/bulk-operations-plugin.js +191 -184
- package/plugins/core/connectors/express-plugin.js +148 -219
- package/plugins/core/connectors/fastify-plugin.js +125 -82
- package/plugins/core/connectors/lib/busboy-detector.js +74 -156
- package/plugins/core/connectors/lib/connector-core.js +144 -20
- package/plugins/core/connectors/lib/formidable-detector.js +63 -120
- package/plugins/core/connectors/lib/http-validators.js +77 -0
- package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
- package/plugins/core/connectors/lib/request-helpers.js +10 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
- package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
- package/plugins/core/file-handling-plugin.js +87 -177
- package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
- package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
- package/plugins/core/lib/anyapi/schema-utils.js +25 -16
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
- package/plugins/core/lib/dbIntrospection.js +228 -21
- package/plugins/core/lib/dbTablesOperations.js +409 -92
- package/plugins/core/lib/fractional-positioning.js +12 -48
- package/plugins/core/lib/querying/identifier-query.js +19 -0
- package/plugins/core/lib/querying/include-query-helpers.js +364 -0
- package/plugins/core/lib/querying/include-to-many.js +428 -0
- package/plugins/core/lib/querying/include-to-one.js +579 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
- package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
- package/plugins/core/lib/querying/knex-process-includes.js +5 -110
- package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
- package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
- package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
- package/plugins/core/lib/querying/knex-window-queries.js +62 -253
- package/plugins/core/lib/querying/query-builder-utils.js +44 -4
- package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
- package/plugins/core/lib/querying/query-constraint.js +27 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +13 -1
- package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
- package/plugins/core/lib/querying/url-helpers.js +11 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
- package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
- package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
- package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
- package/plugins/core/lib/querying-writing/field-utils.js +113 -92
- package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
- package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
- package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
- package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
- package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
- package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
- package/plugins/core/lib/querying-writing/response-options.js +35 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
- package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
- package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
- package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
- package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
- package/plugins/core/lib/storage/storage-adapter.js +128 -62
- package/plugins/core/lib/storage/storage-mapping.js +78 -28
- package/plugins/core/lib/storage/storage-types.d.ts +502 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
- package/plugins/core/lib/writing/relationship-processor.js +94 -158
- package/plugins/core/lib/writing/resource-version.js +172 -0
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2485
- package/plugins/core/rest-api-autofilter-plugin.js +17 -19
- package/plugins/core/rest-api-cors-plugin.js +52 -205
- package/plugins/core/rest-api-knex-plugin.js +45 -1077
- package/plugins/core/rest-api-label-plugin.js +25 -38
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
- package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
- package/plugins/core/rest-api-plugin-methods/common.js +291 -395
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
- package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
- package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
- package/plugins/core/rest-api-plugin-methods/get.js +36 -68
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
- package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
- package/plugins/core/rest-api-plugin-methods/post.js +51 -51
- package/plugins/core/rest-api-plugin-methods/put.js +69 -148
- package/plugins/core/rest-api-plugin-methods/query.js +37 -81
- package/plugins/core/rest-api-plugin-methods/release.js +2 -1
- package/plugins/core/rest-api-plugin.js +32 -46
- package/plugins/core/rest-api-positioning-plugin.js +183 -628
- package/plugins/core/rest-api-query-projections-plugin.js +19 -102
- package/plugins/core/rest-api-row-policy-plugin.js +3 -3
- package/plugins/core/socketio-plugin.js +364 -344
- package/plugins/storage/local-storage.js +40 -36
- package/types/autofilter.d.ts +57 -0
- package/types/bulk-methods.d.ts +63 -0
- package/types/errors.d.ts +118 -0
- package/types/fastify-options.d.ts +19 -0
- package/types/file-detectors.d.ts +33 -0
- package/types/file-storage.d.ts +48 -0
- package/types/hook-context.d.ts +180 -0
- package/types/plugin-options.d.ts +53 -0
- package/types/relationship-methods.d.ts +74 -0
- package/types/representations.d.ts +50 -0
- package/types/resource-methods.d.ts +95 -0
- package/types/resource-schema.d.ts +157 -0
- package/types/row-policy.d.ts +31 -0
- package/types/runtime.d.ts +132 -0
- package/types/transactions.d.ts +21 -0
- package/.claude/agents/library-guide-expert.md +0 -30
- package/.claude/agents/rest-api-internals-expert.md +0 -54
- package/.claude/settings.local.json +0 -70
- package/AGENTS.md +0 -34
- package/CLAUDE.md +0 -160
- package/CRUSH.md +0 -55
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
- package/TODO.todo +0 -13
- package/connectors_ideas/fastify.js +0 -217
- package/connectors_ideas/koa-plugin.js +0 -112
- package/docs/.bundle/config +0 -2
- package/docs/COMPARISON.md +0 -433
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
- package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
- package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
- package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
- package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
- package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
- package/docs/Gemfile +0 -6
- package/docs/Gemfile.lock +0 -308
- package/docs/ONBOARDING.md +0 -315
- package/docs/_config.yml +0 -36
- package/docs/_layouts/default.html +0 -123
- package/docs/apple-touch-icon.svg +0 -4
- package/docs/assets/css/style.scss +0 -392
- package/docs/favicon.svg +0 -4
- package/docs/index.md +0 -164
- package/docs/mini-guides/file-uploads-complete.md +0 -161
- package/eslint.config.js +0 -13
- package/examples/url-override-example.js +0 -137
- package/fixing_plan.md +0 -112
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
- package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
- package/plugins/storage/s3-storage.js +0 -93
- package/quickTest.js +0 -136
- package/scripts/debug-anyapi-review-filter.mjs +0 -63
- package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
- package/scripts/inspect-anyapi-descriptor.mjs +0 -19
- package/scripts/run-docs.js +0 -133
- package/test-debug.js +0 -59
- package/tests/README.md +0 -202
- package/tests/TEST_TEMPLATE.test.js +0 -178
- package/tests/anyapi-basic.test.js +0 -105
- package/tests/anyapi-cursor-pagination.test.js +0 -148
- package/tests/anyapi-custom-idproperty.test.js +0 -163
- package/tests/autofilter.test.js +0 -463
- package/tests/bulk-operations.test.js +0 -657
- package/tests/computed-fields.test.js +0 -586
- package/tests/cors.test.js +0 -834
- package/tests/curl.test.js +0 -467
- package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
- package/tests/custom-id-simplified-post.test.js +0 -75
- package/tests/custom-idproperty-relationships.test.js +0 -932
- package/tests/db-introspection.test.js +0 -521
- package/tests/db-table-operations.test.js +0 -798
- package/tests/fastify-plugin.test.js +0 -184
- package/tests/field-getters.test.js +0 -370
- package/tests/field-setters.test.js +0 -306
- package/tests/file-handling.test.js +0 -155
- package/tests/fixtures/api-configs.js +0 -2566
- package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
- package/tests/helpers/fake-fastify.js +0 -99
- package/tests/helpers/storage-mode.js +0 -65
- package/tests/helpers/test-utils.js +0 -203
- package/tests/http-connectors-parity.test.js +0 -395
- package/tests/id-normalization.test.js +0 -444
- package/tests/include-depth-validation.test.js +0 -331
- package/tests/includes.test.js +0 -427
- package/tests/local-storage.test.js +0 -96
- package/tests/logical-id-write-validation.test.js +0 -151
- package/tests/nested-includes.test.js +0 -617
- package/tests/pagination-boundaries.test.js +0 -154
- package/tests/pagination-contract.test.js +0 -122
- package/tests/pagination-cursor-multifield.test.js +0 -641
- package/tests/pagination-enhanced.test.js +0 -563
- package/tests/polymorphic-relationships.test.js +0 -347
- package/tests/polymorphic-simple.test.js +0 -148
- package/tests/positioning-advanced.test.js +0 -692
- package/tests/positioning-extended.test.js +0 -915
- package/tests/positioning-multifilter.test.js +0 -699
- package/tests/positioning.test.js +0 -560
- package/tests/post-commit-hooks.test.js +0 -120
- package/tests/projected-fields.test.js +0 -319
- package/tests/public-exports.test.js +0 -15
- package/tests/put-create-parity.test.js +0 -216
- package/tests/queries.test.js +0 -521
- package/tests/query-limits.test.js +0 -584
- package/tests/relationship-endpoints.test.js +0 -620
- package/tests/relationship-include-storage-adapter.test.js +0 -56
- package/tests/relationships.test.js +0 -681
- package/tests/rest-api.test.js +0 -337
- package/tests/return-record-settings.test.js +0 -184
- package/tests/row-policy.test.js +0 -489
- package/tests/s3-storage.test.js +0 -38
- package/tests/searchschema-knex-behavior.test.js +0 -187
- package/tests/searchschema-merge.test.js +0 -151
- package/tests/socketio.test.js +0 -860
- package/tests/storage-mapping.test.js +0 -362
- package/tests/temporal-boundaries.test.js +0 -208
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,641 +0,0 @@
|
|
|
1
|
-
import { describe, it, before, after, beforeEach } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import knexLib from 'knex'
|
|
4
|
-
import {
|
|
5
|
-
validateJsonApiStructure,
|
|
6
|
-
cleanTables,
|
|
7
|
-
createJsonApiDocument
|
|
8
|
-
} from './helpers/test-utils.js'
|
|
9
|
-
import { createCursorPaginationApi } from './fixtures/api-configs.js'
|
|
10
|
-
import { storageMode } from './helpers/storage-mode.js'
|
|
11
|
-
|
|
12
|
-
// Create Knex instance for tests
|
|
13
|
-
const knex = knexLib({
|
|
14
|
-
client: 'better-sqlite3',
|
|
15
|
-
connection: {
|
|
16
|
-
filename: ':memory:'
|
|
17
|
-
},
|
|
18
|
-
useNullAsDefault: true
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
// API instance that persists across tests
|
|
22
|
-
let api
|
|
23
|
-
const anyapiIt = storageMode.isAnyApi() ? it : it.skip
|
|
24
|
-
|
|
25
|
-
async function countAnyRecords (resource) {
|
|
26
|
-
const result = await knex('any_records')
|
|
27
|
-
.where({
|
|
28
|
-
tenant_id: api.anyapi.tenantId,
|
|
29
|
-
resource
|
|
30
|
-
})
|
|
31
|
-
.count('* as count')
|
|
32
|
-
.first()
|
|
33
|
-
|
|
34
|
-
return Number(result?.count ?? 0)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe('Multi-field Cursor Pagination', () => {
|
|
38
|
-
before(async () => {
|
|
39
|
-
// Initialize API once with cursor pagination configuration
|
|
40
|
-
api = await createCursorPaginationApi(knex)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
after(async () => {
|
|
44
|
-
// Close database connection
|
|
45
|
-
await knex.destroy()
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
anyapiIt('cleans cursor fixture records from AnyAPI canonical storage', async () => {
|
|
49
|
-
await cleanTables(knex, ['cursor_products', 'cursor_items'])
|
|
50
|
-
|
|
51
|
-
await api.resources.products.post({
|
|
52
|
-
inputRecord: createJsonApiDocument('products', {
|
|
53
|
-
name: 'Cleanup Product',
|
|
54
|
-
category: 'Cleanup',
|
|
55
|
-
brand: 'Fixture',
|
|
56
|
-
price: 10,
|
|
57
|
-
sku: 'CLEAN-001'
|
|
58
|
-
}),
|
|
59
|
-
simplified: false
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
await api.resources.items.post({
|
|
63
|
-
inputRecord: createJsonApiDocument('items', {
|
|
64
|
-
code: 'CLEAN',
|
|
65
|
-
name: 'Cleanup Item',
|
|
66
|
-
category: 'Cleanup',
|
|
67
|
-
type: 'fixture'
|
|
68
|
-
}),
|
|
69
|
-
simplified: false
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
assert.equal(await countAnyRecords('products'), 1)
|
|
73
|
-
assert.equal(await countAnyRecords('items'), 1)
|
|
74
|
-
|
|
75
|
-
await cleanTables(knex, ['cursor_products'])
|
|
76
|
-
|
|
77
|
-
assert.equal(await countAnyRecords('products'), 0)
|
|
78
|
-
assert.equal(await countAnyRecords('items'), 1)
|
|
79
|
-
|
|
80
|
-
await cleanTables(knex, ['cursor_items'])
|
|
81
|
-
|
|
82
|
-
assert.equal(await countAnyRecords('items'), 0)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
describe('Basic Multi-field Cursor Pagination', () => {
|
|
86
|
-
beforeEach(async () => {
|
|
87
|
-
await cleanTables(knex, ['cursor_products'])
|
|
88
|
-
|
|
89
|
-
// Create test data with some duplicate categories and brands
|
|
90
|
-
const products = [
|
|
91
|
-
{ name: 'Apple', category: 'Fruit', brand: 'Fresh Farm', price: 2.50, sku: 'FF-001' },
|
|
92
|
-
{ name: 'Banana', category: 'Fruit', brand: 'Fresh Farm', price: 1.50, sku: 'FF-002' },
|
|
93
|
-
{ name: 'Cherry', category: 'Fruit', brand: 'Fresh Farm', price: 3.00, sku: 'FF-003' },
|
|
94
|
-
{ name: 'Date', category: 'Fruit', brand: 'Desert Delights', price: 4.00, sku: 'DD-001' },
|
|
95
|
-
{ name: 'Elderberry', category: 'Fruit', brand: 'Wild Harvest', price: 5.00, sku: 'WH-001' },
|
|
96
|
-
{ name: 'Asparagus', category: 'Vegetable', brand: 'Green Gardens', price: 3.50, sku: 'GG-001' },
|
|
97
|
-
{ name: 'Broccoli', category: 'Vegetable', brand: 'Green Gardens', price: 2.00, sku: 'GG-002' },
|
|
98
|
-
{ name: 'Carrot', category: 'Vegetable', brand: 'Root Ranch', price: 1.00, sku: 'RR-001' }
|
|
99
|
-
]
|
|
100
|
-
|
|
101
|
-
for (const product of products) {
|
|
102
|
-
await api.resources.products.post({
|
|
103
|
-
inputRecord: createJsonApiDocument('products', product),
|
|
104
|
-
simplified: false
|
|
105
|
-
})
|
|
106
|
-
}
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('should handle cursor pagination with multi-field sorting without skipping records', async () => {
|
|
110
|
-
// Sort by category first (which has duplicates), then by name
|
|
111
|
-
const pageSize = 3
|
|
112
|
-
const allProducts = []
|
|
113
|
-
let cursor = null
|
|
114
|
-
let pageCount = 0
|
|
115
|
-
|
|
116
|
-
// Collect all products through cursor pagination
|
|
117
|
-
while (pageCount < 10) { // Safety limit
|
|
118
|
-
const result = await api.resources.products.query({
|
|
119
|
-
queryParams: {
|
|
120
|
-
page: {
|
|
121
|
-
size: pageSize,
|
|
122
|
-
...(cursor && { after: cursor })
|
|
123
|
-
},
|
|
124
|
-
sort: ['category', 'name']
|
|
125
|
-
},
|
|
126
|
-
simplified: false
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
validateJsonApiStructure(result, true)
|
|
130
|
-
allProducts.push(...result.data)
|
|
131
|
-
pageCount++
|
|
132
|
-
|
|
133
|
-
if (!result.meta.pagination.hasMore) {
|
|
134
|
-
break
|
|
135
|
-
}
|
|
136
|
-
cursor = result.meta.pagination.cursor.next
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Verify we got all 8 products
|
|
140
|
-
assert.equal(allProducts.length, 8, 'Should retrieve all 8 products')
|
|
141
|
-
|
|
142
|
-
// Verify the order is correct
|
|
143
|
-
const productNames = allProducts.map(p => p.attributes.name)
|
|
144
|
-
const expectedOrder = [
|
|
145
|
-
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', // All Fruits in alphabetical order
|
|
146
|
-
'Asparagus', 'Broccoli', 'Carrot' // All Vegetables in alphabetical order
|
|
147
|
-
]
|
|
148
|
-
assert.deepEqual(productNames, expectedOrder, 'Products should be in correct order')
|
|
149
|
-
|
|
150
|
-
// Verify no duplicates
|
|
151
|
-
const ids = allProducts.map(p => p.id)
|
|
152
|
-
const uniqueIds = [...new Set(ids)]
|
|
153
|
-
assert.equal(ids.length, uniqueIds.length, 'Should have no duplicate products')
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('should handle DESC sorting with duplicates correctly', async () => {
|
|
157
|
-
// Sort by category DESC, then name ASC
|
|
158
|
-
const firstPage = await api.resources.products.query({
|
|
159
|
-
queryParams: {
|
|
160
|
-
page: { size: 3 },
|
|
161
|
-
sort: ['-category', 'name']
|
|
162
|
-
},
|
|
163
|
-
simplified: false
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
const secondPage = await api.resources.products.query({
|
|
167
|
-
queryParams: {
|
|
168
|
-
page: {
|
|
169
|
-
size: 3,
|
|
170
|
-
after: firstPage.meta.pagination.cursor.next
|
|
171
|
-
},
|
|
172
|
-
sort: ['-category', 'name']
|
|
173
|
-
},
|
|
174
|
-
simplified: false
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
// First page should have vegetables (category DESC)
|
|
178
|
-
const firstPageCategories = firstPage.data.map(p => p.attributes.category)
|
|
179
|
-
assert(firstPageCategories.every(c => c === 'Vegetable'), 'First page should only have Vegetables')
|
|
180
|
-
|
|
181
|
-
// Second page should start with remaining vegetables or fruits
|
|
182
|
-
const secondPageNames = secondPage.data.map(p => p.attributes.name)
|
|
183
|
-
assert(secondPageNames.includes('Apple') || secondPageNames.includes('Banana'),
|
|
184
|
-
'Second page should include fruits that come after vegetables')
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
it('should handle three-field sorting with multiple duplicate values', async () => {
|
|
188
|
-
// Add more products with duplicate category AND brand
|
|
189
|
-
await api.resources.products.post({
|
|
190
|
-
inputRecord: createJsonApiDocument('products', {
|
|
191
|
-
name: 'Zucchini',
|
|
192
|
-
category: 'Vegetable',
|
|
193
|
-
brand: 'Green Gardens',
|
|
194
|
-
price: 2.50,
|
|
195
|
-
sku: 'GG-003'
|
|
196
|
-
}),
|
|
197
|
-
simplified: false
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
const pageSize = 2
|
|
201
|
-
const allProducts = []
|
|
202
|
-
let cursor = null
|
|
203
|
-
let iterations = 0
|
|
204
|
-
|
|
205
|
-
// Sort by category, brand, then name
|
|
206
|
-
while (iterations < 20) {
|
|
207
|
-
const result = await api.resources.products.query({
|
|
208
|
-
queryParams: {
|
|
209
|
-
page: {
|
|
210
|
-
size: pageSize,
|
|
211
|
-
...(cursor && { after: cursor })
|
|
212
|
-
},
|
|
213
|
-
sort: ['category', 'brand', 'name']
|
|
214
|
-
},
|
|
215
|
-
simplified: false
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
allProducts.push(...result.data)
|
|
219
|
-
|
|
220
|
-
if (!result.meta.pagination.hasMore) {
|
|
221
|
-
break
|
|
222
|
-
}
|
|
223
|
-
cursor = result.meta.pagination.cursor.next
|
|
224
|
-
iterations += 1
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
assert(iterations < 20, 'Cursor pagination should converge within expected page count')
|
|
228
|
-
|
|
229
|
-
// Find all Green Gardens vegetables
|
|
230
|
-
const greenGardensVeggies = allProducts.filter(p =>
|
|
231
|
-
p.attributes.category === 'Vegetable' &&
|
|
232
|
-
p.attributes.brand === 'Green Gardens'
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
assert.equal(greenGardensVeggies.length, 3, 'Should have all 3 Green Gardens vegetables')
|
|
236
|
-
|
|
237
|
-
// Verify they're in name order
|
|
238
|
-
const ggNames = greenGardensVeggies.map(p => p.attributes.name)
|
|
239
|
-
assert.deepEqual(ggNames, ['Asparagus', 'Broccoli', 'Zucchini'],
|
|
240
|
-
'Green Gardens vegetables should be in alphabetical order by name')
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
it('should handle cursor pagination with numeric and text fields', async () => {
|
|
244
|
-
// Sort by price (numeric), then category (text), then name (text)
|
|
245
|
-
const firstPage = await api.resources.products.query({
|
|
246
|
-
queryParams: {
|
|
247
|
-
page: { size: 3 },
|
|
248
|
-
sort: ['price', 'category', 'name']
|
|
249
|
-
},
|
|
250
|
-
simplified: false
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
// The cheapest item is Carrot at 1.00
|
|
254
|
-
assert.equal(firstPage.data[0].attributes.name, 'Carrot')
|
|
255
|
-
assert.equal(firstPage.data[0].attributes.price, 1.00)
|
|
256
|
-
|
|
257
|
-
// Get second page
|
|
258
|
-
const secondPage = await api.resources.products.query({
|
|
259
|
-
queryParams: {
|
|
260
|
-
page: {
|
|
261
|
-
size: 3,
|
|
262
|
-
after: firstPage.meta.pagination.cursor.next
|
|
263
|
-
},
|
|
264
|
-
sort: ['price', 'category', 'name']
|
|
265
|
-
},
|
|
266
|
-
simplified: false
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
// Verify prices are non-decreasing across pages
|
|
270
|
-
const lastPriceFirstPage = firstPage.data[firstPage.data.length - 1].attributes.price
|
|
271
|
-
const firstPriceSecondPage = secondPage.data[0].attributes.price
|
|
272
|
-
assert(firstPriceSecondPage >= lastPriceFirstPage,
|
|
273
|
-
'Prices should be non-decreasing across pages')
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
it('should advance when sorting by logical camelCase fields backed by snake_case storage columns', async () => {
|
|
277
|
-
await cleanTables(knex, ['cursor_products'])
|
|
278
|
-
|
|
279
|
-
const products = [
|
|
280
|
-
{
|
|
281
|
-
name: 'Newest Product',
|
|
282
|
-
category: 'Catalog',
|
|
283
|
-
brand: 'Acme',
|
|
284
|
-
price: 10,
|
|
285
|
-
sku: 'CAMEL-001',
|
|
286
|
-
createdAt: '2026-05-03T10:00:00.000Z'
|
|
287
|
-
},
|
|
288
|
-
{
|
|
289
|
-
name: 'Recent Product',
|
|
290
|
-
category: 'Catalog',
|
|
291
|
-
brand: 'Acme',
|
|
292
|
-
price: 20,
|
|
293
|
-
sku: 'CAMEL-002',
|
|
294
|
-
createdAt: '2026-05-03T09:00:00.000Z'
|
|
295
|
-
},
|
|
296
|
-
{
|
|
297
|
-
name: 'Older Product',
|
|
298
|
-
category: 'Catalog',
|
|
299
|
-
brand: 'Acme',
|
|
300
|
-
price: 30,
|
|
301
|
-
sku: 'CAMEL-003',
|
|
302
|
-
createdAt: '2026-05-03T08:00:00.000Z'
|
|
303
|
-
}
|
|
304
|
-
]
|
|
305
|
-
|
|
306
|
-
for (const product of products) {
|
|
307
|
-
await api.resources.products.post({
|
|
308
|
-
inputRecord: createJsonApiDocument('products', product),
|
|
309
|
-
simplified: false
|
|
310
|
-
})
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const firstPage = await api.resources.products.query({
|
|
314
|
-
queryParams: {
|
|
315
|
-
page: { size: 2 },
|
|
316
|
-
sort: ['-createdAt']
|
|
317
|
-
},
|
|
318
|
-
simplified: false
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
assert.equal(firstPage.data.length, 2)
|
|
322
|
-
assert.equal(firstPage.meta.pagination.cursor?.next.includes('createdAt:'), true,
|
|
323
|
-
'Cursor should include the logical createdAt field')
|
|
324
|
-
|
|
325
|
-
const secondPage = await api.resources.products.query({
|
|
326
|
-
queryParams: {
|
|
327
|
-
page: {
|
|
328
|
-
size: 2,
|
|
329
|
-
after: firstPage.meta.pagination.cursor.next
|
|
330
|
-
},
|
|
331
|
-
sort: ['-createdAt']
|
|
332
|
-
},
|
|
333
|
-
simplified: false
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
assert.deepEqual(
|
|
337
|
-
firstPage.data.map((record) => record.attributes.name),
|
|
338
|
-
['Newest Product', 'Recent Product']
|
|
339
|
-
)
|
|
340
|
-
assert.deepEqual(
|
|
341
|
-
firstPage.data.map((record) => record.attributes.createdAt),
|
|
342
|
-
['2026-05-03T10:00:00.000Z', '2026-05-03T09:00:00.000Z']
|
|
343
|
-
)
|
|
344
|
-
assert.deepEqual(
|
|
345
|
-
secondPage.data.map((record) => record.attributes.name),
|
|
346
|
-
['Older Product']
|
|
347
|
-
)
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
it('should generate correct cursor values for multi-field sorting', async () => {
|
|
351
|
-
const result = await api.resources.products.query({
|
|
352
|
-
queryParams: {
|
|
353
|
-
page: { size: 3 },
|
|
354
|
-
sort: ['category', 'name']
|
|
355
|
-
},
|
|
356
|
-
simplified: false
|
|
357
|
-
})
|
|
358
|
-
|
|
359
|
-
// Check that cursor is present
|
|
360
|
-
assert(result.meta.pagination.cursor?.next, 'Should have next cursor')
|
|
361
|
-
|
|
362
|
-
// The cursor should be parseable (not throwing an error)
|
|
363
|
-
const cursor = result.meta.pagination.cursor.next
|
|
364
|
-
assert(typeof cursor === 'string', 'Cursor should be a string')
|
|
365
|
-
|
|
366
|
-
// Cursor should encode multiple field values
|
|
367
|
-
// Since we're using the simplified format, it should contain field:value pairs
|
|
368
|
-
assert(cursor.includes('category:'), 'Cursor should include category field')
|
|
369
|
-
assert(cursor.includes('name:'), 'Cursor should include name field')
|
|
370
|
-
})
|
|
371
|
-
|
|
372
|
-
it('should handle page[before] with multi-field sorting', async () => {
|
|
373
|
-
// First, get to the middle of the dataset
|
|
374
|
-
const firstPage = await api.resources.products.query({
|
|
375
|
-
queryParams: {
|
|
376
|
-
page: { size: 4 },
|
|
377
|
-
sort: ['category', 'name']
|
|
378
|
-
},
|
|
379
|
-
simplified: false
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
const secondPage = await api.resources.products.query({
|
|
383
|
-
queryParams: {
|
|
384
|
-
page: {
|
|
385
|
-
size: 4,
|
|
386
|
-
after: firstPage.meta.pagination.cursor.next
|
|
387
|
-
},
|
|
388
|
-
sort: ['category', 'name']
|
|
389
|
-
},
|
|
390
|
-
simplified: false
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
// Now go backwards from the second page
|
|
394
|
-
const previousPage = await api.resources.products.query({
|
|
395
|
-
queryParams: {
|
|
396
|
-
page: {
|
|
397
|
-
size: 4,
|
|
398
|
-
before: `category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)},id:${encodeURIComponent(secondPage.data[0].id)}`
|
|
399
|
-
},
|
|
400
|
-
sort: ['category', 'name']
|
|
401
|
-
},
|
|
402
|
-
simplified: false
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
// Verify we got different records going backwards
|
|
406
|
-
const secondPageIds = secondPage.data.map(p => p.id)
|
|
407
|
-
const previousPageIds = previousPage.data.map(p => p.id)
|
|
408
|
-
assert(!secondPageIds.some(id => previousPageIds.includes(id)),
|
|
409
|
-
'Previous page should have different records than second page')
|
|
410
|
-
})
|
|
411
|
-
})
|
|
412
|
-
|
|
413
|
-
describe('Edge Cases and Special Scenarios', () => {
|
|
414
|
-
beforeEach(async () => {
|
|
415
|
-
await cleanTables(knex, ['cursor_products', 'cursor_items'])
|
|
416
|
-
})
|
|
417
|
-
|
|
418
|
-
it('should handle all records having the same value in first sort field', async () => {
|
|
419
|
-
// Create products all in the same category
|
|
420
|
-
const products = [
|
|
421
|
-
{ name: 'Alpha', category: 'Electronics', brand: 'TechCo', price: 100, sku: 'TC-001' },
|
|
422
|
-
{ name: 'Beta', category: 'Electronics', brand: 'TechCo', price: 200, sku: 'TC-002' },
|
|
423
|
-
{ name: 'Gamma', category: 'Electronics', brand: 'GadgetInc', price: 150, sku: 'GI-001' },
|
|
424
|
-
{ name: 'Delta', category: 'Electronics', brand: 'GadgetInc', price: 250, sku: 'GI-002' },
|
|
425
|
-
{ name: 'Epsilon', category: 'Electronics', brand: 'TechCo', price: 300, sku: 'TC-003' }
|
|
426
|
-
]
|
|
427
|
-
|
|
428
|
-
for (const product of products) {
|
|
429
|
-
await api.resources.products.post({
|
|
430
|
-
inputRecord: createJsonApiDocument('products', product),
|
|
431
|
-
simplified: false
|
|
432
|
-
})
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Sort by category (all same), then brand, then name
|
|
436
|
-
const pageSize = 2
|
|
437
|
-
const allProducts = []
|
|
438
|
-
let cursor = null
|
|
439
|
-
|
|
440
|
-
while (true) {
|
|
441
|
-
const result = await api.resources.products.query({
|
|
442
|
-
queryParams: {
|
|
443
|
-
page: {
|
|
444
|
-
size: pageSize,
|
|
445
|
-
...(cursor && { after: cursor })
|
|
446
|
-
},
|
|
447
|
-
sort: ['category', 'brand', 'name']
|
|
448
|
-
},
|
|
449
|
-
simplified: false
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
allProducts.push(...result.data)
|
|
453
|
-
|
|
454
|
-
if (!result.meta.pagination.hasMore) {
|
|
455
|
-
break
|
|
456
|
-
}
|
|
457
|
-
cursor = result.meta.pagination.cursor.next
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
assert.equal(allProducts.length, 5, 'Should get all 5 products')
|
|
461
|
-
|
|
462
|
-
// Check order: should be grouped by brand, then by name
|
|
463
|
-
const productInfo = allProducts.map(p => ({
|
|
464
|
-
brand: p.attributes.brand,
|
|
465
|
-
name: p.attributes.name
|
|
466
|
-
}))
|
|
467
|
-
|
|
468
|
-
// GadgetInc should come before TechCo
|
|
469
|
-
const gadgetIncIndex = productInfo.findIndex(p => p.brand === 'GadgetInc')
|
|
470
|
-
const firstTechCoIndex = productInfo.findIndex(p => p.brand === 'TechCo')
|
|
471
|
-
assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo')
|
|
472
|
-
})
|
|
473
|
-
|
|
474
|
-
it('should handle empty results with multi-field cursor', async () => {
|
|
475
|
-
// Create one product
|
|
476
|
-
await api.resources.products.post({
|
|
477
|
-
inputRecord: createJsonApiDocument('products', {
|
|
478
|
-
name: 'Single Product',
|
|
479
|
-
category: 'Misc',
|
|
480
|
-
brand: 'Generic',
|
|
481
|
-
price: 10,
|
|
482
|
-
sku: 'GEN-001'
|
|
483
|
-
}),
|
|
484
|
-
simplified: false
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
// Get the first (and only) page
|
|
488
|
-
const firstPage = await api.resources.products.query({
|
|
489
|
-
queryParams: {
|
|
490
|
-
page: { size: 5 },
|
|
491
|
-
sort: ['category', 'name']
|
|
492
|
-
},
|
|
493
|
-
simplified: false
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
assert.equal(firstPage.data.length, 1)
|
|
497
|
-
assert.equal(firstPage.meta.pagination.hasMore, false)
|
|
498
|
-
assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page')
|
|
499
|
-
})
|
|
500
|
-
|
|
501
|
-
it('should handle special characters in multi-field cursors', async () => {
|
|
502
|
-
// Create products with special characters
|
|
503
|
-
const products = [
|
|
504
|
-
{ name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
|
|
505
|
-
{ name: 'Product: Extra', category: 'Cat:1', brand: 'Brand,B', price: 20, sku: 'SP-002' },
|
|
506
|
-
{ name: 'Product: Normal', category: 'Cat:2', brand: 'Brand,A', price: 15, sku: 'SP-003' }
|
|
507
|
-
]
|
|
508
|
-
|
|
509
|
-
for (const product of products) {
|
|
510
|
-
await api.resources.products.post({
|
|
511
|
-
inputRecord: createJsonApiDocument('products', product),
|
|
512
|
-
simplified: false
|
|
513
|
-
})
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Navigate through pages
|
|
517
|
-
const firstPage = await api.resources.products.query({
|
|
518
|
-
queryParams: {
|
|
519
|
-
page: { size: 1 },
|
|
520
|
-
sort: ['category', 'brand', 'name']
|
|
521
|
-
},
|
|
522
|
-
simplified: false
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
assert(firstPage.meta.pagination.cursor?.next, 'Should have cursor')
|
|
526
|
-
|
|
527
|
-
// Use the cursor to get next page - should not throw
|
|
528
|
-
const secondPage = await api.resources.products.query({
|
|
529
|
-
queryParams: {
|
|
530
|
-
page: {
|
|
531
|
-
size: 1,
|
|
532
|
-
after: firstPage.meta.pagination.cursor.next
|
|
533
|
-
},
|
|
534
|
-
sort: ['category', 'brand', 'name']
|
|
535
|
-
},
|
|
536
|
-
simplified: false
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
assert.equal(secondPage.data.length, 1)
|
|
540
|
-
assert.notEqual(secondPage.data[0].id, firstPage.data[0].id,
|
|
541
|
-
'Should get different record on second page')
|
|
542
|
-
})
|
|
543
|
-
|
|
544
|
-
it('should maintain stable sorting with custom id property', async () => {
|
|
545
|
-
// Create items using the resource with custom ID property
|
|
546
|
-
const items = [
|
|
547
|
-
{ code: 'A001', name: 'Apple', category: 'Fruit', type: 'Fresh' },
|
|
548
|
-
{ code: 'A002', name: 'Apricot', category: 'Fruit', type: 'Fresh' },
|
|
549
|
-
{ code: 'B001', name: 'Banana', category: 'Fruit', type: 'Fresh' },
|
|
550
|
-
{ code: 'C001', name: 'Carrot', category: 'Vegetable', type: 'Root' }
|
|
551
|
-
]
|
|
552
|
-
|
|
553
|
-
for (const item of items) {
|
|
554
|
-
await api.resources.items.post({
|
|
555
|
-
inputRecord: createJsonApiDocument('items', item),
|
|
556
|
-
simplified: false
|
|
557
|
-
})
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Query with multi-field sort
|
|
561
|
-
const result = await api.resources.items.query({
|
|
562
|
-
queryParams: {
|
|
563
|
-
page: { size: 2 },
|
|
564
|
-
sort: ['category', 'name']
|
|
565
|
-
},
|
|
566
|
-
simplified: false
|
|
567
|
-
})
|
|
568
|
-
|
|
569
|
-
// Should use 'item_id' as the ID field in responses
|
|
570
|
-
assert(result.data[0].id, 'Should have id field')
|
|
571
|
-
assert(typeof result.data[0].id === 'string', 'ID should be a string')
|
|
572
|
-
|
|
573
|
-
// Cursor should work with custom ID
|
|
574
|
-
if (result.meta.pagination.cursor?.next) {
|
|
575
|
-
const secondPage = await api.resources.items.query({
|
|
576
|
-
queryParams: {
|
|
577
|
-
page: {
|
|
578
|
-
size: 2,
|
|
579
|
-
after: result.meta.pagination.cursor.next
|
|
580
|
-
},
|
|
581
|
-
sort: ['category', 'name']
|
|
582
|
-
},
|
|
583
|
-
simplified: false
|
|
584
|
-
})
|
|
585
|
-
|
|
586
|
-
assert(secondPage.data.length > 0, 'Should get results on second page')
|
|
587
|
-
}
|
|
588
|
-
})
|
|
589
|
-
|
|
590
|
-
it('should handle the exact bug scenario - multiple fruits with same category', async () => {
|
|
591
|
-
// This is the exact scenario from the bug report
|
|
592
|
-
await cleanTables(knex, ['cursor_products'])
|
|
593
|
-
|
|
594
|
-
const products = [
|
|
595
|
-
{ name: 'Apple', category: 'Fruit', brand: 'Farm Fresh', price: 2.00, sku: 'FRUIT-001' },
|
|
596
|
-
{ name: 'Banana', category: 'Fruit', brand: 'Farm Fresh', price: 1.50, sku: 'FRUIT-002' },
|
|
597
|
-
{ name: 'Cherry', category: 'Fruit', brand: 'Farm Fresh', price: 3.00, sku: 'FRUIT-003' },
|
|
598
|
-
{ name: 'Date', category: 'Fruit', brand: 'Desert Farm', price: 4.00, sku: 'FRUIT-004' },
|
|
599
|
-
{ name: 'Asparagus', category: 'Vegetable', brand: 'Green Farm', price: 2.50, sku: 'VEG-001' }
|
|
600
|
-
]
|
|
601
|
-
|
|
602
|
-
for (const product of products) {
|
|
603
|
-
await api.resources.products.post({
|
|
604
|
-
inputRecord: createJsonApiDocument('products', product),
|
|
605
|
-
simplified: false
|
|
606
|
-
})
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Get first page with size 3 (should get Apple, Banana, Cherry)
|
|
610
|
-
const firstPage = await api.resources.products.query({
|
|
611
|
-
queryParams: {
|
|
612
|
-
page: { size: 3 },
|
|
613
|
-
sort: ['category', 'name']
|
|
614
|
-
},
|
|
615
|
-
simplified: false
|
|
616
|
-
})
|
|
617
|
-
|
|
618
|
-
assert.equal(firstPage.data.length, 3)
|
|
619
|
-
const firstPageNames = firstPage.data.map(p => p.attributes.name)
|
|
620
|
-
assert.deepEqual(firstPageNames, ['Apple', 'Banana', 'Cherry'])
|
|
621
|
-
|
|
622
|
-
// Get second page - MUST include Date (the bug would skip it)
|
|
623
|
-
const secondPage = await api.resources.products.query({
|
|
624
|
-
queryParams: {
|
|
625
|
-
page: {
|
|
626
|
-
size: 3,
|
|
627
|
-
after: firstPage.meta.pagination.cursor.next
|
|
628
|
-
},
|
|
629
|
-
sort: ['category', 'name']
|
|
630
|
-
},
|
|
631
|
-
simplified: false
|
|
632
|
-
})
|
|
633
|
-
|
|
634
|
-
const secondPageNames = secondPage.data.map(p => p.attributes.name)
|
|
635
|
-
assert(secondPageNames.includes('Date'),
|
|
636
|
-
'Second page MUST include Date - this was the bug where it would be skipped')
|
|
637
|
-
assert.deepEqual(secondPageNames, ['Date', 'Asparagus'],
|
|
638
|
-
'Second page should have Date and Asparagus in that order')
|
|
639
|
-
})
|
|
640
|
-
})
|
|
641
|
-
})
|