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,2647 @@
|
|
|
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
|
+
vi.mock('../src/em', () => ({ default: { emit: vi.fn() } }))
|
|
10
|
+
|
|
11
|
+
// --- Realistic e-commerce schema ---
|
|
12
|
+
|
|
13
|
+
const MoneySchema = new Schema({ amount: Number, currency: { type: String, default: 'USD' } }, { _id: false })
|
|
14
|
+
|
|
15
|
+
const LineItemSchema = new Schema(
|
|
16
|
+
{
|
|
17
|
+
productId: { type: Schema.Types.ObjectId, required: true },
|
|
18
|
+
sku: { type: String, required: true },
|
|
19
|
+
name: { type: String, required: true },
|
|
20
|
+
quantity: { type: Number, required: true },
|
|
21
|
+
price: { type: MoneySchema, required: true },
|
|
22
|
+
discount: { type: MoneySchema },
|
|
23
|
+
tags: [String],
|
|
24
|
+
metadata: { type: Schema.Types.Mixed },
|
|
25
|
+
},
|
|
26
|
+
{ _id: true },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const AddressSchema = new Schema(
|
|
30
|
+
{
|
|
31
|
+
label: String,
|
|
32
|
+
street: String,
|
|
33
|
+
city: String,
|
|
34
|
+
state: String,
|
|
35
|
+
zip: String,
|
|
36
|
+
country: { type: String, default: 'US' },
|
|
37
|
+
coords: { lat: Number, lng: Number },
|
|
38
|
+
},
|
|
39
|
+
{ _id: false },
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const PaymentSchema = new Schema(
|
|
43
|
+
{
|
|
44
|
+
method: { type: String, enum: ['card', 'paypal', 'crypto', 'bank'] },
|
|
45
|
+
last4: String,
|
|
46
|
+
transactionId: String,
|
|
47
|
+
paidAt: Date,
|
|
48
|
+
},
|
|
49
|
+
{ _id: false },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
interface Money {
|
|
53
|
+
amount: number
|
|
54
|
+
currency: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface LineItem {
|
|
58
|
+
productId: mongoose.Types.ObjectId
|
|
59
|
+
sku: string
|
|
60
|
+
name: string
|
|
61
|
+
quantity: number
|
|
62
|
+
price: Money
|
|
63
|
+
discount?: Money
|
|
64
|
+
tags?: string[]
|
|
65
|
+
metadata?: Record<string, unknown>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Address {
|
|
69
|
+
label?: string
|
|
70
|
+
street?: string
|
|
71
|
+
city?: string
|
|
72
|
+
state?: string
|
|
73
|
+
zip?: string
|
|
74
|
+
country?: string
|
|
75
|
+
coords?: { lat: number; lng: number }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Payment {
|
|
79
|
+
method?: string
|
|
80
|
+
last4?: string
|
|
81
|
+
transactionId?: string
|
|
82
|
+
paidAt?: Date
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface EcomOrder {
|
|
86
|
+
orderNumber: string
|
|
87
|
+
customerId: mongoose.Types.ObjectId
|
|
88
|
+
status: string
|
|
89
|
+
items: LineItem[]
|
|
90
|
+
shippingAddress: Address
|
|
91
|
+
billingAddress: Address
|
|
92
|
+
payment: Payment
|
|
93
|
+
totals: { subtotal: Money; tax: Money; shipping: Money; total: Money }
|
|
94
|
+
notes: string[]
|
|
95
|
+
internalNotes: string
|
|
96
|
+
assignedTo: mongoose.Types.ObjectId[]
|
|
97
|
+
priority: number
|
|
98
|
+
tags: string[]
|
|
99
|
+
createdAt?: Date
|
|
100
|
+
updatedAt?: Date
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const EcomOrderSchema = new Schema<EcomOrder>(
|
|
104
|
+
{
|
|
105
|
+
orderNumber: { type: String, required: true, unique: true },
|
|
106
|
+
customerId: { type: Schema.Types.ObjectId, required: true },
|
|
107
|
+
status: { type: String, default: 'pending' },
|
|
108
|
+
items: [LineItemSchema],
|
|
109
|
+
shippingAddress: AddressSchema,
|
|
110
|
+
billingAddress: AddressSchema,
|
|
111
|
+
payment: PaymentSchema,
|
|
112
|
+
totals: { subtotal: MoneySchema, tax: MoneySchema, shipping: MoneySchema, total: MoneySchema },
|
|
113
|
+
notes: [String],
|
|
114
|
+
internalNotes: String,
|
|
115
|
+
assignedTo: [{ type: Schema.Types.ObjectId }],
|
|
116
|
+
priority: { type: Number, default: 0 },
|
|
117
|
+
tags: [String],
|
|
118
|
+
},
|
|
119
|
+
{ timestamps: true },
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
EcomOrderSchema.plugin(patchHistoryPlugin, {
|
|
123
|
+
eventCreated: 'order-created',
|
|
124
|
+
eventUpdated: 'order-updated',
|
|
125
|
+
eventDeleted: 'order-deleted',
|
|
126
|
+
omit: ['__v', 'createdAt', 'updatedAt', 'internalNotes'],
|
|
127
|
+
getUser: () => ({ userId: 'admin-123', role: 'admin' }),
|
|
128
|
+
getReason: () => 'system-action',
|
|
129
|
+
getMetadata: () => ({ service: 'order-service', version: '2.0' }),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const EcomOrderModel = model<EcomOrder>('EcomOrder', EcomOrderSchema)
|
|
133
|
+
|
|
134
|
+
// --- Stable ObjectIds for cross-test reference ---
|
|
135
|
+
|
|
136
|
+
const productIds = Array.from({ length: 4 }, () => new mongoose.Types.ObjectId())
|
|
137
|
+
const customerId = new mongoose.Types.ObjectId()
|
|
138
|
+
const agentIds = Array.from({ length: 3 }, () => new mongoose.Types.ObjectId())
|
|
139
|
+
|
|
140
|
+
const createOrder = () =>
|
|
141
|
+
EcomOrderModel.create({
|
|
142
|
+
orderNumber: `ORD-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
143
|
+
customerId,
|
|
144
|
+
status: 'pending',
|
|
145
|
+
items: [
|
|
146
|
+
{
|
|
147
|
+
productId: productIds[0],
|
|
148
|
+
sku: 'WIDGET-001',
|
|
149
|
+
name: 'Premium Widget',
|
|
150
|
+
quantity: 2,
|
|
151
|
+
price: { amount: 29.99, currency: 'USD' },
|
|
152
|
+
discount: { amount: 5, currency: 'USD' },
|
|
153
|
+
tags: ['electronics', 'sale'],
|
|
154
|
+
metadata: { weight: 0.5, dimensions: { w: 10, h: 5, d: 3 } },
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
productId: productIds[1],
|
|
158
|
+
sku: 'GADGET-002',
|
|
159
|
+
name: 'Super Gadget',
|
|
160
|
+
quantity: 1,
|
|
161
|
+
price: { amount: 149.99, currency: 'USD' },
|
|
162
|
+
tags: ['electronics'],
|
|
163
|
+
metadata: { weight: 1.2 },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
shippingAddress: {
|
|
167
|
+
label: 'Home',
|
|
168
|
+
street: '123 Main St',
|
|
169
|
+
city: 'Springfield',
|
|
170
|
+
state: 'IL',
|
|
171
|
+
zip: '62701',
|
|
172
|
+
country: 'US',
|
|
173
|
+
coords: { lat: 39.7817, lng: -89.6501 },
|
|
174
|
+
},
|
|
175
|
+
billingAddress: {
|
|
176
|
+
label: 'Office',
|
|
177
|
+
street: '456 Corp Ave',
|
|
178
|
+
city: 'Chicago',
|
|
179
|
+
state: 'IL',
|
|
180
|
+
zip: '60601',
|
|
181
|
+
country: 'US',
|
|
182
|
+
},
|
|
183
|
+
payment: { method: 'card', last4: '4242' },
|
|
184
|
+
totals: {
|
|
185
|
+
subtotal: { amount: 209.97, currency: 'USD' },
|
|
186
|
+
tax: { amount: 18.9, currency: 'USD' },
|
|
187
|
+
shipping: { amount: 9.99, currency: 'USD' },
|
|
188
|
+
total: { amount: 238.86, currency: 'USD' },
|
|
189
|
+
},
|
|
190
|
+
notes: ['Gift wrap requested', 'Leave at door'],
|
|
191
|
+
internalNotes: 'VIP customer',
|
|
192
|
+
assignedTo: [agentIds[0]],
|
|
193
|
+
priority: 2,
|
|
194
|
+
tags: ['vip', 'express'],
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const getPatch = (entry: { patch?: { op: string; path: string; value?: unknown }[] } | undefined, path: string) => entry?.patch?.find((p) => p.path === path && p.op === 'replace')
|
|
198
|
+
|
|
199
|
+
describe('plugin — complex data structures', () => {
|
|
200
|
+
const instance = server('plugin-complex-data')
|
|
201
|
+
|
|
202
|
+
beforeAll(async () => {
|
|
203
|
+
await instance.create()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
afterAll(async () => {
|
|
207
|
+
await instance.destroy()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
beforeEach(async () => {
|
|
211
|
+
await mongoose.connection.collection('ecomorders').deleteMany({})
|
|
212
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
vi.resetAllMocks()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// --- Create ---
|
|
220
|
+
|
|
221
|
+
it('should capture full document structure on create with correct omissions', async () => {
|
|
222
|
+
const order = await createOrder()
|
|
223
|
+
const [entry] = await HistoryModel.find({ collectionId: order._id })
|
|
224
|
+
|
|
225
|
+
expect(entry?.op).toBe('create')
|
|
226
|
+
expect(entry?.version).toBe(0)
|
|
227
|
+
|
|
228
|
+
const doc = entry?.doc as Record<string, unknown>
|
|
229
|
+
expect(doc.orderNumber).toBe(order.orderNumber)
|
|
230
|
+
expect(doc.status).toBe('pending')
|
|
231
|
+
expect(doc.priority).toBe(2)
|
|
232
|
+
expect((doc.items as unknown[]).length).toBe(2)
|
|
233
|
+
expect((doc.notes as string[]).length).toBe(2)
|
|
234
|
+
expect((doc.tags as string[]).length).toBe(2)
|
|
235
|
+
expect(doc.shippingAddress).toHaveProperty('coords')
|
|
236
|
+
expect(doc.payment).toHaveProperty('method', 'card')
|
|
237
|
+
expect(doc.totals).toHaveProperty('total')
|
|
238
|
+
|
|
239
|
+
expect(doc).not.toHaveProperty('internalNotes')
|
|
240
|
+
expect(doc).not.toHaveProperty('__v')
|
|
241
|
+
expect(doc).not.toHaveProperty('createdAt')
|
|
242
|
+
expect(doc).not.toHaveProperty('updatedAt')
|
|
243
|
+
|
|
244
|
+
expect(entry?.user).toEqual({ userId: 'admin-123', role: 'admin' })
|
|
245
|
+
expect(entry?.reason).toBe('system-action')
|
|
246
|
+
expect(entry?.metadata).toEqual({ service: 'order-service', version: '2.0' })
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// --- Nested subdocument updates ---
|
|
250
|
+
|
|
251
|
+
it('should track address change with deep coords and capture exact patch values', async () => {
|
|
252
|
+
const order = await createOrder()
|
|
253
|
+
|
|
254
|
+
order.shippingAddress = {
|
|
255
|
+
label: 'New Home',
|
|
256
|
+
street: '789 Oak Rd',
|
|
257
|
+
city: 'Portland',
|
|
258
|
+
state: 'OR',
|
|
259
|
+
zip: '97201',
|
|
260
|
+
country: 'US',
|
|
261
|
+
coords: { lat: 45.5152, lng: -122.6784 },
|
|
262
|
+
}
|
|
263
|
+
await order.save()
|
|
264
|
+
|
|
265
|
+
const [update] = await HistoryModel.find({ op: 'update', collectionId: order._id })
|
|
266
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
267
|
+
|
|
268
|
+
expect(paths).toContain('/shippingAddress/label')
|
|
269
|
+
expect(paths).toContain('/shippingAddress/street')
|
|
270
|
+
expect(paths).toContain('/shippingAddress/city')
|
|
271
|
+
expect(paths).toContain('/shippingAddress/coords/lat')
|
|
272
|
+
expect(paths).toContain('/shippingAddress/coords/lng')
|
|
273
|
+
|
|
274
|
+
expect(getPatch(update, '/shippingAddress/city')?.value).toBe('Portland')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should track payment completion with transactionId and paidAt date', async () => {
|
|
278
|
+
const order = await createOrder()
|
|
279
|
+
const paidAt = new Date('2026-03-15T10:00:00Z')
|
|
280
|
+
|
|
281
|
+
await EcomOrderModel.updateOne({ _id: order._id }, { $set: { status: 'paid', payment: { method: 'card', last4: '4242', transactionId: 'txn_abc123', paidAt } } }).exec()
|
|
282
|
+
|
|
283
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
|
|
284
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
285
|
+
|
|
286
|
+
expect(paths.some((p) => p?.includes('/status'))).toBe(true)
|
|
287
|
+
expect(paths.some((p) => p?.includes('/payment'))).toBe(true)
|
|
288
|
+
expect(getPatch(update, '/status')?.value).toBe('paid')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// --- Array of subdocuments ---
|
|
292
|
+
|
|
293
|
+
it('should track adding a line item via $push with nested Money schema', async () => {
|
|
294
|
+
const order = await createOrder()
|
|
295
|
+
|
|
296
|
+
await EcomOrderModel.updateOne(
|
|
297
|
+
{ _id: order._id },
|
|
298
|
+
{
|
|
299
|
+
$push: {
|
|
300
|
+
items: {
|
|
301
|
+
productId: productIds[2],
|
|
302
|
+
sku: 'CABLE-003',
|
|
303
|
+
name: 'USB-C Cable',
|
|
304
|
+
quantity: 3,
|
|
305
|
+
price: { amount: 9.99, currency: 'USD' },
|
|
306
|
+
discount: { amount: 1, currency: 'USD' },
|
|
307
|
+
tags: ['accessories', 'cables'],
|
|
308
|
+
metadata: { color: 'black', length: '2m' },
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
).exec()
|
|
313
|
+
|
|
314
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
|
|
315
|
+
expect(update?.patch?.length).toBeGreaterThan(0)
|
|
316
|
+
|
|
317
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
318
|
+
expect(paths.some((p) => p?.startsWith('/items'))).toBe(true)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('should track removing a line item via save', async () => {
|
|
322
|
+
const order = await createOrder()
|
|
323
|
+
expect(order.items.length).toBe(2)
|
|
324
|
+
|
|
325
|
+
order.items = [order.items[0]]
|
|
326
|
+
await order.save()
|
|
327
|
+
|
|
328
|
+
const [update] = await HistoryModel.find({ op: 'update', collectionId: order._id })
|
|
329
|
+
expect(update?.patch?.length).toBeGreaterThan(0)
|
|
330
|
+
|
|
331
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
332
|
+
expect(paths.some((p) => p?.startsWith('/items'))).toBe(true)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// --- Compound operations ---
|
|
336
|
+
|
|
337
|
+
it('should track $set + $inc + $push in a single update', async () => {
|
|
338
|
+
const order = await createOrder()
|
|
339
|
+
|
|
340
|
+
await EcomOrderModel.updateOne(
|
|
341
|
+
{ _id: order._id },
|
|
342
|
+
{
|
|
343
|
+
$set: { status: 'processing' },
|
|
344
|
+
$inc: { priority: 1 },
|
|
345
|
+
$push: { tags: 'rush' },
|
|
346
|
+
},
|
|
347
|
+
).exec()
|
|
348
|
+
|
|
349
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
|
|
350
|
+
expect(update?.patch?.length).toBeGreaterThan(0)
|
|
351
|
+
|
|
352
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
353
|
+
expect(paths.some((p) => p?.includes('/status'))).toBe(true)
|
|
354
|
+
expect(paths.some((p) => p?.includes('/priority'))).toBe(true)
|
|
355
|
+
expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
// --- ObjectId mutations ---
|
|
359
|
+
|
|
360
|
+
it('should track adding and replacing ObjectId refs in assignedTo array', async () => {
|
|
361
|
+
const order = await createOrder()
|
|
362
|
+
|
|
363
|
+
await EcomOrderModel.updateOne({ _id: order._id }, { $push: { assignedTo: agentIds[1] } }).exec()
|
|
364
|
+
|
|
365
|
+
const [push] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
|
|
366
|
+
expect(push?.patch?.some((p) => p.path.startsWith('/assignedTo'))).toBe(true)
|
|
367
|
+
|
|
368
|
+
await EcomOrderModel.updateOne({ _id: order._id }, { assignedTo: [agentIds[2]] }).exec()
|
|
369
|
+
|
|
370
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: order._id }).sort('createdAt')
|
|
371
|
+
expect(updates).toHaveLength(2)
|
|
372
|
+
expect(updates[1]?.patch?.some((p) => p.path.startsWith('/assignedTo'))).toBe(true)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('should track changing customerId (ObjectId field replacement)', async () => {
|
|
376
|
+
const order = await createOrder()
|
|
377
|
+
const newCustomerId = new mongoose.Types.ObjectId()
|
|
378
|
+
|
|
379
|
+
await EcomOrderModel.updateOne({ _id: order._id }, { $set: { customerId: newCustomerId } }).exec()
|
|
380
|
+
|
|
381
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
|
|
382
|
+
expect(update?.patch?.some((p) => p.path === '/customerId')).toBe(true)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// --- Money / totals ---
|
|
386
|
+
|
|
387
|
+
it('should track totals recalculation with exact values', async () => {
|
|
388
|
+
const order = await createOrder()
|
|
389
|
+
|
|
390
|
+
await EcomOrderModel.updateOne(
|
|
391
|
+
{ _id: order._id },
|
|
392
|
+
{
|
|
393
|
+
$set: {
|
|
394
|
+
'totals.tax': { amount: 25.01, currency: 'USD' },
|
|
395
|
+
'totals.shipping': { amount: 0, currency: 'USD' },
|
|
396
|
+
'totals.total': { amount: 234.97, currency: 'USD' },
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
).exec()
|
|
400
|
+
|
|
401
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: order._id })
|
|
402
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
403
|
+
|
|
404
|
+
expect(paths.some((p) => p?.includes('/totals/tax'))).toBe(true)
|
|
405
|
+
expect(paths.some((p) => p?.includes('/totals/shipping'))).toBe(true)
|
|
406
|
+
expect(paths.some((p) => p?.includes('/totals/total'))).toBe(true)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
// --- Status workflow ---
|
|
410
|
+
|
|
411
|
+
it('should track full order lifecycle with correct versions and ops', async () => {
|
|
412
|
+
const order = await createOrder()
|
|
413
|
+
|
|
414
|
+
for (const status of ['processing', 'shipped', 'delivered']) {
|
|
415
|
+
await EcomOrderModel.updateOne({ _id: order._id }, { $set: { status } }).exec()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
await EcomOrderModel.deleteOne({ _id: order._id }).exec()
|
|
419
|
+
|
|
420
|
+
const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
|
|
421
|
+
expect(history).toHaveLength(5)
|
|
422
|
+
|
|
423
|
+
expect(history[0]?.op).toBe('create')
|
|
424
|
+
expect(history[0]?.version).toBe(0)
|
|
425
|
+
|
|
426
|
+
expect(history[1]?.version).toBe(1)
|
|
427
|
+
expect(getPatch(history[1], '/status')?.value).toBe('processing')
|
|
428
|
+
|
|
429
|
+
expect(history[2]?.version).toBe(2)
|
|
430
|
+
expect(getPatch(history[2], '/status')?.value).toBe('shipped')
|
|
431
|
+
|
|
432
|
+
expect(history[3]?.version).toBe(3)
|
|
433
|
+
expect(getPatch(history[3], '/status')?.value).toBe('delivered')
|
|
434
|
+
|
|
435
|
+
expect(history[4]?.op).toBe('deleteOne')
|
|
436
|
+
expect(history[4]?.doc).toHaveProperty('status', 'delivered')
|
|
437
|
+
expect(history[4]?.doc).not.toHaveProperty('internalNotes')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
// --- Omission across lifecycle ---
|
|
441
|
+
|
|
442
|
+
it('should never leak omitted fields in any history entry type', async () => {
|
|
443
|
+
const order = await createOrder()
|
|
444
|
+
|
|
445
|
+
order.internalNotes = 'Escalated to manager'
|
|
446
|
+
order.status = 'processing'
|
|
447
|
+
await order.save()
|
|
448
|
+
|
|
449
|
+
await EcomOrderModel.updateOne({ _id: order._id }, { $set: { internalNotes: 'Resolved', status: 'shipped' } }).exec()
|
|
450
|
+
|
|
451
|
+
await EcomOrderModel.deleteOne({ _id: order._id }).exec()
|
|
452
|
+
|
|
453
|
+
const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
|
|
454
|
+
expect(history.length).toBe(4)
|
|
455
|
+
|
|
456
|
+
for (const entry of history) {
|
|
457
|
+
if (entry.doc) {
|
|
458
|
+
expect(entry.doc).not.toHaveProperty('internalNotes')
|
|
459
|
+
expect(entry.doc).not.toHaveProperty('__v')
|
|
460
|
+
expect(entry.doc).not.toHaveProperty('createdAt')
|
|
461
|
+
expect(entry.doc).not.toHaveProperty('updatedAt')
|
|
462
|
+
}
|
|
463
|
+
if (entry.patch) {
|
|
464
|
+
const paths = entry.patch.map((p) => p.path)
|
|
465
|
+
expect(paths).not.toContain('/internalNotes')
|
|
466
|
+
expect(paths).not.toContain('/__v')
|
|
467
|
+
expect(paths).not.toContain('/createdAt')
|
|
468
|
+
expect(paths).not.toContain('/updatedAt')
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
// --- Bulk ---
|
|
474
|
+
|
|
475
|
+
it('should handle insertMany with varied complex documents', async () => {
|
|
476
|
+
const orders = Array.from({ length: 5 }, (_, i) => ({
|
|
477
|
+
orderNumber: `BULK-${Date.now()}-${i}`,
|
|
478
|
+
customerId,
|
|
479
|
+
status: i % 2 === 0 ? 'pending' : 'processing',
|
|
480
|
+
items: [
|
|
481
|
+
{
|
|
482
|
+
productId: productIds[i % productIds.length],
|
|
483
|
+
sku: `BULK-${i}`,
|
|
484
|
+
name: `Bulk Item ${i}`,
|
|
485
|
+
quantity: i + 1,
|
|
486
|
+
price: { amount: 10.5 * (i + 1), currency: i % 2 === 0 ? 'USD' : 'EUR' },
|
|
487
|
+
tags: [`batch-${i}`],
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
shippingAddress: { street: `${100 + i} Bulk St`, city: 'Bulk City', state: 'BC', zip: `${10000 + i}` },
|
|
491
|
+
totals: {
|
|
492
|
+
subtotal: { amount: 10.5 * (i + 1) },
|
|
493
|
+
tax: { amount: 0.9 * (i + 1) },
|
|
494
|
+
shipping: { amount: 5 },
|
|
495
|
+
total: { amount: 10.5 * (i + 1) + 0.9 * (i + 1) + 5 },
|
|
496
|
+
},
|
|
497
|
+
priority: i,
|
|
498
|
+
}))
|
|
499
|
+
|
|
500
|
+
await EcomOrderModel.insertMany(orders)
|
|
501
|
+
|
|
502
|
+
const history = await HistoryModel.find({ op: 'create' })
|
|
503
|
+
expect(history).toHaveLength(5)
|
|
504
|
+
|
|
505
|
+
for (const entry of history) {
|
|
506
|
+
expect(entry.user).toEqual({ userId: 'admin-123', role: 'admin' })
|
|
507
|
+
expect(entry.reason).toBe('system-action')
|
|
508
|
+
expect(entry.metadata).toEqual({ service: 'order-service', version: '2.0' })
|
|
509
|
+
expect(entry.doc).not.toHaveProperty('internalNotes')
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
// --- updateMany ---
|
|
514
|
+
|
|
515
|
+
it('should track updateMany across multiple complex documents', async () => {
|
|
516
|
+
await createOrder()
|
|
517
|
+
await createOrder()
|
|
518
|
+
|
|
519
|
+
await EcomOrderModel.updateMany({ customerId }, { $set: { priority: 10 }, $push: { tags: 'bulk-updated' } }).exec()
|
|
520
|
+
|
|
521
|
+
const updates = await HistoryModel.find({ op: 'updateMany' })
|
|
522
|
+
expect(updates).toHaveLength(2)
|
|
523
|
+
|
|
524
|
+
for (const entry of updates) {
|
|
525
|
+
expect(entry.patch?.some((p) => p.path === '/priority')).toBe(true)
|
|
526
|
+
expect(entry.patch?.some((p) => p.path.startsWith('/tags'))).toBe(true)
|
|
527
|
+
expect(entry.user).toEqual({ userId: 'admin-123', role: 'admin' })
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
// --- Delete ---
|
|
532
|
+
|
|
533
|
+
it('should preserve complete document snapshot on delete with nested data intact', async () => {
|
|
534
|
+
const order = await createOrder()
|
|
535
|
+
|
|
536
|
+
await EcomOrderModel.deleteOne({ _id: order._id }).exec()
|
|
537
|
+
|
|
538
|
+
const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: order._id })
|
|
539
|
+
const doc = deletion?.doc as Record<string, unknown>
|
|
540
|
+
|
|
541
|
+
expect(doc.orderNumber).toBe(order.orderNumber)
|
|
542
|
+
expect(doc.status).toBe('pending')
|
|
543
|
+
expect((doc.items as unknown[]).length).toBe(2)
|
|
544
|
+
expect(doc.shippingAddress).toHaveProperty('coords')
|
|
545
|
+
expect((doc.shippingAddress as Address).coords?.lat).toBe(39.7817)
|
|
546
|
+
expect(doc.payment).toHaveProperty('last4', '4242')
|
|
547
|
+
expect((doc.totals as Record<string, Money>).total.amount).toBe(238.86)
|
|
548
|
+
expect((doc.assignedTo as string[]).length).toBe(1)
|
|
549
|
+
expect(doc.tags as string[]).toEqual(expect.arrayContaining(['vip', 'express']))
|
|
550
|
+
|
|
551
|
+
expect(doc).not.toHaveProperty('internalNotes')
|
|
552
|
+
expect(doc).not.toHaveProperty('__v')
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
// --- All mongoose schema types ---
|
|
557
|
+
|
|
558
|
+
const hasDouble = 'Double' in Schema.Types
|
|
559
|
+
const hasInt32 = 'Int32' in Schema.Types
|
|
560
|
+
const hasBigInt = 'BigInt' in Schema.Types
|
|
561
|
+
|
|
562
|
+
const AllTypesSchema = new Schema(
|
|
563
|
+
{
|
|
564
|
+
str: String,
|
|
565
|
+
num: Number,
|
|
566
|
+
bool: Boolean,
|
|
567
|
+
date: Date,
|
|
568
|
+
objectId: Schema.Types.ObjectId,
|
|
569
|
+
decimal: Schema.Types.Decimal128,
|
|
570
|
+
uuid: Schema.Types.UUID,
|
|
571
|
+
buf: Buffer,
|
|
572
|
+
mixed: Schema.Types.Mixed,
|
|
573
|
+
nested: { deep: { value: String } },
|
|
574
|
+
map: { type: Map, of: String },
|
|
575
|
+
arrStr: [String],
|
|
576
|
+
arrNum: [Number],
|
|
577
|
+
arrObjectId: [Schema.Types.ObjectId],
|
|
578
|
+
arrNested: [new Schema({ label: String, score: Number }, { _id: false })],
|
|
579
|
+
},
|
|
580
|
+
{ timestamps: true },
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
if (hasDouble) AllTypesSchema.add({ dbl: (Schema.Types as Record<string, unknown>).Double })
|
|
584
|
+
if (hasInt32) AllTypesSchema.add({ int32: (Schema.Types as Record<string, unknown>).Int32 })
|
|
585
|
+
if (hasBigInt) AllTypesSchema.add({ bigint: (Schema.Types as Record<string, unknown>).BigInt })
|
|
586
|
+
|
|
587
|
+
// --- Realistic SaaS Organization model (e2e) ---
|
|
588
|
+
|
|
589
|
+
const ContactSchema = new Schema(
|
|
590
|
+
{
|
|
591
|
+
email: { type: String, required: true },
|
|
592
|
+
phone: String,
|
|
593
|
+
website: String,
|
|
594
|
+
},
|
|
595
|
+
{ _id: false },
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
const BillingSchema = new Schema(
|
|
599
|
+
{
|
|
600
|
+
plan: { type: String, enum: ['free', 'starter', 'pro', 'enterprise'], default: 'free' },
|
|
601
|
+
mrr: Schema.Types.Decimal128,
|
|
602
|
+
currency: { type: String, default: 'USD' },
|
|
603
|
+
cardLast4: String,
|
|
604
|
+
nextBillingDate: Date,
|
|
605
|
+
},
|
|
606
|
+
{ _id: false },
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
const TeamMemberSchema = new Schema(
|
|
610
|
+
{
|
|
611
|
+
userId: { type: Schema.Types.ObjectId, required: true },
|
|
612
|
+
role: { type: String, enum: ['owner', 'admin', 'member', 'viewer'], required: true },
|
|
613
|
+
joinedAt: { type: Date, default: Date.now },
|
|
614
|
+
},
|
|
615
|
+
{ _id: true },
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
const HeadquartersSchema = new Schema(
|
|
619
|
+
{
|
|
620
|
+
street: String,
|
|
621
|
+
city: String,
|
|
622
|
+
state: String,
|
|
623
|
+
zip: String,
|
|
624
|
+
country: { type: String, default: 'US' },
|
|
625
|
+
coords: { lat: Number, lng: Number },
|
|
626
|
+
timezone: String,
|
|
627
|
+
},
|
|
628
|
+
{ _id: false },
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
interface Contact {
|
|
632
|
+
email: string
|
|
633
|
+
phone?: string
|
|
634
|
+
website?: string
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
interface Billing {
|
|
638
|
+
plan: string
|
|
639
|
+
mrr?: mongoose.Types.Decimal128
|
|
640
|
+
currency: string
|
|
641
|
+
cardLast4?: string
|
|
642
|
+
nextBillingDate?: Date
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
interface TeamMember {
|
|
646
|
+
userId: mongoose.Types.ObjectId
|
|
647
|
+
role: string
|
|
648
|
+
joinedAt?: Date
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
interface Headquarters {
|
|
652
|
+
street?: string
|
|
653
|
+
city?: string
|
|
654
|
+
state?: string
|
|
655
|
+
zip?: string
|
|
656
|
+
country?: string
|
|
657
|
+
coords?: { lat: number; lng: number }
|
|
658
|
+
timezone?: string
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
interface Organization {
|
|
662
|
+
name: string
|
|
663
|
+
slug: string
|
|
664
|
+
apiKey: mongoose.Types.UUID
|
|
665
|
+
active: boolean
|
|
666
|
+
contact: Contact
|
|
667
|
+
billing: Billing
|
|
668
|
+
headquarters: Headquarters
|
|
669
|
+
team: TeamMember[]
|
|
670
|
+
tags: string[]
|
|
671
|
+
domains: string[]
|
|
672
|
+
settings: Map<string, string>
|
|
673
|
+
featureFlags: Record<string, unknown>
|
|
674
|
+
logo?: Buffer
|
|
675
|
+
notes: string
|
|
676
|
+
seatCount: number
|
|
677
|
+
createdAt?: Date
|
|
678
|
+
updatedAt?: Date
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const OrganizationSchema = new Schema<Organization>(
|
|
682
|
+
{
|
|
683
|
+
name: { type: String, required: true },
|
|
684
|
+
slug: { type: String, required: true, unique: true },
|
|
685
|
+
apiKey: { type: Schema.Types.UUID, required: true },
|
|
686
|
+
active: { type: Boolean, default: true },
|
|
687
|
+
contact: { type: ContactSchema, required: true },
|
|
688
|
+
billing: { type: BillingSchema, default: () => ({}) },
|
|
689
|
+
headquarters: HeadquartersSchema,
|
|
690
|
+
team: [TeamMemberSchema],
|
|
691
|
+
tags: [String],
|
|
692
|
+
domains: [String],
|
|
693
|
+
settings: { type: Map, of: String },
|
|
694
|
+
featureFlags: { type: Schema.Types.Mixed, default: {} },
|
|
695
|
+
logo: Buffer,
|
|
696
|
+
notes: String,
|
|
697
|
+
seatCount: { type: Number, default: 1 },
|
|
698
|
+
},
|
|
699
|
+
{ timestamps: true },
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
const ORG_CREATED = 'org-created'
|
|
703
|
+
const ORG_UPDATED = 'org-updated'
|
|
704
|
+
const ORG_DELETED = 'org-deleted'
|
|
705
|
+
|
|
706
|
+
OrganizationSchema.plugin(patchHistoryPlugin, {
|
|
707
|
+
eventCreated: ORG_CREATED,
|
|
708
|
+
eventUpdated: ORG_UPDATED,
|
|
709
|
+
eventDeleted: ORG_DELETED,
|
|
710
|
+
omit: ['__v', 'createdAt', 'updatedAt', 'notes'],
|
|
711
|
+
getUser: () => ({ userId: 'system', role: 'service-account' }),
|
|
712
|
+
getReason: () => 'api-call',
|
|
713
|
+
getMetadata: () => ({ service: 'org-service', requestId: 'req-123' }),
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
const OrganizationModel = model<Organization>('Organization', OrganizationSchema)
|
|
717
|
+
|
|
718
|
+
AllTypesSchema.plugin(patchHistoryPlugin, {
|
|
719
|
+
omit: ['__v', 'createdAt', 'updatedAt'],
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
const AllTypesModel = model('AllTypes', AllTypesSchema)
|
|
723
|
+
|
|
724
|
+
describe('plugin — all mongoose schema types', () => {
|
|
725
|
+
const instance = server('plugin-all-types')
|
|
726
|
+
|
|
727
|
+
beforeAll(async () => {
|
|
728
|
+
await instance.create()
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
afterAll(async () => {
|
|
732
|
+
await instance.destroy()
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
beforeEach(async () => {
|
|
736
|
+
await mongoose.connection.collection('alltypes').deleteMany({})
|
|
737
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
afterEach(() => {
|
|
741
|
+
vi.resetAllMocks()
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('should create and track a document with every schema type', async () => {
|
|
745
|
+
const refId = new mongoose.Types.ObjectId()
|
|
746
|
+
const doc = await AllTypesModel.create({
|
|
747
|
+
str: 'hello',
|
|
748
|
+
num: 42,
|
|
749
|
+
bool: true,
|
|
750
|
+
date: new Date('2026-01-15'),
|
|
751
|
+
objectId: refId,
|
|
752
|
+
decimal: mongoose.Types.Decimal128.fromString('99.99'),
|
|
753
|
+
uuid: '550e8400-e29b-41d4-a716-446655440000',
|
|
754
|
+
buf: Buffer.from('binary data'),
|
|
755
|
+
mixed: { anything: [1, 'two', { three: true }] },
|
|
756
|
+
nested: { deep: { value: 'found it' } },
|
|
757
|
+
map: new Map([
|
|
758
|
+
['key1', 'val1'],
|
|
759
|
+
['key2', 'val2'],
|
|
760
|
+
]),
|
|
761
|
+
arrStr: ['a', 'b', 'c'],
|
|
762
|
+
arrNum: [1, 2, 3],
|
|
763
|
+
arrObjectId: [new mongoose.Types.ObjectId(), new mongoose.Types.ObjectId()],
|
|
764
|
+
arrNested: [
|
|
765
|
+
{ label: 'first', score: 10 },
|
|
766
|
+
{ label: 'second', score: 20 },
|
|
767
|
+
],
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
const [entry] = await HistoryModel.find({ collectionId: doc._id })
|
|
771
|
+
expect(entry?.op).toBe('create')
|
|
772
|
+
|
|
773
|
+
const saved = entry?.doc as Record<string, unknown>
|
|
774
|
+
expect(saved.str).toBe('hello')
|
|
775
|
+
expect(saved.num).toBe(42)
|
|
776
|
+
expect(saved.bool).toBe(true)
|
|
777
|
+
expect(saved.date).toBeDefined()
|
|
778
|
+
expect(saved.objectId).toBeDefined()
|
|
779
|
+
expect(saved.decimal).toBeDefined()
|
|
780
|
+
expect(saved.uuid).toBeDefined()
|
|
781
|
+
expect(saved.buf).toBeDefined()
|
|
782
|
+
expect(saved.mixed).toHaveProperty('anything')
|
|
783
|
+
expect(saved.nested).toEqual({ deep: { value: 'found it' } })
|
|
784
|
+
expect(saved.map).toBeDefined()
|
|
785
|
+
expect(saved.arrStr).toEqual(['a', 'b', 'c'])
|
|
786
|
+
expect(saved.arrNum).toEqual([1, 2, 3])
|
|
787
|
+
expect((saved.arrObjectId as unknown[]).length).toBe(2)
|
|
788
|
+
expect((saved.arrNested as unknown[]).length).toBe(2)
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it('should track String update', async () => {
|
|
792
|
+
const doc = await AllTypesModel.create({ str: 'before' })
|
|
793
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { str: 'after' }).exec()
|
|
794
|
+
|
|
795
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
796
|
+
expect(getPatch(update, '/str')?.value).toBe('after')
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('should track Number update', async () => {
|
|
800
|
+
const doc = await AllTypesModel.create({ num: 1 })
|
|
801
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { num: 999 }).exec()
|
|
802
|
+
|
|
803
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
804
|
+
expect(getPatch(update, '/num')?.value).toBe(999)
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it('should track Boolean toggle', async () => {
|
|
808
|
+
const doc = await AllTypesModel.create({ bool: false })
|
|
809
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { bool: true }).exec()
|
|
810
|
+
|
|
811
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
812
|
+
expect(getPatch(update, '/bool')?.value).toBe(true)
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
it('should track Date update', async () => {
|
|
816
|
+
const doc = await AllTypesModel.create({ date: new Date('2025-01-01') })
|
|
817
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { date: new Date('2026-06-15') }).exec()
|
|
818
|
+
|
|
819
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
820
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
821
|
+
expect(paths).toContain('/date')
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it('should track ObjectId reference change', async () => {
|
|
825
|
+
const id1 = new mongoose.Types.ObjectId()
|
|
826
|
+
const id2 = new mongoose.Types.ObjectId()
|
|
827
|
+
const doc = await AllTypesModel.create({ objectId: id1 })
|
|
828
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { objectId: id2 }).exec()
|
|
829
|
+
|
|
830
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
831
|
+
expect(getPatch(update, '/objectId')).toBeDefined()
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
it('should track Decimal128 update', async () => {
|
|
835
|
+
const doc = await AllTypesModel.create({ decimal: mongoose.Types.Decimal128.fromString('10.00') })
|
|
836
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { decimal: mongoose.Types.Decimal128.fromString('99.95') }).exec()
|
|
837
|
+
|
|
838
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
839
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
840
|
+
expect(paths.some((p) => p?.startsWith('/decimal'))).toBe(true)
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('should track UUID update', async () => {
|
|
844
|
+
const doc = await AllTypesModel.create({ uuid: '550e8400-e29b-41d4-a716-446655440000' })
|
|
845
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { uuid: '6ba7b810-9dad-11d1-80b4-00c04fd430c8' }).exec()
|
|
846
|
+
|
|
847
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
848
|
+
expect(getPatch(update, '/uuid')).toBeDefined()
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it('should track Buffer update', async () => {
|
|
852
|
+
const doc = await AllTypesModel.create({ buf: Buffer.from('old') })
|
|
853
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { buf: Buffer.from('new') }).exec()
|
|
854
|
+
|
|
855
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
856
|
+
expect(getPatch(update, '/buf')).toBeDefined()
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
it('should track Mixed type update (arbitrary object)', async () => {
|
|
860
|
+
const doc = await AllTypesModel.create({ mixed: { version: 1, data: [1, 2] } })
|
|
861
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { mixed: { version: 2, data: [1, 2, 3], extra: 'new' } }).exec()
|
|
862
|
+
|
|
863
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
864
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
865
|
+
expect(paths.some((p) => p?.startsWith('/mixed'))).toBe(true)
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
it('should track deeply nested field update', async () => {
|
|
869
|
+
const doc = await AllTypesModel.create({ nested: { deep: { value: 'old' } } })
|
|
870
|
+
doc.nested = { deep: { value: 'new' } }
|
|
871
|
+
await doc.save()
|
|
872
|
+
|
|
873
|
+
const [update] = await HistoryModel.find({ op: 'update', collectionId: doc._id })
|
|
874
|
+
expect(getPatch(update, '/nested/deep/value')?.value).toBe('new')
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
it('should track Map field update via save', async () => {
|
|
878
|
+
const doc = await AllTypesModel.create({ map: new Map([['a', '1']]) })
|
|
879
|
+
doc.map = new Map([
|
|
880
|
+
['a', '1'],
|
|
881
|
+
['b', '2'],
|
|
882
|
+
])
|
|
883
|
+
await doc.save()
|
|
884
|
+
|
|
885
|
+
const [update] = await HistoryModel.find({ op: 'update', collectionId: doc._id })
|
|
886
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
887
|
+
expect(paths.some((p) => p?.startsWith('/map'))).toBe(true)
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('should track array of strings mutation', async () => {
|
|
891
|
+
const doc = await AllTypesModel.create({ arrStr: ['x', 'y'] })
|
|
892
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { arrStr: ['x', 'y', 'z'] }).exec()
|
|
893
|
+
|
|
894
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
895
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
896
|
+
expect(paths.some((p) => p?.startsWith('/arrStr'))).toBe(true)
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
it('should track array of nested objects mutation', async () => {
|
|
900
|
+
const doc = await AllTypesModel.create({ arrNested: [{ label: 'a', score: 1 }] })
|
|
901
|
+
await AllTypesModel.updateOne(
|
|
902
|
+
{ _id: doc._id },
|
|
903
|
+
{
|
|
904
|
+
arrNested: [
|
|
905
|
+
{ label: 'a', score: 1 },
|
|
906
|
+
{ label: 'b', score: 2 },
|
|
907
|
+
],
|
|
908
|
+
},
|
|
909
|
+
).exec()
|
|
910
|
+
|
|
911
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
912
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
913
|
+
expect(paths.some((p) => p?.startsWith('/arrNested'))).toBe(true)
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
it('should track setting a field from undefined to a value', async () => {
|
|
917
|
+
const doc = await AllTypesModel.create({ str: 'exists' })
|
|
918
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { num: 42, bool: true, date: new Date() }).exec()
|
|
919
|
+
|
|
920
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
921
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
922
|
+
expect(paths).toContain('/num')
|
|
923
|
+
expect(paths).toContain('/bool')
|
|
924
|
+
expect(paths).toContain('/date')
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('should track setting a field to null', async () => {
|
|
928
|
+
const doc = await AllTypesModel.create({ str: 'hello', num: 42 })
|
|
929
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { str: null, num: null }).exec()
|
|
930
|
+
|
|
931
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
932
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
933
|
+
expect(paths).toContain('/str')
|
|
934
|
+
expect(paths).toContain('/num')
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
it.runIf(hasDouble)('should track Double update', async () => {
|
|
938
|
+
const doc = await AllTypesModel.create({ dbl: 3.14 })
|
|
939
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { dbl: 2.71 }).exec()
|
|
940
|
+
|
|
941
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
942
|
+
expect(getPatch(update, '/dbl')?.value).toBe(2.71)
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
it.runIf(hasInt32)('should track Int32 update', async () => {
|
|
946
|
+
const doc = await AllTypesModel.create({ int32: 100 })
|
|
947
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { int32: 200 }).exec()
|
|
948
|
+
|
|
949
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
950
|
+
expect(getPatch(update, '/int32')?.value).toBe(200)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
it.runIf(hasBigInt)('should track BigInt update', async () => {
|
|
954
|
+
const doc = await AllTypesModel.create({ bigint: BigInt(1000) })
|
|
955
|
+
await AllTypesModel.updateOne({ _id: doc._id }, { bigint: BigInt(9999) }).exec()
|
|
956
|
+
|
|
957
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
|
|
958
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
959
|
+
expect(paths.some((p) => p?.startsWith('/bigint'))).toBe(true)
|
|
960
|
+
})
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
// --- Populated documents ---
|
|
964
|
+
|
|
965
|
+
const AuthorSchema = new Schema({ name: String, email: String }, { timestamps: true })
|
|
966
|
+
AuthorSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
|
|
967
|
+
const AuthorModel = model('Author', AuthorSchema)
|
|
968
|
+
|
|
969
|
+
const ArticleSchema = new Schema(
|
|
970
|
+
{
|
|
971
|
+
title: String,
|
|
972
|
+
body: String,
|
|
973
|
+
author: { type: Schema.Types.ObjectId, ref: 'Author' },
|
|
974
|
+
reviewers: [{ type: Schema.Types.ObjectId, ref: 'Author' }],
|
|
975
|
+
},
|
|
976
|
+
{ timestamps: true },
|
|
977
|
+
)
|
|
978
|
+
ArticleSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
|
|
979
|
+
const ArticleModel = model('Article', ArticleSchema)
|
|
980
|
+
|
|
981
|
+
describe('plugin — populated documents', () => {
|
|
982
|
+
const instance = server('plugin-populated')
|
|
983
|
+
|
|
984
|
+
beforeAll(async () => {
|
|
985
|
+
await instance.create()
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
afterAll(async () => {
|
|
989
|
+
await instance.destroy()
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
beforeEach(async () => {
|
|
993
|
+
await mongoose.connection.collection('authors').deleteMany({})
|
|
994
|
+
await mongoose.connection.collection('articles').deleteMany({})
|
|
995
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
afterEach(() => {
|
|
999
|
+
vi.resetAllMocks()
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
it('should store ObjectId refs not populated objects in history', async () => {
|
|
1003
|
+
const author = await AuthorModel.create({ name: 'Jane', email: 'jane@example.com' })
|
|
1004
|
+
const article = await ArticleModel.create({ title: 'Test', body: 'Content', author: author._id })
|
|
1005
|
+
|
|
1006
|
+
const [entry] = await HistoryModel.find({ collectionId: article._id })
|
|
1007
|
+
const doc = entry?.doc as Record<string, unknown>
|
|
1008
|
+
|
|
1009
|
+
expect(doc.author).toBeDefined()
|
|
1010
|
+
expect(JSON.stringify(doc.author)).toContain(author._id.toString())
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
it('should track author ref change as ObjectId diff', async () => {
|
|
1014
|
+
const author1 = await AuthorModel.create({ name: 'Jane', email: 'jane@example.com' })
|
|
1015
|
+
const author2 = await AuthorModel.create({ name: 'John', email: 'john@example.com' })
|
|
1016
|
+
const article = await ArticleModel.create({ title: 'Test', body: 'Content', author: author1._id })
|
|
1017
|
+
|
|
1018
|
+
await ArticleModel.updateOne({ _id: article._id }, { author: author2._id }).exec()
|
|
1019
|
+
|
|
1020
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: article._id })
|
|
1021
|
+
expect(updates).toHaveLength(1)
|
|
1022
|
+
|
|
1023
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1024
|
+
expect(paths).toContain('/author')
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
it('should track changes to populated array refs', async () => {
|
|
1028
|
+
const reviewer1 = await AuthorModel.create({ name: 'R1', email: 'r1@example.com' })
|
|
1029
|
+
const reviewer2 = await AuthorModel.create({ name: 'R2', email: 'r2@example.com' })
|
|
1030
|
+
const article = await ArticleModel.create({ title: 'Reviewed', body: 'Content', reviewers: [reviewer1._id] })
|
|
1031
|
+
|
|
1032
|
+
await ArticleModel.updateOne({ _id: article._id }, { $push: { reviewers: reviewer2._id } }).exec()
|
|
1033
|
+
|
|
1034
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: article._id })
|
|
1035
|
+
expect(updates).toHaveLength(1)
|
|
1036
|
+
|
|
1037
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1038
|
+
expect(paths.some((p) => p?.startsWith('/reviewers'))).toBe(true)
|
|
1039
|
+
})
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
// --- Discriminators ---
|
|
1043
|
+
|
|
1044
|
+
const BaseEventSchema = new Schema({ timestamp: { type: Date, default: Date.now }, source: String }, { timestamps: true, discriminatorKey: 'kind' })
|
|
1045
|
+
BaseEventSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
|
|
1046
|
+
const BaseEventModel = model('BaseEvent', BaseEventSchema)
|
|
1047
|
+
|
|
1048
|
+
const ClickEventModel = BaseEventModel.discriminator('ClickEvent', new Schema({ url: String, buttonId: String }))
|
|
1049
|
+
const SignupEventModel = BaseEventModel.discriminator('SignupEvent', new Schema({ username: String, plan: String }))
|
|
1050
|
+
|
|
1051
|
+
describe('plugin — discriminators', () => {
|
|
1052
|
+
const instance = server('plugin-discriminators')
|
|
1053
|
+
|
|
1054
|
+
beforeAll(async () => {
|
|
1055
|
+
await instance.create()
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
afterAll(async () => {
|
|
1059
|
+
await instance.destroy()
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
beforeEach(async () => {
|
|
1063
|
+
await mongoose.connection.collection('baseevents').deleteMany({})
|
|
1064
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
afterEach(() => {
|
|
1068
|
+
vi.resetAllMocks()
|
|
1069
|
+
})
|
|
1070
|
+
|
|
1071
|
+
it('should create history for discriminator with type-specific fields', async () => {
|
|
1072
|
+
const click = await ClickEventModel.create({ source: 'web', url: 'https://example.com', buttonId: 'cta-1' })
|
|
1073
|
+
|
|
1074
|
+
const [entry] = await HistoryModel.find({ collectionId: click._id })
|
|
1075
|
+
const doc = entry?.doc as Record<string, unknown>
|
|
1076
|
+
|
|
1077
|
+
expect(doc.kind).toBe('ClickEvent')
|
|
1078
|
+
expect(doc.url).toBe('https://example.com')
|
|
1079
|
+
expect(doc.buttonId).toBe('cta-1')
|
|
1080
|
+
expect(doc.source).toBe('web')
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
it('should track updates to discriminator-specific fields', async () => {
|
|
1084
|
+
const click = await ClickEventModel.create({ source: 'web', url: 'https://old.com', buttonId: 'btn-1' })
|
|
1085
|
+
|
|
1086
|
+
await ClickEventModel.updateOne({ _id: click._id }, { url: 'https://new.com' }).exec()
|
|
1087
|
+
|
|
1088
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: click._id })
|
|
1089
|
+
expect(updates).toHaveLength(1)
|
|
1090
|
+
|
|
1091
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1092
|
+
expect(paths).toContain('/url')
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
it('should track different discriminator types independently', async () => {
|
|
1096
|
+
const click = await ClickEventModel.create({ source: 'web', url: 'https://example.com' })
|
|
1097
|
+
const signup = await SignupEventModel.create({ source: 'app', username: 'newuser', plan: 'free' })
|
|
1098
|
+
|
|
1099
|
+
await SignupEventModel.updateOne({ _id: signup._id }, { plan: 'pro' }).exec()
|
|
1100
|
+
|
|
1101
|
+
const clickHistory = await HistoryModel.find({ collectionId: click._id })
|
|
1102
|
+
const signupHistory = await HistoryModel.find({ collectionId: signup._id }).sort('createdAt')
|
|
1103
|
+
|
|
1104
|
+
expect(clickHistory).toHaveLength(1)
|
|
1105
|
+
expect(clickHistory[0]?.op).toBe('create')
|
|
1106
|
+
|
|
1107
|
+
expect(signupHistory).toHaveLength(2)
|
|
1108
|
+
expect(signupHistory[1]?.patch?.some((p) => p.path === '/plan')).toBe(true)
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
it('should delete discriminator and preserve type in history', async () => {
|
|
1112
|
+
const signup = await SignupEventModel.create({ source: 'app', username: 'todelete', plan: 'trial' })
|
|
1113
|
+
|
|
1114
|
+
await SignupEventModel.deleteOne({ _id: signup._id }).exec()
|
|
1115
|
+
|
|
1116
|
+
const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: signup._id })
|
|
1117
|
+
const doc = deletion?.doc as Record<string, unknown>
|
|
1118
|
+
|
|
1119
|
+
expect(doc.kind).toBe('SignupEvent')
|
|
1120
|
+
expect(doc.username).toBe('todelete')
|
|
1121
|
+
expect(doc.plan).toBe('trial')
|
|
1122
|
+
})
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
// --- Subdocument manipulation ---
|
|
1126
|
+
|
|
1127
|
+
const CommentSchema = new Schema({ text: String, rating: Number }, { timestamps: false })
|
|
1128
|
+
|
|
1129
|
+
const PostSchema = new Schema(
|
|
1130
|
+
{
|
|
1131
|
+
title: String,
|
|
1132
|
+
comments: [CommentSchema],
|
|
1133
|
+
featured: { type: CommentSchema, default: undefined },
|
|
1134
|
+
},
|
|
1135
|
+
{ timestamps: true },
|
|
1136
|
+
)
|
|
1137
|
+
PostSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
|
|
1138
|
+
const PostModel = model('Post', PostSchema)
|
|
1139
|
+
|
|
1140
|
+
describe('plugin — subdocument manipulation', () => {
|
|
1141
|
+
const instance = server('plugin-subdocs')
|
|
1142
|
+
|
|
1143
|
+
beforeAll(async () => {
|
|
1144
|
+
await instance.create()
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
afterAll(async () => {
|
|
1148
|
+
await instance.destroy()
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
beforeEach(async () => {
|
|
1152
|
+
await mongoose.connection.collection('posts').deleteMany({})
|
|
1153
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
afterEach(() => {
|
|
1157
|
+
vi.resetAllMocks()
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
it('should track pushing subdoc via mongoose array push then save', async () => {
|
|
1161
|
+
const post = await PostModel.create({ title: 'Hello', comments: [{ text: 'First', rating: 5 }] })
|
|
1162
|
+
|
|
1163
|
+
post.comments.push({ text: 'Second', rating: 3 } as never)
|
|
1164
|
+
await post.save()
|
|
1165
|
+
|
|
1166
|
+
const updates = await HistoryModel.find({ op: 'update', collectionId: post._id })
|
|
1167
|
+
expect(updates).toHaveLength(1)
|
|
1168
|
+
|
|
1169
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1170
|
+
expect(paths.some((p) => p?.startsWith('/comments'))).toBe(true)
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
it('should track removing subdoc from array then save', async () => {
|
|
1174
|
+
const post = await PostModel.create({
|
|
1175
|
+
title: 'Hello',
|
|
1176
|
+
comments: [
|
|
1177
|
+
{ text: 'A', rating: 1 },
|
|
1178
|
+
{ text: 'B', rating: 2 },
|
|
1179
|
+
],
|
|
1180
|
+
})
|
|
1181
|
+
|
|
1182
|
+
post.comments.splice(0, 1)
|
|
1183
|
+
await post.save()
|
|
1184
|
+
|
|
1185
|
+
const updates = await HistoryModel.find({ op: 'update', collectionId: post._id })
|
|
1186
|
+
expect(updates).toHaveLength(1)
|
|
1187
|
+
|
|
1188
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1189
|
+
expect(paths.some((p) => p?.startsWith('/comments'))).toBe(true)
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
it('should track modifying a subdoc field then saving parent', async () => {
|
|
1193
|
+
const post = await PostModel.create({ title: 'Hello', comments: [{ text: 'Original', rating: 5 }] })
|
|
1194
|
+
|
|
1195
|
+
post.comments[0].text = 'Edited'
|
|
1196
|
+
await post.save()
|
|
1197
|
+
|
|
1198
|
+
const updates = await HistoryModel.find({ op: 'update', collectionId: post._id })
|
|
1199
|
+
expect(updates).toHaveLength(1)
|
|
1200
|
+
|
|
1201
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1202
|
+
expect(paths.some((p) => p?.includes('/comments') && p?.includes('/text'))).toBe(true)
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
it('should track setting single nested subdoc', async () => {
|
|
1206
|
+
const post = await PostModel.create({ title: 'Hello', comments: [] })
|
|
1207
|
+
|
|
1208
|
+
post.featured = { text: 'Featured comment', rating: 10 } as never
|
|
1209
|
+
await post.save()
|
|
1210
|
+
|
|
1211
|
+
const updates = await HistoryModel.find({ op: 'update', collectionId: post._id })
|
|
1212
|
+
expect(updates).toHaveLength(1)
|
|
1213
|
+
|
|
1214
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1215
|
+
expect(paths.some((p) => p?.startsWith('/featured'))).toBe(true)
|
|
1216
|
+
})
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
// --- Virtuals, getters, validation ---
|
|
1220
|
+
|
|
1221
|
+
const ProfileSchema = new Schema(
|
|
1222
|
+
{
|
|
1223
|
+
firstName: String,
|
|
1224
|
+
lastName: String,
|
|
1225
|
+
email: {
|
|
1226
|
+
type: String,
|
|
1227
|
+
get: (v: string) => v?.toLowerCase(),
|
|
1228
|
+
required: true,
|
|
1229
|
+
},
|
|
1230
|
+
age: { type: Number, min: 0, max: 150 },
|
|
1231
|
+
},
|
|
1232
|
+
{ timestamps: true, toJSON: { getters: true }, toObject: { getters: false } },
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
ProfileSchema.virtual('fullName').get(function () {
|
|
1236
|
+
return `${this.firstName} ${this.lastName}`
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
ProfileSchema.plugin(patchHistoryPlugin, { omit: ['__v', 'createdAt', 'updatedAt'] })
|
|
1240
|
+
const ProfileModel = model('Profile', ProfileSchema)
|
|
1241
|
+
|
|
1242
|
+
describe('plugin — virtuals, getters, validation', () => {
|
|
1243
|
+
const instance = server('plugin-virtuals')
|
|
1244
|
+
|
|
1245
|
+
beforeAll(async () => {
|
|
1246
|
+
await instance.create()
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
afterAll(async () => {
|
|
1250
|
+
await instance.destroy()
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
beforeEach(async () => {
|
|
1254
|
+
await mongoose.connection.collection('profiles').deleteMany({})
|
|
1255
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
afterEach(() => {
|
|
1259
|
+
vi.resetAllMocks()
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
it('should NOT include virtual fields in history', async () => {
|
|
1263
|
+
const profile = await ProfileModel.create({ firstName: 'Jane', lastName: 'Doe', email: 'Jane@Example.COM' })
|
|
1264
|
+
|
|
1265
|
+
const [entry] = await HistoryModel.find({ collectionId: profile._id })
|
|
1266
|
+
const doc = entry?.doc as Record<string, unknown>
|
|
1267
|
+
|
|
1268
|
+
expect(doc).not.toHaveProperty('fullName')
|
|
1269
|
+
expect(doc.firstName).toBe('Jane')
|
|
1270
|
+
expect(doc.lastName).toBe('Doe')
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
it('should store raw email value not getter-transformed in history', async () => {
|
|
1274
|
+
const profile = await ProfileModel.create({ firstName: 'Jane', lastName: 'Doe', email: 'Jane@Example.COM' })
|
|
1275
|
+
|
|
1276
|
+
const [entry] = await HistoryModel.find({ collectionId: profile._id })
|
|
1277
|
+
const doc = entry?.doc as Record<string, unknown>
|
|
1278
|
+
|
|
1279
|
+
expect(doc.email).toBe('Jane@Example.COM')
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
it('should NOT create history when validation fails', async () => {
|
|
1283
|
+
try {
|
|
1284
|
+
await ProfileModel.create({ firstName: 'Bad', lastName: 'User' })
|
|
1285
|
+
} catch {
|
|
1286
|
+
// expected — email is required
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const history = await HistoryModel.find({})
|
|
1290
|
+
expect(history).toHaveLength(0)
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
it('should NOT create update history when validation fails on save', async () => {
|
|
1294
|
+
const profile = await ProfileModel.create({ firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com' })
|
|
1295
|
+
|
|
1296
|
+
const historyBefore = await HistoryModel.find({})
|
|
1297
|
+
expect(historyBefore).toHaveLength(1)
|
|
1298
|
+
|
|
1299
|
+
profile.age = -5
|
|
1300
|
+
try {
|
|
1301
|
+
await profile.save()
|
|
1302
|
+
} catch {
|
|
1303
|
+
// expected — age min 0
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const historyAfter = await HistoryModel.find({})
|
|
1307
|
+
expect(historyAfter).toHaveLength(1)
|
|
1308
|
+
})
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
// --- Concurrent updates & large batch ---
|
|
1312
|
+
|
|
1313
|
+
describe('plugin — concurrent updates', () => {
|
|
1314
|
+
const instance = server('plugin-concurrent')
|
|
1315
|
+
|
|
1316
|
+
beforeAll(async () => {
|
|
1317
|
+
await instance.create()
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1320
|
+
afterAll(async () => {
|
|
1321
|
+
await instance.destroy()
|
|
1322
|
+
})
|
|
1323
|
+
|
|
1324
|
+
beforeEach(async () => {
|
|
1325
|
+
await mongoose.connection.collection('ecomorders').deleteMany({})
|
|
1326
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
afterEach(() => {
|
|
1330
|
+
vi.resetAllMocks()
|
|
1331
|
+
})
|
|
1332
|
+
|
|
1333
|
+
it('should handle sequential rapid updates with correct versions', async () => {
|
|
1334
|
+
const order = await EcomOrderModel.create({
|
|
1335
|
+
orderNumber: `CONCURRENT-${Date.now()}`,
|
|
1336
|
+
customerId,
|
|
1337
|
+
items: [{ productId: productIds[0], sku: 'C-1', name: 'Item', quantity: 1, price: { amount: 10 } }],
|
|
1338
|
+
shippingAddress: { street: '1 St', city: 'C', zip: '00000' },
|
|
1339
|
+
totals: { subtotal: { amount: 10 }, tax: { amount: 1 }, shipping: { amount: 5 }, total: { amount: 16 } },
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
for (let i = 1; i <= 5; i++) {
|
|
1343
|
+
await EcomOrderModel.updateOne({ _id: order._id }, { $set: { priority: i } }).exec()
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
|
|
1347
|
+
expect(history).toHaveLength(6)
|
|
1348
|
+
|
|
1349
|
+
for (let i = 1; i <= 5; i++) {
|
|
1350
|
+
expect(history[i]?.version).toBe(i)
|
|
1351
|
+
}
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
it('should handle deleteMany with preDelete on 15 documents', async () => {
|
|
1355
|
+
const orders = Array.from({ length: 15 }, (_, i) => ({
|
|
1356
|
+
orderNumber: `BATCH-DEL-${Date.now()}-${i}`,
|
|
1357
|
+
customerId,
|
|
1358
|
+
tags: ['batch-delete'],
|
|
1359
|
+
items: [{ productId: productIds[0], sku: `BD-${i}`, name: `Batch ${i}`, quantity: 1, price: { amount: 5 } }],
|
|
1360
|
+
shippingAddress: { street: `${i} St`, city: 'BD', zip: '00000' },
|
|
1361
|
+
totals: { subtotal: { amount: 5 }, tax: { amount: 0 }, shipping: { amount: 0 }, total: { amount: 5 } },
|
|
1362
|
+
}))
|
|
1363
|
+
|
|
1364
|
+
await EcomOrderModel.insertMany(orders)
|
|
1365
|
+
|
|
1366
|
+
const createHistory = await HistoryModel.find({ op: 'create' })
|
|
1367
|
+
expect(createHistory).toHaveLength(15)
|
|
1368
|
+
|
|
1369
|
+
await EcomOrderModel.deleteMany({ tags: 'batch-delete' }).exec()
|
|
1370
|
+
|
|
1371
|
+
const deleteHistory = await HistoryModel.find({ op: 'deleteMany' })
|
|
1372
|
+
expect(deleteHistory).toHaveLength(15)
|
|
1373
|
+
|
|
1374
|
+
for (const entry of deleteHistory) {
|
|
1375
|
+
expect(entry.doc).toHaveProperty('orderNumber')
|
|
1376
|
+
expect(entry.doc).not.toHaveProperty('internalNotes')
|
|
1377
|
+
}
|
|
1378
|
+
})
|
|
1379
|
+
})
|
|
1380
|
+
|
|
1381
|
+
// --- Additional delete operations ---
|
|
1382
|
+
|
|
1383
|
+
describe('plugin — findOneAndDelete / findByIdAndDelete', () => {
|
|
1384
|
+
const instance = server('plugin-deletes')
|
|
1385
|
+
|
|
1386
|
+
beforeAll(async () => {
|
|
1387
|
+
await instance.create()
|
|
1388
|
+
})
|
|
1389
|
+
|
|
1390
|
+
afterAll(async () => {
|
|
1391
|
+
await instance.destroy()
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
beforeEach(async () => {
|
|
1395
|
+
await mongoose.connection.collection('ecomorders').deleteMany({})
|
|
1396
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
afterEach(() => {
|
|
1400
|
+
vi.resetAllMocks()
|
|
1401
|
+
})
|
|
1402
|
+
|
|
1403
|
+
it('should track findOneAndDelete with full document snapshot', async () => {
|
|
1404
|
+
const order = await EcomOrderModel.create({
|
|
1405
|
+
orderNumber: `FOAD-${Date.now()}`,
|
|
1406
|
+
customerId,
|
|
1407
|
+
items: [{ productId: productIds[0], sku: 'FOAD-1', name: 'FindAndDel', quantity: 1, price: { amount: 25 } }],
|
|
1408
|
+
shippingAddress: { street: '1 St', city: 'FD', zip: '00000' },
|
|
1409
|
+
totals: { subtotal: { amount: 25 }, tax: { amount: 2 }, shipping: { amount: 5 }, total: { amount: 32 } },
|
|
1410
|
+
tags: ['findAndDelete'],
|
|
1411
|
+
})
|
|
1412
|
+
|
|
1413
|
+
await EcomOrderModel.findOneAndDelete({ _id: order._id }).exec()
|
|
1414
|
+
|
|
1415
|
+
const deletion = await HistoryModel.findOne({ op: 'findOneAndDelete', collectionId: order._id })
|
|
1416
|
+
expect(deletion).toBeDefined()
|
|
1417
|
+
expect(deletion?.doc).toHaveProperty('orderNumber')
|
|
1418
|
+
expect(deletion?.doc).toHaveProperty('items')
|
|
1419
|
+
expect(deletion?.doc).not.toHaveProperty('internalNotes')
|
|
1420
|
+
})
|
|
1421
|
+
|
|
1422
|
+
it('should track findByIdAndDelete with full document snapshot', async () => {
|
|
1423
|
+
const order = await EcomOrderModel.create({
|
|
1424
|
+
orderNumber: `FBAD-${Date.now()}`,
|
|
1425
|
+
customerId,
|
|
1426
|
+
items: [{ productId: productIds[0], sku: 'FBAD-1', name: 'ByIdDel', quantity: 1, price: { amount: 15 } }],
|
|
1427
|
+
shippingAddress: { street: '2 St', city: 'BD', zip: '00000' },
|
|
1428
|
+
totals: { subtotal: { amount: 15 }, tax: { amount: 1 }, shipping: { amount: 3 }, total: { amount: 19 } },
|
|
1429
|
+
})
|
|
1430
|
+
|
|
1431
|
+
await EcomOrderModel.findByIdAndDelete(order._id).exec()
|
|
1432
|
+
|
|
1433
|
+
const history = await HistoryModel.find({ collectionId: order._id }).sort('createdAt')
|
|
1434
|
+
expect(history.length).toBeGreaterThanOrEqual(2)
|
|
1435
|
+
|
|
1436
|
+
const deletion = history.find((h) => h.op.includes('delete') || h.op.includes('Delete'))
|
|
1437
|
+
expect(deletion).toBeDefined()
|
|
1438
|
+
expect(deletion?.doc).toHaveProperty('orderNumber')
|
|
1439
|
+
})
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
// --- replaceOne ---
|
|
1443
|
+
|
|
1444
|
+
describe('plugin — replaceOne', () => {
|
|
1445
|
+
const instance = server('plugin-replace')
|
|
1446
|
+
|
|
1447
|
+
beforeAll(async () => {
|
|
1448
|
+
await instance.create()
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1451
|
+
afterAll(async () => {
|
|
1452
|
+
await instance.destroy()
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
beforeEach(async () => {
|
|
1456
|
+
await mongoose.connection.collection('ecomorders').deleteMany({})
|
|
1457
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
1458
|
+
})
|
|
1459
|
+
|
|
1460
|
+
afterEach(() => {
|
|
1461
|
+
vi.resetAllMocks()
|
|
1462
|
+
})
|
|
1463
|
+
|
|
1464
|
+
it('should track replaceOne with full document replacement', async () => {
|
|
1465
|
+
const order = await EcomOrderModel.create({
|
|
1466
|
+
orderNumber: `REPLACE-${Date.now()}`,
|
|
1467
|
+
customerId,
|
|
1468
|
+
status: 'pending',
|
|
1469
|
+
items: [{ productId: productIds[0], sku: 'OLD-1', name: 'Old Item', quantity: 1, price: { amount: 10 } }],
|
|
1470
|
+
shippingAddress: { street: '1 Old St', city: 'OldCity', zip: '00000' },
|
|
1471
|
+
totals: { subtotal: { amount: 10 }, tax: { amount: 1 }, shipping: { amount: 2 }, total: { amount: 13 } },
|
|
1472
|
+
tags: ['original'],
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
await EcomOrderModel.replaceOne(
|
|
1476
|
+
{ _id: order._id },
|
|
1477
|
+
{
|
|
1478
|
+
orderNumber: order.orderNumber,
|
|
1479
|
+
customerId,
|
|
1480
|
+
status: 'replaced',
|
|
1481
|
+
items: [{ productId: productIds[1], sku: 'NEW-1', name: 'New Item', quantity: 5, price: { amount: 99 } }],
|
|
1482
|
+
shippingAddress: { street: '2 New St', city: 'NewCity', zip: '11111' },
|
|
1483
|
+
totals: { subtotal: { amount: 99 }, tax: { amount: 9 }, shipping: { amount: 0 }, total: { amount: 108 } },
|
|
1484
|
+
tags: ['replaced'],
|
|
1485
|
+
},
|
|
1486
|
+
).exec()
|
|
1487
|
+
|
|
1488
|
+
const updates = await HistoryModel.find({ op: 'replaceOne', collectionId: order._id })
|
|
1489
|
+
expect(updates).toHaveLength(1)
|
|
1490
|
+
expect(updates[0]?.patch?.length).toBeGreaterThan(0)
|
|
1491
|
+
|
|
1492
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1493
|
+
expect(paths.some((p) => p?.includes('/status'))).toBe(true)
|
|
1494
|
+
expect(paths.some((p) => p?.includes('/items'))).toBe(true)
|
|
1495
|
+
expect(paths.some((p) => p?.includes('/shippingAddress'))).toBe(true)
|
|
1496
|
+
})
|
|
1497
|
+
})
|
|
1498
|
+
|
|
1499
|
+
// --- Organization e2e lifecycle ---
|
|
1500
|
+
|
|
1501
|
+
describe('plugin — organization e2e lifecycle', () => {
|
|
1502
|
+
const instance = server('plugin-org-e2e')
|
|
1503
|
+
|
|
1504
|
+
beforeAll(async () => {
|
|
1505
|
+
await instance.create()
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
afterAll(async () => {
|
|
1509
|
+
await instance.destroy()
|
|
1510
|
+
})
|
|
1511
|
+
|
|
1512
|
+
beforeEach(async () => {
|
|
1513
|
+
await mongoose.connection.collection('organizations').deleteMany({})
|
|
1514
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
afterEach(() => {
|
|
1518
|
+
vi.resetAllMocks()
|
|
1519
|
+
})
|
|
1520
|
+
|
|
1521
|
+
const makeOrg = () => ({
|
|
1522
|
+
name: 'Acme Corp',
|
|
1523
|
+
slug: `acme-${Date.now()}`,
|
|
1524
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
1525
|
+
active: true,
|
|
1526
|
+
contact: { email: 'admin@acme.com', phone: '+1-555-0100', website: 'https://acme.com' },
|
|
1527
|
+
billing: {
|
|
1528
|
+
plan: 'pro' as const,
|
|
1529
|
+
mrr: mongoose.Types.Decimal128.fromString('499.00'),
|
|
1530
|
+
currency: 'USD',
|
|
1531
|
+
cardLast4: '4242',
|
|
1532
|
+
nextBillingDate: new Date('2026-05-01'),
|
|
1533
|
+
},
|
|
1534
|
+
headquarters: {
|
|
1535
|
+
street: '100 Innovation Dr',
|
|
1536
|
+
city: 'San Francisco',
|
|
1537
|
+
state: 'CA',
|
|
1538
|
+
zip: '94105',
|
|
1539
|
+
country: 'US',
|
|
1540
|
+
coords: { lat: 37.7749, lng: -122.4194 },
|
|
1541
|
+
timezone: 'America/Los_Angeles',
|
|
1542
|
+
},
|
|
1543
|
+
team: [
|
|
1544
|
+
{ userId: new mongoose.Types.ObjectId(), role: 'owner' },
|
|
1545
|
+
{ userId: new mongoose.Types.ObjectId(), role: 'admin' },
|
|
1546
|
+
{ userId: new mongoose.Types.ObjectId(), role: 'member' },
|
|
1547
|
+
],
|
|
1548
|
+
tags: ['saas', 'enterprise', 'active'],
|
|
1549
|
+
domains: ['acme.com', 'acme.io'],
|
|
1550
|
+
settings: new Map([
|
|
1551
|
+
['theme', 'dark'],
|
|
1552
|
+
['locale', 'en-US'],
|
|
1553
|
+
['notifications', 'enabled'],
|
|
1554
|
+
]),
|
|
1555
|
+
featureFlags: { betaDashboard: true, newBilling: false, aiAssistant: { enabled: true, model: 'claude' } },
|
|
1556
|
+
logo: Buffer.from('fake-png-data'),
|
|
1557
|
+
notes: 'Internal: VIP customer, handle with care',
|
|
1558
|
+
seatCount: 25,
|
|
1559
|
+
})
|
|
1560
|
+
|
|
1561
|
+
it('should create organization and record full history', async () => {
|
|
1562
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1563
|
+
|
|
1564
|
+
const history = await HistoryModel.find({ collectionId: org._id })
|
|
1565
|
+
expect(history).toHaveLength(1)
|
|
1566
|
+
|
|
1567
|
+
const [entry] = history
|
|
1568
|
+
expect(entry?.op).toBe('create')
|
|
1569
|
+
expect(entry?.version).toBe(0)
|
|
1570
|
+
|
|
1571
|
+
const doc = entry?.doc as Record<string, unknown>
|
|
1572
|
+
expect(doc.name).toBe('Acme Corp')
|
|
1573
|
+
expect(doc.active).toBe(true)
|
|
1574
|
+
expect(doc.contact).toHaveProperty('email', 'admin@acme.com')
|
|
1575
|
+
expect(doc.billing).toHaveProperty('plan', 'pro')
|
|
1576
|
+
expect(doc.billing).toHaveProperty('cardLast4', '4242')
|
|
1577
|
+
expect(doc.headquarters).toHaveProperty('city', 'San Francisco')
|
|
1578
|
+
expect((doc.headquarters as Record<string, unknown>).coords).toHaveProperty('lat', 37.7749)
|
|
1579
|
+
expect((doc.team as unknown[]).length).toBe(3)
|
|
1580
|
+
expect(doc.tags).toEqual(['saas', 'enterprise', 'active'])
|
|
1581
|
+
expect(doc.domains).toEqual(['acme.com', 'acme.io'])
|
|
1582
|
+
expect(doc.settings).toBeDefined()
|
|
1583
|
+
expect(doc.featureFlags).toHaveProperty('betaDashboard', true)
|
|
1584
|
+
expect(doc.logo).toBeDefined()
|
|
1585
|
+
expect(doc.seatCount).toBe(25)
|
|
1586
|
+
expect(doc).not.toHaveProperty('notes')
|
|
1587
|
+
expect(doc).not.toHaveProperty('__v')
|
|
1588
|
+
expect(doc).not.toHaveProperty('createdAt')
|
|
1589
|
+
})
|
|
1590
|
+
|
|
1591
|
+
it('should track billing plan upgrade via save', async () => {
|
|
1592
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1593
|
+
|
|
1594
|
+
org.billing.plan = 'enterprise'
|
|
1595
|
+
org.billing.mrr = mongoose.Types.Decimal128.fromString('1299.00')
|
|
1596
|
+
org.seatCount = 100
|
|
1597
|
+
await org.save()
|
|
1598
|
+
|
|
1599
|
+
const updates = await HistoryModel.find({ op: 'update', collectionId: org._id })
|
|
1600
|
+
expect(updates).toHaveLength(1)
|
|
1601
|
+
|
|
1602
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1603
|
+
expect(paths.some((p) => p?.includes('/billing'))).toBe(true)
|
|
1604
|
+
expect(paths).toContain('/seatCount')
|
|
1605
|
+
})
|
|
1606
|
+
|
|
1607
|
+
it('should track team member changes via updateOne', async () => {
|
|
1608
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1609
|
+
const newMember = { userId: new mongoose.Types.ObjectId(), role: 'viewer' }
|
|
1610
|
+
|
|
1611
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $push: { team: newMember } }).exec()
|
|
1612
|
+
|
|
1613
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1614
|
+
expect(updates).toHaveLength(1)
|
|
1615
|
+
|
|
1616
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1617
|
+
expect(paths.some((p) => p?.startsWith('/team'))).toBe(true)
|
|
1618
|
+
})
|
|
1619
|
+
|
|
1620
|
+
it('should track featureFlags Mixed and settings Map changes', async () => {
|
|
1621
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1622
|
+
|
|
1623
|
+
org.featureFlags = { betaDashboard: true, newBilling: true, aiAssistant: { enabled: true, model: 'opus' } }
|
|
1624
|
+
org.settings.set('theme', 'light')
|
|
1625
|
+
org.settings.set('newKey', 'newVal')
|
|
1626
|
+
await org.save()
|
|
1627
|
+
|
|
1628
|
+
const updates = await HistoryModel.find({ op: 'update', collectionId: org._id })
|
|
1629
|
+
expect(updates).toHaveLength(1)
|
|
1630
|
+
|
|
1631
|
+
const paths = (updates[0]?.patch ?? []).map((p) => p.path ?? '')
|
|
1632
|
+
expect(paths.some((p) => p.includes('featureFlags'))).toBe(true)
|
|
1633
|
+
expect(paths.some((p) => p.includes('settings'))).toBe(true)
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
it('should track headquarters relocation via findOneAndUpdate', async () => {
|
|
1637
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1638
|
+
|
|
1639
|
+
await OrganizationModel.findOneAndUpdate(
|
|
1640
|
+
{ _id: org._id },
|
|
1641
|
+
{
|
|
1642
|
+
headquarters: {
|
|
1643
|
+
street: '1 Austin Blvd',
|
|
1644
|
+
city: 'Austin',
|
|
1645
|
+
state: 'TX',
|
|
1646
|
+
zip: '73301',
|
|
1647
|
+
country: 'US',
|
|
1648
|
+
coords: { lat: 30.2672, lng: -97.7431 },
|
|
1649
|
+
timezone: 'America/Chicago',
|
|
1650
|
+
},
|
|
1651
|
+
},
|
|
1652
|
+
).exec()
|
|
1653
|
+
|
|
1654
|
+
const updates = await HistoryModel.find({ op: 'findOneAndUpdate', collectionId: org._id })
|
|
1655
|
+
expect(updates).toHaveLength(1)
|
|
1656
|
+
|
|
1657
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1658
|
+
expect(paths.some((p) => p?.includes('/headquarters'))).toBe(true)
|
|
1659
|
+
})
|
|
1660
|
+
|
|
1661
|
+
it('should track domain and tag array changes', async () => {
|
|
1662
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1663
|
+
|
|
1664
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $addToSet: { domains: 'acme.dev' }, $pull: { tags: 'active' } }).exec()
|
|
1665
|
+
|
|
1666
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1667
|
+
expect(updates).toHaveLength(1)
|
|
1668
|
+
|
|
1669
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1670
|
+
expect(paths.some((p) => p?.startsWith('/domains'))).toBe(true)
|
|
1671
|
+
expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
|
|
1672
|
+
})
|
|
1673
|
+
|
|
1674
|
+
it('should track deactivation and record deletion', async () => {
|
|
1675
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1676
|
+
|
|
1677
|
+
await OrganizationModel.updateOne({ _id: org._id }, { active: false }).exec()
|
|
1678
|
+
|
|
1679
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1680
|
+
expect(updates).toHaveLength(1)
|
|
1681
|
+
expect(getPatch(updates[0], '/active')?.value).toBe(false)
|
|
1682
|
+
|
|
1683
|
+
await OrganizationModel.deleteOne({ _id: org._id }).exec()
|
|
1684
|
+
|
|
1685
|
+
const allHistory = await HistoryModel.find({ collectionId: org._id }).sort('createdAt')
|
|
1686
|
+
expect(allHistory.length).toBe(3)
|
|
1687
|
+
expect(allHistory[0]?.op).toBe('create')
|
|
1688
|
+
expect(allHistory[1]?.op).toBe('updateOne')
|
|
1689
|
+
expect(allHistory[2]?.op).toBe('deleteOne')
|
|
1690
|
+
expect(allHistory[2]?.doc).toHaveProperty('name', 'Acme Corp')
|
|
1691
|
+
expect(allHistory[2]?.doc).not.toHaveProperty('notes')
|
|
1692
|
+
})
|
|
1693
|
+
|
|
1694
|
+
it('should handle full lifecycle: create → multiple updates → delete with version tracking', async () => {
|
|
1695
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1696
|
+
|
|
1697
|
+
org.name = 'Acme Inc'
|
|
1698
|
+
org.contact.email = 'hello@acme.io'
|
|
1699
|
+
await org.save()
|
|
1700
|
+
|
|
1701
|
+
org.billing.plan = 'enterprise'
|
|
1702
|
+
org.seatCount = 200
|
|
1703
|
+
await org.save()
|
|
1704
|
+
|
|
1705
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $addToSet: { tags: 'flagship' } }).exec()
|
|
1706
|
+
|
|
1707
|
+
await OrganizationModel.deleteOne({ _id: org._id }).exec()
|
|
1708
|
+
|
|
1709
|
+
const history = await HistoryModel.find({ collectionId: org._id }).sort('createdAt')
|
|
1710
|
+
expect(history.length).toBe(5)
|
|
1711
|
+
|
|
1712
|
+
expect(history[0]?.op).toBe('create')
|
|
1713
|
+
expect(history[0]?.version).toBe(0)
|
|
1714
|
+
|
|
1715
|
+
expect(history[1]?.op).toBe('update')
|
|
1716
|
+
expect(history[1]?.version).toBe(1)
|
|
1717
|
+
|
|
1718
|
+
expect(history[2]?.op).toBe('update')
|
|
1719
|
+
expect(history[2]?.version).toBe(2)
|
|
1720
|
+
|
|
1721
|
+
expect(history[3]?.op).toBe('updateOne')
|
|
1722
|
+
expect(history[3]?.version).toBe(3)
|
|
1723
|
+
|
|
1724
|
+
expect(history[4]?.op).toBe('deleteOne')
|
|
1725
|
+
})
|
|
1726
|
+
|
|
1727
|
+
it('should track insertMany with history for each document', async () => {
|
|
1728
|
+
await OrganizationModel.insertMany([
|
|
1729
|
+
{ ...makeOrg(), slug: `bulk-a-${Date.now()}`, name: 'Bulk A' },
|
|
1730
|
+
{ ...makeOrg(), slug: `bulk-b-${Date.now()}`, name: 'Bulk B' },
|
|
1731
|
+
{ ...makeOrg(), slug: `bulk-c-${Date.now()}`, name: 'Bulk C' },
|
|
1732
|
+
])
|
|
1733
|
+
|
|
1734
|
+
const history = await HistoryModel.find({ op: 'create' }).sort('doc.name')
|
|
1735
|
+
expect(history).toHaveLength(3)
|
|
1736
|
+
|
|
1737
|
+
for (const entry of history) {
|
|
1738
|
+
expect(entry.version).toBe(0)
|
|
1739
|
+
expect(entry.doc).toHaveProperty('name')
|
|
1740
|
+
expect(entry.doc).toHaveProperty('apiKey')
|
|
1741
|
+
expect(entry.doc).not.toHaveProperty('notes')
|
|
1742
|
+
expect(entry.doc).not.toHaveProperty('__v')
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const names = history.map((h) => (h.doc as Record<string, unknown>).name)
|
|
1746
|
+
expect(names).toContain('Bulk A')
|
|
1747
|
+
expect(names).toContain('Bulk B')
|
|
1748
|
+
expect(names).toContain('Bulk C')
|
|
1749
|
+
})
|
|
1750
|
+
|
|
1751
|
+
it('should track updateMany across multiple organizations', async () => {
|
|
1752
|
+
await OrganizationModel.create({ ...makeOrg(), slug: `many-a-${Date.now()}`, tags: ['region-eu'] })
|
|
1753
|
+
await OrganizationModel.create({ ...makeOrg(), slug: `many-b-${Date.now()}`, tags: ['region-eu'] })
|
|
1754
|
+
await OrganizationModel.create({ ...makeOrg(), slug: `many-c-${Date.now()}`, tags: ['region-us'] })
|
|
1755
|
+
|
|
1756
|
+
await OrganizationModel.updateMany({ tags: 'region-eu' }, { $set: { active: false } }).exec()
|
|
1757
|
+
|
|
1758
|
+
const updates = await HistoryModel.find({ op: 'updateMany' })
|
|
1759
|
+
expect(updates).toHaveLength(2)
|
|
1760
|
+
|
|
1761
|
+
for (const entry of updates) {
|
|
1762
|
+
expect(entry.patch?.length).toBeGreaterThan(0)
|
|
1763
|
+
const paths = entry.patch?.map((p) => p.path) ?? []
|
|
1764
|
+
expect(paths).toContain('/active')
|
|
1765
|
+
}
|
|
1766
|
+
})
|
|
1767
|
+
|
|
1768
|
+
it('should track findByIdAndUpdate (mongoose normalizes op to findOneAndUpdate)', async () => {
|
|
1769
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1770
|
+
|
|
1771
|
+
await OrganizationModel.findByIdAndUpdate(org._id, { name: 'Acme Renamed' }).exec()
|
|
1772
|
+
|
|
1773
|
+
const updates = await HistoryModel.find({ collectionId: org._id, version: { $gt: 0 } })
|
|
1774
|
+
expect(updates).toHaveLength(1)
|
|
1775
|
+
expect(updates[0]?.op).toBe('findOneAndUpdate')
|
|
1776
|
+
expect(updates[0]?.patch?.find((p) => p.path === '/name' && p.op === 'replace')?.value).toBe('Acme Renamed')
|
|
1777
|
+
})
|
|
1778
|
+
|
|
1779
|
+
it('should track findOneAndReplace with full document swap', async () => {
|
|
1780
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1781
|
+
const replacement = makeOrg()
|
|
1782
|
+
replacement.name = 'Replaced Corp'
|
|
1783
|
+
replacement.slug = org.slug
|
|
1784
|
+
replacement.billing.plan = 'enterprise'
|
|
1785
|
+
replacement.seatCount = 500
|
|
1786
|
+
|
|
1787
|
+
await OrganizationModel.findOneAndReplace({ _id: org._id }, replacement).exec()
|
|
1788
|
+
|
|
1789
|
+
const updates = await HistoryModel.find({ op: 'findOneAndReplace', collectionId: org._id })
|
|
1790
|
+
expect(updates).toHaveLength(1)
|
|
1791
|
+
expect(updates[0]?.patch?.length).toBeGreaterThan(0)
|
|
1792
|
+
|
|
1793
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1794
|
+
expect(paths).toContain('/name')
|
|
1795
|
+
expect(paths.some((p) => p?.includes('/billing'))).toBe(true)
|
|
1796
|
+
expect(paths).toContain('/seatCount')
|
|
1797
|
+
})
|
|
1798
|
+
|
|
1799
|
+
it('should track replaceOne', async () => {
|
|
1800
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1801
|
+
const replacement = makeOrg()
|
|
1802
|
+
replacement.name = 'ReplaceOne Corp'
|
|
1803
|
+
replacement.slug = org.slug
|
|
1804
|
+
replacement.active = false
|
|
1805
|
+
|
|
1806
|
+
await OrganizationModel.replaceOne({ _id: org._id }, replacement).exec()
|
|
1807
|
+
|
|
1808
|
+
const updates = await HistoryModel.find({ op: 'replaceOne', collectionId: org._id })
|
|
1809
|
+
expect(updates).toHaveLength(1)
|
|
1810
|
+
expect(updates[0]?.patch?.length).toBeGreaterThan(0)
|
|
1811
|
+
|
|
1812
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1813
|
+
expect(paths).toContain('/name')
|
|
1814
|
+
expect(paths).toContain('/active')
|
|
1815
|
+
})
|
|
1816
|
+
|
|
1817
|
+
it('should track findOneAndDelete with full document snapshot', async () => {
|
|
1818
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1819
|
+
|
|
1820
|
+
await OrganizationModel.findOneAndDelete({ _id: org._id }).exec()
|
|
1821
|
+
|
|
1822
|
+
const history = await HistoryModel.find({ collectionId: org._id }).sort('createdAt')
|
|
1823
|
+
expect(history).toHaveLength(2)
|
|
1824
|
+
|
|
1825
|
+
const deletion = history[1]
|
|
1826
|
+
expect(deletion?.op).toBe('findOneAndDelete')
|
|
1827
|
+
expect(deletion?.doc).toHaveProperty('name', 'Acme Corp')
|
|
1828
|
+
expect(deletion?.doc).toHaveProperty('billing')
|
|
1829
|
+
expect(deletion?.doc).toHaveProperty('team')
|
|
1830
|
+
expect(deletion?.doc).not.toHaveProperty('notes')
|
|
1831
|
+
})
|
|
1832
|
+
|
|
1833
|
+
it('should track findByIdAndDelete (mongoose normalizes op to findOneAndDelete)', async () => {
|
|
1834
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1835
|
+
|
|
1836
|
+
await OrganizationModel.findByIdAndDelete(org._id).exec()
|
|
1837
|
+
|
|
1838
|
+
const history = await HistoryModel.find({ collectionId: org._id }).sort('createdAt')
|
|
1839
|
+
expect(history).toHaveLength(2)
|
|
1840
|
+
|
|
1841
|
+
expect(history[1]?.op).toBe('findOneAndDelete')
|
|
1842
|
+
expect(history[1]?.doc).toHaveProperty('name', 'Acme Corp')
|
|
1843
|
+
expect(history[1]?.doc).toHaveProperty('contact')
|
|
1844
|
+
expect(history[1]?.doc).not.toHaveProperty('notes')
|
|
1845
|
+
})
|
|
1846
|
+
|
|
1847
|
+
it('should track deleteMany with snapshots for all deleted documents', async () => {
|
|
1848
|
+
await OrganizationModel.create({ ...makeOrg(), slug: `del-a-${Date.now()}`, name: 'Del A', tags: ['sunset'] })
|
|
1849
|
+
await OrganizationModel.create({ ...makeOrg(), slug: `del-b-${Date.now()}`, name: 'Del B', tags: ['sunset'] })
|
|
1850
|
+
await OrganizationModel.create({ ...makeOrg(), slug: `del-c-${Date.now()}`, name: 'Del C', tags: ['keep'] })
|
|
1851
|
+
|
|
1852
|
+
await OrganizationModel.deleteMany({ tags: 'sunset' }).exec()
|
|
1853
|
+
|
|
1854
|
+
const deletions = await HistoryModel.find({ op: 'deleteMany' })
|
|
1855
|
+
expect(deletions).toHaveLength(2)
|
|
1856
|
+
|
|
1857
|
+
const names = deletions.map((d) => (d.doc as Record<string, unknown>).name)
|
|
1858
|
+
expect(names).toContain('Del A')
|
|
1859
|
+
expect(names).toContain('Del B')
|
|
1860
|
+
|
|
1861
|
+
for (const entry of deletions) {
|
|
1862
|
+
expect(entry.doc).toHaveProperty('contact')
|
|
1863
|
+
expect(entry.doc).toHaveProperty('billing')
|
|
1864
|
+
expect(entry.doc).not.toHaveProperty('notes')
|
|
1865
|
+
}
|
|
1866
|
+
})
|
|
1867
|
+
|
|
1868
|
+
it('should track upsert creating a new organization', async () => {
|
|
1869
|
+
const slug = `upsert-new-${Date.now()}`
|
|
1870
|
+
|
|
1871
|
+
await OrganizationModel.findOneAndUpdate(
|
|
1872
|
+
{ slug },
|
|
1873
|
+
{
|
|
1874
|
+
name: 'Upserted Corp',
|
|
1875
|
+
slug,
|
|
1876
|
+
apiKey: '660e8400-e29b-41d4-a716-446655440000',
|
|
1877
|
+
contact: { email: 'upsert@test.com' },
|
|
1878
|
+
seatCount: 5,
|
|
1879
|
+
},
|
|
1880
|
+
{ upsert: true },
|
|
1881
|
+
).exec()
|
|
1882
|
+
|
|
1883
|
+
const docs = await OrganizationModel.find({ slug })
|
|
1884
|
+
expect(docs).toHaveLength(1)
|
|
1885
|
+
|
|
1886
|
+
const history = await HistoryModel.find({})
|
|
1887
|
+
expect(history).toHaveLength(1)
|
|
1888
|
+
expect(history[0]?.op).toBe('findOneAndUpdate')
|
|
1889
|
+
expect(history[0]?.doc).toHaveProperty('name', 'Upserted Corp')
|
|
1890
|
+
})
|
|
1891
|
+
|
|
1892
|
+
it('should track upsert updating an existing organization', async () => {
|
|
1893
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1894
|
+
|
|
1895
|
+
await OrganizationModel.findOneAndUpdate({ slug: org.slug }, { $set: { seatCount: 999 } }, { upsert: true }).exec()
|
|
1896
|
+
|
|
1897
|
+
const updates = await HistoryModel.find({ op: 'findOneAndUpdate', collectionId: org._id })
|
|
1898
|
+
expect(updates).toHaveLength(1)
|
|
1899
|
+
|
|
1900
|
+
const paths = updates[0]?.patch?.map((p) => p.path) ?? []
|
|
1901
|
+
expect(paths).toContain('/seatCount')
|
|
1902
|
+
})
|
|
1903
|
+
|
|
1904
|
+
// --- $ modifier operators ---
|
|
1905
|
+
|
|
1906
|
+
it('$set — should track field replacement', async () => {
|
|
1907
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1908
|
+
|
|
1909
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $set: { name: 'Set Corp', 'contact.phone': '+1-555-9999' } }).exec()
|
|
1910
|
+
|
|
1911
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1912
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
1913
|
+
expect(paths).toContain('/name')
|
|
1914
|
+
expect(paths.some((p) => p?.includes('/contact'))).toBe(true)
|
|
1915
|
+
})
|
|
1916
|
+
|
|
1917
|
+
it('$unset — should track field removal', async () => {
|
|
1918
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1919
|
+
|
|
1920
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $unset: { logo: '' } }).exec()
|
|
1921
|
+
|
|
1922
|
+
const current = await OrganizationModel.findById(org._id).lean().exec()
|
|
1923
|
+
expect(current?.logo).toBeUndefined()
|
|
1924
|
+
|
|
1925
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1926
|
+
expect(update).toBeDefined()
|
|
1927
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
1928
|
+
expect(paths.some((p) => p?.includes('/logo'))).toBe(true)
|
|
1929
|
+
})
|
|
1930
|
+
|
|
1931
|
+
it('$inc — should track numeric increment with correct post-update value', async () => {
|
|
1932
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1933
|
+
expect(org.seatCount).toBe(25)
|
|
1934
|
+
|
|
1935
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $inc: { seatCount: 10 } }).exec()
|
|
1936
|
+
|
|
1937
|
+
const current = await OrganizationModel.findById(org._id).lean().exec()
|
|
1938
|
+
expect(current?.seatCount).toBe(35)
|
|
1939
|
+
|
|
1940
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1941
|
+
expect(update).toBeDefined()
|
|
1942
|
+
const seatPatch = update?.patch?.find((p) => p.path === '/seatCount' && p.op === 'replace')
|
|
1943
|
+
expect(seatPatch?.value).toBe(35)
|
|
1944
|
+
})
|
|
1945
|
+
|
|
1946
|
+
it('$mul — should track numeric multiply with correct post-update value', async () => {
|
|
1947
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1948
|
+
|
|
1949
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $mul: { seatCount: 2 } }).exec()
|
|
1950
|
+
|
|
1951
|
+
const current = await OrganizationModel.findById(org._id).lean().exec()
|
|
1952
|
+
expect(current?.seatCount).toBe(50)
|
|
1953
|
+
|
|
1954
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1955
|
+
expect(update).toBeDefined()
|
|
1956
|
+
const seatPatch = update?.patch?.find((p) => p.path === '/seatCount' && p.op === 'replace')
|
|
1957
|
+
expect(seatPatch?.value).toBe(50)
|
|
1958
|
+
})
|
|
1959
|
+
|
|
1960
|
+
it('$min — should track conditional numeric update', async () => {
|
|
1961
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1962
|
+
|
|
1963
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $min: { seatCount: 5 } }).exec()
|
|
1964
|
+
|
|
1965
|
+
const current = await OrganizationModel.findById(org._id).lean().exec()
|
|
1966
|
+
expect(current?.seatCount).toBe(5)
|
|
1967
|
+
|
|
1968
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1969
|
+
expect(update).toBeDefined()
|
|
1970
|
+
const seatPatch = update?.patch?.find((p) => p.path === '/seatCount' && p.op === 'replace')
|
|
1971
|
+
expect(seatPatch?.value).toBe(5)
|
|
1972
|
+
})
|
|
1973
|
+
|
|
1974
|
+
it('$pullAll — should track multiple array element removals', async () => {
|
|
1975
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1976
|
+
|
|
1977
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $pullAll: { tags: ['saas', 'enterprise'] } }).exec()
|
|
1978
|
+
|
|
1979
|
+
const current = await OrganizationModel.findById(org._id).lean().exec()
|
|
1980
|
+
expect(current?.tags).toEqual(['active'])
|
|
1981
|
+
|
|
1982
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1983
|
+
expect(update).toBeDefined()
|
|
1984
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
1985
|
+
expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
|
|
1986
|
+
})
|
|
1987
|
+
|
|
1988
|
+
it('$push — should track array element addition', async () => {
|
|
1989
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
1990
|
+
|
|
1991
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $push: { tags: 'new-tag' } }).exec()
|
|
1992
|
+
|
|
1993
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
1994
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
1995
|
+
expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
|
|
1996
|
+
})
|
|
1997
|
+
|
|
1998
|
+
it('$push with $each — should track multiple array additions', async () => {
|
|
1999
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2000
|
+
|
|
2001
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $push: { domains: { $each: ['acme.dev', 'acme.ai'] } } }).exec()
|
|
2002
|
+
|
|
2003
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2004
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2005
|
+
expect(paths.some((p) => p?.startsWith('/domains'))).toBe(true)
|
|
2006
|
+
})
|
|
2007
|
+
|
|
2008
|
+
it('$addToSet — should track unique array addition', async () => {
|
|
2009
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2010
|
+
|
|
2011
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $addToSet: { tags: 'unique-tag' } }).exec()
|
|
2012
|
+
|
|
2013
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2014
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2015
|
+
expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
|
|
2016
|
+
})
|
|
2017
|
+
|
|
2018
|
+
it('$pull — should track array element removal', async () => {
|
|
2019
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2020
|
+
|
|
2021
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $pull: { tags: 'enterprise' } }).exec()
|
|
2022
|
+
|
|
2023
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2024
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2025
|
+
expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
|
|
2026
|
+
})
|
|
2027
|
+
|
|
2028
|
+
it('$pop — should track removal of last array element', async () => {
|
|
2029
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2030
|
+
|
|
2031
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $pop: { tags: 1 } }).exec()
|
|
2032
|
+
|
|
2033
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2034
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2035
|
+
expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
|
|
2036
|
+
})
|
|
2037
|
+
|
|
2038
|
+
it('$rename — should track field rename', async () => {
|
|
2039
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2040
|
+
|
|
2041
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $rename: { 'contact.phone': 'contact.mobile' } }).exec()
|
|
2042
|
+
|
|
2043
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2044
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2045
|
+
expect(paths.some((p) => p?.includes('/contact'))).toBe(true)
|
|
2046
|
+
})
|
|
2047
|
+
|
|
2048
|
+
it('$currentDate — should track date set to now', async () => {
|
|
2049
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2050
|
+
|
|
2051
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $currentDate: { 'billing.nextBillingDate': true } }).exec()
|
|
2052
|
+
|
|
2053
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2054
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2055
|
+
expect(paths.some((p) => p?.includes('/billing'))).toBe(true)
|
|
2056
|
+
})
|
|
2057
|
+
|
|
2058
|
+
it('combined $set + $inc + $push — should track all operators with correct values', async () => {
|
|
2059
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2060
|
+
|
|
2061
|
+
await OrganizationModel.updateOne(
|
|
2062
|
+
{ _id: org._id },
|
|
2063
|
+
{
|
|
2064
|
+
$set: { name: 'Combined Corp', active: false },
|
|
2065
|
+
$inc: { seatCount: 50 },
|
|
2066
|
+
$push: { tags: 'combined' },
|
|
2067
|
+
},
|
|
2068
|
+
).exec()
|
|
2069
|
+
|
|
2070
|
+
const current = await OrganizationModel.findById(org._id).lean().exec()
|
|
2071
|
+
expect(current?.name).toBe('Combined Corp')
|
|
2072
|
+
expect(current?.active).toBe(false)
|
|
2073
|
+
expect(current?.seatCount).toBe(75)
|
|
2074
|
+
expect(current?.tags).toContain('combined')
|
|
2075
|
+
|
|
2076
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2077
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2078
|
+
expect(paths).toContain('/name')
|
|
2079
|
+
expect(paths).toContain('/active')
|
|
2080
|
+
expect(paths).toContain('/seatCount')
|
|
2081
|
+
expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
|
|
2082
|
+
|
|
2083
|
+
expect(update?.patch?.find((p) => p.path === '/name' && p.op === 'replace')?.value).toBe('Combined Corp')
|
|
2084
|
+
expect(update?.patch?.find((p) => p.path === '/active' && p.op === 'replace')?.value).toBe(false)
|
|
2085
|
+
expect(update?.patch?.find((p) => p.path === '/seatCount' && p.op === 'replace')?.value).toBe(75)
|
|
2086
|
+
})
|
|
2087
|
+
|
|
2088
|
+
it('$push subdocument into team array — should track nested array addition', async () => {
|
|
2089
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2090
|
+
const newMemberId = new mongoose.Types.ObjectId()
|
|
2091
|
+
|
|
2092
|
+
await OrganizationModel.updateOne({ _id: org._id }, { $push: { team: { userId: newMemberId, role: 'viewer' } } }).exec()
|
|
2093
|
+
|
|
2094
|
+
const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2095
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2096
|
+
expect(paths.some((p) => p?.startsWith('/team'))).toBe(true)
|
|
2097
|
+
})
|
|
2098
|
+
|
|
2099
|
+
// --- getUser / getReason / getMetadata ---
|
|
2100
|
+
|
|
2101
|
+
it('create history should contain user, reason, and metadata', async () => {
|
|
2102
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2103
|
+
|
|
2104
|
+
const [entry] = await HistoryModel.find({ collectionId: org._id })
|
|
2105
|
+
expect(entry?.user).toEqual({ userId: 'system', role: 'service-account' })
|
|
2106
|
+
expect(entry?.reason).toBe('api-call')
|
|
2107
|
+
expect(entry?.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
|
|
2108
|
+
})
|
|
2109
|
+
|
|
2110
|
+
it('update history should contain user, reason, and metadata', async () => {
|
|
2111
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2112
|
+
|
|
2113
|
+
org.name = 'Updated Corp'
|
|
2114
|
+
await org.save()
|
|
2115
|
+
|
|
2116
|
+
const update = await HistoryModel.findOne({ op: 'update', collectionId: org._id })
|
|
2117
|
+
expect(update?.user).toEqual({ userId: 'system', role: 'service-account' })
|
|
2118
|
+
expect(update?.reason).toBe('api-call')
|
|
2119
|
+
expect(update?.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
|
|
2120
|
+
})
|
|
2121
|
+
|
|
2122
|
+
it('delete history should contain user, reason, and metadata', async () => {
|
|
2123
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2124
|
+
|
|
2125
|
+
await OrganizationModel.deleteOne({ _id: org._id }).exec()
|
|
2126
|
+
|
|
2127
|
+
const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: org._id })
|
|
2128
|
+
expect(deletion?.user).toEqual({ userId: 'system', role: 'service-account' })
|
|
2129
|
+
expect(deletion?.reason).toBe('api-call')
|
|
2130
|
+
expect(deletion?.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
|
|
2131
|
+
})
|
|
2132
|
+
|
|
2133
|
+
it('updateOne history should contain user, reason, and metadata', async () => {
|
|
2134
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2135
|
+
|
|
2136
|
+
await OrganizationModel.updateOne({ _id: org._id }, { name: 'Query Updated' }).exec()
|
|
2137
|
+
|
|
2138
|
+
const update = await HistoryModel.findOne({ op: 'updateOne', collectionId: org._id })
|
|
2139
|
+
expect(update?.user).toEqual({ userId: 'system', role: 'service-account' })
|
|
2140
|
+
expect(update?.reason).toBe('api-call')
|
|
2141
|
+
expect(update?.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
|
|
2142
|
+
})
|
|
2143
|
+
|
|
2144
|
+
// --- History record structure ---
|
|
2145
|
+
|
|
2146
|
+
it('create history should contain doc snapshot, no patch array', async () => {
|
|
2147
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2148
|
+
|
|
2149
|
+
const [entry] = await HistoryModel.find({ collectionId: org._id })
|
|
2150
|
+
expect(entry?.op).toBe('create')
|
|
2151
|
+
expect(entry?.modelName).toBe('Organization')
|
|
2152
|
+
expect(entry?.collectionName).toBe('organizations')
|
|
2153
|
+
expect(entry?.collectionId).toEqual(org._id)
|
|
2154
|
+
expect(entry?.version).toBe(0)
|
|
2155
|
+
expect(entry?.doc).toBeDefined()
|
|
2156
|
+
expect(entry?.patch).toEqual([])
|
|
2157
|
+
expect(entry?.createdAt).toBeDefined()
|
|
2158
|
+
expect(entry?.updatedAt).toBeDefined()
|
|
2159
|
+
})
|
|
2160
|
+
|
|
2161
|
+
it('update history should contain JSON patch, no doc snapshot', async () => {
|
|
2162
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2163
|
+
|
|
2164
|
+
org.name = 'Changed'
|
|
2165
|
+
await org.save()
|
|
2166
|
+
|
|
2167
|
+
const update = await HistoryModel.findOne({ op: 'update', collectionId: org._id })
|
|
2168
|
+
expect(update?.doc).toBeUndefined()
|
|
2169
|
+
expect(update?.patch).toBeDefined()
|
|
2170
|
+
expect(update?.patch?.length).toBeGreaterThan(0)
|
|
2171
|
+
|
|
2172
|
+
const nameOp = update?.patch?.find((p) => p.path === '/name' && p.op === 'replace')
|
|
2173
|
+
expect(nameOp).toBeDefined()
|
|
2174
|
+
expect(nameOp?.value).toBe('Changed')
|
|
2175
|
+
})
|
|
2176
|
+
|
|
2177
|
+
it('delete history should contain doc snapshot, no patch array', async () => {
|
|
2178
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2179
|
+
|
|
2180
|
+
await OrganizationModel.deleteOne({ _id: org._id }).exec()
|
|
2181
|
+
|
|
2182
|
+
const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: org._id })
|
|
2183
|
+
expect(deletion?.doc).toBeDefined()
|
|
2184
|
+
expect(deletion?.doc).toHaveProperty('name', 'Acme Corp')
|
|
2185
|
+
expect(deletion?.patch).toEqual([])
|
|
2186
|
+
})
|
|
2187
|
+
|
|
2188
|
+
// --- Event payloads ---
|
|
2189
|
+
|
|
2190
|
+
it('create event should emit with { doc } payload', async () => {
|
|
2191
|
+
await OrganizationModel.create(makeOrg())
|
|
2192
|
+
|
|
2193
|
+
expect(em.emit).toHaveBeenCalledWith(ORG_CREATED, expect.objectContaining({ doc: expect.objectContaining({ name: 'Acme Corp' }) }))
|
|
2194
|
+
})
|
|
2195
|
+
|
|
2196
|
+
it('update event should emit with { doc, oldDoc, patch } payload', async () => {
|
|
2197
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2198
|
+
vi.mocked(em.emit).mockClear()
|
|
2199
|
+
|
|
2200
|
+
org.name = 'New Name'
|
|
2201
|
+
await org.save()
|
|
2202
|
+
|
|
2203
|
+
expect(em.emit).toHaveBeenCalledWith(
|
|
2204
|
+
ORG_UPDATED,
|
|
2205
|
+
expect.objectContaining({
|
|
2206
|
+
oldDoc: expect.objectContaining({ name: 'Acme Corp' }),
|
|
2207
|
+
doc: expect.objectContaining({ name: 'New Name' }),
|
|
2208
|
+
patch: expect.arrayContaining([expect.objectContaining({ path: '/name', op: 'replace', value: 'New Name' })]),
|
|
2209
|
+
}),
|
|
2210
|
+
)
|
|
2211
|
+
})
|
|
2212
|
+
|
|
2213
|
+
it('delete event should emit with { oldDoc } payload', async () => {
|
|
2214
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2215
|
+
vi.mocked(em.emit).mockClear()
|
|
2216
|
+
|
|
2217
|
+
await OrganizationModel.deleteOne({ _id: org._id }).exec()
|
|
2218
|
+
|
|
2219
|
+
expect(em.emit).toHaveBeenCalledWith(ORG_DELETED, expect.objectContaining({ oldDoc: expect.objectContaining({ name: 'Acme Corp' }) }))
|
|
2220
|
+
})
|
|
2221
|
+
|
|
2222
|
+
// --- Omit behavior ---
|
|
2223
|
+
|
|
2224
|
+
it('omitted fields should never appear in create history doc', async () => {
|
|
2225
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2226
|
+
|
|
2227
|
+
const [entry] = await HistoryModel.find({ collectionId: org._id })
|
|
2228
|
+
expect(entry?.doc).not.toHaveProperty('notes')
|
|
2229
|
+
expect(entry?.doc).not.toHaveProperty('__v')
|
|
2230
|
+
expect(entry?.doc).not.toHaveProperty('createdAt')
|
|
2231
|
+
expect(entry?.doc).not.toHaveProperty('updatedAt')
|
|
2232
|
+
})
|
|
2233
|
+
|
|
2234
|
+
it('omitted fields should never appear in update patch paths', async () => {
|
|
2235
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2236
|
+
|
|
2237
|
+
org.notes = 'changed internal note'
|
|
2238
|
+
org.name = 'Trigger Real Change'
|
|
2239
|
+
await org.save()
|
|
2240
|
+
|
|
2241
|
+
const update = await HistoryModel.findOne({ op: 'update', collectionId: org._id })
|
|
2242
|
+
const paths = update?.patch?.map((p) => p.path) ?? []
|
|
2243
|
+
expect(paths).toContain('/name')
|
|
2244
|
+
expect(paths.every((p) => !p?.includes('notes'))).toBe(true)
|
|
2245
|
+
expect(paths.every((p) => !p?.includes('__v'))).toBe(true)
|
|
2246
|
+
expect(paths.every((p) => !p?.includes('createdAt'))).toBe(true)
|
|
2247
|
+
expect(paths.every((p) => !p?.includes('updatedAt'))).toBe(true)
|
|
2248
|
+
})
|
|
2249
|
+
|
|
2250
|
+
it('omitted fields should never appear in delete history doc', async () => {
|
|
2251
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2252
|
+
|
|
2253
|
+
await OrganizationModel.deleteOne({ _id: org._id }).exec()
|
|
2254
|
+
|
|
2255
|
+
const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: org._id })
|
|
2256
|
+
expect(deletion?.doc).not.toHaveProperty('notes')
|
|
2257
|
+
expect(deletion?.doc).not.toHaveProperty('__v')
|
|
2258
|
+
expect(deletion?.doc).not.toHaveProperty('createdAt')
|
|
2259
|
+
expect(deletion?.doc).not.toHaveProperty('updatedAt')
|
|
2260
|
+
})
|
|
2261
|
+
|
|
2262
|
+
// --- Version tracking ---
|
|
2263
|
+
|
|
2264
|
+
it('versions should increment sequentially per document', async () => {
|
|
2265
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2266
|
+
|
|
2267
|
+
for (let i = 1; i <= 5; i++) {
|
|
2268
|
+
org.seatCount = i * 10
|
|
2269
|
+
await org.save()
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const history = await HistoryModel.find({ collectionId: org._id }).sort('version')
|
|
2273
|
+
expect(history).toHaveLength(6)
|
|
2274
|
+
|
|
2275
|
+
for (let i = 0; i < 6; i++) {
|
|
2276
|
+
expect(history[i]?.version).toBe(i)
|
|
2277
|
+
}
|
|
2278
|
+
})
|
|
2279
|
+
|
|
2280
|
+
it('versions should be independent per document', async () => {
|
|
2281
|
+
const orgA = await OrganizationModel.create({ ...makeOrg(), slug: `a-${Date.now()}` })
|
|
2282
|
+
const orgB = await OrganizationModel.create({ ...makeOrg(), slug: `b-${Date.now()}` })
|
|
2283
|
+
|
|
2284
|
+
orgA.name = 'A changed'
|
|
2285
|
+
await orgA.save()
|
|
2286
|
+
|
|
2287
|
+
orgB.name = 'B changed'
|
|
2288
|
+
await orgB.save()
|
|
2289
|
+
|
|
2290
|
+
const historyA = await HistoryModel.find({ collectionId: orgA._id }).sort('version')
|
|
2291
|
+
const historyB = await HistoryModel.find({ collectionId: orgB._id }).sort('version')
|
|
2292
|
+
|
|
2293
|
+
expect(historyA[0]?.version).toBe(0)
|
|
2294
|
+
expect(historyA[1]?.version).toBe(1)
|
|
2295
|
+
expect(historyB[0]?.version).toBe(0)
|
|
2296
|
+
expect(historyB[1]?.version).toBe(1)
|
|
2297
|
+
})
|
|
2298
|
+
|
|
2299
|
+
// --- ignoreHook / ignoreEvent / ignorePatchHistory ---
|
|
2300
|
+
|
|
2301
|
+
it('ignoreHook should skip both history and events', async () => {
|
|
2302
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2303
|
+
vi.mocked(em.emit).mockClear()
|
|
2304
|
+
|
|
2305
|
+
await OrganizationModel.updateOne({ _id: org._id }, { name: 'Ignored' }).setOptions({ ignoreHook: true }).exec()
|
|
2306
|
+
|
|
2307
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2308
|
+
expect(updates).toHaveLength(0)
|
|
2309
|
+
expect(em.emit).not.toHaveBeenCalledWith(ORG_UPDATED, expect.anything())
|
|
2310
|
+
})
|
|
2311
|
+
|
|
2312
|
+
it('ignoreEvent should keep history but skip events', async () => {
|
|
2313
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2314
|
+
vi.mocked(em.emit).mockClear()
|
|
2315
|
+
|
|
2316
|
+
await OrganizationModel.updateOne({ _id: org._id }, { name: 'EventSkipped' }).setOptions({ ignoreEvent: true }).exec()
|
|
2317
|
+
|
|
2318
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2319
|
+
expect(updates).toHaveLength(1)
|
|
2320
|
+
expect(em.emit).not.toHaveBeenCalledWith(ORG_UPDATED, expect.anything())
|
|
2321
|
+
})
|
|
2322
|
+
|
|
2323
|
+
it('ignorePatchHistory should skip history but keep events', async () => {
|
|
2324
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2325
|
+
vi.mocked(em.emit).mockClear()
|
|
2326
|
+
|
|
2327
|
+
await OrganizationModel.updateOne({ _id: org._id }, { name: 'HistorySkipped' }).setOptions({ ignorePatchHistory: true }).exec()
|
|
2328
|
+
|
|
2329
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2330
|
+
expect(updates).toHaveLength(0)
|
|
2331
|
+
expect(em.emit).toHaveBeenCalledWith(ORG_UPDATED, expect.anything())
|
|
2332
|
+
})
|
|
2333
|
+
|
|
2334
|
+
// --- No-op safety ---
|
|
2335
|
+
|
|
2336
|
+
it('update with no actual changes should not produce history', async () => {
|
|
2337
|
+
const org = await OrganizationModel.create(makeOrg())
|
|
2338
|
+
|
|
2339
|
+
await OrganizationModel.updateOne({ _id: org._id }, { name: 'Acme Corp' }).exec()
|
|
2340
|
+
|
|
2341
|
+
const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
|
|
2342
|
+
expect(updates).toHaveLength(0)
|
|
2343
|
+
})
|
|
2344
|
+
|
|
2345
|
+
it('update targeting non-existent document should not crash or produce history', async () => {
|
|
2346
|
+
const fakeId = new mongoose.Types.ObjectId()
|
|
2347
|
+
|
|
2348
|
+
await OrganizationModel.updateOne({ _id: fakeId }, { name: 'Ghost' }).exec()
|
|
2349
|
+
|
|
2350
|
+
const history = await HistoryModel.find({})
|
|
2351
|
+
expect(history).toHaveLength(0)
|
|
2352
|
+
})
|
|
2353
|
+
|
|
2354
|
+
it('delete targeting non-existent document should not crash or produce history', async () => {
|
|
2355
|
+
const fakeId = new mongoose.Types.ObjectId()
|
|
2356
|
+
|
|
2357
|
+
await OrganizationModel.deleteOne({ _id: fakeId }).exec()
|
|
2358
|
+
|
|
2359
|
+
const history = await HistoryModel.find({})
|
|
2360
|
+
expect(history).toHaveLength(0)
|
|
2361
|
+
})
|
|
2362
|
+
|
|
2363
|
+
// --- insertMany with user/reason/metadata ---
|
|
2364
|
+
|
|
2365
|
+
it('insertMany should attach user, reason, and metadata to each history record', async () => {
|
|
2366
|
+
await OrganizationModel.insertMany([
|
|
2367
|
+
{ ...makeOrg(), slug: `ins-a-${Date.now()}`, name: 'Ins A' },
|
|
2368
|
+
{ ...makeOrg(), slug: `ins-b-${Date.now()}`, name: 'Ins B' },
|
|
2369
|
+
])
|
|
2370
|
+
|
|
2371
|
+
const history = await HistoryModel.find({ op: 'create' }).sort('doc.name')
|
|
2372
|
+
expect(history).toHaveLength(2)
|
|
2373
|
+
|
|
2374
|
+
for (const entry of history) {
|
|
2375
|
+
expect(entry.user).toEqual({ userId: 'system', role: 'service-account' })
|
|
2376
|
+
expect(entry.reason).toBe('api-call')
|
|
2377
|
+
expect(entry.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
|
|
2378
|
+
}
|
|
2379
|
+
})
|
|
2380
|
+
|
|
2381
|
+
// --- preDelete callback ---
|
|
2382
|
+
|
|
2383
|
+
it('preDelete should receive cloned documents before deletion', async () => {
|
|
2384
|
+
const preDeleteDocs: unknown[][] = []
|
|
2385
|
+
|
|
2386
|
+
const PreDeleteSchema = new Schema<Organization>(
|
|
2387
|
+
{
|
|
2388
|
+
name: { type: String, required: true },
|
|
2389
|
+
slug: { type: String, required: true },
|
|
2390
|
+
apiKey: { type: Schema.Types.UUID, required: true },
|
|
2391
|
+
active: { type: Boolean, default: true },
|
|
2392
|
+
contact: { type: ContactSchema, required: true },
|
|
2393
|
+
seatCount: { type: Number, default: 1 },
|
|
2394
|
+
},
|
|
2395
|
+
{ timestamps: true },
|
|
2396
|
+
)
|
|
2397
|
+
|
|
2398
|
+
PreDeleteSchema.plugin(patchHistoryPlugin, {
|
|
2399
|
+
omit: ['__v', 'createdAt', 'updatedAt'],
|
|
2400
|
+
preDelete: async (docs) => {
|
|
2401
|
+
preDeleteDocs.push(docs)
|
|
2402
|
+
},
|
|
2403
|
+
})
|
|
2404
|
+
|
|
2405
|
+
if (mongoose.models.PreDeleteOrg) mongoose.deleteModel('PreDeleteOrg')
|
|
2406
|
+
const PreDeleteModel = model<Organization>('PreDeleteOrg', PreDeleteSchema)
|
|
2407
|
+
|
|
2408
|
+
const org = await PreDeleteModel.create({
|
|
2409
|
+
name: 'Doomed Corp',
|
|
2410
|
+
slug: `doomed-${Date.now()}`,
|
|
2411
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
2412
|
+
contact: { email: 'bye@doomed.com' },
|
|
2413
|
+
})
|
|
2414
|
+
|
|
2415
|
+
await PreDeleteModel.deleteOne({ _id: org._id }).exec()
|
|
2416
|
+
|
|
2417
|
+
expect(preDeleteDocs).toHaveLength(1)
|
|
2418
|
+
expect(preDeleteDocs[0]).toHaveLength(1)
|
|
2419
|
+
expect(preDeleteDocs[0]?.[0]).toHaveProperty('name', 'Doomed Corp')
|
|
2420
|
+
})
|
|
2421
|
+
})
|
|
2422
|
+
|
|
2423
|
+
// --- patchHistoryDisabled mode ---
|
|
2424
|
+
|
|
2425
|
+
const EventOnlySchema = new Schema<Organization>(
|
|
2426
|
+
{
|
|
2427
|
+
name: { type: String, required: true },
|
|
2428
|
+
slug: { type: String, required: true },
|
|
2429
|
+
apiKey: { type: Schema.Types.UUID, required: true },
|
|
2430
|
+
active: { type: Boolean, default: true },
|
|
2431
|
+
contact: { type: ContactSchema, required: true },
|
|
2432
|
+
seatCount: { type: Number, default: 1 },
|
|
2433
|
+
},
|
|
2434
|
+
{ timestamps: true },
|
|
2435
|
+
)
|
|
2436
|
+
|
|
2437
|
+
const EVENT_ONLY_CREATED = 'event-only-created'
|
|
2438
|
+
const EVENT_ONLY_UPDATED = 'event-only-updated'
|
|
2439
|
+
const EVENT_ONLY_DELETED = 'event-only-deleted'
|
|
2440
|
+
|
|
2441
|
+
EventOnlySchema.plugin(patchHistoryPlugin, {
|
|
2442
|
+
eventCreated: EVENT_ONLY_CREATED,
|
|
2443
|
+
eventUpdated: EVENT_ONLY_UPDATED,
|
|
2444
|
+
eventDeleted: EVENT_ONLY_DELETED,
|
|
2445
|
+
patchHistoryDisabled: true,
|
|
2446
|
+
omit: ['__v', 'createdAt', 'updatedAt'],
|
|
2447
|
+
})
|
|
2448
|
+
|
|
2449
|
+
const EventOnlyModel = model<Organization>('EventOnlyOrg', EventOnlySchema)
|
|
2450
|
+
|
|
2451
|
+
describe('plugin — patchHistoryDisabled (events only, no history)', () => {
|
|
2452
|
+
const instance = server('plugin-events-only')
|
|
2453
|
+
|
|
2454
|
+
beforeAll(async () => {
|
|
2455
|
+
await instance.create()
|
|
2456
|
+
})
|
|
2457
|
+
|
|
2458
|
+
afterAll(async () => {
|
|
2459
|
+
await instance.destroy()
|
|
2460
|
+
})
|
|
2461
|
+
|
|
2462
|
+
beforeEach(async () => {
|
|
2463
|
+
await mongoose.connection.collection('eventonlyorgs').deleteMany({})
|
|
2464
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
2465
|
+
})
|
|
2466
|
+
|
|
2467
|
+
afterEach(() => {
|
|
2468
|
+
vi.resetAllMocks()
|
|
2469
|
+
})
|
|
2470
|
+
|
|
2471
|
+
it('create should emit event but not write history', async () => {
|
|
2472
|
+
await EventOnlyModel.create({
|
|
2473
|
+
name: 'EventOnly Corp',
|
|
2474
|
+
slug: `eo-${Date.now()}`,
|
|
2475
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
2476
|
+
contact: { email: 'eo@test.com' },
|
|
2477
|
+
})
|
|
2478
|
+
|
|
2479
|
+
expect(em.emit).toHaveBeenCalledWith(EVENT_ONLY_CREATED, expect.objectContaining({ doc: expect.objectContaining({ name: 'EventOnly Corp' }) }))
|
|
2480
|
+
|
|
2481
|
+
const history = await HistoryModel.find({})
|
|
2482
|
+
expect(history).toHaveLength(0)
|
|
2483
|
+
})
|
|
2484
|
+
|
|
2485
|
+
it('update should emit event but not write history', async () => {
|
|
2486
|
+
const org = await EventOnlyModel.create({
|
|
2487
|
+
name: 'EventOnly Corp',
|
|
2488
|
+
slug: `eo-${Date.now()}`,
|
|
2489
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
2490
|
+
contact: { email: 'eo@test.com' },
|
|
2491
|
+
})
|
|
2492
|
+
|
|
2493
|
+
vi.mocked(em.emit).mockClear()
|
|
2494
|
+
|
|
2495
|
+
org.name = 'Updated EventOnly'
|
|
2496
|
+
await org.save()
|
|
2497
|
+
|
|
2498
|
+
expect(em.emit).toHaveBeenCalledWith(
|
|
2499
|
+
EVENT_ONLY_UPDATED,
|
|
2500
|
+
expect.objectContaining({
|
|
2501
|
+
oldDoc: expect.objectContaining({ name: 'EventOnly Corp' }),
|
|
2502
|
+
doc: expect.objectContaining({ name: 'Updated EventOnly' }),
|
|
2503
|
+
patch: expect.any(Array),
|
|
2504
|
+
}),
|
|
2505
|
+
)
|
|
2506
|
+
|
|
2507
|
+
const history = await HistoryModel.find({})
|
|
2508
|
+
expect(history).toHaveLength(0)
|
|
2509
|
+
})
|
|
2510
|
+
|
|
2511
|
+
it('delete should emit event but not write history', async () => {
|
|
2512
|
+
const org = await EventOnlyModel.create({
|
|
2513
|
+
name: 'EventOnly Corp',
|
|
2514
|
+
slug: `eo-${Date.now()}`,
|
|
2515
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
2516
|
+
contact: { email: 'eo@test.com' },
|
|
2517
|
+
})
|
|
2518
|
+
|
|
2519
|
+
vi.mocked(em.emit).mockClear()
|
|
2520
|
+
|
|
2521
|
+
await EventOnlyModel.deleteOne({ _id: org._id }).exec()
|
|
2522
|
+
|
|
2523
|
+
expect(em.emit).toHaveBeenCalledWith(EVENT_ONLY_DELETED, expect.objectContaining({ oldDoc: expect.objectContaining({ name: 'EventOnly Corp' }) }))
|
|
2524
|
+
|
|
2525
|
+
const history = await HistoryModel.find({})
|
|
2526
|
+
expect(history).toHaveLength(0)
|
|
2527
|
+
})
|
|
2528
|
+
|
|
2529
|
+
it('updateOne should emit event but not write history', async () => {
|
|
2530
|
+
const org = await EventOnlyModel.create({
|
|
2531
|
+
name: 'EventOnly Corp',
|
|
2532
|
+
slug: `eo-${Date.now()}`,
|
|
2533
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
2534
|
+
contact: { email: 'eo@test.com' },
|
|
2535
|
+
})
|
|
2536
|
+
|
|
2537
|
+
vi.mocked(em.emit).mockClear()
|
|
2538
|
+
|
|
2539
|
+
await EventOnlyModel.updateOne({ _id: org._id }, { name: 'Query Updated' }).exec()
|
|
2540
|
+
|
|
2541
|
+
expect(em.emit).toHaveBeenCalledWith(EVENT_ONLY_UPDATED, expect.anything())
|
|
2542
|
+
|
|
2543
|
+
const history = await HistoryModel.find({})
|
|
2544
|
+
expect(history).toHaveLength(0)
|
|
2545
|
+
})
|
|
2546
|
+
})
|
|
2547
|
+
|
|
2548
|
+
// --- Async getUser/getReason/getMetadata ---
|
|
2549
|
+
|
|
2550
|
+
const AsyncCallbackSchema = new Schema<Organization>(
|
|
2551
|
+
{
|
|
2552
|
+
name: { type: String, required: true },
|
|
2553
|
+
slug: { type: String, required: true },
|
|
2554
|
+
apiKey: { type: Schema.Types.UUID, required: true },
|
|
2555
|
+
active: { type: Boolean, default: true },
|
|
2556
|
+
contact: { type: ContactSchema, required: true },
|
|
2557
|
+
seatCount: { type: Number, default: 1 },
|
|
2558
|
+
},
|
|
2559
|
+
{ timestamps: true },
|
|
2560
|
+
)
|
|
2561
|
+
|
|
2562
|
+
AsyncCallbackSchema.plugin(patchHistoryPlugin, {
|
|
2563
|
+
omit: ['__v', 'createdAt', 'updatedAt'],
|
|
2564
|
+
getUser: async () => {
|
|
2565
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
2566
|
+
return { userId: 'async-user', source: 'http-context' }
|
|
2567
|
+
},
|
|
2568
|
+
getReason: async () => {
|
|
2569
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
2570
|
+
return 'async-reason'
|
|
2571
|
+
},
|
|
2572
|
+
getMetadata: async () => {
|
|
2573
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
2574
|
+
return { async: true, timestamp: Date.now() }
|
|
2575
|
+
},
|
|
2576
|
+
})
|
|
2577
|
+
|
|
2578
|
+
const AsyncCallbackModel = model<Organization>('AsyncCallbackOrg', AsyncCallbackSchema)
|
|
2579
|
+
|
|
2580
|
+
describe('plugin — async getUser/getReason/getMetadata', () => {
|
|
2581
|
+
const instance = server('plugin-async-callbacks')
|
|
2582
|
+
|
|
2583
|
+
beforeAll(async () => {
|
|
2584
|
+
await instance.create()
|
|
2585
|
+
})
|
|
2586
|
+
|
|
2587
|
+
afterAll(async () => {
|
|
2588
|
+
await instance.destroy()
|
|
2589
|
+
})
|
|
2590
|
+
|
|
2591
|
+
beforeEach(async () => {
|
|
2592
|
+
await mongoose.connection.collection('asynccallbackorgs').deleteMany({})
|
|
2593
|
+
await mongoose.connection.collection('history').deleteMany({})
|
|
2594
|
+
})
|
|
2595
|
+
|
|
2596
|
+
afterEach(() => {
|
|
2597
|
+
vi.resetAllMocks()
|
|
2598
|
+
})
|
|
2599
|
+
|
|
2600
|
+
it('create should resolve async getUser/getReason/getMetadata', async () => {
|
|
2601
|
+
const org = await AsyncCallbackModel.create({
|
|
2602
|
+
name: 'Async Corp',
|
|
2603
|
+
slug: `async-${Date.now()}`,
|
|
2604
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
2605
|
+
contact: { email: 'async@test.com' },
|
|
2606
|
+
})
|
|
2607
|
+
|
|
2608
|
+
const [entry] = await HistoryModel.find({ collectionId: org._id })
|
|
2609
|
+
expect(entry?.user).toEqual({ userId: 'async-user', source: 'http-context' })
|
|
2610
|
+
expect(entry?.reason).toBe('async-reason')
|
|
2611
|
+
expect(entry?.metadata).toHaveProperty('async', true)
|
|
2612
|
+
expect(entry?.metadata).toHaveProperty('timestamp')
|
|
2613
|
+
})
|
|
2614
|
+
|
|
2615
|
+
it('update should resolve async callbacks', async () => {
|
|
2616
|
+
const org = await AsyncCallbackModel.create({
|
|
2617
|
+
name: 'Async Corp',
|
|
2618
|
+
slug: `async-${Date.now()}`,
|
|
2619
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
2620
|
+
contact: { email: 'async@test.com' },
|
|
2621
|
+
})
|
|
2622
|
+
|
|
2623
|
+
org.name = 'Async Updated'
|
|
2624
|
+
await org.save()
|
|
2625
|
+
|
|
2626
|
+
const update = await HistoryModel.findOne({ op: 'update', collectionId: org._id })
|
|
2627
|
+
expect(update?.user).toEqual({ userId: 'async-user', source: 'http-context' })
|
|
2628
|
+
expect(update?.reason).toBe('async-reason')
|
|
2629
|
+
expect(update?.metadata).toHaveProperty('async', true)
|
|
2630
|
+
})
|
|
2631
|
+
|
|
2632
|
+
it('delete should resolve async callbacks', async () => {
|
|
2633
|
+
const org = await AsyncCallbackModel.create({
|
|
2634
|
+
name: 'Async Corp',
|
|
2635
|
+
slug: `async-${Date.now()}`,
|
|
2636
|
+
apiKey: '550e8400-e29b-41d4-a716-446655440000',
|
|
2637
|
+
contact: { email: 'async@test.com' },
|
|
2638
|
+
})
|
|
2639
|
+
|
|
2640
|
+
await AsyncCallbackModel.deleteOne({ _id: org._id }).exec()
|
|
2641
|
+
|
|
2642
|
+
const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: org._id })
|
|
2643
|
+
expect(deletion?.user).toEqual({ userId: 'async-user', source: 'http-context' })
|
|
2644
|
+
expect(deletion?.reason).toBe('async-reason')
|
|
2645
|
+
expect(deletion?.metadata).toHaveProperty('async', true)
|
|
2646
|
+
})
|
|
2647
|
+
})
|