ts-patch-mongoose 2.9.6 → 3.0.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.
@@ -0,0 +1,741 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import mongoose, { model, Schema } from 'mongoose'
4
+ import em from '../src/em'
5
+ import { patchHistoryPlugin } from '../src/index'
6
+ import { HistoryModel } from '../src/model'
7
+ import server from './mongo/server'
8
+
9
+ import type { Types } from 'mongoose'
10
+
11
+ vi.mock('../src/em', () => ({ default: { emit: vi.fn() } }))
12
+
13
+ interface Address {
14
+ street: string
15
+ city: string
16
+ zip: string
17
+ }
18
+
19
+ const AddressSchema = new Schema<Address>(
20
+ {
21
+ street: { type: String, required: true },
22
+ city: { type: String, required: true },
23
+ zip: { type: String, required: true },
24
+ },
25
+ { _id: false, timestamps: false },
26
+ )
27
+
28
+ interface Order {
29
+ item: string
30
+ quantity: number
31
+ tags: string[]
32
+ address: Address
33
+ notes?: string
34
+ priority?: number
35
+ assignedTo?: Types.ObjectId
36
+ createdAt?: Date
37
+ updatedAt?: Date
38
+ }
39
+
40
+ const OrderSchema = new Schema<Order>(
41
+ {
42
+ item: { type: String, required: true },
43
+ quantity: { type: Number, required: true },
44
+ tags: { type: [String], default: undefined },
45
+ address: { type: AddressSchema, required: true },
46
+ notes: { type: String },
47
+ priority: { type: Number, default: 0 },
48
+ assignedTo: { type: Schema.Types.ObjectId, ref: 'User' },
49
+ },
50
+ { timestamps: true },
51
+ )
52
+
53
+ const ORDER_CREATED = 'order-created'
54
+ const ORDER_UPDATED = 'order-updated'
55
+ const ORDER_DELETED = 'order-deleted'
56
+
57
+ const preDeleteDocs: unknown[][] = []
58
+
59
+ OrderSchema.plugin(patchHistoryPlugin, {
60
+ modelName: 'CustomOrder',
61
+ collectionName: 'custom_orders',
62
+ eventCreated: ORDER_CREATED,
63
+ eventUpdated: ORDER_UPDATED,
64
+ eventDeleted: ORDER_DELETED,
65
+ omit: ['__v', 'createdAt', 'updatedAt', 'notes'],
66
+ getUser: () => ({ name: 'test-user', role: 'admin' }),
67
+ getReason: () => 'automated-test',
68
+ getMetadata: () => ({ source: 'test-suite', version: 1 }),
69
+ preDelete: async (docs) => {
70
+ preDeleteDocs.push(docs)
71
+ },
72
+ })
73
+
74
+ const OrderModel = model<Order>('Order', OrderSchema)
75
+
76
+ describe('plugin — all features', () => {
77
+ const instance = server('plugin-all-features')
78
+
79
+ beforeAll(async () => {
80
+ await instance.create()
81
+ })
82
+
83
+ afterAll(async () => {
84
+ await instance.destroy()
85
+ })
86
+
87
+ beforeEach(async () => {
88
+ await mongoose.connection.collection('orders').deleteMany({})
89
+ await mongoose.connection.collection('history').deleteMany({})
90
+ preDeleteDocs.length = 0
91
+ })
92
+
93
+ afterEach(() => {
94
+ vi.resetAllMocks()
95
+ })
96
+
97
+ it('should use custom modelName and collectionName in history', async () => {
98
+ const order = await OrderModel.create({
99
+ item: 'Widget',
100
+ quantity: 10,
101
+ tags: ['electronics'],
102
+ address: { street: '123 Main St', city: 'Springfield', zip: '62701' },
103
+ })
104
+
105
+ const history = await HistoryModel.find({})
106
+ expect(history).toHaveLength(1)
107
+
108
+ const [entry] = history
109
+ expect(entry.modelName).toBe('CustomOrder')
110
+ expect(entry.collectionName).toBe('custom_orders')
111
+ expect(entry.collectionId).toEqual(order._id)
112
+ })
113
+
114
+ it('should store user, reason, and metadata from callbacks', async () => {
115
+ await OrderModel.create({
116
+ item: 'Gadget',
117
+ quantity: 5,
118
+ tags: ['tech'],
119
+ address: { street: '456 Oak Ave', city: 'Portland', zip: '97201' },
120
+ })
121
+
122
+ const history = await HistoryModel.find({})
123
+ expect(history).toHaveLength(1)
124
+
125
+ const [entry] = history
126
+ expect(entry.user).toEqual({ name: 'test-user', role: 'admin' })
127
+ expect(entry.reason).toBe('automated-test')
128
+ expect(entry.metadata).toEqual({ source: 'test-suite', version: 1 })
129
+ })
130
+
131
+ it('should omit specified fields from history doc', async () => {
132
+ await OrderModel.create({
133
+ item: 'Gizmo',
134
+ quantity: 3,
135
+ tags: ['misc'],
136
+ address: { street: '789 Elm Blvd', city: 'Austin', zip: '73301' },
137
+ notes: 'secret internal note',
138
+ })
139
+
140
+ const history = await HistoryModel.find({})
141
+ expect(history).toHaveLength(1)
142
+
143
+ const [entry] = history
144
+ expect(entry.doc).toHaveProperty('item', 'Gizmo')
145
+ expect(entry.doc).toHaveProperty('quantity', 3)
146
+ expect(entry.doc).not.toHaveProperty('notes')
147
+ expect(entry.doc).not.toHaveProperty('createdAt')
148
+ expect(entry.doc).not.toHaveProperty('updatedAt')
149
+ expect(entry.doc).not.toHaveProperty('__v')
150
+ })
151
+
152
+ it('should omit specified fields from update patches', async () => {
153
+ const order = await OrderModel.create({
154
+ item: 'Widget',
155
+ quantity: 10,
156
+ tags: ['electronics'],
157
+ address: { street: '123 Main St', city: 'Springfield', zip: '62701' },
158
+ notes: 'initial note',
159
+ })
160
+
161
+ order.notes = 'updated note'
162
+ await order.save()
163
+
164
+ const history = await HistoryModel.find({})
165
+ expect(history).toHaveLength(1)
166
+ expect(history[0]?.op).toBe('create')
167
+ })
168
+
169
+ it('should track save updates with nested object changes', async () => {
170
+ const order = await OrderModel.create({
171
+ item: 'Widget',
172
+ quantity: 10,
173
+ tags: ['electronics'],
174
+ address: { street: '123 Main St', city: 'Springfield', zip: '62701' },
175
+ })
176
+
177
+ order.address = { street: '456 New St', city: 'Chicago', zip: '60601' }
178
+ order.quantity = 20
179
+ await order.save()
180
+
181
+ const history = await HistoryModel.find({}).sort('version')
182
+ expect(history).toHaveLength(2)
183
+
184
+ const [, update] = history
185
+ expect(update?.op).toBe('update')
186
+ expect(update?.version).toBe(1)
187
+ expect(update?.patch?.length).toBeGreaterThan(0)
188
+
189
+ const paths = update?.patch?.map((p) => p.path)
190
+ expect(paths).toContain('/quantity')
191
+ expect(paths).toContain('/address/street')
192
+ expect(paths).toContain('/address/city')
193
+ expect(paths).toContain('/address/zip')
194
+ })
195
+
196
+ it('should track updateOne with $set and $inc operators', async () => {
197
+ const order = await OrderModel.create({
198
+ item: 'Widget',
199
+ quantity: 10,
200
+ priority: 1,
201
+ tags: ['electronics'],
202
+ address: { street: '123 Main St', city: 'Springfield', zip: '62701' },
203
+ })
204
+
205
+ await OrderModel.updateOne({ _id: order._id }, { $set: { item: 'Super Widget' }, $inc: { priority: 2 } }).exec()
206
+
207
+ const history = await HistoryModel.find({}).sort('version')
208
+ expect(history).toHaveLength(2)
209
+
210
+ const [, update] = history
211
+ expect(update?.op).toBe('updateOne')
212
+
213
+ const paths = update?.patch?.map((p) => p.path)
214
+ expect(paths).toContain('/item')
215
+ })
216
+
217
+ it('should track findOneAndUpdate', async () => {
218
+ const order = await OrderModel.create({
219
+ item: 'Widget',
220
+ quantity: 10,
221
+ tags: ['electronics'],
222
+ address: { street: '123 Main St', city: 'Springfield', zip: '62701' },
223
+ })
224
+
225
+ await OrderModel.findOneAndUpdate({ _id: order._id }, { quantity: 50 }).exec()
226
+
227
+ const history = await HistoryModel.find({}).sort('version')
228
+ expect(history).toHaveLength(2)
229
+
230
+ const [, update] = history
231
+ expect(update?.op).toBe('findOneAndUpdate')
232
+ expect(update?.patch).toMatchObject(expect.arrayContaining([expect.objectContaining({ op: 'replace', path: '/quantity', value: 50 })]))
233
+ })
234
+
235
+ it('should call preDelete and track deleteOne', async () => {
236
+ const order = await OrderModel.create({
237
+ item: 'Widget',
238
+ quantity: 10,
239
+ tags: ['electronics'],
240
+ address: { street: '123 Main St', city: 'Springfield', zip: '62701' },
241
+ })
242
+
243
+ await OrderModel.deleteOne({ _id: order._id }).exec()
244
+
245
+ expect(preDeleteDocs).toHaveLength(1)
246
+ expect(preDeleteDocs[0]).toHaveLength(1)
247
+ expect(preDeleteDocs[0]?.[0]).toHaveProperty('item', 'Widget')
248
+
249
+ const history = await HistoryModel.find({}).sort('version')
250
+ expect(history).toHaveLength(2)
251
+
252
+ const [, deletion] = history
253
+ expect(deletion?.op).toBe('deleteOne')
254
+ expect(deletion?.doc).toHaveProperty('item', 'Widget')
255
+ expect(deletion?.doc).not.toHaveProperty('notes')
256
+ expect(deletion?.doc).not.toHaveProperty('__v')
257
+ })
258
+
259
+ it('should call preDelete and track deleteMany', async () => {
260
+ await OrderModel.create({
261
+ item: 'A',
262
+ quantity: 1,
263
+ tags: ['bulk'],
264
+ address: { street: '1 St', city: 'A', zip: '00001' },
265
+ })
266
+ await OrderModel.create({
267
+ item: 'B',
268
+ quantity: 2,
269
+ tags: ['bulk'],
270
+ address: { street: '2 St', city: 'B', zip: '00002' },
271
+ })
272
+
273
+ await OrderModel.deleteMany({ tags: 'bulk' }).exec()
274
+
275
+ expect(preDeleteDocs).toHaveLength(1)
276
+ expect(preDeleteDocs[0]).toHaveLength(2)
277
+
278
+ const history = await HistoryModel.find({ op: 'deleteMany' })
279
+ expect(history).toHaveLength(2)
280
+
281
+ for (const entry of history) {
282
+ expect(entry.modelName).toBe('CustomOrder')
283
+ expect(entry.user).toEqual({ name: 'test-user', role: 'admin' })
284
+ expect(entry.reason).toBe('automated-test')
285
+ expect(entry.doc).not.toHaveProperty('notes')
286
+ }
287
+ })
288
+
289
+ it('should track insertMany', async () => {
290
+ await OrderModel.insertMany([
291
+ { item: 'X', quantity: 1, tags: ['batch'], address: { street: '1 St', city: 'X', zip: '11111' } },
292
+ { item: 'Y', quantity: 2, tags: ['batch'], address: { street: '2 St', city: 'Y', zip: '22222' } },
293
+ { item: 'Z', quantity: 3, tags: ['batch'], address: { street: '3 St', city: 'Z', zip: '33333' } },
294
+ ])
295
+
296
+ const history = await HistoryModel.find({ op: 'create' }).sort('doc.item')
297
+ expect(history).toHaveLength(3)
298
+
299
+ for (const entry of history) {
300
+ expect(entry.modelName).toBe('CustomOrder')
301
+ expect(entry.collectionName).toBe('custom_orders')
302
+ expect(entry.version).toBe(0)
303
+ expect(entry.user).toEqual({ name: 'test-user', role: 'admin' })
304
+ expect(entry.reason).toBe('automated-test')
305
+ expect(entry.metadata).toEqual({ source: 'test-suite', version: 1 })
306
+ }
307
+ })
308
+
309
+ it('should emit all three event types', async () => {
310
+ const order = await OrderModel.create({
311
+ item: 'EventTest',
312
+ quantity: 1,
313
+ tags: [],
314
+ address: { street: '1 St', city: 'E', zip: '00000' },
315
+ })
316
+
317
+ order.item = 'EventTestUpdated'
318
+ await order.save()
319
+
320
+ await OrderModel.deleteOne({ _id: order._id }).exec()
321
+
322
+ expect(em.emit).toHaveBeenCalledWith(ORDER_CREATED, expect.objectContaining({ doc: expect.any(Object) }))
323
+ expect(em.emit).toHaveBeenCalledWith(
324
+ ORDER_UPDATED,
325
+ expect.objectContaining({
326
+ oldDoc: expect.objectContaining({ item: 'EventTest' }),
327
+ doc: expect.objectContaining({ item: 'EventTestUpdated' }),
328
+ patch: expect.any(Array),
329
+ }),
330
+ )
331
+ expect(em.emit).toHaveBeenCalledWith(ORDER_DELETED, expect.objectContaining({ oldDoc: expect.any(Object) }))
332
+ })
333
+
334
+ it('should handle updateMany across multiple documents', async () => {
335
+ await OrderModel.create({ item: 'A', quantity: 1, tags: ['group'], address: { street: '1', city: 'A', zip: '00001' } })
336
+ await OrderModel.create({ item: 'B', quantity: 2, tags: ['group'], address: { street: '2', city: 'B', zip: '00002' } })
337
+
338
+ await OrderModel.updateMany({ tags: 'group' }, { $set: { quantity: 99 } }).exec()
339
+
340
+ const updates = await HistoryModel.find({ op: 'updateMany' })
341
+ expect(updates).toHaveLength(2)
342
+
343
+ for (const entry of updates) {
344
+ expect(entry.patch).toMatchObject(expect.arrayContaining([expect.objectContaining({ op: 'replace', path: '/quantity', value: 99 })]))
345
+ expect(entry.user).toEqual({ name: 'test-user', role: 'admin' })
346
+ expect(entry.reason).toBe('automated-test')
347
+ }
348
+ })
349
+
350
+ it('should track array field changes', async () => {
351
+ const order = await OrderModel.create({
352
+ item: 'TagTest',
353
+ quantity: 1,
354
+ tags: ['a', 'b'],
355
+ address: { street: '1 St', city: 'T', zip: '00000' },
356
+ })
357
+
358
+ await OrderModel.updateOne({ _id: order._id }, { tags: ['a', 'b', 'c'] }).exec()
359
+
360
+ const updates = await HistoryModel.find({ op: 'updateOne' })
361
+ expect(updates).toHaveLength(1)
362
+
363
+ const paths = updates[0]?.patch?.map((p) => p.path)
364
+ expect(paths?.some((p) => p?.startsWith('/tags'))).toBe(true)
365
+ })
366
+
367
+ it('should handle upsert creating a new document', async () => {
368
+ await OrderModel.findOneAndUpdate({ item: 'UpsertNew' }, { item: 'UpsertNew', quantity: 1, tags: ['upsert'], address: { street: '1 St', city: 'U', zip: '00000' } }, { upsert: true, runValidators: true }).exec()
369
+
370
+ const docs = await OrderModel.find({ item: 'UpsertNew' })
371
+ expect(docs).toHaveLength(1)
372
+
373
+ const history = await HistoryModel.find({})
374
+ expect(history).toHaveLength(1)
375
+ expect(history[0]?.op).toBe('findOneAndUpdate')
376
+ expect(history[0]?.doc).toHaveProperty('item', 'UpsertNew')
377
+ })
378
+
379
+ it('should skip hooks when ignoreHook is set', async () => {
380
+ await OrderModel.create({
381
+ item: 'IgnoreHook',
382
+ quantity: 1,
383
+ tags: [],
384
+ address: { street: '1 St', city: 'I', zip: '00000' },
385
+ })
386
+
387
+ const historyAfterCreate = await HistoryModel.find({})
388
+ expect(historyAfterCreate).toHaveLength(1)
389
+
390
+ await OrderModel.updateOne({ item: 'IgnoreHook' }, { quantity: 99 }).setOptions({ ignoreHook: true }).exec()
391
+
392
+ const historyAfterUpdate = await HistoryModel.find({})
393
+ expect(historyAfterUpdate).toHaveLength(1)
394
+
395
+ await OrderModel.deleteOne({ item: 'IgnoreHook' }).setOptions({ ignoreHook: true }).exec()
396
+
397
+ const historyAfterDelete = await HistoryModel.find({})
398
+ expect(historyAfterDelete).toHaveLength(1)
399
+ })
400
+
401
+ it('should skip events but keep history when ignoreEvent is set', async () => {
402
+ const order = await OrderModel.create({
403
+ item: 'IgnoreEvent',
404
+ quantity: 1,
405
+ tags: [],
406
+ address: { street: '1 St', city: 'E', zip: '00000' },
407
+ })
408
+
409
+ vi.resetAllMocks()
410
+
411
+ await OrderModel.updateOne({ _id: order._id }, { quantity: 50 }).setOptions({ ignoreEvent: true }).exec()
412
+
413
+ const history = await HistoryModel.find({ op: 'updateOne' })
414
+ expect(history).toHaveLength(1)
415
+ expect(history[0]?.patch).toMatchObject(expect.arrayContaining([expect.objectContaining({ op: 'replace', path: '/quantity', value: 50 })]))
416
+
417
+ expect(em.emit).not.toHaveBeenCalledWith(ORDER_UPDATED, expect.anything())
418
+ })
419
+
420
+ it('should skip history but keep events when ignorePatchHistory is set', async () => {
421
+ const order = await OrderModel.create({
422
+ item: 'IgnoreHistory',
423
+ quantity: 1,
424
+ tags: [],
425
+ address: { street: '1 St', city: 'H', zip: '00000' },
426
+ })
427
+
428
+ vi.resetAllMocks()
429
+
430
+ await OrderModel.updateOne({ _id: order._id }, { quantity: 50 }).setOptions({ ignorePatchHistory: true }).exec()
431
+
432
+ const updates = await HistoryModel.find({ op: 'updateOne' })
433
+ expect(updates).toHaveLength(0)
434
+
435
+ expect(em.emit).toHaveBeenCalledWith(ORDER_UPDATED, expect.objectContaining({ patch: expect.any(Array) }))
436
+ })
437
+
438
+ it('should increment version across multiple updates', async () => {
439
+ const order = await OrderModel.create({
440
+ item: 'Versioning',
441
+ quantity: 1,
442
+ tags: [],
443
+ address: { street: '1 St', city: 'V', zip: '00000' },
444
+ })
445
+
446
+ order.quantity = 2
447
+ await order.save()
448
+ order.quantity = 3
449
+ await order.save()
450
+ order.quantity = 4
451
+ await order.save()
452
+
453
+ const history = await HistoryModel.find({ collectionId: order._id }).sort('version')
454
+ expect(history).toHaveLength(4)
455
+ expect(history[0]?.version).toBe(0)
456
+ expect(history[1]?.version).toBe(1)
457
+ expect(history[2]?.version).toBe(2)
458
+ expect(history[3]?.version).toBe(3)
459
+ })
460
+
461
+ it('should track ObjectId reference field changes', async () => {
462
+ const userId = new mongoose.Types.ObjectId()
463
+ const order = await OrderModel.create({
464
+ item: 'RefTest',
465
+ quantity: 1,
466
+ tags: [],
467
+ address: { street: '1 St', city: 'R', zip: '00000' },
468
+ })
469
+
470
+ await OrderModel.updateOne({ _id: order._id }, { assignedTo: userId }).exec()
471
+
472
+ const updates = await HistoryModel.find({ op: 'updateOne' })
473
+ expect(updates).toHaveLength(1)
474
+
475
+ const paths = updates[0]?.patch?.map((p) => p.path)
476
+ expect(paths).toContain('/assignedTo')
477
+ })
478
+
479
+ it('should handle full lifecycle: create → update → update → delete', async () => {
480
+ const order = await OrderModel.create({
481
+ item: 'Lifecycle',
482
+ quantity: 1,
483
+ tags: ['start'],
484
+ address: { street: '1 St', city: 'L', zip: '00000' },
485
+ })
486
+
487
+ order.quantity = 10
488
+ order.tags = ['start', 'updated']
489
+ await order.save()
490
+
491
+ await OrderModel.updateOne({ _id: order._id }, { $set: { item: 'Lifecycle Done' } }).exec()
492
+
493
+ await OrderModel.deleteOne({ _id: order._id }).exec()
494
+
495
+ const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
496
+ expect(history).toHaveLength(4)
497
+
498
+ expect(history[0]?.op).toBe('create')
499
+ expect(history[0]?.version).toBe(0)
500
+
501
+ expect(history[1]?.op).toBe('update')
502
+ expect(history[1]?.version).toBe(1)
503
+ expect(history[1]?.patch?.length).toBeGreaterThan(0)
504
+
505
+ expect(history[2]?.op).toBe('updateOne')
506
+ expect(history[2]?.version).toBe(2)
507
+
508
+ expect(history[3]?.op).toBe('deleteOne')
509
+ expect(history[3]?.version).toBe(0)
510
+ expect(history[3]?.doc).toHaveProperty('item', 'Lifecycle Done')
511
+ expect(history[3]?.doc).not.toHaveProperty('__v')
512
+ expect(history[3]?.doc).not.toHaveProperty('notes')
513
+
514
+ expect(em.emit).toHaveBeenCalledWith(ORDER_CREATED, expect.any(Object))
515
+ expect(em.emit).toHaveBeenCalledWith(ORDER_UPDATED, expect.any(Object))
516
+ expect(em.emit).toHaveBeenCalledWith(ORDER_DELETED, expect.any(Object))
517
+ })
518
+
519
+ it('should handle $push $pull $addToSet operators', async () => {
520
+ const order = await OrderModel.create({
521
+ item: 'ArrayOps',
522
+ quantity: 1,
523
+ tags: ['initial'],
524
+ address: { street: '1 St', city: 'A', zip: '00000' },
525
+ })
526
+
527
+ await OrderModel.updateOne({ _id: order._id }, { $push: { tags: 'pushed' } }).exec()
528
+
529
+ const updates = await HistoryModel.find({ op: 'updateOne' })
530
+ expect(updates).toHaveLength(1)
531
+
532
+ const paths = updates[0]?.patch?.map((p) => p.path)
533
+ expect(paths?.some((p) => p?.startsWith('/tags'))).toBe(true)
534
+ })
535
+
536
+ it('should handle multiple $ operators in one update', async () => {
537
+ const order = await OrderModel.create({
538
+ item: 'MultiOp',
539
+ quantity: 1,
540
+ priority: 0,
541
+ tags: ['start'],
542
+ address: { street: '1 St', city: 'M', zip: '00000' },
543
+ })
544
+
545
+ await OrderModel.updateOne({ _id: order._id }, { $set: { item: 'MultiOpDone' }, $inc: { priority: 5 } }).exec()
546
+
547
+ const updates = await HistoryModel.find({ op: 'updateOne' })
548
+ expect(updates).toHaveLength(1)
549
+
550
+ const paths = updates[0]?.patch?.map((p) => p.path)
551
+ expect(paths).toContain('/item')
552
+ expect(paths).toContain('/priority')
553
+ })
554
+
555
+ it('should produce no patch for no-op update (same values)', async () => {
556
+ const order = await OrderModel.create({
557
+ item: 'NoOp',
558
+ quantity: 1,
559
+ tags: [],
560
+ address: { street: '1 St', city: 'N', zip: '00000' },
561
+ })
562
+
563
+ await OrderModel.updateOne({ _id: order._id }, { item: 'NoOp', quantity: 1 }).exec()
564
+
565
+ const updates = await HistoryModel.find({ op: 'updateOne' })
566
+ expect(updates).toHaveLength(0)
567
+ })
568
+
569
+ it('should track field set to null', async () => {
570
+ const order = await OrderModel.create({
571
+ item: 'NullField',
572
+ quantity: 1,
573
+ priority: 5,
574
+ tags: [],
575
+ address: { street: '1 St', city: 'F', zip: '00000' },
576
+ })
577
+
578
+ await OrderModel.updateOne({ _id: order._id }, { priority: null }).exec()
579
+
580
+ const updates = await HistoryModel.find({ op: 'updateOne' })
581
+ expect(updates).toHaveLength(1)
582
+
583
+ const paths = updates[0]?.patch?.map((p) => p.path)
584
+ expect(paths).toContain('/priority')
585
+ })
586
+
587
+ it('should track findOneAndReplace', async () => {
588
+ const order = await OrderModel.create({
589
+ item: 'ReplaceMe',
590
+ quantity: 1,
591
+ tags: ['old'],
592
+ address: { street: '1 St', city: 'R', zip: '00000' },
593
+ })
594
+
595
+ await OrderModel.findOneAndReplace({ _id: order._id }, { item: 'Replaced', quantity: 99, tags: ['new'], address: { street: '2 St', city: 'R', zip: '11111' } }).exec()
596
+
597
+ const updates = await HistoryModel.find({ op: 'findOneAndReplace' })
598
+ expect(updates).toHaveLength(1)
599
+ expect(updates[0]?.patch?.length).toBeGreaterThan(0)
600
+ })
601
+
602
+ it('should track findByIdAndUpdate', async () => {
603
+ const order = await OrderModel.create({
604
+ item: 'ById',
605
+ quantity: 1,
606
+ tags: [],
607
+ address: { street: '1 St', city: 'B', zip: '00000' },
608
+ })
609
+
610
+ await OrderModel.findByIdAndUpdate(order._id, { quantity: 42 }).exec()
611
+
612
+ const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
613
+ expect(history.length).toBeGreaterThanOrEqual(2)
614
+
615
+ const update = history.find((h) => h.patch && h.patch.length > 0)
616
+ expect(update).toBeDefined()
617
+
618
+ const paths = update?.patch?.map((p) => p.path)
619
+ expect(paths).toContain('/quantity')
620
+ })
621
+
622
+ it('should not crash on update with no matching documents', async () => {
623
+ const fakeId = new mongoose.Types.ObjectId()
624
+ await OrderModel.updateOne({ _id: fakeId }, { quantity: 999 }).exec()
625
+
626
+ const history = await HistoryModel.find({})
627
+ expect(history).toHaveLength(0)
628
+ })
629
+
630
+ it('should not crash on delete with no matching documents', async () => {
631
+ const fakeId = new mongoose.Types.ObjectId()
632
+ await OrderModel.deleteOne({ _id: fakeId }).exec()
633
+
634
+ const history = await HistoryModel.find({})
635
+ expect(history).toHaveLength(0)
636
+ })
637
+
638
+ it('should handle getUser/getReason/getMetadata throwing gracefully', async () => {
639
+ const ThrowSchema = new Schema<Order>(
640
+ {
641
+ item: { type: String, required: true },
642
+ quantity: { type: Number, required: true },
643
+ tags: { type: [String], default: undefined },
644
+ address: { type: AddressSchema, required: true },
645
+ },
646
+ { timestamps: true },
647
+ )
648
+
649
+ ThrowSchema.plugin(patchHistoryPlugin, {
650
+ eventCreated: ORDER_CREATED,
651
+ omit: ['__v', 'createdAt', 'updatedAt'],
652
+ getUser: () => {
653
+ throw new Error('user callback failed')
654
+ },
655
+ getReason: () => {
656
+ throw new Error('reason callback failed')
657
+ },
658
+ getMetadata: () => {
659
+ throw new Error('metadata callback failed')
660
+ },
661
+ })
662
+
663
+ const ThrowModel = model<Order>('ThrowOrder', ThrowSchema)
664
+
665
+ const doc = await ThrowModel.create({
666
+ item: 'ThrowTest',
667
+ quantity: 1,
668
+ tags: [],
669
+ address: { street: '1 St', city: 'T', zip: '00000' },
670
+ })
671
+
672
+ const history = await HistoryModel.find({ collectionId: doc._id })
673
+ expect(history).toHaveLength(1)
674
+ expect(history[0]?.user).toBeUndefined()
675
+ expect(history[0]?.reason).toBeUndefined()
676
+ expect(history[0]?.metadata).toBeUndefined()
677
+ })
678
+
679
+ it('should skip everything when ignoreEvent + ignorePatchHistory', async () => {
680
+ const order = await OrderModel.create({
681
+ item: 'SkipAll',
682
+ quantity: 1,
683
+ tags: [],
684
+ address: { street: '1 St', city: 'S', zip: '00000' },
685
+ })
686
+
687
+ vi.resetAllMocks()
688
+
689
+ await OrderModel.updateOne({ _id: order._id }, { quantity: 50 }).setOptions({ ignoreEvent: true, ignorePatchHistory: true }).exec()
690
+
691
+ const updates = await HistoryModel.find({ op: 'updateOne' })
692
+ expect(updates).toHaveLength(0)
693
+ expect(em.emit).not.toHaveBeenCalled()
694
+ })
695
+
696
+ it('should track deeply nested changes (3+ levels)', async () => {
697
+ const DeepSchema = new Schema(
698
+ {
699
+ name: String,
700
+ config: {
701
+ type: new Schema(
702
+ {
703
+ settings: {
704
+ type: new Schema(
705
+ {
706
+ theme: String,
707
+ notifications: Boolean,
708
+ },
709
+ { _id: false },
710
+ ),
711
+ },
712
+ },
713
+ { _id: false },
714
+ ),
715
+ },
716
+ },
717
+ { timestamps: true },
718
+ )
719
+
720
+ DeepSchema.plugin(patchHistoryPlugin, {
721
+ omit: ['__v', 'createdAt', 'updatedAt'],
722
+ })
723
+
724
+ const DeepModel = model('DeepDoc', DeepSchema)
725
+
726
+ const doc = await DeepModel.create({
727
+ name: 'deep',
728
+ config: { settings: { theme: 'dark', notifications: true } },
729
+ })
730
+
731
+ doc.config = { settings: { theme: 'light', notifications: false } }
732
+ await doc.save()
733
+
734
+ const updates = await HistoryModel.find({ op: 'update', collectionId: doc._id })
735
+ expect(updates).toHaveLength(1)
736
+
737
+ const paths = updates[0]?.patch?.map((p) => p.path)
738
+ expect(paths).toContain('/config/settings/theme')
739
+ expect(paths).toContain('/config/settings/notifications')
740
+ })
741
+ })