json-rest-api 1.0.24 → 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 (82) hide show
  1. package/README.md +2 -1
  2. package/docs/API.md +12 -9
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
  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_Row_Policies.md +603 -0
  10. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  11. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  12. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  13. package/docs/GUIDE/index.md +4 -1
  14. package/fixing_plan.md +112 -0
  15. package/index.js +2 -0
  16. package/package.json +1 -1
  17. package/plugins/core/bulk-operations-plugin.js +5 -4
  18. package/plugins/core/connectors/express-plugin.js +6 -1
  19. package/plugins/core/connectors/fastify-plugin.js +5 -1
  20. package/plugins/core/connectors/lib/connector-core.js +17 -11
  21. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  22. package/plugins/core/file-handling-plugin.js +126 -96
  23. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  24. package/plugins/core/lib/anyapi/schema-utils.js +1 -0
  25. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  26. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  27. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  28. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  29. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  30. package/plugins/core/lib/querying/url-helpers.js +9 -21
  31. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  32. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  33. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  34. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  35. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  36. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  37. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  38. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  39. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  40. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  41. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  42. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  43. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  44. package/plugins/core/rest-api-knex-plugin.js +70 -60
  45. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  46. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  47. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  48. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  49. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  50. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  51. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  52. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  53. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  54. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  55. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  56. package/plugins/core/rest-api-plugin.js +6 -36
  57. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  58. package/plugins/storage/local-storage.js +44 -19
  59. package/plugins/storage/s3-storage.js +21 -55
  60. package/tests/README.md +1 -1
  61. package/tests/anyapi-cursor-pagination.test.js +7 -1
  62. package/tests/anyapi-custom-idproperty.test.js +163 -0
  63. package/tests/fastify-plugin.test.js +28 -0
  64. package/tests/field-setters.test.js +10 -10
  65. package/tests/file-handling.test.js +118 -0
  66. package/tests/fixtures/api-configs.js +362 -4
  67. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  68. package/tests/http-connectors-parity.test.js +165 -5
  69. package/tests/local-storage.test.js +96 -0
  70. package/tests/pagination-cursor-multifield.test.js +121 -0
  71. package/tests/pagination-enhanced.test.js +66 -15
  72. package/tests/post-commit-hooks.test.js +120 -0
  73. package/tests/public-exports.test.js +15 -0
  74. package/tests/put-create-parity.test.js +216 -0
  75. package/tests/relationship-endpoints.test.js +259 -0
  76. package/tests/return-record-settings.test.js +184 -0
  77. package/tests/row-policy.test.js +433 -0
  78. package/tests/s3-storage.test.js +38 -0
  79. package/tests/searchschema-merge.test.js +17 -0
  80. package/tests/socketio.test.js +62 -0
  81. package/tests/storage-mapping.test.js +1 -0
  82. package/tests/test-development-guide.md +5 -5
