Skip to content

Commit 1a3fb00

Browse files
authored
Merge pull request #745 from LTS2/test/deep-merge-unit-tests
test(shared): add unit tests for deep-merge utility
2 parents c6fb5e5 + 2042a29 commit 1a3fb00

File tree

1 file changed

+336
-0
lines changed

1 file changed

+336
-0
lines changed

src/shared/deep-merge.test.ts

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { deepMerge, isPlainObject } from "./deep-merge"
3+
4+
type AnyObject = Record<string, unknown>
5+
6+
describe("isPlainObject", () => {
7+
test("returns false for null", () => {
8+
//#given
9+
const value = null
10+
11+
//#when
12+
const result = isPlainObject(value)
13+
14+
//#then
15+
expect(result).toBe(false)
16+
})
17+
18+
test("returns false for undefined", () => {
19+
//#given
20+
const value = undefined
21+
22+
//#when
23+
const result = isPlainObject(value)
24+
25+
//#then
26+
expect(result).toBe(false)
27+
})
28+
29+
test("returns false for string", () => {
30+
//#given
31+
const value = "hello"
32+
33+
//#when
34+
const result = isPlainObject(value)
35+
36+
//#then
37+
expect(result).toBe(false)
38+
})
39+
40+
test("returns false for number", () => {
41+
//#given
42+
const value = 42
43+
44+
//#when
45+
const result = isPlainObject(value)
46+
47+
//#then
48+
expect(result).toBe(false)
49+
})
50+
51+
test("returns false for boolean", () => {
52+
//#given
53+
const value = true
54+
55+
//#when
56+
const result = isPlainObject(value)
57+
58+
//#then
59+
expect(result).toBe(false)
60+
})
61+
62+
test("returns false for array", () => {
63+
//#given
64+
const value = [1, 2, 3]
65+
66+
//#when
67+
const result = isPlainObject(value)
68+
69+
//#then
70+
expect(result).toBe(false)
71+
})
72+
73+
test("returns false for Date", () => {
74+
//#given
75+
const value = new Date()
76+
77+
//#when
78+
const result = isPlainObject(value)
79+
80+
//#then
81+
expect(result).toBe(false)
82+
})
83+
84+
test("returns false for RegExp", () => {
85+
//#given
86+
const value = /test/
87+
88+
//#when
89+
const result = isPlainObject(value)
90+
91+
//#then
92+
expect(result).toBe(false)
93+
})
94+
95+
test("returns true for plain object", () => {
96+
//#given
97+
const value = { a: 1 }
98+
99+
//#when
100+
const result = isPlainObject(value)
101+
102+
//#then
103+
expect(result).toBe(true)
104+
})
105+
106+
test("returns true for empty object", () => {
107+
//#given
108+
const value = {}
109+
110+
//#when
111+
const result = isPlainObject(value)
112+
113+
//#then
114+
expect(result).toBe(true)
115+
})
116+
117+
test("returns true for nested object", () => {
118+
//#given
119+
const value = { a: { b: 1 } }
120+
121+
//#when
122+
const result = isPlainObject(value)
123+
124+
//#then
125+
expect(result).toBe(true)
126+
})
127+
})
128+
129+
describe("deepMerge", () => {
130+
describe("basic merging", () => {
131+
test("merges two simple objects", () => {
132+
//#given
133+
const base: AnyObject = { a: 1 }
134+
const override: AnyObject = { b: 2 }
135+
136+
//#when
137+
const result = deepMerge(base, override)
138+
139+
//#then
140+
expect(result).toEqual({ a: 1, b: 2 })
141+
})
142+
143+
test("override value takes precedence", () => {
144+
//#given
145+
const base = { a: 1 }
146+
const override = { a: 2 }
147+
148+
//#when
149+
const result = deepMerge(base, override)
150+
151+
//#then
152+
expect(result).toEqual({ a: 2 })
153+
})
154+
155+
test("deeply merges nested objects", () => {
156+
//#given
157+
const base: AnyObject = { a: { b: 1, c: 2 } }
158+
const override: AnyObject = { a: { b: 10 } }
159+
160+
//#when
161+
const result = deepMerge(base, override)
162+
163+
//#then
164+
expect(result).toEqual({ a: { b: 10, c: 2 } })
165+
})
166+
167+
test("handles multiple levels of nesting", () => {
168+
//#given
169+
const base: AnyObject = { a: { b: { c: { d: 1 } } } }
170+
const override: AnyObject = { a: { b: { c: { e: 2 } } } }
171+
172+
//#when
173+
const result = deepMerge(base, override)
174+
175+
//#then
176+
expect(result).toEqual({ a: { b: { c: { d: 1, e: 2 } } } })
177+
})
178+
})
179+
180+
describe("edge cases", () => {
181+
test("returns undefined when both are undefined", () => {
182+
//#given
183+
const base = undefined
184+
const override = undefined
185+
186+
//#when
187+
const result = deepMerge<AnyObject>(base, override)
188+
189+
//#then
190+
expect(result).toBeUndefined()
191+
})
192+
193+
test("returns override when base is undefined", () => {
194+
//#given
195+
const base = undefined
196+
const override = { a: 1 }
197+
198+
//#when
199+
const result = deepMerge<AnyObject>(base, override)
200+
201+
//#then
202+
expect(result).toEqual({ a: 1 })
203+
})
204+
205+
test("returns base when override is undefined", () => {
206+
//#given
207+
const base = { a: 1 }
208+
const override = undefined
209+
210+
//#when
211+
const result = deepMerge<AnyObject>(base, override)
212+
213+
//#then
214+
expect(result).toEqual({ a: 1 })
215+
})
216+
217+
test("preserves base value when override value is undefined", () => {
218+
//#given
219+
const base = { a: 1, b: 2 }
220+
const override = { a: undefined, b: 3 }
221+
222+
//#when
223+
const result = deepMerge(base, override)
224+
225+
//#then
226+
expect(result).toEqual({ a: 1, b: 3 })
227+
})
228+
229+
test("does not mutate base object", () => {
230+
//#given
231+
const base = { a: 1, b: { c: 2 } }
232+
const override = { b: { c: 10 } }
233+
const originalBase = JSON.parse(JSON.stringify(base))
234+
235+
//#when
236+
deepMerge(base, override)
237+
238+
//#then
239+
expect(base).toEqual(originalBase)
240+
})
241+
})
242+
243+
describe("array handling", () => {
244+
test("replaces arrays instead of merging them", () => {
245+
//#given
246+
const base = { arr: [1, 2] }
247+
const override = { arr: [3, 4, 5] }
248+
249+
//#when
250+
const result = deepMerge(base, override)
251+
252+
//#then
253+
expect(result).toEqual({ arr: [3, 4, 5] })
254+
})
255+
256+
test("replaces nested arrays", () => {
257+
//#given
258+
const base = { a: { arr: [1, 2, 3] } }
259+
const override = { a: { arr: [4] } }
260+
261+
//#when
262+
const result = deepMerge(base, override)
263+
264+
//#then
265+
expect(result).toEqual({ a: { arr: [4] } })
266+
})
267+
})
268+
269+
describe("prototype pollution protection", () => {
270+
test("ignores __proto__ key", () => {
271+
//#given
272+
const base: AnyObject = { a: 1 }
273+
const override: AnyObject = JSON.parse('{"__proto__": {"polluted": true}, "b": 2}')
274+
275+
//#when
276+
const result = deepMerge(base, override)
277+
278+
//#then
279+
expect(result).toEqual({ a: 1, b: 2 })
280+
expect(({} as AnyObject).polluted).toBeUndefined()
281+
})
282+
283+
test("ignores constructor key", () => {
284+
//#given
285+
const base: AnyObject = { a: 1 }
286+
const override: AnyObject = { constructor: { polluted: true }, b: 2 }
287+
288+
//#when
289+
const result = deepMerge(base, override)
290+
291+
//#then
292+
expect(result!.b).toBe(2)
293+
expect(result!["constructor"]).not.toEqual({ polluted: true })
294+
})
295+
296+
test("ignores prototype key", () => {
297+
//#given
298+
const base: AnyObject = { a: 1 }
299+
const override: AnyObject = { prototype: { polluted: true }, b: 2 }
300+
301+
//#when
302+
const result = deepMerge(base, override)
303+
304+
//#then
305+
expect(result!.b).toBe(2)
306+
expect(result!.prototype).toBeUndefined()
307+
})
308+
})
309+
310+
describe("depth limit", () => {
311+
test("returns override when depth exceeds MAX_DEPTH", () => {
312+
//#given
313+
const createDeepObject = (depth: number, leaf: AnyObject): AnyObject => {
314+
if (depth === 0) return leaf
315+
return { nested: createDeepObject(depth - 1, leaf) }
316+
}
317+
// Use different keys to distinguish base vs override
318+
const base = createDeepObject(55, { baseKey: "base" })
319+
const override = createDeepObject(55, { overrideKey: "override" })
320+
321+
//#when
322+
const result = deepMerge(base, override)
323+
324+
//#then
325+
// Navigate to depth 55 (leaf level, beyond MAX_DEPTH of 50)
326+
let current: AnyObject = result as AnyObject
327+
for (let i = 0; i < 55; i++) {
328+
current = current.nested as AnyObject
329+
}
330+
// At depth 55, only override's key should exist because
331+
// override replaced base entirely at depth 51+ (beyond MAX_DEPTH)
332+
expect(current.overrideKey).toBe("override")
333+
expect(current.baseKey).toBeUndefined()
334+
})
335+
})
336+
})

0 commit comments

Comments
 (0)