kindrid/gotest

View on GitHub
should/json.go

Summary

Maintainability
B
4 hrs
Test Coverage
package should

import (
    "fmt"
    "reflect"
    "regexp"

    "github.com/Jeffail/gabs"
)

/* About the JSON parser: https://github.com/tidwall/gjson and
/* https://github.com/tidwall/gjson both fit most of our needs. Gjson is faster
/* most of the time, but uses unsafe and doesn't give distinct parse errors. */

// ParseJSON accepts JSON in various formats (including its output format) and returns traversable output.
func ParseJSON(actual interface{}) (StructureExplorer, error) {
    var result *GabsExplorer
    // gabs := &gabs.Container{}
    gabs, err := parseJSON(actual)
    if err != nil {
        return nil, err
    }
    result = (*GabsExplorer)(gabs)
    return result, nil
}

func parseJSON(actual interface{}) (*gabs.Container, error) {
    switch v := actual.(type) {
    case string:
        container, err := gabs.ParseJSON([]byte(v))
        if err != nil {
            return nil, fmt.Errorf(FormatFailure("Error parsing JSON.", err.Error(), "", ""))
        }
        return container, err
    case *string:
        container, err := gabs.ParseJSON([]byte(*v))
        if err != nil {
            return nil, fmt.Errorf(FormatFailure("Error parsing JSON.", err.Error(), "", ""))
        }
        return container, err
    case []byte:
        return gabs.ParseJSON(v)
    case *gabs.Container:
        return v, nil
    case *GabsExplorer: // until we convert the other tests over to use StructureExplorers
        return (*gabs.Container)(v), nil
    default:
        return nil, fmt.Errorf(
            FormatFailure(
                "JSON parser given a value it can't parse.",
                fmt.Sprintf("Expecting a JSON string or a structure representing one, not a %T.", actual),
                "", "",
            ),
        )
    }
}

// HaveFields passes if the JSON container or string has fields with certain values or types of values:
//
//   HaveFields(json, "id", reflect.String)  // assert that there is a field `id` with a  string value.
//   HaveFields(json, "count", reflect.Float64)  // assert that there is a field `count` with a numeric value.
//   HaveFields(json, "count", 100)  // assert that there is a field `count` with a numeric value equal to 100.
//   HaveFields(json, "default", reflect.Interface)  // assert that there is a field `default` with any type of value.
//
func HaveFields(actual interface{}, expected ...interface{}) (fail string) {
    usage := "HaveFields expects parseable JSON to be compared to fieldPath string, fieldKind reflect.Kind pairs."
    if actual == nil {
        return usage
    }
    json, err := parseJSON(actual)
    if err != nil {
        return err.Error()
    }
    return haveFields(json, true, expected...)
}

// AllowFields passes if fields in the JSON container or string either don't exist or match expected types.
//
//   AllowFields(json, "id", reflect.String)  // assert that there is a field `id` with a  string value.
//   AllowFields(json, "count", reflect.Float64)  // assert that there is a field `count` with an numeric value.
//   AllowFields(json, "default", reflect.Interface)  // assert that there is a field `default` with any type of value.
//
func AllowFields(actual interface{}, expected ...interface{}) (fail string) {
    usage := "AllowFields expects parseable JSON to be compared to fieldPath string, fieldKind reflect.Kind pairs."
    if actual == nil {
        return usage
    }
    json, err := parseJSON(actual)
    if err != nil {
        return err.Error()
    }
    fail = haveFields(json, false, expected...)
    if fail != "" {
        fail = FormatFailure("JSON field(s) do not match expected types.", fail, "", "")
    }
    return
}

// haveFields checks to see if json contains fields and types matching expected.
// if required=false, it tolerates fields not appearing in the object.
// expected is [fieldPath string, fieldKind reflect.Kind, ...]  pairs
func haveFields(json *gabs.Container, required bool, expected ...interface{}) (fail string) {
    for i := 0; i < len(expected); i += 2 {
        // check existence of key
        fieldPath := expected[i].(string)
        container := json.Path(fieldPath)
        if container == nil || container.Data() == nil { // field not found
            if required {
                fail += fmt.Sprintf("Field '%s' is missing.\n%s", fieldPath, json)
            }
            continue
        }

        expectedInterface := expected[i+1]
        expectedKind, ok := expectedInterface.(reflect.Kind)
        if !ok { // We have a value, not a Kind
            return Equal(container.Data(), expectedInterface)
        }
        // check type of value
        if expectedKind == reflect.Interface { // allow any type
            continue
        }
        actualKind := reflect.ValueOf(container.Data()).Kind()
        if actualKind != expectedKind {
            fail += fmt.Sprintf("Expecting a '%s' value of type %s, got %s.\nJSON: %s", fieldPath, expectedKind, actualKind, json)
        }
    }

    return
}