@@ -0,0 +1,120 @@
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
+ cleanTables,
6
+ countRecords,
7
+ createJsonApiDocument,
8
+ createRelationship,
9
+ resourceIdentifier
10
+ } from './helpers/test-utils.js'
11
+ import { createBasicApi } 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
+ let afterCommitFailureMethod = null
23
+ const rollbackEvents = []
24
+
25
+ describe('Post-commit hook failure semantics', () => {
26
+ before(async () => {
27
+ api = await createBasicApi(knex, {
28
+ apiName: 'post-commit-hook-test',
29
+ tablePrefix: 'post_commit'
30
+ })
31
+
32
+ await api.customize({
33
+ hooks: {
34
+ afterCommit: {
35
+ functionName: 'post-commit-failure-test',
36
+ handler: async ({ context }) => {
37
+ if (context.method === afterCommitFailureMethod) {
38
+ throw new Error(`afterCommit failed for ${context.method}`)
39
+ }
40
+ }
41
+ },
42
+ afterRollback: {
43
+ functionName: 'post-commit-rollback-tracker',
44
+ handler: async ({ context }) => {
45
+ rollbackEvents.push(context.method)
46
+ }
47
+ }
48
+ }
49
+ })
50
+ })
51
+
52
+ after(async () => {
53
+ await knex.destroy()
54
+ })
55
+
56
+ beforeEach(async () => {
57
+ afterCommitFailureMethod = null
58
+ rollbackEvents.length = 0
59
+
60
+ await cleanTables(knex, [
61
+ 'post_commit_countries',
62
+ 'post_commit_publishers',
63
+ 'post_commit_authors',
64
+ 'post_commit_books',
65
+ 'post_commit_book_authors'
66
+ ])
67
+ })
68
+
69
+ it('surfaces afterCommit errors without rolling back committed resource writes', async () => {
70
+ afterCommitFailureMethod = 'post'
71
+
72
+ await assert.rejects(
73
+ api.resources.countries.post({
74
+ inputRecord: createJsonApiDocument('countries', {
75
+ name: 'Committed Country',
76
+ code: 'CC'
77
+ }),
78
+ simplified: false
79
+ }),
80
+ /afterCommit failed for post/
81
+ )
82
+
83
+ assert.equal(await countRecords(knex, 'post_commit_countries'), 1)
84
+ assert.deepEqual(rollbackEvents, [])
85
+ })
86
+
87
+ it('surfaces afterCommit errors without rolling back committed relationship writes', async () => {
88
+ const country = await api.resources.countries.post({
89
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' }),
90
+ simplified: false
91
+ })
92
+ const book = await api.resources.books.post({
93
+ inputRecord: createJsonApiDocument(
94
+ 'books',
95
+ { title: 'Committed Book' },
96
+ { country: createRelationship(resourceIdentifier('countries', country.data.id)) }
97
+ ),
98
+ simplified: false
99
+ })
100
+ const author = await api.resources.authors.post({
101
+ inputRecord: createJsonApiDocument('authors', { name: 'Committed Author' }),
102
+ simplified: false
103
+ })
104
+
105
+ rollbackEvents.length = 0
106
+ afterCommitFailureMethod = 'postRelationship'
107
+
108
+ await assert.rejects(
109
+ api.resources.books.postRelationship({
110
+ id: book.data.id,
111
+ relationshipName: 'authors',
112
+ relationshipData: [resourceIdentifier('authors', author.data.id)]
113
+ }),
114
+ /afterCommit failed for postRelationship/
115
+ )
116
+
117
+ assert.equal(await countRecords(knex, 'post_commit_book_authors'), 1)
118
+ assert.deepEqual(rollbackEvents, [])
119
+ })
120
+ })
@@ -0,0 +1,15 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { RowPolicyPlugin, SocketIOPlugin } from '../index.js'
4
+
5
+ describe('Public exports', () => {
6
+ it('exports the documented Socket.IO plugin from the package entrypoint', () => {
7
+ assert.equal(SocketIOPlugin.name, 'socketio')
8
+ assert.equal(typeof SocketIOPlugin.install, 'function')
9
+ })
10
+
11
+ it('exports the row-policy plugin from the package entrypoint', () => {
12
+ assert.equal(RowPolicyPlugin.name, 'row-policy')
13
+ assert.equal(typeof RowPolicyPlugin.install, 'function')
14
+ })
15
+ })
@@ -0,0 +1,216 @@
1
+ import { describe, it, before, beforeEach, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import { Api } from 'hooked-api'
5
+
6
+ import {
7
+ RestApiAnyapiKnexPlugin,
8
+ RestApiKnexPlugin,
9
+ RestApiPlugin,
10
+ } from '../index.js'
11
+ import { ensureAnyApiSchema } from '../plugins/core/lib/anyapi/schema-utils.js'
12
+ import {
13
+ cleanTables,
14
+ validateJsonApiStructure,
15
+ } from './helpers/test-utils.js'
16
+ import { storageMode } from './helpers/storage-mode.js'
17
+
18
+ const knex = knexLib({
19
+ client: 'better-sqlite3',
20
+ connection: {
21
+ filename: ':memory:'
22
+ },
23
+ useNullAsDefault: true
24
+ })
25
+
26
+ const tenantId = 'put_create_parity_tenant'
27
+ const profilesTable = 'put_create_profiles'
28
+ const accountsTable = 'put_create_accounts'
29
+
30
+ let api
31
+
32
+ const registerTable = (tableName, resourceName) => {
33
+ if (storageMode.isAnyApi()) {
34
+ storageMode.registerTable(tableName, resourceName, tenantId)
35
+ }
36
+ }
37
+
38
+ const findStoredRow = async (tableName, resourceName, idColumn, id) => {
39
+ if (storageMode.isAnyApi()) {
40
+ return knex('any_records')
41
+ .where({
42
+ tenant_id: tenantId,
43
+ resource: resourceName,
44
+ logical_id: String(id),
45
+ })
46
+ .first()
47
+ }
48
+
49
+ return knex(tableName)
50
+ .where(idColumn, id)
51
+ .first()
52
+ }
53
+
54
+ const countStoredRows = async (tableName, resourceName, idColumn, id) => {
55
+ if (storageMode.isAnyApi()) {
56
+ const row = await knex('any_records')
57
+ .where({
58
+ tenant_id: tenantId,
59
+ resource: resourceName,
60
+ logical_id: String(id),
61
+ })
62
+ .count('* as count')
63
+ .first()
64
+ return Number(row?.count || 0)
65
+ }
66
+
67
+ const row = await knex(tableName)
68
+ .where(idColumn, id)
69
+ .count('* as count')
70
+ .first()
71
+ return Number(row?.count || 0)
72
+ }
73
+
74
+ describe(`PUT create parity (${storageMode.mode})`, () => {
75
+ before(async () => {
76
+ if (storageMode.isAnyApi()) {
77
+ storageMode.clearRegistry()
78
+ storageMode.setCurrentTenant(tenantId)
79
+ await ensureAnyApiSchema(knex)
80
+ }
81
+
82
+ api = new Api({
83
+ name: 'put-create-parity-test',
84
+ log: { level: 'warn' }
85
+ })
86
+
87
+ await api.use(RestApiPlugin, {
88
+ simplifiedApi: false,
89
+ simplifiedTransport: false,
90
+ returnRecordApi: {
91
+ post: 'full',
92
+ put: 'full',
93
+ patch: 'full'
94
+ },
95
+ returnRecordTransport: {
96
+ post: 'full',
97
+ put: 'full',
98
+ patch: 'full'
99
+ },
100
+ sortableFields: ['id', 'account_id', 'name', 'code']
101
+ })
102
+
103
+ if (storageMode.isAnyApi()) {
104
+ await api.use(RestApiAnyapiKnexPlugin, { knex, tenantId })
105
+ } else {
106
+ await api.use(RestApiKnexPlugin, { knex })
107
+ }
108
+
109
+ await api.addResource('profiles', {
110
+ tableName: profilesTable,
111
+ schema: {
112
+ id: { type: 'id' },
113
+ name: { type: 'string', required: true },
114
+ code: { type: 'string', required: true },
115
+ }
116
+ })
117
+ await api.resources.profiles.createKnexTable()
118
+ registerTable(profilesTable, 'profiles')
119
+
120
+ await api.addResource('accounts', {
121
+ tableName: accountsTable,
122
+ idProperty: 'account_id',
123
+ schema: {
124
+ name: { type: 'string', required: true },
125
+ code: { type: 'string', required: true },
126
+ }
127
+ })
128
+ await api.resources.accounts.createKnexTable()
129
+ registerTable(accountsTable, 'accounts')
130
+ })
131
+
132
+ after(async () => {
133
+ await api?.release()
134
+ await knex.destroy()
135
+ if (storageMode.isAnyApi()) {
136
+ storageMode.clearRegistry()
137
+ }
138
+ })
139
+
140
+ beforeEach(async () => {
141
+ await cleanTables(knex, [profilesTable, accountsTable])
142
+ })
143
+
144
+ it('creates a missing resource through PUT with the default id property', async () => {
145
+ const created = await api.resources.profiles.put({
146
+ id: '101',
147
+ inputRecord: {
148
+ data: {
149
+ type: 'profiles',
150
+ id: '101',
151
+ attributes: {
152
+ name: 'Created Profile',
153
+ code: 'CP'
154
+ }
155
+ }
156
+ },
157
+ simplified: false
158
+ })
159
+
160
+ validateJsonApiStructure(created)
161
+ assert.equal(created.data.id, '101')
162
+ assert.equal(created.data.attributes.name, 'Created Profile')
163
+
164
+ const stored = await findStoredRow(profilesTable, 'profiles', 'id', 101)
165
+ assert(stored, 'PUT create should persist a row')
166
+ })
167
+
168
+ it('creates and then replaces a missing resource through PUT with a custom id property', async () => {
169
+ const created = await api.resources.accounts.put({
170
+ id: '501',
171
+ inputRecord: {
172
+ data: {
173
+ type: 'accounts',
174
+ id: '501',
175
+ attributes: {
176
+ name: 'Created Account',
177
+ code: 'CA'
178
+ }
179
+ }
180
+ },
181
+ simplified: false
182
+ })
183
+
184
+ validateJsonApiStructure(created)
185
+ assert.equal(created.data.id, '501')
186
+ assert.equal(created.data.attributes.name, 'Created Account')
187
+ assert.equal(created.data.attributes.account_id, undefined)
188
+
189
+ const replaced = await api.resources.accounts.put({
190
+ id: '501',
191
+ inputRecord: {
192
+ data: {
193
+ type: 'accounts',
194
+ id: '501',
195
+ attributes: {
196
+ name: 'Replaced Account',
197
+ code: 'RA'
198
+ }
199
+ }
200
+ },
201
+ simplified: false
202
+ })
203
+
204
+ assert.equal(replaced.data.id, '501')
205
+ assert.equal(replaced.data.attributes.name, 'Replaced Account')
206
+ assert.equal(await countStoredRows(accountsTable, 'accounts', 'account_id', 501), 1)
207
+
208
+ const fetched = await api.resources.accounts.get({
209
+ id: '501',
210
+ simplified: false
211
+ })
212
+ assert.equal(fetched.data.attributes.name, 'Replaced Account')
213
+ assert.equal(fetched.data.attributes.code, 'RA')
214
+ assert.equal(fetched.data.attributes.account_id, undefined)
215
+ })
216
+ })
@@ -1,5 +1,7 @@
1
1
  import { describe, it, before, after, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
+ import express from 'express'
4
+ import request from 'supertest'
3
5
  import knexLib from 'knex'
4
6
  import {
5
7
  validateJsonApiStructure,
@@ -23,16 +25,50 @@ const knex = knexLib({
23
25
 
24
26
  // API instance that persists across tests
25
27
  let api
28
+ let app
29
+ const relationshipCommitEvents = []
30
+ const relationshipRollbackEvents = []
26
31
 
27
32
  describe('Relationship Endpoints Plugin', () => {
28
33
  before(async () => {
29
34
  // Initialize API with the relationships plugin
35
+ app = express()
30
36
  api = await createBasicApi(knex, {
31
37
  express: {
32
38
  mountPath: '' // No mount path for this test
33
39
  },
34
40
  includeExpress: true
35
41
  })
42
+ api.http.express.mount(app)
43
+
44
+ await api.customize({
45
+ hooks: {
46
+ afterCommit: {
47
+ functionName: 'relationship-after-commit-test-tracker',
48
+ handler: async ({ context }) => {
49
+ if (context.method?.endsWith('Relationship')) {
50
+ relationshipCommitEvents.push({
51
+ method: context.method,
52
+ shouldCommit: context.shouldCommit,
53
+ hasTransaction: !!context.transaction
54
+ })
55
+ }
56
+ }
57
+ },
58
+ afterRollback: {
59
+ functionName: 'relationship-after-rollback-test-tracker',
60
+ handler: async ({ context }) => {
61
+ if (context.method?.endsWith('Relationship')) {
62
+ relationshipRollbackEvents.push({
63
+ method: context.method,
64
+ shouldCommit: context.shouldCommit,
65
+ hasTransaction: !!context.transaction
66
+ })
67
+ }
68
+ }
69
+ }
70
+ }
71
+ })
36
72
  })
37
73
 
38
74
  after(async () => {
@@ -358,4 +394,227 @@ describe('Relationship Endpoints Plugin', () => {
358
394
  // Note: belongsTo relationships (like 'publisher') are not accessible via relationship endpoints
359
395
  // They are foreign key fields, not true JSON:API relationships
360
396
  })
397
+
398
+ describe('Relationship Cardinality Validation', () => {
399
+ let countryId
400
+ let bookId
401
+ let authorId
402
+
403
+ const assertRelationshipIdValidationError = (error) => {
404
+ assert.equal(error.code, 'REST_API_VALIDATION')
405
+ assert.match(error.message, /id/i)
406
+ return true
407
+ }
408
+
409
+ beforeEach(async () => {
410
+ await cleanTables(knex, [
411
+ 'basic_countries', 'basic_publishers', 'basic_authors', 'basic_books', 'basic_book_authors'
412
+ ])
413
+
414
+ const country = await api.resources.countries.post({
415
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' }),
416
+ simplified: false
417
+ })
418
+ countryId = country.data.id
419
+
420
+ const book = await api.resources.books.post({
421
+ inputRecord: createJsonApiDocument(
422
+ 'books',
423
+ { title: 'Cardinality Book' },
424
+ { country: createRelationship(resourceIdentifier('countries', countryId)) }
425
+ ),
426
+ simplified: false
427
+ })
428
+ bookId = book.data.id
429
+
430
+ const author = await api.resources.authors.post({
431
+ inputRecord: createJsonApiDocument('authors', { name: 'Author One' }),
432
+ simplified: false
433
+ })
434
+ authorId = author.data.id
435
+ })
436
+
437
+ it('rejects arrays for to-one relationship PATCH calls', async () => {
438
+ await assert.rejects(
439
+ api.resources.books.patchRelationship({
440
+ id: bookId,
441
+ relationshipName: 'country',
442
+ relationshipData: [resourceIdentifier('countries', countryId)]
443
+ }),
444
+ (error) => {
445
+ assert.equal(error.code, 'REST_API_VALIDATION')
446
+ assert.match(error.message, /single resource identifier or null/)
447
+ assert(error.details.fields.includes('data.relationships.country.data'))
448
+ return true
449
+ }
450
+ )
451
+ })
452
+
453
+ it('rejects scalars for to-many relationship PATCH calls', async () => {
454
+ await assert.rejects(
455
+ api.resources.books.patchRelationship({
456
+ id: bookId,
457
+ relationshipName: 'authors',
458
+ relationshipData: resourceIdentifier('authors', authorId)
459
+ }),
460
+ (error) => {
461
+ assert.equal(error.code, 'REST_API_VALIDATION')
462
+ assert.match(error.message, /array of resource identifiers/)
463
+ assert(error.details.fields.includes('data.relationships.authors.data'))
464
+ return true
465
+ }
466
+ )
467
+ })
468
+
469
+ it('rejects invalid relationship cardinality through Express transport', async () => {
470
+ const toOneResponse = await request(app)
471
+ .patch(`/books/${bookId}/relationships/country`)
472
+ .set('Content-Type', 'application/vnd.api+json')
473
+ .set('Accept', 'application/vnd.api+json')
474
+ .send({
475
+ data: [resourceIdentifier('countries', countryId)]
476
+ })
477
+
478
+ assert.equal(toOneResponse.status, 422)
479
+ assert.match(toOneResponse.body.errors?.[0]?.detail || '', /single resource identifier or null/)
480
+
481
+ const toManyResponse = await request(app)
482
+ .patch(`/books/${bookId}/relationships/authors`)
483
+ .set('Content-Type', 'application/vnd.api+json')
484
+ .set('Accept', 'application/vnd.api+json')
485
+ .send({
486
+ data: resourceIdentifier('authors', authorId)
487
+ })
488
+
489
+ assert.equal(toManyResponse.status, 422)
490
+ assert.match(toManyResponse.body.errors?.[0]?.detail || '', /array of resource identifiers/)
491
+ })
492
+
493
+ it('rejects malformed relationship identifiers through direct relationship methods', async () => {
494
+ await assert.rejects(
495
+ api.resources.books.postRelationship({
496
+ id: bookId,
497
+ relationshipName: 'authors',
498
+ relationshipData: [{ type: 'authors' }]
499
+ }),
500
+ assertRelationshipIdValidationError
501
+ )
502
+
503
+ await assert.rejects(
504
+ api.resources.books.patchRelationship({
505
+ id: bookId,
506
+ relationshipName: 'country',
507
+ relationshipData: { type: 'countries' }
508
+ }),
509
+ assertRelationshipIdValidationError
510
+ )
511
+ })
512
+
513
+ it('rejects malformed relationship identifiers through Express transport', async () => {
514
+ const response = await request(app)
515
+ .delete(`/books/${bookId}/relationships/authors`)
516
+ .set('Content-Type', 'application/vnd.api+json')
517
+ .set('Accept', 'application/vnd.api+json')
518
+ .send({
519
+ data: [{ type: 'authors' }]
520
+ })
521
+
522
+ assert.equal(response.status, 422)
523
+ assert.match(response.body.errors?.[0]?.detail || '', /id/i)
524
+ })
525
+ })
526
+
527
+ describe('Relationship Transaction Hooks', () => {
528
+ let countryId
529
+ let secondCountryId
530
+ let bookId
531
+ let authorId
532
+
533
+ beforeEach(async () => {
534
+ relationshipCommitEvents.length = 0
535
+ relationshipRollbackEvents.length = 0
536
+
537
+ await cleanTables(knex, [
538
+ 'basic_countries', 'basic_publishers', 'basic_authors', 'basic_books', 'basic_book_authors'
539
+ ])
540
+
541
+ const country = await api.resources.countries.post({
542
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' }),
543
+ simplified: false
544
+ })
545
+ countryId = country.data.id
546
+
547
+ const secondCountry = await api.resources.countries.post({
548
+ inputRecord: createJsonApiDocument('countries', { name: 'Canada', code: 'CA' }),
549
+ simplified: false
550
+ })
551
+ secondCountryId = secondCountry.data.id
552
+
553
+ const book = await api.resources.books.post({
554
+ inputRecord: createJsonApiDocument(
555
+ 'books',
556
+ { title: 'Hook Book' },
557
+ { country: createRelationship(resourceIdentifier('countries', countryId)) }
558
+ ),
559
+ simplified: false
560
+ })
561
+ bookId = book.data.id
562
+
563
+ const author = await api.resources.authors.post({
564
+ inputRecord: createJsonApiDocument('authors', { name: 'Hook Author' }),
565
+ simplified: false
566
+ })
567
+ authorId = author.data.id
568
+
569
+ relationshipCommitEvents.length = 0
570
+ relationshipRollbackEvents.length = 0
571
+ })
572
+
573
+ it('runs afterCommit for relationship methods that own their transaction', async () => {
574
+ await api.resources.books.postRelationship({
575
+ id: bookId,
576
+ relationshipName: 'authors',
577
+ relationshipData: [resourceIdentifier('authors', authorId)]
578
+ })
579
+
580
+ await api.resources.books.patchRelationship({
581
+ id: bookId,
582
+ relationshipName: 'country',
583
+ relationshipData: resourceIdentifier('countries', secondCountryId)
584
+ })
585
+
586
+ await api.resources.books.deleteRelationship({
587
+ id: bookId,
588
+ relationshipName: 'authors',
589
+ relationshipData: [resourceIdentifier('authors', authorId)]
590
+ })
591
+
592
+ assert.deepEqual(
593
+ relationshipCommitEvents.map((event) => event.method),
594
+ ['postRelationship', 'patchRelationship', 'deleteRelationship']
595
+ )
596
+ assert(relationshipCommitEvents.every((event) => event.shouldCommit))
597
+ assert(relationshipCommitEvents.every((event) => event.hasTransaction))
598
+ assert.equal(relationshipRollbackEvents.length, 0)
599
+ })
600
+
601
+ it('preserves afterRollback for failed relationship methods that own their transaction', async () => {
602
+ await assert.rejects(
603
+ api.resources.books.postRelationship({
604
+ id: bookId,
605
+ relationshipName: 'missing',
606
+ relationshipData: []
607
+ }),
608
+ /Relationship 'missing' not found/
609
+ )
610
+
611
+ assert.deepEqual(
612
+ relationshipRollbackEvents.map((event) => event.method),
613
+ ['postRelationship']
614
+ )
615
+ assert(relationshipRollbackEvents[0].shouldCommit)
616
+ assert(relationshipRollbackEvents[0].hasTransaction)
617
+ assert.equal(relationshipCommitEvents.length, 0)
618
+ })
619
+ })
361
620
  })