Skip to content

Commit a1bacf7

Browse files
committed
feat(acctest): compare slice of map[string]interface{}
1 parent bde239d commit a1bacf7

File tree

3 files changed

+250
-24
lines changed

3 files changed

+250
-24
lines changed

internal/acctest/acctest_test.go

Lines changed: 197 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ var simpleSliceOfStringsRequest = `{
173173
]
174174
}
175175
`
176+
176177
var simpleSliceOfStringsCassette = `{
177178
"strings": [
178179
"3",
@@ -182,6 +183,162 @@ var simpleSliceOfStringsCassette = `{
182183
}
183184
`
184185

186+
var ludacrisBodyRequest = `{
187+
"payload": {
188+
"artists": [
189+
{
190+
"name": "Ludacris",
191+
"age": 45,
192+
"songs": ["Ludacris", "Ludacris", "Ludacris"]
193+
}
194+
}
195+
}
196+
}
197+
`
198+
199+
var jdillaBodyCassette = `{
200+
"payload": {
201+
"artists": [
202+
{
203+
"name": "Jdilla",
204+
"age": 54,
205+
"songs": ["this", "is", "jdilla"]
206+
}
207+
]
208+
}
209+
}
210+
`
211+
212+
var requestInstanceSettings = `{
213+
"settings": [
214+
{
215+
"name": "max_connections",
216+
"value": "200"
217+
},
218+
{
219+
"name": "max_parallel_workers",
220+
"value": "2"
221+
},
222+
{
223+
"name": "effective_cache_size",
224+
"value": "1300"
225+
},
226+
{
227+
"name": "maintenance_work_mem",
228+
"value": "150"
229+
},
230+
{
231+
"name": "max_parallel_workers_per_gather",
232+
"value": "2"
233+
},
234+
{
235+
"name": "work_mem",
236+
"value": "4"
237+
}
238+
]
239+
}
240+
`
241+
242+
var cassetteInstanceSettings = `{
243+
"settings": [
244+
{
245+
"name": "maintenance_work_mem",
246+
"value": "150"
247+
},
248+
{
249+
"name": "effective_cache_size",
250+
"value": "1300"
251+
},
252+
{
253+
"name": "work_mem",
254+
"value": "4"
255+
},
256+
{
257+
"name": "max_parallel_workers",
258+
"value": "2"
259+
},
260+
{
261+
"name": "max_parallel_workers_per_gather",
262+
"value": "2"
263+
},
264+
{
265+
"name": "max_connections",
266+
"value": "200"
267+
}
268+
]
269+
}
270+
`
271+
272+
var objectBodyRequest = `{
273+
"Id": "MyPolicy",
274+
"Statement": [
275+
{
276+
"Action": [
277+
"s3:ListBucket",
278+
"s3:GetObject"
279+
],
280+
"Effect": "Allow",
281+
"Principal": {
282+
"SCW": "*"
283+
},
284+
"Resource": [
285+
"tf-tests-scw-obp-basic-4713290580220176511",
286+
"tf-tests-scw-obp-basic-4713290580220176511/*"
287+
],
288+
"Sid": "GrantToEveryone"
289+
},
290+
{
291+
"Action": [
292+
"s3:ListBucket",
293+
"s3:GetObject"
294+
],
295+
"Effect": "Allow",
296+
"Principal": {
297+
"SCW": "*"
298+
},
299+
"Sid": "GrantToEveryone",
300+
"project_id": "1234567890"
301+
}
302+
],
303+
"Version": "2012-10-17"
304+
}
305+
`
306+
307+
var objectBodyCassette = `{
308+
"Id": "MyPolicy",
309+
"Statement": [
310+
{
311+
"Action": [
312+
"s3:ListBucket",
313+
"s3:GetObject"
314+
],
315+
"Effect": "Allow",
316+
"Principal": {
317+
"SCW": "*"
318+
},
319+
"Sid": "GrantToEveryone",
320+
"project_id": "9876543210"
321+
},
322+
{
323+
"Action": [
324+
"s3:ListBucket",
325+
"s3:GetObject"
326+
],
327+
"Effect": "Allow",
328+
"Principal": {
329+
"SCW": "*"
330+
},
331+
"Sid": "GrantToEveryone",
332+
"Resource": [
333+
"tf-tests-scw-obp-basic-1234567890",
334+
"tf-tests-scw-obp-basic-1234567890/*"
335+
]
336+
}
337+
],
338+
"Version": "2012-10-17"
339+
}
340+
`
341+
185342
// we don't use httptest.NewRequest because it does not set the GetBody func
186343
func newRequest(method, url string, body io.Reader) *http.Request {
187344
req, err := http.NewRequestWithContext(context.Background(), method, url, body)
@@ -197,7 +354,7 @@ var testBodyMatcherCases = []struct {
197354
cassetteBody *cassette.Request
198355
shouldMatch bool
199356
}{
200-
// create bar compare with foo
357+
// bar does not match foo
201358
{
202359
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(barMemberCreationBody)),
203360
cassetteBody: &cassette.Request{
@@ -208,7 +365,7 @@ var testBodyMatcherCases = []struct {
208365
},
209366
shouldMatch: false,
210367
},
211-
// create bar compare with bar
368+
// bar matches bar
212369
{
213370
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(barMemberCreationBody)),
214371
cassetteBody: &cassette.Request{
@@ -231,8 +388,6 @@ var testBodyMatcherCases = []struct {
231388
shouldMatch: true,
232389
},
233390
// patch secret with nested slices of map[string]interface{} in different order
234-
// we cannot user deep equal because the order of the slices is different although the values are the same
235-
// it is not possible to sort them because they are not comparable (map[string]interface{})
236391
{
237392
requestBody: newRequest(http.MethodPatch, "https://api.scaleway.com/secrets/v1/secrets/123", strings.NewReader(secretPatchBodyRequest)),
238393
cassetteBody: &cassette.Request{
@@ -243,7 +398,28 @@ var testBodyMatcherCases = []struct {
243398
},
244399
shouldMatch: true,
245400
},
246-
// compare nested slices of different integers
401+
{
402+
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(requestInstanceSettings)),
403+
cassetteBody: &cassette.Request{
404+
URL: "https://api.scaleway.com/iam/v1alpha1/users",
405+
Method: http.MethodPost,
406+
Body: cassetteInstanceSettings,
407+
ContentLength: int64(len(cassetteInstanceSettings)),
408+
},
409+
shouldMatch: true,
410+
},
411+
// complex slice of maps case
412+
{
413+
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/policies", strings.NewReader(objectBodyRequest)),
414+
cassetteBody: &cassette.Request{
415+
URL: "https://api.scaleway.com/iam/v1alpha1/policies",
416+
Method: http.MethodPost,
417+
Body: objectBodyCassette,
418+
ContentLength: int64(len(objectBodyCassette)),
419+
},
420+
shouldMatch: true,
421+
},
422+
// compare slices of different integers
247423
{
248424
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(integertestBodyRequest)),
249425
cassetteBody: &cassette.Request{
@@ -254,7 +430,7 @@ var testBodyMatcherCases = []struct {
254430
},
255431
shouldMatch: false,
256432
},
257-
// compare nested slices of same integers in different order
433+
// compare slices of same integers in different order
258434
{
259435
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(integerBodyRequestOutOfOrder)),
260436
cassetteBody: &cassette.Request{
@@ -265,7 +441,7 @@ var testBodyMatcherCases = []struct {
265441
},
266442
shouldMatch: true,
267443
},
268-
// compare nested slices of slices of strings
444+
// compare slices of slices of strings in different order
269445
{
270446
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(nestedSliceOfSlicesRequest)),
271447
cassetteBody: &cassette.Request{
@@ -286,7 +462,7 @@ var testBodyMatcherCases = []struct {
286462
},
287463
shouldMatch: true,
288464
},
289-
// compare simple slice of strings
465+
// compare slices of strings in different order
290466
{
291467
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(simpleSliceOfStringsRequest)),
292468
cassetteBody: &cassette.Request{
@@ -297,13 +473,26 @@ var testBodyMatcherCases = []struct {
297473
},
298474
shouldMatch: true,
299475
},
476+
// ludacris does not match jdilla
477+
{
478+
requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(ludacrisBodyRequest)),
479+
cassetteBody: &cassette.Request{
480+
URL: "https://api.scaleway.com/iam/v1alpha1/users",
481+
Method: http.MethodPost,
482+
Body: jdillaBodyCassette,
483+
ContentLength: int64(len(jdillaBodyCassette)),
484+
},
485+
shouldMatch: false,
486+
},
300487
}
301488

302489
func TestCassetteMatcher(t *testing.T) {
303490
for i, test := range testBodyMatcherCases {
304491
shouldMatch := acctest.CassetteMatcher(test.requestBody, *test.cassetteBody)
305492
if shouldMatch != test.shouldMatch {
306493
t.Errorf("test %d: expected %v, got %v", i, test.shouldMatch, shouldMatch)
494+
t.Errorf("requestBody: %s", test.requestBody.Body)
495+
t.Errorf("cassetteBody: %s", test.cassetteBody.Body)
307496
}
308497
}
309498
}

internal/acctest/compare.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import (
77
"strings"
88
)
99

10-
// compareJSONFields compare two given json fields
11-
// it will recurse on map[string]interface{} and []interface{}
10+
// compareJSONFields is the entry point for comparing two interface values
11+
// handle string with special cases, map[string]interface{} and []interface{} or any other primitive type
1212
func compareJSONFields(requestValue, cassetteValue interface{}) bool {
1313
switch requestValue := requestValue.(type) {
1414
case string:
@@ -152,7 +152,7 @@ func compareStringSlices(request, cassette []string) bool {
152152

153153
// compareJSONSlices compares two slices of interface{}
154154
// if the slices are comparable (string or float64), it will sort them and compare them
155-
// it returns true in case of slice of map[string]interface{} because it is impossible to sort
155+
// in case of slice of map[string]interface{}, it will attempt to normalize them or recurse on compareJSONFields
156156
func compareSlices(request, cassette []interface{}) bool {
157157
if len(request) != len(cassette) {
158158
return false
@@ -168,6 +168,7 @@ func compareSlices(request, cassette []interface{}) bool {
168168
for i, v := range request {
169169
requestStrings[i] = v.(string)
170170
}
171+
171172
cassetteStrings := make([]string, len(cassette))
172173
for i, v := range cassette {
173174
cassetteStrings[i] = v.(string)
@@ -190,7 +191,43 @@ func compareSlices(request, cassette []interface{}) bool {
190191

191192
return true
192193
case map[string]interface{}:
193-
return true
194+
// compare list of maps[string]interface{} without order and without ignored keys
195+
matched := 0
196+
197+
reqVisited := make([]bool, len(request))
198+
casVisited := make([]bool, len(cassette))
199+
200+
for i := range request {
201+
if reqVisited[i] {
202+
continue
203+
}
204+
205+
// cleanup ignored keys
206+
for _, key := range BodyMatcherIgnore {
207+
removeKeyRecursive(request[i].(map[string]interface{}), key)
208+
}
209+
210+
for j := range cassette {
211+
if casVisited[j] {
212+
continue
213+
}
214+
215+
// cleanup ignored keys
216+
for _, key := range BodyMatcherIgnore {
217+
removeKeyRecursive(cassette[j].(map[string]interface{}), key)
218+
}
219+
220+
if compareJSONFields(request[i], cassette[j]) {
221+
matched++
222+
reqVisited[i] = true
223+
casVisited[j] = true
224+
225+
break
226+
}
227+
}
228+
}
229+
230+
return matched == len(request)
194231
default:
195232
return reflect.DeepEqual(request, cassette)
196233
}

internal/acctest/vcr.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ var BodyMatcherIgnore = []string{
5050
"mnq_nats_subject",
5151
}
5252

53+
// removeKeyRecursive removes a key from a map and all its nested maps
54+
func removeKeyRecursive(m map[string]interface{}, key string) {
55+
delete(m, key)
56+
57+
for _, v := range m {
58+
if v, ok := v.(map[string]interface{}); ok {
59+
removeKeyRecursive(v, key)
60+
}
61+
}
62+
}
63+
5364
// getTestFilePath returns a valid filename path based on the go test name and suffix. (Take care of non fs friendly char)
5465
func getTestFilePath(t *testing.T, pkgFolder string, suffix string) string {
5566
t.Helper()
@@ -132,8 +143,7 @@ func cassetteBodyMatcher(request *http.Request, cassette cassette.Request) bool
132143
// actualRequest contains JSON but cassette may not contain JSON, this doesn't match in this case
133144
return false
134145
}
135-
136-
// Remove keys that should be ignored during comparison
146+
// remove keys that should be ignored during comparison
137147
for _, key := range BodyMatcherIgnore {
138148
removeKeyRecursive(requestJSON, key)
139149
removeKeyRecursive(cassetteJSON, key)
@@ -142,16 +152,6 @@ func cassetteBodyMatcher(request *http.Request, cassette cassette.Request) bool
142152
return compareJSONBodies(requestJSON, cassetteJSON)
143153
}
144154

145-
func removeKeyRecursive(m map[string]interface{}, key string) {
146-
delete(m, key)
147-
148-
for _, v := range m {
149-
if v, ok := v.(map[string]interface{}); ok {
150-
removeKeyRecursive(v, key)
151-
}
152-
}
153-
}
154-
155155
// CassetteMatcher is a custom matcher that check equivalence of a played request against a recorded one
156156
// It compares method, path and query but will remove unwanted values from query
157157
func CassetteMatcher(request *http.Request, cassette cassette.Request) bool {

0 commit comments

Comments
 (0)