ts-patch-mongoose 2.9.6 → 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/README.md +42 -27
- package/biome.json +2 -5
- package/dist/index.cjs +307 -93
- package/dist/index.d.cts +42 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +42 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +307 -93
- package/package.json +13 -19
- package/src/helpers.ts +132 -10
- package/src/hooks/delete-hooks.ts +5 -7
- package/src/hooks/update-hooks.ts +48 -34
- package/src/index.ts +4 -32
- package/src/ms.ts +66 -0
- package/src/omit-deep.ts +56 -0
- package/src/patch.ts +42 -47
- package/src/types.ts +1 -0
- package/src/version.ts +5 -4
- package/tests/em.test.ts +26 -8
- package/tests/helpers.test.ts +291 -2
- package/tests/ms.test.ts +113 -0
- package/tests/omit-deep.test.ts +235 -0
- package/tests/patch.test.ts +6 -5
- package/tests/plugin-all-features.test.ts +844 -0
- package/tests/plugin-complex-data.test.ts +2647 -0
- package/tests/plugin-event-created.test.ts +10 -10
- package/tests/plugin-event-deleted.test.ts +10 -10
- package/tests/plugin-event-updated.test.ts +9 -9
- package/tests/plugin-global.test.ts +6 -6
- package/tests/plugin-omit-all.test.ts +1 -1
- package/tests/plugin-patch-history-disabled.test.ts +1 -1
- package/tests/plugin-pre-delete.test.ts +8 -8
- package/tests/plugin-pre-save.test.ts +2 -2
- package/tests/plugin.test.ts +3 -3
- package/tsconfig.json +2 -3
- package/vite.config.mts +2 -1
- package/src/modules/omit-deep.d.ts +0 -3
|
@@ -0,0 +1,844 @@
|
|
|
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 produce no update history when only omitted fields change', 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
|
+
if (mongoose.models.ThrowOrder) mongoose.deleteModel('ThrowOrder')
|
|
664
|
+
const ThrowModel = model<Order>('ThrowOrder', ThrowSchema)
|
|
665
|
+
|
|
666
|
+
const doc = await ThrowModel.create({
|
|
667
|
+
item: 'ThrowTest',
|
|
668
|
+
quantity: 1,
|
|
669
|
+
tags: [],
|
|
670
|
+
address: { street: '1 St', city: 'T', zip: '00000' },
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
const history = await HistoryModel.find({ collectionId: doc._id })
|
|
674
|
+
expect(history).toHaveLength(1)
|
|
675
|
+
expect(history[0]?.user).toBeUndefined()
|
|
676
|
+
expect(history[0]?.reason).toBeUndefined()
|
|
677
|
+
expect(history[0]?.metadata).toBeUndefined()
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('should skip everything when ignoreEvent + ignorePatchHistory', async () => {
|
|
681
|
+
const order = await OrderModel.create({
|
|
682
|
+
item: 'SkipAll',
|
|
683
|
+
quantity: 1,
|
|
684
|
+
tags: [],
|
|
685
|
+
address: { street: '1 St', city: 'S', zip: '00000' },
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
vi.resetAllMocks()
|
|
689
|
+
|
|
690
|
+
await OrderModel.updateOne({ _id: order._id }, { quantity: 50 }).setOptions({ ignoreEvent: true, ignorePatchHistory: true }).exec()
|
|
691
|
+
|
|
692
|
+
const updates = await HistoryModel.find({ op: 'updateOne' })
|
|
693
|
+
expect(updates).toHaveLength(0)
|
|
694
|
+
expect(em.emit).not.toHaveBeenCalled()
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('should track deeply nested changes (3+ levels)', async () => {
|
|
698
|
+
const DeepSchema = new Schema(
|
|
699
|
+
{
|
|
700
|
+
name: String,
|
|
701
|
+
config: {
|
|
702
|
+
type: new Schema(
|
|
703
|
+
{
|
|
704
|
+
settings: {
|
|
705
|
+
type: new Schema(
|
|
706
|
+
{
|
|
707
|
+
theme: String,
|
|
708
|
+
notifications: Boolean,
|
|
709
|
+
},
|
|
710
|
+
{ _id: false },
|
|
711
|
+
),
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
{ _id: false },
|
|
715
|
+
),
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
{ timestamps: true },
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
DeepSchema.plugin(patchHistoryPlugin, {
|
|
722
|
+
omit: ['__v', 'createdAt', 'updatedAt'],
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
if (mongoose.models.DeepDoc) mongoose.deleteModel('DeepDoc')
|
|
726
|
+
const DeepModel = model('DeepDoc', DeepSchema)
|
|
727
|
+
|
|
728
|
+
const doc = await DeepModel.create({
|
|
729
|
+
name: 'deep',
|
|
730
|
+
config: { settings: { theme: 'dark', notifications: true } },
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
doc.config = { settings: { theme: 'light', notifications: false } }
|
|
734
|
+
await doc.save()
|
|
735
|
+
|
|
736
|
+
const updates = await HistoryModel.find({ op: 'update', collectionId: doc._id })
|
|
737
|
+
expect(updates).toHaveLength(1)
|
|
738
|
+
|
|
739
|
+
const paths = updates[0]?.patch?.map((p) => p.path)
|
|
740
|
+
expect(paths).toContain('/config/settings/theme')
|
|
741
|
+
expect(paths).toContain('/config/settings/notifications')
|
|
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
|
+
})
|
|
844
|
+
})
|