949 lines
29 KiB
TypeScript
949 lines
29 KiB
TypeScript
import { describe, it } from "@effect/vitest"
|
|
import { deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils"
|
|
import * as JsonPatch from "effect/JsonPatch"
|
|
import type * as Schema from "effect/Schema"
|
|
|
|
const expectMessage = (f: () => unknown, includes: string) => {
|
|
throws(f, (e) => {
|
|
if (e instanceof Error && e.message.includes(includes)) return undefined
|
|
throw e
|
|
})
|
|
}
|
|
|
|
const expectAppliesTo = (patch: JsonPatch.JsonPatch, oldValue: Schema.Json, newValue: Schema.Json) => {
|
|
deepStrictEqual(JsonPatch.apply(patch, oldValue), newValue)
|
|
}
|
|
|
|
describe("JsonPatch", () => {
|
|
describe("get", () => {
|
|
describe("root values", () => {
|
|
it("returns [] for identical values", () => {
|
|
const cases: ReadonlyArray<Schema.Json> = [
|
|
1,
|
|
"hello",
|
|
true,
|
|
null,
|
|
[1, 2, 3],
|
|
{ a: 1 }
|
|
]
|
|
for (const v of cases) {
|
|
deepStrictEqual(JsonPatch.get(v, v), [])
|
|
}
|
|
})
|
|
|
|
it("emits a root replace for primitive changes", () => {
|
|
const cases: ReadonlyArray<[Schema.Json, Schema.Json]> = [
|
|
[1, 2],
|
|
["hello", "world"],
|
|
[true, false],
|
|
[null, 42]
|
|
]
|
|
for (const [from, to] of cases) {
|
|
deepStrictEqual(JsonPatch.get(from, to), [{ op: "replace", path: "", value: to }])
|
|
}
|
|
})
|
|
|
|
it("emits a root replace for type changes", () => {
|
|
deepStrictEqual(JsonPatch.get(1, "string"), [{ op: "replace", path: "", value: "string" }])
|
|
deepStrictEqual(JsonPatch.get([1, 2], { a: 1 }), [{ op: "replace", path: "", value: { a: 1 } }])
|
|
deepStrictEqual(JsonPatch.get({ a: 1 }, [1, 2]), [{ op: "replace", path: "", value: [1, 2] }])
|
|
})
|
|
})
|
|
|
|
describe("arrays", () => {
|
|
it("adds new elements (append semantics)", () => {
|
|
deepStrictEqual(JsonPatch.get([1, 2], [1, 2, 3]), [{ op: "add", path: "/2", value: 3 }])
|
|
deepStrictEqual(JsonPatch.get([], [1, 2, 3]), [
|
|
{ op: "add", path: "/0", value: 1 },
|
|
{ op: "add", path: "/1", value: 2 },
|
|
{ op: "add", path: "/2", value: 3 }
|
|
])
|
|
})
|
|
|
|
it("removes elements in descending index order (no index shifting)", () => {
|
|
deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 2]), [{ op: "remove", path: "/2" }])
|
|
|
|
const patch = JsonPatch.get([0, 1, 2, 3, 4, 5], [0, 1, 3])
|
|
const removeIdx = patch
|
|
.filter((op) => op.op === "remove")
|
|
.map((op) => Number(op.path.slice(1)))
|
|
|
|
deepStrictEqual(removeIdx, [...removeIdx].sort((a, b) => b - a))
|
|
expectAppliesTo(patch, [0, 1, 2, 3, 4, 5], [0, 1, 3])
|
|
})
|
|
|
|
it("replaces modified elements", () => {
|
|
deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 4, 3]), [{ op: "replace", path: "/1", value: 4 }])
|
|
})
|
|
|
|
it("handles mixed operations", () => {
|
|
deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 4, 5, 6]), [
|
|
{ op: "replace", path: "/1", value: 4 },
|
|
{ op: "replace", path: "/2", value: 5 },
|
|
{ op: "add", path: "/3", value: 6 }
|
|
])
|
|
|
|
deepStrictEqual(JsonPatch.get([1, 2, 3, 4], [1, 5]), [
|
|
{ op: "replace", path: "/1", value: 5 },
|
|
{ op: "remove", path: "/3" },
|
|
{ op: "remove", path: "/2" }
|
|
])
|
|
})
|
|
|
|
it("supports nested arrays", () => {
|
|
deepStrictEqual(JsonPatch.get([[1, 2], [3, 4]], [[1, 2], [3, 5]]), [
|
|
{ op: "replace", path: "/1/1", value: 5 }
|
|
])
|
|
|
|
deepStrictEqual(JsonPatch.get([[1, 2]], [[1, 2], [3, 4]]), [
|
|
{ op: "add", path: "/1", value: [3, 4] }
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("objects", () => {
|
|
it("uses stable key order for deterministic patches", () => {
|
|
const patch = JsonPatch.get({ b: 1, a: 1 }, { a: 2, b: 2 })
|
|
deepStrictEqual(
|
|
patch.filter((op) => op.op === "replace").map((op) => op.path),
|
|
["/a", "/b"]
|
|
)
|
|
expectAppliesTo(patch, { b: 1, a: 1 }, { a: 2, b: 2 })
|
|
})
|
|
|
|
it("adds / removes / replaces properties", () => {
|
|
deepStrictEqual(JsonPatch.get({ a: 1 }, { a: 1, b: 2 }), [{ op: "add", path: "/b", value: 2 }])
|
|
deepStrictEqual(JsonPatch.get({ a: 1, b: 2 }, { a: 1 }), [{ op: "remove", path: "/b" }])
|
|
deepStrictEqual(JsonPatch.get({ a: 1, b: 2 }, { a: 1, b: 3 }), [{ op: "replace", path: "/b", value: 3 }])
|
|
})
|
|
|
|
it("removes properties in sorted key order", () => {
|
|
deepStrictEqual(JsonPatch.get({ a: 1, b: 2, c: 3 }, {}), [
|
|
{ op: "remove", path: "/a" },
|
|
{ op: "remove", path: "/b" },
|
|
{ op: "remove", path: "/c" }
|
|
])
|
|
})
|
|
|
|
it("handles mixed object operations", () => {
|
|
deepStrictEqual(JsonPatch.get({ a: 1, b: 2 }, { a: 1, c: 3, d: 4 }), [
|
|
{ op: "remove", path: "/b" },
|
|
{ op: "add", path: "/c", value: 3 },
|
|
{ op: "add", path: "/d", value: 4 }
|
|
])
|
|
})
|
|
|
|
it("supports nested objects", () => {
|
|
deepStrictEqual(JsonPatch.get({ a: { b: 1 } }, { a: { b: 2 } }), [
|
|
{ op: "replace", path: "/a/b", value: 2 }
|
|
])
|
|
deepStrictEqual(JsonPatch.get({ a: { b: 1 } }, { a: { b: 1, c: 2 } }), [
|
|
{ op: "add", path: "/a/c", value: 2 }
|
|
])
|
|
deepStrictEqual(JsonPatch.get({ a: { b: 1, c: 2 } }, { a: { b: 1 } }), [
|
|
{ op: "remove", path: "/a/c" }
|
|
])
|
|
})
|
|
|
|
describe("JSON Pointer escaping in keys", () => {
|
|
it("escapes '/' as '~1' and '~' as '~0'", () => {
|
|
deepStrictEqual(JsonPatch.get({ "a/b": 1 }, { "a/b": 2 }), [
|
|
{ op: "replace", path: "/a~1b", value: 2 }
|
|
])
|
|
deepStrictEqual(JsonPatch.get({ "a~b": 1 }, { "a~b": 2 }), [
|
|
{ op: "replace", path: "/a~0b", value: 2 }
|
|
])
|
|
})
|
|
|
|
it("represents a literal key '~1' as token '~01'", () => {
|
|
deepStrictEqual(JsonPatch.get({}, { "~1": 0 }), [
|
|
{ op: "add", path: "/~01", value: 0 }
|
|
])
|
|
})
|
|
|
|
it("does not confuse '~01' with '/' (unescape order)", () => {
|
|
deepStrictEqual(JsonPatch.get({ "a~1b": 1 }, { "a~1b": 2 }), [
|
|
{ op: "replace", path: "/a~01b", value: 2 }
|
|
])
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("complex nested structures", () => {
|
|
it("emits a minimal-ish patch for deep updates (and is applicable)", () => {
|
|
const oldValue: Schema.Json = {
|
|
users: [
|
|
{ id: 1, name: "Alice", tags: ["admin"] },
|
|
{ id: 2, name: "Bob", tags: [] }
|
|
],
|
|
metadata: { version: 1 }
|
|
}
|
|
|
|
const newValue: Schema.Json = {
|
|
users: [
|
|
{ id: 1, name: "Alice", tags: ["admin", "moderator"] },
|
|
{ id: 2, name: "Bob", tags: [] },
|
|
{ id: 3, name: "Charlie", tags: [] }
|
|
],
|
|
metadata: { version: 2 }
|
|
}
|
|
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
deepStrictEqual(patch, [
|
|
{ op: "replace", path: "/metadata/version", value: 2 },
|
|
{ op: "add", path: "/users/0/tags/1", value: "moderator" },
|
|
{ op: "add", path: "/users/2", value: { id: 3, name: "Charlie", tags: [] } }
|
|
])
|
|
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
})
|
|
})
|
|
|
|
describe("immutability", () => {
|
|
it("does not mutate input values", () => {
|
|
const oldArray = [1, 2, 3]
|
|
const newArray = [1, 2, 4]
|
|
const originalArray = [...oldArray]
|
|
|
|
JsonPatch.get(oldArray, newArray)
|
|
deepStrictEqual(oldArray, originalArray)
|
|
|
|
const oldObject = { a: 1, b: 2 }
|
|
const newObject = { a: 1, b: 3, c: 4 }
|
|
const originalObject = { ...oldObject }
|
|
|
|
JsonPatch.get(oldObject, newObject)
|
|
deepStrictEqual(oldObject, originalObject)
|
|
})
|
|
|
|
it("returns empty array for identical references", () => {
|
|
const value = { a: 1, b: [2, 3] }
|
|
const patch = JsonPatch.get(value, value)
|
|
deepStrictEqual(patch, [])
|
|
})
|
|
})
|
|
|
|
describe("empty structures", () => {
|
|
it("handles empty array to empty array", () => {
|
|
deepStrictEqual(JsonPatch.get([], []), [])
|
|
})
|
|
|
|
it("handles empty object to empty object", () => {
|
|
deepStrictEqual(JsonPatch.get({}, {}), [])
|
|
})
|
|
|
|
it("handles empty array to non-empty array", () => {
|
|
deepStrictEqual(JsonPatch.get([], [1, 2]), [
|
|
{ op: "add", path: "/0", value: 1 },
|
|
{ op: "add", path: "/1", value: 2 }
|
|
])
|
|
})
|
|
|
|
it("handles non-empty array to empty array", () => {
|
|
const patch = JsonPatch.get([1, 2], [])
|
|
deepStrictEqual(patch, [{ op: "remove", path: "/1" }, { op: "remove", path: "/0" }])
|
|
expectAppliesTo(patch, [1, 2], [])
|
|
})
|
|
|
|
it("handles empty object to non-empty object", () => {
|
|
deepStrictEqual(JsonPatch.get({}, { a: 1, b: 2 }), [
|
|
{ op: "add", path: "/a", value: 1 },
|
|
{ op: "add", path: "/b", value: 2 }
|
|
])
|
|
})
|
|
|
|
it("handles non-empty object to empty object", () => {
|
|
const patch = JsonPatch.get({ a: 1, b: 2 }, {})
|
|
deepStrictEqual(patch, [
|
|
{ op: "remove", path: "/a" },
|
|
{ op: "remove", path: "/b" }
|
|
])
|
|
expectAppliesTo(patch, { a: 1, b: 2 }, {})
|
|
})
|
|
})
|
|
|
|
describe("reference equality optimization", () => {
|
|
it("returns empty patch for structurally equal objects and same references", () => {
|
|
const value1 = { a: 1 }
|
|
const value2 = { a: 1 }
|
|
const value3 = value1
|
|
|
|
const patch1 = JsonPatch.get(value1, value2)
|
|
deepStrictEqual(patch1, [])
|
|
expectAppliesTo(patch1, value1, value2)
|
|
|
|
deepStrictEqual(JsonPatch.get(value1, value3), [])
|
|
})
|
|
|
|
it("returns empty patch for structurally equal but different objects", () => {
|
|
const value1 = { a: 1, b: { c: 2 } }
|
|
const value2 = { a: 1, b: { c: 2 } }
|
|
deepStrictEqual(JsonPatch.get(value1, value2), [])
|
|
})
|
|
|
|
it("emits the property replace for structurally different objects", () => {
|
|
const value1 = { a: 1 }
|
|
const value2 = { a: 2 }
|
|
const patch = JsonPatch.get(value1, value2)
|
|
deepStrictEqual(patch.length > 0, true)
|
|
expectAppliesTo(patch, value1, value2)
|
|
})
|
|
|
|
it("returns empty patch for same array reference", () => {
|
|
const arr = [1, 2, 3]
|
|
deepStrictEqual(JsonPatch.get(arr, arr), [])
|
|
})
|
|
|
|
it("returns empty patch for same object reference", () => {
|
|
const obj = { a: 1, b: 2 }
|
|
deepStrictEqual(JsonPatch.get(obj, obj), [])
|
|
})
|
|
})
|
|
|
|
describe("edge cases", () => {
|
|
it("handles single-element arrays", () => {
|
|
deepStrictEqual(JsonPatch.get([1], [2]), [{ op: "replace", path: "/0", value: 2 }])
|
|
deepStrictEqual(JsonPatch.get([1], []), [{ op: "remove", path: "/0" }])
|
|
deepStrictEqual(JsonPatch.get([], [1]), [{ op: "add", path: "/0", value: 1 }])
|
|
})
|
|
|
|
it("handles single-property objects", () => {
|
|
deepStrictEqual(JsonPatch.get({ a: 1 }, { a: 2 }), [{ op: "replace", path: "/a", value: 2 }])
|
|
deepStrictEqual(JsonPatch.get({ a: 1 }, {}), [{ op: "remove", path: "/a" }])
|
|
deepStrictEqual(JsonPatch.get({}, { a: 1 }), [{ op: "add", path: "/a", value: 1 }])
|
|
})
|
|
|
|
it("handles array index 0 operations", () => {
|
|
deepStrictEqual(JsonPatch.get([1, 2, 3], [0, 2, 3]), [{ op: "replace", path: "/0", value: 0 }])
|
|
const patch = JsonPatch.get([1, 2, 3], [2, 3])
|
|
expectAppliesTo(patch, [1, 2, 3], [2, 3])
|
|
deepStrictEqual(patch, [
|
|
{ op: "replace", path: "/0", value: 2 },
|
|
{ op: "replace", path: "/1", value: 3 },
|
|
{ op: "remove", path: "/2" }
|
|
])
|
|
})
|
|
|
|
it("handles operations at last array index", () => {
|
|
deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 2, 4]), [{ op: "replace", path: "/2", value: 4 }])
|
|
deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 2]), [{ op: "remove", path: "/2" }])
|
|
})
|
|
})
|
|
|
|
describe("deep nesting", () => {
|
|
it("handles deeply nested structures", () => {
|
|
const oldValue: Schema.Json = {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
level4: {
|
|
level5: { value: "old" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const newValue: Schema.Json = {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
level4: {
|
|
level5: { value: "new", extra: "added" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
})
|
|
|
|
it("handles nested arrays within objects", () => {
|
|
const oldValue: Schema.Json = {
|
|
items: [
|
|
[1, 2],
|
|
[3, 4]
|
|
]
|
|
}
|
|
|
|
const newValue: Schema.Json = {
|
|
items: [
|
|
[1, 2],
|
|
[3, 5],
|
|
[6, 7]
|
|
]
|
|
}
|
|
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("apply", () => {
|
|
describe("happy paths", () => {
|
|
it("applies operations after a root replace", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply(
|
|
[
|
|
{ op: "replace", path: "", value: {} },
|
|
{ op: "add", path: "/a", value: 1 }
|
|
],
|
|
{ old: true }
|
|
),
|
|
{ a: 1 }
|
|
)
|
|
})
|
|
|
|
it("replace", () => {
|
|
deepStrictEqual(JsonPatch.apply([{ op: "replace", path: "", value: 42 }], 1), 42)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "/a", value: 2 }], { a: 1 }),
|
|
{ a: 2 }
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "/1", value: 20 }], [1, 2, 3]),
|
|
[1, 20, 3]
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "/a/b", value: 2 }], { a: { b: 1 } }),
|
|
{ a: { b: 2 } }
|
|
)
|
|
})
|
|
|
|
it("add", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/b", value: 2 }], { a: 1 }),
|
|
{ a: 1, b: 2 }
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/1", value: 10 }], [1, 2, 3]),
|
|
[1, 10, 2, 3]
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/-", value: 4 }], [1, 2, 3]),
|
|
[1, 2, 3, 4]
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/users/0/tags/-", value: "admin" }], {
|
|
users: [{ id: 1, tags: [] }]
|
|
}),
|
|
{ users: [{ id: 1, tags: ["admin"] }] }
|
|
)
|
|
})
|
|
|
|
it("remove", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "remove", path: "/a" }], { a: 1, b: 2 }),
|
|
{ b: 2 }
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "remove", path: "/1" }], [1, 2, 3]),
|
|
[1, 3]
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "remove", path: "/a/b" }], { a: { b: 1, c: 2 } }),
|
|
{ a: { c: 2 } }
|
|
)
|
|
})
|
|
|
|
it("applies multiple operations in sequence", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply(
|
|
[
|
|
{ op: "add", path: "/c", value: 3 },
|
|
{ op: "replace", path: "/a", value: 10 },
|
|
{ op: "remove", path: "/b" }
|
|
],
|
|
{ a: 1, b: 2 }
|
|
),
|
|
{ a: 10, c: 3 }
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("JSON Pointer decoding", () => {
|
|
it("decodes '~1' as '/' and '~0' as '~'", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "/a~1b", value: 2 }], { "a/b": 1 }),
|
|
{ "a/b": 2 }
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/a~0b", value: 1 }], {}),
|
|
{ "a~b": 1 }
|
|
)
|
|
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/path~1to~0key", value: "value" }], {}),
|
|
{ "path/to~key": "value" }
|
|
)
|
|
})
|
|
|
|
it("decodes '~01' as a literal '~1' (unescape order)", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/a~01b", value: 1 }], {}),
|
|
{ "a~1b": 1 }
|
|
)
|
|
})
|
|
|
|
it("addresses a literal key '~1' via token '~01'", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/~01", value: 0 }], {}),
|
|
{ "~1": 0 }
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("errors", () => {
|
|
it("rejects non-empty pointers that do not start with '/'", () => {
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "add", path: "invalid", value: 1 }], {}),
|
|
`must start with "/"`
|
|
)
|
|
})
|
|
|
|
it("rejects invalid array indices", () => {
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "add", path: "/abc", value: 1 }], []),
|
|
`Invalid array index`
|
|
)
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "replace", path: "/-1", value: 1 }], [1, 2, 3]),
|
|
`Invalid array index`
|
|
)
|
|
})
|
|
|
|
it("rejects out-of-bounds array access", () => {
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "replace", path: "/10", value: 1 }], [1, 2, 3]),
|
|
"Array index out of bounds"
|
|
)
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "add", path: "/10", value: 1 }], [1, 2, 3]),
|
|
"Array index out of bounds"
|
|
)
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "remove", path: "/10" }], [1, 2, 3]),
|
|
"Array index out of bounds"
|
|
)
|
|
})
|
|
|
|
it("rejects '-' for replace/remove", () => {
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "replace", path: "/-", value: 1 }], [1, 2, 3]),
|
|
`"-" is not valid for replace`
|
|
)
|
|
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "remove", path: "/-" }], [1, 2, 3]),
|
|
`"-" is not valid for remove`
|
|
)
|
|
})
|
|
|
|
it("rejects replace/remove of non-existent object members", () => {
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "replace", path: "/nonexistent", value: 1 }], { a: 1 }),
|
|
`does not exist`
|
|
)
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "remove", path: "/nonexistent" }], { a: 1 }),
|
|
`does not exist`
|
|
)
|
|
})
|
|
|
|
it("rejects add/replace when the parent is missing or not a container", () => {
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "add", path: "/a/b", value: 1 }], { a: null }),
|
|
"Cannot add at"
|
|
)
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "add", path: "/a/b", value: 1 }], {}),
|
|
"Cannot add at"
|
|
)
|
|
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "add", path: "/a/b", value: 1 }], { a: "string" }),
|
|
"not a container"
|
|
)
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "replace", path: "/a/b", value: 1 }], { a: 42 }),
|
|
"not a container"
|
|
)
|
|
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "add", path: "/a/b/c", value: 1 }], { a: { b: "not-object" } }),
|
|
"not a container"
|
|
)
|
|
})
|
|
|
|
it("rejects remove at the root", () => {
|
|
expectMessage(
|
|
() => JsonPatch.apply([{ op: "remove", path: "" }], { a: 1 }),
|
|
"root"
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("immutability", () => {
|
|
it("does not mutate input document", () => {
|
|
const original = { a: 1, b: [2, 3] }
|
|
const originalCopy = { a: 1, b: [2, 3] }
|
|
|
|
JsonPatch.apply([{ op: "replace", path: "/a", value: 10 }], original)
|
|
deepStrictEqual(original, originalCopy)
|
|
|
|
const originalArray = [1, 2, 3]
|
|
const originalArrayCopy = [1, 2, 3]
|
|
|
|
JsonPatch.apply([{ op: "add", path: "/1", value: 99 }], originalArray)
|
|
deepStrictEqual(originalArray, originalArrayCopy)
|
|
})
|
|
|
|
it("does not mutate patch array", () => {
|
|
const patch: JsonPatch.JsonPatch = [{ op: "add", path: "/b", value: 2 }]
|
|
const patchCopy = JSON.parse(JSON.stringify(patch))
|
|
|
|
JsonPatch.apply(patch, { a: 1 })
|
|
deepStrictEqual(patch, patchCopy)
|
|
})
|
|
})
|
|
|
|
describe("empty patch optimization", () => {
|
|
it("returns original reference for empty patch", () => {
|
|
const doc = { a: 1, b: 2 }
|
|
const result = JsonPatch.apply([], doc)
|
|
strictEqual(result, doc)
|
|
})
|
|
|
|
it("returns original reference for empty patch with arrays", () => {
|
|
const arr = [1, 2, 3]
|
|
const result = JsonPatch.apply([], arr)
|
|
strictEqual(result, arr)
|
|
})
|
|
|
|
it("returns original reference for empty patch with primitives", () => {
|
|
const primitives: ReadonlyArray<Schema.Json> = [1, "hello", true, null]
|
|
for (const value of primitives) {
|
|
const result = JsonPatch.apply([], value)
|
|
strictEqual(result, value)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("sequential path dependencies", () => {
|
|
it("applies operations on paths created by earlier operations", () => {
|
|
const result = JsonPatch.apply(
|
|
[
|
|
{ op: "add", path: "/new", value: {} },
|
|
{ op: "add", path: "/new/nested", value: { value: 1 } },
|
|
{ op: "replace", path: "/new/nested/value", value: 2 }
|
|
],
|
|
{}
|
|
)
|
|
deepStrictEqual(result, { new: { nested: { value: 2 } } })
|
|
})
|
|
|
|
it("applies operations on array paths created by earlier operations", () => {
|
|
const result = JsonPatch.apply(
|
|
[
|
|
{ op: "add", path: "/items", value: [] },
|
|
{ op: "add", path: "/items/-", value: 1 },
|
|
{ op: "add", path: "/items/-", value: 2 },
|
|
{ op: "replace", path: "/items/0", value: 10 }
|
|
],
|
|
{}
|
|
)
|
|
deepStrictEqual(result, { items: [10, 2] })
|
|
})
|
|
|
|
it("applies multiple operations on same path", () => {
|
|
const result = JsonPatch.apply(
|
|
[
|
|
{ op: "add", path: "/counter", value: 0 },
|
|
{ op: "replace", path: "/counter", value: 1 },
|
|
{ op: "replace", path: "/counter", value: 2 }
|
|
],
|
|
{}
|
|
)
|
|
deepStrictEqual(result, { counter: 2 })
|
|
})
|
|
|
|
it("applies remove then add on same path", () => {
|
|
const result = JsonPatch.apply(
|
|
[
|
|
{ op: "remove", path: "/a" },
|
|
{ op: "add", path: "/a", value: "new" }
|
|
],
|
|
{ a: "old", b: 2 }
|
|
)
|
|
deepStrictEqual(result, { a: "new", b: 2 })
|
|
})
|
|
})
|
|
|
|
describe("edge cases", () => {
|
|
it("handles operations on empty arrays", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/-", value: 1 }], []),
|
|
[1]
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/0", value: 1 }], []),
|
|
[1]
|
|
)
|
|
})
|
|
|
|
it("handles operations on empty objects", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/key", value: "value" }], {}),
|
|
{ key: "value" }
|
|
)
|
|
})
|
|
|
|
it("handles array operations at index 0", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "/0", value: 10 }], [1, 2, 3]),
|
|
[10, 2, 3]
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "remove", path: "/0" }], [1, 2, 3]),
|
|
[2, 3]
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/0", value: 0 }], [1, 2, 3]),
|
|
[0, 1, 2, 3]
|
|
)
|
|
})
|
|
|
|
it("handles array operations at last index", () => {
|
|
const arr = [1, 2, 3]
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "/2", value: 30 }], arr),
|
|
[1, 2, 30]
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "remove", path: "/2" }], arr),
|
|
[1, 2]
|
|
)
|
|
})
|
|
|
|
it("handles single-element array operations", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "/0", value: 2 }], [1]),
|
|
[2]
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "remove", path: "/0" }], [1]),
|
|
[]
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "add", path: "/0", value: 0 }], [1]),
|
|
[0, 1]
|
|
)
|
|
})
|
|
|
|
it("handles single-property object operations", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "/a", value: 2 }], { a: 1 }),
|
|
{ a: 2 }
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "remove", path: "/a" }], { a: 1 }),
|
|
{}
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("root operations", () => {
|
|
it("applies root replace followed by nested operations", () => {
|
|
const result = JsonPatch.apply(
|
|
[
|
|
{ op: "replace", path: "", value: {} },
|
|
{ op: "add", path: "/a", value: 1 },
|
|
{ op: "add", path: "/b", value: 2 }
|
|
],
|
|
{ old: "data" }
|
|
)
|
|
deepStrictEqual(result, { a: 1, b: 2 })
|
|
})
|
|
|
|
it("applies root replace to different types", () => {
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "", value: [] }], { a: 1 }),
|
|
[]
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "", value: "string" }], 42),
|
|
"string"
|
|
)
|
|
deepStrictEqual(
|
|
JsonPatch.apply([{ op: "replace", path: "", value: null }], true),
|
|
null
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("round-trip", () => {
|
|
const cases: ReadonlyArray<[Schema.Json, Schema.Json]> = [
|
|
[
|
|
{
|
|
users: [
|
|
{ id: 1, name: "Alice", active: true },
|
|
{ id: 2, name: "Bob", active: false }
|
|
],
|
|
metadata: { version: 1, tags: ["v1"] }
|
|
},
|
|
{
|
|
users: [
|
|
{ id: 1, name: "Alice Updated", active: true },
|
|
{ id: 3, name: "Charlie", active: true }
|
|
],
|
|
metadata: { version: 2, tags: ["v2", "latest"] }
|
|
}
|
|
],
|
|
[
|
|
[1, 2, 3, 4, 5],
|
|
[1, 20, 3, 40, 50, 60]
|
|
],
|
|
[
|
|
{ a: 1, b: 2, c: 3 },
|
|
{ a: 10, d: 4, e: 5 }
|
|
],
|
|
[
|
|
{ level1: { level2: { level3: { value: "old" } } } },
|
|
{ level1: { level2: { level3: { value: "new", extra: "added" } } } }
|
|
]
|
|
]
|
|
|
|
it("apply(get(old, new), old) === new", () => {
|
|
for (const [oldValue, newValue] of cases) {
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
}
|
|
})
|
|
|
|
it("round-trip with empty structures", () => {
|
|
const emptyCases: ReadonlyArray<[Schema.Json, Schema.Json]> = [
|
|
[[], [1, 2, 3]],
|
|
[[1, 2, 3], []],
|
|
[{}, { a: 1, b: 2 }],
|
|
[{ a: 1, b: 2 }, {}]
|
|
]
|
|
|
|
for (const [oldValue, newValue] of emptyCases) {
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
}
|
|
})
|
|
|
|
it("round-trip with nested arrays and objects", () => {
|
|
const oldValue: Schema.Json = {
|
|
matrix: [
|
|
[1, 2, 3],
|
|
[4, 5, 6],
|
|
[7, 8, 9]
|
|
],
|
|
config: {
|
|
settings: {
|
|
nested: {
|
|
deep: { value: "old" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const newValue: Schema.Json = {
|
|
matrix: [
|
|
[1, 2, 3],
|
|
[4, 50, 6],
|
|
[7, 8, 9],
|
|
[10, 11, 12]
|
|
],
|
|
config: {
|
|
settings: {
|
|
nested: {
|
|
deep: { value: "new", extra: "field" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
})
|
|
|
|
it("round-trip with complex array operations", () => {
|
|
const oldValue: Schema.Json = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
const newValue: Schema.Json = [10, 1, 20, 3, 40, 50, 6, 70, 8]
|
|
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
})
|
|
|
|
it("round-trip with escaped keys", () => {
|
|
const oldValue: Schema.Json = {
|
|
"key/with/slash": 1,
|
|
"key~with~tilde": 2,
|
|
"normal": 3
|
|
}
|
|
|
|
const newValue: Schema.Json = {
|
|
"key/with/slash": 10,
|
|
"key~with~tilde": 20,
|
|
"normal": 30,
|
|
"new/key": 40
|
|
}
|
|
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
})
|
|
|
|
it("round-trip with type changes", () => {
|
|
const typeChangeCases: ReadonlyArray<[Schema.Json, Schema.Json]> = [
|
|
[1, "string"],
|
|
["string", 42],
|
|
[true, null],
|
|
[null, []],
|
|
[[], {}],
|
|
[{}, []]
|
|
]
|
|
|
|
for (const [oldValue, newValue] of typeChangeCases) {
|
|
const patch = JsonPatch.get(oldValue, newValue)
|
|
expectAppliesTo(patch, oldValue, newValue)
|
|
}
|
|
})
|
|
|
|
it("round-trip preserves identical values (empty patch)", () => {
|
|
const identicalCases: ReadonlyArray<Schema.Json> = [
|
|
{ a: 1, b: 2 },
|
|
[1, 2, 3],
|
|
"string",
|
|
42,
|
|
true,
|
|
null,
|
|
{ nested: { deep: { value: "test" } } },
|
|
[[1, 2], [3, 4]]
|
|
]
|
|
|
|
for (const value of identicalCases) {
|
|
const patch = JsonPatch.get(value, value)
|
|
deepStrictEqual(patch, [])
|
|
const result = JsonPatch.apply(patch, value)
|
|
deepStrictEqual(result, value)
|
|
}
|
|
})
|
|
})
|
|
})
|