ts-patch-mongoose 3.0.0 → 3.1.0

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.
package/src/omit-deep.ts CHANGED
@@ -8,74 +8,33 @@ const isUnsafeKey = (key: string): boolean => {
8
8
  return key === '__proto__' || key === 'constructor' || key === 'prototype'
9
9
  }
10
10
 
11
- const getValue = (obj: Record<string, unknown>, path: string): unknown => {
12
- const segs = path.split('.')
13
- let current: unknown = obj
14
- for (const seg of segs) {
15
- if (current == null || typeof current !== 'object') return undefined
16
- current = (current as Record<string, unknown>)[seg]
17
- }
18
- return current
19
- }
20
-
21
- const hasValue = (val: unknown): boolean => {
22
- if (val == null) return false
23
- if (typeof val === 'boolean' || typeof val === 'number' || typeof val === 'function') return true
24
- if (typeof val === 'string') return val.length !== 0
25
- if (Array.isArray(val)) return val.length !== 0
26
- if (val instanceof RegExp) return val.source !== '(?:)' && val.source !== ''
27
- if (val instanceof Error) return val.message !== ''
28
- if (val instanceof Map || val instanceof Set) return val.size !== 0
29
- if (typeof val === 'object') {
30
- for (const key of Object.keys(val)) {
31
- if (hasValue((val as Record<string, unknown>)[key])) return true
32
- }
33
- return false
34
- }
35
- return true
36
- }
37
-
38
- const has = (obj: unknown, path: string): boolean => {
39
- if (obj != null && typeof obj === 'object' && typeof path === 'string') {
40
- return hasValue(getValue(obj as Record<string, unknown>, path))
41
- }
42
- return false
43
- }
11
+ const classifyKeys = (omitKeys: string[]): { topLevel: Set<string>; nested: Map<string, string[]> } => {
12
+ const topLevel = new Set<string>()
13
+ const nested = new Map<string, string[]>()
44
14
 
45
- const unset = (obj: Record<string, unknown>, prop: string): boolean => {
46
- if (typeof obj !== 'object' || obj === null) return false
47
-
48
- if (Object.hasOwn(obj, prop)) {
49
- delete obj[prop]
50
- return true
51
- }
52
-
53
- if (has(obj, prop)) {
54
- const segs = prop.split('.')
55
- let last = segs.pop()
56
- while (segs.length && segs.at(-1)?.slice(-1) === '\\') {
57
- last = `${(segs.pop() as string).slice(0, -1)}.${last}`
58
- }
59
- let target: unknown = obj
60
- while (segs.length) {
61
- const seg = segs.shift() as string
62
- if (isUnsafeKey(seg)) return false
63
- target = (target as Record<string, unknown>)[seg]
15
+ for (const key of omitKeys) {
16
+ const dotIdx = key.indexOf('.')
17
+ if (dotIdx === -1) {
18
+ topLevel.add(key)
19
+ } else {
20
+ const head = key.slice(0, dotIdx)
21
+ const tail = key.slice(dotIdx + 1)
22
+ if (!isUnsafeKey(head)) {
23
+ const existing = nested.get(head) ?? []
24
+ existing.push(tail)
25
+ nested.set(head, existing)
26
+ }
64
27
  }
65
- return delete (target as Record<string, unknown>)[last ?? '']
66
28
  }
67
29
 
68
- return true
30
+ return { topLevel, nested }
69
31
  }
70
32
 
71
33
  export const omitDeep = <T>(value: T, keys: string | string[]): T => {
72
34
  if (value === undefined) return {} as T
73
35
 
74
36
  if (Array.isArray(value)) {
75
- for (let i = 0; i < value.length; i++) {
76
- value[i] = omitDeep(value[i], keys)
77
- }
78
- return value
37
+ return value.map((item) => omitDeep(item, keys)) as T
79
38
  }
80
39
 
81
40
  if (!isPlainObject(value)) return value
@@ -83,13 +42,15 @@ export const omitDeep = <T>(value: T, keys: string | string[]): T => {
83
42
  const omitKeys = typeof keys === 'string' ? [keys] : keys
84
43
  if (!Array.isArray(omitKeys)) return value
85
44
 
86
- for (const key of omitKeys) {
87
- unset(value, key)
88
- }
45
+ const { topLevel, nested } = classifyKeys(omitKeys)
46
+ const result = {} as Record<string, unknown>
89
47
 
90
48
  for (const key of Object.keys(value)) {
91
- ;(value as Record<string, unknown>)[key] = omitDeep((value as Record<string, unknown>)[key], omitKeys)
49
+ if (topLevel.has(key)) continue
50
+
51
+ const nestedKeys = nested.get(key)
52
+ result[key] = omitDeep((value as Record<string, unknown>)[key], nestedKeys ?? omitKeys)
92
53
  }
93
54
 
94
- return value
55
+ return result as T
95
56
  }
package/src/patch.ts CHANGED
@@ -11,45 +11,33 @@ const isPatchHistoryEnabled = <T>(opts: PluginOptions<T>, context: PatchContext<
11
11
  return !opts.patchHistoryDisabled && !context.ignorePatchHistory
12
12
  }
13
13
 
14
- export const getJsonOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
15
- // NOSONAR structuredClone cannot handle mongoose documents (they contain non-cloneable methods)
16
- const object = JSON.parse(JSON.stringify(doc)) as Partial<T>
14
+ const applyOmit = <T>(object: Partial<T>, opts: PluginOptions<T>): Partial<T> => {
15
+ return opts.omit ? omit(object, opts.omit) : object
16
+ }
17
17
 
18
- if (opts.omit) {
19
- return omit(object, opts.omit)
20
- }
18
+ const replacer = (_key: string, value: unknown): unknown => (typeof value === 'bigint' ? value.toString() : value)
21
19
 
22
- return object
20
+ export const getJsonOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
21
+ // NOSONAR — structuredClone cannot handle mongoose documents (they contain non-cloneable methods)
22
+ return applyOmit(JSON.parse(JSON.stringify(doc, replacer)) as Partial<T>, opts)
23
23
  }
24
24
 
25
25
  export const getObjectOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
26
- if (opts.omit) {
27
- return omit(isFunction(doc?.toObject) ? doc.toObject() : doc, opts.omit)
28
- }
29
-
30
- return doc
26
+ return applyOmit(isFunction(doc?.toObject) ? doc.toObject() : doc, opts)
31
27
  }
32
28
 
33
- export const getUser = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<User | undefined> => {
34
- if (isFunction(opts.getUser)) {
35
- return await opts.getUser(doc)
29
+ const getOptionalField = async <T, R>(fn: ((doc: HydratedDocument<T>) => Promise<R> | R) | undefined, doc?: HydratedDocument<T>): Promise<R | undefined> => {
30
+ if (isFunction(fn)) {
31
+ return await fn(doc as HydratedDocument<T>)
36
32
  }
37
33
  return undefined
38
34
  }
39
35
 
40
- export const getReason = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<string | undefined> => {
41
- if (isFunction(opts.getReason)) {
42
- return await opts.getReason(doc)
43
- }
44
- return undefined
45
- }
36
+ export const getUser = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<User | undefined> => getOptionalField(opts.getUser, doc)
46
37
 
47
- export const getMetadata = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<Metadata | undefined> => {
48
- if (isFunction(opts.getMetadata)) {
49
- return await opts.getMetadata(doc)
50
- }
51
- return undefined
52
- }
38
+ export const getReason = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<string | undefined> => getOptionalField(opts.getReason, doc)
39
+
40
+ export const getMetadata = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<Metadata | undefined> => getOptionalField(opts.getMetadata, doc)
53
41
 
54
42
  export const getValue = <T>(item: PromiseSettledResult<T>): T | undefined => {
55
43
  return item.status === 'fulfilled' ? item.value : undefined
@@ -63,7 +51,11 @@ export const getData = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T
63
51
 
64
52
  export const emitEvent = <T>(context: PatchContext<T>, event: string | undefined, data: PatchEvent<T>): void => {
65
53
  if (event && !context.ignoreEvent) {
66
- em.emit(event, data)
54
+ try {
55
+ em.emit(event, data)
56
+ } catch {
57
+ // Listener errors must not crash patch history recording
58
+ }
67
59
  }
68
60
  }
69
61
 
@@ -80,7 +72,8 @@ export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext
80
72
  const bulk = []
81
73
 
82
74
  for (const doc of batch) {
83
- emitEvent(context, event, { [key]: doc })
75
+ const omitted = getObjectOmit(opts, doc)
76
+ emitEvent(context, event, { [key]: omitted })
84
77
 
85
78
  if (history) {
86
79
  const [user, reason, metadata] = await getData(opts, doc)
@@ -91,7 +84,7 @@ export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext
91
84
  modelName: context.modelName,
92
85
  collectionName: context.collectionName,
93
86
  collectionId: doc._id as Types.ObjectId,
94
- doc: getObjectOmit(opts, doc),
87
+ doc: omitted,
95
88
  version: 0,
96
89
  ...(user !== undefined && { user }),
97
90
  ...(reason !== undefined && { reason }),
@@ -103,8 +96,9 @@ export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext
103
96
  }
104
97
 
105
98
  if (history && !isEmpty(bulk)) {
99
+ const onError = opts.onError ?? console.error
106
100
  await HistoryModel.bulkWrite(bulk, { ordered: false }).catch((error: MongooseError) => {
107
- console.error(error.message)
101
+ onError(error)
108
102
  })
109
103
  }
110
104
  }
@@ -138,6 +132,7 @@ export const updatePatch = async <T>(opts: PluginOptions<T>, context: PatchConte
138
132
  }
139
133
 
140
134
  const [user, reason, metadata] = await getData(opts, current)
135
+ const onError = opts.onError ?? console.error
141
136
  await HistoryModel.create({
142
137
  op: context.op,
143
138
  modelName: context.modelName,
@@ -148,6 +143,8 @@ export const updatePatch = async <T>(opts: PluginOptions<T>, context: PatchConte
148
143
  ...(user !== undefined && { user }),
149
144
  ...(reason !== undefined && { reason }),
150
145
  ...(metadata !== undefined && { metadata }),
146
+ }).catch((error: MongooseError) => {
147
+ onError(error)
151
148
  })
152
149
  }
153
150
  }
package/src/types.ts CHANGED
@@ -49,4 +49,5 @@ export interface PluginOptions<T> {
49
49
  omit?: string[]
50
50
  patchHistoryDisabled?: boolean
51
51
  preDelete?: (docs: HydratedDocument<T>[]) => Promise<void>
52
+ onError?: (error: Error) => void
52
53
  }
package/tests/em.test.ts CHANGED
@@ -1,10 +1,14 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
3
  import { patchEventEmitter } from '../src/index'
4
4
  import { emitEvent } from '../src/patch'
5
5
 
6
6
  describe('em', () => {
7
- it('should subscribe and count', async () => {
7
+ afterEach(() => {
8
+ patchEventEmitter.removeAllListeners()
9
+ })
10
+
11
+ it('should subscribe and count', () => {
8
12
  let count = 0
9
13
  const fn = () => {
10
14
  count++
@@ -17,7 +21,7 @@ describe('em', () => {
17
21
  expect(count).toBe(1)
18
22
  })
19
23
 
20
- it('emitEvent', async () => {
24
+ it('emitEvent', () => {
21
25
  const fn = vi.fn()
22
26
  patchEventEmitter.on('test', fn)
23
27
 
@@ -29,12 +33,10 @@ describe('em', () => {
29
33
 
30
34
  // @ts-expect-error expected
31
35
  emitEvent(context, 'test', { doc: { name: 'test' } })
32
- expect(fn).toHaveBeenCalledTimes(1)
33
-
34
- patchEventEmitter.off('test', fn)
36
+ expect(fn).toHaveBeenCalledOnce()
35
37
  })
36
38
 
37
- it('emitEvent ignore', async () => {
39
+ it('emitEvent ignore', () => {
38
40
  const fn = vi.fn()
39
41
  patchEventEmitter.on('test', fn)
40
42
 
@@ -48,7 +50,21 @@ describe('em', () => {
48
50
  // @ts-expect-error expected
49
51
  emitEvent(context, 'test', { doc: { name: 'test' } })
50
52
  expect(fn).toHaveBeenCalledTimes(0)
53
+ })
51
54
 
52
- patchEventEmitter.off('test', fn)
55
+ it('emitEvent should not throw when listener throws', () => {
56
+ const fn = () => {
57
+ throw new Error('listener error')
58
+ }
59
+ patchEventEmitter.on('throw-test', fn)
60
+
61
+ const context = {
62
+ op: 'test',
63
+ modelName: 'Test',
64
+ collectionName: 'tests',
65
+ }
66
+
67
+ // @ts-expect-error expected
68
+ expect(() => emitEvent(context, 'throw-test', { doc: { name: 'test' } })).not.toThrow()
53
69
  })
54
70
  })
@@ -308,4 +308,66 @@ describe('cloneDeep', () => {
308
308
  expect(cloned.b).toEqual({ c: 2 })
309
309
  expect(cloned.b).not.toBe(original.b)
310
310
  })
311
+
312
+ it('should clone Error instances preserving message and stack', () => {
313
+ const original = new Error('test error')
314
+ const cloned = cloneDeep(original)
315
+ expect(cloned).not.toBe(original)
316
+ expect(cloned).toBeInstanceOf(Error)
317
+ expect(cloned.message).toBe('test error')
318
+ expect(cloned.stack).toBe(original.stack)
319
+ })
320
+
321
+ it('should fall through to cloneCollection for partial BSON-like objects', () => {
322
+ const original = { _bsontype: 'SomeType', value: 42 }
323
+ const cloned = cloneDeep(original)
324
+ expect(cloned).toEqual({ _bsontype: 'SomeType', value: 42 })
325
+ expect(cloned).not.toBe(original)
326
+ })
327
+
328
+ it('should clone BSON-like objects via toHexString', () => {
329
+ const hex = '507f1f77bcf86cd799439011'
330
+ const original = {
331
+ _bsontype: 'ObjectId',
332
+ toHexString: () => hex,
333
+ toJSON: () => hex,
334
+ }
335
+ Object.setPrototypeOf(original, {
336
+ constructor: class ObjectId {
337
+ hex: string
338
+ constructor(h: string) {
339
+ this.hex = h
340
+ }
341
+ toHexString() {
342
+ return this.hex
343
+ }
344
+ },
345
+ })
346
+ const cloned = cloneDeep(original)
347
+ expect(cloned).not.toBe(original)
348
+ expect(cloned.toHexString()).toBe(hex)
349
+ })
350
+ })
351
+
352
+ describe('setPatchHistoryTTL', () => {
353
+ it('should call custom onError handler on failure', async () => {
354
+ const indexes = HistoryModel.collection.indexes as Mock
355
+ indexes.mockRejectedValue(new Error('connection failed'))
356
+
357
+ const onError = vi.fn()
358
+ await setPatchHistoryTTL('1h', onError)
359
+
360
+ expect(onError).toHaveBeenCalledOnce()
361
+ expect(onError).toHaveBeenCalledWith(expect.any(Error))
362
+ })
363
+
364
+ it('should fall back to console.error when no onError provided', async () => {
365
+ const indexes = HistoryModel.collection.indexes as Mock
366
+ indexes.mockRejectedValue(new Error('connection failed'))
367
+
368
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
369
+ await setPatchHistoryTTL('1h')
370
+
371
+ expect(spy).toHaveBeenCalledOnce()
372
+ })
311
373
  })
@@ -109,9 +109,9 @@ describe('omitDeep', () => {
109
109
  expect(omitDeep(obj, ['a', 'c'])).toEqual({ b: 2, d: 4 })
110
110
  })
111
111
 
112
- it('should not omit from dot-notation when value at path has no value', () => {
112
+ it('should omit from dot-notation even when value is empty', () => {
113
113
  const obj = { a: { b: '' } }
114
- expect(omitDeep(obj, ['a.b'])).toEqual({ a: { b: '' } })
114
+ expect(omitDeep(obj, ['a.b'])).toEqual({ a: {} })
115
115
  })
116
116
 
117
117
  it('should omit dot-notation with nested value present', () => {
@@ -181,10 +181,9 @@ describe('omitDeep', () => {
181
181
  expect(omitDeep(obj, ['config.data'])).toEqual({ config: {} })
182
182
  })
183
183
 
184
- it('should not unset empty Map at dot path (hasValue is false)', () => {
184
+ it('should unset empty Map at dot path', () => {
185
185
  const obj = { config: { data: new Map() } } as Record<string, unknown>
186
- const result = omitDeep(obj, ['config.data'])
187
- expect(result.config).toHaveProperty('data')
186
+ expect(omitDeep(obj, ['config.data'])).toEqual({ config: {} })
188
187
  })
189
188
 
190
189
  it('should unset non-empty Set at dot path', () => {
@@ -192,10 +191,9 @@ describe('omitDeep', () => {
192
191
  expect(omitDeep(obj, ['config.data'])).toEqual({ config: {} })
193
192
  })
194
193
 
195
- it('should not unset empty Set at dot path (hasValue is false)', () => {
194
+ it('should unset empty Set at dot path', () => {
196
195
  const obj = { config: { data: new Set() } } as Record<string, unknown>
197
- const result = omitDeep(obj, ['config.data'])
198
- expect(result.config).toHaveProperty('data')
196
+ expect(omitDeep(obj, ['config.data'])).toEqual({ config: {} })
199
197
  })
200
198
 
201
199
  it('should handle null value at dot-notation intermediate', () => {
@@ -213,8 +211,25 @@ describe('omitDeep', () => {
213
211
  expect(omitDeep(obj, ['config.items'])).toEqual({ config: {} })
214
212
  })
215
213
 
216
- it('should not unset empty array at dot path (hasValue is false)', () => {
214
+ it('should unset empty array at dot path', () => {
217
215
  const obj = { config: { items: [] } }
218
- expect(omitDeep(obj, ['config.items'])).toEqual({ config: { items: [] } })
216
+ expect(omitDeep(obj, ['config.items'])).toEqual({ config: {} })
217
+ })
218
+
219
+ it('should not mutate the original object', () => {
220
+ const obj = { a: 1, b: 2, nested: { c: 3, d: 4 } }
221
+ const original = structuredClone(obj)
222
+ omitDeep(obj, ['b', 'nested.c'])
223
+ expect(obj).toEqual(original)
224
+ })
225
+
226
+ it('should not mutate the original array', () => {
227
+ const arr = [
228
+ { a: 1, b: 2 },
229
+ { a: 3, b: 4 },
230
+ ]
231
+ const original = structuredClone(arr)
232
+ omitDeep(arr, ['b'])
233
+ expect(arr).toEqual(original)
219
234
  })
220
235
  })
@@ -1,6 +1,5 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { afterEach } from 'node:test'
4
3
  import mongoose from 'mongoose'
5
4
  import em from '../src/em'
6
5
  import { patchHistoryPlugin } from '../src/index'
@@ -37,8 +36,8 @@ describe('patch tests', () => {
37
36
  await mongoose.connection.collection('patches').deleteMany({})
38
37
  })
39
38
 
40
- afterEach(async () => {
41
- vi.clearAllMocks()
39
+ afterEach(() => {
40
+ vi.resetAllMocks()
42
41
  })
43
42
 
44
43
  describe('getObjects', () => {
@@ -125,6 +124,8 @@ describe('patch tests', () => {
125
124
  it('should return if one object is empty', async () => {
126
125
  const current = await UserModel.create({ name: 'John', role: 'user' })
127
126
 
127
+ vi.clearAllMocks()
128
+
128
129
  const pluginOptions: PluginOptions<User> = {
129
130
  eventDeleted: USER_DELETED,
130
131
  patchHistoryDisabled: true,
@@ -137,7 +138,7 @@ describe('patch tests', () => {
137
138
  }
138
139
 
139
140
  await updatePatch(pluginOptions, context, current, {} as HydratedDocument<User>)
140
- expect(em.emit).toHaveBeenCalled()
141
+ expect(em.emit).not.toHaveBeenCalled()
141
142
  })
142
143
  })
143
144
 
@@ -149,7 +149,7 @@ describe('plugin — all features', () => {
149
149
  expect(entry.doc).not.toHaveProperty('__v')
150
150
  })
151
151
 
152
- it('should omit specified fields from update patches', async () => {
152
+ it('should produce no update history when only omitted fields change', async () => {
153
153
  const order = await OrderModel.create({
154
154
  item: 'Widget',
155
155
  quantity: 10,
@@ -660,6 +660,7 @@ describe('plugin — all features', () => {
660
660
  },
661
661
  })
662
662
 
663
+ if (mongoose.models.ThrowOrder) mongoose.deleteModel('ThrowOrder')
663
664
  const ThrowModel = model<Order>('ThrowOrder', ThrowSchema)
664
665
 
665
666
  const doc = await ThrowModel.create({
@@ -721,6 +722,7 @@ describe('plugin — all features', () => {
721
722
  omit: ['__v', 'createdAt', 'updatedAt'],
722
723
  })
723
724
 
725
+ if (mongoose.models.DeepDoc) mongoose.deleteModel('DeepDoc')
724
726
  const DeepModel = model('DeepDoc', DeepSchema)
725
727
 
726
728
  const doc = await DeepModel.create({
@@ -738,4 +740,105 @@ describe('plugin — all features', () => {
738
740
  expect(paths).toContain('/config/settings/theme')
739
741
  expect(paths).toContain('/config/settings/notifications')
740
742
  })
743
+
744
+ it('should not crash when post-hook runs after ignoreHook skipped pre-hook (update)', async () => {
745
+ const order = await OrderModel.create({
746
+ item: 'GuardTest',
747
+ quantity: 1,
748
+ tags: [],
749
+ address: { street: '1 St', city: 'G', zip: '00000' },
750
+ })
751
+
752
+ await OrderModel.updateOne({ _id: order._id }, { quantity: 5 }).setOptions({ ignoreHook: true }).exec()
753
+
754
+ await OrderModel.updateOne({ _id: order._id }, { quantity: 10 }).exec()
755
+
756
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
757
+ expect(updates).toHaveLength(1)
758
+ expect(updates[0]?.patch).toMatchObject(expect.arrayContaining([expect.objectContaining({ op: 'replace', path: '/quantity' })]))
759
+ })
760
+
761
+ it('should not crash when post-hook runs after ignoreHook skipped pre-hook (delete)', async () => {
762
+ const order = await OrderModel.create({
763
+ item: 'GuardDelete',
764
+ quantity: 1,
765
+ tags: [],
766
+ address: { street: '1 St', city: 'G', zip: '00000' },
767
+ })
768
+
769
+ await OrderModel.deleteOne({ _id: order._id }).setOptions({ ignoreHook: true }).exec()
770
+
771
+ const history = await HistoryModel.find({ collectionId: order._id })
772
+ expect(history).toHaveLength(1)
773
+ expect(history[0]?.op).toBe('create')
774
+ })
775
+
776
+ it('should call onError when bulkWrite fails during create', async () => {
777
+ const onError = vi.fn()
778
+
779
+ const ErrorSchema = new Schema<Order>(
780
+ {
781
+ item: { type: String, required: true },
782
+ quantity: { type: Number, required: true },
783
+ tags: { type: [String], default: undefined },
784
+ address: { type: AddressSchema, required: true },
785
+ },
786
+ { timestamps: true },
787
+ )
788
+
789
+ ErrorSchema.plugin(patchHistoryPlugin, {
790
+ omit: ['__v', 'createdAt', 'updatedAt'],
791
+ onError,
792
+ })
793
+
794
+ if (mongoose.models.ErrorOrder) mongoose.deleteModel('ErrorOrder')
795
+ const ErrorModel = model<Order>('ErrorOrder', ErrorSchema)
796
+
797
+ vi.spyOn(HistoryModel, 'bulkWrite').mockRejectedValueOnce(new Error('bulkWrite failed'))
798
+
799
+ await ErrorModel.create({
800
+ item: 'BulkErr',
801
+ quantity: 1,
802
+ tags: [],
803
+ address: { street: '1 St', city: 'E', zip: '00000' },
804
+ })
805
+
806
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'bulkWrite failed' }))
807
+ })
808
+
809
+ it('should call onError when create fails during update', async () => {
810
+ const onError = vi.fn()
811
+
812
+ const ErrorSchema2 = new Schema<Order>(
813
+ {
814
+ item: { type: String, required: true },
815
+ quantity: { type: Number, required: true },
816
+ tags: { type: [String], default: undefined },
817
+ address: { type: AddressSchema, required: true },
818
+ },
819
+ { timestamps: true },
820
+ )
821
+
822
+ ErrorSchema2.plugin(patchHistoryPlugin, {
823
+ omit: ['__v', 'createdAt', 'updatedAt'],
824
+ onError,
825
+ })
826
+
827
+ if (mongoose.models.ErrorOrder2) mongoose.deleteModel('ErrorOrder2')
828
+ const ErrorModel2 = model<Order>('ErrorOrder2', ErrorSchema2)
829
+
830
+ const doc = await ErrorModel2.create({
831
+ item: 'CreateErr',
832
+ quantity: 1,
833
+ tags: [],
834
+ address: { street: '1 St', city: 'E', zip: '00000' },
835
+ })
836
+
837
+ onError.mockClear()
838
+ vi.spyOn(HistoryModel, 'create').mockRejectedValueOnce(new Error('create failed') as never)
839
+
840
+ await ErrorModel2.updateOne({ _id: doc._id }, { quantity: 99 }).exec()
841
+
842
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'create failed' }))
843
+ })
741
844
  })