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,1332 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import mongoose, { model, Schema } from 'mongoose'
4
+ import { patchHistoryPlugin } from '../src/index'
5
+ import { HistoryModel } from '../src/model'
6
+ import server from './mongo/server'
7
+
8
+ vi.mock('../src/em', () => ({ default: { emit: vi.fn() } }))
9
+
10
+ // --- Realistic e-commerce schema ---
11
+
12
+ const MoneySchema = new Schema({ amount: Number, currency: { type: String, default: 'USD' } }, { _id: false })
13
+
14
+ const LineItemSchema = new Schema(
15
+ {
16
+ productId: { type: Schema.Types.ObjectId, required: true },
17
+ sku: { type: String, required: true },
18
+ name: { type: String, required: true },
19
+ quantity: { type: Number, required: true },
20
+ price: { type: MoneySchema, required: true },
21
+ discount: { type: MoneySchema },
22
+ tags: [String],
23
+ metadata: { type: Schema.Types.Mixed },
24
+ },
25
+ { _id: true },
26
+ )
27
+
28
+ const AddressSchema = new Schema(
29
+ {
30
+ label: String,
31
+ street: String,
32
+ city: String,
33
+ state: String,
34
+ zip: String,
35
+ country: { type: String, default: 'US' },
36
+ coords: { lat: Number, lng: Number },
37
+ },
38
+ { _id: false },
39
+ )
40
+
41
+ const PaymentSchema = new Schema(
42
+ {
43
+ method: { type: String, enum: ['card', 'paypal', 'crypto', 'bank'] },
44
+ last4: String,
45
+ transactionId: String,
46
+ paidAt: Date,
47
+ },
48
+ { _id: false },
49
+ )
50
+
51
+ interface Money {
52
+ amount: number
53
+ currency: string
54
+ }
55
+
56
+ interface LineItem {
57
+ productId: mongoose.Types.ObjectId
58
+ sku: string
59
+ name: string
60
+ quantity: number
61
+ price: Money
62
+ discount?: Money
63
+ tags?: string[]
64
+ metadata?: Record<string, unknown>
65
+ }
66
+
67
+ interface Address {
68
+ label?: string
69
+ street?: string
70
+ city?: string
71
+ state?: string
72
+ zip?: string
73
+ country?: string
74
+ coords?: { lat: number; lng: number }
75
+ }
76
+
77
+ interface Payment {
78
+ method?: string
79
+ last4?: string
80
+ transactionId?: string
81
+ paidAt?: Date
82
+ }
83
+
84
+ interface EcomOrder {
85
+ orderNumber: string
86
+ customerId: mongoose.Types.ObjectId
87
+ status: string
88
+ items: LineItem[]
89
+ shippingAddress: Address
90
+ billingAddress: Address
91
+ payment: Payment
92
+ totals: { subtotal: Money; tax: Money; shipping: Money; total: Money }
93
+ notes: string[]
94
+ internalNotes: string
95
+ assignedTo: mongoose.Types.ObjectId[]
96
+ priority: number
97
+ tags: string[]
98
+ createdAt?: Date
99
+ updatedAt?: Date
100
+ }
101
+
102
+ const EcomOrderSchema = new Schema<EcomOrder>(
103
+ {
104
+ orderNumber: { type: String, required: true, unique: true },
105
+ customerId: { type: Schema.Types.ObjectId, required: true },
106
+ status: { type: String, default: 'pending' },
107
+ items: [LineItemSchema],
108
+ shippingAddress: AddressSchema,
109
+ billingAddress: AddressSchema,
110
+ payment: PaymentSchema,
111
+ totals: { subtotal: MoneySchema, tax: MoneySchema, shipping: MoneySchema, total: MoneySchema },
112
+ notes: [String],
113
+ internalNotes: String,
114
+ assignedTo: [{ type: Schema.Types.ObjectId }],
115
+ priority: { type: Number, default: 0 },
116
+ tags: [String],
117
+ },
118
+ { timestamps: true },
119
+ )
120
+
121
+ EcomOrderSchema.plugin(patchHistoryPlugin, {
122
+ eventCreated: 'order-created',
123
+ eventUpdated: 'order-updated',
124
+ eventDeleted: 'order-deleted',
125
+ omit: ['__v', 'createdAt', 'updatedAt', 'internalNotes'],
126
+ getUser: () => ({ userId: 'admin-123', role: 'admin' }),
127
+ getReason: () => 'system-action',
128
+ getMetadata: () => ({ service: 'order-service', version: '2.0' }),
129
+ })
130
+
131
+ const EcomOrderModel = model<EcomOrder>('EcomOrder', EcomOrderSchema)
132
+
133
+ // --- Stable ObjectIds for cross-test reference ---
134
+
135
+ const productIds = Array.from({ length: 4 }, () => new mongoose.Types.ObjectId())
136
+ const customerId = new mongoose.Types.ObjectId()
137
+ const agentIds = Array.from({ length: 3 }, () => new mongoose.Types.ObjectId())
138
+
139
+ const createOrder = () =>
140
+ EcomOrderModel.create({
141
+ orderNumber: `ORD-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
142
+ customerId,
143
+ status: 'pending',
144
+ items: [
145
+ {
146
+ productId: productIds[0],
147
+ sku: 'WIDGET-001',
148
+ name: 'Premium Widget',
149
+ quantity: 2,
150
+ price: { amount: 29.99, currency: 'USD' },
151
+ discount: { amount: 5, currency: 'USD' },
152
+ tags: ['electronics', 'sale'],
153
+ metadata: { weight: 0.5, dimensions: { w: 10, h: 5, d: 3 } },
154
+ },
155
+ {
156
+ productId: productIds[1],
157
+ sku: 'GADGET-002',
158
+ name: 'Super Gadget',
159
+ quantity: 1,
160
+ price: { amount: 149.99, currency: 'USD' },
161
+ tags: ['electronics'],
162
+ metadata: { weight: 1.2 },
163
+ },
164
+ ],
165
+ shippingAddress: {
166
+ label: 'Home',
167
+ street: '123 Main St',
168
+ city: 'Springfield',
169
+ state: 'IL',
170
+ zip: '62701',
171
+ country: 'US',
172
+ coords: { lat: 39.7817, lng: -89.6501 },
173
+ },
174
+ billingAddress: {
175
+ label: 'Office',
176
+ street: '456 Corp Ave',
177
+ city: 'Chicago',
178
+ state: 'IL',
179
+ zip: '60601',
180
+ country: 'US',
181
+ },
182
+ payment: { method: 'card', last4: '4242' },
183
+ totals: {
184
+ subtotal: { amount: 209.97, currency: 'USD' },
185
+ tax: { amount: 18.9, currency: 'USD' },
186
+ shipping: { amount: 9.99, currency: 'USD' },
187
+ total: { amount: 238.86, currency: 'USD' },
188
+ },
189
+ notes: ['Gift wrap requested', 'Leave at door'],
190
+ internalNotes: 'VIP customer',
191
+ assignedTo: [agentIds[0]],
192
+ priority: 2,
193
+ tags: ['vip', 'express'],
194
+ })
195
+
196
+ const getPatch = (entry: { patch?: { op: string; path: string; value?: unknown }[] } | undefined, path: string) => entry?.patch?.find((p) => p.path === path && p.op === 'replace')
197
+
198
+ describe('plugin — complex data structures', () => {
199
+ const instance = server('plugin-complex-data')
200
+
201
+ beforeAll(async () => {
202
+ await instance.create()
203
+ })
204
+
205
+ afterAll(async () => {
206
+ await instance.destroy()
207
+ })
208
+
209
+ beforeEach(async () => {
210
+ await mongoose.connection.collection('ecomorders').deleteMany({})
211
+ await mongoose.connection.collection('history').deleteMany({})
212
+ })
213
+
214
+ afterEach(() => {
215
+ vi.resetAllMocks()
216
+ })
217
+
218
+ // --- Create ---
219
+
220
+ it('should capture full document structure on create with correct omissions', async () => {
221
+ const order = await createOrder()
222
+ const [entry] = await HistoryModel.find({ collectionId: order._id })
223
+
224
+ expect(entry?.op).toBe('create')
225
+ expect(entry?.version).toBe(0)
226
+
227
+ const doc = entry?.doc as Record<string, unknown>
228
+ expect(doc.orderNumber).toBe(order.orderNumber)
229
+ expect(doc.status).toBe('pending')
230
+ expect(doc.priority).toBe(2)
231
+ expect((doc.items as unknown[]).length).toBe(2)
232
+ expect((doc.notes as string[]).length).toBe(2)
233
+ expect((doc.tags as string[]).length).toBe(2)
234
+ expect(doc.shippingAddress).toHaveProperty('coords')
235
+ expect(doc.payment).toHaveProperty('method', 'card')
236
+ expect(doc.totals).toHaveProperty('total')
237
+
238
+ expect(doc).not.toHaveProperty('internalNotes')
239
+ expect(doc).not.toHaveProperty('__v')
240
+ expect(doc).not.toHaveProperty('createdAt')
241
+ expect(doc).not.toHaveProperty('updatedAt')
242
+
243
+ expect(entry?.user).toEqual({ userId: 'admin-123', role: 'admin' })
244
+ expect(entry?.reason).toBe('system-action')
245
+ expect(entry?.metadata).toEqual({ service: 'order-service', version: '2.0' })
246
+ })
247
+
248
+ // --- Nested subdocument updates ---
249
+
250
+ it('should track address change with deep coords and capture exact patch values', async () => {
251
+ const order = await createOrder()
252
+
253
+ order.shippingAddress = {
254
+ label: 'New Home',
255
+ street: '789 Oak Rd',
256
+ city: 'Portland',
257
+ state: 'OR',
258
+ zip: '97201',
259
+ country: 'US',
260
+ coords: { lat: 45.5152, lng: -122.6784 },
261
+ }
262
+ await order.save()
263
+
264
+ const [update] = await HistoryModel.find({ op: 'update', collectionId: order._id })
265
+ const paths = update?.patch?.map((p) => p.path) ?? []
266
+
267
+ expect(paths).toContain('/shippingAddress/label')
268
+ expect(paths).toContain('/shippingAddress/street')
269
+ expect(paths).toContain('/shippingAddress/city')
270
+ expect(paths).toContain('/shippingAddress/coords/lat')
271
+ expect(paths).toContain('/shippingAddress/coords/lng')
272
+
273
+ expect(getPatch(update, '/shippingAddress/city')?.value).toBe('Portland')
274
+ })
275
+
276
+ it('should track payment completion with transactionId and paidAt date', async () => {
277
+ const order = await createOrder()
278
+ const paidAt = new Date('2026-03-15T10:00:00Z')
279
+
280
+ await EcomOrderModel.updateOne({ _id: order._id }, { $set: { status: 'paid', payment: { method: 'card', last4: '4242', transactionId: 'txn_abc123', paidAt } } }).exec()
281
+
282
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
283
+ const paths = update?.patch?.map((p) => p.path) ?? []
284
+
285
+ expect(paths.some((p) => p?.includes('/status'))).toBe(true)
286
+ expect(paths.some((p) => p?.includes('/payment'))).toBe(true)
287
+ expect(getPatch(update, '/status')?.value).toBe('paid')
288
+ })
289
+
290
+ // --- Array of subdocuments ---
291
+
292
+ it('should track adding a line item via $push with nested Money schema', async () => {
293
+ const order = await createOrder()
294
+
295
+ await EcomOrderModel.updateOne(
296
+ { _id: order._id },
297
+ {
298
+ $push: {
299
+ items: {
300
+ productId: productIds[2],
301
+ sku: 'CABLE-003',
302
+ name: 'USB-C Cable',
303
+ quantity: 3,
304
+ price: { amount: 9.99, currency: 'USD' },
305
+ discount: { amount: 1, currency: 'USD' },
306
+ tags: ['accessories', 'cables'],
307
+ metadata: { color: 'black', length: '2m' },
308
+ },
309
+ },
310
+ },
311
+ ).exec()
312
+
313
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
314
+ expect(update?.patch?.length).toBeGreaterThan(0)
315
+
316
+ const paths = update?.patch?.map((p) => p.path) ?? []
317
+ expect(paths.some((p) => p?.startsWith('/items'))).toBe(true)
318
+ })
319
+
320
+ it('should track removing a line item via save', async () => {
321
+ const order = await createOrder()
322
+ expect(order.items.length).toBe(2)
323
+
324
+ order.items = [order.items[0]]
325
+ await order.save()
326
+
327
+ const [update] = await HistoryModel.find({ op: 'update', collectionId: order._id })
328
+ expect(update?.patch?.length).toBeGreaterThan(0)
329
+
330
+ const paths = update?.patch?.map((p) => p.path) ?? []
331
+ expect(paths.some((p) => p?.startsWith('/items'))).toBe(true)
332
+ })
333
+
334
+ // --- Compound operations ---
335
+
336
+ it('should track $set + $inc + $push in a single update', async () => {
337
+ const order = await createOrder()
338
+
339
+ await EcomOrderModel.updateOne(
340
+ { _id: order._id },
341
+ {
342
+ $set: { status: 'processing' },
343
+ $inc: { priority: 1 },
344
+ $push: { tags: 'rush' },
345
+ },
346
+ ).exec()
347
+
348
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
349
+ expect(update?.patch?.length).toBeGreaterThan(0)
350
+
351
+ const paths = update?.patch?.map((p) => p.path) ?? []
352
+ expect(paths.some((p) => p?.includes('/status'))).toBe(true)
353
+ expect(paths.some((p) => p?.includes('/priority'))).toBe(true)
354
+ expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
355
+ })
356
+
357
+ // --- ObjectId mutations ---
358
+
359
+ it('should track adding and replacing ObjectId refs in assignedTo array', async () => {
360
+ const order = await createOrder()
361
+
362
+ await EcomOrderModel.updateOne({ _id: order._id }, { $push: { assignedTo: agentIds[1] } }).exec()
363
+
364
+ const [push] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
365
+ expect(push?.patch?.some((p) => p.path.startsWith('/assignedTo'))).toBe(true)
366
+
367
+ await EcomOrderModel.updateOne({ _id: order._id }, { assignedTo: [agentIds[2]] }).exec()
368
+
369
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: order._id }).sort('createdAt')
370
+ expect(updates).toHaveLength(2)
371
+ expect(updates[1]?.patch?.some((p) => p.path.startsWith('/assignedTo'))).toBe(true)
372
+ })
373
+
374
+ it('should track changing customerId (ObjectId field replacement)', async () => {
375
+ const order = await createOrder()
376
+ const newCustomerId = new mongoose.Types.ObjectId()
377
+
378
+ await EcomOrderModel.updateOne({ _id: order._id }, { $set: { customerId: newCustomerId } }).exec()
379
+
380
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
381
+ expect(update?.patch?.some((p) => p.path === '/customerId')).toBe(true)
382
+ })
383
+
384
+ // --- Money / totals ---
385
+
386
+ it('should track totals recalculation with exact values', async () => {
387
+ const order = await createOrder()
388
+
389
+ await EcomOrderModel.updateOne(
390
+ { _id: order._id },
391
+ {
392
+ $set: {
393
+ 'totals.tax': { amount: 25.01, currency: 'USD' },
394
+ 'totals.shipping': { amount: 0, currency: 'USD' },
395
+ 'totals.total': { amount: 234.97, currency: 'USD' },
396
+ },
397
+ },
398
+ ).exec()
399
+
400
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
401
+ const paths = update?.patch?.map((p) => p.path) ?? []
402
+
403
+ expect(paths.some((p) => p?.includes('/totals/tax'))).toBe(true)
404
+ expect(paths.some((p) => p?.includes('/totals/shipping'))).toBe(true)
405
+ expect(paths.some((p) => p?.includes('/totals/total'))).toBe(true)
406
+ })
407
+
408
+ // --- Status workflow ---
409
+
410
+ it('should track full order lifecycle with correct versions and ops', async () => {
411
+ const order = await createOrder()
412
+
413
+ for (const status of ['processing', 'shipped', 'delivered']) {
414
+ await EcomOrderModel.updateOne({ _id: order._id }, { $set: { status } }).exec()
415
+ }
416
+
417
+ await EcomOrderModel.deleteOne({ _id: order._id }).exec()
418
+
419
+ const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
420
+ expect(history).toHaveLength(5)
421
+
422
+ expect(history[0]?.op).toBe('create')
423
+ expect(history[0]?.version).toBe(0)
424
+
425
+ expect(history[1]?.version).toBe(1)
426
+ expect(getPatch(history[1], '/status')?.value).toBe('processing')
427
+
428
+ expect(history[2]?.version).toBe(2)
429
+ expect(getPatch(history[2], '/status')?.value).toBe('shipped')
430
+
431
+ expect(history[3]?.version).toBe(3)
432
+ expect(getPatch(history[3], '/status')?.value).toBe('delivered')
433
+
434
+ expect(history[4]?.op).toBe('deleteOne')
435
+ expect(history[4]?.doc).toHaveProperty('status', 'delivered')
436
+ expect(history[4]?.doc).not.toHaveProperty('internalNotes')
437
+ })
438
+
439
+ // --- Omission across lifecycle ---
440
+
441
+ it('should never leak omitted fields in any history entry type', async () => {
442
+ const order = await createOrder()
443
+
444
+ order.internalNotes = 'Escalated to manager'
445
+ order.status = 'processing'
446
+ await order.save()
447
+
448
+ await EcomOrderModel.updateOne({ _id: order._id }, { $set: { internalNotes: 'Resolved', status: 'shipped' } }).exec()
449
+
450
+ await EcomOrderModel.deleteOne({ _id: order._id }).exec()
451
+
452
+ const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
453
+ expect(history.length).toBe(4)
454
+
455
+ for (const entry of history) {
456
+ if (entry.doc) {
457
+ expect(entry.doc).not.toHaveProperty('internalNotes')
458
+ expect(entry.doc).not.toHaveProperty('__v')
459
+ expect(entry.doc).not.toHaveProperty('createdAt')
460
+ expect(entry.doc).not.toHaveProperty('updatedAt')
461
+ }
462
+ if (entry.patch) {
463
+ const paths = entry.patch.map((p) => p.path)
464
+ expect(paths).not.toContain('/internalNotes')
465
+ expect(paths).not.toContain('/__v')
466
+ expect(paths).not.toContain('/createdAt')
467
+ expect(paths).not.toContain('/updatedAt')
468
+ }
469
+ }
470
+ })
471
+
472
+ // --- Bulk ---
473
+
474
+ it('should handle insertMany with varied complex documents', async () => {
475
+ const orders = Array.from({ length: 5 }, (_, i) => ({
476
+ orderNumber: `BULK-${Date.now()}-${i}`,
477
+ customerId,
478
+ status: i % 2 === 0 ? 'pending' : 'processing',
479
+ items: [
480
+ {
481
+ productId: productIds[i % productIds.length],
482
+ sku: `BULK-${i}`,
483
+ name: `Bulk Item ${i}`,
484
+ quantity: i + 1,
485
+ price: { amount: 10.5 * (i + 1), currency: i % 2 === 0 ? 'USD' : 'EUR' },
486
+ tags: [`batch-${i}`],
487
+ },
488
+ ],
489
+ shippingAddress: { street: `${100 + i} Bulk St`, city: 'Bulk City', state: 'BC', zip: `${10000 + i}` },
490
+ totals: {
491
+ subtotal: { amount: 10.5 * (i + 1) },
492
+ tax: { amount: 0.9 * (i + 1) },
493
+ shipping: { amount: 5 },
494
+ total: { amount: 10.5 * (i + 1) + 0.9 * (i + 1) + 5 },
495
+ },
496
+ priority: i,
497
+ }))
498
+
499
+ await EcomOrderModel.insertMany(orders)
500
+
501
+ const history = await HistoryModel.find({ op: 'create' })
502
+ expect(history).toHaveLength(5)
503
+
504
+ for (const entry of history) {
505
+ expect(entry.user).toEqual({ userId: 'admin-123', role: 'admin' })
506
+ expect(entry.reason).toBe('system-action')
507
+ expect(entry.metadata).toEqual({ service: 'order-service', version: '2.0' })
508
+ expect(entry.doc).not.toHaveProperty('internalNotes')
509
+ }
510
+ })
511
+
512
+ // --- updateMany ---
513
+
514
+ it('should track updateMany across multiple complex documents', async () => {
515
+ await createOrder()
516
+ await createOrder()
517
+
518
+ await EcomOrderModel.updateMany({ customerId }, { $set: { priority: 10 }, $push: { tags: 'bulk-updated' } }).exec()
519
+
520
+ const updates = await HistoryModel.find({ op: 'updateMany' })
521
+ expect(updates).toHaveLength(2)
522
+
523
+ for (const entry of updates) {
524
+ expect(entry.patch?.some((p) => p.path === '/priority')).toBe(true)
525
+ expect(entry.patch?.some((p) => p.path.startsWith('/tags'))).toBe(true)
526
+ expect(entry.user).toEqual({ userId: 'admin-123', role: 'admin' })
527
+ }
528
+ })
529
+
530
+ // --- Delete ---
531
+
532
+ it('should preserve complete document snapshot on delete with nested data intact', async () => {
533
+ const order = await createOrder()
534
+
535
+ await EcomOrderModel.deleteOne({ _id: order._id }).exec()
536
+
537
+ const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: order._id })
538
+ const doc = deletion?.doc as Record<string, unknown>
539
+
540
+ expect(doc.orderNumber).toBe(order.orderNumber)
541
+ expect(doc.status).toBe('pending')
542
+ expect((doc.items as unknown[]).length).toBe(2)
543
+ expect(doc.shippingAddress).toHaveProperty('coords')
544
+ expect((doc.shippingAddress as Address).coords?.lat).toBe(39.7817)
545
+ expect(doc.payment).toHaveProperty('last4', '4242')
546
+ expect((doc.totals as Record<string, Money>).total.amount).toBe(238.86)
547
+ expect((doc.assignedTo as string[]).length).toBe(1)
548
+ expect(doc.tags as string[]).toEqual(expect.arrayContaining(['vip', 'express']))
549
+
550
+ expect(doc).not.toHaveProperty('internalNotes')
551
+ expect(doc).not.toHaveProperty('__v')
552
+ })
553
+ })
554
+
555
+ // --- All mongoose schema types ---
556
+
557
+ const AllTypesSchema = new Schema(
558
+ {
559
+ str: String,
560
+ num: Number,
561
+ bool: Boolean,
562
+ date: Date,
563
+ objectId: Schema.Types.ObjectId,
564
+ decimal: Schema.Types.Decimal128,
565
+ uuid: Schema.Types.UUID,
566
+ buf: Buffer,
567
+ mixed: Schema.Types.Mixed,
568
+ nested: { deep: { value: String } },
569
+ map: { type: Map, of: String },
570
+ arrStr: [String],
571
+ arrNum: [Number],
572
+ arrObjectId: [Schema.Types.ObjectId],
573
+ arrNested: [new Schema({ label: String, score: Number }, { _id: false })],
574
+ },
575
+ { timestamps: true },
576
+ )
577
+
578
+ AllTypesSchema.plugin(patchHistoryPlugin, {
579
+ omit: ['__v', 'createdAt', 'updatedAt'],
580
+ })
581
+
582
+ const AllTypesModel = model('AllTypes', AllTypesSchema)
583
+
584
+ describe('plugin — all mongoose schema types', () => {
585
+ const instance = server('plugin-all-types')
586
+
587
+ beforeAll(async () => {
588
+ await instance.create()
589
+ })
590
+
591
+ afterAll(async () => {
592
+ await instance.destroy()
593
+ })
594
+
595
+ beforeEach(async () => {
596
+ await mongoose.connection.collection('alltypes').deleteMany({})
597
+ await mongoose.connection.collection('history').deleteMany({})
598
+ })
599
+
600
+ afterEach(() => {
601
+ vi.resetAllMocks()
602
+ })
603
+
604
+ it('should create and track a document with every schema type', async () => {
605
+ const refId = new mongoose.Types.ObjectId()
606
+ const doc = await AllTypesModel.create({
607
+ str: 'hello',
608
+ num: 42,
609
+ bool: true,
610
+ date: new Date('2026-01-15'),
611
+ objectId: refId,
612
+ decimal: mongoose.Types.Decimal128.fromString('99.99'),
613
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
614
+ buf: Buffer.from('binary data'),
615
+ mixed: { anything: [1, 'two', { three: true }] },
616
+ nested: { deep: { value: 'found it' } },
617
+ map: new Map([
618
+ ['key1', 'val1'],
619
+ ['key2', 'val2'],
620
+ ]),
621
+ arrStr: ['a', 'b', 'c'],
622
+ arrNum: [1, 2, 3],
623
+ arrObjectId: [new mongoose.Types.ObjectId(), new mongoose.Types.ObjectId()],
624
+ arrNested: [
625
+ { label: 'first', score: 10 },
626
+ { label: 'second', score: 20 },
627
+ ],
628
+ })
629
+
630
+ const [entry] = await HistoryModel.find({ collectionId: doc._id })
631
+ expect(entry?.op).toBe('create')
632
+
633
+ const saved = entry?.doc as Record<string, unknown>
634
+ expect(saved.str).toBe('hello')
635
+ expect(saved.num).toBe(42)
636
+ expect(saved.bool).toBe(true)
637
+ expect(saved.date).toBeDefined()
638
+ expect(saved.objectId).toBeDefined()
639
+ expect(saved.decimal).toBeDefined()
640
+ expect(saved.uuid).toBeDefined()
641
+ expect(saved.buf).toBeDefined()
642
+ expect(saved.mixed).toHaveProperty('anything')
643
+ expect(saved.nested).toEqual({ deep: { value: 'found it' } })
644
+ expect(saved.map).toBeDefined()
645
+ expect(saved.arrStr).toEqual(['a', 'b', 'c'])
646
+ expect(saved.arrNum).toEqual([1, 2, 3])
647
+ expect((saved.arrObjectId as unknown[]).length).toBe(2)
648
+ expect((saved.arrNested as unknown[]).length).toBe(2)
649
+ })
650
+
651
+ it('should track String update', async () => {
652
+ const doc = await AllTypesModel.create({ str: 'before' })
653
+ await AllTypesModel.updateOne({ _id: doc._id }, { str: 'after' }).exec()
654
+
655
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
656
+ expect(getPatch(update, '/str')?.value).toBe('after')
657
+ })
658
+
659
+ it('should track Number update', async () => {
660
+ const doc = await AllTypesModel.create({ num: 1 })
661
+ await AllTypesModel.updateOne({ _id: doc._id }, { num: 999 }).exec()
662
+
663
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
664
+ expect(getPatch(update, '/num')?.value).toBe(999)
665
+ })
666
+
667
+ it('should track Boolean toggle', async () => {
668
+ const doc = await AllTypesModel.create({ bool: false })
669
+ await AllTypesModel.updateOne({ _id: doc._id }, { bool: true }).exec()
670
+
671
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
672
+ expect(getPatch(update, '/bool')?.value).toBe(true)
673
+ })
674
+
675
+ it('should track Date update', async () => {
676
+ const doc = await AllTypesModel.create({ date: new Date('2025-01-01') })
677
+ await AllTypesModel.updateOne({ _id: doc._id }, { date: new Date('2026-06-15') }).exec()
678
+
679
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
680
+ const paths = update?.patch?.map((p) => p.path) ?? []
681
+ expect(paths).toContain('/date')
682
+ })
683
+
684
+ it('should track ObjectId reference change', async () => {
685
+ const id1 = new mongoose.Types.ObjectId()
686
+ const id2 = new mongoose.Types.ObjectId()
687
+ const doc = await AllTypesModel.create({ objectId: id1 })
688
+ await AllTypesModel.updateOne({ _id: doc._id }, { objectId: id2 }).exec()
689
+
690
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
691
+ expect(getPatch(update, '/objectId')).toBeDefined()
692
+ })
693
+
694
+ it('should track Decimal128 update', async () => {
695
+ const doc = await AllTypesModel.create({ decimal: mongoose.Types.Decimal128.fromString('10.00') })
696
+ await AllTypesModel.updateOne({ _id: doc._id }, { decimal: mongoose.Types.Decimal128.fromString('99.95') }).exec()
697
+
698
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
699
+ const paths = update?.patch?.map((p) => p.path) ?? []
700
+ expect(paths.some((p) => p?.startsWith('/decimal'))).toBe(true)
701
+ })
702
+
703
+ it('should track UUID update', async () => {
704
+ const doc = await AllTypesModel.create({ uuid: '550e8400-e29b-41d4-a716-446655440000' })
705
+ await AllTypesModel.updateOne({ _id: doc._id }, { uuid: '6ba7b810-9dad-11d1-80b4-00c04fd430c8' }).exec()
706
+
707
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
708
+ expect(getPatch(update, '/uuid')).toBeDefined()
709
+ })
710
+
711
+ it('should track Buffer update', async () => {
712
+ const doc = await AllTypesModel.create({ buf: Buffer.from('old') })
713
+ await AllTypesModel.updateOne({ _id: doc._id }, { buf: Buffer.from('new') }).exec()
714
+
715
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
716
+ expect(getPatch(update, '/buf')).toBeDefined()
717
+ })
718
+
719
+ it('should track Mixed type update (arbitrary object)', async () => {
720
+ const doc = await AllTypesModel.create({ mixed: { version: 1, data: [1, 2] } })
721
+ await AllTypesModel.updateOne({ _id: doc._id }, { mixed: { version: 2, data: [1, 2, 3], extra: 'new' } }).exec()
722
+
723
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
724
+ const paths = update?.patch?.map((p) => p.path) ?? []
725
+ expect(paths.some((p) => p?.startsWith('/mixed'))).toBe(true)
726
+ })
727
+
728
+ it('should track deeply nested field update', async () => {
729
+ const doc = await AllTypesModel.create({ nested: { deep: { value: 'old' } } })
730
+ doc.nested = { deep: { value: 'new' } }
731
+ await doc.save()
732
+
733
+ const [update] = await HistoryModel.find({ op: 'update', collectionId: doc._id })
734
+ expect(getPatch(update, '/nested/deep/value')?.value).toBe('new')
735
+ })
736
+
737
+ it('should track Map field update via save', async () => {
738
+ const doc = await AllTypesModel.create({ map: new Map([['a', '1']]) })
739
+ doc.map = new Map([
740
+ ['a', '1'],
741
+ ['b', '2'],
742
+ ])
743
+ await doc.save()
744
+
745
+ const [update] = await HistoryModel.find({ op: 'update', collectionId: doc._id })
746
+ const paths = update?.patch?.map((p) => p.path) ?? []
747
+ expect(paths.some((p) => p?.startsWith('/map'))).toBe(true)
748
+ })
749
+
750
+ it('should track array of strings mutation', async () => {
751
+ const doc = await AllTypesModel.create({ arrStr: ['x', 'y'] })
752
+ await AllTypesModel.updateOne({ _id: doc._id }, { arrStr: ['x', 'y', 'z'] }).exec()
753
+
754
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
755
+ const paths = update?.patch?.map((p) => p.path) ?? []
756
+ expect(paths.some((p) => p?.startsWith('/arrStr'))).toBe(true)
757
+ })
758
+
759
+ it('should track array of nested objects mutation', async () => {
760
+ const doc = await AllTypesModel.create({ arrNested: [{ label: 'a', score: 1 }] })
761
+ await AllTypesModel.updateOne(
762
+ { _id: doc._id },
763
+ {
764
+ arrNested: [
765
+ { label: 'a', score: 1 },
766
+ { label: 'b', score: 2 },
767
+ ],
768
+ },
769
+ ).exec()
770
+
771
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
772
+ const paths = update?.patch?.map((p) => p.path) ?? []
773
+ expect(paths.some((p) => p?.startsWith('/arrNested'))).toBe(true)
774
+ })
775
+
776
+ it('should track setting a field from undefined to a value', async () => {
777
+ const doc = await AllTypesModel.create({ str: 'exists' })
778
+ await AllTypesModel.updateOne({ _id: doc._id }, { num: 42, bool: true, date: new Date() }).exec()
779
+
780
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
781
+ const paths = update?.patch?.map((p) => p.path) ?? []
782
+ expect(paths).toContain('/num')
783
+ expect(paths).toContain('/bool')
784
+ expect(paths).toContain('/date')
785
+ })
786
+
787
+ it('should track setting a field to null', async () => {
788
+ const doc = await AllTypesModel.create({ str: 'hello', num: 42 })
789
+ await AllTypesModel.updateOne({ _id: doc._id }, { str: null, num: null }).exec()
790
+
791
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
792
+ const paths = update?.patch?.map((p) => p.path) ?? []
793
+ expect(paths).toContain('/str')
794
+ expect(paths).toContain('/num')
795
+ })
796
+ })
797
+
798
+ // --- Populated documents ---
799
+
800
+ const AuthorSchema = new Schema({ name: String, email: String }, { timestamps: true })
801
+ AuthorSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
802
+ const AuthorModel = model('Author', AuthorSchema)
803
+
804
+ const ArticleSchema = new Schema(
805
+ {
806
+ title: String,
807
+ body: String,
808
+ author: { type: Schema.Types.ObjectId, ref: 'Author' },
809
+ reviewers: [{ type: Schema.Types.ObjectId, ref: 'Author' }],
810
+ },
811
+ { timestamps: true },
812
+ )
813
+ ArticleSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
814
+ const ArticleModel = model('Article', ArticleSchema)
815
+
816
+ describe('plugin — populated documents', () => {
817
+ const instance = server('plugin-populated')
818
+
819
+ beforeAll(async () => {
820
+ await instance.create()
821
+ })
822
+
823
+ afterAll(async () => {
824
+ await instance.destroy()
825
+ })
826
+
827
+ beforeEach(async () => {
828
+ await mongoose.connection.collection('authors').deleteMany({})
829
+ await mongoose.connection.collection('articles').deleteMany({})
830
+ await mongoose.connection.collection('history').deleteMany({})
831
+ })
832
+
833
+ afterEach(() => {
834
+ vi.resetAllMocks()
835
+ })
836
+
837
+ it('should store ObjectId refs not populated objects in history', async () => {
838
+ const author = await AuthorModel.create({ name: 'Jane', email: 'jane@example.com' })
839
+ const article = await ArticleModel.create({ title: 'Test', body: 'Content', author: author._id })
840
+
841
+ const [entry] = await HistoryModel.find({ collectionId: article._id })
842
+ const doc = entry?.doc as Record<string, unknown>
843
+
844
+ expect(doc.author).toBeDefined()
845
+ expect(JSON.stringify(doc.author)).toContain(author._id.toString())
846
+ })
847
+
848
+ it('should track author ref change as ObjectId diff', async () => {
849
+ const author1 = await AuthorModel.create({ name: 'Jane', email: 'jane@example.com' })
850
+ const author2 = await AuthorModel.create({ name: 'John', email: 'john@example.com' })
851
+ const article = await ArticleModel.create({ title: 'Test', body: 'Content', author: author1._id })
852
+
853
+ await ArticleModel.updateOne({ _id: article._id }, { author: author2._id }).exec()
854
+
855
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: article._id })
856
+ expect(updates).toHaveLength(1)
857
+
858
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
859
+ expect(paths).toContain('/author')
860
+ })
861
+
862
+ it('should track changes to populated array refs', async () => {
863
+ const reviewer1 = await AuthorModel.create({ name: 'R1', email: 'r1@example.com' })
864
+ const reviewer2 = await AuthorModel.create({ name: 'R2', email: 'r2@example.com' })
865
+ const article = await ArticleModel.create({ title: 'Reviewed', body: 'Content', reviewers: [reviewer1._id] })
866
+
867
+ await ArticleModel.updateOne({ _id: article._id }, { $push: { reviewers: reviewer2._id } }).exec()
868
+
869
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: article._id })
870
+ expect(updates).toHaveLength(1)
871
+
872
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
873
+ expect(paths.some((p) => p?.startsWith('/reviewers'))).toBe(true)
874
+ })
875
+ })
876
+
877
+ // --- Discriminators ---
878
+
879
+ const BaseEventSchema = new Schema({ timestamp: { type: Date, default: Date.now }, source: String }, { timestamps: true, discriminatorKey: 'kind' })
880
+ BaseEventSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
881
+ const BaseEventModel = model('BaseEvent', BaseEventSchema)
882
+
883
+ const ClickEventModel = BaseEventModel.discriminator('ClickEvent', new Schema({ url: String, buttonId: String }))
884
+ const SignupEventModel = BaseEventModel.discriminator('SignupEvent', new Schema({ username: String, plan: String }))
885
+
886
+ describe('plugin — discriminators', () => {
887
+ const instance = server('plugin-discriminators')
888
+
889
+ beforeAll(async () => {
890
+ await instance.create()
891
+ })
892
+
893
+ afterAll(async () => {
894
+ await instance.destroy()
895
+ })
896
+
897
+ beforeEach(async () => {
898
+ await mongoose.connection.collection('baseevents').deleteMany({})
899
+ await mongoose.connection.collection('history').deleteMany({})
900
+ })
901
+
902
+ afterEach(() => {
903
+ vi.resetAllMocks()
904
+ })
905
+
906
+ it('should create history for discriminator with type-specific fields', async () => {
907
+ const click = await ClickEventModel.create({ source: 'web', url: 'https://example.com', buttonId: 'cta-1' })
908
+
909
+ const [entry] = await HistoryModel.find({ collectionId: click._id })
910
+ const doc = entry?.doc as Record<string, unknown>
911
+
912
+ expect(doc.kind).toBe('ClickEvent')
913
+ expect(doc.url).toBe('https://example.com')
914
+ expect(doc.buttonId).toBe('cta-1')
915
+ expect(doc.source).toBe('web')
916
+ })
917
+
918
+ it('should track updates to discriminator-specific fields', async () => {
919
+ const click = await ClickEventModel.create({ source: 'web', url: 'https://old.com', buttonId: 'btn-1' })
920
+
921
+ await ClickEventModel.updateOne({ _id: click._id }, { url: 'https://new.com' }).exec()
922
+
923
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: click._id })
924
+ expect(updates).toHaveLength(1)
925
+
926
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
927
+ expect(paths).toContain('/url')
928
+ })
929
+
930
+ it('should track different discriminator types independently', async () => {
931
+ const click = await ClickEventModel.create({ source: 'web', url: 'https://example.com' })
932
+ const signup = await SignupEventModel.create({ source: 'app', username: 'newuser', plan: 'free' })
933
+
934
+ await SignupEventModel.updateOne({ _id: signup._id }, { plan: 'pro' }).exec()
935
+
936
+ const clickHistory = await HistoryModel.find({ collectionId: click._id })
937
+ const signupHistory = await HistoryModel.find({ collectionId: signup._id }).sort('createdAt')
938
+
939
+ expect(clickHistory).toHaveLength(1)
940
+ expect(clickHistory[0]?.op).toBe('create')
941
+
942
+ expect(signupHistory).toHaveLength(2)
943
+ expect(signupHistory[1]?.patch?.some((p) => p.path === '/plan')).toBe(true)
944
+ })
945
+
946
+ it('should delete discriminator and preserve type in history', async () => {
947
+ const signup = await SignupEventModel.create({ source: 'app', username: 'todelete', plan: 'trial' })
948
+
949
+ await SignupEventModel.deleteOne({ _id: signup._id }).exec()
950
+
951
+ const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: signup._id })
952
+ const doc = deletion?.doc as Record<string, unknown>
953
+
954
+ expect(doc.kind).toBe('SignupEvent')
955
+ expect(doc.username).toBe('todelete')
956
+ expect(doc.plan).toBe('trial')
957
+ })
958
+ })
959
+
960
+ // --- Subdocument manipulation ---
961
+
962
+ const CommentSchema = new Schema({ text: String, rating: Number }, { timestamps: false })
963
+
964
+ const PostSchema = new Schema(
965
+ {
966
+ title: String,
967
+ comments: [CommentSchema],
968
+ featured: { type: CommentSchema, default: undefined },
969
+ },
970
+ { timestamps: true },
971
+ )
972
+ PostSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
973
+ const PostModel = model('Post', PostSchema)
974
+
975
+ describe('plugin — subdocument manipulation', () => {
976
+ const instance = server('plugin-subdocs')
977
+
978
+ beforeAll(async () => {
979
+ await instance.create()
980
+ })
981
+
982
+ afterAll(async () => {
983
+ await instance.destroy()
984
+ })
985
+
986
+ beforeEach(async () => {
987
+ await mongoose.connection.collection('posts').deleteMany({})
988
+ await mongoose.connection.collection('history').deleteMany({})
989
+ })
990
+
991
+ afterEach(() => {
992
+ vi.resetAllMocks()
993
+ })
994
+
995
+ it('should track pushing subdoc via mongoose array push then save', async () => {
996
+ const post = await PostModel.create({ title: 'Hello', comments: [{ text: 'First', rating: 5 }] })
997
+
998
+ post.comments.push({ text: 'Second', rating: 3 } as never)
999
+ await post.save()
1000
+
1001
+ const updates = await HistoryModel.find({ op: 'update', collectionId: post._id })
1002
+ expect(updates).toHaveLength(1)
1003
+
1004
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1005
+ expect(paths.some((p) => p?.startsWith('/comments'))).toBe(true)
1006
+ })
1007
+
1008
+ it('should track removing subdoc from array then save', async () => {
1009
+ const post = await PostModel.create({
1010
+ title: 'Hello',
1011
+ comments: [
1012
+ { text: 'A', rating: 1 },
1013
+ { text: 'B', rating: 2 },
1014
+ ],
1015
+ })
1016
+
1017
+ post.comments.splice(0, 1)
1018
+ await post.save()
1019
+
1020
+ const updates = await HistoryModel.find({ op: 'update', collectionId: post._id })
1021
+ expect(updates).toHaveLength(1)
1022
+
1023
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1024
+ expect(paths.some((p) => p?.startsWith('/comments'))).toBe(true)
1025
+ })
1026
+
1027
+ it('should track modifying a subdoc field then saving parent', async () => {
1028
+ const post = await PostModel.create({ title: 'Hello', comments: [{ text: 'Original', rating: 5 }] })
1029
+
1030
+ post.comments[0].text = 'Edited'
1031
+ await post.save()
1032
+
1033
+ const updates = await HistoryModel.find({ op: 'update', collectionId: post._id })
1034
+ expect(updates).toHaveLength(1)
1035
+
1036
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1037
+ expect(paths.some((p) => p?.includes('/comments') && p?.includes('/text'))).toBe(true)
1038
+ })
1039
+
1040
+ it('should track setting single nested subdoc', async () => {
1041
+ const post = await PostModel.create({ title: 'Hello', comments: [] })
1042
+
1043
+ post.featured = { text: 'Featured comment', rating: 10 } as never
1044
+ await post.save()
1045
+
1046
+ const updates = await HistoryModel.find({ op: 'update', collectionId: post._id })
1047
+ expect(updates).toHaveLength(1)
1048
+
1049
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1050
+ expect(paths.some((p) => p?.startsWith('/featured'))).toBe(true)
1051
+ })
1052
+ })
1053
+
1054
+ // --- Virtuals, getters, validation ---
1055
+
1056
+ const ProfileSchema = new Schema(
1057
+ {
1058
+ firstName: String,
1059
+ lastName: String,
1060
+ email: {
1061
+ type: String,
1062
+ get: (v: string) => v?.toLowerCase(),
1063
+ required: true,
1064
+ },
1065
+ age: { type: Number, min: 0, max: 150 },
1066
+ },
1067
+ { timestamps: true, toJSON: { getters: true }, toObject: { getters: false } },
1068
+ )
1069
+
1070
+ ProfileSchema.virtual('fullName').get(function () {
1071
+ return `${this.firstName} ${this.lastName}`
1072
+ })
1073
+
1074
+ ProfileSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
1075
+ const ProfileModel = model('Profile', ProfileSchema)
1076
+
1077
+ describe('plugin — virtuals, getters, validation', () => {
1078
+ const instance = server('plugin-virtuals')
1079
+
1080
+ beforeAll(async () => {
1081
+ await instance.create()
1082
+ })
1083
+
1084
+ afterAll(async () => {
1085
+ await instance.destroy()
1086
+ })
1087
+
1088
+ beforeEach(async () => {
1089
+ await mongoose.connection.collection('profiles').deleteMany({})
1090
+ await mongoose.connection.collection('history').deleteMany({})
1091
+ })
1092
+
1093
+ afterEach(() => {
1094
+ vi.resetAllMocks()
1095
+ })
1096
+
1097
+ it('should NOT include virtual fields in history', async () => {
1098
+ const profile = await ProfileModel.create({ firstName: 'Jane', lastName: 'Doe', email: 'Jane@Example.COM' })
1099
+
1100
+ const [entry] = await HistoryModel.find({ collectionId: profile._id })
1101
+ const doc = entry?.doc as Record<string, unknown>
1102
+
1103
+ expect(doc).not.toHaveProperty('fullName')
1104
+ expect(doc.firstName).toBe('Jane')
1105
+ expect(doc.lastName).toBe('Doe')
1106
+ })
1107
+
1108
+ it('should store raw email value not getter-transformed in history', async () => {
1109
+ const profile = await ProfileModel.create({ firstName: 'Jane', lastName: 'Doe', email: 'Jane@Example.COM' })
1110
+
1111
+ const [entry] = await HistoryModel.find({ collectionId: profile._id })
1112
+ const doc = entry?.doc as Record<string, unknown>
1113
+
1114
+ expect(doc.email).toBe('Jane@Example.COM')
1115
+ })
1116
+
1117
+ it('should NOT create history when validation fails', async () => {
1118
+ try {
1119
+ await ProfileModel.create({ firstName: 'Bad', lastName: 'User' })
1120
+ } catch {
1121
+ // expected — email is required
1122
+ }
1123
+
1124
+ const history = await HistoryModel.find({})
1125
+ expect(history).toHaveLength(0)
1126
+ })
1127
+
1128
+ it('should NOT create update history when validation fails on save', async () => {
1129
+ const profile = await ProfileModel.create({ firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com' })
1130
+
1131
+ const historyBefore = await HistoryModel.find({})
1132
+ expect(historyBefore).toHaveLength(1)
1133
+
1134
+ profile.age = -5
1135
+ try {
1136
+ await profile.save()
1137
+ } catch {
1138
+ // expected — age min 0
1139
+ }
1140
+
1141
+ const historyAfter = await HistoryModel.find({})
1142
+ expect(historyAfter).toHaveLength(1)
1143
+ })
1144
+ })
1145
+
1146
+ // --- Concurrent updates & large batch ---
1147
+
1148
+ describe('plugin — concurrent updates', () => {
1149
+ const instance = server('plugin-concurrent')
1150
+
1151
+ beforeAll(async () => {
1152
+ await instance.create()
1153
+ })
1154
+
1155
+ afterAll(async () => {
1156
+ await instance.destroy()
1157
+ })
1158
+
1159
+ beforeEach(async () => {
1160
+ await mongoose.connection.collection('ecomorders').deleteMany({})
1161
+ await mongoose.connection.collection('history').deleteMany({})
1162
+ })
1163
+
1164
+ afterEach(() => {
1165
+ vi.resetAllMocks()
1166
+ })
1167
+
1168
+ it('should handle sequential rapid updates with correct versions', async () => {
1169
+ const order = await EcomOrderModel.create({
1170
+ orderNumber: `CONCURRENT-${Date.now()}`,
1171
+ customerId,
1172
+ items: [{ productId: productIds[0], sku: 'C-1', name: 'Item', quantity: 1, price: { amount: 10 } }],
1173
+ shippingAddress: { street: '1 St', city: 'C', zip: '00000' },
1174
+ totals: { subtotal: { amount: 10 }, tax: { amount: 1 }, shipping: { amount: 5 }, total: { amount: 16 } },
1175
+ })
1176
+
1177
+ for (let i = 1; i <= 5; i++) {
1178
+ await EcomOrderModel.updateOne({ _id: order._id }, { $set: { priority: i } }).exec()
1179
+ }
1180
+
1181
+ const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
1182
+ expect(history).toHaveLength(6)
1183
+
1184
+ for (let i = 1; i <= 5; i++) {
1185
+ expect(history[i]?.version).toBe(i)
1186
+ }
1187
+ })
1188
+
1189
+ it('should handle deleteMany with preDelete on 15 documents', async () => {
1190
+ const orders = Array.from({ length: 15 }, (_, i) => ({
1191
+ orderNumber: `BATCH-DEL-${Date.now()}-${i}`,
1192
+ customerId,
1193
+ tags: ['batch-delete'],
1194
+ items: [{ productId: productIds[0], sku: `BD-${i}`, name: `Batch ${i}`, quantity: 1, price: { amount: 5 } }],
1195
+ shippingAddress: { street: `${i} St`, city: 'BD', zip: '00000' },
1196
+ totals: { subtotal: { amount: 5 }, tax: { amount: 0 }, shipping: { amount: 0 }, total: { amount: 5 } },
1197
+ }))
1198
+
1199
+ await EcomOrderModel.insertMany(orders)
1200
+
1201
+ const createHistory = await HistoryModel.find({ op: 'create' })
1202
+ expect(createHistory).toHaveLength(15)
1203
+
1204
+ await EcomOrderModel.deleteMany({ tags: 'batch-delete' }).exec()
1205
+
1206
+ const deleteHistory = await HistoryModel.find({ op: 'deleteMany' })
1207
+ expect(deleteHistory).toHaveLength(15)
1208
+
1209
+ for (const entry of deleteHistory) {
1210
+ expect(entry.doc).toHaveProperty('orderNumber')
1211
+ expect(entry.doc).not.toHaveProperty('internalNotes')
1212
+ }
1213
+ })
1214
+ })
1215
+
1216
+ // --- Additional delete operations ---
1217
+
1218
+ describe('plugin — findOneAndDelete / findByIdAndDelete', () => {
1219
+ const instance = server('plugin-deletes')
1220
+
1221
+ beforeAll(async () => {
1222
+ await instance.create()
1223
+ })
1224
+
1225
+ afterAll(async () => {
1226
+ await instance.destroy()
1227
+ })
1228
+
1229
+ beforeEach(async () => {
1230
+ await mongoose.connection.collection('ecomorders').deleteMany({})
1231
+ await mongoose.connection.collection('history').deleteMany({})
1232
+ })
1233
+
1234
+ afterEach(() => {
1235
+ vi.resetAllMocks()
1236
+ })
1237
+
1238
+ it('should track findOneAndDelete with full document snapshot', async () => {
1239
+ const order = await EcomOrderModel.create({
1240
+ orderNumber: `FOAD-${Date.now()}`,
1241
+ customerId,
1242
+ items: [{ productId: productIds[0], sku: 'FOAD-1', name: 'FindAndDel', quantity: 1, price: { amount: 25 } }],
1243
+ shippingAddress: { street: '1 St', city: 'FD', zip: '00000' },
1244
+ totals: { subtotal: { amount: 25 }, tax: { amount: 2 }, shipping: { amount: 5 }, total: { amount: 32 } },
1245
+ tags: ['findAndDelete'],
1246
+ })
1247
+
1248
+ await EcomOrderModel.findOneAndDelete({ _id: order._id }).exec()
1249
+
1250
+ const deletion = await HistoryModel.findOne({ op: 'findOneAndDelete', collectionId: order._id })
1251
+ expect(deletion).toBeDefined()
1252
+ expect(deletion?.doc).toHaveProperty('orderNumber')
1253
+ expect(deletion?.doc).toHaveProperty('items')
1254
+ expect(deletion?.doc).not.toHaveProperty('internalNotes')
1255
+ })
1256
+
1257
+ it('should track findByIdAndDelete with full document snapshot', async () => {
1258
+ const order = await EcomOrderModel.create({
1259
+ orderNumber: `FBAD-${Date.now()}`,
1260
+ customerId,
1261
+ items: [{ productId: productIds[0], sku: 'FBAD-1', name: 'ByIdDel', quantity: 1, price: { amount: 15 } }],
1262
+ shippingAddress: { street: '2 St', city: 'BD', zip: '00000' },
1263
+ totals: { subtotal: { amount: 15 }, tax: { amount: 1 }, shipping: { amount: 3 }, total: { amount: 19 } },
1264
+ })
1265
+
1266
+ await EcomOrderModel.findByIdAndDelete(order._id).exec()
1267
+
1268
+ const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
1269
+ expect(history.length).toBeGreaterThanOrEqual(2)
1270
+
1271
+ const deletion = history.find((h) => h.op.includes('delete') || h.op.includes('Delete'))
1272
+ expect(deletion).toBeDefined()
1273
+ expect(deletion?.doc).toHaveProperty('orderNumber')
1274
+ })
1275
+ })
1276
+
1277
+ // --- replaceOne ---
1278
+
1279
+ describe('plugin — replaceOne', () => {
1280
+ const instance = server('plugin-replace')
1281
+
1282
+ beforeAll(async () => {
1283
+ await instance.create()
1284
+ })
1285
+
1286
+ afterAll(async () => {
1287
+ await instance.destroy()
1288
+ })
1289
+
1290
+ beforeEach(async () => {
1291
+ await mongoose.connection.collection('ecomorders').deleteMany({})
1292
+ await mongoose.connection.collection('history').deleteMany({})
1293
+ })
1294
+
1295
+ afterEach(() => {
1296
+ vi.resetAllMocks()
1297
+ })
1298
+
1299
+ it('should track replaceOne with full document replacement', async () => {
1300
+ const order = await EcomOrderModel.create({
1301
+ orderNumber: `REPLACE-${Date.now()}`,
1302
+ customerId,
1303
+ status: 'pending',
1304
+ items: [{ productId: productIds[0], sku: 'OLD-1', name: 'Old Item', quantity: 1, price: { amount: 10 } }],
1305
+ shippingAddress: { street: '1 Old St', city: 'OldCity', zip: '00000' },
1306
+ totals: { subtotal: { amount: 10 }, tax: { amount: 1 }, shipping: { amount: 2 }, total: { amount: 13 } },
1307
+ tags: ['original'],
1308
+ })
1309
+
1310
+ await EcomOrderModel.replaceOne(
1311
+ { _id: order._id },
1312
+ {
1313
+ orderNumber: order.orderNumber,
1314
+ customerId,
1315
+ status: 'replaced',
1316
+ items: [{ productId: productIds[1], sku: 'NEW-1', name: 'New Item', quantity: 5, price: { amount: 99 } }],
1317
+ shippingAddress: { street: '2 New St', city: 'NewCity', zip: '11111' },
1318
+ totals: { subtotal: { amount: 99 }, tax: { amount: 9 }, shipping: { amount: 0 }, total: { amount: 108 } },
1319
+ tags: ['replaced'],
1320
+ },
1321
+ ).exec()
1322
+
1323
+ const updates = await HistoryModel.find({ op: 'replaceOne', collectionId: order._id })
1324
+ expect(updates).toHaveLength(1)
1325
+ expect(updates[0]?.patch?.length).toBeGreaterThan(0)
1326
+
1327
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1328
+ expect(paths.some((p) => p?.includes('/status'))).toBe(true)
1329
+ expect(paths.some((p) => p?.includes('/items'))).toBe(true)
1330
+ expect(paths.some((p) => p?.includes('/shippingAddress'))).toBe(true)
1331
+ })
1332
+ })