// HaveOnlyFields passes if the JSON container or string has fields with certain types of values:
//
//   HaveOnlyFields(json, "id", reflect.String)  // assert that there may a field `id` with a string value.
//   HaveOnlyFields(json, "count", reflect.Float64)  // assert that there may a field `count` with an numeric value.
//   HaveOnlyFields(json, "default", reflect.Interface)  // assert that there may a field `default` with any type of value.
//
func HaveOnlyFields(actual interface{}, allowed ...interface{}) (fail string) {
    usage := "HaveOnlyFields expects parseable JSON to be compared to an fieldPath string, fieldKind reflect.Kind pairs."
    if actual == nil {
        return usage
    }
    json, err := parseJSON(actual)
    if err != nil {
        return err.Error()
    }

    fail = haveOnlyKeys(json, allowed...)
    if fail != "" {
        fail = FormatFailure("JSON has unexpected fields.", fail, "", "")
        return
    }
    fail = haveFields(json, false, allowed...)
    if fail != "" {
        fail = FormatFailure("JSON fields do not match expected type.", fail, "", "")
    }
    return
}

func haveOnlyKeys(json *gabs.Container, allowed ...interface{}) (fail string) {
    children, err := json.ChildrenMap()
    if err != nil {
        return err.Error()
    }
    for key, child := range children {
        found := false
        for _, v := range allowed {
            name, ok := v.(string)
            if !ok { // onlhy pay attention to strings
                continue
            }
            if key == name {
                found = true
                break
            }
        }
        if !found {
            fail += fmt.Sprintf("fields['%s'] (%s) is not allowed. ", key, child)
        }
    }
    return
}

// BeJSON asserts that the first argument can be parsed as JSON.
func BeJSON(actual interface{}, expected ...interface{}) (fail string) {
    usage := "BeJson expects a single string argument and passes if that argument parses as JSON."
    if actual == nil {
        return usage
    }
    _, err := parseJSON(actual)
    if err != nil {
        return err.Error()
    }
    return ""
}

// HaveOnlyCamelcaseKeys passes if all the attributes within a JSON container or
// string contain only upper and lower case ASCII letters.
//
func HaveOnlyCamelcaseKeys(actual interface{}, ignored ...interface{}) (fail string) {
    usage := "HaveOnlyCamelcaseKeys expects parseable JSON in actual. It's keys will recursively checked to make sure they have no snake_case keys. Any right-side arguments should be snake_case key names to be ignored."
    if actual == nil {
        return usage
    }
    json, err := parseJSON(actual)
    if err != nil {
        return err.Error()
    }

    ignoreMap := make(map[string]bool)
    for _, igI := range ignored {
        igS, ok := igI.(string)
        if !ok {
            return fmt.Sprintf("%s. One of the ignored values (%#v) is a %T, not a string.\n%#v", usage, igI, igI, ignored)
        }
        ignoreMap[igS] = true
    }

    fail = checkCamelcaseKeys(json, ignoreMap)
    if fail != "" {
        fail = FormatFailure("JSON field names is not camelCase.", fail, "", "")
    }
    return
}

var camelCaseRegexp = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)

func checkCamelcaseKeys(j *gabs.Container, ignores map[string]bool) (fail string) {
    // if j is an Object with keys, check each of keys and children
    children, notObjectErr := j.ChildrenMap()
    if notObjectErr == nil {
        for k, v := range children {
            if ignores[k] {
                return
            }
            if camelCaseRegexp.MatchString(k) {
                fail = checkCamelcaseKeys(v, ignores)
            } else {
                fail = fmt.Sprintf("Expecting only camelCase keys: found '%s'.\nWithin: %s ",
                    k, j)
            }
            if fail != "" {
                return // fail fast to limit recursion
            }
        }
        return
    }

    // If j is an array, check each of it's elements
    // j.Children() will succeed for objects but lose the keys, so order is
    // it's important to check this AFTER the j.ChildMap() check
    elements, notArrayErr := j.Children()
    if notArrayErr == nil {
        for _, element := range elements {
            fail = checkCamelcaseKeys(element, ignores)
            if len(fail) > 0 {
                return // fail fast to limit recursion
            }
        }
        return
    }

    // otherwise this is an atomic object. No check necessary.
    return ""
}

func argsForBesortedByField(
    actual interface{}, args ...interface{}) (
    parsedActual StructureExplorer,
    elementPath, fieldName string, isDescending bool,
    fail string,
) {
    var (
        err error
        ok  bool
    )
    usage := "BeSortedByField expects parseable JSON and at least two right-side args. \nThe JSON is in the left-side arg. It extracts an item arg[0] from the json and checks that it is sorted on field arg[1], ascending by default, descending if arg[2] == true."
    if actual == nil {
        fail = FormatFailure(usage, "", "", "")
        return
    }
    // grab the JSON in actual
    if parsedActual, err = ParseJSON(actual); err != nil {
        fail = FormatFailure("Error Parsing JSON", fmt.Sprintf("%s\n%s", usage, err.Error()), "", "")
        return
    }
    // grab the elementPath and field Name
    if len(args) < 2 {
        fail = FormatFailure(fmt.Sprintf("Expecting at least 2 right-side arguments, got %d", len(args)), usage, "", "")
    }
    if elementPath, ok = args[0].(string); !ok {
        msg := fmt.Sprintf("Expected elementPath to be a string but got %#v instead", args[0])
        fail = FormatFailure(msg, usage, "", "")
        return
    }
    if fieldName, ok = args[1].(string); !ok {
        msg := fmt.Sprintf("Expected fieldName to be a string but got %#v instead", args[1])
        fail = FormatFailure(msg, usage, "", "")
        return
    }
    // grab optional direction param
    if len(args) >= 3 {
        if isDescending, ok = args[2].(bool); !ok {
            msg := fmt.Sprintf("Expected isDecending, if present, to be a bool but got %#v instead", args[2])
            fail = FormatFailure(msg, usage, "", "")
            return
        }
    }
    return
}

