diff --git a/go.mod b/go.mod index 362ae47..4876cd0 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/opensearch-project/opensearch-go/v4 v4.6.0 github.com/peterldowns/pgtestdb v0.1.1 github.com/peterldowns/pgtestdb/migrators/golangmigrator v0.1.1 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/rs/zerolog v1.34.0 github.com/samber/lo v1.52.0 github.com/spf13/viper v1.21.0 @@ -23,6 +24,7 @@ require ( github.com/swaggo/files/v2 v2.0.2 github.com/swaggo/swag v1.16.6 github.com/testcontainers/testcontainers-go v0.40.0 + github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 golang.org/x/crypto v0.48.0 @@ -108,7 +110,6 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect @@ -120,7 +121,6 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect diff --git a/pkg/httpassert/README.md b/pkg/httpassert/README.md index 4da4918..5c24154 100644 --- a/pkg/httpassert/README.md +++ b/pkg/httpassert/README.md @@ -11,15 +11,20 @@ import "github.com/greenbone/opensight-golang-libraries/pkg/httpassert" ## Index - [Constants](<#constants>) +- [func AssertJSONCanonicalEq\(t \*testing.T, expected, actual string\) bool](<#AssertJSONCanonicalEq>) +- [func NormalizeJSON\(t \*testing.T, s string\) string](<#NormalizeJSON>) - [type Extractor](<#Extractor>) - [func ExtractRegexTo\(value string, ptr any\) Extractor](<#ExtractRegexTo>) - [func ExtractTo\(ptr any\) Extractor](<#ExtractTo>) - [type Matcher](<#Matcher>) - [func Contains\(v string\) Matcher](<#Contains>) - [func HasSize\(e int\) Matcher](<#HasSize>) + - [func IsUUID\(\) Matcher](<#IsUUID>) + - [func NotEmpty\(\) Matcher](<#NotEmpty>) - [func Regex\(expr string\) Matcher](<#Regex>) - [type Request](<#Request>) - - [func New\(t \*testing.T, router http.Handler\) Request](<#New>) +- [type RequestStart](<#RequestStart>) + - [func New\(t \*testing.T, router http.Handler\) RequestStart](<#New>) - [type Response](<#Response>) @@ -31,6 +36,24 @@ import "github.com/greenbone/opensight-golang-libraries/pkg/httpassert" const IgnoreJsonValue = "" ``` + +## func [AssertJSONCanonicalEq]() + +```go +func AssertJSONCanonicalEq(t *testing.T, expected, actual string) bool +``` + +AssertJSONCanonicalEq compares two JSON strings by normalizing both first. On mismatch, it prints a readable diff of the normalized forms. + + +## func [NormalizeJSON]() + +```go +func NormalizeJSON(t *testing.T, s string) string +``` + +NormalizeJSON parses JSON and re\-marshals it with stable key ordering and indentation. \- Uses Decoder.UseNumber\(\) to preserve numeric fidelity \(avoid float64 surprises\). + ## type [Extractor]() @@ -64,7 +87,7 @@ request.Expect().JsonPath("$.data.id", httpassert.ExtractTo(&id)) ``` -## type [Matcher]() +## type [Matcher]() @@ -73,39 +96,84 @@ type Matcher func(t *testing.T, actual any) bool ``` -### func [Contains]() +### func [Contains]() ```go func Contains(v string) Matcher ``` -Contains checks if a string contains the value Example: ExpectJsonPath\("$.data.name", httpassert.Contains\("foo"\)\) +Contains checks if a string contains the value Example: JsonPath\("$.data.name", httpassert.Contains\("foo"\)\) -### func [HasSize]() +### func [HasSize]() ```go func HasSize(e int) Matcher ``` -HasSize checks the length of arrays, maps, or strings. Example: ExpectJsonPath\("$.data", httpassert.HasSize\(11\)\) +HasSize checks the length of arrays, maps, or strings. Example: JsonPath\("$.data", httpassert.HasSize\(11\)\) + + +### func [IsUUID]() + +```go +func IsUUID() Matcher +``` + +IsUUID checks if a string is a UUID Example: JsonPath\("$.id", httpassert.IsUUID\(\)\) + + +### func [NotEmpty]() + +```go +func NotEmpty() Matcher +``` + +NotEmpty checks if a string is not empty Example: JsonPath\("$.data.name", httpassert.NotEmpty\(\)\) -### func [Regex]() +### func [Regex]() ```go func Regex(expr string) Matcher ``` -Regex checks if a string matches the given regular expression Example: ExpectJsonPath\("$.data.name", httpassert.Regex\("^foo.\*bar$"\)\) +Regex checks if a string matches the given regular expression Example: JsonPath\("$.data.name", httpassert.Regex\("^foo.\*bar$"\)\) -## type [Request]() +## type [Request]() -nolint:interfacebloat Request interface provides fluent HTTP request building. +nolint:interfacebloat Request provides fluent request configuration ```go type Request interface { + AuthHeader(header string) Request + Headers(headers map[string]string) Request + Header(key, value string) Request + AuthJwt(jwt string) Request + + ContentType(string) Request + + Content(string) Request + ContentFile(string) Request + + JsonContent(string) Request + JsonContentTemplate(body string, values map[string]any) Request + JsonContentObject(any) Request + JsonContentFile(path string) Request + + Expect() Response + ExpectEventually(check func(r Response), timeout time.Duration, interval time.Duration) Response +} +``` + + +## type [RequestStart]() + +nolint:interfacebloat RequestStart provides fluent HTTP \*method \+ path\* selection. Each call returns a fresh Request + +```go +type RequestStart interface { Get(path string) Request Getf(format string, a ...interface{}) Request Post(path string) Request @@ -121,33 +189,17 @@ type Request interface { Perform(verb string, path string) Request Performf(verb string, path string, a ...interface{}) Request - - AuthHeader(header string) Request - Headers(headers map[string]string) Request - Header(key, value string) Request - AuthJwt(jwt string) Request - - ContentType(string) Request - - Content(string) Request - JsonContent(string) Request - JsonContentObject(any) Request - ContentFile(string) Request - JsonContentFile(path string) Request - - Expect() Response - ExpectEventually(check func(r Response), timeout time.Duration, interval time.Duration) Response } ``` -### func [New]() +### func [New]() ```go -func New(t *testing.T, router http.Handler) Request +func New(t *testing.T, router http.Handler) RequestStart ``` -New returns a new Request instance for the given router. +New returns a new RequestStart instance for the given router. All method calls \(Get/Post/...\) return a \*fresh\* Request. ## type [Response]() diff --git a/pkg/httpassert/assert.go b/pkg/httpassert/assert.go new file mode 100644 index 0000000..7c1bbb2 --- /dev/null +++ b/pkg/httpassert/assert.go @@ -0,0 +1,52 @@ +package httpassert + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/pmezard/go-difflib/difflib" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// NormalizeJSON parses JSON and re-marshals it with stable key ordering and indentation. +// - Uses Decoder.UseNumber() to preserve numeric fidelity (avoid float64 surprises). +func NormalizeJSON(t *testing.T, s string) string { + t.Helper() + + dec := json.NewDecoder(bytes.NewReader([]byte(s))) + dec.UseNumber() + + var v any + require.NoError(t, dec.Decode(&v), "invalid JSON") + + b, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err, "failed to marshal normalized JSON") + + return string(b) +} + +// AssertJSONCanonicalEq compares two JSON strings by normalizing both first. +// On mismatch, it prints a readable diff of the normalized forms. +func AssertJSONCanonicalEq(t *testing.T, expected, actual string) bool { + t.Helper() + + expNorm := NormalizeJSON(t, expected) + actNorm := NormalizeJSON(t, actual) + + if expNorm != actNorm { + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(expNorm), + B: difflib.SplitLines(actNorm), + FromFile: "Expected", + ToFile: "Actual", + Context: 3, + } + + text, _ := difflib.GetUnifiedDiffString(diff) + + return assert.Fail(t, "JSON mismatch:\nDiff:\n"+text) + } + return true +} diff --git a/pkg/httpassert/matcher.go b/pkg/httpassert/matcher.go index 65e2bdc..75c87b8 100644 --- a/pkg/httpassert/matcher.go +++ b/pkg/httpassert/matcher.go @@ -9,13 +9,14 @@ import ( "regexp" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" ) type Matcher func(t *testing.T, actual any) bool // HasSize checks the length of arrays, maps, or strings. -// Example: ExpectJsonPath("$.data", httpassert.HasSize(11)) +// Example: JsonPath("$.data", httpassert.HasSize(11)) func HasSize(e int) Matcher { return func(t *testing.T, actual any) bool { var size int @@ -39,7 +40,7 @@ func HasSize(e int) Matcher { } // Contains checks if a string contains the value -// Example: ExpectJsonPath("$.data.name", httpassert.Contains("foo")) +// Example: JsonPath("$.data.name", httpassert.Contains("foo")) func Contains(v string) Matcher { return func(t *testing.T, value any) bool { valid := assert.Contains(t, value, v) @@ -48,7 +49,7 @@ func Contains(v string) Matcher { } // Regex checks if a string matches the given regular expression -// Example: ExpectJsonPath("$.data.name", httpassert.Regex("^foo.*bar$")) +// Example: JsonPath("$.data.name", httpassert.Regex("^foo.*bar$")) func Regex(expr string) Matcher { re := regexp.MustCompile(expr) @@ -58,7 +59,7 @@ func Regex(expr string) Matcher { } // NotEmpty checks if a string is not empty -// Example: ExpectJsonPath("$.data.name", httpassert.NotEmpty()) +// Example: JsonPath("$.data.name", httpassert.NotEmpty()) func NotEmpty() Matcher { return func(t *testing.T, value any) bool { str, ok := value.(string) @@ -69,3 +70,23 @@ func NotEmpty() Matcher { return assert.NotEmpty(t, str) } } + +// IsUUID checks if a string is a UUID +// Example: JsonPath("$.id", httpassert.IsUUID()) +func IsUUID() Matcher { + return func(t *testing.T, value any) bool { + t.Helper() + + str, ok := value.(string) + if !ok { + return assert.Fail(t, "value is not a string") + } + + _, err := uuid.Parse(str) + if err != nil { + return assert.Fail(t, "value is not a valid UUID", "'%s': %v", str, err) + } + + return true + } +} diff --git a/pkg/httpassert/matcher_test.go b/pkg/httpassert/matcher_test.go index 8b5155c..011b627 100644 --- a/pkg/httpassert/matcher_test.go +++ b/pkg/httpassert/matcher_test.go @@ -9,6 +9,7 @@ import ( "net/http" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" ) @@ -56,3 +57,18 @@ func Test_NoEmptyMatcher(t *testing.T) { StatusCode(http.StatusOK). JsonPath("$.data.name", NotEmpty()) } + +func Test_IsUUIDMatcher(t *testing.T) { + router := http.NewServeMux() + router.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprintf(w, `{"id": "%s"}`, uuid.New()) + assert.NoError(t, err) + }) + + request := New(t, router) + + request.Get("/api"). + Expect(). + StatusCode(http.StatusOK). + JsonPath("$.id", IsUUID()) +} diff --git a/pkg/httpassert/request.go b/pkg/httpassert/request.go index 260a71f..93e946f 100644 --- a/pkg/httpassert/request.go +++ b/pkg/httpassert/request.go @@ -11,16 +11,20 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) // nolint:interfacebloat -// Request interface provides fluent HTTP request building. -type Request interface { +// RequestStart provides fluent HTTP *method + path* selection. +// Each call returns a fresh Request +type RequestStart interface { Get(path string) Request Getf(format string, a ...interface{}) Request Post(path string) Request @@ -36,7 +40,11 @@ type Request interface { Perform(verb string, path string) Request Performf(verb string, path string, a ...interface{}) Request +} +// nolint:interfacebloat +// Request provides fluent request configuration +type Request interface { AuthHeader(header string) Request Headers(headers map[string]string) Request Header(key, value string) Request @@ -45,18 +53,25 @@ type Request interface { ContentType(string) Request Content(string) Request + ContentFile(string) Request + JsonContent(string) Request + JsonContentTemplate(body string, values map[string]any) Request JsonContentObject(any) Request - ContentFile(string) Request JsonContentFile(path string) Request Expect() Response ExpectEventually(check func(r Response), timeout time.Duration, interval time.Duration) Response } -type request struct { - t *testing.T +type starter struct { + t *testing.T + router http.Handler +} +// request holds the per-request state (fresh instance per verb selection). +type request struct { + t *testing.T router http.Handler method string @@ -68,46 +83,6 @@ type request struct { response *httptest.ResponseRecorder } -func (m *request) Perform(verb string, path string) Request { - switch verb { - case "Post", http.MethodPost: - return m.Post(path) - case "Put", http.MethodPut: - return m.Put(path) - case "Get", http.MethodGet: - return m.Get(path) - case "Delete", http.MethodDelete: - return m.Delete(path) - case "Options", http.MethodOptions: - return m.Options(path) - case "Patch", http.MethodPatch: - return m.Patch(path) - default: - m.t.Fatalf("unknown verb: %s", verb) - return m - } -} - -func (m *request) Performf(verb string, path string, a ...interface{}) Request { - switch verb { - case "Post", http.MethodPost: - return m.Postf(path, a...) - case "Put", http.MethodPut: - return m.Putf(path, a...) - case "Get", http.MethodGet: - return m.Getf(path, a...) - case "Delete", http.MethodDelete: - return m.Deletef(path, a...) - case "Options", http.MethodOptions: - return m.Optionsf(path, a...) - case "Patch", http.MethodPatch: - return m.Patchf(path, a...) - default: - m.t.Fatalf("unknown verb: %s", verb) - return m - } -} - // responseImpl implements Response. type responseImpl struct { t *testing.T @@ -115,85 +90,107 @@ type responseImpl struct { request *request } -// New returns a new Request instance for the given router. -func New(t *testing.T, router http.Handler) Request { +// New returns a new RequestStart instance for the given router. +// All method calls (Get/Post/...) return a *fresh* Request. +func New(t *testing.T, router http.Handler) RequestStart { + return &starter{t: t, router: router} +} + +func (s *starter) newRequest(method, url string) Request { return &request{ - t: t, - router: router, - headers: map[string]string{}, + t: s.t, + router: s.router, + method: method, + url: url, + headers: map[string]string{}, // fresh map: no tinted headers } } -func (m *request) Post(path string) Request { - m.method = http.MethodPost - m.url = path - return m +func (s *starter) Post(path string) Request { + return s.newRequest(http.MethodPost, path) } -func (m *request) Postf(format string, a ...interface{}) Request { - m.method = http.MethodPost - m.url = fmt.Sprintf(format, a...) - return m +func (s *starter) Postf(format string, a ...interface{}) Request { + return s.newRequest(http.MethodPost, fmt.Sprintf(format, a...)) } -func (m *request) Put(path string) Request { - m.method = http.MethodPut - m.url = path - return m +func (s *starter) Put(path string) Request { + return s.newRequest(http.MethodPut, path) } -func (m *request) Putf(format string, a ...interface{}) Request { - m.method = http.MethodPut - m.url = fmt.Sprintf(format, a...) - return m +func (s *starter) Putf(format string, a ...interface{}) Request { + return s.newRequest(http.MethodPut, fmt.Sprintf(format, a...)) } -func (m *request) Get(path string) Request { - m.method = http.MethodGet - m.url = path - return m +func (s *starter) Get(path string) Request { + return s.newRequest(http.MethodGet, path) } -func (m *request) Getf(format string, a ...interface{}) Request { - m.method = http.MethodGet - m.url = fmt.Sprintf(format, a...) - return m +func (s *starter) Getf(format string, a ...interface{}) Request { + return s.newRequest(http.MethodGet, fmt.Sprintf(format, a...)) } -func (m *request) Options(path string) Request { - m.method = http.MethodOptions - m.url = path - return m +func (s *starter) Options(path string) Request { + return s.newRequest(http.MethodOptions, path) +} +func (s *starter) Optionsf(format string, a ...interface{}) Request { + return s.newRequest(http.MethodOptions, fmt.Sprintf(format, a...)) } -func (m *request) Optionsf(format string, a ...interface{}) Request { - m.method = http.MethodOptions - m.url = fmt.Sprintf(format, a...) - return m +func (s *starter) Delete(path string) Request { + return s.newRequest(http.MethodDelete, path) } -func (m *request) Delete(path string) Request { - m.method = http.MethodDelete - m.url = path - return m +func (s *starter) Deletef(format string, a ...interface{}) Request { + return s.newRequest(http.MethodDelete, fmt.Sprintf(format, a...)) } -func (m *request) Deletef(format string, a ...interface{}) Request { - m.method = http.MethodDelete - m.url = fmt.Sprintf(format, a...) - return m +func (s *starter) Patch(path string) Request { + return s.newRequest(http.MethodPatch, path) } -func (m *request) Patch(path string) Request { - m.method = http.MethodPatch - m.url = path - return m +func (s *starter) Patchf(format string, a ...interface{}) Request { + return s.newRequest(http.MethodPatch, fmt.Sprintf(format, a...)) } -func (m *request) Patchf(format string, a ...interface{}) Request { - m.method = http.MethodPatch - m.url = fmt.Sprintf(format, a...) - return m +func (s *starter) Perform(verb string, path string) Request { + switch verb { + case "Post", http.MethodPost: + return s.Post(path) + case "Put", http.MethodPut: + return s.Put(path) + case "Get", http.MethodGet: + return s.Get(path) + case "Delete", http.MethodDelete: + return s.Delete(path) + case "Options", http.MethodOptions: + return s.Options(path) + case "Patch", http.MethodPatch: + return s.Patch(path) + default: + s.t.Fatalf("unknown verb: %s", verb) + return &request{} // unreachable, but keeps compiler happy + } +} + +func (s *starter) Performf(verb string, format string, a ...interface{}) Request { + switch verb { + case "Post", http.MethodPost: + return s.Postf(format, a...) + case "Put", http.MethodPut: + return s.Putf(format, a...) + case "Get", http.MethodGet: + return s.Getf(format, a...) + case "Delete", http.MethodDelete: + return s.Deletef(format, a...) + case "Options", http.MethodOptions: + return s.Optionsf(format, a...) + case "Patch", http.MethodPatch: + return s.Patchf(format, a...) + default: + s.t.Fatalf("unknown verb: %s", verb) + return &request{} // unreachable, but keeps compiler happy + } } func (m *request) AuthHeader(header string) Request { @@ -226,12 +223,48 @@ func (m *request) Content(body string) Request { return m } +func (m *request) ContentFile(path string) Request { + content, err := os.ReadFile(path) + if err != nil { + assert.Fail(m.t, err.Error()) + } + m.body = string(content) + return m +} + func (m *request) JsonContent(body string) Request { m.ContentType("application/json") m.Content(body) return m } +func (m *request) JsonContentTemplate(body string, values map[string]any) Request { + m.ContentType("application/json") + + jsonBody := body + // apply provided values into the template + for k, v := range values { + // normalize JSONPath-like keys (convert $.a[0].b to a.0.b) + key := strings.TrimPrefix(k, "$.") + key = strings.ReplaceAll(key, "[", ".") + key = strings.ReplaceAll(key, "]", "") + + if !gjson.Get(jsonBody, key).Exists() { + assert.Fail(m.t, "Json key does not exist in template: "+k) + } + + tmp, err := sjson.Set(jsonBody, key, v) + if err != nil { + assert.Fail(m.t, "JsonTemplate set value failed: "+err.Error()) + return m + } + jsonBody = tmp + } + + m.Content(jsonBody) + return m +} + func (m *request) JsonContentObject(obj any) Request { marshal, err := json.Marshal(obj) require.NoError(m.t, err) @@ -240,15 +273,6 @@ func (m *request) JsonContentObject(obj any) Request { return m } -func (m *request) ContentFile(path string) Request { - content, err := os.ReadFile(path) - if err != nil { - assert.Fail(m.t, err.Error()) - } - m.body = string(content) - return m -} - func (m *request) JsonContentFile(path string) Request { m.ContentType("application/json") m.ContentFile(path) diff --git a/pkg/httpassert/request_test.go b/pkg/httpassert/request_test.go index 867672e..5836b52 100644 --- a/pkg/httpassert/request_test.go +++ b/pkg/httpassert/request_test.go @@ -6,6 +6,7 @@ package httpassert import ( "fmt" + "io" "net/http" "testing" "time" @@ -155,6 +156,32 @@ func TestRequest(t *testing.T) { Expect(). StatusCode(http.StatusOK) }) + + t.Run("JSON content template", func(t *testing.T) { + var content []byte + router := http.NewServeMux() + router.HandleFunc("/json", func(w http.ResponseWriter, r *http.Request) { + bodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + content = bodyBytes + w.WriteHeader(http.StatusOK) + }) + + New(t, router). + Post("/json"). + JsonContentTemplate(`{ + "n": { + "foo": "bar", + "asd": "" + } + }`, map[string]any{ + "$.n.asd": "123", + }). + Expect(). + StatusCode(http.StatusOK) + + AssertJSONCanonicalEq(t, `{"n": {"foo": "bar","asd":"123"}}`, string(content)) + }) } func TestExpectEventually(t *testing.T) { diff --git a/pkg/httpassert/response.go b/pkg/httpassert/response.go index 44dee26..29d1489 100644 --- a/pkg/httpassert/response.go +++ b/pkg/httpassert/response.go @@ -12,6 +12,7 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" "github.com/yalp/jsonpath" ) @@ -71,6 +72,8 @@ func (r *responseImpl) StatusCode(expected int) Response { } func (r *responseImpl) JsonPath(path string, expected any) Response { + r.t.Helper() + var tmp any if err := jsoniter.Unmarshal(r.response.Body.Bytes(), &tmp); err != nil { assert.Fail(r.t, err.Error()) @@ -118,7 +121,7 @@ func (r *responseImpl) JsonPathJson(path string, expectedJson string) Response { assert.Fail(r.t, err.Error()) return r } - assert.JSONEq(r.t, expectedJson, string(pathJson)) + AssertJSONCanonicalEq(r.t, expectedJson, string(pathJson)) return r } @@ -128,7 +131,7 @@ func (r *responseImpl) NoContent() Response { } func (r *responseImpl) Json(expectedJson string) Response { - assert.JSONEq(r.t, expectedJson, r.response.Body.String()) + AssertJSONCanonicalEq(r.t, expectedJson, r.response.Body.String()) return r } @@ -142,6 +145,10 @@ func (r *responseImpl) JsonTemplate(expectedJsonTemplate string, values map[stri key = strings.ReplaceAll(key, "[", ".") key = strings.ReplaceAll(key, "]", "") + if !gjson.Get(expectedJson, key).Exists() { + assert.Fail(r.t, "Json key does not exist in template: "+k) + } + tmp, err := sjson.Set(expectedJson, key, v) if err != nil { assert.Fail(r.t, "JsonTemplate set value failed: "+err.Error()) @@ -161,6 +168,10 @@ func (r *responseImpl) JsonTemplate(expectedJsonTemplate string, values map[stri key = strings.ReplaceAll(key, "[", ".") key = strings.ReplaceAll(key, "]", "") + if !gjson.Get(actual, key).Exists() { + assert.Fail(r.t, "Json key does not exist in template: "+k) + } + tmp, err := sjson.Set(actual, key, v) if err != nil { assert.Fail(r.t, "JsonTemplate ignore replacement failed: "+err.Error()) @@ -169,8 +180,7 @@ func (r *responseImpl) JsonTemplate(expectedJsonTemplate string, values map[stri actual = tmp } - // compare the resulting JSONs ignoring order - valid := assert.JSONEq(r.t, expectedJson, actual) + valid := AssertJSONCanonicalEq(r.t, expectedJson, actual) if !valid { r.Log() } @@ -198,7 +208,7 @@ func (r *responseImpl) JsonFile(path string) Response { assert.Fail(r.t, err.Error()) return r } - assert.JSONEq(r.t, string(content), r.response.Body.String()) + AssertJSONCanonicalEq(r.t, string(content), r.response.Body.String()) return r }