json-rest-api 1.0.21 → 1.0.23

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 (88) hide show
  1. package/CLAUDE.md +1 -2
  2. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
  3. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
  4. package/docs/API.md +51 -14
  5. package/docs/COMPARISON.md +2 -2
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
  7. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
  8. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
  9. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
  10. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
  11. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
  12. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
  13. package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
  14. package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
  15. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +250 -0
  16. package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
  17. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
  18. package/docs/GUIDE/index.md +18 -9
  19. package/docs/QUICKSTART.md +5 -1
  20. package/docs/index.md +1 -1
  21. package/index.js +3 -2
  22. package/package.json +4 -4
  23. package/plugins/core/connectors/express-plugin.js +97 -184
  24. package/plugins/core/connectors/fastify-plugin.js +206 -0
  25. package/plugins/core/connectors/lib/connector-core.js +195 -0
  26. package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
  27. package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
  28. package/plugins/core/lib/dbIntrospection.js +1078 -0
  29. package/plugins/core/lib/dbTablesOperations.js +1104 -160
  30. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
  31. package/plugins/core/lib/querying/knex-process-includes.js +3 -4
  32. package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
  33. package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
  34. package/plugins/core/lib/querying/query-builder-utils.js +19 -0
  35. package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
  36. package/plugins/core/lib/querying/sort-helpers.js +33 -0
  37. package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
  38. package/plugins/core/lib/querying/url-helpers.js +8 -2
  39. package/plugins/core/lib/querying-writing/compile-schemas.js +12 -1
  40. package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
  41. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
  42. package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
  43. package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
  44. package/plugins/core/lib/storage/canonical-storage-mapping.js +113 -0
  45. package/plugins/core/lib/storage/storage-adapter.js +79 -2
  46. package/plugins/core/lib/storage/storage-mapping.js +204 -0
  47. package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
  48. package/plugins/core/rest-api-autofilter-plugin.js +414 -0
  49. package/plugins/core/rest-api-knex-plugin.js +249 -211
  50. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
  51. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
  52. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
  53. package/plugins/core/rest-api-plugin-methods/common.js +121 -83
  54. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
  55. package/plugins/core/rest-api-plugin-methods/get.js +23 -14
  56. package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
  57. package/plugins/core/rest-api-plugin-methods/post.js +13 -8
  58. package/plugins/core/rest-api-plugin-methods/put.js +45 -21
  59. package/plugins/core/rest-api-plugin-methods/query.js +15 -53
  60. package/plugins/core/rest-api-plugin.js +2 -0
  61. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  62. package/plugins/core/rest-api-query-projections-plugin.js +130 -0
  63. package/plugins/core/socketio-plugin.js +1 -3
  64. package/tests/autofilter.test.js +463 -0
  65. package/tests/custom-id-simplified-post.test.js +75 -0
  66. package/tests/custom-idproperty-relationships.test.js +33 -0
  67. package/tests/db-introspection.test.js +518 -0
  68. package/tests/db-table-operations.test.js +781 -0
  69. package/tests/fastify-plugin.test.js +141 -0
  70. package/tests/field-getters.test.js +1 -2
  71. package/tests/field-setters.test.js +0 -4
  72. package/tests/fixtures/api-configs.js +188 -219
  73. package/tests/helpers/fake-fastify.js +99 -0
  74. package/tests/http-connectors-parity.test.js +235 -0
  75. package/tests/projected-fields.test.js +319 -0
  76. package/tests/relationships.test.js +3 -3
  77. package/tests/searchschema-knex-behavior.test.js +187 -0
  78. package/tests/searchschema-merge.test.js +21 -69
  79. package/tests/storage-mapping.test.js +210 -0
  80. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
  81. package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
  82. package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
  83. package/plugins/core/rest-api-access.js +0 -459
  84. package/plugins/core/rest-api-multihome-plugin.js +0 -314
  85. package/tests/multihome.test.js +0 -553
  86. package/tests/multihome.test.js.bak +0 -550
  87. package/tests/ownership-optout.test.js +0 -76
  88. package/tests/rest-api-access.test.js +0 -206
