json-rest-api 1.0.23 → 1.0.25

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.
Files changed (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. package/tests/test-development-guide.md +5 -5
@@ -0,0 +1,96 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { promises as fs } from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { LocalStorage } from '../plugins/storage/local-storage.js'
7
+
8
+ let tempRoot
9
+
10
+ async function createTempRoot () {
11
+ tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'json-rest-api-local-storage-'))
12
+ return tempRoot
13
+ }
14
+
15
+ function createFile (filename = 'photo.png') {
16
+ return {
17
+ filename,
18
+ mimetype: 'image/png',
19
+ data: Buffer.from('x')
20
+ }
21
+ }
22
+
23
+ describe('LocalStorage path containment', () => {
24
+ beforeEach(async () => {
25
+ await createTempRoot()
26
+ })
27
+
28
+ afterEach(async () => {
29
+ await fs.rm(tempRoot, { recursive: true, force: true })
30
+ })
31
+
32
+ it('rejects custom generated paths that escape to sibling directories', async () => {
33
+ const directory = path.join(tempRoot, 'uploads')
34
+ const evilDirectory = path.join(tempRoot, 'uploads_evil')
35
+ await fs.mkdir(evilDirectory, { recursive: true })
36
+
37
+ const storage = new LocalStorage({
38
+ directory,
39
+ fileBaseUrl: '/uploads',
40
+ nameStrategy: 'custom',
41
+ nameGenerator: async () => '../uploads_evil/pwn'
42
+ })
43
+
44
+ await assert.rejects(
45
+ storage.upload(createFile()),
46
+ /Custom nameGenerator must return a basename, not a path/
47
+ )
48
+
49
+ await assert.rejects(
50
+ fs.access(path.join(evilDirectory, 'pwn.png')),
51
+ { code: 'ENOENT' }
52
+ )
53
+ })
54
+
55
+ it('rejects empty and drive-prefixed custom generated names', async () => {
56
+ const directory = path.join(tempRoot, 'uploads')
57
+
58
+ const emptyNameStorage = new LocalStorage({
59
+ directory,
60
+ nameStrategy: 'custom',
61
+ nameGenerator: async () => ' '
62
+ })
63
+ await assert.rejects(
64
+ emptyNameStorage.upload(createFile()),
65
+ /Custom nameGenerator must return a non-empty basename/
66
+ )
67
+
68
+ const driveNameStorage = new LocalStorage({
69
+ directory,
70
+ nameStrategy: 'custom',
71
+ nameGenerator: async () => 'C:evil'
72
+ })
73
+ await assert.rejects(
74
+ driveNameStorage.upload(createFile()),
75
+ /Custom nameGenerator must return a basename, not a path/
76
+ )
77
+ })
78
+
79
+ it('keeps safe custom basenames inside the configured directory', async () => {
80
+ const directory = path.join(tempRoot, 'uploads')
81
+ const storage = new LocalStorage({
82
+ directory,
83
+ fileBaseUrl: '/uploads',
84
+ nameStrategy: 'custom',
85
+ nameGenerator: async () => 'avatar'
86
+ })
87
+
88
+ const firstUrl = await storage.upload(createFile())
89
+ const secondUrl = await storage.upload(createFile())
90
+
91
+ assert.equal(firstUrl, '/uploads/avatar.png')
92
+ assert.equal(secondUrl, '/uploads/avatar_1.png')
93
+ assert.equal(await fs.readFile(path.join(directory, 'avatar.png'), 'utf8'), 'x')
94
+ assert.equal(await fs.readFile(path.join(directory, 'avatar_1.png'), 'utf8'), 'x')
95
+ })
96
+ })
@@ -0,0 +1,151 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import { Api } from 'hooked-api'
5
+ import { RestApiPlugin } from '../plugins/core/rest-api-plugin.js'
6
+ import { RestApiKnexPlugin } from '../plugins/core/rest-api-knex-plugin.js'
7
+ import {
8
+ cleanTables,
9
+ validateJsonApiStructure,
10
+ } from './helpers/test-utils.js'
11
+
12
+ const knex = knexLib({
13
+ client: 'better-sqlite3',
14
+ connection: {
15
+ filename: ':memory:'
16
+ },
17
+ useNullAsDefault: true
18
+ })
19
+
20
+ let api
21
+
22
+ describe('Logical resource ids in write validation', () => {
23
+ before(async () => {
24
+ api = new Api({
25
+ name: 'logical-id-write-validation-test'
26
+ })
27
+
28
+ await api.use(RestApiPlugin, {
29
+ simplifiedApi: false,
30
+ simplifiedTransport: false,
31
+ returnRecordApi: {
32
+ post: 'full',
33
+ put: 'full',
34
+ patch: 'full'
35
+ }
36
+ })
37
+ await api.use(RestApiKnexPlugin, { knex })
38
+
39
+ await api.addResource('profiles', {
40
+ tableName: 'logical_id_profiles',
41
+ idProperty: 'user_id',
42
+ schema: {
43
+ id: { type: 'id', required: true, storage: { column: 'user_id' } },
44
+ name: { type: 'string', required: true, max: 64 }
45
+ }
46
+ })
47
+ await api.resources.profiles.createKnexTable()
48
+ })
49
+
50
+ after(async () => {
51
+ await knex.destroy()
52
+ })
53
+
54
+ beforeEach(async () => {
55
+ await cleanTables(knex, ['logical_id_profiles'])
56
+ })
57
+
58
+ it('accepts explicit resource ids in non-simplified POST without exposing id as an attribute', async () => {
59
+ const created = await api.resources.profiles.post({
60
+ inputRecord: {
61
+ data: {
62
+ type: 'profiles',
63
+ id: '101',
64
+ attributes: {
65
+ name: 'Alice'
66
+ }
67
+ }
68
+ }
69
+ })
70
+
71
+ validateJsonApiStructure(created)
72
+ assert.equal(created.data.id, '101')
73
+ assert.equal(created.data.attributes.name, 'Alice')
74
+ assert.ok(!('id' in created.data.attributes))
75
+
76
+ const dbRow = await knex('logical_id_profiles').where('user_id', 101).first()
77
+ assert.ok(dbRow)
78
+ assert.equal(String(dbRow.user_id), '101')
79
+ assert.equal(dbRow.name, 'Alice')
80
+ })
81
+
82
+ it('reports missing logical ids against data.id during POST validation', async () => {
83
+ await assert.rejects(
84
+ () => api.resources.profiles.post({
85
+ inputRecord: {
86
+ data: {
87
+ type: 'profiles',
88
+ attributes: {
89
+ name: 'Missing Id'
90
+ }
91
+ }
92
+ }
93
+ }),
94
+ (error) => {
95
+ assert.equal(error.code, 'REST_API_VALIDATION')
96
+ assert.deepEqual(error.details?.fields, ['data.id'])
97
+ assert.equal(error.details?.violations?.[0]?.field, 'data.id')
98
+ return true
99
+ }
100
+ )
101
+ })
102
+
103
+ it('accepts explicit resource ids in simplified POST and still keeps id out of attributes', async () => {
104
+ const created = await api.resources.profiles.post({
105
+ id: '202',
106
+ name: 'Bob'
107
+ })
108
+
109
+ assert.equal(created.id, '202')
110
+ assert.equal(created.name, 'Bob')
111
+
112
+ const fetched = await api.resources.profiles.get({
113
+ id: '202',
114
+ simplified: false
115
+ })
116
+
117
+ validateJsonApiStructure(fetched)
118
+ assert.equal(fetched.data.id, '202')
119
+ assert.equal(fetched.data.attributes.name, 'Bob')
120
+ assert.ok(!('id' in fetched.data.attributes))
121
+ })
122
+
123
+ it('accepts logical id validation through PUT full replacement', async () => {
124
+ await knex('logical_id_profiles').insert({
125
+ user_id: 303,
126
+ name: 'Before'
127
+ })
128
+
129
+ const updated = await api.resources.profiles.put({
130
+ id: '303',
131
+ inputRecord: {
132
+ data: {
133
+ type: 'profiles',
134
+ id: '303',
135
+ attributes: {
136
+ name: 'After'
137
+ }
138
+ }
139
+ }
140
+ })
141
+
142
+ validateJsonApiStructure(updated)
143
+ assert.equal(updated.data.id, '303')
144
+ assert.equal(updated.data.attributes.name, 'After')
145
+ assert.ok(!('id' in updated.data.attributes))
146
+
147
+ const dbRow = await knex('logical_id_profiles').where('user_id', 303).first()
148
+ assert.ok(dbRow)
149
+ assert.equal(dbRow.name, 'After')
150
+ })
151
+ })
@@ -7,6 +7,7 @@ import {
7
7
  createJsonApiDocument
8
8
  } from './helpers/test-utils.js'
