json-rest-api 1.0.10 → 1.0.13

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 (117) hide show
  1. package/.claude/settings.local.json +9 -1
  2. package/AGENTS.md +34 -0
  3. package/CLAUDE.md +26 -1
  4. package/CRUSH.md +55 -0
  5. package/README.md +3 -3
  6. package/TODO.todo +8 -0
  7. package/docs/API.md +17 -18
  8. package/docs/COMPARISON.md +18 -13
  9. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
  10. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
  11. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
  12. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
  13. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
  14. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
  15. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
  16. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
  17. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
  18. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
  19. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
  20. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
  21. package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
  22. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
  23. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
  24. package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
  25. package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
  26. package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
  27. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
  28. package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
  29. package/docs/GUIDE/index.md +3 -0
  30. package/docs/QUICKSTART.md +12 -12
  31. package/docs/index.md +2 -2
  32. package/docs/mini-guides/file-uploads-complete.md +0 -1
  33. package/examples/url-override-example.js +137 -0
  34. package/index.js +7 -1
  35. package/package.json +7 -11
  36. package/plugins/core/bulk-operations-plugin.js +2 -0
  37. package/plugins/core/connectors/express-plugin.js +43 -24
  38. package/plugins/core/connectors/lib/request-helpers.js +2 -2
  39. package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
  40. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
  41. package/plugins/core/lib/anyapi/schema-utils.js +186 -0
  42. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
  43. package/plugins/core/lib/dbTablesOperations.js +305 -0
  44. package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
  45. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
  46. package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
  47. package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
  48. package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
  49. package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
  50. package/plugins/core/lib/querying/url-helpers.js +119 -0
  51. package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
  52. package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
  53. package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
  54. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
  55. package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
  56. package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
  57. package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
  58. package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
  59. package/plugins/core/lib/storage/storage-adapter.js +160 -0
  60. package/plugins/core/lib/writing/relationship-processor.js +4 -11
  61. package/plugins/core/rest-api-access.js +456 -0
  62. package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
  63. package/plugins/core/rest-api-knex-plugin.js +226 -87
  64. package/plugins/core/rest-api-multihome-plugin.js +89 -27
  65. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
  66. package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
  67. package/plugins/core/rest-api-plugin-methods/common.js +46 -10
  68. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
  69. package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
  70. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
  71. package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
  72. package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
  73. package/plugins/core/rest-api-plugin-methods/get.js +2 -5
  74. package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
  75. package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
  76. package/plugins/core/rest-api-plugin-methods/post.js +23 -11
  77. package/plugins/core/rest-api-plugin-methods/put.js +18 -21
  78. package/plugins/core/rest-api-plugin-methods/query.js +3 -4
  79. package/plugins/core/rest-api-plugin-methods/release.js +13 -0
  80. package/plugins/core/rest-api-plugin.js +3 -4
  81. package/plugins/core/rest-api-positioning-plugin.js +253 -76
  82. package/plugins/core/socketio-plugin.js +436 -502
  83. package/quickTest.js +2 -2
  84. package/scripts/debug-anyapi-review-filter.mjs +63 -0
  85. package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
  86. package/scripts/inspect-anyapi-descriptor.mjs +19 -0
  87. package/test-debug.js +59 -0
  88. package/tests/README.md +7 -1
  89. package/tests/anyapi-basic.test.js +102 -0
  90. package/tests/anyapi-cursor-pagination.test.js +144 -0
  91. package/tests/computed-fields.test.js +27 -12
  92. package/tests/cors.test.js +5 -6
  93. package/tests/curl.test.js +1 -1
  94. package/tests/custom-idproperty-relationships.test.js +37 -29
  95. package/tests/field-getters.test.js +25 -7
  96. package/tests/fixtures/api-configs.js +576 -150
  97. package/tests/helpers/storage-mode.js +65 -0
  98. package/tests/helpers/test-utils.js +85 -3
  99. package/tests/include-depth-validation.test.js +35 -1
  100. package/tests/nested-includes.test.js +130 -103
  101. package/tests/pagination-cursor-multifield.test.js +17 -13
  102. package/tests/pagination-enhanced.test.js +19 -24
  103. package/tests/polymorphic-simple.test.js +9 -9
  104. package/tests/positioning-advanced.test.js +19 -19
  105. package/tests/positioning-extended.test.js +25 -25
  106. package/tests/positioning-multifilter.test.js +62 -62
  107. package/tests/positioning.test.js +7 -7
  108. package/tests/queries.test.js +5 -5
  109. package/tests/query-limits.test.js +77 -92
  110. package/tests/rest-api-access.test.js +206 -0
  111. package/tests/searchschema-merge.test.js +182 -0
  112. package/tests/socketio.test.js +33 -14
  113. package/tests/virtual-fields.test.js +0 -1
  114. package/docs/DOCS_ALL.md +0 -21894
  115. package/plugins/core/jwt-auth-plugin.js +0 -897
  116. package/plugins/core/lib/jwt-auth-helpers.js +0 -130
  117. package/tests/jwt-auth.test.js +0 -692
