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
@@ -118,10 +118,10 @@ describe('dbTablesOperations.createKnexTable', () => {
118
118
 
119
119
  await db('users').insert({ username: 'testuser' })
120
120
  const user = await db('users').where('username', 'testuser').first()
121
- await db('posts').insert({ title: 'Test Post', userId: user.id })
121
+ await db('posts').insert({ title: 'Test Post', user_id: user.id })
122
122
 
123
123
  const posts = await db('posts')
124
- .join('users', 'posts.userId', 'users.id')
124
+ .join('users', 'posts.user_id', 'users.id')
125
125
  .select('posts.title', 'users.username')
126
126
 
127
127
  assert.equal(posts.length, 1)
@@ -173,10 +173,11 @@ describe('dbTablesOperations.createKnexTable', () => {
173
173
  })
174
174
  })
175
175
 
176
- it('uses storage column overrides when creating tables', async () => {
176
+ it('defaults storage columns to snake_case and keeps per-field overrides', async () => {
177
177
  const schema = makeTableSchema({
178
- displayName: { type: 'string', required: true, storage: { column: 'display_name' } },
179
- lastSeenAt: { type: 'dateTime', storage: { column: 'last_seen_at' } }
178
+ displayName: { type: 'string', required: true },
179
+ lastSeenAt: { type: 'dateTime' },
180
+ externalRef: { type: 'string', storage: { column: 'legacy_ref' } }
180
181
  })
181
182
 
182
183
  await createKnexTable(db, makeSchemaInfo('mapped_profiles'), schema)
@@ -185,7 +186,9 @@ describe('dbTablesOperations.createKnexTable', () => {
185
186
  assert.ok(info.id)
186
187
  assert.ok(info.display_name)
187
188
  assert.ok(info.last_seen_at)
189
+ assert.ok(info.legacy_ref)
188
190
  assert.equal(info.displayName, undefined)
191
+ assert.equal(info.external_ref, undefined)
189
192
  })
190
193
 
191
194
  it('supports logical id fields mapped to custom storage columns', async () => {
@@ -221,10 +224,10 @@ describe('dbTablesOperations.createKnexTable', () => {
221
224
  `)
222
225
 
223
226
  const schema = makeTableSchema({
224
- workspaceId: { type: 'id', required: true, storage: { column: 'workspace_id' } },
225
- userId: { type: 'id', required: true, storage: { column: 'user_id' } },
227
+ workspaceId: { type: 'id', required: true },
228
+ userId: { type: 'id', required: true },
226
229
  role: { type: 'string', enum: ['owner', 'member'], required: true, defaultTo: 'member' },
227
- noteCount: { type: 'number', defaultTo: 0, storage: { column: 'note_count' } }
230
+ noteCount: { type: 'number', defaultTo: 0 }
228
231
  }, {
229
232
  indexes: [
230
233
  {
@@ -352,20 +355,22 @@ describe('dbTablesOperations.generateKnexMigration', () => {
352
355
  assert.ok(migration.includes("table.string('title', 200).notNullable()"))
353
356
  assert.ok(migration.includes("table.decimal('price', 10, 2)"))
354
357
  assert.ok(migration.includes(".defaultTo(true)"))
355
- assert.ok(migration.includes("table.foreign(['categoryId'], 'products_categoryId_foreign').references(['id']).inTable('categories').onDelete('SET NULL')"))
358
+ assert.ok(migration.includes("table.foreign(['category_id'], 'products_category_id_foreign').references(['id']).inTable('categories').onDelete('SET NULL')"))
356
359
  assert.ok(migration.includes('table.timestamps(true, true)'))
357
360
  })
358
361
 
359
- it('uses storage column overrides in generated migrations', () => {
362
+ it('uses default snake_case columns in generated migrations and keeps per-field overrides', () => {
360
363
  const schema = makeTableSchema({
361
- displayName: { type: 'string', required: true, storage: { column: 'display_name' } },
362
- loginCount: { type: 'number', defaultTo: 0, storage: { column: 'login_count' } }
364
+ displayName: { type: 'string', required: true },
365
+ loginCount: { type: 'number', defaultTo: 0 },
366
+ externalRef: { type: 'string', storage: { column: 'legacy_ref' } }
363
367
  })
364
368
 
365
369
  const migration = generateKnexMigration('mapped_profiles', schema)
366
370
 
367
371
  assert.ok(migration.includes("table.string('display_name').notNullable()"))
368
372
  assert.ok(migration.includes("table.float('login_count').defaultTo(0)"))
373
+ assert.ok(migration.includes("table.string('legacy_ref')"))
369
374
  assert.ok(!migration.includes("table.string('displayName')"))
370
375
  })
371
376
 
@@ -384,11 +389,11 @@ describe('dbTablesOperations.generateKnexMigration', () => {
384
389
 
385
390
  it('includes named composite indexes, foreign keys, checks, enum, and set columns in generated migrations', () => {
386
391
  const schema = makeTableSchema({
387
- workspaceId: { type: 'id', required: true, storage: { column: 'workspace_id' } },
388
- userId: { type: 'id', required: true, storage: { column: 'user_id' } },
392
+ workspaceId: { type: 'id', required: true },
393
+ userId: { type: 'id', required: true },
389
394
  role: { type: 'string', enum: ['owner', 'member'], required: true, defaultTo: 'member' },
390
395
  flags: { type: 'string', setValues: ['featured', 'archived'] },
391
- noteCount: { type: 'number', defaultTo: 0, storage: { column: 'note_count' } }
396
+ noteCount: { type: 'number', defaultTo: 0 }
392
397
  }, {
393
398
  indexes: [
394
399
  {
@@ -502,10 +507,10 @@ describe('dbTablesOperations.generateKnexMigrationDiff', () => {
502
507
  idColumn: 'id'
503
508
  })
504
509
  const desiredSchema = makeTableSchema({
505
- workspaceId: { type: 'id', required: true, storage: { column: 'workspace_id' } },
506
- userId: { type: 'id', required: true, storage: { column: 'user_id' } },
510
+ workspaceId: { type: 'id', required: true },
511
+ userId: { type: 'id', required: true },
507
512
  role: { type: 'string', enum: ['owner', 'member'], required: true, defaultTo: 'member' },
508
- noteCount: { type: 'number', defaultTo: 0, storage: { column: 'note_count' } }
513
+ noteCount: { type: 'number', defaultTo: 0 }
509
514
  }, {
510
515
  indexes: [
511
516
  {
@@ -737,8 +742,8 @@ describe('RestApiKnexPlugin migration scope methods', () => {
737
742
  await api.addResource('memberships', {
738
743
  schema: {
739
744
  id: { type: 'id' },
740
- workspaceId: { type: 'id', required: true, storage: { column: 'workspace_id' } },
741
- userId: { type: 'id', required: true, storage: { column: 'user_id' } },
745
+ workspaceId: { type: 'id', required: true },
746
+ userId: { type: 'id', required: true },
742
747
  role: { type: 'string', enum: ['owner', 'member'], required: true, defaultTo: 'member' }
743
748
  },
744
749
  indexes: [
@@ -2,12 +2,27 @@ import { describe, it, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { Api } from 'hooked-api'
4
4
  import { RestApiPlugin, FastifyPlugin } from '../index.js'
5
+ import { getRequestContracts } from '../plugins/core/lib/querying-writing/request-contracts.js'
5
6
  import {
6
7
  FakeFastifyApp,
7
8
  FakeReply,
8
9
  findFastifyRoute
9
10
  } from './helpers/fake-fastify.js'
10
11
 
12
+ function resolveSchemaNode (rootSchema, schemaNode) {
13
+ if (!schemaNode || !Array.isArray(schemaNode.allOf) || schemaNode.allOf.length !== 1) {
14
+ return schemaNode
15
+ }
16
+
17
+ const ref = schemaNode.allOf[0]?.$ref || ''
18
+ if (!ref.startsWith('#/definitions/')) {
19
+ return schemaNode
20
+ }
21
+
22
+ const definitionName = ref.slice('#/definitions/'.length)
23
+ return rootSchema?.definitions?.[definitionName] || schemaNode
24
+ }
25
+
11
26
  async function createFastifyConnectorApi () {
12
27
  const app = new FakeFastifyApp()
13
28
  const api = new Api({
@@ -61,6 +76,12 @@ describe('Fastify Plugin', () => {
61
76
  const postRelationshipRoute = findFastifyRoute(app, 'POST', '/api/articles/:id/relationships/:relationshipName')
62
77
  const patchRelationshipRoute = findFastifyRoute(app, 'PATCH', '/api/articles/:id/relationships/:relationshipName')
63
78
  const deleteRelationshipRoute = findFastifyRoute(app, 'DELETE', '/api/articles/:id/relationships/:relationshipName')
79
+ const relationshipContracts = getRequestContracts({
80
+ scopeName: 'articles',
81
+ schemaInfo: api.resources.articles.vars.schemaInfo,
82
+ includeDepthLimit: api.resources.articles.vars.includeDepthLimit,
83
+ sortableFields: api.resources.articles.vars.sortableFields
84
+ })
64
85
 
65
86
  assert.ok(postRoute?.schema?.body, 'POST route should have a body schema')
66
87
  assert.ok(putRoute?.schema?.body, 'PUT route should have a body schema')
@@ -70,7 +91,7 @@ describe('Fastify Plugin', () => {
70
91
  assert.ok(patchRelationshipRoute?.schema?.body, 'PATCH relationship route should have a body schema')
71
92
  assert.ok(deleteRelationshipRoute?.schema?.body, 'DELETE relationship route should have a body schema')
72
93
 
73
- const postDataSchema = postRoute.schema.body.properties.data
94
+ const postDataSchema = resolveSchemaNode(postRoute.schema.body, postRoute.schema.body.properties.data)
74
95
  const postAttributes = postDataSchema.properties.attributes
75
96
 
76
97
  assert.deepEqual(postDataSchema.required, ['type'])
@@ -78,14 +99,15 @@ describe('Fastify Plugin', () => {
78
99
  assert.equal(postAttributes.properties.author_id, undefined, 'belongsTo foreign key should not be accepted in attributes')
79
100
  assert.equal(postAttributes.properties.internal_summary, undefined, 'computed fields should not be accepted in attributes')
80
101
  assert.equal(postAttributes.properties.id, undefined, 'resource ids should not appear in attributes')
81
- assert.ok(postDataSchema.properties.relationships.properties.author, 'declared belongsTo relationships should be represented in the transport schema')
102
+ const postRelationships = resolveSchemaNode(postRoute.schema.body, postDataSchema.properties.relationships)
103
+ assert.ok(postRelationships.properties.author, 'declared belongsTo relationships should be represented in the transport schema')
82
104
 
83
- const putDataSchema = putRoute.schema.body.properties.data
105
+ const putDataSchema = resolveSchemaNode(putRoute.schema.body, putRoute.schema.body.properties.data)
84
106
  assert.ok(putDataSchema.required.includes('id'), 'PUT body schema should require data.id')
85
107
 
86
- const patchDataSchema = patchRoute.schema.body.properties.data
87
- assert.ok(Array.isArray(patchDataSchema.anyOf), 'PATCH body schema should require at least one writable section')
88
- assert.deepEqual(patchDataSchema.anyOf, [
108
+ const patchDataSchemaNode = patchRoute.schema.body.properties.data
109
+ assert.ok(Array.isArray(patchDataSchemaNode.anyOf), 'PATCH body schema should require at least one writable section')
110
+ assert.deepEqual(patchDataSchemaNode.anyOf, [
89
111
  { required: ['attributes'] },
90
112
  { required: ['relationships'] }
91
113
  ])
@@ -93,6 +115,27 @@ describe('Fastify Plugin', () => {
93
115
  assert.equal(postRelationshipRoute.schema.body.properties.data.type, 'array')
94
116
  assert.ok(Array.isArray(patchRelationshipRoute.schema.body.properties.data.anyOf))
95
117
  assert.equal(deleteRelationshipRoute.schema.body.properties.data.type, 'array')
118
+ assert.deepEqual(
119
+ postRelationshipRoute.schema.body,
120
+ relationshipContracts.postRelationship.schema.toJsonSchema({
121
+ mode: relationshipContracts.postRelationship.mode,
122
+ additionalProperties: false
123
+ })
124
+ )
125
+ assert.deepEqual(
126
+ patchRelationshipRoute.schema.body,
127
+ relationshipContracts.patchRelationship.schema.toJsonSchema({
128
+ mode: relationshipContracts.patchRelationship.mode,
129
+ additionalProperties: false
130
+ })
131
+ )
132
+ assert.deepEqual(
133
+ deleteRelationshipRoute.schema.body,
134
+ relationshipContracts.deleteRelationship.schema.toJsonSchema({
135
+ mode: relationshipContracts.deleteRelationship.mode,
136
+ additionalProperties: false
137
+ })
138
+ )
96
139
  })
97
140
 
98
141
  it('registers the JSON:API content-type parser and rejects unsupported write content types before the route handler', async () => {
@@ -176,7 +176,7 @@ describe('Field Setters', () => {
176
176
  await cleanTables(knex, ['setter_users'])
177
177
  })
178
178
 
179
- it('should handle setter errors gracefully', async () => {
179
+ it('should reject setter errors and roll back the write', async () => {
180
180
  // Create a resource with a failing setter
181
181
  await api.addResource('error_test', {
182
182
  schema: {
@@ -196,16 +196,16 @@ describe('Field Setters', () => {
196
196
  })
197
197
  await api.resources.error_test.createKnexTable()
198
198
 
199
- const record = await api.resources.error_test.post({
200
- good_field: 'HELLO',
201
- bad_field: 'world'
202
- })
199
+ await assert.rejects(
200
+ api.resources.error_test.post({
201
+ good_field: 'HELLO',
202
+ bad_field: 'world'
203
+ }),
204
+ /Setter for field 'bad_field' failed: Setter failed!/
205
+ )
203
206
 
204
- const fetchedRecord = await api.resources.error_test.get({ id: record.id })
205
- // Good field should be transformed
206
- assert.equal(fetchedRecord.good_field, 'hello')
207
- // Bad field should keep validated value (error logged but not thrown)
208
- assert.equal(fetchedRecord.bad_field, 'world')
207
+ const records = await api.resources.error_test.query()
208
+ assert.equal(records?.length || 0, 0)
209
209
 
210
210
  await cleanTables(knex, ['setter_errors'])
211
211
  })
@@ -0,0 +1,118 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import { cleanTables } from './helpers/test-utils.js'
5
+ import { createFileUploadApi } from './fixtures/api-configs.js'
6
+
7
+ const knex = knexLib({
8
+ client: 'better-sqlite3',
9
+ connection: {
10
+ filename: ':memory:'
11
+ },
12
+ useNullAsDefault: true
13
+ })
14
+
15
+ let api
16
+ let detectorState
17
+ let storage
18
+
19
+ function createTrackingStorage () {
20
+ return {
21
+ uploaded: [],
22
+ deleted: [],
23
+
24
+ async upload (file) {
25
+ const url = `/uploads/${file.filename}`
26
+ this.uploaded.push(url)
27
+ return url
28
+ },
29
+
30
+ async delete (url) {
31
+ this.deleted.push(url)
32
+ }
33
+ }
34
+ }
35
+
36
+ function createTestFile ({ filename = 'upload.png', mimetype = 'image/png', cleanup } = {}) {
37
+ return {
38
+ filename,
39
+ mimetype,
40
+ size: 1,
41
+ data: Buffer.from('x'),
42
+ cleanup
43
+ }
44
+ }
45
+
46
+ describe('File handling cleanup', () => {
47
+ before(async () => {
48
+ detectorState = { payload: null }
49
+ storage = createTrackingStorage()
50
+ api = await createFileUploadApi(knex, { detectorState, storage })
51
+ })
52
+
53
+ after(async () => {
54
+ await knex.destroy()
55
+ })
56
+
57
+ beforeEach(async () => {
58
+ await cleanTables(knex, ['file_documents'])
59
+ detectorState.payload = null
60
+ storage.uploaded = []
61
+ storage.deleted = []
62
+ })
63
+
64
+ it('cleans detector temp files when MIME validation rejects an upload', async () => {
65
+ let cleanupCalls = 0
66
+ detectorState.payload = {
67
+ fields: { title: 'Invalid MIME' },
68
+ files: {
69
+ attachment: createTestFile({
70
+ filename: 'invalid.txt',
71
+ mimetype: 'text/plain',
72
+ cleanup: async () => {
73
+ cleanupCalls++
74
+ }
75
+ })
76
+ }
77
+ }
78
+
79
+ await assert.rejects(
80
+ api.resources.documents.post({
81
+ inputRecord: { data: { type: 'documents', attributes: {} } },
82
+ simplified: false
83
+ }),
84
+ /Invalid file type for field 'attachment'/
85
+ )
86
+
87
+ assert.equal(cleanupCalls, 1)
88
+ assert.deepEqual(storage.uploaded, [])
89
+ assert.deepEqual(storage.deleted, [])
90
+ })
91
+
92
+ it('deletes uploaded files when a later write validation error rolls back', async () => {
93
+ let cleanupCalls = 0
94
+ detectorState.payload = {
95
+ fields: {},
96
+ files: {
97
+ attachment: createTestFile({
98
+ filename: 'valid.png',
99
+ cleanup: async () => {
100
+ cleanupCalls++
101
+ }
102
+ })
103
+ }
104
+ }
105
+
106
+ await assert.rejects(
107
+ api.resources.documents.post({
108
+ inputRecord: { data: { type: 'documents', attributes: {} } },
109
+ simplified: false
110
+ }),
111
+ /Schema validation failed for resource attributes/
112
+ )
113
+
114
+ assert.equal(cleanupCalls, 1)
115
+ assert.deepEqual(storage.uploaded, ['/uploads/valid.png'])
116
+ assert.deepEqual(storage.deleted, ['/uploads/valid.png'])
117
+ })
118
+ })