9
9
  import { createCursorPaginationApi } from './fixtures/api-configs.js'
10
+ import { storageMode } from './helpers/storage-mode.js'
10
11
 
11
12
  // Create Knex instance for tests
12
13
  const knex = knexLib({
@@ -19,6 +20,19 @@ const knex = knexLib({
19
20
 
20
21
  // API instance that persists across tests
21
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
+ }
22
36
 
23
37
  describe('Multi-field Cursor Pagination', () => {
24
38
  before(async () => {
@@ -31,6 +45,43 @@ describe('Multi-field Cursor Pagination', () => {
31
45
  await knex.destroy()
32
46
  })
33
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
+
34
85
  describe('Basic Multi-field Cursor Pagination', () => {
35
86
  beforeEach(async () => {
36
87
  await cleanTables(knex, ['cursor_products'])
@@ -55,7 +106,7 @@ describe('Multi-field Cursor Pagination', () => {
55
106
  }
56
107
  })
57
108
 
58
- it.skip('should handle cursor pagination with multi-field sorting without skipping records', async () => {
109
+ it('should handle cursor pagination with multi-field sorting without skipping records', async () => {
59
110
  // Sort by category first (which has duplicates), then by name
60
111
  const pageSize = 3
61
112
  const allProducts = []
@@ -102,7 +153,7 @@ describe('Multi-field Cursor Pagination', () => {
102
153
  assert.equal(ids.length, uniqueIds.length, 'Should have no duplicate products')
103
154
  })
104
155
 
105
- it.skip('should handle DESC sorting with duplicates correctly', async () => {
156
+ it('should handle DESC sorting with duplicates correctly', async () => {
106
157
  // Sort by category DESC, then name ASC
107
158
  const firstPage = await api.resources.products.query({
108
159
  queryParams: {
@@ -189,7 +240,7 @@ describe('Multi-field Cursor Pagination', () => {
189
240
  'Green Gardens vegetables should be in alphabetical order by name')
190
241
  })
191
242
 
192
- it.skip('should handle cursor pagination with numeric and text fields', async () => {
243
+ it('should handle cursor pagination with numeric and text fields', async () => {
193
244
  // Sort by price (numeric), then category (text), then name (text)
194
245
  const firstPage = await api.resources.products.query({
195
246
  queryParams: {
@@ -222,7 +273,77 @@ describe('Multi-field Cursor Pagination', () => {
222
273
  'Prices should be non-decreasing across pages')
223
274
  })
224
275
 
225
- it.skip('should generate correct cursor values for multi-field sorting', async () => {
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
+ secondPage.data.map((record) => record.attributes.name),
342
+ ['Older Product']
343
+ )
344
+ })
345
+
346
+ it('should generate correct cursor values for multi-field sorting', async () => {
226
347
  const result = await api.resources.products.query({
227
348
  queryParams: {
228
349
  page: { size: 3 },
@@ -244,7 +365,7 @@ describe('Multi-field Cursor Pagination', () => {
244
365
  assert(cursor.includes('name:'), 'Cursor should include name field')
245
366
  })
246
367
 
247
- it.skip('should handle page[before] with multi-field sorting', async () => {
368
+ it('should handle page[before] with multi-field sorting', async () => {
248
369
  // First, get to the middle of the dataset
249
370
  const firstPage = await api.resources.products.query({
250
371
  queryParams: {
@@ -272,7 +393,7 @@ describe('Multi-field Cursor Pagination', () => {
272
393
  size: 4,
273
394
  before: secondPage.meta.pagination.cursor?.next ||
274
395
  // If no next cursor, create one from the last record
275
- `category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)}`
396
+ `category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)},id:${encodeURIComponent(secondPage.data[0].id)}`
276
397
  },
277
398
  sort: ['category', 'name']
278
399
  },
@@ -292,7 +413,7 @@ describe('Multi-field Cursor Pagination', () => {
292
413
  await cleanTables(knex, ['cursor_products', 'cursor_items'])
293
414
  })
294
415
 
295
- it.skip('should handle all records having the same value in first sort field', async () => {
416
+ it('should handle all records having the same value in first sort field', async () => {
296
417
  // Create products all in the same category
297
418
  const products = [
298
419
  { name: 'Alpha', category: 'Electronics', brand: 'TechCo', price: 100, sku: 'TC-001' },
@@ -348,7 +469,7 @@ describe('Multi-field Cursor Pagination', () => {
348
469
  assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo')
349
470
  })
350
471
 
351
- it.skip('should handle empty results with multi-field cursor', async () => {
472
+ it('should handle empty results with multi-field cursor', async () => {
352
473
  // Create one product
353
474
  await api.resources.products.post({
354
475
  inputRecord: createJsonApiDocument('products', {
@@ -375,7 +496,7 @@ describe('Multi-field Cursor Pagination', () => {
375
496
  assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page')
376
497
  })
377
498
 
378
- it.skip('should handle special characters in multi-field cursors', async () => {
499
+ it('should handle special characters in multi-field cursors', async () => {
379
500
  // Create products with special characters
380
501
  const products = [
381
502
  { name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
@@ -418,7 +539,7 @@ describe('Multi-field Cursor Pagination', () => {
418
539
  'Should get different record on second page')
419
540
  })
420
541
 
421
- it.skip('should maintain stable sorting with custom id property', async () => {
542
+ it('should maintain stable sorting with custom id property', async () => {
422
543
  // Create items using the resource with custom ID property
423
544
  const items = [
424
545
  { code: 'A001', name: 'Apple', category: 'Fruit', type: 'Fresh' },
@@ -464,7 +585,7 @@ describe('Multi-field Cursor Pagination', () => {
464
585
  }
465
586
  })
466
587
 
467
- it.skip('should handle the exact bug scenario - multiple fruits with same category', async () => {
588
+ it('should handle the exact bug scenario - multiple fruits with same category', async () => {
468
589
  // This is the exact scenario from the bug report
469
590
  await cleanTables(knex, ['cursor_products'])
470
591
 
@@ -9,6 +9,7 @@ import {
9
9
  resourceIdentifier
10
10
  } from './helpers/test-utils.js'
11
11
  import { createPaginationApi } from './fixtures/api-configs.js'
12
+ import { parseJsonApiQuery } from '../plugins/core/lib/querying-writing/connectors-query-parser.js'
12
13
 
13
14
  // Create Knex instance for tests
14
15
  const knex = knexLib({
@@ -22,6 +23,11 @@ const knex = knexLib({
22
23
  // API instance that persists across tests
23
24
  let api
24
25
 
26
+ const parseLinkQuery = (link) => {
27
+ const url = new URL(link, 'https://example.test')
28
+ return parseJsonApiQuery(url.search.slice(1))
29
+ }
30
+
25
31
  describe('Enhanced Pagination Features', () => {
26
32
  before(async () => {
27
33
  // Initialize API once with pagination features
@@ -34,6 +40,8 @@ describe('Enhanced Pagination Features', () => {
34
40
  })
35
41
 
36
42
  describe('Self Links', () => {
43
+ let countryId
44
+
37
45
  beforeEach(async () => {
38
46
  await cleanTables(knex, [
39
47
  'pagination_countries', 'pagination_publishers', 'pagination_books'
@@ -45,6 +53,7 @@ describe('Enhanced Pagination Features', () => {
45
53
  inputRecord: countryDoc,
46
54
  simplified: false
47
55
  })
56
+ countryId = countryResult.data.id
48
57
 
49
58
  // Create some books
50
59
  for (let i = 1; i <= 3; i++) {
@@ -131,6 +140,38 @@ describe('Enhanced Pagination Features', () => {
131
140
  assert(result.links, 'Response should have top-level links')
132
141
  assert(result.links.self.startsWith('/books'), 'Top-level self link should be relative')
133
142
  })
143
+
144
+ it('should serialize collection self-link query parameters in JSON:API form', async () => {
145
+ const result = await api.resources.books.query({
146
+ queryParams: {
147
+ filters: {
148
+ country: countryId
149
+ },
150
+ fields: {
151
+ books: 'title,country_id',
152
+ countries: 'name,code'
153
+ },
154
+ include: ['country'],
155
+ sort: ['title']
156
+ },
157
+ simplified: false
158
+ })
159
+
160
+ validateJsonApiStructure(result, true)
161
+
162
+ const selfUrl = new URL(result.links.self, 'https://example.test')
163
+ const selfQuery = parseLinkQuery(result.links.self)
164
+
165
+ assert.equal(selfUrl.searchParams.get('filter[country]'), countryId)
166
+ assert.equal(selfUrl.searchParams.has('filters[country]'), false)
167
+ assert.deepEqual(selfQuery.filters, { country: countryId })
168
+ assert.deepEqual(selfQuery.include, ['country'])
169
+ assert.deepEqual(selfQuery.sort, ['title'])
170
+ assert.deepEqual(selfQuery.fields, {
171
+ books: 'title,country_id',
172
+ countries: 'name,code'
173
+ })
174
+ })
134
175
  })
135
176
 
136
177
  describe('Pagination Metadata', () => {
@@ -207,13 +248,11 @@ describe('Enhanced Pagination Features', () => {
207
248
  assert(result.links.prev, 'Should have prev link')
208
249
  assert(result.links.next, 'Should have next link')
209
250
 
210
- // Verify link structure
211
- assert(result.links.self.includes('page[number]=2'))
212
- assert(result.links.self.includes('page[size]=3'))
213
- assert(result.links.first.includes('page[number]=1'))
214
- assert(result.links.last.includes('page[number]=4'))
215
- assert(result.links.prev.includes('page[number]=1'))
216
- assert(result.links.next.includes('page[number]=3'))
251
+ assert.deepEqual(parseLinkQuery(result.links.self).page, { number: 2, size: 3 })
252
+ assert.deepEqual(parseLinkQuery(result.links.first).page, { number: 1, size: 3 })
253
+ assert.deepEqual(parseLinkQuery(result.links.last).page, { number: 4, size: 3 })
254
+ assert.deepEqual(parseLinkQuery(result.links.prev).page, { number: 1, size: 3 })
255
+ assert.deepEqual(parseLinkQuery(result.links.next).page, { number: 3, size: 3 })
217
256
  })
218
257
 
219
258
  it('should not include prev link on first page', async () => {
@@ -253,9 +292,9 @@ describe('Enhanced Pagination Features', () => {
253
292
 
254
293
  assert(result.links, 'Should have links')
255
294
  assert(result.links.next, 'Should have next link since page 2 of 4')
256
- assert(result.links.next.includes('sort=-title'))
257
- assert(result.links.next.includes('page[number]=3'))
258
- assert(result.links.next.includes('page[size]=3'))
295
+ const nextQuery = parseLinkQuery(result.links.next)
296
+ assert.deepEqual(nextQuery.sort, ['-title'])
297
+ assert.deepEqual(nextQuery.page, { number: 3, size: 3 })
259
298
  })
260
299
  })
261
300
 
@@ -335,7 +374,7 @@ describe('Enhanced Pagination Features', () => {
335
374
  assert(result.links.self)
336
375
  assert(result.links.first)
337
376
  assert(result.links.next)
338
- assert(result.links.next.includes('page[after]='), 'Next link should include cursor parameter')
377
+ assert(parseLinkQuery(result.links.next).page.after, 'Next link should include cursor parameter')
339
378
  })
340
379
 
341
380
  it('should handle last page with cursor pagination', async () => {
@@ -476,6 +515,10 @@ describe('Enhanced Pagination Features', () => {
476
515
  filters: {
477
516
  country: parseInt(usCountryId)
478
517
  },
518
+ fields: {
519
+ books: 'title,country_id',
520
+ countries: 'name,code'
521
+ },
479
522
  include: ['country'],
480
523
  sort: ['title']
481
524
  },
@@ -503,10 +546,18 @@ describe('Enhanced Pagination Features', () => {
503
546
  assert(book.links.self)
504
547
  })
505
548
 
506
- // Check pagination links include all parameters
507
- assert(result.links.next.includes(`filters[country]=${usCountryId}`))
508
- assert(result.links.next.includes('include=country'))
509
- assert(result.links.next.includes('sort=title'))
549
+ const nextUrl = new URL(result.links.next, 'https://example.test')
550
+ const nextQuery = parseJsonApiQuery(nextUrl.search.slice(1))
551
+ assert.equal(nextUrl.searchParams.get('filter[country]'), usCountryId)
552
+ assert.equal(nextUrl.searchParams.has('filters[country]'), false)
553
+ assert.deepEqual(nextQuery.filters, { country: usCountryId })
554
+ assert.deepEqual(nextQuery.include, ['country'])
555
+ assert.deepEqual(nextQuery.sort, ['title'])
556
+ assert.deepEqual(nextQuery.fields, {
557
+ books: 'title,country_id',
558
+ countries: 'name,code'
559
+ })
560
+ assert.deepEqual(nextQuery.page, { number: 2, size: 5 })
510
561
  })
511
562
  })
512
563
  })