json-rest-api 1.0.27 → 1.0.29

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 (39) hide show
  1. package/docs/API.md +2 -0
  2. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +11 -2
  3. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +3 -3
  4. package/docs/GUIDE/GUIDE_Y_Appendices.md +57 -29
  5. package/index.js +1 -0
  6. package/lib/rest-api-errors.js +35 -0
  7. package/package.json +2 -2
  8. package/plugins/core/connectors/lib/transport-http-helpers.js +12 -1
  9. package/plugins/core/file-handling-plugin.js +3 -0
  10. package/plugins/core/lib/anyapi/schema-utils.js +2 -1
  11. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -2
  12. package/plugins/core/lib/dbTablesOperations.js +20 -10
  13. package/plugins/core/lib/querying/knex-pagination-helpers.js +29 -13
  14. package/plugins/core/lib/querying/knex-relationship-includes.js +18 -7
  15. package/plugins/core/lib/querying/query-field-sort-helpers.js +93 -15
  16. package/plugins/core/lib/querying/sort-helpers.js +8 -1
  17. package/plugins/core/lib/querying-writing/database-value-normalizers.js +297 -143
  18. package/plugins/core/lib/querying-writing/knex-field-helpers.js +15 -1
  19. package/plugins/core/lib/querying-writing/request-contracts.js +16 -6
  20. package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -1
  21. package/plugins/core/lib/storage/storage-adapter.js +19 -3
  22. package/plugins/core/lib/storage/storage-mapping.js +7 -1
  23. package/plugins/core/rest-api-anyapi-knex-plugin.js +36 -60
  24. package/plugins/core/rest-api-knex-plugin.js +28 -20
  25. package/plugins/core/rest-api-plugin-methods/common.js +15 -0
  26. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +3 -0
  27. package/plugins/core/rest-api-plugin-methods/get.js +8 -1
  28. package/plugins/core/rest-api-plugin-methods/query.js +8 -1
  29. package/tests/anyapi-cursor-pagination.test.js +1 -3
  30. package/tests/computed-fields.test.js +21 -0
  31. package/tests/db-table-operations.test.js +13 -1
  32. package/tests/file-handling.test.js +37 -0
  33. package/tests/fixtures/api-configs.js +140 -3
  34. package/tests/pagination-boundaries.test.js +154 -0
  35. package/tests/pagination-contract.test.js +122 -0
  36. package/tests/pagination-cursor-multifield.test.js +5 -3
  37. package/tests/relationship-include-storage-adapter.test.js +56 -0
  38. package/tests/storage-mapping.test.js +112 -10
  39. package/tests/temporal-boundaries.test.js +208 -0
@@ -4,6 +4,11 @@ import knexLib from 'knex'
4
4
  import { Api } from 'hooked-api'
5
5
  import { RestApiPlugin } from '../plugins/core/rest-api-plugin.js'
6
6
  import { RestApiKnexPlugin } from '../plugins/core/rest-api-knex-plugin.js'