@@ -8,6 +8,7 @@ import {
8
8
  cleanTables,
9
9
  countRecords,
10
10
  createJsonApiDocument,
11
+ createRelationship,
11
12
  validateJsonApiStructure
12
13
  } from './helpers/test-utils.js';
13
14
 
@@ -61,7 +62,8 @@ describe('Query Limits and Include Limits', () => {
61
62
  },
62
63
  relationships: {
63
64
  books: {
64
- hasMany: 'books',
65
+ type: 'hasMany',
66
+ target: 'books',
65
67
  foreignKey: 'publisher_id',
66
68
  // Relationships are always includable via ?include=
67
69
  include: {
@@ -81,7 +83,7 @@ describe('Query Limits and Include Limits', () => {
81
83
  },
82
84
  relationships: {
83
85
  books: {
84
- hasMany: 'books',
86
+ type: 'manyToMany',
85
87
  through: 'book_authors',
86
88
  foreignKey: 'author_id',
87
89
  otherKey: 'book_id',
@@ -104,13 +106,13 @@ describe('Query Limits and Include Limits', () => {
104
106
  },
105
107
  relationships: {
106
108
  authors: {
107
- hasMany: 'authors',
109
+ type: 'manyToMany',
108
110
  through: 'book_authors',
109
111
  foreignKey: 'book_id',
110
- otherKey: 'author_id',
112
+ otherKey: 'author_id'
111
113
  // Relationships are always includable via ?include=
112
114
  },
113
- reviews: { hasMany: 'reviews', via: 'reviewable' }
115
+ reviews: { type: 'hasMany', target: 'reviews', via: 'reviewable' }
114
116
  }
115
117
  });
116
118
  await api.resources.books.createKnexTable();
@@ -215,29 +217,21 @@ describe('Query Limits and Include Limits', () => {
215
217
  });
216
218
 
217
219
  const publisher = await api.resources.publishers.post({
218
- inputRecord: {
219
- data: {
220
- type: 'publishers',
221
- attributes: {
222
- name: 'Test Publisher',
223
- country_id: parseInt(country.id)
224
- }
225
- }
226
- }
220
+ inputRecord: createJsonApiDocument('publishers', {
221
+ name: 'Test Publisher'
222
+ }, {
223
+ country: createRelationship({ type: 'countries', id: String(country.id) })
224
+ })
227
225
  });
228
226
 
229
227
  // Create 25 books for the publisher
230
228
  for (let i = 1; i <= 25; i++) {
231
229
  await api.resources.books.post({
232
- inputRecord: {
233
- data: {
234
- type: 'books',
235
- attributes: {
236
- title: `Book ${i}`,
237
- publisher_id: parseInt(publisher.id)
238
- }
239
- }
240
- }
230
+ inputRecord: createJsonApiDocument('books', {
231
+ title: `Book ${i}`
232
+ }, {
233
+ publisher: createRelationship({ type: 'publishers', id: String(publisher.id) })
234
+ })
241
235
  });
242
236
  }
243
237
  });
@@ -262,46 +256,47 @@ describe('Query Limits and Include Limits', () => {
262
256
  });
263
257
 
264
258
  it('should respect explicit limit in relationship config', async () => {
265
- // Create a publisher first
259
+ // Create a country first
260
+ const country = await api.resources.countries.post({
261
+ inputRecord: createJsonApiDocument('countries', {
262
+ name: 'Test Country',
263
+ code: 'TC'
264
+ })
265
+ });
266
+
267
+ // Create a publisher
266
268
  const publisher = await api.resources.publishers.post({
267
269
  inputRecord: createJsonApiDocument('publishers', {
268
- name: 'Test Publisher',
269
- country_id: 1
270
+ name: 'Test Publisher'
271
+ }, {
272
+ country: createRelationship({ type: 'countries', id: String(country.id) })
270
273
  })
271
274
  });
272
275
 
273
276
  // Create authors with many books
274
277
  const author = await api.resources.authors.post({
275
- inputRecord: {
276
- data: {
277
- type: 'authors',
278
- attributes: {
279
- name: 'Test Author',
280
- country_id: 1
281
- }
282
- }
283
- }
278
+ inputRecord: createJsonApiDocument('authors', {
279
+ name: 'Test Author'
280
+ }, {
281
+ country: createRelationship({ type: 'countries', id: String(country.id) })
282
+ })
284
283
  });
285
284
 
286
285
  // Create 10 books and associate with author
287
286
  for (let i = 1; i <= 10; i++) {
288
287
  const book = await api.resources.books.post({
289
- inputRecord: {
290
- data: {
291
- type: 'books',
292
- attributes: {
293
- title: `Book ${i}`,
294
- publisher_id: parseInt(publisher.id)
295
- }
296
- }
297
- }
288
+ inputRecord: createJsonApiDocument('books', {
289
+ title: `Book ${i}`
290
+ }, {
291
+ publisher: createRelationship({ type: 'publishers', id: String(publisher.id) })
292
+ })
298
293
  });
299
294
 
300
295
  await api.resources.book_authors.post({
301
- inputRecord: createJsonApiDocument('book_authors', {
302
- book_id: parseInt(book.id),
303
- author_id: parseInt(author.id)
304
- })
296
+ inputRecord: createJsonApiDocument('book_authors', {}, {
297
+ book: createRelationship({ type: 'books', id: String(book.id) }),
298
+ author: createRelationship({ type: 'authors', id: String(author.id) })
299
+ })
305
300
  });
306
301
  }
307
302
 
@@ -331,9 +326,10 @@ describe('Query Limits and Include Limits', () => {
331
326
  // Create a publisher with country
332
327
  const publisher = await api.resources.publishers.post({
333
328
  inputRecord: createJsonApiDocument('publishers', {
334
- name: 'Test Publisher',
335
- country_id: parseInt(country.id)
336
- })
329
+ name: 'Test Publisher'
330
+ }, {
331
+ country: createRelationship({ type: 'countries', id: String(country.id) })
332
+ })
337
333
  });
338
334
 
339
335
  // Create books with reviews
@@ -419,7 +415,8 @@ describe('Query Limits and Include Limits', () => {
419
415
  },
420
416
  relationships: {
421
417
  subitems: {
422
- hasMany: 'subitems',
418
+ type: 'hasMany',
419
+ target: 'subitems',
423
420
  foreignKey: 'test_item_id',
424
421
  include: {
425
422
  limit: 25 // Exceeds queryMaxLimit of 20
@@ -428,7 +425,9 @@ describe('Query Limits and Include Limits', () => {
428
425
  }
429
426
  });
430
427
  },
431
- /limit.*exceeds queryMaxLimit/,
428
+ (err) => {
429
+ return err.message && err.message.includes('limit') && err.message.includes('exceeds queryMaxLimit');
430
+ },
432
431
  'Should throw error for excessive include limit'
433
432
  );
434
433
  });
@@ -437,7 +436,7 @@ describe('Query Limits and Include Limits', () => {
437
436
  describe('Window Strategy with Defaults', () => {
438
437
  beforeEach(async () => {
439
438
  // Create a country first
440
- await api.resources.countries.post({
439
+ const country = await api.resources.countries.post({
441
440
  inputRecord: createJsonApiDocument('countries', {
442
441
  name: 'USA',
443
442
  code: 'US'
@@ -448,15 +447,13 @@ describe('Query Limits and Include Limits', () => {
448
447
  for (let pubNum = 1; pubNum <= 3; pubNum++) {
449
448
  const publisher = await api.resources.publishers.post({
450
449
  inputRecord: {
451
- data: {
452
- type: 'publishers',
453
- attributes: {
454
- name: `Publisher ${pubNum}`,
455
- country_id: 1
456
- }
457
- }
450
+ data: createJsonApiDocument('publishers', {
451
+ name: `Publisher ${pubNum}`
452
+ }, {
453
+ country: createRelationship({ type: 'countries', id: String(country.id) })
454
+ }).data
458
455
  }
459
- });
456
+ })
460
457
 
461
458
  // Create different number of books for each publisher
462
459
  const bookCount = pubNum * 8; // 8, 16, 24 books
@@ -518,30 +515,22 @@ describe('Query Limits and Include Limits', () => {
518
515
  });
519
516
 
520
517
  const publisher = await api.resources.publishers.post({
521
- inputRecord: {
522
- data: {
523
- type: 'publishers',
524
- attributes: {
525
- name: 'Test Publisher',
526
- country_id: parseInt(country.id)
527
- }
528
- }
529
- }
518
+ inputRecord: createJsonApiDocument('publishers', {
519
+ name: 'Test Publisher'
520
+ }, {
521
+ country: createRelationship({ type: 'countries', id: String(country.id) })
522
+ })
530
523
  });
531
524
 
532
525
  // Create 2 authors
533
526
  const authors = [];
534
527
  for (let i = 1; i <= 2; i++) {
535
528
  const author = await api.resources.authors.post({
536
- inputRecord: {
537
- data: {
538
- type: 'authors',
539
- attributes: {
540
- name: `Author ${i}`,
541
- country_id: parseInt(country.id)
542
- }
543
- }
544
- }
529
+ inputRecord: createJsonApiDocument('authors', {
530
+ name: `Author ${i}`
531
+ }, {
532
+ country: createRelationship({ type: 'countries', id: String(country.id) })
533
+ })
545
534
  });
546
535
  authors.push(author);
547
536
  }
@@ -549,23 +538,19 @@ describe('Query Limits and Include Limits', () => {
549
538
  // Create 10 books and associate ALL with BOTH authors
550
539
  for (let i = 1; i <= 10; i++) {
551
540
  const book = await api.resources.books.post({
552
- inputRecord: {
553
- data: {
554
- type: 'books',
555
- attributes: {
556
- title: `Book ${i}`,
557
- publisher_id: parseInt(publisher.id)
558
- }
559
- }
560
- }
541
+ inputRecord: createJsonApiDocument('books', {
542
+ title: `Book ${i}`
543
+ }, {
544
+ publisher: createRelationship({ type: 'publishers', id: String(publisher.id) })
545
+ })
561
546
  });
562
547
 
563
548
  // Associate with both authors
564
549
  for (const author of authors) {
565
550
  await api.resources.book_authors.post({
566
- inputRecord: createJsonApiDocument('book_authors', {
567
- book_id: parseInt(book.id),
568
- author_id: parseInt(author.id)
551
+ inputRecord: createJsonApiDocument('book_authors', {}, {
552
+ book: createRelationship({ type: 'books', id: String(book.id) }),
553
+ author: createRelationship({ type: 'authors', id: String(author.id) })
569
554
  })
570
555
  });
571
556
  }
@@ -0,0 +1,206 @@
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
+ assertResourceAttributes,
9
+ assertResourceRelationship
10
+ } from './helpers/test-utils.js';
11
+ import { createAccessControlApi } from './fixtures/api-configs.js';
12
+
13
+ const knex = knexLib({
14
+ client: 'better-sqlite3',
15
+ connection: {
16
+ filename: ':memory:'
17
+ },
18
+ useNullAsDefault: true
19
+ });
20
+
21
+ let api;
22
+
23
+ async function createUser(email, displayName = 'Test User') {
24
+ const userDoc = createJsonApiDocument('users', {
25
+ email,
26
+ display_name: displayName
27
+ });
28
+
29
+ const result = await api.resources.users.post({
30
+ inputRecord: userDoc,
31
+ simplified: false
32
+ }, { auth: { system: true } });
33
+
34
+ validateJsonApiStructure(result);
35
+ assertResourceAttributes(result.data, {
36
+ email,
37
+ display_name: displayName
38
+ });
39
+
40
+ return Number(result.data.id);
41
+ }
42
+
43
+ async function createProjectForUser(userId, name, status = null) {
44
+ const projectDoc = createJsonApiDocument('projects', {
45
+ name,
46
+ status
47
+ });
48
+
49
+ const result = await api.resources.projects.post({
50
+ inputRecord: projectDoc,
51
+ simplified: false
52
+ }, { auth: { userId } });
53
+
54
+ validateJsonApiStructure(result);
55
+ return result.data;
56
+ }
57
+
58
+ describe('Access plugin', () => {
59
+ before(async () => {
60
+ api = await createAccessControlApi(knex);
61
+ });
62
+
63
+ after(async () => {
64
+ await knex.destroy();
65
+ });
66
+
67
+ beforeEach(async () => {
68
+ await cleanTables(knex, [
69
+ 'access_notes',
70
+ 'access_projects',
71
+ 'access_users'
72
+ ]);
73
+ });
74
+
75
+ it('rejects unauthenticated requests for protected operations', async () => {
76
+ const projectDoc = createJsonApiDocument('projects', {
77
+ name: 'Unauthenticated project'
78
+ });
79
+
80
+ await assert.rejects(async () => {
81
+ await api.resources.projects.post({
82
+ inputRecord: projectDoc,
83
+ simplified: false
84
+ });
85
+ }, (error) => {
86
+ assert.equal(error.statusCode, 403);
87
+ assert.match(error.message, /Access denied/);
88
+ return true;
89
+ });
90
+ });
91
+
92
+ it('auto assigns ownership when creating a project', async () => {
93
+ const ownerId = await createUser('owner@example.com', 'Owner One');
94
+
95
+ const projectDoc = createJsonApiDocument('projects', {
96
+ name: 'Owned Project',
97
+ status: 'active'
98
+ });
99
+
100
+ const createResult = await api.resources.projects.post({
101
+ inputRecord: projectDoc,
102
+ simplified: false
103
+ }, { auth: { userId: ownerId } });
104
+
105
+ validateJsonApiStructure(createResult);
106
+ assertResourceAttributes(createResult.data, {
107
+ name: 'Owned Project',
108
+ status: 'active'
109
+ });
110
+ assertResourceRelationship(createResult.data, 'user', {
111
+ type: 'users',
112
+ id: String(ownerId)
113
+ });
114
+
115
+ const getResult = await api.resources.projects.get({
116
+ id: createResult.data.id,
117
+ simplified: false
118
+ }, { auth: { userId: ownerId } });
119
+
120
+ validateJsonApiStructure(getResult);
121
+ assertResourceRelationship(getResult.data, 'user', {
122
+ type: 'users',
123
+ id: String(ownerId)
124
+ });
125
+ });
126
+
127
+ it('filters query results by ownership for non-admin users', async () => {
128
+ const aliceId = await createUser('alice@example.com', 'Alice');
129
+ const bobId = await createUser('bob@example.com', 'Bob');
130
+
131
+ const aliceProject = await createProjectForUser(aliceId, 'Alice Project', 'active');
132
+ await createProjectForUser(bobId, 'Bob Project', 'planning');
133
+
134
+ const queryResult = await api.resources.projects.query({
135
+ simplified: false
136
+ }, { auth: { userId: aliceId } });
137
+
138
+ validateJsonApiStructure(queryResult, true);
139
+ assert.equal(queryResult.data.length, 1);
140
+ assert.equal(queryResult.data[0].id, aliceProject.id);
141
+ assertResourceRelationship(queryResult.data[0], 'user', {
142
+ type: 'users',
143
+ id: String(aliceId)
144
+ });
145
+ });
146
+
147
+ it('allows admin roles to bypass ownership filtering', async () => {
148
+ const userA = await createUser('admin_a@example.com', 'Admin A');
149
+ const userB = await createUser('admin_b@example.com', 'Admin B');
150
+
151
+ await createProjectForUser(userA, 'Admin Project A');
152
+ await createProjectForUser(userB, 'Admin Project B');
153
+
154
+ const adminQuery = await api.resources.projects.query({
155
+ simplified: false
156
+ }, { auth: { userId: 999, roles: ['admin'] } });
157
+
158
+ validateJsonApiStructure(adminQuery, true);
159
+ assert.equal(adminQuery.data.length, 2);
160
+
161
+ const relatedUserIds = adminQuery.data.map((resource) => resource.relationships.user.data.id).sort();
162
+ assert.deepEqual(relatedUserIds, [String(userA), String(userB)]);
163
+ });
164
+
165
+ it('prevents users from accessing records they do not own', async () => {
166
+ const ownerId = await createUser('primary@example.com', 'Primary');
167
+ const otherId = await createUser('secondary@example.com', 'Secondary');
168
+
169
+ const project = await createProjectForUser(ownerId, 'Primary Project');
170
+
171
+ await assert.rejects(async () => {
172
+ await api.resources.projects.get({
173
+ id: project.id,
174
+ simplified: false
175
+ }, { auth: { userId: otherId } });
176
+ }, (error) => {
177
+ assert.equal(error.statusCode, 403);
178
+ assert.match(error.message, /Access denied/);
179
+ return true;
180
+ });
181
+ });
182
+
183
+ it('enforces helper ownership checks', () => {
184
+ const context = { auth: { userId: 123 } };
185
+ const resource = { user_id: 123 };
186
+
187
+ const auth = api.helpers.auth.requireOwnership(context, resource);
188
+ assert.equal(auth.userId, 123);
189
+
190
+ assert.throws(() => {
191
+ api.helpers.auth.requireOwnership({ auth: { userId: 123 } }, { user_id: 456 });
192
+ }, (error) => {
193
+ assert.equal(error.statusCode, 403);
194
+ assert.match(error.message, /Access denied/);
195
+ return true;
196
+ });
197
+
198
+ assert.throws(() => {
199
+ api.helpers.auth.requireAuth({});
200
+ }, (error) => {
201
+ assert.equal(error.statusCode, 401);
202
+ assert.match(error.message, /Authentication required/);
203
+ return true;
204
+ });
205
+ });
206
+ });
@@ -0,0 +1,182 @@
1
+ import { describe, it, before, beforeEach, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import knexLib from 'knex';
4
+ import {
5
+ cleanTables,
6
+ createJsonApiDocument,
7
+ createRelationship
8
+ } from './helpers/test-utils.js';
9
+ import { createSearchSchemaMergeApi } from './fixtures/api-configs.js';
10
+
11
+ // Create Knex instance for tests - always use SQLite in-memory
12
+ const knex = knexLib({
13
+ client: 'better-sqlite3',
14
+ connection: {
15
+ filename: ':memory:'
16
+ },
17
+ useNullAsDefault: true
18
+ });
19
+
20
+ describe('SearchSchema Merge Behavior', () => {
21
+ let api;
22
+
23
+ before(async () => {
24
+ // Create API instance once - NEVER in beforeEach
25
+ api = await createSearchSchemaMergeApi(knex);
26
+ });
27
+
28
+ after(async () => {
29
+ // Clean up - destroy connection
30
+ await knex.destroy();
31
+ });
32
+
33
+ beforeEach(async () => {
34
+ // Clean tables between tests
35
+ await cleanTables(knex, ['searchmerge_products', 'searchmerge_users', 'searchmerge_orders']);
36
+ });
37
+
38
+ it('should merge search:true fields with explicit searchSchema', async () => {
39
+ // First do a query to trigger schema compilation
40
+ await api.resources.products.query({
41
+ queryParams: {}
42
+ });
43
+
44
+ // Get the compiled searchSchema for products (has both search:true and searchSchema)
45
+ const scope = api.resources.products;
46
+ const searchSchema = scope.vars.schemaInfo.searchSchemaInstance;
47
+
48
+ // Verify searchSchema exists
49
+ assert.ok(searchSchema, 'searchSchema should exist');
50
+ assert.ok(searchSchema.structure, 'searchSchema should have structure');
51
+
52
+ // Check that search:true fields were added
53
+ assert.ok(searchSchema.structure.name, 'name field should be searchable');
54
+ assert.equal(searchSchema.structure.name.filterOperator || '=', '=', 'name should use default = operator');
55
+
56
+ assert.ok(searchSchema.structure.description, 'description field should be searchable');
57
+ assert.equal(searchSchema.structure.description.filterOperator || '=', '=', 'description should use default = operator');
58
+
59
+ assert.ok(searchSchema.structure.sku, 'sku field should be searchable');
60
+ assert.equal(searchSchema.structure.sku.filterOperator || '=', '=', 'sku should use default = operator');
61
+
62
+ // Check that explicit searchSchema fields override search:true
63
+ assert.ok(searchSchema.structure.price, 'price field should be searchable');
64
+ assert.equal(searchSchema.structure.price.filterOperator, 'between', 'price should use between operator from searchSchema');
65
+
66
+ // Check that virtual fields from searchSchema are present
67
+ assert.ok(searchSchema.structure.category_name, 'category_name virtual field should be searchable');
68
+ assert.equal(searchSchema.structure.category_name.filterOperator, 'like', 'category_name should use like operator');
69
+ assert.equal(searchSchema.structure.category_name.actualField, 'category.name', 'category_name should map to category.name');
70
+
71
+ // Check explicit searchSchema field not marked with search:true
72
+ assert.ok(searchSchema.structure.status, 'status field should be searchable from searchSchema');
73
+ assert.equal(searchSchema.structure.status.filterOperator, 'in', 'status should use in operator');
74
+
75
+ // Check that non-searchable fields are not included
76
+ assert.ok(!searchSchema.structure.category_id, 'category_id should not be searchable');
77
+ });
78
+
79
+ it('should work with only search:true fields (no explicit searchSchema)', async () => {
80
+ // First do a query to trigger schema compilation
81
+ await api.resources.users.query({
82
+ queryParams: {}
83
+ });
84
+
85
+ // Get the compiled searchSchema for users (only search:true fields)
86
+ const scope = api.resources.users;
87
+ const searchSchema = scope.vars.schemaInfo.searchSchemaInstance;
88
+
89
+ assert.ok(searchSchema, 'searchSchema should exist');
90
+ assert.ok(searchSchema.structure.username, 'username should be searchable');
91
+ assert.ok(searchSchema.structure.email, 'email should be searchable');
92
+ assert.ok(searchSchema.structure.bio, 'bio should be searchable');
93
+ assert.equal(searchSchema.structure.bio.filterOperator, 'like', 'bio should use like operator');
94
+ assert.ok(!searchSchema.structure.age, 'age should not be searchable');
95
+ });
96
+
97
+ it('should work with only explicit searchSchema (no search:true)', async () => {
98
+ // First do a query to trigger schema compilation
99
+ await api.resources.orders.query({
100
+ queryParams: {}
101
+ });
102
+
103
+ // Get the compiled searchSchema for orders (only explicit searchSchema)
104
+ const scope = api.resources.orders;
105
+ const searchSchema = scope.vars.schemaInfo.searchSchemaInstance;
106
+
107
+ assert.ok(searchSchema, 'searchSchema should exist');
108
+ assert.ok(searchSchema.structure.order_number, 'order_number should be searchable');
109
+ assert.ok(searchSchema.structure.status, 'status should be searchable');
110
+ assert.equal(searchSchema.structure.status.filterOperator, 'in', 'status should use in operator');
111
+ assert.ok(!searchSchema.structure.total, 'total should not be searchable');
112
+ });
113
+
114
+ it('should allow filtering with merged searchSchema fields', async () => {
115
+ const context = {};
116
+
117
+ // Create some test data using API methods
118
+ const product1 = await api.resources.products.post({
119
+ inputRecord: createJsonApiDocument('products', {
120
+ name: 'Widget A',
121
+ description: 'A great widget',
122
+ price: 50,
123
+ sku: 'WGT-001',
124
+ status: 'active'
125
+ })
126
+ }, context);
127
+
128
+ const product2 = await api.resources.products.post({
129
+ inputRecord: createJsonApiDocument('products', {
130
+ name: 'Widget B',
131
+ description: 'Another widget',
132
+ price: 150,
133
+ sku: 'WGT-002',
134
+ status: 'inactive'
135
+ })
136
+ }, context);
137
+
138
+ // Test filtering with field from search:true
139
+ const nameResults = await api.resources.products.query({
140
+ queryParams: {
141
+ filters: {
142
+ name: 'Widget A'
143
+ }
144
+ }
145
+ }, context);
146
+ assert.equal(nameResults.data.length, 1, 'Should find one product by name');
147
+ assert.equal(nameResults.data[0].attributes.name, 'Widget A');
148
+
149
+ // Test filtering with field from explicit searchSchema (between operator)
150
+ const priceResults = await api.resources.products.query({
151
+ queryParams: {
152
+ filters: {
153
+ price: [40, 60] // Between 40 and 60
154
+ }
155
+ }
156
+ }, context);
157
+ assert.equal(priceResults.data.length, 1, 'Should find one product in price range');
158
+ assert.equal(priceResults.data[0].attributes.price, 50);
159
+
160
+ // Test filtering with explicit searchSchema field (in operator)
161
+ const statusResults = await api.resources.products.query({
162
+ queryParams: {
163
+ filters: {
164
+ status: ['active']
165
+ }
166
+ }
167
+ }, context);
168
+ assert.equal(statusResults.data.length, 1, 'Should find one active product');
169
+ assert.equal(statusResults.data[0].attributes.status, 'active');
170
+
171
+ // Test filtering with search:true field that wasn't overridden
172
+ const skuResults = await api.resources.products.query({
173
+ queryParams: {
174
+ filters: {
175
+ sku: 'WGT-002'
176
+ }
177
+ }
178
+ }, context);
179
+ assert.equal(skuResults.data.length, 1, 'Should find one product by SKU');
180
+ assert.equal(skuResults.data[0].attributes.sku, 'WGT-002');
181
+ });
182
+ });