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