7
+ import {
8
+ REST_API_TEMPORAL_DATA_ERROR_CODE,
9
+ RestApiTemporalDataError
10
+ } from '../lib/rest-api-errors.js'
11
+ import { mapRestApiErrorToHttp } from '../plugins/core/connectors/lib/transport-http-helpers.js'
7
12
  import {
8
13
  cleanTables,
9
14
  createJsonApiDocument,
@@ -23,14 +28,16 @@ const knex = knexLib({
23
28
  })
24
29
 
25
30
  let api
31
+ const DEFAULT_PROFILE_CREATED_AT = '2026-08-25T12:34:56.789Z'
32
+ const COMPUTED_PROFILE_DATE = new Date('2026-08-25T23:45:01.987Z')
26
33
 
27
34
  const serializeDateTimeForSql = (value) => {
28
35
  if (value == null) return null
29
- if (!(value instanceof Date)) {
30
- throw new Error('serializeDateTimeForSql expects a Date value after schema normalization')
36
+ if (typeof value !== 'string') {
37
+ throw new Error('serializeDateTimeForSql expects an RFC 3339 string after schema validation')
31
38
  }
32
39
 
33
- return value.toISOString().replace('T', ' ').slice(0, 19)
40
+ return new Date(value).toISOString().replace('T', ' ').slice(0, 19)
34
41
  }
35
42
 
36
43
  describe('Storage mapping', () => {
@@ -76,10 +83,27 @@ describe('Storage mapping', () => {
76
83
  lastSeenAt: {
77
84
  type: 'dateTime',
78
85
  nullable: true,
86
+ temporalPrecision: 0,
87
+ getter: (value) => value == null ? value : new Date(value),
79
88
  storage: {
80
89
  serialize: serializeDateTimeForSql
81
90
  }
82
91
  },
92
+ createdAt: {
93
+ type: 'dateTime',
94
+ temporalPrecision: 3,
95
+ defaultTo: () => new Date(DEFAULT_PROFILE_CREATED_AT).toISOString()
96
+ },
97
+ calculatedAt: {
98
+ type: 'dateTime',
99
+ temporalPrecision: 0,
100
+ computed: true,
101
+ compute: () => new Date(COMPUTED_PROFILE_DATE)
102
+ },
103
+ birthDate: { type: 'date', nullable: true },
104
+ openingTime: { type: 'time', nullable: true, temporalPrecision: 2 },
105
+ observedAtMs: { type: 'epochMilliseconds', nullable: true },
106
+ observedAtSeconds: { type: 'epochSeconds', nullable: true },
83
107
  countryId: {
84
108
  type: 'id',
85
109
  nullable: true,
@@ -126,7 +150,11 @@ describe('Storage mapping', () => {
126
150
  {
127
151
  displayName: 'Mercury',
128
152
  loginCount: 7,
129
- lastSeenAt: '2026-01-02T03:04:05Z',
153
+ lastSeenAt: '2026-01-02T11:04:05+08:00',
154
+ birthDate: '2020-02-29',
155
+ openingTime: '09:30:15.12',
156
+ observedAtMs: '1767323045000',
157
+ observedAtSeconds: '1767323045',
130
158
  externalRef: 'legacy-001',
131
159
  confirmationToken: 'secret-token'
132
160
  },
@@ -142,8 +170,13 @@ describe('Storage mapping', () => {
142
170
  loginCount: 7
143
171
  })
144
172
  assert.equal(createResult.data.attributes.confirmationToken, 'secret-token')
145
- assert.ok(createResult.data.attributes.lastSeenAt instanceof Date)
146
- assert.equal(createResult.data.attributes.lastSeenAt.toISOString(), '2026-01-02T03:04:05.000Z')
173
+ assert.equal(createResult.data.attributes.lastSeenAt, '2026-01-02T03:04:05Z')
174
+ assert.equal(createResult.data.attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
175
+ assert.equal(createResult.data.attributes.calculatedAt, '2026-08-25T23:45:01Z')
176
+ assert.equal(createResult.data.attributes.birthDate, '2020-02-29')
177
+ assert.equal(createResult.data.attributes.openingTime, '09:30:15.12')
178
+ assert.equal(createResult.data.attributes.observedAtMs, 1767323045000)
179
+ assert.equal(createResult.data.attributes.observedAtSeconds, 1767323045)
147
180
  assert.ok(!('display_name' in createResult.data.attributes))
148
181
  assert.ok(!('country_id' in createResult.data.attributes))
149
182
 
@@ -151,6 +184,8 @@ describe('Storage mapping', () => {
151
184
  assert.equal(dbRecord.display_name, 'Mercury')
152
185
  assert.equal(dbRecord.login_count, 7)
153
186
  assert.equal(dbRecord.last_seen_at, '2026-01-02 03:04:05')
187
+ assert.equal(dbRecord.observed_at_ms, 1767323045000)
188
+ assert.equal(dbRecord.observed_at_seconds, 1767323045)
154
189
  assert.equal(String(dbRecord.country_id), country.data.id)
155
190
  assert.equal(dbRecord.legacy_ref, 'legacy-001')
156
191
  assert.ok(!('displayName' in dbRecord))
@@ -169,8 +204,13 @@ describe('Storage mapping', () => {
169
204
  displayName: 'Mercury',
170
205
  loginCount: 7
171
206
  })
172
- assert.ok(getResult.data.attributes.lastSeenAt instanceof Date)
173
- assert.equal(getResult.data.attributes.lastSeenAt.toISOString(), '2026-01-02T03:04:05.000Z')
207
+ assert.equal(getResult.data.attributes.lastSeenAt, '2026-01-02T03:04:05Z')
208
+ assert.equal(getResult.data.attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
209
+ assert.equal(getResult.data.attributes.calculatedAt, '2026-08-25T23:45:01Z')
210
+ assert.equal(getResult.data.attributes.birthDate, '2020-02-29')
211
+ assert.equal(getResult.data.attributes.openingTime, '09:30:15.12')
212
+ assert.equal(getResult.data.attributes.observedAtMs, 1767323045000)
213
+ assert.equal(getResult.data.attributes.observedAtSeconds, 1767323045)
174
214
  assert.ok(!('display_name' in getResult.data.attributes))
175
215
  assertResourceRelationship(
176
216
  getResult.data,
@@ -193,6 +233,8 @@ describe('Storage mapping', () => {
193
233
  displayName: 'Mercury',
194
234
  externalRef: 'legacy-001'
195
235
  })
236
+ assert.equal(queryResult.data[0].attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
237
+ assert.equal(queryResult.data[0].attributes.calculatedAt, '2026-08-25T23:45:01Z')
196
238
  })
197
239
 
198
240
  it('applies storage mapping on PATCH updates and sparse fieldsets', async () => {
@@ -228,8 +270,7 @@ describe('Storage mapping', () => {
228
270
  assert.deepEqual(Object.keys(patchResult.data.attributes).sort(), ['confirmationToken', 'displayName', 'lastSeenAt'])
229
271
  assert.equal(patchResult.data.attributes.displayName, 'After')
230
272
  assert.equal(patchResult.data.attributes.confirmationToken, 'updated-token')
231
- assert.ok(patchResult.data.attributes.lastSeenAt instanceof Date)
232
- assert.equal(patchResult.data.attributes.lastSeenAt.toISOString(), '2026-02-03T04:05:06.000Z')
273
+ assert.equal(patchResult.data.attributes.lastSeenAt, '2026-02-03T04:05:06Z')
233
274
 
234
275
  const dbRecord = await knex('mapped_profiles').where('id', created.data.id).first()
235
276
  assert.equal(dbRecord.display_name, 'After')
@@ -237,6 +278,67 @@ describe('Storage mapping', () => {
237
278
  assert.ok(!('displayName' in dbRecord))
238
279
  })
239
280
 
281
+ it('rejects temporal values outside the public JSON contract before storage', async () => {
282
+ const invalidTemporalValues = [
283
+ { lastSeenAt: '2026-01-02 03:04:05' },
284
+ { lastSeenAt: new Date('2026-01-02T03:04:05Z') },
285
+ { birthDate: '2026-02-30' },
286
+ { openingTime: '09:30Z' }
287
+ ]
288
+
289
+ for (const [index, temporalAttributes] of invalidTemporalValues.entries()) {
290
+ await assert.rejects(
291
+ api.resources.profiles.post({
292
+ inputRecord: createJsonApiDocument('profiles', {
293
+ displayName: `Invalid temporal value ${index}`,
294
+ ...temporalAttributes
295
+ })
296
+ }),
297
+ (error) => error?.code === 'REST_API_VALIDATION'
298
+ )
299
+ }
300
+ })
301
+
302
+ it('fails loudly when storage returns an invalid non-null temporal value', async () => {
303
+ const insertedIds = await knex('mapped_profiles').insert({
304
+ display_name: 'Corrupt temporal row',
305
+ login_count: 0,
306
+ last_seen_at: 'not-a-date',
307
+ created_at: new Date(DEFAULT_PROFILE_CREATED_AT)
308
+ })
309
+ const profileId = insertedIds[0]
310
+
311
+ await assert.rejects(
312
+ api.resources.profiles.get({ id: profileId }),
313
+ (error) => {
314
+ assert(error instanceof RestApiTemporalDataError)
315
+ assert.equal(error.code, REST_API_TEMPORAL_DATA_ERROR_CODE)
316
+ assert.equal(
317
+ error.message,
318
+ "Invalid database value for dateTime field 'profiles.lastSeenAt'."
319
+ )
320
+ assert.deepEqual(error.details, {
321
+ field: 'lastSeenAt',
322
+ resourceType: 'profiles',
323
+ fieldType: 'dateTime',
324
+ source: 'database'
325
+ })
326
+ assert.deepEqual(mapRestApiErrorToHttp(error), {
327
+ status: 500,
328
+ body: {
329
+ errors: [{
330
+ status: '500',
331
+ code: REST_API_TEMPORAL_DATA_ERROR_CODE,
332
+ title: 'Invalid Temporal Data',
333
+ detail: "Invalid database value for dateTime field 'profiles.lastSeenAt'."
334
+ }]
335
+ }
336
+ })
337
+ return true
338
+ }
339
+ )
340
+ })
341
+
240
342
  it('can opt out of snake_case translation for an entire resource', async () => {
241
343
  const created = await api.resources.verbatim_profiles.post({
242
344
  inputRecord: createJsonApiDocument('verbatim_profiles', {
@@ -0,0 +1,208 @@
1
+ import { after, before, beforeEach, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { applyFieldSetters, handleRecordReturnAfterWrite } from '../plugins/core/rest-api-plugin-methods/common.js'
4
+ import knexLib from 'knex'
5
+ import { createTemporalBoundaryApi } from './fixtures/api-configs.js'
6
+ import { cleanTables, createJsonApiDocument } from './helpers/test-utils.js'
7
+ import { mapRestApiErrorToHttp } from '../plugins/core/connectors/lib/transport-http-helpers.js'
8
+ import { normalizeAttributes, normalizeRecordAttributes } from '../plugins/core/lib/querying-writing/database-value-normalizers.js'
9
+ import { storageMode } from './helpers/storage-mode.js'
10
+ import { RestApiTemporalDataError } from '../lib/rest-api-errors.js'
11
+
12
+ const knex = knexLib({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true })
13
+ let api
14
+ let finishValue
15
+ const document = (attributes = {}) => createJsonApiDocument('events', { name: 'Event', ...attributes })
16
+ const assertValidationError = (error) => {
17
+ assert.equal(error.code, 'REST_API_VALIDATION')
18
+ assert.equal(mapRestApiErrorToHttp(error).status, 422)
19
+ return true
20
+ }
21
+
22
+ describe('Temporal storage and response boundaries', () => {
23
+ before(async () => {
24
+ api = await createTemporalBoundaryApi(knex)
25
+ const handler = async ({ context }) => {
26
+ if (finishValue === undefined || context.scopeName !== 'events') return
27
+ const attributes = context.simplified ? context.responseRecord : context.responseRecord?.data?.attributes
28
+ if (attributes) attributes.producedAt = finishValue
29
+ if (context.simplified && attributes?.person) attributes.person.producedAt = finishValue
30
+ for (const included of context.responseRecord?.included || []) included.attributes.producedAt = finishValue
31
+ }
32
+ await api.customize({ hooks: {
33
+ finishPost: { functionName: 'temporal-finish-post', handler },
34
+ finishPut: { functionName: 'temporal-finish-put', handler },
35
+ finishPatch: { functionName: 'temporal-finish-patch', handler }
36
+ } })
37
+ })
38
+ after(async () => { await knex.destroy() })
39
+ beforeEach(async () => {
40
+ finishValue = undefined
41
+ await cleanTables(knex, ['temporal_events', 'temporal_people'])
42
+ })
43
+
44
+ it('rejects submillisecond writes without inserting or changing data', async () => {
45
+ const precise = '2026-09-01T10:20:30.123456Z'
46
+ await assert.rejects(api.resources.events.post({ inputRecord: document({ occurredAt: precise }) }), assertValidationError)
47
+ assert.equal((await api.resources.events.query()).data.length, 0)
48
+ const created = await api.resources.events.post({ inputRecord: document({ occurredAt: '2026-09-01T10:20:30.123000Z' }) })
49
+ for (const method of ['put', 'patch']) {
50
+ const inputRecord = document({ occurredAt: precise })
51
+ inputRecord.data.id = created.data.id
52
+ await assert.rejects(api.resources.events[method]({ id: created.data.id, inputRecord }), assertValidationError)
53
+ const result = await api.resources.events.get({ id: created.data.id })
54
+ assert.equal(result.data.attributes.occurredAt, '2026-09-01T10:20:30.123Z')
55
+ }
56
+ })
57
+
58
+ it('retains submillisecond precision supplied by storage, including timezone offsets', () => {
59
+ for (const value of ['2026-09-01 10:20:30.123456', '2026-09-01T18:20:30.123456+08:00']) {
60
+ const result = normalizeAttributes({ occurredAt: value }, { occurredAt: { type: 'dateTime', temporalPrecision: 6 } })
61
+ assert.equal(result.occurredAt, '2026-09-01T10:20:30.123456Z')
62
+ }
63
+ })
64
+
65
+ it('normalizes numeric epoch values returned as strings or bigints by drivers', () => {
66
+ const schema = { milliseconds: { type: 'epochMilliseconds' }, seconds: { type: 'epochSeconds' } }
67
+ assert.deepEqual(normalizeAttributes({ milliseconds: '1788258030123', seconds: 1788258030n }, schema), {
68
+ milliseconds: 1788258030123, seconds: 1788258030
69
+ })
70
+ for (const value of ['bad', '8640000000000001', 1.25]) {
71
+ assert.throws(() => normalizeAttributes({ milliseconds: value }, schema), { code: 'REST_API_TEMPORAL_DATA_INVALID' })
72
+ }
73
+ })
74
+
75
+ it('normalizes temporal query projections and follows their cursors', async () => {
76
+ for (let index = 0; index < 4; index++) {
77
+ await api.resources.events.post({ inputRecord: document({ occurredAt: `2026-09-01T10:20:3${index}.123Z` }) })
78
+ }
79
+ const queryParams = { sort: ['projectedAt'], fields: { events: 'projectedAt' }, page: { size: 2 } }
80
+ const first = await api.resources.events.query({ queryParams })
81
+ assert.equal(first.data[0].attributes.projectedAt, '2026-09-01T10:20:30.123Z')
82
+ const second = await api.resources.events.query({ queryParams: {
83
+ ...queryParams, page: { size: 2, after: first.meta.pagination.cursor.next }
84
+ } })
85
+ assert.equal(second.data[0].attributes.projectedAt, '2026-09-01T10:20:32.123Z')
86
+ })
87
+
88
+ it('uses a custom serializer consistently for writes and filters', { skip: storageMode.isAnyApi() }, async () => {
89
+ const value = '2026-09-01T10:20:30.123456Z'
90
+ const created = await api.resources.events.post({ inputRecord: document({ serializedAt: value }) })
91
+ assert.equal(created.data.attributes.serializedAt, value)
92
+ const filtered = await api.resources.events.query({ queryParams: { filters: { serializedAt: value } } })
93
+ assert.deepEqual(filtered.data.map(entry => entry.id), [created.data.id])
94
+ })
95
+
96
+ it('validates included fieldsets even when the relationship or collection is empty', async () => {
97
+ const queryParams = { include: ['person'], fields: { people: 'missingField' } }
98
+ await assert.rejects(api.resources.events.query({ queryParams }), { code: 'REST_API_FIELDSET_INVALID' })
99
+ const created = await api.resources.events.post({ inputRecord: document() })
100
+ await assert.rejects(api.resources.events.get({ id: created.data.id, queryParams }), { code: 'REST_API_FIELDSET_INVALID' })
101
+ })
102
+
103
+ it('rejects inherited object names as unknown sparse fields', async () => {
104
+ for (const field of ['constructor', 'toString', '__proto__']) {
105
+ await assert.rejects(api.resources.events.query({ queryParams: { fields: { events: field } } }), {
106
+ code: 'REST_API_FIELDSET_INVALID'
107
+ })
108
+ }
109
+ })
110
+
111
+ it('normalizes nested simplified to-many and polymorphic resources without changing ordinary objects', () => {
112
+ const date = new Date('2026-09-08T11:22:33.987Z')
113
+ const child = { id: 'child', producedAt: date }
114
+ const payload = { id: 'event', person: { id: 'person', events: [child, child] },
115
+ subject: { id: 'person', _type: 'people', producedAt: date },
116
+ arbitrary: { producedAt: date, value: 'unchanged' } }
117
+ const result = normalizeRecordAttributes(payload, api.resources, { simplified: true, resourceType: 'events', source: 'response' })
118
+ assert.equal(result.person.events[0].producedAt, '2026-09-08T11:22:33Z')
119
+ assert.equal(result.person.events[0], result.person.events[1])
120
+ assert.equal(result.subject.producedAt, '2026-09-08T11:22:33Z')
121
+ assert.equal(result.subject._type, 'people')
122
+ assert.equal(result.arbitrary, payload.arbitrary)
123
+ assert.equal(child.producedAt, date)
124
+ })
125
+
126
+ it('preserves typed API errors thrown by computed fields', async () => {
127
+ const created = await api.resources.events.post({ inputRecord: document() })
128
+ const definition = api.resources.events.vars.schemaInfo.computed.producedAt
129
+ const original = definition.compute
130
+ const error = new RestApiTemporalDataError({ field: 'producedAt', resourceType: 'events', fieldType: 'dateTime', source: 'response' })
131
+ definition.compute = () => { throw error }
132
+ try {
133
+ await assert.rejects(api.resources.events.get({ id: created.data.id }), actual => actual === error)
134
+ } finally {
135
+ definition.compute = original
136
+ }
137
+ })
138
+
139
+ it('preserves typed API errors thrown by getters', async () => {
140
+ const created = await api.resources.events.post({ inputRecord: document() })
141
+ const schemaInfo = api.resources.events.vars.schemaInfo
142
+ const originalGetters = schemaInfo.fieldGetters
143
+ const originalOrder = schemaInfo.sortedGetterFields
144
+ const error = new RestApiTemporalDataError({ field: 'name', resourceType: 'events', source: 'response' })
145
+ schemaInfo.fieldGetters = { name: { getter: () => { throw error } } }
146
+ schemaInfo.sortedGetterFields = ['name']
147
+ try {
148
+ await assert.rejects(api.resources.events.get({ id: created.data.id }), actual => actual === error)
149
+ } finally {
150
+ schemaInfo.fieldGetters = originalGetters
151
+ schemaInfo.sortedGetterFields = originalOrder
152
+ }
153
+ })
154
+
155
+ it('preserves typed errors from setters and post-write storage reads', async () => {
156
+ const error = new RestApiTemporalDataError({ field: 'occurredAt', resourceType: 'events' })
157
+ await assert.rejects(applyFieldSetters({ occurredAt: '2026-09-08T00:00:00Z' }, {
158
+ fieldSetters: { occurredAt: { setter: () => { throw error } } },
159
+ sortedSetterFields: ['occurredAt']
160
+ }, { scopeName: 'events' }), actual => actual === error)
161
+ await assert.rejects(handleRecordReturnAfterWrite({
162
+ context: { method: 'POST' }, scopeName: 'events',
163
+ helpers: { dataGetMinimal: async () => { throw error } },
164
+ log: { warn () {} }
165
+ }), actual => actual === error)
166
+ })
167
+
168
+ for (const direction of ['after', 'before']) {
169
+ it(`rejects malformed temporal and numeric values in page[${direction}] as client errors`, async () => {
170
+ for (const [field, value] of [['occurredAt', 'garbage'], ['day', '2026-02-30'], ['atTime', '25:30'], ['observedAtMs', 'NaN']]) {
171
+ await assert.rejects(api.resources.events.query({ queryParams: {
172
+ sort: [field], page: { size: 2, [direction]: `${field}:${encodeURIComponent(value)},id:1` }
173
+ } }), (error) => {
174
+ assertValidationError(error)
175
+ assert.deepEqual(error.details.fields, [`page.${direction}`])
176
+ return true
177
+ })
178
+ }
179
+ })
180
+ }
181
+
182
+ for (const simplified of [false, true]) {
183
+ for (const method of ['post', 'put', 'patch']) {
184
+ it(`normalizes ${method} finish values and included resources with simplified=${simplified}`, async () => {
185
+ const person = await api.resources.people.post({ inputRecord: createJsonApiDocument('people', { name: 'Person' }) })
186
+ const inputRecord = document()
187
+ inputRecord.data.relationships = { person: { data: { type: 'people', id: person.data.id } } }
188
+ const created = method === 'post' ? null : await api.resources.events.post({ inputRecord })
189
+ if (created) inputRecord.data.id = created.data.id
190
+ finishValue = new Date('2026-09-08T11:22:33.987Z')
191
+ const result = await api.resources.events[method]({
192
+ ...(created ? { id: created.data.id } : {}), inputRecord,
193
+ queryParams: { include: ['person'] }, simplified
194
+ })
195
+ assert.equal((simplified ? result : result.data.attributes).producedAt, '2026-09-08T11:22:33Z')
196
+ assert.equal((simplified ? result.person : result.included[0].attributes).producedAt, '2026-09-08T11:22:33Z')
197
+ })
198
+ }
199
+ it(`rejects invalid finish values with simplified=${simplified}`, async () => {
200
+ finishValue = 'not-a-date'
201
+ await assert.rejects(api.resources.events.post({ inputRecord: document(), simplified }), (error) => {
202
+ assert.equal(error.code, 'REST_API_TEMPORAL_DATA_INVALID')
203
+ assert.equal(mapRestApiErrorToHttp(error).status, 500)
204
+ return true
205
+ })
206
+ })
207
+ }
208
+ })