@@ -0,0 +1,463 @@
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
+ createRelationship,
10
+ resourceIdentifier
11
+ } from './helpers/test-utils.js'
12
+ import { storageMode } from './helpers/storage-mode.js'
13
+ import { createAutoFilterApi } from './fixtures/api-configs.js'
14
+
15
+ const knex = knexLib({
16
+ client: 'better-sqlite3',
17
+ connection: {
18
+ filename: ':memory:'
19
+ },
20
+ useNullAsDefault: true
21
+ })
22
+
23
+ let api
24
+
25
+ const scopedContext = (workspaceId, userId, projectId) => ({
26
+ scopeValues: {
27
+ workspaceId,
28
+ userId,
29
+ ...(projectId !== undefined ? { projectId } : {})
30
+ }
31
+ })
32
+
33
+ const moveTaskOutOfScope = async ({ taskId, workspaceId, userId }) => {
34
+ if (storageMode.isAnyApi()) {
35
+ const descriptor = await api.anyapi.registry.getDescriptor(api.anyapi.tenantId, 'tasks')
36
+ await knex('any_records')
37
+ .where({
38
+ id: Number(taskId),
39
+ tenant_id: api.anyapi.tenantId,
40
+ resource: 'tasks'
41
+ })
42
+ .update({
43
+ [descriptor.fields.workspace_id.slot]: workspaceId,
44
+ [descriptor.fields.user_id.slot]: userId
45
+ })
46
+ return
47
+ }
48
+
49
+ await knex('autofilter_tasks')
50
+ .where({ id: Number(taskId) })
51
+ .update({
52
+ workspace_id: workspaceId,
53
+ user_id: userId
54
+ })
55
+ }
56
+
57
+ describe('AutoFilter Plugin', () => {
58
+ before(async () => {
59
+ api = await createAutoFilterApi(knex)
60
+ })
61
+
62
+ after(async () => {
63
+ await knex.destroy()
64
+ })
65
+
66
+ beforeEach(async () => {
67
+ await cleanTables(knex, [
68
+ 'autofilter_workspace_reports',
69
+ 'autofilter_user_notes',
70
+ 'autofilter_projects',
71
+ 'autofilter_tasks',
72
+ 'autofilter_optional_tasks',
73
+ 'autofilter_system_settings'
74
+ ])
75
+ })
76
+
77
+ it('stamps workspace-scoped records and filters collections by workspace', async () => {
78
+ const reportA = await api.resources.workspace_reports.post({
79
+ inputRecord: createJsonApiDocument('workspace_reports', {
80
+ title: 'Workspace A report'
81
+ }),
82
+ simplified: false
83
+ }, scopedContext('workspace-a', 101))
84
+
85
+ await api.resources.workspace_reports.post({
86
+ inputRecord: createJsonApiDocument('workspace_reports', {
87
+ title: 'Workspace B report'
88
+ }),
89
+ simplified: false
90
+ }, scopedContext('workspace-b', 202))
91
+
92
+ validateJsonApiStructure(reportA)
93
+ assertResourceAttributes(reportA.data, {
94
+ title: 'Workspace A report',
95
+ workspace_id: 'workspace-a'
96
+ })
97
+
98
+ const queryA = await api.resources.workspace_reports.query({
99
+ simplified: false
100
+ }, scopedContext('workspace-a', 999))
101
+
102
+ validateJsonApiStructure(queryA, true)
103
+ assert.equal(queryA.data.length, 1)
104
+ assert.equal(queryA.data[0].attributes.title, 'Workspace A report')
105
+ assert.equal(queryA.data[0].attributes.workspace_id, 'workspace-a')
106
+ })
107
+
108
+ it('stamps user-scoped records and filters collections by user', async () => {
109
+ const noteA = await api.resources.user_notes.post({
110
+ inputRecord: createJsonApiDocument('user_notes', {
111
+ body: 'User 101 note'
112
+ }),
113
+ simplified: false
114
+ }, scopedContext('workspace-a', 101))
115
+
116
+ await api.resources.user_notes.post({
117
+ inputRecord: createJsonApiDocument('user_notes', {
118
+ body: 'User 202 note'
119
+ }),
120
+ simplified: false
121
+ }, scopedContext('workspace-a', 202))
122
+
123
+ validateJsonApiStructure(noteA)
124
+ assertResourceAttributes(noteA.data, {
125
+ body: 'User 101 note',
126
+ user_id: 101
127
+ })
128
+
129
+ const queryA = await api.resources.user_notes.query({
130
+ simplified: false
131
+ }, scopedContext('workspace-b', 101))
132
+
133
+ validateJsonApiStructure(queryA, true)
134
+ assert.equal(queryA.data.length, 1)
135
+ assert.equal(queryA.data[0].attributes.body, 'User 101 note')
136
+ assert.equal(queryA.data[0].attributes.user_id, 101)
137
+ })
138
+
139
+ it('applies composite workspace+user scoping', async () => {
140
+ const ownProject = await api.resources.projects.post({
141
+ inputRecord: createJsonApiDocument('projects', {
142
+ name: 'Workspace A / User 101'
143
+ }),
144
+ simplified: false
145
+ }, scopedContext('workspace-a', 101))
146
+
147
+ await api.resources.projects.post({
148
+ inputRecord: createJsonApiDocument('projects', {
149
+ name: 'Workspace A / User 202'
150
+ }),
151
+ simplified: false
152
+ }, scopedContext('workspace-a', 202))
153
+
154
+ await api.resources.projects.post({
155
+ inputRecord: createJsonApiDocument('projects', {
156
+ name: 'Workspace B / User 101'
157
+ }),
158
+ simplified: false
159
+ }, scopedContext('workspace-b', 101))
160
+
161
+ validateJsonApiStructure(ownProject)
162
+ assertResourceAttributes(ownProject.data, {
163
+ name: 'Workspace A / User 101',
164
+ workspace_id: 'workspace-a',
165
+ user_id: 101
166
+ })
167
+
168
+ const scopedQuery = await api.resources.projects.query({
169
+ simplified: false
170
+ }, scopedContext('workspace-a', 101))
171
+
172
+ validateJsonApiStructure(scopedQuery, true)
173
+ assert.equal(scopedQuery.data.length, 1)
174
+ assert.equal(scopedQuery.data[0].attributes.name, 'Workspace A / User 101')
175
+ })
176
+
177
+ it('treats public resources as unscoped', async () => {
178
+ const created = await api.resources.system_settings.post({
179
+ inputRecord: createJsonApiDocument('system_settings', {
180
+ key: 'app.version',
181
+ value: '1.0.0'
182
+ }),
183
+ simplified: false
184
+ })
185
+
186
+ validateJsonApiStructure(created)
187
+
188
+ const anonymousQuery = await api.resources.system_settings.query({
189
+ simplified: false
190
+ })
191
+ validateJsonApiStructure(anonymousQuery, true)
192
+ assert.equal(anonymousQuery.data.length, 1)
193
+
194
+ const scopedQuery = await api.resources.system_settings.query({
195
+ simplified: false
196
+ }, scopedContext('workspace-a', 101))
197
+ validateJsonApiStructure(scopedQuery, true)
198
+ assert.equal(scopedQuery.data.length, 1)
199
+ })
200
+
201
+ it('scopes single-record lookups so out-of-scope records are not found', async () => {
202
+ const project = await api.resources.projects.post({
203
+ inputRecord: createJsonApiDocument('projects', {
204
+ name: 'Scoped Project'
205
+ }),
206
+ simplified: false
207
+ }, scopedContext('workspace-a', 101))
208
+
209
+ await assert.rejects(
210
+ async () => {
211
+ await api.resources.projects.get({
212
+ id: project.data.id,
213
+ simplified: false
214
+ }, scopedContext('workspace-a', 202))
215
+ },
216
+ (error) => error.code === 'REST_API_RESOURCE',
217
+ 'Out-of-scope records should behave as not found'
218
+ )
219
+ })
220
+
221
+ it('stamps scoped fields on PUT and rejects inconsistent scoped updates', async () => {
222
+ const project = await api.resources.projects.post({
223
+ inputRecord: createJsonApiDocument('projects', {
224
+ name: 'Original Project',
225
+ description: 'Original Description'
226
+ }),
227
+ simplified: false
228
+ }, scopedContext('workspace-a', 101))
229
+
230
+ await api.resources.projects.put({
231
+ inputRecord: {
232
+ data: {
233
+ type: 'projects',
234
+ id: project.data.id,
235
+ attributes: {
236
+ name: 'Updated Project',
237
+ description: 'Updated Description',
238
+ status: 'active'
239
+ }
240
+ }
241
+ },
242
+ simplified: false
243
+ }, scopedContext('workspace-a', 101))
244
+
245
+ const afterPut = await api.resources.projects.get({
246
+ id: project.data.id,
247
+ simplified: false
248
+ }, scopedContext('workspace-a', 101))
249
+
250
+ validateJsonApiStructure(afterPut)
251
+ assertResourceAttributes(afterPut.data, {
252
+ name: 'Updated Project',
253
+ description: 'Updated Description',
254
+ workspace_id: 'workspace-a',
255
+ user_id: 101
256
+ })
257
+
258
+ await assert.rejects(
259
+ async () => {
260
+ await api.resources.projects.patch({
261
+ id: project.data.id,
262
+ inputRecord: {
263
+ data: {
264
+ type: 'projects',
265
+ id: project.data.id,
266
+ attributes: {
267
+ workspace_id: 'workspace-b'
268
+ }
269
+ }
270
+ },
271
+ simplified: false
272
+ }, scopedContext('workspace-a', 101))
273
+ },
274
+ (error) => error.code === 'REST_API_VALIDATION',
275
+ 'PATCH should reject inconsistent scope-field updates'
276
+ )
277
+ })
278
+
279
+ it('uses scoped lookups when validating relationships', async () => {
280
+ const projectA = await api.resources.projects.post({
281
+ inputRecord: createJsonApiDocument('projects', {
282
+ name: 'Project A'
283
+ }),
284
+ simplified: false
285
+ }, scopedContext('workspace-a', 101))
286
+
287
+ const projectB = await api.resources.projects.post({
288
+ inputRecord: createJsonApiDocument('projects', {
289
+ name: 'Project B'
290
+ }),
291
+ simplified: false
292
+ }, scopedContext('workspace-b', 101))
293
+
294
+ const task = await api.resources.tasks.post({
295
+ inputRecord: createJsonApiDocument('tasks',
296
+ {
297
+ title: 'Task A'
298
+ },
299
+ {
300
+ project: createRelationship(resourceIdentifier('projects', projectA.data.id))
301
+ }
302
+ ),
303
+ simplified: false
304
+ }, scopedContext('workspace-a', 101))
305
+
306
+ validateJsonApiStructure(task)
307
+ assertResourceAttributes(task.data, {
308
+ title: 'Task A',
309
+ workspace_id: 'workspace-a',
310
+ user_id: 101
311
+ })
312
+
313
+ await assert.rejects(
314
+ async () => {
315
+ await api.resources.tasks.post({
316
+ inputRecord: createJsonApiDocument('tasks',
317
+ {
318
+ title: 'Cross-scope Task'
319
+ },
320
+ {
321
+ project: createRelationship(resourceIdentifier('projects', projectB.data.id))
322
+ }
323
+ ),
324
+ simplified: false
325
+ }, scopedContext('workspace-a', 101))
326
+ },
327
+ (error) => error.code === 'REST_API_RESOURCE' || error.code === 'REST_API_VALIDATION',
328
+ 'Relationship validation should respect scoped datasets'
329
+ )
330
+ })
331
+
332
+ it('scopes included resources through the same autofilter path as top-level queries', async () => {
333
+ const project = await api.resources.projects.post({
334
+ inputRecord: createJsonApiDocument('projects', {
335
+ name: 'Scoped include project'
336
+ }),
337
+ simplified: false
338
+ }, scopedContext('workspace-a', 101))
339
+
340
+ const visibleTask = await api.resources.tasks.post({
341
+ inputRecord: createJsonApiDocument('tasks',
342
+ {
343
+ title: 'Visible task'
344
+ },
345
+ {
346
+ project: createRelationship(resourceIdentifier('projects', project.data.id))
347
+ }
348
+ ),
349
+ simplified: false
350
+ }, scopedContext('workspace-a', 101))
351
+
352
+ const hiddenTask = await api.resources.tasks.post({
353
+ inputRecord: createJsonApiDocument('tasks',
354
+ {
355
+ title: 'Out-of-scope task'
356
+ },
357
+ {
358
+ project: createRelationship(resourceIdentifier('projects', project.data.id))
359
+ }
360
+ ),
361
+ simplified: false
362
+ }, scopedContext('workspace-a', 101))
363
+
364
+ await moveTaskOutOfScope({
365
+ taskId: hiddenTask.data.id,
366
+ workspaceId: 'workspace-b',
367
+ userId: 202
368
+ })
369
+
370
+ const result = await api.resources.projects.get({
371
+ id: project.data.id,
372
+ queryParams: {
373
+ include: ['tasks']
374
+ },
375
+ simplified: false
376
+ }, scopedContext('workspace-a', 101))
377
+
378
+ validateJsonApiStructure(result)
379
+ assert.deepEqual(result.data.relationships.tasks.data, [
380
+ resourceIdentifier('tasks', visibleTask.data.id)
381
+ ])
382
+ assert.equal(result.included?.length, 1)
383
+ assert.equal(result.included?.[0]?.id, visibleTask.data.id)
384
+ assert.equal(result.included?.[0]?.attributes.title, 'Visible task')
385
+ })
386
+
387
+ it('treats null belongsTo autofilter values as valid null linkage on writes', async () => {
388
+ const implicitNull = await api.resources.optional_tasks.post({
389
+ inputRecord: createJsonApiDocument('optional_tasks', {
390
+ title: 'Implicit null project'
391
+ }),
392
+ simplified: false
393
+ }, scopedContext('workspace-a', 101))
394
+
395
+ const explicitNull = await api.resources.optional_tasks.post({
396
+ inputRecord: createJsonApiDocument(
397
+ 'optional_tasks',
398
+ {
399
+ title: 'Explicit null project'
400
+ },
401
+ {
402
+ project: { data: null }
403
+ }
404
+ ),
405
+ simplified: false
406
+ }, scopedContext('workspace-a', 101))
407
+
408
+ const implicitFetched = await api.resources.optional_tasks.get({
409
+ id: implicitNull.data.id,
410
+ simplified: false
411
+ }, scopedContext('workspace-a', 101))
412
+
413
+ const explicitFetched = await api.resources.optional_tasks.get({
414
+ id: explicitNull.data.id,
415
+ simplified: false
416
+ }, scopedContext('workspace-a', 101))
417
+
418
+ validateJsonApiStructure(implicitFetched)
419
+ validateJsonApiStructure(explicitFetched)
420
+ assert.equal(implicitFetched.data.relationships.project.data, null)
421
+ assert.equal(explicitFetched.data.relationships.project.data, null)
422
+ })
423
+
424
+ it('fails cleanly when a required autofilter value is missing', async () => {
425
+ await assert.rejects(
426
+ async () => {
427
+ await api.resources.projects.query({
428
+ simplified: false
429
+ })
430
+ },
431
+ (error) => error.code === 'REST_API_AUTOFILTER_CONTEXT',
432
+ 'Scoped resources should require configured scope values'
433
+ )
434
+
435
+ await assert.rejects(
436
+ async () => {
437
+ await api.resources.projects.post({
438
+ inputRecord: createJsonApiDocument('projects', {
439
+ name: 'No Scope'
440
+ }),
441
+ simplified: false
442
+ })
443
+ },
444
+ (error) => error.code === 'REST_API_AUTOFILTER_CONTEXT',
445
+ 'Scoped writes should require configured scope values'
446
+ )
447
+ })
448
+
449
+ it('exposes compiled preset configuration without auth semantics', () => {
450
+ assert.deepEqual(api.autofilter.getConfig(), {
451
+ presets: ['public', 'workspace', 'user', 'workspace_user'],
452
+ resolvers: ['workspace', 'user', 'currentProject']
453
+ })
454
+
455
+ assert.deepEqual(api.autofilter.getScopeConfig('projects'), {
456
+ preset: 'workspace_user',
457
+ filters: [
458
+ { field: 'workspace_id', resolver: 'workspace', required: true },
459
+ { field: 'user_id', resolver: 'user', required: true }
460
+ ]
461
+ })
462
+ })
463
+ })
@@ -0,0 +1,75 @@
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 { cleanTables } from './helpers/test-utils.js'
8
+
9
+ const knex = knexLib({
10
+ client: 'better-sqlite3',
11
+ connection: {
12
+ filename: ':memory:'
13
+ },
14
+ useNullAsDefault: true
15
+ })
16
+
17
+ let api
18
+
19
+ describe('Simplified POST with explicit resource ids', () => {
20
+ before(async () => {
21
+ api = new Api({
22
+ name: 'custom-id-simplified-post-test'
23
+ })
24
+
25
+ await api.use(RestApiPlugin, {
26
+ simplifiedApi: true,
27
+ simplifiedTransport: false,
28
+ returnRecordApi: {
29
+ post: 'full',
30
+ put: 'full',
31
+ patch: 'full'
32
+ }
33
+ })
34
+ await api.use(RestApiKnexPlugin, { knex })
35
+
36
+ await api.addResource('user_settings', {
37
+ tableName: 'test_user_settings',
38
+ idProperty: 'user_id',
39
+ searchSchema: {
40
+ id: { type: 'id', actualField: 'id' }
41
+ },
42
+ schema: {
43
+ theme: { type: 'string', required: true, max: 32 }
44
+ }
45
+ })
46
+ await api.resources.user_settings.createKnexTable()
47
+ })
48
+
49
+ after(async () => {
50
+ await knex.destroy()
51
+ })
52
+
53
+ beforeEach(async () => {
54
+ await cleanTables(knex, ['test_user_settings'])
55
+ })
56
+
57
+ it('persists the provided resource id when posting simplified records to a custom-id resource', async () => {
58
+ const created = await api.resources.user_settings.post({
59
+ id: '7',
60
+ theme: 'dark'
61
+ })
62
+
63
+ assert.equal(created.id, '7')
64
+ assert.equal(created.theme, 'dark')
65
+
66
+ const dbRow = await knex('test_user_settings').where('user_id', 7).first()
67
+ assert.ok(dbRow)
68
+ assert.equal(String(dbRow.user_id), '7')
69
+ assert.equal(dbRow.theme, 'dark')
70
+
71
+ const fetched = await api.resources.user_settings.get({ id: '7' })
72
+ assert.equal(fetched.id, '7')
73
+ assert.equal(fetched.theme, 'dark')
74
+ })
75
+ })
@@ -161,6 +161,14 @@ describe('Custom idProperty Relationship Operations', () => {
161
161
  assert.equal(getResult.included.length, 1)
162
162
  assert.equal(getResult.included[0].type, 'countries')
163
163
  assert.equal(getResult.included[0].attributes.name, 'United States')
164
+ assert.equal(
165
+ getResult.data.relationships.country.links.self,
166
+ `/publishers/${publisherResult.data.id}/relationships/country`
167
+ )
168
+ assert.equal(
169
+ getResult.data.relationships.country.links.related,
170
+ `/publishers/${publisherResult.data.id}/country`
171
+ )
164
172
  })
165
173
 
166
174
  it('should update belongsTo relationship via PATCH', async () => {
@@ -895,5 +903,30 @@ describe('Custom idProperty Relationship Operations', () => {
895
903
  assertResourceRelationship(putResult.data, 'country',
896
904
  resourceIdentifier('countries', country.data.id))
897
905
  })
906
+
907
+ it('should reject PUT when an existing persisted attribute is omitted', async () => {
908
+ const country = await api.resources.countries.post({
909
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
910
+ })
911
+
912
+ await assert.rejects(
913
+ async () => api.resources.countries.put({
914
+ inputRecord: {
915
+ data: {
916
+ type: 'countries',
917
+ id: country.data.id,
918
+ attributes: {
919
+ name: 'Renamed Country'
920
+ }
921
+ }
922
+ }
923
+ }),
924
+ (error) => {
925
+ assert.equal(error.message, 'PUT request omits persisted fields that already have stored values')
926
+ assert(error.details.fields.includes('data.attributes.code'))
927
+ return true
928
+ }
929
+ )
930
+ })
898
931
  })
899
932
  })