- attach.PublicURL and (*File).URL build Winter File::getPath() URLs; the thumbnailer decodes webp via golang.org/x/image v0.46.0 and checks the image size from the header before decoding - tide requests carry multipart parts (files beside the fixture pinned by sha256) encoded with the fixed MultipartBoundary, so PHP and Go receive byte-identical bodies - tide masks the random partition, disk name and file id of url/thumb_url upload URLs while still diffing prefix, size, mode and extension, and NormalizePublications masks Carbon dates in the published album
293 lines
6.3 KiB
Go
293 lines
6.3 KiB
Go
package tide
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func compareBodies(want, got Response, step Step) []Diff {
|
|
return compareBodiesWith(want, got, step, maskOptions{})
|
|
}
|
|
|
|
func compareBodiesWith(want, got Response, step Step, opts maskOptions) []Diff {
|
|
wantJSON := isJSONContentType(want.Headers)
|
|
gotJSON := isJSONContentType(got.Headers)
|
|
if wantJSON && gotJSON {
|
|
wantRaw, wantDiffs := normalizeJSON([]byte(want.Body), step, opts)
|
|
gotRaw, gotDiffs := normalizeJSON([]byte(got.Body), step, opts)
|
|
diffs := append([]Diff{}, wantDiffs...)
|
|
diffs = append(diffs, gotDiffs...)
|
|
diffs = append(diffs, diffJSON(wantRaw, gotRaw)...)
|
|
return diffs
|
|
}
|
|
return diffBytes([]byte(want.Body), []byte(got.Body))
|
|
}
|
|
|
|
var globalCompareHeaders = []string{
|
|
"Content-Type",
|
|
"X-Total-Count",
|
|
"Access-Control-Allow-Origin",
|
|
"Access-Control-Allow-Credentials",
|
|
"Access-Control-Allow-Headers",
|
|
"Access-Control-Allow-Methods",
|
|
"Access-Control-Expose-Headers",
|
|
}
|
|
|
|
var extraCompareHeaders = []string{
|
|
"Cache-Control",
|
|
"Pragma",
|
|
"WWW-Authenticate",
|
|
"Content-Disposition",
|
|
"Link",
|
|
"Location",
|
|
}
|
|
|
|
var neverCompareHeaders = []string{
|
|
"Date",
|
|
"Server",
|
|
"X-Request-Id",
|
|
"X-Request-ID",
|
|
"X-Correlation-Id",
|
|
}
|
|
|
|
func compareHeaders(want, got, extra map[string]string) []Diff {
|
|
allow := map[string]bool{}
|
|
for _, n := range globalCompareHeaders {
|
|
allow[http.CanonicalHeaderKey(n)] = true
|
|
}
|
|
for _, n := range extraCompareHeaders {
|
|
allow[http.CanonicalHeaderKey(n)] = true
|
|
}
|
|
for n := range extra {
|
|
allow[http.CanonicalHeaderKey(n)] = true
|
|
}
|
|
for _, n := range neverCompareHeaders {
|
|
delete(allow, http.CanonicalHeaderKey(n))
|
|
}
|
|
var diffs []Diff
|
|
for k, wv := range want {
|
|
ck := http.CanonicalHeaderKey(k)
|
|
if !allow[ck] {
|
|
continue
|
|
}
|
|
gv := headerValue(got, k)
|
|
if gv == wv {
|
|
continue
|
|
}
|
|
if gv == "" {
|
|
gv = "<missing>"
|
|
}
|
|
diffs = append(diffs, Diff{Path: "header." + ck, Expected: wv, Actual: gv})
|
|
}
|
|
return diffs
|
|
}
|
|
|
|
func isJSONContentType(headers map[string]string) bool {
|
|
ct := headerValue(headers, "Content-Type")
|
|
if ct == "" {
|
|
return false
|
|
}
|
|
media, _, err := mime.ParseMediaType(ct)
|
|
if err != nil {
|
|
return strings.Contains(strings.ToLower(ct), "json")
|
|
}
|
|
return media == "application/json" || strings.HasSuffix(media, "+json")
|
|
}
|
|
|
|
func headerValue(headers map[string]string, name string) string {
|
|
if v, ok := headers[name]; ok {
|
|
return v
|
|
}
|
|
for k, v := range headers {
|
|
if strings.EqualFold(k, name) {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func diffJSON(want, got []byte) []Diff {
|
|
wantVal, err := decodeJSON(want)
|
|
if err != nil {
|
|
return []Diff{{Path: "$", Expected: "valid JSON", Actual: err.Error()}}
|
|
}
|
|
gotVal, err := decodeJSON(got)
|
|
if err != nil {
|
|
return []Diff{{Path: "$", Expected: formatValue(wantVal), Actual: err.Error()}}
|
|
}
|
|
var diffs []Diff
|
|
compareValue("$", wantVal, gotVal, &diffs)
|
|
return diffs
|
|
}
|
|
|
|
func decodeJSON(raw []byte) (any, error) {
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
var v any
|
|
if err := dec.Decode(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
if dec.More() {
|
|
return nil, fmt.Errorf("trailing JSON after first value")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func compareValue(path string, want, got any, diffs *[]Diff) {
|
|
switch w := want.(type) {
|
|
case map[string]any:
|
|
g, ok := got.(map[string]any)
|
|
if !ok {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
|
return
|
|
}
|
|
for k, wv := range w {
|
|
gv, exists := g[k]
|
|
child := pathJoin(path, k)
|
|
if !exists {
|
|
*diffs = append(*diffs, Diff{Path: child, Expected: formatValue(wv), Actual: "<missing>"})
|
|
continue
|
|
}
|
|
compareValue(child, wv, gv, diffs)
|
|
}
|
|
for k, gv := range g {
|
|
if _, exists := w[k]; !exists {
|
|
*diffs = append(*diffs, Diff{Path: pathJoin(path, k), Expected: "<missing>", Actual: formatValue(gv)})
|
|
}
|
|
}
|
|
case []any:
|
|
g, ok := got.([]any)
|
|
if !ok {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
|
return
|
|
}
|
|
if len(w) != len(g) {
|
|
*diffs = append(*diffs, Diff{
|
|
Path: path,
|
|
Expected: fmt.Sprintf("array[%d]", len(w)),
|
|
Actual: fmt.Sprintf("array[%d]", len(g)),
|
|
})
|
|
}
|
|
n := min(len(w), len(g))
|
|
for i := 0; i < n; i++ {
|
|
compareValue(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], diffs)
|
|
}
|
|
default:
|
|
if !scalarEqual(want, got) {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
|
}
|
|
}
|
|
}
|
|
|
|
func scalarEqual(want, got any) bool {
|
|
if want == nil || got == nil {
|
|
return want == nil && got == nil
|
|
}
|
|
switch w := want.(type) {
|
|
case json.Number:
|
|
g, ok := got.(json.Number)
|
|
return ok && w == g
|
|
case string:
|
|
g, ok := got.(string)
|
|
return ok && w == g
|
|
case bool:
|
|
g, ok := got.(bool)
|
|
return ok && w == g
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func formatValue(v any) string {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return "null"
|
|
case json.Number:
|
|
return "number " + string(t)
|
|
case string:
|
|
return strconv.Quote(t)
|
|
case bool:
|
|
return fmt.Sprintf("%v", t)
|
|
case map[string]any:
|
|
return "object"
|
|
case []any:
|
|
return fmt.Sprintf("array[%d]", len(t))
|
|
default:
|
|
return fmt.Sprintf("%v", t)
|
|
}
|
|
}
|
|
|
|
func pathJoin(parent, key string) string {
|
|
if parent == "$" {
|
|
return "$." + key
|
|
}
|
|
return parent + "." + key
|
|
}
|
|
|
|
func diffBytes(want, got []byte) []Diff {
|
|
n := min(len(want), len(got))
|
|
off := n
|
|
for i := 0; i < n; i++ {
|
|
if want[i] != got[i] {
|
|
off = i
|
|
break
|
|
}
|
|
}
|
|
if off == n && len(want) == len(got) {
|
|
return nil
|
|
}
|
|
return []Diff{{
|
|
Path: fmt.Sprintf("body[%d]", off),
|
|
Expected: printableWindow(want, off),
|
|
Actual: printableWindow(got, off),
|
|
Offset: off,
|
|
Byte: true,
|
|
}}
|
|
}
|
|
|
|
func printableWindow(b []byte, off int) string {
|
|
if len(b) == 0 {
|
|
return `""`
|
|
}
|
|
start := off - 8
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := off + 8
|
|
if end > len(b) {
|
|
end = len(b)
|
|
}
|
|
return quotePrintable(b[start:end])
|
|
}
|
|
|
|
func quotePrintable(b []byte) string {
|
|
var buf strings.Builder
|
|
buf.WriteByte('"')
|
|
for i := 0; i < len(b); {
|
|
r, size := utf8.DecodeRune(b[i:])
|
|
if r == utf8.RuneError && size == 1 {
|
|
fmt.Fprintf(&buf, "\\x%02x", b[i])
|
|
i++
|
|
continue
|
|
}
|
|
if r == '\\' || r == '"' {
|
|
buf.WriteByte('\\')
|
|
buf.WriteRune(r)
|
|
} else if unicode.IsPrint(r) {
|
|
buf.WriteRune(r)
|
|
} else {
|
|
fmt.Fprintf(&buf, "\\u%04x", r)
|
|
}
|
|
i += size
|
|
}
|
|
buf.WriteByte('"')
|
|
return buf.String()
|
|
}
|