aboutsummaryrefslogtreecommitdiff
path: root/jsonmatch/diff_test.go
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2025-08-11 12:38:24 +0200
committerStefan Majewsky <majewsky@gmx.net>2025-08-11 12:38:50 +0200
commit2b4530f9c535027816aa46d06a6a565b97bd305c (patch)
treea01a1d36025aa43180486531b734905242b8c322 /jsonmatch/diff_test.go
parentd53f04ea869fb52c5d5d68a2032534bbaa27d120 (diff)
downloadgo-gg-2b4530f9c535027816aa46d06a6a565b97bd305c.tar.gz
add package jsonmatch
Diffstat (limited to 'jsonmatch/diff_test.go')
-rw-r--r--jsonmatch/diff_test.go392
1 files changed, 392 insertions, 0 deletions
diff --git a/jsonmatch/diff_test.go b/jsonmatch/diff_test.go
new file mode 100644
index 0000000..f0f4fdd
--- /dev/null
+++ b/jsonmatch/diff_test.go
@@ -0,0 +1,392 @@
+// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package jsonmatch_test
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ . "github.com/majewsky/gg/internal/test"
+ "github.com/majewsky/gg/jsonmatch"
+ . "github.com/majewsky/gg/option"
+)
+
+// assert that types implement the expected interfaces
+// (CaptureField needs dynamic casts because CaptureField() returns `any`)
+var (
+ _ jsonmatch.Diffable = jsonmatch.Array{}
+ _ jsonmatch.Diffable = jsonmatch.Object{}
+ _ jsonmatch.Diffable = jsonmatch.Null()
+ _ jsonmatch.Diffable = jsonmatch.Scalar("foo")
+ _ jsonmatch.Diffable = jsonmatch.Scalar(42)
+ _ jsonmatch.Diffable = jsonmatch.Scalar(false)
+ _ = jsonmatch.CaptureField(Some(1).AsPointer()).(json.Marshaler)
+ _ = jsonmatch.CaptureField(Some(1).AsPointer()).(json.Unmarshaler)
+)
+
+func TestCanonicalizesActualPayload(t *testing.T) {
+ testCases := [][]byte{
+ // all of these are functionally identical, so they should produce an empty diff
+ // against our expectations regardless of key order and whitespace
+ []byte(`{"data": {"qux":[5,null,15], "foo": 42, "bar": "hello world"}}`),
+ []byte(`{"data":{"bar":"hello world","foo":42,"qux":[5,null,15]}}`),
+ []byte(`{
+ "data": {
+ "bar": "hello world",
+ "qux": [
+ 5,
+ null,
+ 15
+ ],
+ "foo": 42
+ }
+ }`),
+ }
+
+ for _, message := range testCases {
+ t.Logf("message = %q", message)
+
+ // we test with several variants of `expected` using different underlying
+ // types that represent identical JSON payloads, but in different ways
+ match := jsonmatch.Object{
+ "data": jsonmatch.Object{
+ "foo": 42,
+ "bar": "hello world",
+ "qux": []any{5, nil, 15},
+ },
+ }
+ AssertEqual(t, match.DiffAgainst(message), nil)
+
+ // changing the type of `data` to map[string]any does not change anything at all;
+ // using the jsonmatch.Object name on this level is mostly syntactic sugar to communicate intent
+ match = jsonmatch.Object{
+ "data": map[string]any{
+ "foo": 42,
+ "bar": "hello world",
+ "qux": []any{5, nil, 15},
+ },
+ }
+ AssertEqual(t, match.DiffAgainst(message), nil)
+
+ // this is using subtypes that our logic cannot recurse into
+ // (map[opaqueString]any instead of map[string]any and []Option[int] instead of []any);
+ // comparison will be less granular and only be able to fail on the level of the opaque subtype, but it will still work
+ type opaqueString string
+ match = jsonmatch.Object{
+ "data": map[opaqueString]any{
+ "foo": 42,
+ "bar": "hello world",
+ "qux": []Option[int]{Some(5), None[int](), Some(15)},
+ },
+ }
+ AssertEqual(t, match.DiffAgainst(message), nil)
+
+ // this is using a specific struct type instead of a map[string]any, which results in a different serialization
+ // (map[string]any serializes with keys sorted alphabetically, but structs serialize with keys sorted by field declaration order;
+ // jsonmatch knows how to normalize this and thus correctly reports an empty diff because the serializations are identical except for field order)
+ match = jsonmatch.Object{
+ "data": struct {
+ Foo int `json:"foo"`
+ Bar string `json:"bar"`
+ Qux []Option[int] `json:"qux"`
+ }{
+ Foo: 42,
+ Bar: "hello world",
+ Qux: []Option[int]{Some(5), None[int](), Some(15)},
+ },
+ }
+ AssertEqual(t, match.DiffAgainst(message), nil)
+
+ // to try and trip up the normalization shown above, this match deliberately contains an unmarshalable object;
+ // jsonmatch should recognize that marshaling and unmarshaling does not work and skip the normalization
+ match = jsonmatch.Object{
+ "data": unmarshalableObject{},
+ }
+ AssertEqual(t, match.DiffAgainst(message), []jsonmatch.Diff{{
+ Kind: "type mismatch",
+ Pointer: "/data",
+ ExpectedJSON: `<not marshalable to JSON, %#v is jsonmatch_test.unmarshalableObject{}>`,
+ ActualJSON: `{"bar":"hello world","foo":42,"qux":[5,null,15]}`,
+ }})
+ }
+}
+
+func TestCapturesFields(t *testing.T) {
+ const (
+ uuid1 = "2cff2c65-f775-4ed5-8f86-be0998b19781"
+ uuid2 = "ce38aa5c-62ed-4367-a2f8-cbe2d73094a8"
+ )
+ message := fmt.Appendf(nil, `{"objects":[{"id":"%s","tags":["foo"]},{"id":"%s","tags":["bar"]}]}`, uuid1, uuid2)
+
+ // check that CaptureField() works as intended when contained within one of the supported container types
+ type opaqueString string
+ var (
+ capturedUUID1 string
+ capturedUUID2 string
+ capturedTag1 opaqueString // check that capturing also works for custom types
+ )
+ match := jsonmatch.Object{
+ "objects": []jsonmatch.Object{
+ {
+ "id": jsonmatch.CaptureField(&capturedUUID1),
+ "tags": []string{"foo"},
+ },
+ {
+ "id": jsonmatch.CaptureField(&capturedUUID2),
+ "tags": []any{jsonmatch.CaptureField(&capturedTag1)},
+ },
+ },
+ }
+
+ AssertEqual(t, match.DiffAgainst(message), nil)
+ AssertEqual(t, capturedUUID1, uuid1)
+ AssertEqual(t, capturedUUID2, uuid2)
+ AssertEqual(t, capturedTag1, "bar")
+
+ // check that CaptureField() complains when unmarshaling JSON messages into incompatible types
+ var (
+ capturedUUID3 int
+ )
+ match = jsonmatch.Object{
+ "objects": []jsonmatch.Object{
+ {
+ "id": jsonmatch.CaptureField(&capturedUUID3),
+ "tags": []string{"foo"},
+ },
+ {
+ "id": uuid2,
+ "tags": []string{"bar"},
+ },
+ },
+ }
+
+ AssertEqual(t, match.DiffAgainst(message), []jsonmatch.Diff{{
+ Kind: "cannot unmarshal into capture slot (json: cannot unmarshal string into Go value of type int)",
+ Pointer: "/objects/0/id",
+ ExpectedJSON: "<capture slot of type *int>",
+ ActualJSON: fmt.Sprintf("%q", uuid1),
+ }})
+
+ // check that CaptureField() does not work when contained within unsupported types
+ //
+ // This is a restriction that could be lifted in the future, but it would involve using advanced
+ // reflection shenanigans that complicate the implementation. The fact that this example uses
+ // somewhat contrived types to even be able to place a capture inside another structure shows that
+ // this restriction ought not be too problematic in practice.
+ capturedUUID1 = "unset"
+ capturedUUID2 = "unset"
+ capturedTag1 = "unset"
+ match = jsonmatch.Object{
+ "objects": []struct {
+ ID any `json:"id"`
+ Tags []any `json:"tags"`
+ }{
+ {
+ ID: jsonmatch.CaptureField(&capturedUUID1),
+ Tags: []any{"foo"},
+ },
+ {
+ ID: jsonmatch.CaptureField(&capturedUUID2),
+ Tags: []any{jsonmatch.CaptureField(&capturedTag1)},
+ },
+ },
+ }
+
+ AssertEqual(t, match.DiffAgainst(message), []jsonmatch.Diff{{
+ Kind: "value mismatch",
+ Pointer: "/objects",
+ ActualJSON: fmt.Sprintf(`[{"id":"%s","tags":["foo"]},{"id":"%s","tags":["bar"]}]`, uuid1, uuid2),
+ ExpectedJSON: `[{"id":"unset","tags":["foo"]},{"id":"unset","tags":["unset"]}]`,
+ }})
+}
+
+func TestFailsOnValueMismatch(t *testing.T) {
+ message := []byte(`{"users": [
+ {"id":23,"name":"Alice","tags":[{"name":"admin"},{"name":"senior"}]},
+ {"id":42,"name":"Bob","tags":[{"name":"support"}]}
+ ]}`)
+ match := jsonmatch.Object{
+ "users": []map[string]any{ // also side-note, because we did not have it anywhere else, this covers recursion into []map[string]any
+ {
+ "id": 23,
+ "name": "Alicia", // should be "Alice"
+ "status": "fixing stuff", // unexpected field
+ "tags": []jsonmatch.Object{{"name": "administrator"}}, // name should be "admin"; second list entry missing
+ },
+ {
+ // "id" field is missing
+ "name": "Bob",
+ "tags": []jsonmatch.Object{{"name": "support"}, {"name": "postmaster"}}, // unexpected list entry
+ },
+ },
+ }
+
+ AssertEqual(t, match.DiffAgainst(message), []jsonmatch.Diff{
+ {
+ Kind: "value mismatch",
+ Pointer: "/users/0/name",
+ ActualJSON: `"Alice"`,
+ ExpectedJSON: `"Alicia"`,
+ },
+ {
+ Kind: "value mismatch",
+ Pointer: "/users/0/tags/0/name",
+ ActualJSON: `"admin"`,
+ ExpectedJSON: `"administrator"`,
+ },
+ {
+ Kind: "value mismatch",
+ Pointer: "/users/0/tags/1",
+ ActualJSON: `{"name":"senior"}`,
+ ExpectedJSON: `<missing>`,
+ },
+ {
+ Kind: "value mismatch",
+ Pointer: "/users/0/status",
+ ActualJSON: `<missing>`,
+ ExpectedJSON: `"fixing stuff"`,
+ },
+ {
+ Kind: "value mismatch",
+ Pointer: "/users/1/id",
+ ActualJSON: `42`,
+ ExpectedJSON: `<missing>`,
+ },
+ {
+ Kind: "value mismatch",
+ Pointer: "/users/1/tags/1",
+ ActualJSON: `<missing>`,
+ ExpectedJSON: `{"name":"postmaster"}`,
+ },
+ })
+}
+
+func TestFailsOnTypeMismatch(t *testing.T) {
+ // several JSON values with incompatible JSON-level types, paired with their code-level representation
+ testCases := []struct {
+ JSON string
+ Data any
+ Scalar Option[jsonmatch.Diffable] // for testing calls to jsonmatch.Scalar().DiffAgainst() (see below)
+ }{
+ {
+ JSON: `null`,
+ Data: nil,
+ Scalar: Some(jsonmatch.Null()),
+ },
+ {
+ JSON: `true`,
+ Data: true,
+ Scalar: Some(jsonmatch.Scalar(true)),
+ },
+ {
+ JSON: `42`,
+ Data: 42,
+ Scalar: Some(jsonmatch.Scalar(42)),
+ },
+ {
+ JSON: `"foo"`,
+ Data: "foo",
+ Scalar: Some(jsonmatch.Scalar("foo")),
+ },
+ {
+ JSON: `{"value":42}`,
+ Data: map[string]any{"value": 42},
+ Scalar: None[jsonmatch.Diffable](),
+ },
+ {
+ JSON: `[42]`,
+ Data: []any{42},
+ Scalar: None[jsonmatch.Diffable](),
+ }}
+
+ for idx1, tc1 := range testCases {
+ objectMessage := fmt.Appendf(nil, `{"payload":%s}`, tc1.JSON)
+ arrayMessage := fmt.Appendf(nil, `[1,%s]`, tc1.JSON)
+ plainMessage := []byte(tc1.JSON)
+
+ for idx2, tc2 := range testCases {
+ // type mismatch inside of an object
+ objectMatch := jsonmatch.Object{"payload": tc2.Data}
+ if idx1 == idx2 {
+ // if we chose matching JSON and data types, then everything works as intended
+ AssertEqual(t, objectMatch.DiffAgainst(objectMessage), nil)
+ } else {
+ // otherwise we expect a "type mismatch" error
+ AssertEqual(t, objectMatch.DiffAgainst(objectMessage), []jsonmatch.Diff{{
+ Kind: "type mismatch",
+ Pointer: "/payload",
+ ActualJSON: tc1.JSON,
+ ExpectedJSON: tc2.JSON,
+ }})
+ }
+
+ // type mismatch inside of an array
+ arrayMatch := jsonmatch.Array{1, tc2.Data}
+ if idx1 == idx2 {
+ AssertEqual(t, arrayMatch.DiffAgainst(arrayMessage), nil)
+ } else {
+ AssertEqual(t, arrayMatch.DiffAgainst(arrayMessage), []jsonmatch.Diff{{
+ Kind: "type mismatch",
+ Pointer: "/1",
+ ActualJSON: tc1.JSON,
+ ExpectedJSON: tc2.JSON,
+ }})
+ }
+
+ // type mismatch for plain scalar
+ if scalarMatch, ok := tc2.Scalar.Unpack(); ok {
+ if idx1 == idx2 {
+ AssertEqual(t, scalarMatch.DiffAgainst(plainMessage), nil)
+ } else {
+ AssertEqual(t, scalarMatch.DiffAgainst(plainMessage), []jsonmatch.Diff{{
+ Kind: "type mismatch",
+ Pointer: "",
+ ActualJSON: tc1.JSON,
+ ExpectedJSON: tc2.JSON,
+ }})
+ }
+ }
+ }
+ }
+}
+
+func TestFailsOnUnmarshalError(t *testing.T) {
+ // all of these things are definitely not valid JSON messages
+ testCases := [][]byte{
+ // empty string
+ []byte(""),
+ // looks like text/plain
+ []byte("Not found\n"),
+ // looks like text/yaml
+ []byte("data:\n- 23\n- 42\n"),
+ // incomplete JSON
+ []byte(`{"data":[23,`),
+ // this one is not even a valid UTF-8 string
+ []byte("a\xffb\xC0\xAFc\xff"),
+ }
+ match := jsonmatch.Object{
+ "data": jsonmatch.Array{23, 42},
+ }
+
+ for _, message := range testCases {
+ diffs := match.DiffAgainst(message)
+ if AssertEqual(t, len(diffs), 1) {
+ diff := diffs[0]
+ AssertEqual(t, strings.HasPrefix(diff.Kind, "unmarshal error ("), true)
+ AssertEqual(t, strings.HasSuffix(diff.Kind, ")"), true)
+ AssertEqual(t, diff.Pointer, "")
+ AssertEqual(t, diff.ExpectedJSON, `{"data":[23,42]}`)
+ AssertEqual(t, strings.ReplaceAll(diff.ActualJSON, "\uFFFD", ""), strings.ToValidUTF8(string(message), ""))
+ }
+ }
+}
+
+type unmarshalableObject struct{}
+
+func (unmarshalableObject) MarshalJSON() ([]byte, error) {
+ return nil, errors.New("this object is unmarshalable")
+}