// BeSortedByField passes if actual parses to JSON and has an element named
// args[0] in which every element has a field named arg[1] which is sorted,
// ascending by default, if arg[2] is true, the sort is descending.
func BeSortedByField(actual interface{}, args ...interface{}) (fail string) {
    json, path, field, isDescending, fail := argsForBesortedByField(actual, args...)
    if fail != "" {
        return
    }
    _, _, _, _ = json, path, field, isDescending
    data, ok := json.GetPathCheck(path)
    if !(ok && data.IsArray()) {
        fail = FormatFailure(fmt.Sprintf("Actual.%s should be an array, but it's not.", path), "", "", "")
        return
    }
    var (
        n           = data.Len()
        direction   = ">="
        assertion   = BeGreaterThanOrEqualTo
        cur, prev   string
        initialized bool
    )
    if isDescending {
        direction = "<="
        assertion = BeLessThanOrEqualTo
    }

    getFieldFrom := func(i int) string {
        record := data.GetElement(i)
        dataNode, ok := record.GetPathCheck(field)
        if !ok {
            fail = FormatFailure(fmt.Sprintf("Couldn't find field %s in item %d of sorted array", field, i), record.String(), "", "")
        }
        data, ok := dataNode.Data().(string)
        if !ok {
            short := fmt.Sprintf("Expected items[%d].%s to be a string, but it was a %T: %#v", i, field, dataNode.Data(), dataNode.Data())
            long := fmt.Sprintf("items[%d] = %s\nraw(items[%d].%s)=%s", i, record, i, field, dataNode)
            fail = FormatFailure(short, long, "", "")
        }
        return data
    }

    for i := 0; i < n; i++ {

        cur = getFieldFrom(i)
        if fail != "" {
            return
        }
        if !initialized {
            initialized = true
            prev = cur
            continue
        }
        fail = assertion(cur, prev)
        if fail != "" {
            msg := fmt.Sprintf("Sorted array a[%d].%s=%s is not %s item a[%d],%s=%s", i, field, cur, direction, i-1, field, prev)
            long := fmt.Sprintf("a[%d] = %s\nWHICH SHOULD HAVE .%s %s...\na[%d] = %s", i, data.GetElement(i), field, direction, i-1, data.GetElement(i-1))
            fail = FormatFailure(msg, long, "", "")
        }
        prev = cur
    }

    return
}

func argsForCountTests(
    actual interface{}, args ...interface{}) (
    parsedActual StructureExplorer,
    elementPath string,
    count int,
    fail string,
) {
    var (
        err error
        ok  bool
    )

    usage := `CountAtLeast expects parseable JSON and at  two right-side args. The
    JSON is in the left-side arg. It extracts an item arg[0] from the json and
    checks that it is an array field with at least arg[1] items.`

    if actual == nil {
        fail = FormatFailure(usage, "", "", "")
        return
    }
    // grab the JSON in actual
    if parsedActual, err = ParseJSON(actual); err != nil {
        fail = FormatFailure("Error Parsing JSON", fmt.Sprintf("%s\n%s", usage, err.Error()), "", "")
        return
    }
    // grab the elementPath
    if len(args) < 2 {
        fail = FormatFailure(fmt.Sprintf("Expecting  2 right-side arguments, got %d", len(args)), usage, "", "")
    }
    if elementPath, ok = args[0].(string); !ok {
        msg := fmt.Sprintf("Expected elementPath to be a string but got %#v instead", args[0])
        fail = FormatFailure(msg, usage, "", "")
        return
    }
    if count, ok = args[1].(int); !ok {
        msg := fmt.Sprintf("Expected count to be an integer but got %#v instead", args[1])
        fail = FormatFailure(msg, usage, "", "")
        return
    }
    return
}

// CountAtLeast passes if actual parses to a JSON and has an element named
// args[0] which is an array with >= arg[1] items
func CountAtLeast(actual interface{}, args ...interface{}) (fail string) {
    json, path, count, fail := argsForCountTests(actual, args...)
    if fail != "" {
        return
    }
    data, ok := json.GetPathCheck(path)
    if !(ok && data.IsArray()) {
        fail = FormatFailure(fmt.Sprintf("Actual.%s should be an array, but it's not.", path), "", "", "")
        return
    }
    if data.Len() < count {
        fail = FormatFailure(
            fmt.Sprintf("Expected at least %d items in Actual.%s, but found %d.",
                count, path, data.Len()),
            data.String(), "", "",
        )
    }
    return
}