├── .autorc ├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── ci-build.yml │ └── golangci-lint.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README-ru.md ├── README.md ├── checker ├── addons │ └── openapi2_compliance │ │ ├── checker.go │ │ ├── go.mod │ │ └── go.sum ├── checker.go ├── response_body │ └── response_body.go ├── response_db │ ├── response_db.go │ └── response_multi_db.go └── response_header │ ├── response_header.go │ └── response_header_test.go ├── cmd_runner ├── cmd_runner.go └── cmd_runner_windows.go ├── compare ├── compare.go ├── compare_query.go ├── compare_query_test.go └── compare_test.go ├── examples ├── dog-api │ └── successful_case.yaml ├── mock-based-on-request │ ├── cases │ │ └── do.yaml │ ├── func_test.go │ └── main.go ├── mock-body-matching │ ├── cases │ │ └── do.yaml │ ├── func_test.go │ └── main.go ├── mock-field-json-str │ ├── cases │ │ └── proxy.yaml │ ├── func_test.go │ └── main.go ├── mock-xml-matching │ ├── cases │ │ └── do.yaml │ ├── func_test.go │ └── main.go ├── traffic-lights-demo │ ├── func_test.go │ ├── main.go │ └── tests │ │ └── cases │ │ ├── light_set_get.yaml │ │ └── light_set_get_negative.yaml ├── with-cases-example │ ├── cases │ │ └── do.yaml │ ├── func_test.go │ └── main.go ├── with-db-example │ ├── Makefile │ ├── cases │ │ ├── aerospike │ │ │ └── aerospike.yaml │ │ └── postgres │ │ │ ├── database-with-vars.yaml │ │ │ ├── focused-tests.yaml │ │ │ └── skipped-and-broken-tests.yaml │ ├── docker-compose.yaml │ ├── fixtures │ │ └── aerospike.yaml │ ├── requirements.txt │ ├── server.dockerfile │ └── server.py └── xml-body-matching │ ├── cases │ └── do.yaml │ ├── func_test.go │ └── main.go ├── fixtures ├── aerospike │ ├── aerospike.go │ └── aerospike_test.go ├── loader.go ├── multidb │ ├── multidb.go │ └── multidb_test.go ├── mysql │ ├── mysql.go │ └── mysql_test.go ├── postgres │ ├── postgres.go │ └── postgres_test.go ├── redis │ ├── parser │ │ ├── context.go │ │ ├── file.go │ │ ├── fixture.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ └── yaml.go │ └── redis.go └── testdata │ ├── aerospike.yaml │ ├── aerospike_extend.yaml │ ├── redis.yaml │ ├── redis_example.yaml │ ├── redis_extend.yaml │ ├── redis_inherits.yaml │ ├── simple1.yaml │ ├── simple2.yaml │ ├── sql.yaml │ ├── sql_extend.yaml │ ├── sql_refs.yaml │ └── sql_schema.yaml ├── go.mod ├── go.sum ├── gonkey.json ├── main.go ├── mocks ├── definition.go ├── error.go ├── loader.go ├── mocks.go ├── reply_strategy.go ├── request_constraint.go ├── request_constraint_test.go ├── service_mock.go └── template_reply_strategy.go ├── models ├── result.go └── test.go ├── output ├── allure_report │ ├── allure.go │ ├── allure_report.go │ └── beans │ │ ├── attachment.go │ │ ├── step.go │ │ ├── suite.go │ │ └── test.go ├── console_colored │ └── console_colored.go ├── output.go └── testing │ └── testing.go ├── runner ├── console_handler.go ├── console_handler_test.go ├── previous_result_test.go ├── request.go ├── request_multipart_form_data_test.go ├── runner.go ├── runner_multidb.go ├── runner_test.go ├── runner_testing.go ├── runner_upload_file_test.go └── testdata │ ├── dont-follow-redirects │ └── with-redirect.yaml │ ├── multipart │ └── form-data │ │ └── form-data.yaml │ ├── previous-result │ └── previous-result.yaml │ └── upload-files │ ├── file1.txt │ ├── file2.log │ └── upload-files.yaml ├── storage └── aerospike │ └── aerospike.go ├── testloader ├── loader.go └── yaml_file │ ├── parser.go │ ├── parser_test.go │ ├── test.go │ ├── test_definition.go │ ├── test_test.go │ ├── test_variables_test.go │ ├── testdata │ ├── combined-variables.yaml │ ├── test.env │ ├── variables-enviroment.yaml │ ├── variables.yaml │ ├── with-db-checks.yaml │ └── with-fixtures.yaml │ └── yaml_file.go ├── variables ├── response_parser.go ├── variable.go └── variables.go └── xmlparsing ├── parser.go └── parser_test.go /.autorc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "git-tag", 4 | "released" 5 | ], 6 | "owner": "lamoda", 7 | "repo": "gonkey", 8 | "name": "autorelease", 9 | "email": "autorelease@lamoda.noreply" 10 | } 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /_vendor* 2 | /vendor 3 | /README.md 4 | /Dockerfile 5 | /.idea 6 | /.vscode 7 | debug -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: gomod 5 | directory: / 6 | schedule: 7 | interval: daily 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci-build.yml: -------------------------------------------------------------------------------- 1 | name: Building and testing against different environments 2 | 3 | on: 4 | push: 5 | branches: 6 | tags: 7 | pull_request: 8 | 9 | env: 10 | CGO_ENABLED: '0' # https://github.com/golang/go/issues/26988 11 | GO111MODULE: 'on' 12 | 13 | jobs: 14 | 15 | build: 16 | name: Build on ${{ matrix.os }} for golang ${{ matrix.go }} 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest, windows-latest] 21 | go: ['1.23.x', '1.24.x'] 22 | steps: 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ matrix.go }} 28 | id: go 29 | 30 | - name: Check out code into the Go module directory 31 | uses: actions/checkout@v4 32 | 33 | - name: Get dependencies 34 | run: | 35 | go get -v -t -d ./... 36 | 37 | - name: Build 38 | run: go build -v . 39 | 40 | - name: Test 41 | run: | 42 | go test -v ./... 43 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version: "1.24" 21 | cache: false 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v7 24 | with: 25 | version: v2.1.1 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | \vendor/ 2 | \.idea/ 3 | \.vscode/ 4 | \pkg/ 5 | gonkey 6 | _vendor* 7 | Gopkg.lock 8 | tmpfile* 9 | /allure-results 10 | .DS_Store 11 | 12 | # debug 13 | debug 14 | **/debug.test 15 | 16 | # local env-file 17 | .*.env -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | tests: false 4 | linters: 5 | disable: 6 | - staticcheck 7 | enable: 8 | - bodyclose 9 | - goconst 10 | - gocritic 11 | - gocyclo 12 | - goprintffuncname 13 | - gosec 14 | - lll 15 | - misspell 16 | - nakedret 17 | - nlreturn 18 | - nolintlint 19 | - revive 20 | - rowserrcheck 21 | - staticcheck 22 | - unconvert 23 | - unparam 24 | settings: 25 | gocritic: 26 | disabled-checks: 27 | - hugeParam 28 | enabled-tags: 29 | - performance 30 | - style 31 | - experimental 32 | gosec: 33 | excludes: 34 | - G204 35 | - G306 36 | lll: 37 | line-length: 140 38 | revive: 39 | rules: 40 | - name: var-naming 41 | disabled: true 42 | staticcheck: 43 | checks: 44 | - -ST1003 45 | - -ST1016 46 | - -ST1020 47 | - -ST1021 48 | - -ST1022 49 | - all 50 | exclusions: 51 | generated: lax 52 | presets: 53 | - comments 54 | - common-false-positives 55 | - legacy 56 | - std-error-handling 57 | paths: 58 | - .*easyjson\.go$ 59 | - allure 60 | - mocks 61 | - third_party$ 62 | - builtin$ 63 | - examples$ 64 | - examples/.* 65 | formatters: 66 | enable: 67 | - gci 68 | settings: 69 | gci: 70 | sections: 71 | - standard 72 | - default 73 | - prefix(github.com/lamoda/gonkey) 74 | custom-order: false 75 | no-lex-order: false 76 | exclusions: 77 | generated: lax 78 | paths: 79 | - .*easyjson\.go$ 80 | - allure 81 | - mocks 82 | - third_party$ 83 | - builtin$ 84 | - examples$ 85 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 as build 2 | 3 | ENV GOOS linux 4 | ENV GOARCH amd64 5 | ENV CGO_ENABLED 0 6 | 7 | WORKDIR /build 8 | 9 | # We want to populate the module cache based on the go.{mod,sum} files. 10 | COPY go.mod . 11 | COPY go.sum . 12 | 13 | # This is the ‘magic’ step that will download all the dependencies that are specified in 14 | # the go.mod and go.sum file. 15 | # Because of how the layer caching system works in Docker, the go mod download 16 | # command will _only_ be re-run when the go.mod or go.sum file change 17 | RUN go mod download 18 | 19 | COPY . . 20 | RUN make build 21 | 22 | FROM alpine:3.10 23 | LABEL Author="Denis Sheshnev " 24 | 25 | COPY --from=build /build/gonkey /bin/gonkey 26 | ENTRYPOINT ["/bin/gonkey"] 27 | CMD ["-spec=/gonkey/swagger.yaml", "-host=${HOST_ARG}"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2020 Lamoda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export GO111MODULE=on 2 | 3 | REPO=lamoda 4 | NAME=gonkey 5 | VERSION=$(shell git describe --tags 2> /dev/null || git rev-parse --short HEAD) 6 | 7 | DOCKER_TAG ?= latest 8 | 9 | .PHONY: @dockerbuild @push @stub test 10 | 11 | build: @build 12 | 13 | @dockerbuild: 14 | docker build --force-rm --pull -t $(REPO)/$(NAME):$(DOCKER_TAG) . 15 | 16 | @build: 17 | go build -a -o gonkey 18 | 19 | @push: 20 | docker push $(REPO)/$(NAME):$(DOCKER_TAG) 21 | 22 | @stub: 23 | echo "VERSION=$(VERSION)" > .artifact 24 | 25 | test: 26 | go test ./... 27 | 28 | lint: 29 | docker run --rm -v $(PWD):/app -w /app golangci/golangci-lint:v2.1.1 golangci-lint run -v 30 | 31 | fmt: 32 | docker run --rm -v $(PWD):/app -w /app golangci/golangci-lint:v2.1.1 golangci-lint fmt -v -------------------------------------------------------------------------------- /checker/addons/openapi2_compliance/checker.go: -------------------------------------------------------------------------------- 1 | package openapi2_compliance 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/go-openapi/errors" 8 | "github.com/go-openapi/loads" 9 | "github.com/go-openapi/spec" 10 | "github.com/go-openapi/strfmt" 11 | "github.com/go-openapi/validate" 12 | 13 | "github.com/lamoda/gonkey/checker" 14 | "github.com/lamoda/gonkey/models" 15 | ) 16 | 17 | type ResponseSchemaChecker struct { 18 | swagger *spec.Swagger 19 | } 20 | 21 | func NewChecker(specLocation string) checker.CheckerInterface { 22 | document, err := loads.Spec(specLocation) 23 | if err != nil { 24 | return nil 25 | } 26 | document, err = document.Expanded() 27 | if err != nil { 28 | return nil 29 | } 30 | return &ResponseSchemaChecker{ 31 | swagger: document.Spec(), 32 | } 33 | } 34 | 35 | func (c *ResponseSchemaChecker) Check(t models.TestInterface, result *models.Result) ([]error, error) { 36 | // decode actual body 37 | var actual interface{} 38 | if err := json.Unmarshal([]byte(result.ResponseBody), &actual); err != nil { 39 | return nil, err 40 | } 41 | 42 | errs := validateResponseAgainstSwagger( 43 | t.Path(), 44 | t.GetMethod(), 45 | result.ResponseStatusCode, 46 | actual, 47 | c.swagger, 48 | ) 49 | return errs, nil 50 | } 51 | 52 | func validateResponseAgainstSwagger(path, method string, statusCode int, response interface{}, swagger *spec.Swagger) []error { 53 | var errs []error 54 | swaggerResponse := findResponse(swagger, path, method, statusCode) 55 | if swaggerResponse == nil { 56 | return errs 57 | } 58 | if len(swaggerResponse.Headers) > 0 { 59 | err := validateHeaders(response, swaggerResponse.Headers) 60 | if err != nil { 61 | errs = append(errs, err) 62 | } 63 | } 64 | err := validate.AgainstSchema(swaggerResponse.Schema, response, strfmt.Default) 65 | if err != nil { 66 | if compositeError, ok := err.(*errors.CompositeError); ok { 67 | errs = append(errs, compositeError.Errors...) 68 | } else { 69 | errs = append(errs, err) 70 | } 71 | } 72 | return errs 73 | } 74 | 75 | func findResponse(swagger *spec.Swagger, testPath, testMethod string, statusCode int) *spec.Response { 76 | if swagger.SwaggerProps.Paths.Paths == nil { 77 | return nil 78 | } 79 | var operation *spec.Operation 80 | for path, p := range swagger.SwaggerProps.Paths.Paths { 81 | if strings.ToLower(testPath) == strings.ToLower(swagger.BasePath+path) { 82 | if operation = methodToOperation(p, testMethod); operation != nil { 83 | break 84 | } 85 | } 86 | } 87 | if operation == nil { 88 | return nil 89 | } 90 | var swaggerResponse *spec.Response 91 | if resp, ok := operation.Responses.StatusCodeResponses[statusCode]; ok { 92 | swaggerResponse = &resp 93 | } else { 94 | swaggerResponse = operation.Responses.Default 95 | } 96 | return swaggerResponse 97 | } 98 | 99 | func validateHeaders(response interface{}, headers map[string]spec.Header) error { 100 | //for name, header := range headers { 101 | // v := validate.NewHeaderValidator(name, &header, strfmt.Default) 102 | // v.Validate(&spec.Header{}) 103 | //} 104 | return nil 105 | } 106 | 107 | func methodToOperation(p spec.PathItem, method string) *spec.Operation { 108 | method = strings.ToUpper(method) 109 | switch method { 110 | case "GET": 111 | return p.Get 112 | case "PUT": 113 | return p.Put 114 | case "POST": 115 | return p.Post 116 | case "DELETE": 117 | return p.Delete 118 | case "OPTIONS": 119 | return p.Options 120 | case "HEAD": 121 | return p.Head 122 | case "PATCH": 123 | return p.Patch 124 | default: 125 | return nil 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /checker/addons/openapi2_compliance/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lamoda/gonkey/checker/addons/openapi2_compliance 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/go-openapi/errors v0.20.0 7 | github.com/go-openapi/loads v0.20.2 8 | github.com/go-openapi/spec v0.20.3 9 | github.com/go-openapi/strfmt v0.20.0 10 | github.com/go-openapi/validate v0.20.2 11 | github.com/lamoda/gonkey v1.3.1 12 | ) 13 | -------------------------------------------------------------------------------- /checker/checker.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import "github.com/lamoda/gonkey/models" 4 | 5 | type CheckerInterface interface { 6 | Check(models.TestInterface, *models.Result) ([]error, error) 7 | } 8 | -------------------------------------------------------------------------------- /checker/response_body/response_body.go: -------------------------------------------------------------------------------- 1 | package response_body 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/lamoda/gonkey/checker" 10 | "github.com/lamoda/gonkey/compare" 11 | "github.com/lamoda/gonkey/models" 12 | "github.com/lamoda/gonkey/xmlparsing" 13 | ) 14 | 15 | type ResponseBodyChecker struct{} 16 | 17 | func NewChecker() checker.CheckerInterface { 18 | return &ResponseBodyChecker{} 19 | } 20 | 21 | func (c *ResponseBodyChecker) Check(t models.TestInterface, result *models.Result) ([]error, error) { 22 | var errs []error 23 | var foundResponse bool 24 | // test response with the expected response body 25 | if expectedBody, ok := t.GetResponse(result.ResponseStatusCode); ok { 26 | foundResponse = true 27 | // is the response JSON document? 28 | switch { 29 | case strings.Contains(result.ResponseContentType, "json") && expectedBody != "": 30 | checkErrs, err := compareJsonBody(t, expectedBody, result) 31 | if err != nil { 32 | return nil, err 33 | } 34 | errs = append(errs, checkErrs...) 35 | case strings.Contains(result.ResponseContentType, "xml") && expectedBody != "": 36 | checkErrs, err := compareXmlBody(t, expectedBody, result) 37 | if err != nil { 38 | return nil, err 39 | } 40 | errs = append(errs, checkErrs...) 41 | default: 42 | errs = append(errs, compare.Compare(expectedBody, result.ResponseBody, compare.Params{})...) 43 | } 44 | 45 | } 46 | if !foundResponse { 47 | err := fmt.Errorf("server responded with status %d", result.ResponseStatusCode) 48 | errs = append(errs, err) 49 | } 50 | 51 | return errs, nil 52 | } 53 | 54 | func compareJsonBody(t models.TestInterface, expectedBody string, result *models.Result) ([]error, error) { 55 | // decode expected body 56 | var expected interface{} 57 | if err := json.Unmarshal([]byte(expectedBody), &expected); err != nil { 58 | return nil, fmt.Errorf( 59 | "invalid JSON in response for test %s (status %d): %s", 60 | t.GetName(), 61 | result.ResponseStatusCode, 62 | err.Error(), 63 | ) 64 | } 65 | 66 | // decode actual body 67 | var actual interface{} 68 | if err := json.Unmarshal([]byte(result.ResponseBody), &actual); err != nil { 69 | return []error{errors.New("could not parse response")}, nil 70 | } 71 | 72 | params := compare.Params{ 73 | IgnoreValues: !t.NeedsCheckingValues(), 74 | IgnoreArraysOrdering: t.IgnoreArraysOrdering(), 75 | DisallowExtraFields: t.DisallowExtraFields(), 76 | } 77 | 78 | return compare.Compare(expected, actual, params), nil 79 | } 80 | 81 | func compareXmlBody(t models.TestInterface, expectedBody string, result *models.Result) ([]error, error) { 82 | // decode expected body 83 | expected, err := xmlparsing.Parse(expectedBody) 84 | if err != nil { 85 | return nil, fmt.Errorf( 86 | "invalid XML in response for test %s (status %d): %s", 87 | t.GetName(), 88 | result.ResponseStatusCode, 89 | err.Error(), 90 | ) 91 | } 92 | 93 | // decode actual body 94 | actual, err := xmlparsing.Parse(result.ResponseBody) 95 | if err != nil { 96 | return []error{errors.New("could not parse response")}, nil 97 | } 98 | params := compare.Params{ 99 | IgnoreValues: !t.NeedsCheckingValues(), 100 | IgnoreArraysOrdering: t.IgnoreArraysOrdering(), 101 | DisallowExtraFields: t.DisallowExtraFields(), 102 | } 103 | 104 | return compare.Compare(expected, actual, params), nil 105 | } 106 | -------------------------------------------------------------------------------- /checker/response_db/response_db.go: -------------------------------------------------------------------------------- 1 | package response_db 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | "github.com/kylelemons/godebug/pretty" 11 | 12 | "github.com/lamoda/gonkey/checker" 13 | "github.com/lamoda/gonkey/compare" 14 | "github.com/lamoda/gonkey/models" 15 | ) 16 | 17 | type ResponseDbChecker struct { 18 | db *sql.DB 19 | } 20 | 21 | func NewChecker(dbConnect *sql.DB) checker.CheckerInterface { 22 | return &ResponseDbChecker{ 23 | db: dbConnect, 24 | } 25 | } 26 | 27 | func (c *ResponseDbChecker) Check(t models.TestInterface, result *models.Result) ([]error, error) { 28 | var errors []error 29 | errs, err := c.check(t.GetName(), t.IgnoreDbOrdering(), t, result) 30 | if err != nil { 31 | return nil, err 32 | } 33 | errors = append(errors, errs...) 34 | 35 | for _, dbCheck := range t.GetDatabaseChecks() { 36 | errs, err := c.check(t.GetName(), t.IgnoreDbOrdering(), dbCheck, result) 37 | if err != nil { 38 | return nil, err 39 | } 40 | errors = append(errors, errs...) 41 | } 42 | 43 | return errors, nil 44 | } 45 | 46 | func (c *ResponseDbChecker) check( 47 | testName string, 48 | ignoreOrdering bool, 49 | t models.DatabaseCheck, 50 | result *models.Result, 51 | ) ([]error, error) { 52 | var errors []error 53 | 54 | // don't check if there are no data for db test 55 | if t.DbQueryString() == "" && t.DbResponseJson() == nil { 56 | return errors, nil 57 | } 58 | 59 | // check expected db query exist 60 | if t.DbQueryString() == "" { 61 | return nil, fmt.Errorf("DB query not found for test \"%s\"", testName) 62 | } 63 | 64 | // check expected response exist 65 | if t.DbResponseJson() == nil { 66 | return nil, fmt.Errorf("expected DB response not found for test \"%s\"", testName) 67 | } 68 | 69 | // get DB response 70 | actualDbResponse, err := newQuery(t.DbQueryString(), c.db) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | result.DatabaseResult = append( 76 | result.DatabaseResult, 77 | models.DatabaseResult{Query: t.DbQueryString(), Response: actualDbResponse}, 78 | ) 79 | 80 | // compare responses length 81 | if err := compareDbResponseLength(t.DbResponseJson(), actualDbResponse, t.DbQueryString()); err != nil { 82 | errors = append(errors, err) 83 | 84 | return errors, nil 85 | } 86 | // compare responses as json lists 87 | expectedItems, err := toJSONArray(t.DbResponseJson(), "expected", testName) 88 | if err != nil { 89 | return nil, err 90 | } 91 | actualItems, err := toJSONArray(actualDbResponse, "actual", testName) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | errs := compare.Compare(expectedItems, actualItems, compare.Params{ 97 | IgnoreArraysOrdering: ignoreOrdering, 98 | }) 99 | 100 | errors = append(errors, errs...) 101 | 102 | return errors, nil 103 | } 104 | 105 | func toJSONArray(items []string, qual, testName string) ([]interface{}, error) { 106 | itemJSONs := make([]interface{}, 0, len(items)) 107 | for i, row := range items { 108 | var itemJSON interface{} 109 | if err := json.Unmarshal([]byte(row), &itemJSON); err != nil { 110 | return nil, fmt.Errorf( 111 | "invalid JSON in the %s DB response for test %s:\n row #%d:\n %s\n error:\n%s", 112 | qual, 113 | testName, 114 | i, 115 | row, 116 | err.Error(), 117 | ) 118 | } 119 | itemJSONs = append(itemJSONs, itemJSON) 120 | } 121 | 122 | return itemJSONs, nil 123 | } 124 | 125 | func compareDbResponseLength(expected, actual []string, query interface{}) error { 126 | var err error 127 | 128 | if len(expected) != len(actual) { 129 | err = fmt.Errorf( 130 | "quantity of items in database do not match (-expected: %s +actual: %s)\n test query:\n%s\n result diff:\n%s", 131 | color.CyanString("%v", len(expected)), 132 | color.CyanString("%v", len(actual)), 133 | color.CyanString("%v", query), 134 | color.CyanString("%v", pretty.Compare(expected, actual)), 135 | ) 136 | } 137 | 138 | return err 139 | } 140 | 141 | func newQuery(dbQuery string, db *sql.DB) ([]string, error) { 142 | var dbResponse []string 143 | var jsonString string 144 | 145 | if idx := strings.IndexByte(dbQuery, ';'); idx >= 0 { 146 | dbQuery = dbQuery[:idx] 147 | } 148 | 149 | rows, err := db.Query(fmt.Sprintf("SELECT row_to_json(rows) FROM (%s) rows;", dbQuery)) 150 | if err != nil { 151 | return nil, err 152 | } 153 | defer rows.Close() 154 | 155 | for rows.Next() { 156 | err = rows.Scan(&jsonString) 157 | if err != nil { 158 | return nil, err 159 | } 160 | dbResponse = append(dbResponse, jsonString) 161 | } 162 | err = rows.Err() 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | return dbResponse, nil 168 | } 169 | -------------------------------------------------------------------------------- /checker/response_db/response_multi_db.go: -------------------------------------------------------------------------------- 1 | package response_db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/lamoda/gonkey/checker" 8 | "github.com/lamoda/gonkey/models" 9 | ) 10 | 11 | type ResponseMultiDbChecker struct { 12 | checkers map[string]MultiDBCheckerInstance 13 | } 14 | 15 | type MultiDBCheckerInstance interface { 16 | check(testName string, ignoreOrdering bool, t models.DatabaseCheck, result *models.Result) ([]error, error) 17 | } 18 | 19 | func NewMultiDbChecker(dbMap map[string]*sql.DB) checker.CheckerInterface { 20 | checkers := make(map[string]MultiDBCheckerInstance, len(dbMap)) 21 | for dbName, conn := range dbMap { 22 | checkers[dbName] = &ResponseDbChecker{ 23 | db: conn, 24 | } 25 | } 26 | 27 | return &ResponseMultiDbChecker{checkers: checkers} 28 | } 29 | 30 | func (c *ResponseMultiDbChecker) Check(t models.TestInterface, result *models.Result) ([]error, error) { 31 | var errors []error 32 | 33 | for _, dbCheck := range t.GetDatabaseChecks() { 34 | dbChecker, ok := c.checkers[dbCheck.DbNameString()] 35 | if !ok || dbChecker == nil { 36 | return nil, fmt.Errorf("DB name %s not mapped not found for test \"%s\"", dbCheck.DbNameString(), t.GetName()) 37 | } 38 | 39 | errs, err := dbChecker.check(t.GetName(), t.IgnoreDbOrdering(), dbCheck, result) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | errors = append(errors, errs...) 45 | } 46 | 47 | return errors, nil 48 | } 49 | -------------------------------------------------------------------------------- /checker/response_header/response_header.go: -------------------------------------------------------------------------------- 1 | package response_header 2 | 3 | import ( 4 | "fmt" 5 | "net/textproto" 6 | 7 | "github.com/lamoda/gonkey/checker" 8 | "github.com/lamoda/gonkey/compare" 9 | "github.com/lamoda/gonkey/models" 10 | ) 11 | 12 | type ResponseHeaderChecker struct{} 13 | 14 | func NewChecker() checker.CheckerInterface { 15 | return &ResponseHeaderChecker{} 16 | } 17 | 18 | func (c *ResponseHeaderChecker) Check(t models.TestInterface, result *models.Result) ([]error, error) { 19 | // test response headers with the expected headers 20 | expectedHeaders, ok := t.GetResponseHeaders(result.ResponseStatusCode) 21 | if !ok || len(expectedHeaders) == 0 { 22 | return nil, nil 23 | } 24 | 25 | var errs []error 26 | for k, v := range expectedHeaders { 27 | k = textproto.CanonicalMIMEHeaderKey(k) 28 | actualValues, ok := result.ResponseHeaders[k] 29 | if !ok { 30 | errs = append(errs, fmt.Errorf("response does not include expected header %s", k)) 31 | 32 | continue 33 | } 34 | found := false 35 | for _, actualValue := range actualValues { 36 | e := compare.Compare(v, actualValue, compare.Params{}) 37 | if len(e) == 0 { 38 | found = true 39 | } 40 | } 41 | if !found { 42 | errs = append(errs, fmt.Errorf("response header %s value does not match expected %s", k, v)) 43 | } 44 | } 45 | 46 | return errs, nil 47 | } 48 | -------------------------------------------------------------------------------- /checker/response_header/response_header_test.go: -------------------------------------------------------------------------------- 1 | package response_header 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/lamoda/gonkey/models" 11 | "github.com/lamoda/gonkey/testloader/yaml_file" 12 | ) 13 | 14 | func TestCheckShouldMatchSubset(t *testing.T) { 15 | test := &yaml_file.Test{ 16 | ResponseHeaders: map[int]map[string]string{ 17 | 200: { 18 | "content-type": "application/json", 19 | "ACCEPT": "text/html", 20 | }, 21 | }, 22 | } 23 | 24 | result := &models.Result{ 25 | ResponseStatusCode: 200, 26 | ResponseHeaders: map[string][]string{ 27 | "Content-Type": { 28 | "application/json", 29 | }, 30 | "Accept": { 31 | // uts enough for expected value to match only one entry of the actual values slice 32 | "application/json", 33 | "text/html", 34 | }, 35 | }, 36 | } 37 | 38 | checker := NewChecker() 39 | errs, err := checker.Check(test, result) 40 | 41 | assert.NoError(t, err, "Check must not result with an error") 42 | assert.Empty(t, errs, "Check must succeed") 43 | } 44 | 45 | func TestCheckWhenNotMatchedShouldReturnError(t *testing.T) { 46 | test := &yaml_file.Test{ 47 | ResponseHeaders: map[int]map[string]string{ 48 | 200: { 49 | "content-type": "application/json", 50 | "accept": "text/html", 51 | }, 52 | }, 53 | } 54 | 55 | result := &models.Result{ 56 | ResponseStatusCode: 200, 57 | ResponseHeaders: map[string][]string{ 58 | // no header "Content-Type" in response 59 | "Accept": { 60 | "application/json", 61 | }, 62 | }, 63 | } 64 | 65 | checker := NewChecker() 66 | errs, err := checker.Check(test, result) 67 | 68 | sort.Slice(errs, func(i, j int) bool { 69 | return errs[i].Error() < errs[j].Error() 70 | }) 71 | 72 | assert.NoError(t, err, "Check must not result with an error") 73 | assert.Equal( 74 | t, 75 | errs, 76 | []error{ 77 | errors.New("response does not include expected header Content-Type"), 78 | errors.New("response header Accept value does not match expected text/html"), 79 | }, 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /cmd_runner/cmd_runner.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package cmd_runner 5 | 6 | import ( 7 | "bufio" 8 | "fmt" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "strings" 13 | "syscall" 14 | "time" 15 | ) 16 | 17 | func CmdRun(scriptPath string, timeout int) error { 18 | // by default timeout should be 3s 19 | if timeout <= 0 { 20 | timeout = 3 21 | } 22 | cmd := exec.Command(strings.TrimRight(scriptPath, "\n")) 23 | cmd.Env = os.Environ() 24 | 25 | // Set up a process group which will be killed later 26 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 27 | 28 | stdout, err := cmd.StdoutPipe() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if err := cmd.Start(); err != nil { 34 | return err 35 | } 36 | 37 | done := make(chan error, 1) 38 | go func() { 39 | done <- cmd.Wait() 40 | }() 41 | 42 | select { 43 | case <-time.After(time.Duration(timeout) * time.Second): 44 | 45 | // Get process group which we want to kill 46 | pgid, err := syscall.Getpgid(cmd.Process.Pid) 47 | if err != nil { 48 | return err 49 | } 50 | // Send kill to process group 51 | if err := syscall.Kill(-pgid, 15); err != nil { 52 | return err 53 | } 54 | fmt.Printf("Process killed as timeout(%d) reached\n", timeout) 55 | case err := <-done: 56 | if err != nil { 57 | return fmt.Errorf("process finished with error = %v", err) 58 | } 59 | log.Print("Process finished successfully") 60 | } 61 | 62 | // Print log 63 | scanner := bufio.NewScanner(stdout) 64 | for scanner.Scan() { 65 | m := scanner.Text() 66 | fmt.Println(m) 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /cmd_runner/cmd_runner_windows.go: -------------------------------------------------------------------------------- 1 | package cmd_runner 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | func CmdRun(scriptPath string, timeout int) error { 14 | //by default timeout should be 3s 15 | if timeout <= 0 { 16 | timeout = 3 17 | } 18 | cmd := exec.Command(strings.TrimRight(scriptPath, "\n")) 19 | cmd.Env = os.Environ() 20 | 21 | stdout, err := cmd.StdoutPipe() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if err := cmd.Start(); err != nil { 27 | return err 28 | } 29 | 30 | done := make(chan error, 1) 31 | go func() { 32 | done <- cmd.Wait() 33 | }() 34 | 35 | select { 36 | case <-time.After(time.Duration(timeout) * time.Second): 37 | // Kill process 38 | if err := cmd.Process.Kill(); err != nil { 39 | return err 40 | } 41 | fmt.Printf("Process killed as timeout(%d) reached\n", timeout) 42 | case err := <-done: 43 | if err != nil { 44 | return fmt.Errorf("process finished with error = %v", err) 45 | } 46 | log.Print("Process finished successfully") 47 | } 48 | 49 | // Print log 50 | scanner := bufio.NewScanner(stdout) 51 | for scanner.Scan() { 52 | m := scanner.Text() 53 | fmt.Println(m) 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /compare/compare_query.go: -------------------------------------------------------------------------------- 1 | package compare 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | func Query(expected, actual []string) (bool, error) { 9 | if len(expected) != len(actual) { 10 | return false, fmt.Errorf("expected and actual query params have different lengths") 11 | } 12 | 13 | remove := func(array []string, i int) []string { 14 | array[i] = array[len(array)-1] 15 | 16 | return array[:len(array)-1] 17 | } 18 | 19 | expectedCopy := make([]string, len(expected)) 20 | copy(expectedCopy, expected) 21 | actualCopy := make([]string, len(actual)) 22 | copy(actualCopy, actual) 23 | 24 | for len(expectedCopy) != 0 { 25 | found := false 26 | 27 | for i, expectedValue := range expectedCopy { 28 | for j, actualValue := range actualCopy { 29 | if matches := regexExprRx.FindStringSubmatch(expectedValue); matches != nil { 30 | rx, err := regexp.Compile(matches[1]) 31 | if err != nil { 32 | return false, err 33 | } 34 | 35 | found = rx.MatchString(actualValue) 36 | } else { 37 | found = expectedValue == actualValue 38 | } 39 | 40 | if found { 41 | expectedCopy = remove(expectedCopy, i) 42 | actualCopy = remove(actualCopy, j) 43 | 44 | break 45 | } 46 | } 47 | 48 | if found { 49 | break 50 | } 51 | } 52 | 53 | if !found { 54 | return false, nil 55 | } 56 | } 57 | 58 | return true, nil 59 | } 60 | -------------------------------------------------------------------------------- /compare/compare_query_test.go: -------------------------------------------------------------------------------- 1 | package compare 2 | 3 | import "testing" 4 | 5 | func TestCompareQuery(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | expectedQuery []string 9 | actualQuery []string 10 | }{ 11 | { 12 | name: "simple expected and actual", 13 | expectedQuery: []string{"cake"}, 14 | actualQuery: []string{"cake"}, 15 | }, 16 | { 17 | name: "expected and actual with two values", 18 | expectedQuery: []string{"cake", "tea"}, 19 | actualQuery: []string{"cake", "tea"}, 20 | }, 21 | { 22 | name: "expected and actual with two values and different order", 23 | expectedQuery: []string{"cake", "tea"}, 24 | actualQuery: []string{"tea", "cake"}, 25 | }, 26 | { 27 | name: "expected and actual with same values", 28 | expectedQuery: []string{"tea", "cake", "tea"}, 29 | actualQuery: []string{"cake", "tea", "tea"}, 30 | }, 31 | { 32 | name: "expected and actual with regexp", 33 | expectedQuery: []string{"tea", "$matchRegexp(^c\\w+)"}, 34 | actualQuery: []string{"cake", "tea"}, 35 | }, 36 | } 37 | 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | ok, err := Query(tt.expectedQuery, tt.actualQuery) 41 | if err != nil { 42 | t.Error(err) 43 | } 44 | if !ok { 45 | t.Errorf("expected and actual queries do not match") 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/mock-based-on-request/cases/do.yaml: -------------------------------------------------------------------------------- 1 | - name: Test concurrent with query mathing 2 | mocks: 3 | backend: 4 | strategy: basedOnRequest 5 | uris: 6 | - strategy: constant 7 | body: > 8 | { 9 | "value": 1 10 | } 11 | requestConstraints: 12 | - kind: queryMatches 13 | expectedQuery: "key=value1" 14 | - kind: pathMatches 15 | path: /request 16 | - strategy: constant 17 | body: > 18 | { 19 | "value": 22 20 | } 21 | requestConstraints: 22 | - kind: queryMatches 23 | expectedQuery: "key=value2" 24 | - kind: pathMatches 25 | path: /request 26 | - strategy: template 27 | requestConstraints: 28 | - kind: queryMatches 29 | expectedQuery: "value=3" 30 | - kind: pathMatches 31 | path: /request 32 | body: > 33 | { 34 | "value": {{ .request.Query "value" }}, 35 | "value-unused": 10 36 | } 37 | - strategy: template 38 | requestConstraints: 39 | - kind: queryMatches 40 | expectedQuery: "value=4" 41 | - kind: pathMatches 42 | path: /request 43 | body: > 44 | { 45 | "value": {{ .request.Json.data.value }} 46 | } 47 | method: GET 48 | path: /do 49 | response: 50 | 200: '{"total":36}' 51 | -------------------------------------------------------------------------------- /examples/mock-based-on-request/func_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "testing" 7 | 8 | "github.com/lamoda/gonkey/mocks" 9 | "github.com/lamoda/gonkey/runner" 10 | ) 11 | 12 | func TestProxy(t *testing.T) { 13 | m := mocks.NewNop("backend") 14 | if err := m.Start(); err != nil { 15 | t.Fatal(err) 16 | } 17 | defer m.Shutdown() 18 | 19 | os.Setenv("BACKEND_ADDR", m.Service("backend").ServerAddr()) 20 | initServer() 21 | srv := httptest.NewServer(nil) 22 | 23 | runner.RunWithTesting(t, &runner.RunWithTestingParams{ 24 | Server: srv, 25 | TestsDir: "cases", 26 | Mocks: m, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /examples/mock-based-on-request/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | urlpkg "net/url" 11 | "os" 12 | "sync/atomic" 13 | 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | func main() { 18 | initServer() 19 | log.Fatal(http.ListenAndServe(":8080", nil)) 20 | } 21 | 22 | func initServer() { 23 | http.HandleFunc("/do", Do) 24 | } 25 | 26 | func Do(w http.ResponseWriter, r *http.Request) { 27 | params1 := urlpkg.Values{"key": []string{"value1"}}.Encode() 28 | params2 := urlpkg.Values{"key": []string{"value2"}}.Encode() 29 | params3 := urlpkg.Values{"value": []string{"3"}}.Encode() 30 | params4 := urlpkg.Values{"value": []string{"4"}}.Encode() 31 | 32 | doRequest := func(params string, method string, reqBody []byte) (int64, error) { 33 | url := fmt.Sprintf("http://%s/request?%s", os.Getenv("BACKEND_ADDR"), params) 34 | 35 | req, err := http.NewRequest(method, url, bytes.NewReader(reqBody)) 36 | if err != nil { 37 | return 0, fmt.Errorf("failed to build request: %w", err) 38 | } 39 | 40 | res, err := http.DefaultClient.Do(req) 41 | if err != nil { 42 | return 0, err 43 | } 44 | if res.StatusCode != http.StatusOK { 45 | return 0, fmt.Errorf("backend response status code %d", res.StatusCode) 46 | } 47 | body, err := io.ReadAll(res.Body) 48 | _ = res.Body.Close() 49 | if err != nil { 50 | return 0, fmt.Errorf("cannot read response body %w", err) 51 | } 52 | var resp struct { 53 | Value int64 `json:"value"` 54 | } 55 | err = json.Unmarshal(body, &resp) 56 | if err != nil { 57 | return 0, fmt.Errorf("cannot unmarshal response body %w", err) 58 | } 59 | return resp.Value, nil 60 | } 61 | 62 | var total int64 63 | errg := errgroup.Group{} 64 | errg.Go(func() error { 65 | v, err := doRequest(params1, http.MethodGet, nil) 66 | atomic.AddInt64(&total, v) 67 | return err 68 | }) 69 | errg.Go(func() error { 70 | v, err := doRequest(params2, http.MethodGet, nil) 71 | atomic.AddInt64(&total, v) 72 | return err 73 | }) 74 | errg.Go(func() error { 75 | v, err := doRequest(params3, http.MethodGet, nil) 76 | atomic.AddInt64(&total, v) 77 | return err 78 | }) 79 | errg.Go(func() error { 80 | v, err := doRequest(params4, http.MethodPost, []byte(`{"data":{"value": 10}}`)) 81 | atomic.AddInt64(&total, v) 82 | return err 83 | }) 84 | err := errg.Wait() 85 | if err != nil { 86 | w.WriteHeader(http.StatusInternalServerError) 87 | _, _ = io.WriteString(w, err.Error()) 88 | return 89 | } 90 | _, _ = fmt.Fprintf(w, `{"total":%v}`, total) 91 | } 92 | -------------------------------------------------------------------------------- /examples/mock-body-matching/cases/do.yaml: -------------------------------------------------------------------------------- 1 | - name: Test body matching 2 | mocks: 3 | backend: 4 | strategy: uriVary 5 | uris: 6 | /process: 7 | requestConstraints: 8 | - kind: bodyMatchesText 9 | # >- and |- remove the line feed, remove the trailing blank lines. 10 | body: |- 11 | query HeroNameAndFriends { 12 | hero { 13 | name 14 | friends { 15 | name 16 | } 17 | } 18 | } 19 | - kind: bodyMatchesText 20 | regexp: (HeroNameAndFriends) 21 | strategy: constant 22 | body: "OK" 23 | statusCode: 200 24 | method: POST 25 | path: /do 26 | response: 27 | 200: | 28 | { 29 | "data": { 30 | "hero": { 31 | "name": "R2-D2", 32 | "friends": [ 33 | { 34 | "name": "Luke Skywalker" 35 | }, 36 | { 37 | "name": "Han Solo" 38 | }, 39 | { 40 | "name": "Leia Organa" 41 | } 42 | ] 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/mock-body-matching/func_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "testing" 7 | 8 | "github.com/lamoda/gonkey/mocks" 9 | "github.com/lamoda/gonkey/runner" 10 | ) 11 | 12 | func TestProxy(t *testing.T) { 13 | m := mocks.NewNop("backend") 14 | if err := m.Start(); err != nil { 15 | t.Fatal(err) 16 | } 17 | defer m.Shutdown() 18 | 19 | os.Setenv("BACKEND_ADDR", m.Service("backend").ServerAddr()) 20 | initServer() 21 | srv := httptest.NewServer(nil) 22 | 23 | runner.RunWithTesting(t, &runner.RunWithTestingParams{ 24 | Server: srv, 25 | TestsDir: "cases", 26 | Mocks: m, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /examples/mock-body-matching/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | func main() { 12 | initServer() 13 | log.Fatal(http.ListenAndServe(":8080", nil)) 14 | } 15 | 16 | func initServer() { 17 | http.HandleFunc("/do", Do) 18 | } 19 | 20 | func Do(w http.ResponseWriter, r *http.Request) { 21 | if err := BackendPost(); err != nil { 22 | log.Print(err) 23 | w.Write([]byte("{\"status\": \"error\"}")) 24 | return 25 | } 26 | 27 | w.Header().Add("Content-Type", "application/json") 28 | w.Write([]byte("{\n \"data\": {\n \"hero\": {\n \"name\": \"R2-D2\",\n " + 29 | " \"friends\": [\n {\n \"name\": \"Luke Skywalker\"\n },\n " + 30 | "{\n \"name\": \"Han Solo\"\n },\n {\n \"name\": \"Leia Organa\"\n " + 31 | " }\n ]\n }\n }\n}")) 32 | } 33 | 34 | func BackendPost() error { 35 | body := `query HeroNameAndFriends { 36 | hero { 37 | name 38 | friends { 39 | name 40 | } 41 | } 42 | }` 43 | url := fmt.Sprintf("http://%s/process", os.Getenv("BACKEND_ADDR")) 44 | res, err := http.Post(url, "application/json", strings.NewReader(body)) 45 | if err != nil { 46 | return err 47 | } 48 | if res.StatusCode != http.StatusOK { 49 | return fmt.Errorf("backend response status code %d", res.StatusCode) 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /examples/mock-field-json-str/cases/proxy.yaml: -------------------------------------------------------------------------------- 1 | - name: Test passed into mock service params in an awkward way. 2 | mocks: 3 | backend: 4 | strategy: uriVary 5 | uris: 6 | /process: 7 | requestConstraints: 8 | - kind: bodyMatchesJSON 9 | body: | 10 | { 11 | "client_code": "proxy", 12 | "origin_request": { 13 | "body": "{\"some\": {\"request\": \"body\", \"to\": \"be\"}, \"passed\": \"into backend\"}" 14 | } 15 | } 16 | strategy: constant 17 | body: "" 18 | statusCode: 200 19 | method: POST 20 | path: /proxy 21 | request: '{"some": {"request": "body", "to": "be"}, "passed": "into backend"}' 22 | response: 23 | 200: | 24 | { 25 | "status": "ok" 26 | } 27 | 28 | - name: Test passed into mock service params in a handy way. 29 | mocks: 30 | backend: 31 | strategy: uriVary 32 | uris: 33 | /process: 34 | requestConstraints: 35 | - kind: bodyMatchesJSON 36 | body: | 37 | { 38 | "client_code": "proxy" 39 | } 40 | - kind: bodyJSONFieldMatchesJSON 41 | path: origin_request.body 42 | value: | 43 | { 44 | "some": { 45 | "request": "body", 46 | "to": "be" 47 | }, 48 | "passed": "into backend" 49 | } 50 | 51 | strategy: constant 52 | body: "" 53 | statusCode: 200 54 | method: POST 55 | path: /proxy 56 | request: '{"some": {"request": "body", "to": "be"}, "passed": "into backend"}' 57 | response: 58 | 200: | 59 | { 60 | "status": "ok" 61 | } 62 | -------------------------------------------------------------------------------- /examples/mock-field-json-str/func_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "testing" 7 | 8 | "github.com/lamoda/gonkey/mocks" 9 | "github.com/lamoda/gonkey/runner" 10 | ) 11 | 12 | func TestProxy(t *testing.T) { 13 | m := mocks.NewNop("backend") 14 | if err := m.Start(); err != nil { 15 | t.Fatal(err) 16 | } 17 | defer m.Shutdown() 18 | 19 | os.Setenv("BACKEND_ADDR", m.Service("backend").ServerAddr()) 20 | initServer() 21 | srv := httptest.NewServer(nil) 22 | 23 | runner.RunWithTesting(t, &runner.RunWithTestingParams{ 24 | Server: srv, 25 | TestsDir: "cases", 26 | Mocks: m, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /examples/mock-field-json-str/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | ) 12 | 13 | func main() { 14 | initServer() 15 | log.Fatal(http.ListenAndServe(":8080", nil)) 16 | } 17 | 18 | func initServer() { 19 | http.HandleFunc("/proxy", ProxyRequest) 20 | } 21 | 22 | func ProxyRequest(w http.ResponseWriter, r *http.Request) { 23 | body, err := io.ReadAll(r.Body) 24 | if err != nil { 25 | log.Print(err) 26 | w.Write([]byte("{\"status\": \"error\"}")) 27 | return 28 | } 29 | 30 | if err := BackendPost(string(body)); err != nil { 31 | log.Print(err) 32 | w.Write([]byte("{\"status\": \"error\"}")) 33 | return 34 | } 35 | 36 | w.Header().Add("Content-Type", "application/json") 37 | w.Write([]byte("{\"status\": \"ok\"}")) 38 | } 39 | 40 | type BackendParams struct { 41 | ClientCode string `json:"client_code"` 42 | OriginRequest struct { 43 | Body string `json:"body"` 44 | } `json:"origin_request"` 45 | } 46 | 47 | func BackendPost(originBody string) error { 48 | params := BackendParams{ 49 | ClientCode: "proxy", 50 | } 51 | params.OriginRequest.Body = originBody 52 | 53 | jsonParams, err := json.Marshal(params) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | url := fmt.Sprintf("http://%s/process", os.Getenv("BACKEND_ADDR")) 59 | res, err := http.Post(url, "application/json", bytes.NewReader(jsonParams)) 60 | if err != nil { 61 | return err 62 | } 63 | if res.StatusCode != http.StatusOK { 64 | return fmt.Errorf("backend response status code %d", res.StatusCode) 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /examples/mock-xml-matching/cases/do.yaml: -------------------------------------------------------------------------------- 1 | - name: Test XML matching 2 | mocks: 3 | backend: 4 | strategy: uriVary 5 | uris: 6 | /process: 7 | requestConstraints: 8 | - kind: bodyMatchesXML 9 | body: | 10 | 11 | Hogwarts School of Witchcraft and Wizardry 12 | Harry Potter 13 | hpotter@hog.gb 14 | hpotter@gmail.com 15 | 4 Privet Drive 16 | 17 | Hexes 18 | Jinxes 19 | 20 | 21 | 22 | strategy: constant 23 | body: "OK" 24 | statusCode: 200 25 | method: POST 26 | path: /do 27 | response: 28 | 200: | 29 | { 30 | "status": "ok" 31 | } 32 | -------------------------------------------------------------------------------- /examples/mock-xml-matching/func_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "testing" 7 | 8 | "github.com/lamoda/gonkey/mocks" 9 | "github.com/lamoda/gonkey/runner" 10 | ) 11 | 12 | func TestProxy(t *testing.T) { 13 | m := mocks.NewNop("backend") 14 | if err := m.Start(); err != nil { 15 | t.Fatal(err) 16 | } 17 | defer m.Shutdown() 18 | 19 | os.Setenv("BACKEND_ADDR", m.Service("backend").ServerAddr()) 20 | initServer() 21 | srv := httptest.NewServer(nil) 22 | 23 | runner.RunWithTesting(t, &runner.RunWithTestingParams{ 24 | Server: srv, 25 | TestsDir: "cases", 26 | Mocks: m, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /examples/mock-xml-matching/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | func main() { 12 | initServer() 13 | log.Fatal(http.ListenAndServe(":8080", nil)) 14 | } 15 | 16 | func initServer() { 17 | http.HandleFunc("/do", Do) 18 | } 19 | 20 | func Do(w http.ResponseWriter, r *http.Request) { 21 | if err := BackendPost(); err != nil { 22 | log.Print(err) 23 | w.Write([]byte("{\"status\": \"error\"}")) 24 | return 25 | } 26 | 27 | w.Header().Add("Content-Type", "application/json") 28 | w.Write([]byte("{\"status\": \"ok\"}")) 29 | } 30 | 31 | func BackendPost() error { 32 | body := ` 33 | 34 | Harry Potter 35 | Hogwarts School of Witchcraft and Wizardry 36 | hpotter@gmail.com 37 | hpotter@hog.gb 38 | 39 | Jinxes 40 | Hexes 41 | 42 | 4 Privet Drive 43 | 44 | ` 45 | url := fmt.Sprintf("http://%s/process", os.Getenv("BACKEND_ADDR")) 46 | res, err := http.Post(url, "application/json", strings.NewReader(body)) 47 | if err != nil { 48 | return err 49 | } 50 | if res.StatusCode != http.StatusOK { 51 | return fmt.Errorf("backend response status code %d", res.StatusCode) 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /examples/traffic-lights-demo/func_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/lamoda/gonkey/runner" 8 | ) 9 | 10 | func Test_API(t *testing.T) { 11 | initServer() 12 | 13 | srv := httptest.NewServer(nil) 14 | 15 | runner.RunWithTesting(t, &runner.RunWithTestingParams{ 16 | Server: srv, 17 | TestsDir: "tests/cases", 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /examples/traffic-lights-demo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "sync" 10 | ) 11 | 12 | // возможные состояния светофора 13 | const ( 14 | lightRed = "red" 15 | lightYellow = "yellow" 16 | lightGreen = "green" 17 | ) 18 | 19 | // структура для хранения состояния светофора 20 | type trafficLights struct { 21 | CurrentLight string `json:"currentLight"` 22 | mutex sync.RWMutex `json:"-"` 23 | } 24 | 25 | // экземпляр светофора 26 | var lights = trafficLights{ 27 | CurrentLight: lightRed, 28 | } 29 | 30 | func main() { 31 | initServer() 32 | 33 | // запуск сервера (блокирующий) 34 | log.Fatal(http.ListenAndServe(":8080", nil)) 35 | } 36 | 37 | func initServer() { 38 | // метод для получения текущего состояния светофора 39 | http.HandleFunc("/light/get", func(w http.ResponseWriter, r *http.Request) { 40 | lights.mutex.RLock() 41 | defer lights.mutex.RUnlock() 42 | 43 | resp, err := json.Marshal(lights) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | 48 | w.Header().Add("Content-Type", "application/json") 49 | w.Write(resp) 50 | }) 51 | 52 | // метод для установки нового состояния светофора 53 | http.HandleFunc("/light/set", func(w http.ResponseWriter, r *http.Request) { 54 | lights.mutex.Lock() 55 | defer lights.mutex.Unlock() 56 | 57 | request, err := io.ReadAll(r.Body) 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | 62 | var newTrafficLights trafficLights 63 | if err := json.Unmarshal(request, &newTrafficLights); err != nil { 64 | http.Error(w, err.Error(), http.StatusBadRequest) 65 | return 66 | } 67 | 68 | if err := validateRequest(&newTrafficLights); err != nil { 69 | http.Error(w, err.Error(), http.StatusBadRequest) 70 | return 71 | } 72 | 73 | lights.CurrentLight = newTrafficLights.CurrentLight 74 | }) 75 | } 76 | 77 | func validateRequest(lights *trafficLights) error { 78 | if lights.CurrentLight != lightRed && 79 | lights.CurrentLight != lightYellow && 80 | lights.CurrentLight != lightGreen { 81 | return fmt.Errorf("incorrect current light: %s", lights.CurrentLight) 82 | } 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /examples/traffic-lights-demo/tests/cases/light_set_get.yaml: -------------------------------------------------------------------------------- 1 | - name: WHEN set is requested MUST return no response 2 | method: POST 3 | path: /light/set 4 | request: > 5 | { 6 | "currentLight": "green" 7 | } 8 | response: 9 | 200: '' 10 | 11 | - name: WHEN get is requested MUST return green 12 | method: GET 13 | path: /light/get 14 | response: 15 | 200: > 16 | { 17 | "currentLight": "green" 18 | } 19 | -------------------------------------------------------------------------------- /examples/traffic-lights-demo/tests/cases/light_set_get_negative.yaml: -------------------------------------------------------------------------------- 1 | - name: WHEN set is requested MUST return no response 2 | method: POST 3 | path: /light/set 4 | request: > 5 | { 6 | "currentLight": "green" 7 | } 8 | response: 9 | 200: '' 10 | 11 | - name: WHEN incorrect color is passed MUST return error 12 | method: POST 13 | path: /light/set 14 | request: > 15 | { 16 | "currentLight": "blue" 17 | } 18 | response: 19 | 400: > 20 | incorrect current light: blue 21 | 22 | - name: WHEN color is missing MUST return error 23 | method: POST 24 | path: /light/set 25 | request: > 26 | {} 27 | response: 28 | 400: > 29 | incorrect current light: 30 | 31 | - name: WHEN get is requested MUST have color untouched 32 | method: GET 33 | path: /light/get 34 | response: 35 | 200: > 36 | { 37 | "currentLight": "green" 38 | } 39 | 40 | -------------------------------------------------------------------------------- /examples/with-cases-example/cases/do.yaml: -------------------------------------------------------------------------------- 1 | - name: Test /do endpoint 2 | method: POST 3 | path: /do 4 | 5 | variables: 6 | name: name 7 | 8 | request: '{"{{ $name }}": "{{ .name }}"}' 9 | 10 | response: 11 | 200: '{"{{ $num }}":{{ .num }}}' 12 | 13 | cases: 14 | - variables: 15 | num: num 16 | requestArgs: 17 | name: a 18 | responseArgs: 19 | 200: 20 | num: 1 21 | - requestArgs: 22 | name: b 23 | responseArgs: 24 | 200: 25 | num: 2 -------------------------------------------------------------------------------- /examples/with-cases-example/func_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "testing" 7 | 8 | "github.com/lamoda/gonkey/mocks" 9 | "github.com/lamoda/gonkey/runner" 10 | ) 11 | 12 | func TestProxy(t *testing.T) { 13 | m := mocks.NewNop("backend") 14 | if err := m.Start(); err != nil { 15 | t.Fatal(err) 16 | } 17 | defer m.Shutdown() 18 | 19 | os.Setenv("BACKEND_ADDR", m.Service("backend").ServerAddr()) 20 | initServer() 21 | srv := httptest.NewServer(nil) 22 | 23 | runner.RunWithTesting(t, &runner.RunWithTestingParams{ 24 | Server: srv, 25 | TestsDir: "cases", 26 | Mocks: m, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /examples/with-cases-example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | type Request struct { 12 | Name string `json:"name"` 13 | } 14 | 15 | type Response struct { 16 | Num int `json:"num"` 17 | } 18 | 19 | func main() { 20 | initServer() 21 | log.Fatal(http.ListenAndServe(":8080", nil)) 22 | } 23 | 24 | func initServer() { 25 | http.HandleFunc("/do", Do) 26 | } 27 | 28 | func Do(w http.ResponseWriter, r *http.Request) { 29 | var response Response 30 | 31 | if r.Method != "POST" { 32 | response.Num = 0 33 | fmt.Fprint(w, buildResponse(response)) 34 | return 35 | } 36 | 37 | jsonRequest, _ := io.ReadAll(r.Body) 38 | request := buildRequest(jsonRequest) 39 | 40 | if request.Name == "a" { 41 | response.Num = 1 42 | fmt.Fprint(w, buildResponse(response)) 43 | return 44 | } 45 | 46 | response.Num = 2 47 | fmt.Fprint(w, buildResponse(response)) 48 | } 49 | 50 | func buildRequest(jsonRequest []byte) Request { 51 | var request Request 52 | 53 | json.Unmarshal(jsonRequest, &request) 54 | 55 | return request 56 | } 57 | 58 | func buildResponse(response Response) string { 59 | jsonResponse, _ := json.Marshal(response) 60 | 61 | return string(jsonResponse) 62 | } 63 | -------------------------------------------------------------------------------- /examples/with-db-example/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup 2 | setup: teardown 3 | @docker-compose -f docker-compose.yaml up --build --wait -d 4 | @curl http://localhost:5000/info/10 5 | 6 | .PHONY: teardown 7 | teardown: 8 | @docker-compose -f docker-compose.yaml down -v --remove-orphans 9 | 10 | .PHONY: test-postgres 11 | test-postgres: setup 12 | ./gonkey -db_dsn "postgresql://testing_user:testing_password@localhost:5432/testing_db?sslmode=disable" -debug -host http://localhost:5000 -tests ./cases/postgres 13 | make teardown 14 | 15 | .PHONY: test-aerospike 16 | test-aerospike: setup 17 | ./gonkey -debug -fixtures ./fixtures/ -db-type aerospike -aerospike_host "localhost:3000/test" -host http://localhost:5000 -tests ./cases/aerospike 18 | make teardown -------------------------------------------------------------------------------- /examples/with-db-example/cases/aerospike/aerospike.yaml: -------------------------------------------------------------------------------- 1 | - name: Get a number from aerospike 2 | method: GET 3 | path: /aerospike/ 4 | fixtures: 5 | - aerospike.yaml 6 | response: 7 | 200: '{"data": {"bin1": "value1", "bin2": "overwritten"}}' -------------------------------------------------------------------------------- /examples/with-db-example/cases/postgres/database-with-vars.yaml: -------------------------------------------------------------------------------- 1 | - name: Get random number 2 | method: GET 3 | path: /randint/ 4 | response: 5 | 200: '{ "num": {"generated": "$matchRegexp(\\d)" } }' 6 | variables_to_set: 7 | 200: 8 | info_id: num.generated 9 | 10 | - name: Get info with database 11 | method: GET 12 | path: "/info/{{ $info_id }}" 13 | variables_to_set: 14 | 200: 15 | golang_id: query_result.0.0 16 | gonkey_id: query_result.1.0 17 | response: 18 | 200: '{"result_id": "{{ $info_id }}", "query_result": [[ {{ $golang_id }}, "golang"], [2, "gonkey"]]}' 19 | dbQuery: > 20 | SELECT id, name FROM testing WHERE id={{ $golang_id }} 21 | dbResponse: 22 | - '{"id": {{ $golang_id }}, "name": "golang"}' 23 | dbChecks: 24 | - dbQuery: > 25 | SELECT id, name FROM testing WHERE id={{ $gonkey_id }} 26 | dbResponse: 27 | - '{"id": {{ $gonkey_id }}, "name": "gonkey"}' 28 | - dbQuery: SELECT id, name FROM testing WHERE id={{ $gonkey_id }} 29 | dbResponse: 30 | - '{"id": {{ $gonkey_id }}, "name": "$matchRegexp(gonkey)"}' 31 | -------------------------------------------------------------------------------- /examples/with-db-example/cases/postgres/focused-tests.yaml: -------------------------------------------------------------------------------- 1 | - name: Get random number 2 | method: GET 3 | path: /randint/ 4 | response: 5 | 200: '{ "num": {"generated": "$matchRegexp(\\d)" } }' 6 | variables_to_set: 7 | 200: 8 | info_id: num.generated 9 | 10 | - name: Get info with database 11 | status: focus 12 | method: GET 13 | variables: 14 | info_id: 10 15 | path: "/info/{{ $info_id }}" 16 | variables_to_set: 17 | 200: 18 | golang_id: query_result.0.0 19 | response: 20 | 200: '{"result_id": "{{ $info_id }}", "query_result": [[ {{ $golang_id }}, "golang"], [2, "gonkey"]]}' 21 | dbQuery: > 22 | SELECT id, name FROM testing WHERE id={{ $golang_id }} 23 | dbResponse: 24 | - '{"id": {{ $golang_id }}, "name": "golang"}' 25 | -------------------------------------------------------------------------------- /examples/with-db-example/cases/postgres/skipped-and-broken-tests.yaml: -------------------------------------------------------------------------------- 1 | - name: Get random number - will not be run 2 | status: broken # mark test as broken 3 | method: GET 4 | path: /bad-path/ 5 | response: 6 | 200: '{ "num": {"generated": "$matchRegexp(\\d)" } }' 7 | variables_to_set: 8 | 200: 9 | info_id: num.generated 10 | 11 | - name: Get info with database 12 | status: skipped 13 | method: GET 14 | path: "/info/{{ $info_id }}" 15 | variables_to_set: 16 | 200: 17 | golang_id: query_result.0.0 18 | response: 19 | 200: '{"result_id": "{{ $info_id }}", "query_result": [[ {{ $golang_id }}, "golang"], [2, "gonkey"]]}' 20 | dbQuery: > 21 | SELECT id, name FROM testing WHERE id={{ $golang_id }} 22 | dbResponse: 23 | - '{"id": {{ $golang_id }}, "name": "golang"}' 24 | -------------------------------------------------------------------------------- /examples/with-db-example/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres:10.3 6 | command: postgres -c 'max_connections=100' 7 | volumes: 8 | - postgres-db:/var/lib/postgresql/data 9 | environment: 10 | - POSTGRES_HOST=postgres 11 | - POSTGRES_PORT=5432 12 | - POSTGRES_DB=testing_db 13 | - POSTGRES_USER=testing_user 14 | - POSTGRES_PASSWORD=testing_password 15 | ports: 16 | - 5432:5432 17 | healthcheck: 18 | test: "pg_isready -U postgres" 19 | 20 | aerospike: 21 | image: aerospike/aerospike-server:5.6.0.5 22 | ports: 23 | - "3000:3000" 24 | environment: 25 | - NAMESPACE=test 26 | 27 | svc: 28 | build: 29 | context: . 30 | dockerfile: server.dockerfile 31 | command: python /app/server.py 32 | ports: 33 | - 5000:5000 34 | environment: 35 | - APP_POSTGRES_HOST=postgres 36 | - APP_POSTGRES_PORT=5432 37 | - APP_POSTGRES_USER=testing_user 38 | - APP_POSTGRES_PASS=testing_password 39 | - APP_POSTGRES_DB=testing_db 40 | depends_on: 41 | aerospike: 42 | condition: service_started 43 | postgres: 44 | condition: service_healthy 45 | 46 | volumes: 47 | postgres-db: 48 | -------------------------------------------------------------------------------- /examples/with-db-example/fixtures/aerospike.yaml: -------------------------------------------------------------------------------- 1 | templates: 2 | base_tmpl: 3 | bin1: value1 4 | extended_tmpl: 5 | $extend: base_tmpl 6 | bin2: value2 7 | 8 | sets: 9 | set1: 10 | key1: 11 | $extend: base_tmpl 12 | bin1: overwritten 13 | set2: 14 | key1: 15 | $extend: extended_tmpl 16 | bin2: overwritten -------------------------------------------------------------------------------- /examples/with-db-example/requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2-binary==2.9.3 2 | aerospike==7.0.2 -------------------------------------------------------------------------------- /examples/with-db-example/server.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.5 2 | 3 | COPY requirements.txt /app/requirements.txt 4 | RUN pip install -r /app/requirements.txt 5 | 6 | COPY server.py /app/server.py 7 | -------------------------------------------------------------------------------- /examples/with-db-example/server.py: -------------------------------------------------------------------------------- 1 | import http.server 2 | import json 3 | import os 4 | import random 5 | import socketserver 6 | from http import HTTPStatus 7 | 8 | import aerospike 9 | import psycopg2 10 | 11 | 12 | class Handler(http.server.SimpleHTTPRequestHandler): 13 | def get_response(self) -> dict: 14 | if self.path.startswith('/info/'): 15 | response = self.get_info() 16 | elif self.path.startswith('/randint/'): 17 | response = self.get_rand_num() 18 | elif self.path.startswith('/aerospike/'): 19 | response = self.get_from_aerospike() 20 | else: 21 | response = {'non-existing': True} 22 | 23 | return response 24 | 25 | def get_info(self) -> dict: 26 | info_id = self.path.split('/')[-1] 27 | return { 28 | 'result_id': info_id, 29 | 'query_result': postgres.get_sql_result('SELECT id, name FROM testing LIMIT 2'), 30 | } 31 | 32 | def get_rand_num(self) -> dict: 33 | return {'num': {'generated': str(random.randint(0, 100))}} 34 | 35 | def get_from_aerospike(self) -> dict: 36 | return {'data': aerospike.get()} 37 | 38 | def do_GET(self): 39 | # заголовки ответа 40 | self.send_response(HTTPStatus.OK) 41 | self.send_header("Content-type", "application/json") 42 | self.end_headers() 43 | self.wfile.write(json.dumps(self.get_response()).encode()) 44 | 45 | 46 | class PostgresStorage: 47 | def __init__(self): 48 | params = { 49 | "host": os.environ['APP_POSTGRES_HOST'], 50 | "port": os.environ['APP_POSTGRES_PORT'], 51 | "user": os.environ['APP_POSTGRES_USER'], 52 | "password": os.environ['APP_POSTGRES_PASS'], 53 | "database": os.environ['APP_POSTGRES_DB'], 54 | } 55 | self.conn = psycopg2.connect(**params) 56 | psycopg2.extensions.register_type(psycopg2.extensions.UNICODE, self.conn) 57 | self.conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) 58 | self.cursor = self.conn.cursor() 59 | 60 | def apply_migrations(self): 61 | self.cursor.execute( 62 | """ 63 | CREATE TABLE IF NOT EXISTS testing (id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL); 64 | """ 65 | ) 66 | self.conn.commit() 67 | self.cursor.executemany( 68 | "INSERT INTO testing (name) VALUES (%(name)s);", 69 | [{'name': 'golang'}, {'name': 'gonkey'}, {'name': 'testing'}], 70 | ) 71 | self.conn.commit() 72 | 73 | def get_sql_result(self, sql_str): 74 | self.cursor.execute(sql_str) 75 | query_data = list(self.cursor.fetchall()) 76 | self.conn.commit() 77 | return query_data 78 | 79 | 80 | class AerospikeStorage: 81 | def __init__(self): 82 | config = {'hosts': [('aerospike', 3000)]} 83 | self.client = aerospike.client(config).connect() 84 | 85 | def get(self): 86 | # Records are addressable via a tuple of (namespace, set, key) 87 | key = ('test', 'set2', 'key1') 88 | key, metadata, record = self.client.get(key) 89 | return record 90 | 91 | 92 | postgres = PostgresStorage() 93 | postgres.apply_migrations() 94 | 95 | aerospike = AerospikeStorage() 96 | 97 | if __name__ == '__main__': 98 | service = socketserver.TCPServer(('', 5000), Handler) 99 | service.serve_forever() 100 | -------------------------------------------------------------------------------- /examples/xml-body-matching/cases/do.yaml: -------------------------------------------------------------------------------- 1 | - name: Test XML body matching 2 | method: POST 3 | path: /do 4 | response: 5 | 200: >- 6 | 7 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/xml-body-matching/func_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/lamoda/gonkey/runner" 8 | ) 9 | 10 | func TestProxy(t *testing.T) { 11 | 12 | initServer() 13 | srv := httptest.NewServer(nil) 14 | 15 | runner.RunWithTesting(t, &runner.RunWithTestingParams{ 16 | Server: srv, 17 | TestsDir: "cases", 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /examples/xml-body-matching/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func main() { 9 | initServer() 10 | log.Fatal(http.ListenAndServe(":8080", nil)) 11 | } 12 | 13 | func initServer() { 14 | http.HandleFunc("/do", Do) 15 | } 16 | 17 | func Do(w http.ResponseWriter, _ *http.Request) { 18 | 19 | w.Header().Add("Content-Type", "application/xml") 20 | _, err := w.Write([]byte("\r\n " + 21 | "\r\n" + 23 | " \t\r\n \t\t\r\n " + 24 | "\t\r\n ")) 25 | if err != nil { 26 | return 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /fixtures/aerospike/aerospike_test.go: -------------------------------------------------------------------------------- 1 | package aerospike 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestLoaderAerospike_loadYml(t *testing.T) { 11 | type args struct { 12 | data []byte 13 | ctx *loadContext 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | want loadContext 19 | }{ 20 | { 21 | name: "basic", 22 | args: args{ 23 | data: loadTestData(t, "../testdata/aerospike.yaml"), 24 | ctx: &loadContext{ 25 | refsDefinition: make(set), 26 | }, 27 | }, 28 | want: loadContext{ 29 | refsDefinition: set{}, 30 | sets: []loadedSet{ 31 | { 32 | name: "set1", 33 | data: set{ 34 | "key1": { 35 | "bin1": "value1", 36 | "bin2": 1, 37 | }, 38 | "key2": { 39 | "bin1": "value2", 40 | "bin2": 2, 41 | "bin3": 2.569947773654566473, 42 | }, 43 | }, 44 | }, 45 | { 46 | name: "set2", 47 | data: set{ 48 | "key1": { 49 | "bin1": `"`, 50 | "bin4": false, 51 | "bin5": nil, 52 | }, 53 | "key2": { 54 | "bin1": "'", 55 | "bin5": []interface{}{1, "2"}, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, 62 | { 63 | name: "extend", 64 | args: args{ 65 | data: loadTestData(t, "../testdata/aerospike_extend.yaml"), 66 | ctx: &loadContext{ 67 | refsDefinition: make(set), 68 | }, 69 | }, 70 | want: loadContext{ 71 | sets: []loadedSet{ 72 | { 73 | name: "set1", 74 | data: set{ 75 | "key1": { 76 | "$extend": "base_tmpl", 77 | "bin1": "overwritten", 78 | }, 79 | }, 80 | }, 81 | { 82 | name: "set2", 83 | data: set{ 84 | "key1": { 85 | "$extend": "extended_tmpl", 86 | "bin2": "overwritten", 87 | }, 88 | }, 89 | }, 90 | }, 91 | refsDefinition: set{ 92 | "base_tmpl": { 93 | "bin1": "value1", 94 | }, 95 | "extended_tmpl": { 96 | "$extend": "base_tmpl", 97 | "bin1": "value1", 98 | "bin2": "value2", 99 | }, 100 | }, 101 | }, 102 | }, 103 | } 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | f := &LoaderAerospike{} 107 | if err := f.loadYml(tt.args.data, tt.args.ctx); err != nil { 108 | t.Errorf("LoaderAerospike.loadYml() error = %v", err) 109 | } 110 | 111 | require.Equal(t, tt.want, *tt.args.ctx) 112 | }) 113 | } 114 | } 115 | 116 | func loadTestData(t *testing.T, path string) []byte { 117 | aerospikeYaml, err := os.ReadFile(path) 118 | if err != nil { 119 | t.Error("No aerospike.yaml") 120 | } 121 | return aerospikeYaml 122 | } 123 | -------------------------------------------------------------------------------- /fixtures/loader.go: -------------------------------------------------------------------------------- 1 | package fixtures 2 | 3 | import ( 4 | "database/sql" 5 | "strings" 6 | 7 | _ "github.com/lib/pq" 8 | 9 | "github.com/lamoda/gonkey/fixtures/aerospike" 10 | "github.com/lamoda/gonkey/fixtures/mysql" 11 | "github.com/lamoda/gonkey/fixtures/postgres" 12 | "github.com/lamoda/gonkey/models" 13 | aerospikeClient "github.com/lamoda/gonkey/storage/aerospike" 14 | ) 15 | 16 | type DbType int 17 | 18 | const ( 19 | Postgres DbType = iota 20 | Mysql 21 | Aerospike 22 | Redis 23 | CustomLoader // using external loader if gonkey used as a library 24 | ) 25 | 26 | const ( 27 | PostgresParam = "postgres" 28 | MysqlParam = "mysql" 29 | AerospikeParam = "aerospike" 30 | RedisParam = "redis" 31 | ) 32 | 33 | type Config struct { 34 | DB *sql.DB 35 | Aerospike *aerospikeClient.Client 36 | DbType DbType 37 | Location string 38 | Debug bool 39 | FixtureLoader Loader 40 | } 41 | 42 | type Loader interface { 43 | Load(names []string) error 44 | } 45 | 46 | type LoaderMultiDb interface { 47 | Load(fixturesList models.FixturesMultiDb) error 48 | } 49 | 50 | func NewLoader(cfg *Config) Loader { 51 | var loader Loader 52 | 53 | location := strings.TrimRight(cfg.Location, "/") 54 | 55 | switch cfg.DbType { 56 | case Postgres: 57 | loader = postgres.New( 58 | cfg.DB, 59 | location, 60 | cfg.Debug, 61 | ) 62 | case Mysql: 63 | loader = mysql.New( 64 | cfg.DB, 65 | location, 66 | cfg.Debug, 67 | ) 68 | case Aerospike: 69 | loader = aerospike.New( 70 | cfg.Aerospike, 71 | location, 72 | cfg.Debug, 73 | ) 74 | default: 75 | if cfg.FixtureLoader != nil { 76 | return cfg.FixtureLoader 77 | } 78 | panic("unknown db type") 79 | } 80 | 81 | return loader 82 | } 83 | 84 | func FetchDbType(dbType string) DbType { 85 | switch dbType { 86 | case PostgresParam: 87 | return Postgres 88 | case MysqlParam: 89 | return Mysql 90 | case AerospikeParam: 91 | return Aerospike 92 | case RedisParam: 93 | return Redis 94 | default: 95 | panic("unknown db type param") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /fixtures/multidb/multidb.go: -------------------------------------------------------------------------------- 1 | package multidb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lamoda/gonkey/fixtures" 7 | "github.com/lamoda/gonkey/models" 8 | ) 9 | 10 | type LoaderByMap struct { 11 | loaders map[string]fixtures.Loader 12 | } 13 | 14 | func New(loaders map[string]fixtures.Loader) *LoaderByMap { 15 | return &LoaderByMap{ 16 | loaders: loaders, 17 | } 18 | } 19 | 20 | func (l *LoaderByMap) Load(fixturesList models.FixturesMultiDb) error { 21 | for _, fixture := range fixturesList { 22 | loader, ok := l.loaders[fixture.DbName] 23 | if !ok { 24 | return fmt.Errorf("loader %s not exists", fixture.DbName) 25 | } 26 | 27 | if err := loader.Load(fixture.Files); err != nil { 28 | return err 29 | } 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /fixtures/multidb/multidb_test.go: -------------------------------------------------------------------------------- 1 | package multidb 2 | 3 | import ( 4 | "database/sql/driver" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "gopkg.in/DATA-DOG/go-sqlmock.v1" 11 | 12 | "github.com/lamoda/gonkey/fixtures" 13 | "github.com/lamoda/gonkey/fixtures/mysql" 14 | "github.com/lamoda/gonkey/fixtures/postgres" 15 | "github.com/lamoda/gonkey/models" 16 | ) 17 | 18 | const ( 19 | mysqlName = "mysql" 20 | pgName = "pg" 21 | fixturesLocation = "../testdata" 22 | ) 23 | 24 | var idCounter int64 25 | 26 | func TestMultiDBLoader(t *testing.T) { 27 | fixturesList := models.FixturesMultiDb{ 28 | models.Fixture{ 29 | DbName: mysqlName, 30 | Files: []string{ 31 | "simple1.yaml", 32 | }, 33 | }, 34 | models.Fixture{ 35 | DbName: pgName, 36 | Files: []string{ 37 | "simple2.yaml", 38 | }, 39 | }, 40 | } 41 | 42 | mysqlDb, mysqlMock, err := sqlmock.New() 43 | require.NoError(t, err) 44 | defer func() { _ = mysqlDb.Close() }() 45 | 46 | pgDb, pgMock, err := sqlmock.New() 47 | require.NoError(t, err) 48 | defer func() { _ = pgDb.Close() }() 49 | 50 | l := New(map[string]fixtures.Loader{ 51 | mysqlName: mysql.New(mysqlDb, fixturesLocation, true), 52 | pgName: postgres.New(pgDb, fixturesLocation, true), 53 | }) 54 | 55 | setMySQLMocks(t, mysqlMock) 56 | setPgMocks(t, pgMock) 57 | 58 | err = l.Load(fixturesList) 59 | if err != nil { 60 | t.Fatalf("an error '%s' was not expected when loading fixtures", err) 61 | } 62 | 63 | } 64 | 65 | func setMySQLMocks(t *testing.T, mock sqlmock.Sqlmock) { 66 | mock.ExpectBegin() 67 | 68 | mock.ExpectExec("^TRUNCATE TABLE `table1`$"). 69 | WillReturnResult(sqlmock.NewResult(0, 0)) 70 | 71 | expectMySQLInsert(t, mock, 72 | "table1", 73 | []string{"field1", "field2"}, 74 | "\\('value1', 2\\)", 75 | []string{"value1", "2"}, 76 | ) 77 | 78 | mock.ExpectCommit() 79 | } 80 | 81 | func setPgMocks(t *testing.T, mock sqlmock.Sqlmock) { 82 | mock.ExpectBegin() 83 | 84 | mock.ExpectExec("^TRUNCATE TABLE \"public\".\"table2\" CASCADE$"). 85 | WillReturnResult(sqlmock.NewResult(0, 0)) 86 | 87 | q := `^INSERT INTO "public"."table2" AS row \("field3", "field4"\) VALUES ` + 88 | `\('value3', 4\) ` + 89 | `RETURNING row_to_json\(row\)$` 90 | mock.ExpectQuery(q). 91 | WillReturnRows( 92 | sqlmock.NewRows([]string{"json"}). 93 | AddRow("{\"f1\":\"value1\",\"f2\":\"value2\"}"), 94 | ) 95 | 96 | mock.ExpectExec("^DO"). 97 | WillReturnResult(sqlmock.NewResult(0, 0)) 98 | 99 | mock.ExpectCommit() 100 | } 101 | 102 | func expectMySQLInsert( 103 | t *testing.T, 104 | mock sqlmock.Sqlmock, 105 | table string, 106 | fields []string, 107 | valuesToInsert string, 108 | valuesResult []string, 109 | ) { 110 | 111 | t.Helper() 112 | 113 | idCounter++ 114 | 115 | mock.ExpectExec( 116 | fmt.Sprintf("^INSERT INTO `%s` %s VALUES %s$", 117 | table, 118 | fieldsToDbStr(fields), 119 | valuesToInsert, 120 | ), 121 | ). 122 | WillReturnResult(sqlmock.NewResult(idCounter, 1)) 123 | 124 | var valuesRow []driver.Value 125 | for _, v := range valuesResult { 126 | valuesRow = append(valuesRow, driver.Value(v)) 127 | } 128 | 129 | mock.ExpectQuery(fmt.Sprintf("^SELECT \\* FROM `%s` WHERE `id` = \\?$", table)). 130 | WithArgs(idCounter). 131 | WillReturnRows( 132 | sqlmock.NewRows(fields). 133 | AddRow(valuesRow...), 134 | ) 135 | } 136 | 137 | func fieldsToDbStr(values []string) string { 138 | quotedVals := make([]string, len(values)) 139 | 140 | for i, val := range values { 141 | quotedVals[i] = "`" + val + "`" 142 | } 143 | 144 | return "\\(" + strings.Join(quotedVals, ", ") + "\\)" 145 | } 146 | -------------------------------------------------------------------------------- /fixtures/mysql/mysql_test.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "gopkg.in/DATA-DOG/go-sqlmock.v1" 14 | ) 15 | 16 | func TestBuildInsertQuery(t *testing.T) { 17 | 18 | ymlFile, err := os.ReadFile("../testdata/sql.yaml") 19 | require.NoError(t, err) 20 | 21 | expected := []string{ 22 | "INSERT INTO `table` (`field1`, `field2`) VALUES ('value1', 1)", 23 | "INSERT INTO `table` (`field1`, `field2`, `field3`) VALUES ('value2', 2, 2.5699477736545666)", 24 | "INSERT INTO `table` (`field1`, `field4`, `field5`) VALUES ('\"', false, NULL)", 25 | "INSERT INTO `table` (`field1`, `field5`) VALUES ('''', '[1,\"2\"]')", 26 | } 27 | 28 | ctx := loadContext{ 29 | refsDefinition: make(map[string]row), 30 | refsInserted: make(map[string]row), 31 | } 32 | 33 | l := New(&sql.DB{}, "", false) 34 | 35 | require.NoError(t, 36 | l.loadYml(ymlFile, &ctx), 37 | ) 38 | 39 | for i, row := range ctx.tables[0].rows { 40 | query, err := l.buildInsertQuery(&ctx, "table", row) 41 | require.NoError(t, err) 42 | 43 | assert.Equal(t, expected[i], query) 44 | } 45 | } 46 | 47 | func TestLoadTablesShouldResolveRefs(t *testing.T) { 48 | yml, err := os.ReadFile("../testdata/sql_refs.yaml") 49 | require.NoError(t, err) 50 | 51 | db, mock, err := sqlmock.New() 52 | if err != nil { 53 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 54 | } 55 | defer func() { _ = db.Close() }() 56 | 57 | ctx := loadContext{ 58 | refsDefinition: make(map[string]row), 59 | refsInserted: make(map[string]row), 60 | } 61 | 62 | l := New(db, "", true) 63 | 64 | if err = l.loadYml(yml, &ctx); err != nil { 65 | t.Error(err) 66 | t.Fail() 67 | } 68 | 69 | mock.ExpectBegin() 70 | 71 | expectTruncate(mock, "table1") 72 | expectTruncate(mock, "table2") 73 | expectTruncate(mock, "table3") 74 | 75 | // table1 76 | expectInsert(t, mock, 77 | "table1", 78 | []string{"f1", "f2"}, 79 | "\\('value1', 'value2'\\)", 80 | []string{"value1", "value2"}, 81 | ) 82 | 83 | // table2 84 | expectInsert(t, mock, 85 | "table2", 86 | []string{"f1", "f2"}, 87 | "\\('value2', 'value1'\\)", 88 | []string{"value2", "value1"}, 89 | ) 90 | 91 | // table1 92 | expectInsert(t, mock, 93 | "table3", 94 | []string{"f1", "f2"}, 95 | "\\('value1', 'value2'\\)", 96 | []string{"value1", "value2"}, 97 | ) 98 | 99 | mock.ExpectCommit() 100 | 101 | err = l.loadTables(&ctx) 102 | if err != nil { 103 | t.Error(err) 104 | t.Fail() 105 | } 106 | 107 | if err := mock.ExpectationsWereMet(); err != nil { 108 | t.Errorf("there were unfulfilled expectations: %s", err) 109 | t.Fail() 110 | } 111 | } 112 | 113 | func TestLoadTablesShouldExtendRows(t *testing.T) { 114 | yml, err := os.ReadFile("../testdata/sql_extend.yaml") 115 | require.NoError(t, err) 116 | 117 | db, mock, err := sqlmock.New() 118 | if err != nil { 119 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 120 | } 121 | defer func() { _ = db.Close() }() 122 | 123 | ctx := loadContext{ 124 | refsDefinition: make(map[string]row), 125 | refsInserted: make(map[string]row), 126 | } 127 | 128 | l := New(db, "", true) 129 | 130 | if err = l.loadYml(yml, &ctx); err != nil { 131 | t.Error(err) 132 | t.Fail() 133 | } 134 | 135 | mock.ExpectBegin() 136 | 137 | expectTruncate(mock, "table1") 138 | expectTruncate(mock, "table2") 139 | expectTruncate(mock, "table3") 140 | 141 | // table1 142 | expectInsert(t, mock, 143 | "table1", 144 | []string{"f1", "f2"}, 145 | "\\('value1', 'value2'\\)", 146 | []string{"value1", "value2"}, 147 | ) 148 | 149 | // table2 150 | expectInsert(t, mock, 151 | "table2", 152 | []string{"f1", "f2", "f3"}, 153 | "\\('value1 overwritten', 'value2', "+`\("1" \|\| "2" \|\| 3 \+ 5\)\)$`, 154 | []string{"value1 overwritten", "value2", `1`}, 155 | ) 156 | 157 | // table3, data 1 158 | expectInsert(t, mock, 159 | "table3", 160 | []string{"f1", "f2", "f3"}, 161 | "\\('value1 overwritten', 'value2', "+`\("1" \|\| "2" \|\| 3 \+ 5\)\)$`, 162 | []string{"value1 overwritten", "value2", `1`}, 163 | ) 164 | 165 | // table3, data 2 166 | expectInsert(t, mock, 167 | "table3", 168 | []string{"f1", "f2"}, 169 | "\\('tplVal1', 'tplVal2'\\)", 170 | []string{"tplVal1", "tplVal2"}, 171 | ) 172 | 173 | mock.ExpectCommit() 174 | 175 | err = l.loadTables(&ctx) 176 | if err != nil { 177 | t.Error(err) 178 | t.Fail() 179 | } 180 | 181 | if err := mock.ExpectationsWereMet(); err != nil { 182 | t.Errorf("there were unfulfilled expectations: %s", err) 183 | t.Fail() 184 | } 185 | } 186 | 187 | var idCounter int64 188 | 189 | func expectInsert( 190 | t *testing.T, 191 | mock sqlmock.Sqlmock, 192 | table string, 193 | fields []string, 194 | valuesToInsert string, 195 | valuesResult []string, 196 | ) { 197 | 198 | t.Helper() 199 | 200 | idCounter++ 201 | 202 | mock.ExpectExec( 203 | fmt.Sprintf("^INSERT INTO `%s` %s VALUES %s$", 204 | table, 205 | fieldsToDbStr(fields), 206 | valuesToInsert, 207 | ), 208 | ). 209 | WillReturnResult(sqlmock.NewResult(idCounter, 1)) 210 | 211 | var valuesRow []driver.Value 212 | for _, v := range valuesResult { 213 | valuesRow = append(valuesRow, driver.Value(v)) 214 | } 215 | 216 | mock.ExpectQuery(fmt.Sprintf("^SELECT \\* FROM `%s` WHERE `id` = \\?$", table)). 217 | WithArgs(idCounter). 218 | WillReturnRows( 219 | sqlmock.NewRows(fields). 220 | AddRow(valuesRow...), 221 | ) 222 | } 223 | 224 | func expectTruncate(mock sqlmock.Sqlmock, table string) { 225 | mock.ExpectExec(fmt.Sprintf("^TRUNCATE TABLE `%s`$", table)). 226 | WillReturnResult(sqlmock.NewResult(0, 0)) 227 | } 228 | 229 | func fieldsToDbStr(values []string) string { 230 | quotedVals := make([]string, len(values)) 231 | 232 | for i, val := range values { 233 | quotedVals[i] = "`" + val + "`" 234 | } 235 | 236 | return "\\(" + strings.Join(quotedVals, ", ") + "\\)" 237 | } 238 | -------------------------------------------------------------------------------- /fixtures/redis/parser/context.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | type Context struct { 4 | keyRefs map[string]Keys 5 | hashRefs map[string]HashRecordValue 6 | setRefs map[string]SetRecordValue 7 | listRefs map[string]ListRecordValue 8 | zsetRefs map[string]ZSetRecordValue 9 | } 10 | 11 | func NewContext() *Context { 12 | return &Context{ 13 | keyRefs: make(map[string]Keys), 14 | hashRefs: make(map[string]HashRecordValue), 15 | setRefs: make(map[string]SetRecordValue), 16 | listRefs: make(map[string]ListRecordValue), 17 | zsetRefs: make(map[string]ZSetRecordValue), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /fixtures/redis/parser/file.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | ErrFixtureNotFound = errors.New("fixture not found") 12 | ErrFixtureFileLoad = errors.New("failed to load fixture file") 13 | ErrFixtureParseFile = errors.New("failed to parse fixture file") 14 | ErrParserNotFound = errors.New("parser not found") 15 | ) 16 | 17 | func loadError(fixtureName string, err error) error { 18 | return fmt.Errorf("%w %s: %s", ErrFixtureFileLoad, fixtureName, err) 19 | } 20 | 21 | func parseError(fixtureName string, err error) error { 22 | return fmt.Errorf("%w %s: %s", ErrFixtureParseFile, fixtureName, err) 23 | } 24 | 25 | type fileParser struct { 26 | locations []string 27 | } 28 | 29 | func New(locations []string) *fileParser { 30 | return &fileParser{ 31 | locations: locations, 32 | } 33 | } 34 | 35 | func (l *fileParser) ParseFiles(ctx *Context, names []string) ([]*Fixture, error) { 36 | fileNameCache := make(map[string]struct{}) 37 | var fixtures []*Fixture 38 | 39 | for _, name := range names { 40 | for _, loc := range l.locations { 41 | filename, err := l.getFirstExistsFileName(name, loc) 42 | if err != nil { 43 | return nil, loadError(name, err) 44 | } 45 | if _, ok := fileNameCache[filename]; ok { 46 | continue 47 | } 48 | 49 | extension := strings.ReplaceAll(filepath.Ext(filename), ".", "") 50 | fixtureParser := GetParser(extension) 51 | if fixtureParser == nil { 52 | return nil, ErrParserNotFound 53 | } 54 | parserCopy := fixtureParser.Copy(l) 55 | 56 | fixture, err := parserCopy.Parse(ctx, filename) 57 | if err != nil { 58 | return nil, parseError(filename, err) 59 | } 60 | 61 | fixtures = append(fixtures, fixture) 62 | fileNameCache[filename] = struct{}{} 63 | } 64 | } 65 | 66 | return fixtures, nil 67 | } 68 | 69 | func (l *fileParser) getFirstExistsFileName(name, location string) (string, error) { 70 | candidates := []string{ 71 | name, 72 | fmt.Sprintf("%s.yaml", name), 73 | fmt.Sprintf("%s.yml", name), 74 | } 75 | 76 | for _, p := range candidates { 77 | path := filepath.Join(location, p) 78 | paths, err := filepath.Glob(path) 79 | if err != nil { 80 | return "", err 81 | } 82 | if len(paths) > 0 { 83 | return paths[0], nil 84 | } 85 | } 86 | 87 | return "", ErrFixtureNotFound 88 | } 89 | -------------------------------------------------------------------------------- /fixtures/redis/parser/fixture.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // Fixture is a representation of the test data, that is preloaded to a redis database before the test starts 9 | /* Example (yaml): 10 | ```yaml 11 | inherits: 12 | - parent_template 13 | - child_template 14 | - other_fixture 15 | databases: 16 | 1: 17 | keys: 18 | $name: keys1 19 | values: 20 | a: 21 | value: 1 22 | expiration: 10s 23 | b: 24 | value: 2 25 | 2: 26 | keys: 27 | $name: keys2 28 | values: 29 | c: 30 | value: 3 31 | expiration: 10s 32 | d: 33 | value: 4 34 | ``` 35 | */ 36 | type Fixture struct { 37 | Inherits []string `yaml:"inherits"` 38 | Templates Templates `yaml:"templates"` 39 | Databases map[int]Database `yaml:"databases"` 40 | } 41 | 42 | type Templates struct { 43 | Keys []*Keys `yaml:"keys"` 44 | Hashes []*HashRecordValue `yaml:"hashes"` 45 | Sets []*SetRecordValue `yaml:"sets"` 46 | Lists []*ListRecordValue `yaml:"lists"` 47 | ZSets []*ZSetRecordValue `yaml:"zsets"` 48 | } 49 | 50 | // Database contains data to load into Redis database 51 | type Database struct { 52 | Keys *Keys `yaml:"keys"` 53 | Hashes *Hashes `yaml:"hashes"` 54 | Sets *Sets `yaml:"sets"` 55 | Lists *Lists `yaml:"lists"` 56 | ZSets *ZSets `yaml:"zsets"` 57 | } 58 | 59 | const ( 60 | TypeInt = "int" 61 | TypeStr = "str" 62 | TypeFloat = "float" 63 | ) 64 | 65 | type Value struct { 66 | Type string 67 | Value interface{} 68 | } 69 | 70 | func (obj *Value) UnmarshalYAML(unmarshal func(interface{}) error) error { 71 | var internal interface{} 72 | if err := unmarshal(&internal); err != nil { 73 | return err 74 | } 75 | switch v := internal.(type) { 76 | case string: 77 | obj.Type = TypeStr 78 | obj.Value = v 79 | case int, int16, int32, int64: 80 | obj.Type = TypeInt 81 | obj.Value = v 82 | case float64: 83 | obj.Type = TypeFloat 84 | obj.Value = v 85 | default: 86 | return fmt.Errorf("unknown value type: %T", v) 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func Str(value string) Value { 93 | return Value{Type: TypeStr, Value: value} 94 | } 95 | 96 | func Int(value int) Value { 97 | return Value{Type: TypeInt, Value: value} 98 | } 99 | 100 | func Float(value float64) Value { 101 | return Value{Type: TypeFloat, Value: value} 102 | } 103 | 104 | // Keys represent a collection of key/value pairs, that will be loaded into Redis database 105 | type Keys struct { 106 | Name string `yaml:"$name"` 107 | Extend string `yaml:"$extend"` 108 | Values map[string]*KeyValue `yaml:"values"` 109 | } 110 | 111 | // KeyValue represent a redis key/value pair 112 | type KeyValue struct { 113 | Value Value `yaml:"value"` 114 | Expiration time.Duration `yaml:"expiration"` 115 | } 116 | 117 | // Hashes represent a collection of hash data structures, that will be loaded into Redis database 118 | type Hashes struct { 119 | Values map[string]*HashRecordValue `yaml:"values"` 120 | } 121 | 122 | // HashRecordValue represent a single hash data structure 123 | type HashRecordValue struct { 124 | Name string `yaml:"$name"` 125 | Extend string `yaml:"$extend"` 126 | Values []*HashValue `yaml:"values"` 127 | Expiration time.Duration `yaml:"expiration"` 128 | } 129 | 130 | type HashValue struct { 131 | Key Value `yaml:"key"` 132 | Value Value `yaml:"value"` 133 | } 134 | 135 | // Sets represent a collection of set data structures, that will be loaded into Redis database 136 | type Sets struct { 137 | Values map[string]*SetRecordValue `yaml:"values"` 138 | } 139 | 140 | // SetRecordValue represent a single set data structure 141 | type SetRecordValue struct { 142 | Name string `yaml:"$name"` 143 | Extend string `yaml:"$extend"` 144 | Values []*SetValue `yaml:"values"` 145 | Expiration time.Duration `yaml:"expiration"` 146 | } 147 | 148 | // SetValue represent a set value object 149 | type SetValue struct { 150 | Value Value `yaml:"value"` 151 | } 152 | 153 | // Lists represent a collection of Redis list data structures 154 | type Lists struct { 155 | Values map[string]*ListRecordValue `yaml:"values"` 156 | } 157 | 158 | // ListRecordValue represent a single list data structure 159 | type ListRecordValue struct { 160 | Name string `yaml:"$name"` 161 | Extend string `yaml:"$extend"` 162 | Values []*ListValue `yaml:"values"` 163 | Expiration time.Duration `yaml:"expiration"` 164 | } 165 | 166 | // ListValue represent a list value object 167 | type ListValue struct { 168 | Value Value `yaml:"value"` 169 | } 170 | 171 | // ZSets represent a collection of Redis sorted set data structure 172 | type ZSets struct { 173 | Values map[string]*ZSetRecordValue `yaml:"values"` 174 | } 175 | 176 | // ZSetRecordValue represent a single sorted set data structure 177 | type ZSetRecordValue struct { 178 | Name string `yaml:"$name"` 179 | Extend string `yaml:"$extend"` 180 | Values []*ZSetValue `yaml:"values"` 181 | Expiration time.Duration `yaml:"expiration"` 182 | } 183 | 184 | // ZSetValue represent a zset value object 185 | type ZSetValue struct { 186 | Value Value `yaml:"value"` 187 | Score float64 `yaml:"score"` 188 | } 189 | -------------------------------------------------------------------------------- /fixtures/redis/parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | type FixtureFileParser interface { 4 | Parse(ctx *Context, filename string) (*Fixture, error) 5 | Copy(parser *fileParser) FixtureFileParser 6 | } 7 | 8 | var fixtureParsersRegistry = make(map[string]FixtureFileParser) 9 | 10 | func RegisterParser(format string, parser FixtureFileParser) { 11 | fixtureParsersRegistry[format] = parser 12 | } 13 | 14 | func GetParser(format string) FixtureFileParser { 15 | return fixtureParsersRegistry[format] 16 | } 17 | 18 | func init() { 19 | RegisterParser("yaml", &redisYamlParser{}) 20 | } 21 | -------------------------------------------------------------------------------- /fixtures/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/redis/go-redis/v9" 7 | 8 | "github.com/lamoda/gonkey/fixtures/redis/parser" 9 | ) 10 | 11 | type Loader struct { 12 | locations []string 13 | client *redis.Client 14 | } 15 | 16 | type LoaderOptions struct { 17 | FixtureDir string 18 | Redis *redis.Options 19 | } 20 | 21 | func New(opts LoaderOptions) *Loader { 22 | client := redis.NewClient(opts.Redis) 23 | 24 | return &Loader{ 25 | locations: []string{opts.FixtureDir}, 26 | client: client, 27 | } 28 | } 29 | 30 | func (l *Loader) Load(names []string) error { 31 | ctx := parser.NewContext() 32 | fileParser := parser.New(l.locations) 33 | fixtureList, err := fileParser.ParseFiles(ctx, names) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | return l.loadData(fixtureList) 39 | } 40 | 41 | func (l *Loader) loadKeys(ctx context.Context, pipe redis.Pipeliner, db parser.Database) error { 42 | if db.Keys == nil { 43 | return nil 44 | } 45 | for k, v := range db.Keys.Values { 46 | if err := pipe.Set(ctx, k, v.Value.Value, v.Expiration).Err(); err != nil { 47 | return err 48 | } 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (l *Loader) loadSets(ctx context.Context, pipe redis.Pipeliner, db parser.Database) error { 55 | if db.Sets == nil { 56 | return nil 57 | } 58 | for setKey, setRecord := range db.Sets.Values { 59 | values := make([]interface{}, 0, len(setRecord.Values)) 60 | for _, v := range setRecord.Values { 61 | values = append(values, v.Value.Value) 62 | } 63 | if err := pipe.SAdd(ctx, setKey, values).Err(); err != nil { 64 | return err 65 | } 66 | if setRecord.Expiration > 0 { 67 | if err := pipe.Expire(ctx, setKey, setRecord.Expiration).Err(); err != nil { 68 | return err 69 | } 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func (l *Loader) loadHashes(ctx context.Context, pipe redis.Pipeliner, db parser.Database) error { 77 | if db.Hashes == nil { 78 | return nil 79 | } 80 | for key, record := range db.Hashes.Values { 81 | values := make([]interface{}, 0, len(record.Values)*2) 82 | for _, v := range record.Values { 83 | values = append(values, v.Key.Value, v.Value.Value) 84 | } 85 | if err := pipe.HSet(ctx, key, values...).Err(); err != nil { 86 | return err 87 | } 88 | if record.Expiration > 0 { 89 | if err := pipe.Expire(ctx, key, record.Expiration).Err(); err != nil { 90 | return err 91 | } 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (l *Loader) loadLists(ctx context.Context, pipe redis.Pipeliner, db parser.Database) error { 99 | if db.Lists == nil { 100 | return nil 101 | } 102 | for key, record := range db.Lists.Values { 103 | values := make([]interface{}, 0, len(record.Values)) 104 | for _, v := range record.Values { 105 | values = append(values, v.Value.Value) 106 | } 107 | if err := pipe.RPush(ctx, key, values...).Err(); err != nil { 108 | return err 109 | } 110 | if record.Expiration > 0 { 111 | if err := pipe.Expire(ctx, key, record.Expiration).Err(); err != nil { 112 | return err 113 | } 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func (l *Loader) loadSortedSets(ctx context.Context, pipe redis.Pipeliner, db parser.Database) error { 121 | if db.ZSets == nil { 122 | return nil 123 | } 124 | for key, record := range db.ZSets.Values { 125 | values := make([]redis.Z, 0, len(record.Values)) 126 | for _, v := range record.Values { 127 | values = append(values, redis.Z{ 128 | Score: v.Score, 129 | Member: v.Value.Value, 130 | }) 131 | } 132 | if err := pipe.ZAdd(ctx, key, values...).Err(); err != nil { 133 | return err 134 | } 135 | if record.Expiration > 0 { 136 | if err := pipe.Expire(ctx, key, record.Expiration).Err(); err != nil { 137 | return err 138 | } 139 | } 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func (l *Loader) loadRedisDatabase(ctx context.Context, dbID int, db parser.Database, needTruncate bool) error { 146 | pipe := l.client.Pipeline() 147 | err := pipe.Select(ctx, dbID).Err() 148 | if err != nil { 149 | return err 150 | } 151 | 152 | if needTruncate { 153 | if err := pipe.FlushDB(ctx).Err(); err != nil { 154 | return err 155 | } 156 | } 157 | 158 | if err := l.loadKeys(ctx, pipe, db); err != nil { 159 | return err 160 | } 161 | 162 | if err := l.loadSets(ctx, pipe, db); err != nil { 163 | return err 164 | } 165 | 166 | if err := l.loadHashes(ctx, pipe, db); err != nil { 167 | return err 168 | } 169 | 170 | if err := l.loadLists(ctx, pipe, db); err != nil { 171 | return err 172 | } 173 | 174 | if err := l.loadSortedSets(ctx, pipe, db); err != nil { 175 | return err 176 | } 177 | 178 | if _, err := pipe.Exec(ctx); err != nil { 179 | return err 180 | } 181 | 182 | return nil 183 | } 184 | 185 | func (l *Loader) loadData(fixtures []*parser.Fixture) error { 186 | truncatedDatabases := make(map[int]struct{}) 187 | 188 | for _, redisFixture := range fixtures { 189 | for dbID, db := range redisFixture.Databases { 190 | var needTruncate bool 191 | if _, ok := truncatedDatabases[dbID]; !ok { 192 | truncatedDatabases[dbID] = struct{}{} 193 | needTruncate = true 194 | } 195 | err := l.loadRedisDatabase(context.Background(), dbID, db, needTruncate) 196 | if err != nil { 197 | return err 198 | } 199 | } 200 | } 201 | 202 | return nil 203 | } 204 | -------------------------------------------------------------------------------- /fixtures/testdata/aerospike.yaml: -------------------------------------------------------------------------------- 1 | sets: 2 | set1: 3 | key1: 4 | bin1: "value1" 5 | bin2: 1 6 | key2: 7 | bin1: "value2" 8 | bin2: 2 9 | bin3: 2.569947773654566473 10 | set2: 11 | key1: 12 | bin4: false 13 | bin5: null 14 | bin1: '"' 15 | key2: 16 | bin1: "'" 17 | bin5: 18 | - 1 19 | - '2' -------------------------------------------------------------------------------- /fixtures/testdata/aerospike_extend.yaml: -------------------------------------------------------------------------------- 1 | templates: 2 | base_tmpl: 3 | bin1: value1 4 | extended_tmpl: 5 | $extend: base_tmpl 6 | bin2: value2 7 | 8 | sets: 9 | set1: 10 | key1: 11 | $extend: base_tmpl 12 | bin1: overwritten 13 | set2: 14 | key1: 15 | $extend: extended_tmpl 16 | bin2: overwritten -------------------------------------------------------------------------------- /fixtures/testdata/redis.yaml: -------------------------------------------------------------------------------- 1 | databases: 2 | 1: 3 | keys: 4 | values: 5 | key1: 6 | value: value1 7 | key2: 8 | expiration: 10s 9 | value: value2 10 | sets: 11 | values: 12 | set1: 13 | expiration: 10s 14 | values: 15 | - value: a 16 | - value: b 17 | - value: c 18 | set3: 19 | expiration: 5s 20 | values: 21 | - value: x 22 | - value: y 23 | hashes: 24 | values: 25 | map1: 26 | values: 27 | - key: a 28 | value: 1 29 | - key: b 30 | value: 2 31 | map2: 32 | values: 33 | - key: c 34 | value: 3 35 | - key: d 36 | value: 4 37 | lists: 38 | values: 39 | list1: 40 | values: 41 | - value: 1 42 | - value: "2" 43 | - value: a 44 | zsets: 45 | values: 46 | zset1: 47 | values: 48 | - value: 1 49 | score: 1.1 50 | - value: "2" 51 | score: 5.6 52 | 2: 53 | keys: 54 | values: 55 | key3: 56 | value: value3 57 | key4: 58 | expiration: 5s 59 | value: value4 60 | sets: 61 | values: 62 | set2: 63 | expiration: 5s 64 | values: 65 | - value: d 66 | - value: e 67 | - value: f 68 | hashes: 69 | values: 70 | map3: 71 | values: 72 | - key: c 73 | value: 3 74 | - key: d 75 | value: 4 76 | map4: 77 | values: 78 | - key: e 79 | value: 10 80 | - key: f 81 | value: 11 82 | -------------------------------------------------------------------------------- /fixtures/testdata/redis_example.yaml: -------------------------------------------------------------------------------- 1 | templates: 2 | keys: 3 | - $name: parentKeyTemplate 4 | values: 5 | baseKey: 6 | expiration: 1s 7 | value: 1 8 | - $name: childKeyTemplate 9 | $extend: parentKeyTemplate 10 | values: 11 | otherKey: 12 | value: 2 13 | sets: 14 | - $name: parentSetTemplate 15 | expiration: 10s 16 | values: 17 | - value: a 18 | - $name: childSetTemplate 19 | $extend: parentSetTemplate 20 | values: 21 | - value: b 22 | hashes: 23 | - $name: parentHashTemplate 24 | values: 25 | - key: a 26 | value: 1 27 | - key: b 28 | value: 2 29 | - $name: childHashTemplate 30 | $extend: parentHashTemplate 31 | values: 32 | - key: c 33 | value: 3 34 | - key: d 35 | value: 4 36 | lists: 37 | - $name: parentListTemplate 38 | values: 39 | - value: 1 40 | - value: 2 41 | - $name: childListTemplate 42 | values: 43 | - value: 3 44 | - value: 4 45 | zsets: 46 | - $name: parentZSetTemplate 47 | values: 48 | - value: 1 49 | score: 2.1 50 | - value: 2 51 | score: 4.3 52 | - $name: childZSetTemplate 53 | value: 54 | - value: 3 55 | score: 6.5 56 | - value: 4 57 | score: 8.7 58 | databases: 59 | 1: 60 | keys: 61 | $extend: childKeyTemplate 62 | values: 63 | key1: 64 | value: value1 65 | key2: 66 | expiration: 10s 67 | value: value2 68 | sets: 69 | values: 70 | set1: 71 | $extend: childSetTemplate 72 | expiration: 10s 73 | values: 74 | - value: a 75 | - value: b 76 | set3: 77 | expiration: 5s 78 | values: 79 | - value: x 80 | - value: y 81 | hashes: 82 | values: 83 | map1: 84 | $extend: childHashTemplate 85 | values: 86 | - key: a 87 | value: 1 88 | - key: b 89 | value: 2 90 | map2: 91 | values: 92 | - key: c 93 | value: 3 94 | - key: d 95 | value: 4 96 | lists: 97 | values: 98 | list1: 99 | $extend: childListTemplate 100 | values: 101 | - value: 1 102 | - value: 100 103 | - value: 200 104 | zsets: 105 | values: 106 | zset1: 107 | $extend: childZSetTemplate 108 | values: 109 | - value: 5 110 | score: 10.1 111 | 2: 112 | keys: 113 | values: 114 | key3: 115 | value: value3 116 | key4: 117 | expiration: 5s 118 | value: value4 119 | -------------------------------------------------------------------------------- /fixtures/testdata/redis_extend.yaml: -------------------------------------------------------------------------------- 1 | templates: 2 | keys: 3 | - $name: parentKeys 4 | values: 5 | a: 6 | value: 1 7 | b: 8 | value: 2 9 | - $name: childKeys 10 | $extend: parentKeys 11 | values: 12 | c: 13 | value: 3 14 | d: 15 | value: 4 16 | sets: 17 | - $name: parentSet 18 | expiration: 10s 19 | values: 20 | - value: a 21 | - value: b 22 | - $name: childSet 23 | $extend: parentSet 24 | values: 25 | - value: c 26 | hashes: 27 | - $name: parentMap 28 | values: 29 | - key: a1 30 | value: 1 31 | - key: b1 32 | value: 2 33 | - $name: childMap 34 | $extend: parentMap 35 | values: 36 | - key: c1 37 | value: 3 38 | lists: 39 | - $name: parentList 40 | values: 41 | - value: 1 42 | - value: 2 43 | - $name: childList 44 | $extend: parentList 45 | values: 46 | - value: 3 47 | - value: 4 48 | zsets: 49 | - $name: parentZSet 50 | values: 51 | - value: 1 52 | score: 1.2 53 | - value: 2 54 | score: 3.4 55 | - $name: childZSet 56 | $extend: parentZSet 57 | values: 58 | - value: 3 59 | score: 5.6 60 | - value: 4 61 | score: 7.8 62 | 63 | databases: 64 | 1: 65 | keys: 66 | $extend: childKeys 67 | values: 68 | key1: 69 | value: value1 70 | key2: 71 | expiration: 10s 72 | value: value2 73 | sets: 74 | values: 75 | set1: 76 | $extend: childSet 77 | values: 78 | - value: d 79 | set2: 80 | expiration: 10s 81 | values: 82 | - value: x 83 | - value: y 84 | hashes: 85 | values: 86 | map1: 87 | $extend: childMap 88 | $name: baseMap 89 | values: 90 | - key: a 91 | value: 1 92 | - key: b 93 | value: 2 94 | map2: 95 | values: 96 | - key: c 97 | value: 3 98 | - key: d 99 | value: 4 100 | lists: 101 | values: 102 | list1: 103 | $name: list1 104 | $extend: childList 105 | values: 106 | - value: 10 107 | - value: 11 108 | zsets: 109 | values: 110 | zset1: 111 | $name: zset1 112 | $extend: childZSet 113 | values: 114 | - value: 5 115 | score: 10.1 116 | -------------------------------------------------------------------------------- /fixtures/testdata/redis_inherits.yaml: -------------------------------------------------------------------------------- 1 | inherits: 2 | - redis_extend 3 | databases: 4 | 1: 5 | keys: 6 | $extend: childKeys 7 | values: 8 | key1: 9 | value: value1 10 | key2: 11 | expiration: 10s 12 | value: value2 13 | sets: 14 | values: 15 | set1: 16 | $extend: childSet 17 | hashes: 18 | values: 19 | map1: 20 | $extend: baseMap 21 | values: 22 | - key: x 23 | value: 10 24 | - key: y 25 | value: 11 26 | map2: 27 | $extend: childMap 28 | values: 29 | - key: t 30 | value: 500 31 | - key: j 32 | value: 1000 33 | lists: 34 | values: 35 | list2: 36 | $extend: list1 37 | values: 38 | - value: 100 39 | list3: 40 | $extend: childList 41 | values: 42 | - value: 200 43 | zsets: 44 | values: 45 | zset2: 46 | $extend: zset1 47 | values: 48 | - value: 100 49 | score: 100.1 50 | zset3: 51 | $extend: childZSet 52 | values: 53 | - value: 200 54 | score: 200.2 55 | -------------------------------------------------------------------------------- /fixtures/testdata/simple1.yaml: -------------------------------------------------------------------------------- 1 | tables: 2 | table1: 3 | - field1: "value1" 4 | field2: 2 5 | -------------------------------------------------------------------------------- /fixtures/testdata/simple2.yaml: -------------------------------------------------------------------------------- 1 | tables: 2 | table2: 3 | - field3: "value3" 4 | field4: 4 5 | -------------------------------------------------------------------------------- /fixtures/testdata/sql.yaml: -------------------------------------------------------------------------------- 1 | tables: 2 | table: 3 | - field1: "value1" 4 | field2: 1 5 | - field1: "value2" 6 | field2: 2 7 | field3: 2.569947773654566473 8 | - field4: false 9 | field5: null 10 | field1: '"' 11 | - field1: "'" 12 | field5: 13 | - 1 14 | - '2' -------------------------------------------------------------------------------- /fixtures/testdata/sql_extend.yaml: -------------------------------------------------------------------------------- 1 | templates: 2 | baseTpl: 3 | f1: tplVal1 4 | ref3: 5 | $extend: baseTpl 6 | f2: tplVal2 7 | tables: 8 | table1: 9 | - $name: ref1 10 | f1: value1 11 | f2: value2 12 | 13 | table2: 14 | - $name: ref2 15 | $extend: ref1 16 | f1: value1 overwritten 17 | f3: $eval("1" || "2" || 3 + 5) 18 | 19 | table3: 20 | - $extend: ref2 21 | - $extend: ref3 -------------------------------------------------------------------------------- /fixtures/testdata/sql_refs.yaml: -------------------------------------------------------------------------------- 1 | tables: 2 | table1: 3 | - $name: ref1 4 | f1: value1 5 | f2: value2 6 | 7 | table2: 8 | - $name: ref2 9 | f1: $ref1.f2 10 | f2: $ref1.f1 11 | 12 | table3: 13 | - f1: $ref1.f1 14 | f2: $ref2.f1 -------------------------------------------------------------------------------- /fixtures/testdata/sql_schema.yaml: -------------------------------------------------------------------------------- 1 | tables: 2 | schema1.table1: 3 | - f1: value1 4 | f2: value2 5 | 6 | schema2.table2: 7 | - f1: value3 8 | f2: value4 9 | 10 | table3: 11 | - f1: value5 12 | f2: value6 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lamoda/gonkey 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/aerospike/aerospike-client-go/v5 v5.11.0 7 | github.com/fatih/color v1.18.0 8 | github.com/google/uuid v1.6.0 9 | github.com/joho/godotenv v1.5.1 10 | github.com/kylelemons/godebug v1.1.0 11 | github.com/lib/pq v1.10.9 12 | github.com/redis/go-redis/v9 v9.7.3 13 | github.com/stretchr/testify v1.10.0 14 | github.com/tidwall/gjson v1.18.0 15 | golang.org/x/sync v0.10.0 16 | gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 17 | gopkg.in/yaml.v2 v2.4.0 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 25 | github.com/mattn/go-colorable v0.1.13 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/pmezard/go-difflib v1.0.0 // indirect 28 | github.com/tidwall/match v1.1.1 // indirect 29 | github.com/tidwall/pretty v1.2.0 // indirect 30 | github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da // indirect 31 | golang.org/x/sys v0.25.0 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /mocks/definition.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httputil" 7 | "sync" 8 | ) 9 | 10 | const CallsNoConstraint = -1 11 | 12 | type Definition struct { 13 | path string 14 | requestConstraints []verifier 15 | replyStrategy ReplyStrategy 16 | sync.Mutex 17 | calls int 18 | callsConstraint int 19 | } 20 | 21 | func NewDefinition(path string, constraints []verifier, strategy ReplyStrategy, callsConstraint int) *Definition { 22 | return &Definition{ 23 | path: path, 24 | requestConstraints: constraints, 25 | replyStrategy: strategy, 26 | callsConstraint: callsConstraint, 27 | } 28 | } 29 | 30 | func (d *Definition) Execute(w http.ResponseWriter, r *http.Request) []error { 31 | d.Lock() 32 | d.calls++ 33 | d.Unlock() 34 | var errors []error 35 | if len(d.requestConstraints) > 0 { 36 | requestDump, err := httputil.DumpRequest(r, true) 37 | if err != nil { 38 | fmt.Printf("Gonkey internal error: %s\n", err) 39 | } 40 | 41 | for _, c := range d.requestConstraints { 42 | errs := c.Verify(r) 43 | for _, e := range errs { 44 | errors = append(errors, &RequestConstraintError{ 45 | error: e, 46 | Constraint: c, 47 | RequestDump: requestDump, 48 | }) 49 | } 50 | } 51 | } 52 | if d.replyStrategy != nil { 53 | errors = append(errors, d.replyStrategy.HandleRequest(w, r)...) 54 | } 55 | return errors 56 | } 57 | 58 | func (d *Definition) ResetRunningContext() { 59 | if s, ok := d.replyStrategy.(contextAwareStrategy); ok { 60 | s.ResetRunningContext() 61 | } 62 | d.Lock() 63 | d.calls = 0 64 | d.Unlock() 65 | } 66 | 67 | func (d *Definition) EndRunningContext() []error { 68 | d.Lock() 69 | defer d.Unlock() 70 | var errs []error 71 | if s, ok := d.replyStrategy.(contextAwareStrategy); ok { 72 | errs = s.EndRunningContext() 73 | } 74 | if d.callsConstraint != CallsNoConstraint && d.calls != d.callsConstraint { 75 | err := fmt.Errorf("at path %s: number of calls does not match: expected %d, actual %d", 76 | d.path, d.callsConstraint, d.calls) 77 | errs = append(errs, err) 78 | } 79 | return errs 80 | } 81 | 82 | func verifyRequestConstraints(requestConstraints []verifier, r *http.Request) []error { 83 | var errors []error 84 | if len(requestConstraints) > 0 { 85 | requestDump, err := httputil.DumpRequest(r, true) 86 | if err != nil { 87 | requestDump = []byte(fmt.Sprintf("failed to dump request: %s", err)) 88 | } 89 | for _, c := range requestConstraints { 90 | errs := c.Verify(r) 91 | for _, e := range errs { 92 | errors = append(errors, &RequestConstraintError{ 93 | error: e, 94 | Constraint: c, 95 | RequestDump: requestDump, 96 | }) 97 | } 98 | } 99 | } 100 | return errors 101 | } 102 | func (d *Definition) ExecuteWithoutVerifying(w http.ResponseWriter, r *http.Request) []error { 103 | d.Lock() 104 | d.calls++ 105 | d.Unlock() 106 | if d.replyStrategy != nil { 107 | return d.replyStrategy.HandleRequest(w, r) 108 | } 109 | return []error{ 110 | fmt.Errorf("reply strategy undefined"), 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /mocks/error.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | type Error struct { 9 | error 10 | ServiceName string 11 | } 12 | 13 | func (e *Error) Error() string { 14 | return fmt.Sprintf("mock %s: %s", e.ServiceName, e.error.Error()) 15 | } 16 | 17 | type RequestConstraintError struct { 18 | error 19 | Constraint verifier 20 | RequestDump []byte 21 | } 22 | 23 | func (e *RequestConstraintError) Error() string { 24 | kind := reflect.TypeOf(e.Constraint).String() 25 | return fmt.Sprintf("request constraint %s failed: %s, request was:\n %s", kind, e.error.Error(), e.RequestDump) 26 | } 27 | -------------------------------------------------------------------------------- /mocks/mocks.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | type Mocks struct { 11 | mocks map[string]*ServiceMock 12 | } 13 | 14 | func New(mocks ...*ServiceMock) *Mocks { 15 | mocksMap := make(map[string]*ServiceMock, len(mocks)) 16 | for _, v := range mocks { 17 | mocksMap[v.ServiceName] = v 18 | } 19 | return &Mocks{ 20 | mocks: mocksMap, 21 | } 22 | } 23 | 24 | func NewNop(serviceNames ...string) *Mocks { 25 | mocksMap := make(map[string]*ServiceMock, len(serviceNames)) 26 | for _, name := range serviceNames { 27 | mocksMap[name] = NewServiceMock(name, NewDefinition("$", nil, &failReply{}, CallsNoConstraint)) 28 | } 29 | return &Mocks{ 30 | mocks: mocksMap, 31 | } 32 | } 33 | 34 | func (m *Mocks) ResetDefinitions() { 35 | for _, v := range m.mocks { 36 | v.ResetDefinition() 37 | } 38 | } 39 | 40 | func (m *Mocks) Start() error { 41 | for _, v := range m.mocks { 42 | err := v.StartServer() 43 | if err != nil { 44 | m.Shutdown() 45 | return err 46 | } 47 | } 48 | return nil 49 | } 50 | 51 | // Stops immediately, with no gracefully closing connections 52 | func (m *Mocks) Shutdown() { 53 | ctx, cancel := context.WithCancel(context.TODO()) 54 | cancel() 55 | _ = m.ShutdownContext(ctx) 56 | } 57 | 58 | func (m *Mocks) ShutdownContext(ctx context.Context) error { 59 | errs := make([]string, 0, len(m.mocks)) 60 | for _, v := range m.mocks { 61 | if err := v.ShutdownServer(ctx); err != nil { 62 | errs = append(errs, fmt.Sprintf("%s: %s", v.mock.path, err.Error())) 63 | } 64 | } 65 | if len(errs) != 0 { 66 | return errors.New(strings.Join(errs, "; ")) 67 | } 68 | return nil 69 | } 70 | 71 | func (m *Mocks) SetMock(mock *ServiceMock) { 72 | m.mocks[mock.ServiceName] = mock 73 | } 74 | 75 | func (m *Mocks) Service(serviceName string) *ServiceMock { 76 | mock, _ := m.mocks[serviceName] 77 | return mock 78 | } 79 | 80 | func (m *Mocks) ResetRunningContext() { 81 | for _, v := range m.mocks { 82 | v.ResetRunningContext() 83 | } 84 | } 85 | 86 | func (m *Mocks) EndRunningContext() []error { 87 | var errors []error 88 | for _, v := range m.mocks { 89 | errors = append(errors, v.EndRunningContext()...) 90 | } 91 | return errors 92 | } 93 | 94 | func (m *Mocks) GetNames() []string { 95 | names := []string{} 96 | for n := range m.mocks { 97 | names = append(names, n) 98 | } 99 | return names 100 | } 101 | -------------------------------------------------------------------------------- /mocks/reply_strategy.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httputil" 7 | "os" 8 | "strings" 9 | "sync" 10 | ) 11 | 12 | type ReplyStrategy interface { 13 | HandleRequest(w http.ResponseWriter, r *http.Request) []error 14 | } 15 | 16 | type contextAwareStrategy interface { 17 | ResetRunningContext() 18 | EndRunningContext() []error 19 | } 20 | 21 | type constantReply struct { 22 | replyBody []byte 23 | statusCode int 24 | headers map[string]string 25 | } 26 | 27 | func unhandledRequestError(r *http.Request) []error { 28 | requestContent, err := httputil.DumpRequest(r, true) 29 | if err != nil { 30 | return []error{fmt.Errorf("Gonkey internal error during request dump: %s\n", err)} 31 | } 32 | return []error{fmt.Errorf("unhandled request to mock:\n%s", requestContent)} 33 | } 34 | 35 | func NewFileReplyWithCode(filename string, statusCode int, headers map[string]string) (ReplyStrategy, error) { 36 | content, err := os.ReadFile(filename) 37 | if err != nil { 38 | return nil, err 39 | } 40 | r := &constantReply{ 41 | replyBody: content, 42 | statusCode: statusCode, 43 | headers: headers, 44 | } 45 | return r, nil 46 | } 47 | 48 | func NewConstantReplyWithCode(content []byte, statusCode int, headers map[string]string) ReplyStrategy { 49 | return &constantReply{ 50 | replyBody: content, 51 | statusCode: statusCode, 52 | headers: headers, 53 | } 54 | } 55 | 56 | func (s *constantReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 57 | for k, v := range s.headers { 58 | w.Header().Add(k, v) 59 | } 60 | w.WriteHeader(s.statusCode) 61 | w.Write(s.replyBody) 62 | return nil 63 | } 64 | 65 | type dropRequestReply struct { 66 | } 67 | 68 | func NewDropRequestReply() ReplyStrategy { 69 | return &dropRequestReply{} 70 | } 71 | 72 | func (s *dropRequestReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 73 | hj, ok := w.(http.Hijacker) 74 | if !ok { 75 | return []error{fmt.Errorf("Gonkey internal error during drop request: webserver doesn't support hijacking\n")} 76 | } 77 | conn, _, err := hj.Hijack() 78 | if err != nil { 79 | return []error{fmt.Errorf("Gonkey internal error during connection hijacking: %s\n", err)} 80 | } 81 | conn.Close() 82 | return nil 83 | } 84 | 85 | type failReply struct{} 86 | 87 | func (s *failReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 88 | return unhandledRequestError(r) 89 | } 90 | 91 | type nopReply struct{} 92 | 93 | func (s *nopReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 94 | w.WriteHeader(http.StatusNoContent) 95 | return nil 96 | } 97 | 98 | type uriVaryReply struct { 99 | basePath string 100 | variants map[string]*Definition 101 | } 102 | 103 | func NewUriVaryReply(basePath string, variants map[string]*Definition) ReplyStrategy { 104 | return &uriVaryReply{ 105 | basePath: strings.TrimRight(basePath, "/") + "/", 106 | variants: variants, 107 | } 108 | } 109 | 110 | func (s *uriVaryReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 111 | for uri, def := range s.variants { 112 | uri = strings.TrimLeft(uri, "/") 113 | if s.basePath+uri == r.URL.Path { 114 | return def.Execute(w, r) 115 | } 116 | } 117 | return unhandledRequestError(r) 118 | } 119 | 120 | func (s *uriVaryReply) ResetRunningContext() { 121 | for _, def := range s.variants { 122 | def.ResetRunningContext() 123 | } 124 | } 125 | 126 | func (s *uriVaryReply) EndRunningContext() []error { 127 | var errs []error 128 | for _, def := range s.variants { 129 | errs = append(errs, def.EndRunningContext()...) 130 | } 131 | return errs 132 | } 133 | 134 | type methodVaryReply struct { 135 | variants map[string]*Definition 136 | } 137 | 138 | func NewMethodVaryReply(variants map[string]*Definition) ReplyStrategy { 139 | return &methodVaryReply{ 140 | variants: variants, 141 | } 142 | } 143 | 144 | func (s *methodVaryReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 145 | for method, def := range s.variants { 146 | if strings.EqualFold(r.Method, method) { 147 | return def.Execute(w, r) 148 | } 149 | } 150 | return unhandledRequestError(r) 151 | } 152 | 153 | func (s *methodVaryReply) ResetRunningContext() { 154 | for _, def := range s.variants { 155 | def.ResetRunningContext() 156 | } 157 | } 158 | 159 | func (s *methodVaryReply) EndRunningContext() []error { 160 | var errs []error 161 | for _, def := range s.variants { 162 | errs = append(errs, def.EndRunningContext()...) 163 | } 164 | return errs 165 | } 166 | 167 | func NewSequentialReply(strategies []*Definition) ReplyStrategy { 168 | return &sequentialReply{ 169 | sequence: strategies, 170 | } 171 | } 172 | 173 | type sequentialReply struct { 174 | sync.Mutex 175 | count int 176 | sequence []*Definition 177 | } 178 | 179 | func (s *sequentialReply) ResetRunningContext() { 180 | s.Lock() 181 | s.count = 0 182 | s.Unlock() 183 | for _, def := range s.sequence { 184 | def.ResetRunningContext() 185 | } 186 | } 187 | 188 | func (s *sequentialReply) EndRunningContext() []error { 189 | var errs []error 190 | for _, def := range s.sequence { 191 | errs = append(errs, def.EndRunningContext()...) 192 | } 193 | return errs 194 | } 195 | 196 | func (s *sequentialReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 197 | s.Lock() 198 | defer s.Unlock() 199 | // out of bounds, url requested more times than sequence length 200 | if s.count >= len(s.sequence) { 201 | return unhandledRequestError(r) 202 | } 203 | def := s.sequence[s.count] 204 | s.count++ 205 | return def.Execute(w, r) 206 | } 207 | 208 | type basedOnRequestReply struct { 209 | sync.Mutex 210 | variants []*Definition 211 | } 212 | 213 | func newBasedOnRequestReply(variants []*Definition) ReplyStrategy { 214 | return &basedOnRequestReply{ 215 | variants: variants, 216 | } 217 | } 218 | 219 | func (s *basedOnRequestReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 220 | s.Lock() 221 | defer s.Unlock() 222 | 223 | var errors []error 224 | for _, def := range s.variants { 225 | errs := verifyRequestConstraints(def.requestConstraints, r) 226 | if errs == nil { 227 | return def.ExecuteWithoutVerifying(w, r) 228 | } 229 | errors = append(errors, errs...) 230 | } 231 | return append(errors, unhandledRequestError(r)...) 232 | } 233 | 234 | func (s *basedOnRequestReply) ResetRunningContext() { 235 | for _, def := range s.variants { 236 | def.ResetRunningContext() 237 | } 238 | } 239 | 240 | func (s *basedOnRequestReply) EndRunningContext() []error { 241 | var errs []error 242 | for _, def := range s.variants { 243 | errs = append(errs, def.EndRunningContext()...) 244 | } 245 | return errs 246 | } 247 | -------------------------------------------------------------------------------- /mocks/service_mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "sync" 8 | ) 9 | 10 | type ServiceMock struct { 11 | server *http.Server 12 | listener net.Listener 13 | mock *Definition 14 | defaultDefinition *Definition 15 | sync.RWMutex 16 | errors []error 17 | 18 | ServiceName string 19 | } 20 | 21 | func NewServiceMock(serviceName string, mock *Definition) *ServiceMock { 22 | return &ServiceMock{ 23 | mock: mock, 24 | defaultDefinition: mock, 25 | ServiceName: serviceName, 26 | } 27 | } 28 | 29 | func (m *ServiceMock) StartServer() error { 30 | return m.StartServerWithAddr("localhost:0") // loopback, random port 31 | } 32 | 33 | func (m *ServiceMock) StartServerWithAddr(addr string) error { 34 | ln, err := net.Listen("tcp", addr) 35 | if err != nil { 36 | return err 37 | } 38 | m.listener = ln 39 | m.server = &http.Server{Addr: addr, Handler: m} 40 | go m.server.Serve(ln) 41 | return nil 42 | } 43 | 44 | func (m *ServiceMock) ShutdownServer(ctx context.Context) error { 45 | err := m.server.Shutdown(ctx) 46 | m.listener = nil 47 | m.server = nil 48 | return err 49 | } 50 | 51 | func (m *ServiceMock) ServerAddr() string { 52 | if m.listener == nil { 53 | panic("mock server " + m.ServiceName + " is not started") 54 | } 55 | return m.listener.Addr().String() 56 | } 57 | 58 | func (m *ServiceMock) ServeHTTP(w http.ResponseWriter, r *http.Request) { 59 | m.Lock() 60 | defer m.Unlock() 61 | 62 | if m.mock != nil { 63 | errs := m.mock.Execute(w, r) 64 | m.errors = append(m.errors, errs...) 65 | } 66 | } 67 | 68 | func (m *ServiceMock) SetDefinition(newDefinition *Definition) { 69 | m.Lock() 70 | defer m.Unlock() 71 | m.mock = newDefinition 72 | } 73 | 74 | func (m *ServiceMock) ResetDefinition() { 75 | m.Lock() 76 | defer m.Unlock() 77 | m.mock = m.defaultDefinition 78 | } 79 | 80 | func (m *ServiceMock) ResetRunningContext() { 81 | m.Lock() 82 | defer m.Unlock() 83 | m.errors = nil 84 | m.mock.ResetRunningContext() 85 | } 86 | 87 | func (m *ServiceMock) EndRunningContext() []error { 88 | m.RLock() 89 | defer m.RUnlock() 90 | 91 | errs := append(m.errors, m.mock.EndRunningContext()...) 92 | for i, e := range errs { 93 | errs[i] = &Error{ 94 | error: e, 95 | ServiceName: m.ServiceName, 96 | } 97 | } 98 | return errs 99 | } 100 | -------------------------------------------------------------------------------- /mocks/template_reply_strategy.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "sync" 9 | "text/template" 10 | ) 11 | 12 | type templateReply struct { 13 | replyBodyTemplate *template.Template 14 | statusCode int 15 | headers map[string]string 16 | } 17 | 18 | type templateRequest struct { 19 | r *http.Request 20 | 21 | jsonOnce sync.Once 22 | jsonData map[string]interface{} 23 | } 24 | 25 | func (tr *templateRequest) Query(key string) string { 26 | return tr.r.URL.Query().Get(key) 27 | } 28 | 29 | func (tr *templateRequest) Json() (map[string]interface{}, error) { 30 | var err error 31 | tr.jsonOnce.Do(func() { 32 | err = json.NewDecoder(tr.r.Body).Decode(&tr.jsonData) 33 | }) 34 | 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to parse request as Json: %w", err) 37 | } 38 | 39 | return tr.jsonData, nil 40 | } 41 | 42 | func newTemplateReply(content string, statusCode int, headers map[string]string) (ReplyStrategy, error) { 43 | res, err := template.New("").Parse(content) 44 | if err != nil { 45 | return nil, fmt.Errorf("template syntax error: %w", err) 46 | } 47 | 48 | strategy := &templateReply{ 49 | replyBodyTemplate: res, 50 | statusCode: statusCode, 51 | headers: headers, 52 | } 53 | 54 | return strategy, nil 55 | } 56 | 57 | func (s *templateReply) executeResponseTemplate(r *http.Request) (string, error) { 58 | ctx := map[string]*templateRequest{ 59 | "request": {r: r}, 60 | } 61 | 62 | reply := bytes.NewBuffer(nil) 63 | if err := s.replyBodyTemplate.Execute(reply, ctx); err != nil { 64 | return "", fmt.Errorf("template mock error: %w", err) 65 | } 66 | 67 | return reply.String(), nil 68 | } 69 | 70 | func (s *templateReply) HandleRequest(w http.ResponseWriter, r *http.Request) []error { 71 | for k, v := range s.headers { 72 | w.Header().Add(k, v) 73 | } 74 | 75 | responseBody, err := s.executeResponseTemplate(r) 76 | if err != nil { 77 | return []error{err} 78 | } 79 | 80 | w.WriteHeader(s.statusCode) 81 | w.Write([]byte(responseBody)) // nolint:errcheck 82 | 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /models/result.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "errors" 4 | 5 | type DatabaseResult struct { 6 | Query string 7 | Response []string 8 | } 9 | 10 | // Result of test execution 11 | type Result struct { 12 | Path string // TODO: remove 13 | Query string // TODO: remove 14 | RequestBody string 15 | ResponseStatusCode int 16 | ResponseStatus string 17 | ResponseContentType string 18 | ResponseBody string 19 | ResponseHeaders map[string][]string 20 | Errors []error 21 | Test TestInterface 22 | DatabaseResult []DatabaseResult 23 | } 24 | 25 | func allureStatus(status string) bool { 26 | switch status { 27 | case "passed", "failed", "broken", "skipped": 28 | return true 29 | default: 30 | return false 31 | } 32 | } 33 | 34 | func notRunnedStatus(status string) bool { 35 | switch status { 36 | case "broken", "skipped": 37 | return true 38 | default: 39 | return false 40 | } 41 | } 42 | 43 | func (r *Result) AllureStatus() (string, error) { 44 | testStatus := r.Test.GetStatus() 45 | if testStatus != "" && allureStatus(testStatus) && notRunnedStatus(testStatus) { 46 | return testStatus, nil 47 | } 48 | 49 | var ( 50 | status = "passed" 51 | testErrors []error 52 | ) 53 | 54 | if len(r.Errors) != 0 { 55 | status = "failed" 56 | testErrors = r.Errors 57 | } 58 | 59 | if len(testErrors) != 0 { 60 | errText := "" 61 | for _, err := range testErrors { 62 | errText = errText + err.Error() + "\n" 63 | } 64 | 65 | return status, errors.New(errText) 66 | } 67 | 68 | return status, nil 69 | } 70 | 71 | // Passed returns true if test passed (false otherwise) 72 | func (r *Result) Passed() bool { 73 | return len(r.Errors) == 0 74 | } 75 | -------------------------------------------------------------------------------- /models/test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type DatabaseCheck interface { 4 | DbNameString() string 5 | DbQueryString() string 6 | DbResponseJson() []string 7 | 8 | SetDbQueryString(string) 9 | SetDbResponseJson([]string) 10 | } 11 | 12 | // Common Test interface 13 | type TestInterface interface { 14 | ToQuery() string 15 | GetRequest() string 16 | ToJSON() ([]byte, error) 17 | GetMethod() string 18 | Path() string 19 | GetResponses() map[int]string 20 | GetResponse(code int) (string, bool) 21 | GetResponseHeaders(code int) (map[string]string, bool) 22 | GetName() string 23 | GetDescription() string 24 | GetStatus() string 25 | SetStatus(string) 26 | Fixtures() []string 27 | FixturesMultiDb() FixturesMultiDb 28 | ServiceMocks() map[string]interface{} 29 | Pause() int 30 | BeforeScriptPath() string 31 | BeforeScriptTimeout() int 32 | AfterRequestScriptPath() string 33 | AfterRequestScriptTimeout() int 34 | Cookies() map[string]string 35 | Headers() map[string]string 36 | ContentType() string 37 | GetForm() *Form 38 | DbNameString() string 39 | DbQueryString() string 40 | DbResponseJson() []string 41 | GetVariables() map[string]string 42 | GetCombinedVariables() map[string]string 43 | GetVariablesToSet() map[int]map[string]string 44 | GetDatabaseChecks() []DatabaseCheck 45 | SetDatabaseChecks([]DatabaseCheck) 46 | 47 | GetFileName() string 48 | 49 | // setters 50 | SetQuery(string) 51 | SetMethod(string) 52 | SetPath(string) 53 | SetRequest(string) 54 | SetForm(form *Form) 55 | SetResponses(map[int]string) 56 | SetHeaders(map[string]string) 57 | SetDbQueryString(string) 58 | SetDbResponseJson([]string) 59 | 60 | // comparison properties 61 | NeedsCheckingValues() bool 62 | IgnoreArraysOrdering() bool 63 | DisallowExtraFields() bool 64 | IgnoreDbOrdering() bool 65 | 66 | // Clone returns copy of current object 67 | Clone() TestInterface 68 | } 69 | 70 | type Form struct { 71 | Files map[string]string `json:"files" yaml:"files"` 72 | Fields map[string]string `json:"fields" yaml:"fields"` 73 | } 74 | 75 | type Summary struct { 76 | Success bool 77 | Failed int 78 | Skipped int 79 | Broken int 80 | Total int 81 | } 82 | 83 | type Fixture struct { 84 | DbName string `json:"dbName" yaml:"dbName"` 85 | Files []string `json:"files" yaml:"files"` 86 | } 87 | 88 | type FixturesMultiDb []Fixture 89 | -------------------------------------------------------------------------------- /output/allure_report/allure.go: -------------------------------------------------------------------------------- 1 | package allure_report 2 | 3 | import ( 4 | "bytes" 5 | "encoding/xml" 6 | "errors" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | 13 | "github.com/lamoda/gonkey/output/allure_report/beans" 14 | ) 15 | 16 | type Allure struct { 17 | Suites []*beans.Suite 18 | TargetDir string 19 | } 20 | 21 | func New(suites []*beans.Suite) *Allure { 22 | return &Allure{Suites: suites, TargetDir: "allure-results"} 23 | } 24 | 25 | func (a *Allure) GetCurrentSuite() *beans.Suite { 26 | return a.Suites[0] 27 | } 28 | 29 | func (a *Allure) StartSuite(name string, start time.Time) { 30 | a.Suites = append(a.Suites, beans.NewSuite(name, start)) 31 | } 32 | 33 | func (a *Allure) EndSuite(end time.Time) error { 34 | suite := a.GetCurrentSuite() 35 | suite.SetEnd(end) 36 | if suite.HasTests() { 37 | if err := writeSuite(a.TargetDir, suite); err != nil { 38 | return err 39 | } 40 | } 41 | // remove first/current suite 42 | a.Suites = a.Suites[1:] 43 | 44 | return nil 45 | } 46 | 47 | var ( 48 | currentState = map[*beans.Suite]*beans.TestCase{} 49 | currentStep = map[*beans.Suite]*beans.Step{} 50 | ) 51 | 52 | func (a *Allure) StartCase(testName string, start time.Time) *beans.TestCase { 53 | test := beans.NewTestCase(testName, start) 54 | step := beans.NewStep(testName, start) 55 | suite := a.GetCurrentSuite() 56 | currentState[suite] = test 57 | currentStep[suite] = step 58 | suite.AddTest(test) 59 | 60 | return test 61 | } 62 | 63 | func (a *Allure) EndCase(status string, err error, end time.Time) { 64 | suite := a.GetCurrentSuite() 65 | test, ok := currentState[suite] 66 | if ok { 67 | test.End(status, err, end) 68 | } 69 | } 70 | 71 | func (a *Allure) CreateStep(name string, stepFunc func()) { 72 | status := `passed` 73 | a.StartStep(name, time.Now()) 74 | // if test error 75 | stepFunc() 76 | // end 77 | a.EndStep(status, time.Now()) 78 | } 79 | 80 | func (a *Allure) StartStep(stepName string, start time.Time) { 81 | var ( 82 | // FIXME: step is overwritten below 83 | step = beans.NewStep(stepName, start) 84 | suite = a.GetCurrentSuite() 85 | ) 86 | step = currentStep[suite] 87 | step.Parent.AddStep(step) 88 | currentStep[suite] = step 89 | } 90 | 91 | func (a *Allure) EndStep(status string, end time.Time) { 92 | suite := a.GetCurrentSuite() 93 | currentStep[suite].End(status, end) 94 | currentStep[suite] = currentStep[suite].Parent 95 | } 96 | 97 | func (a *Allure) AddAttachment(attachmentName, buf bytes.Buffer, typ string) { 98 | mime, ext := getBufferInfo(buf, typ) 99 | name, _ := writeBuffer(a.TargetDir, buf, ext) 100 | currentState[a.GetCurrentSuite()].AddAttachment(beans.NewAttachment( 101 | attachmentName.String(), 102 | mime, 103 | name, 104 | buf.Len())) 105 | } 106 | 107 | func (a *Allure) PendingCase(testName string, start time.Time) { 108 | a.StartCase(testName, start) 109 | a.EndCase("pending", errors.New("test ignored"), start) 110 | } 111 | 112 | // utils 113 | func getBufferInfo(buf bytes.Buffer, typ string) (string, string) { 114 | // exts,err := mime.ExtensionsByType(typ) 115 | // if err != nil { 116 | // mime.ParseMediaType() 117 | // } 118 | return "text/plain", "txt" 119 | } 120 | 121 | func writeBuffer(pathDir string, buf bytes.Buffer, ext string) (string, error) { 122 | fileName := uuid.New().String() + `-attachment.` + ext 123 | err := os.WriteFile(filepath.Join(pathDir, fileName), buf.Bytes(), 0o644) 124 | 125 | return fileName, err 126 | } 127 | 128 | func writeSuite(pathDir string, suite *beans.Suite) error { 129 | b, err := xml.Marshal(suite) 130 | if err != nil { 131 | return err 132 | } 133 | err = os.WriteFile(filepath.Join(pathDir, uuid.New().String()+`-testsuite.xml`), b, 0o644) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /output/allure_report/allure_report.go: -------------------------------------------------------------------------------- 1 | package allure_report 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/lamoda/gonkey/models" 11 | ) 12 | 13 | type AllureReportOutput struct { 14 | reportLocation string 15 | allure Allure 16 | } 17 | 18 | func NewOutput(suiteName, reportLocation string) *AllureReportOutput { 19 | resultsDir, _ := filepath.Abs(reportLocation) 20 | _ = os.Mkdir(resultsDir, 0o777) 21 | a := Allure{ 22 | Suites: nil, 23 | TargetDir: resultsDir, 24 | } 25 | a.StartSuite(suiteName, time.Now()) 26 | 27 | return &AllureReportOutput{ 28 | reportLocation: reportLocation, 29 | allure: a, 30 | } 31 | } 32 | 33 | func (o *AllureReportOutput) Process(t models.TestInterface, result *models.Result) error { 34 | testCase := o.allure.StartCase(t.GetName(), time.Now()) 35 | testCase.SetDescriptionOrDefaultValue(t.GetDescription(), "No description") 36 | testCase.AddLabel("story", result.Path) 37 | 38 | o.allure.AddAttachment( 39 | *bytes.NewBufferString("Request"), 40 | *bytes.NewBufferString(fmt.Sprintf(`Query: %s \n Body: %s`, result.Query, result.RequestBody)), 41 | "txt") 42 | o.allure.AddAttachment( 43 | *bytes.NewBufferString("Response"), 44 | *bytes.NewBufferString(fmt.Sprintf(`Body: %s`, result.ResponseBody)), 45 | "txt") 46 | 47 | for i, dbresult := range result.DatabaseResult { 48 | if dbresult.Query != "" { 49 | o.allure.AddAttachment( 50 | *bytes.NewBufferString(fmt.Sprintf("Db Query #%d", i+1)), 51 | *bytes.NewBufferString(fmt.Sprintf(`SQL string: %s`, dbresult.Query)), 52 | "txt") 53 | o.allure.AddAttachment( 54 | *bytes.NewBufferString(fmt.Sprintf("Db Response #%d", i+1)), 55 | *bytes.NewBufferString(fmt.Sprintf(`Response: %s`, dbresult.Response)), 56 | "txt") 57 | } 58 | } 59 | 60 | status, err := result.AllureStatus() 61 | o.allure.EndCase(status, err, time.Now()) 62 | 63 | return nil 64 | } 65 | 66 | func (o *AllureReportOutput) Finalize() { 67 | _ = o.allure.EndSuite(time.Now()) 68 | } 69 | -------------------------------------------------------------------------------- /output/allure_report/beans/attachment.go: -------------------------------------------------------------------------------- 1 | package beans 2 | 3 | func NewAttachment(title, mime, source string, size int) *Attachment { 4 | return &Attachment{ 5 | Title: title, 6 | Type: mime, 7 | Source: source, 8 | Size: size, 9 | } 10 | } 11 | 12 | type Attachment struct { 13 | Title string `xml:"title,attr"` 14 | Type string `xml:"type,attr"` 15 | Size int `xml:"size,attr"` 16 | Source string `xml:"source,attr"` 17 | } 18 | -------------------------------------------------------------------------------- /output/allure_report/beans/step.go: -------------------------------------------------------------------------------- 1 | package beans 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func NewStep(name string, start time.Time) *Step { 8 | test := new(Step) 9 | test.Name = name 10 | 11 | if !start.IsZero() { 12 | test.Start = start.UnixNano() / 1000 13 | } else { 14 | test.Start = time.Now().UnixNano() / 1000 15 | } 16 | 17 | return test 18 | } 19 | 20 | type Step struct { 21 | Parent *Step `xml:"-"` 22 | 23 | Status string `xml:"status,attr"` 24 | Start int64 `xml:"start,attr"` 25 | Stop int64 `xml:"stop,attr"` 26 | Name string `xml:"name"` 27 | Steps []*Step `xml:"steps"` 28 | Attachments []*Attachment `xml:"attachments"` 29 | } 30 | 31 | func (s *Step) End(status string, end time.Time) { 32 | if !end.IsZero() { 33 | s.Stop = end.UnixNano() / 1000 34 | } else { 35 | s.Stop = time.Now().UnixNano() / 1000 36 | } 37 | s.Status = status 38 | } 39 | 40 | func (s *Step) AddStep(step *Step) { 41 | if step != nil { 42 | s.Steps = append(s.Steps, step) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /output/allure_report/beans/suite.go: -------------------------------------------------------------------------------- 1 | package beans 2 | 3 | import ( 4 | "encoding/xml" 5 | "time" 6 | ) 7 | 8 | const NsModel = `urn:model.allure.qatools.yandex.ru` 9 | 10 | type Suite struct { 11 | XMLName xml.Name `xml:"ns2:test-suite"` 12 | NsAttr string `xml:"xmlns:ns2,attr"` 13 | Start int64 `xml:"start,attr"` 14 | End int64 `xml:"stop,attr"` 15 | Name string `xml:"name"` 16 | Title string `xml:"title"` 17 | TestCases struct { 18 | Cases []*TestCase `xml:"test-case"` 19 | } `xml:"test-cases"` 20 | } 21 | 22 | func NewSuite(name string, start time.Time) *Suite { 23 | s := new(Suite) 24 | 25 | s.NsAttr = NsModel 26 | s.Name = name 27 | s.Title = name 28 | 29 | if !start.IsZero() { 30 | s.Start = start.UTC().UnixNano() / 1000 31 | } else { 32 | s.Start = time.Now().UTC().UnixNano() / 1000 33 | } 34 | 35 | return s 36 | } 37 | 38 | // SetEnd set end time for suite 39 | func (s *Suite) SetEnd(endTime time.Time) { 40 | if !endTime.IsZero() { 41 | // strict UTC 42 | s.End = endTime.UTC().UnixNano() / 1000 43 | } else { 44 | s.End = time.Now().UTC().UnixNano() / 1000 45 | } 46 | } 47 | 48 | // suite has test-cases? 49 | func (s Suite) HasTests() bool { 50 | return len(s.TestCases.Cases) > 0 51 | } 52 | 53 | // add test in suite 54 | func (s *Suite) AddTest(test *TestCase) { 55 | s.TestCases.Cases = append(s.TestCases.Cases, test) 56 | } 57 | -------------------------------------------------------------------------------- /output/allure_report/beans/test.go: -------------------------------------------------------------------------------- 1 | package beans 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | // start new test case 9 | func NewTestCase(name string, start time.Time) *TestCase { 10 | test := new(TestCase) 11 | test.Name = name 12 | 13 | if !start.IsZero() { 14 | test.Start = start.UnixNano() / 1000 15 | } else { 16 | test.Start = time.Now().UnixNano() / 1000 17 | } 18 | 19 | return test 20 | } 21 | 22 | type TestCase struct { 23 | Status string `xml:"status,attr"` 24 | Start int64 `xml:"start,attr"` 25 | Stop int64 `xml:"stop,attr"` 26 | Name string `xml:"name"` 27 | Steps struct { 28 | Steps []*Step `xml:"step"` 29 | } `xml:"steps"` 30 | Labels struct { 31 | Label []*Label `xml:"label"` 32 | } `xml:"labels"` 33 | Attachments struct { 34 | Attachment []*Attachment `xml:"attachment"` 35 | } `xml:"attachments"` 36 | Desc string `xml:"description"` 37 | Failure struct { 38 | Msg string `xml:"message"` 39 | Trace string `xml:"stack-trace"` 40 | } `xml:"failure,omitempty"` 41 | } 42 | 43 | type Label struct { 44 | Name string `xml:"name,attr"` 45 | Value string `xml:"value,attr"` 46 | } 47 | 48 | func (t *TestCase) SetDescription(desc string) { 49 | t.Desc = desc 50 | } 51 | 52 | func (t *TestCase) SetDescriptionOrDefaultValue(desc, defVal string) { 53 | if desc == "" { 54 | t.Desc = defVal 55 | } else { 56 | t.Desc = desc 57 | } 58 | } 59 | 60 | func (t *TestCase) AddLabel(name, value string) { 61 | t.addLabel(&Label{ 62 | Name: name, 63 | Value: value, 64 | }) 65 | } 66 | 67 | func (t *TestCase) AddStep(step *Step) { 68 | t.Steps.Steps = append(t.Steps.Steps, step) 69 | } 70 | 71 | func (t *TestCase) AddAttachment(attach *Attachment) { 72 | t.Attachments.Attachment = append(t.Attachments.Attachment, attach) 73 | } 74 | 75 | func (t *TestCase) End(status string, err error, end time.Time) { 76 | if !end.IsZero() { 77 | t.Stop = end.UnixNano() / 1000 78 | } else { 79 | t.Stop = time.Now().UnixNano() / 1000 80 | } 81 | t.Status = status 82 | if err != nil { 83 | msg := strings.Split(err.Error(), "\trace") 84 | t.Failure.Msg = msg[0] 85 | t.Failure.Trace = strings.Join(msg[1:], "\n") 86 | } 87 | } 88 | 89 | func (t *TestCase) addLabel(label *Label) { 90 | t.Labels.Label = append(t.Labels.Label, label) 91 | } 92 | -------------------------------------------------------------------------------- /output/console_colored/console_colored.go: -------------------------------------------------------------------------------- 1 | package console_colored 2 | 3 | import ( 4 | "bytes" 5 | "text/template" 6 | 7 | "github.com/fatih/color" 8 | 9 | "github.com/lamoda/gonkey/models" 10 | ) 11 | 12 | const dotsPerLine = 80 13 | 14 | type ConsoleColoredOutput struct { 15 | verbose bool 16 | dots int 17 | coloredPrintf func(format string, a ...interface{}) 18 | } 19 | 20 | func NewOutput(verbose bool) *ConsoleColoredOutput { 21 | return &ConsoleColoredOutput{ 22 | verbose: verbose, 23 | coloredPrintf: color.New().PrintfFunc(), 24 | } 25 | } 26 | 27 | func (o *ConsoleColoredOutput) Process(_ models.TestInterface, result *models.Result) error { 28 | if !result.Passed() || o.verbose { 29 | text, err := renderResult(result) 30 | if err != nil { 31 | return err 32 | } 33 | o.coloredPrintf("%s", text) 34 | } else { 35 | o.coloredPrintf(".") 36 | o.dots++ 37 | if o.dots%dotsPerLine == 0 { 38 | o.coloredPrintf("\n") 39 | } 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func renderResult(result *models.Result) (string, error) { 46 | text := ` 47 | Name: {{ green .Test.GetName }} 48 | Description: 49 | {{- if .Test.GetDescription }}{{ green .Test.GetDescription }}{{ else }}{{ green " No description" }}{{ end }} 50 | File: {{ green .Test.GetFileName }} 51 | 52 | Request: 53 | Method: {{ cyan .Test.GetMethod }} 54 | Path: {{ cyan .Test.Path }} 55 | Query: {{ cyan .Test.ToQuery }} 56 | {{- if .Test.Headers }} 57 | Headers: 58 | {{- range $key, $value := .Test.Headers }} 59 | {{ $key }}: {{ $value }} 60 | {{- end }} 61 | {{- end }} 62 | {{- if .Test.Cookies }} 63 | Cookies: 64 | {{- range $key, $value := .Test.Cookies }} 65 | {{ $key }}: {{ $value }} 66 | {{- end }} 67 | {{- end }} 68 | Body: 69 | {{ if .RequestBody }}{{ cyan .RequestBody }}{{ else }}{{ cyan "" }}{{ end }} 70 | 71 | Response: 72 | Status: {{ cyan .ResponseStatus }} 73 | Body: 74 | {{ if .ResponseBody }}{{ yellow .ResponseBody }}{{ else }}{{ yellow "" }}{{ end }} 75 | 76 | {{ range $i, $dbr := .DatabaseResult }} 77 | {{ if $dbr.Query }} 78 | Db Request #{{ inc $i }}: 79 | {{ cyan $dbr.Query }} 80 | Db Response #{{ inc $i }}: 81 | {{ range $value := $dbr.Response }} 82 | {{ yellow $value }}{{ end }} 83 | {{ end }} 84 | {{ end }} 85 | 86 | {{ if .Errors }} 87 | Result: {{ danger "ERRORS!" }} 88 | 89 | Errors: 90 | {{ range $i, $e := .Errors }} 91 | {{ inc $i }}) {{ $e.Error }} 92 | {{ end }} 93 | {{ else }} 94 | Result: {{ success "OK" }} 95 | {{ end }} 96 | ` 97 | 98 | var buffer bytes.Buffer 99 | t := template.Must(template.New("letter").Funcs(templateFuncMap()).Parse(text)) 100 | if err := t.Execute(&buffer, result); err != nil { 101 | return "", err 102 | } 103 | 104 | return buffer.String(), nil 105 | } 106 | 107 | func templateFuncMap() template.FuncMap { 108 | return template.FuncMap{ 109 | "green": color.GreenString, 110 | "cyan": color.CyanString, 111 | "yellow": color.YellowString, 112 | "danger": color.New(color.FgHiWhite, color.BgRed).Sprint, 113 | "success": color.New(color.FgHiWhite, color.BgGreen).Sprint, 114 | "inc": func(i int) int { return i + 1 }, 115 | } 116 | } 117 | 118 | func (o *ConsoleColoredOutput) ShowSummary(summary *models.Summary) { 119 | o.coloredPrintf( 120 | "\nsuccess %d, failed %d, skipped %d, broken %d, total %d\n", 121 | summary.Total-summary.Broken-summary.Failed-summary.Skipped, 122 | summary.Failed, 123 | summary.Skipped, 124 | summary.Broken, 125 | summary.Total, 126 | ) 127 | } 128 | -------------------------------------------------------------------------------- /output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "github.com/lamoda/gonkey/models" 5 | ) 6 | 7 | type OutputInterface interface { 8 | Process(models.TestInterface, *models.Result) error 9 | } 10 | -------------------------------------------------------------------------------- /output/testing/testing.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | 8 | "github.com/lamoda/gonkey/models" 9 | ) 10 | 11 | type Output struct{} 12 | 13 | func NewOutput() *Output { 14 | return &Output{} 15 | } 16 | 17 | func (o *Output) Process(_ models.TestInterface, result *models.Result) error { 18 | if !result.Passed() { 19 | text, err := renderResult(result) 20 | if err != nil { 21 | return err 22 | } 23 | fmt.Println(text) 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func renderResult(result *models.Result) (string, error) { 30 | text := ` 31 | Name: {{ .Test.GetName }} 32 | Description: 33 | {{- if .Test.GetDescription }}{{ .Test.GetDescription }}{{ else }}{{ " No description" }}{{ end }} 34 | File: {{ .Test.GetFileName }} 35 | 36 | Request: 37 | Method: {{ .Test.GetMethod }} 38 | Path: {{ .Test.Path }} 39 | Query: {{ .Test.ToQuery }} 40 | {{- if .Test.Headers }} 41 | Headers: 42 | {{- range $key, $value := .Test.Headers }} 43 | {{ $key }}: {{ $value }} 44 | {{- end }} 45 | {{- end }} 46 | {{- if .Test.Cookies }} 47 | Cookies: 48 | {{- range $key, $value := .Test.Cookies }} 49 | {{ $key }}: {{ $value }} 50 | {{- end }} 51 | {{- end }} 52 | Body: 53 | {{ if .RequestBody }}{{ .RequestBody }}{{ else }}{{ "" }}{{ end }} 54 | 55 | Response: 56 | Status: {{ .ResponseStatus }} 57 | Body: 58 | {{ if .ResponseBody }}{{ .ResponseBody }}{{ else }}{{ "" }}{{ end }} 59 | 60 | {{ range $i, $dbr := .DatabaseResult }} 61 | {{ if $dbr.Query }} 62 | Db Request #{{ inc $i }}: 63 | {{ $dbr.Query }} 64 | Db Response #{{ inc $i }}: 65 | {{ range $value := $dbr.Response }} 66 | {{ $value }}{{ end }} 67 | {{ end }} 68 | {{ end }} 69 | 70 | {{ if .Errors }} 71 | Result: {{ "ERRORS!" }} 72 | 73 | Errors: 74 | {{ range $i, $e := .Errors }} 75 | {{ inc $i }}) {{ $e.Error }} 76 | {{ end }} 77 | {{ else }} 78 | Result: {{ "OK" }} 79 | {{ end }} 80 | ` 81 | 82 | funcMap := template.FuncMap{ 83 | "inc": func(i int) int { return i + 1 }, 84 | } 85 | 86 | var buffer bytes.Buffer 87 | t := template.Must(template.New("letter").Funcs(funcMap).Parse(text)) 88 | if err := t.Execute(&buffer, result); err != nil { 89 | return "", err 90 | } 91 | 92 | return buffer.String(), nil 93 | } 94 | -------------------------------------------------------------------------------- /runner/console_handler.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/lamoda/gonkey/models" 7 | ) 8 | 9 | type ConsoleHandler struct { 10 | totalTests int 11 | failedTests int 12 | skippedTests int 13 | brokenTests int 14 | } 15 | 16 | func NewConsoleHandler() *ConsoleHandler { 17 | return &ConsoleHandler{} 18 | } 19 | 20 | func (h *ConsoleHandler) HandleTest(test models.TestInterface, executeTest testExecutor) error { 21 | testResult, err := executeTest(test) 22 | switch { 23 | case err != nil && errors.Is(err, errTestSkipped): 24 | h.skippedTests++ 25 | case err != nil && errors.Is(err, errTestBroken): 26 | h.brokenTests++ 27 | case err != nil: 28 | return err 29 | } 30 | 31 | h.totalTests++ 32 | if testResult != nil && !testResult.Passed() { 33 | h.failedTests++ 34 | } 35 | 36 | return nil 37 | } 38 | 39 | func (h *ConsoleHandler) Summary() *models.Summary { 40 | return &models.Summary{ 41 | Success: h.failedTests == 0, 42 | Skipped: h.skippedTests, 43 | Broken: h.brokenTests, 44 | Failed: h.failedTests, 45 | Total: h.totalTests, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /runner/console_handler_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/lamoda/gonkey/models" 10 | ) 11 | 12 | func TestConsoleHandler_HandleTest_Summary(t *testing.T) { 13 | type result struct { 14 | result *models.Result 15 | err error 16 | } 17 | tests := []struct { 18 | name string 19 | testExecResults []result 20 | wantErr bool 21 | expectedSummary *models.Summary 22 | }{ 23 | { 24 | name: "1 successful test", 25 | testExecResults: []result{{result: &models.Result{Errors: nil}}}, 26 | expectedSummary: &models.Summary{ 27 | Success: true, 28 | Failed: 0, 29 | Total: 1, 30 | }, 31 | }, 32 | { 33 | name: "1 successful test, 1 broken test", 34 | testExecResults: []result{ 35 | {result: &models.Result{}, err: nil}, 36 | {result: &models.Result{}, err: errTestBroken}, 37 | }, 38 | expectedSummary: &models.Summary{ 39 | Success: true, 40 | Failed: 0, 41 | Broken: 1, 42 | Total: 2, 43 | }, 44 | }, 45 | { 46 | name: "1 successful test, 1 skipped test", 47 | testExecResults: []result{ 48 | {result: &models.Result{}, err: nil}, 49 | {result: &models.Result{}, err: errTestSkipped}, 50 | }, 51 | expectedSummary: &models.Summary{ 52 | Success: true, 53 | Skipped: 1, 54 | Total: 2, 55 | }, 56 | }, 57 | { 58 | name: "1 successful test, 1 failed test", 59 | testExecResults: []result{ 60 | {result: &models.Result{}, err: nil}, 61 | {result: &models.Result{Errors: []error{errors.New("some err")}}, err: nil}, 62 | }, 63 | expectedSummary: &models.Summary{ 64 | Success: false, 65 | Failed: 1, 66 | Total: 2, 67 | }, 68 | }, 69 | { 70 | name: "test with unexpected error", 71 | testExecResults: []result{ 72 | {result: &models.Result{}, err: errors.New("unexpected error")}, 73 | }, 74 | wantErr: true, 75 | }, 76 | } 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | h := NewConsoleHandler() 80 | 81 | for _, execResult := range tt.testExecResults { 82 | executor := func(models.TestInterface) (*models.Result, error) { 83 | return execResult.result, execResult.err 84 | } 85 | err := h.HandleTest(nil, executor) 86 | if tt.wantErr { 87 | require.Error(t, err) 88 | return 89 | } 90 | 91 | require.NoError(t, err) 92 | } 93 | 94 | require.Equal(t, tt.expectedSummary, h.Summary()) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /runner/previous_result_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | const ( 12 | body = "bla" 13 | ) 14 | 15 | func Test_PreviousResult(t *testing.T) { 16 | 17 | srv := testServer() 18 | defer srv.Close() 19 | 20 | RunWithTesting(t, &RunWithTestingParams{ 21 | Server: srv, 22 | TestsDir: filepath.Join("testdata", "previous-result"), 23 | }) 24 | } 25 | 26 | func testServer() *httptest.Server { 27 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | 29 | resp := []byte(body) 30 | 31 | type nestedInfo struct { 32 | NestedField1 string `json:"nested_field_1"` 33 | NestedField2 string `json:"nested_field_2"` 34 | } 35 | 36 | if r.URL.Path == "/some/path/json" { 37 | w.Header().Set("Content-Type", "application/json") 38 | 39 | resp, _ = json.Marshal(&struct { 40 | Status string `json:"status"` 41 | Other string `json:"other"` 42 | UnusedField string `json:"unused_field"` 43 | NestedInfo nestedInfo `json:"nested_info"` 44 | }{ 45 | Status: "status_val", 46 | Other: "some_info", 47 | UnusedField: "useless_info", 48 | NestedInfo: nestedInfo{ 49 | NestedField1: "nested_val1", 50 | NestedField2: "nested_val2", 51 | }, 52 | }) 53 | } 54 | 55 | _, _ = w.Write(resp) 56 | 57 | })) 58 | } 59 | -------------------------------------------------------------------------------- /runner/request.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "mime" 9 | "mime/multipart" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "path/filepath" 14 | "slices" 15 | "strings" 16 | 17 | "github.com/lamoda/gonkey/models" 18 | ) 19 | 20 | func newClient(proxyURL *url.URL) *http.Client { 21 | transport := &http.Transport{ 22 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // Client is only used for testing. 23 | Proxy: http.ProxyURL(proxyURL), 24 | } 25 | 26 | return &http.Client{ 27 | Transport: transport, 28 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 29 | return http.ErrUseLastResponse 30 | }, 31 | } 32 | } 33 | 34 | func newRequest(host string, test models.TestInterface) (req *http.Request, err error) { 35 | if test.GetForm() != nil { 36 | req, err = newMultipartRequest(host, test) 37 | if err != nil { 38 | return nil, err 39 | } 40 | } else { 41 | req, err = newCommonRequest(host, test) 42 | if err != nil { 43 | return nil, err 44 | } 45 | } 46 | 47 | for k, v := range test.Cookies() { 48 | req.AddCookie(&http.Cookie{Name: k, Value: v}) 49 | } 50 | 51 | return req, nil 52 | } 53 | 54 | func newMultipartRequest(host string, test models.TestInterface) (*http.Request, error) { 55 | var boundary string 56 | 57 | if test.ContentType() != "" { 58 | contentType, params, err := mime.ParseMediaType(test.ContentType()) 59 | if err != nil { 60 | return nil, err 61 | } 62 | if contentType != "multipart/form-data" { 63 | return nil, fmt.Errorf( 64 | "test has unexpected Content-Type: %s, expected: multipart/form-data", 65 | test.ContentType(), 66 | ) 67 | } 68 | 69 | if b, ok := params["boundary"]; ok { 70 | boundary = b 71 | } 72 | } 73 | 74 | var b bytes.Buffer 75 | w := multipart.NewWriter(&b) 76 | 77 | if boundary != "" { 78 | if err := w.SetBoundary(boundary); err != nil { 79 | return nil, fmt.Errorf("SetBoundary : %w", err) 80 | } 81 | } 82 | 83 | err := addFields(test.GetForm().Fields, w) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | err = addFiles(test.GetForm().Files, w) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | _ = w.Close() 94 | 95 | req, err := request(test, &b, host) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | // this is necessary, it will contain boundary 101 | req.Header.Set("Content-Type", w.FormDataContentType()) 102 | 103 | return req, nil 104 | } 105 | 106 | func addFiles(files map[string]string, w *multipart.Writer) error { 107 | for name, path := range files { 108 | err := addFile(path, w, name) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | } 114 | 115 | return nil 116 | } 117 | func addFields(fields map[string]string, w *multipart.Writer) error { 118 | // TODO: sort fields better 119 | fieldNames := make([]string, 0, len(fields)) 120 | for n, _ := range fields { 121 | fieldNames = append(fieldNames, n) 122 | } 123 | slices.Sort(fieldNames) 124 | 125 | for _, name := range fieldNames { 126 | n := name 127 | v := fields[n] 128 | if err := w.WriteField(n, v); err != nil { 129 | return err 130 | } 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func addFile(path string, w *multipart.Writer, name string) error { 137 | f, err := os.Open(path) 138 | if err != nil { 139 | return err 140 | } 141 | defer func() { _ = f.Close() }() 142 | 143 | fw, err := w.CreateFormFile(name, filepath.Base(f.Name())) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | if _, err = io.Copy(fw, f); err != nil { 149 | return err 150 | } 151 | 152 | return nil 153 | } 154 | 155 | func newCommonRequest(host string, test models.TestInterface) (*http.Request, error) { 156 | body, err := test.ToJSON() 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | req, err := request(test, bytes.NewBuffer(body), host) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | if req.Header.Get("Content-Type") == "" { 167 | req.Header.Set("Content-Type", "application/json") 168 | } 169 | 170 | return req, nil 171 | } 172 | 173 | func request(test models.TestInterface, b *bytes.Buffer, host string) (*http.Request, error) { 174 | req, err := http.NewRequest( 175 | strings.ToUpper(test.GetMethod()), 176 | host+test.Path()+test.ToQuery(), 177 | b, 178 | ) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | for k, v := range test.Headers() { 184 | if strings.EqualFold(k, "host") { 185 | req.Host = v 186 | } else { 187 | req.Header.Add(k, v) 188 | } 189 | } 190 | 191 | return req, nil 192 | } 193 | 194 | func actualRequestBody(req *http.Request) string { 195 | if req.Body != nil { 196 | reqBodyStream, _ := req.GetBody() 197 | reqBody, _ := io.ReadAll(reqBodyStream) 198 | 199 | return string(reqBody) 200 | } 201 | 202 | return "" 203 | } 204 | -------------------------------------------------------------------------------- /runner/request_multipart_form_data_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestMultipartFormData(t *testing.T) { 16 | srv := testServerMultipartFormData(t) 17 | defer srv.Close() 18 | 19 | // TODO: refactor RunWithTesting() for testing negative scenario (when tests has expected errors) 20 | RunWithTesting(t, &RunWithTestingParams{ 21 | Server: srv, 22 | TestsDir: filepath.Join("testdata", "multipart", "form-data"), 23 | }) 24 | } 25 | 26 | type multipartResponse struct { 27 | ContentTypeHeader string `json:"content_type_header"` 28 | RequestBodyContent string `json:"request_body_content"` 29 | } 30 | 31 | func testServerMultipartFormData(t *testing.T) *httptest.Server { 32 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | body, err := io.ReadAll(r.Body) 34 | require.NoError(t, err) 35 | r.Body = io.NopCloser(bytes.NewReader(body)) 36 | 37 | resp := multipartResponse{ 38 | ContentTypeHeader: r.Header.Get("Content-Type"), 39 | RequestBodyContent: string(body), 40 | } 41 | 42 | respData, err := json.Marshal(resp) 43 | require.NoError(t, err) 44 | 45 | w.Header().Set("Content-Type", "application/json") 46 | 47 | _, err = w.Write(respData) 48 | require.NoError(t, err) 49 | 50 | })) 51 | } 52 | -------------------------------------------------------------------------------- /runner/runner_multidb.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "database/sql" 5 | "net/http/httptest" 6 | "net/url" 7 | "os" 8 | "testing" 9 | 10 | "github.com/joho/godotenv" 11 | 12 | "github.com/lamoda/gonkey/checker" 13 | "github.com/lamoda/gonkey/checker/response_body" 14 | "github.com/lamoda/gonkey/checker/response_db" 15 | "github.com/lamoda/gonkey/checker/response_header" 16 | "github.com/lamoda/gonkey/fixtures" 17 | "github.com/lamoda/gonkey/fixtures/multidb" 18 | "github.com/lamoda/gonkey/mocks" 19 | "github.com/lamoda/gonkey/output" 20 | "github.com/lamoda/gonkey/output/allure_report" 21 | testingOutput "github.com/lamoda/gonkey/output/testing" 22 | "github.com/lamoda/gonkey/testloader/yaml_file" 23 | "github.com/lamoda/gonkey/variables" 24 | ) 25 | 26 | type DbMapInstance struct { 27 | DB *sql.DB 28 | DbType fixtures.DbType 29 | } 30 | 31 | type RunWithMultiDbParams struct { 32 | Server *httptest.Server 33 | TestsDir string 34 | Mocks *mocks.Mocks 35 | FixturesDir string 36 | DbMap map[string]*DbMapInstance 37 | Aerospike Aerospike 38 | EnvFilePath string 39 | OutputFunc output.OutputInterface 40 | Checkers []checker.CheckerInterface 41 | FixtureLoader fixtures.LoaderMultiDb 42 | } 43 | 44 | // RunWithMultiDb is a helper function the wraps the common Run and provides simple way 45 | // to configure Gonkey by filling the params structure. 46 | func RunWithMultiDb(t *testing.T, params *RunWithMultiDbParams) { 47 | var mocksLoader *mocks.Loader 48 | if params.Mocks != nil { 49 | mocksLoader = mocks.NewLoader(params.Mocks) 50 | registerMocksEnvironment(params.Mocks) 51 | } 52 | 53 | if params.EnvFilePath != "" { 54 | if err := godotenv.Load(params.EnvFilePath); err != nil { 55 | t.Fatal(err) 56 | } 57 | } 58 | 59 | // Configure fixture loader 60 | var fixturesLoader fixtures.LoaderMultiDb 61 | 62 | if params.FixtureLoader == nil { 63 | debug := os.Getenv("GONKEY_DEBUG") != "" 64 | loaders := make(map[string]fixtures.Loader, len(params.DbMap)) 65 | for connName, dbInstance := range params.DbMap { 66 | loaders[connName] = fixtures.NewLoader(&fixtures.Config{ 67 | DB: dbInstance.DB, 68 | Location: params.FixturesDir, 69 | DbType: dbInstance.DbType, 70 | Debug: debug, 71 | }) 72 | } 73 | fixturesLoader = multidb.New(loaders) 74 | } else { 75 | fixturesLoader = params.FixtureLoader 76 | } 77 | 78 | var proxyURL *url.URL 79 | if os.Getenv("HTTP_PROXY") != "" { 80 | httpURL, err := url.Parse(os.Getenv("HTTP_PROXY")) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | proxyURL = httpURL 85 | } 86 | 87 | yamlLoader := yaml_file.NewLoader(params.TestsDir) 88 | yamlLoader.SetFileFilter(os.Getenv("GONKEY_FILE_FILTER")) 89 | 90 | handler := testingHandler{t} 91 | runner := New( 92 | &Config{ 93 | Host: params.Server.URL, 94 | Mocks: params.Mocks, 95 | MocksLoader: mocksLoader, 96 | FixturesLoaderMultiDb: fixturesLoader, 97 | Variables: variables.New(), 98 | HTTPProxyURL: proxyURL, 99 | }, 100 | yamlLoader, 101 | handler.HandleTest, 102 | ) 103 | 104 | if params.OutputFunc != nil { 105 | runner.AddOutput(params.OutputFunc) 106 | } else { 107 | runner.AddOutput(testingOutput.NewOutput()) 108 | } 109 | 110 | if os.Getenv("GONKEY_ALLURE_DIR") != "" { 111 | allureOutput := allure_report.NewOutput("Gonkey", os.Getenv("GONKEY_ALLURE_DIR")) 112 | defer allureOutput.Finalize() 113 | runner.AddOutput(allureOutput) 114 | } 115 | 116 | runner.AddCheckers(response_body.NewChecker()) 117 | runner.AddCheckers(response_header.NewChecker()) 118 | runner.AddCheckers(response_db.NewMultiDbChecker(getDbConnMap(params.DbMap))) 119 | runner.AddCheckers(params.Checkers...) 120 | 121 | err := runner.Run() 122 | if err != nil { 123 | t.Fatal(err) 124 | } 125 | } 126 | 127 | func getDbConnMap(dbMap map[string]*DbMapInstance) map[string]*sql.DB { 128 | result := make(map[string]*sql.DB, len(dbMap)) 129 | 130 | for dbName, conn := range dbMap { 131 | result[dbName] = conn.DB 132 | } 133 | 134 | return result 135 | } 136 | -------------------------------------------------------------------------------- /runner/runner_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestDontFollowRedirects(t *testing.T) { 11 | srv := testServerRedirect() 12 | defer srv.Close() 13 | 14 | RunWithTesting(t, &RunWithTestingParams{ 15 | Server: srv, 16 | TestsDir: filepath.Join("testdata", "dont-follow-redirects"), 17 | }) 18 | } 19 | 20 | func testServerRedirect() *httptest.Server { 21 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | http.Redirect(w, r, "/redirect-url", http.StatusFound) 23 | })) 24 | } 25 | -------------------------------------------------------------------------------- /runner/runner_testing.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "net/http/httptest" 8 | "net/url" 9 | "os" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/aerospike/aerospike-client-go/v5" 14 | "github.com/joho/godotenv" 15 | 16 | "github.com/lamoda/gonkey/checker" 17 | "github.com/lamoda/gonkey/checker/response_body" 18 | "github.com/lamoda/gonkey/checker/response_db" 19 | "github.com/lamoda/gonkey/checker/response_header" 20 | "github.com/lamoda/gonkey/fixtures" 21 | "github.com/lamoda/gonkey/mocks" 22 | "github.com/lamoda/gonkey/models" 23 | "github.com/lamoda/gonkey/output" 24 | "github.com/lamoda/gonkey/output/allure_report" 25 | testingOutput "github.com/lamoda/gonkey/output/testing" 26 | aerospikeAdapter "github.com/lamoda/gonkey/storage/aerospike" 27 | "github.com/lamoda/gonkey/testloader/yaml_file" 28 | "github.com/lamoda/gonkey/variables" 29 | ) 30 | 31 | type Aerospike struct { 32 | *aerospike.Client 33 | Namespace string 34 | } 35 | 36 | type RunWithTestingParams struct { 37 | Server *httptest.Server 38 | TestsDir string 39 | Mocks *mocks.Mocks 40 | FixturesDir string 41 | DB *sql.DB 42 | Aerospike Aerospike 43 | // If DB parameter present, used to recognize type of database, if not set, by default uses Postgres 44 | DbType fixtures.DbType 45 | EnvFilePath string 46 | OutputFunc output.OutputInterface 47 | Checkers []checker.CheckerInterface 48 | FixtureLoader fixtures.Loader 49 | } 50 | 51 | func registerMocksEnvironment(m *mocks.Mocks) { 52 | names := m.GetNames() 53 | for _, n := range names { 54 | varName := fmt.Sprintf("GONKEY_MOCK_%s", strings.ToUpper(n)) 55 | os.Setenv(varName, m.Service(n).ServerAddr()) 56 | } 57 | } 58 | 59 | // RunWithTesting is a helper function the wraps the common Run and provides simple way 60 | // to configure Gonkey by filling the params structure. 61 | func RunWithTesting(t *testing.T, params *RunWithTestingParams) { 62 | var mocksLoader *mocks.Loader 63 | if params.Mocks != nil { 64 | mocksLoader = mocks.NewLoader(params.Mocks) 65 | registerMocksEnvironment(params.Mocks) 66 | } 67 | 68 | if params.EnvFilePath != "" { 69 | if err := godotenv.Load(params.EnvFilePath); err != nil { 70 | t.Fatal(err) 71 | } 72 | } 73 | 74 | debug := os.Getenv("GONKEY_DEBUG") != "" 75 | 76 | var fixturesLoader fixtures.Loader 77 | if params.DB != nil || params.Aerospike.Client != nil || params.FixtureLoader != nil { 78 | fixturesLoader = fixtures.NewLoader(&fixtures.Config{ 79 | Location: params.FixturesDir, 80 | DB: params.DB, 81 | Aerospike: aerospikeAdapter.New(params.Aerospike.Client, params.Aerospike.Namespace), 82 | Debug: debug, 83 | DbType: params.DbType, 84 | FixtureLoader: params.FixtureLoader, 85 | }) 86 | } 87 | 88 | var proxyURL *url.URL 89 | if os.Getenv("HTTP_PROXY") != "" { 90 | httpURL, err := url.Parse(os.Getenv("HTTP_PROXY")) 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | proxyURL = httpURL 95 | } 96 | 97 | runner := initRunner(t, params, mocksLoader, fixturesLoader, proxyURL) 98 | 99 | if params.OutputFunc != nil { 100 | runner.AddOutput(params.OutputFunc) 101 | } else { 102 | runner.AddOutput(testingOutput.NewOutput()) 103 | } 104 | 105 | if os.Getenv("GONKEY_ALLURE_DIR") != "" { 106 | allureOutput := allure_report.NewOutput("Gonkey", os.Getenv("GONKEY_ALLURE_DIR")) 107 | defer allureOutput.Finalize() 108 | runner.AddOutput(allureOutput) 109 | } 110 | 111 | addCheckers(runner, params) 112 | 113 | err := runner.Run() 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | } 118 | 119 | func initRunner( 120 | t *testing.T, 121 | params *RunWithTestingParams, 122 | mocksLoader *mocks.Loader, 123 | fixturesLoader fixtures.Loader, 124 | proxyURL *url.URL, 125 | ) *Runner { 126 | yamlLoader := yaml_file.NewLoader(params.TestsDir) 127 | yamlLoader.SetFileFilter(os.Getenv("GONKEY_FILE_FILTER")) 128 | 129 | handler := testingHandler{t} 130 | runner := New( 131 | &Config{ 132 | Host: params.Server.URL, 133 | Mocks: params.Mocks, 134 | MocksLoader: mocksLoader, 135 | FixturesLoader: fixturesLoader, 136 | Variables: variables.New(), 137 | HTTPProxyURL: proxyURL, 138 | }, 139 | yamlLoader, 140 | handler.HandleTest, 141 | ) 142 | 143 | return runner 144 | } 145 | 146 | func addCheckers(runner *Runner, params *RunWithTestingParams) { 147 | runner.AddCheckers(response_body.NewChecker()) 148 | runner.AddCheckers(response_header.NewChecker()) 149 | 150 | if params.DB != nil { 151 | runner.AddCheckers(response_db.NewChecker(params.DB)) 152 | } 153 | 154 | runner.AddCheckers(params.Checkers...) 155 | } 156 | 157 | type testingHandler struct { 158 | t *testing.T 159 | } 160 | 161 | func (h testingHandler) HandleTest(test models.TestInterface, executeTest testExecutor) error { 162 | var returnErr error 163 | h.t.Run(test.GetName(), func(t *testing.T) { 164 | result, err := executeTest(test) 165 | if err != nil { 166 | if errors.Is(err, errTestSkipped) || errors.Is(err, errTestBroken) { 167 | t.Skip() 168 | } else { 169 | returnErr = err 170 | t.Fatal(err) 171 | } 172 | } 173 | 174 | if !result.Passed() { 175 | t.Fail() 176 | } 177 | }) 178 | 179 | return returnErr 180 | } 181 | -------------------------------------------------------------------------------- /runner/runner_upload_file_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestUploadFiles(t *testing.T) { 15 | srv := testServerUpload(t) 16 | defer srv.Close() 17 | 18 | // TODO: refactor RunWithTesting() for testing negative scenario (when tests has expected errors) 19 | RunWithTesting(t, &RunWithTestingParams{ 20 | Server: srv, 21 | TestsDir: filepath.Join("testdata", "upload-files"), 22 | }) 23 | } 24 | 25 | type response struct { 26 | Status string `json:"status"` 27 | File1Name string `json:"file_1_name"` 28 | File1Content string `json:"file_1_content"` 29 | File2Name string `json:"file_2_name"` 30 | File2Content string `json:"file_2_content"` 31 | FieldsTestName string `json:"fields_test_name"` 32 | FieldsTestContent string `json:"fields_test_content"` 33 | } 34 | 35 | func testServerUpload(t *testing.T) *httptest.Server { 36 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | 38 | resp := response{ 39 | Status: "OK", 40 | } 41 | 42 | resp.File1Name, resp.File1Content = formFile(t, r, "file1") 43 | resp.File2Name, resp.File2Content = formFile(t, r, "file2") 44 | 45 | resp.FieldsTestName, resp.FieldsTestContent = "fieldTest", r.FormValue("fieldTest") 46 | 47 | respData, err := json.Marshal(resp) 48 | require.NoError(t, err) 49 | 50 | w.Header().Set("Content-Type", "application/json") 51 | 52 | _, err = w.Write(respData) 53 | require.NoError(t, err) 54 | 55 | })) 56 | } 57 | 58 | func formFile(t *testing.T, r *http.Request, field string) (string, string) { 59 | 60 | file, header, err := r.FormFile(field) 61 | require.NoError(t, err) 62 | 63 | defer func() { _ = file.Close() }() 64 | 65 | contents, err := io.ReadAll(file) 66 | require.NoError(t, err) 67 | 68 | return header.Filename, string(contents) 69 | } 70 | -------------------------------------------------------------------------------- /runner/testdata/dont-follow-redirects/with-redirect.yaml: -------------------------------------------------------------------------------- 1 | - name: "dont-follow-redirects" 2 | method: GET 3 | response: 4 | 302: "$matchRegexp(redirect-url)" -------------------------------------------------------------------------------- /runner/testdata/multipart/form-data/form-data.yaml: -------------------------------------------------------------------------------- 1 | - name: "form-data: simple form" 2 | method: POST 3 | path: /dontcare 4 | headers: 5 | Content-Type: multipart/form-data; boundary=somebound 6 | form: 7 | fields: 8 | field_test: test value 9 | field2: test value 2 10 | info: 100 11 | response: 12 | 200: | 13 | { 14 | "content_type_header":"multipart/form-data; boundary=somebound", 15 | "request_body_content":"--somebound\r\nContent-Disposition: form-data; name=\"field2\"\r\n\r\ntest value 2\r\n--somebound\r\nContent-Disposition: form-data; name=\"field_test\"\r\n\r\ntest value\r\n--somebound\r\nContent-Disposition: form-data; name=\"info\"\r\n\r\n100\r\n--somebound--\r\n" 16 | } 17 | 18 | - name: "form-data: simple form, several fields" 19 | method: POST 20 | path: /dontcare 21 | headers: 22 | Content-Type: multipart/form-data; boundary=somebound 23 | form: 24 | fields: 25 | field_test: test value 26 | "fieldobj[2][one]": fieldobj 2 prop one 27 | "fieldobj[2][two]": fieldobj 2 prop two 28 | response: 29 | 200: | 30 | { 31 | "content_type_header":"multipart/form-data; boundary=somebound", 32 | "request_body_content":"--somebound\r\nContent-Disposition: form-data; name=\"field_test\"\r\n\r\ntest value\r\n--somebound\r\nContent-Disposition: form-data; name=\"fieldobj[2][one]\"\r\n\r\nfieldobj 2 prop one\r\n--somebound\r\nContent-Disposition: form-data; name=\"fieldobj[2][two]\"\r\n\r\nfieldobj 2 prop two\r\n--somebound--\r\n" 33 | } 34 | -------------------------------------------------------------------------------- /runner/testdata/previous-result/previous-result.yaml: -------------------------------------------------------------------------------- 1 | - method: "GET" 2 | path: "/some/path/plain_text" 3 | response: 4 | 200: "bla" 5 | variables_to_set: 6 | 200: "myResp" 7 | - method: "GET" 8 | path: "/some/path/plain_text" 9 | response: 10 | 200: "{{$myResp}}" 11 | 12 | - method: "GET" 13 | path: "/some/path/json" 14 | response: 15 | 200: > 16 | { 17 | "status": "status_val", 18 | "other": "some_info", 19 | "unused_field": "useless_info", 20 | "nested_info": { 21 | "nested_field_1": "nested_val1", 22 | "nested_field_2": "nested_val2" 23 | } 24 | } 25 | variables_to_set: 26 | 200: 27 | statusVar: "status" 28 | someField: "other" 29 | nestedVar1: "nested_info.nested_field_1" 30 | nestedVar2: "nested_info.nested_field_2" 31 | - method: "GET" 32 | path: "/some/path/json" 33 | response: 34 | 200: > 35 | { 36 | "status": "{{$statusVar}}", 37 | "other": "{{$someField}}", 38 | "nested_info": { 39 | "nested_field_1": "{{$nestedVar1}}", 40 | "nested_field_2": "{{$nestedVar2}}" 41 | } 42 | } -------------------------------------------------------------------------------- /runner/testdata/upload-files/file1.txt: -------------------------------------------------------------------------------- 1 | file1_some_text -------------------------------------------------------------------------------- /runner/testdata/upload-files/file2.log: -------------------------------------------------------------------------------- 1 | file2_content -------------------------------------------------------------------------------- /runner/testdata/upload-files/upload-files.yaml: -------------------------------------------------------------------------------- 1 | - name: "upload-files: with correct Content-Type" 2 | method: POST 3 | form: 4 | files: 5 | file1: "testdata/upload-files/file1.txt" 6 | file2: "testdata/upload-files/file2.log" 7 | headers: 8 | Content-Type: multipart/form-data 9 | response: 10 | 200: | 11 | { 12 | "status": "OK", 13 | "file_1_name": "file1.txt", 14 | "file_1_content": "file1_some_text", 15 | "file_2_name": "file2.log", 16 | "file_2_content": "file2_content" 17 | } 18 | 19 | - name: "upload-files: with empty Content-Type" 20 | method: POST 21 | form: 22 | files: 23 | file1: "testdata/upload-files/file1.txt" 24 | file2: "testdata/upload-files/file2.log" 25 | response: 26 | 200: | 27 | { 28 | "status": "OK", 29 | "file_1_name": "file1.txt", 30 | "file_1_content": "file1_some_text", 31 | "file_2_name": "file2.log", 32 | "file_2_content": "file2_content" 33 | } 34 | 35 | - name: "upload-files: with form fields" 36 | method: POST 37 | form: 38 | fields: 39 | fieldTest: "test_field value" 40 | files: 41 | file1: "testdata/upload-files/file1.txt" 42 | file2: "testdata/upload-files/file2.log" 43 | response: 44 | 200: | 45 | { 46 | "status": "OK", 47 | "file_1_name": "file1.txt", 48 | "file_1_content": "file1_some_text", 49 | "file_2_name": "file2.log", 50 | "file_2_content": "file2_content", 51 | "fields_test_name": "fieldTest", 52 | "fields_test_content": "test_field value" 53 | } 54 | 55 | # TODO: test with incorrect Content-Type -------------------------------------------------------------------------------- /storage/aerospike/aerospike.go: -------------------------------------------------------------------------------- 1 | package aerospike 2 | 3 | import ( 4 | "github.com/aerospike/aerospike-client-go/v5" 5 | ) 6 | 7 | type Client struct { 8 | *aerospike.Client 9 | namespace string 10 | } 11 | 12 | func New(client *aerospike.Client, namespace string) *Client { 13 | return &Client{ 14 | Client: client, 15 | namespace: namespace, 16 | } 17 | } 18 | 19 | func (c *Client) Truncate(set string) error { 20 | return c.Client.Truncate(nil, c.namespace, set, nil) 21 | } 22 | 23 | func (c *Client) InsertBinMap(set, key string, binMap map[string]interface{}) error { 24 | aerospikeKey, err := aerospike.NewKey(c.namespace, set, key) 25 | if err != nil { 26 | return err 27 | } 28 | bins := prepareBins(binMap) 29 | 30 | return c.PutBins(nil, aerospikeKey, bins...) 31 | } 32 | 33 | func prepareBins(binmap map[string]interface{}) []*aerospike.Bin { 34 | var bins []*aerospike.Bin 35 | for binName, binData := range binmap { 36 | if binName == "$extend" { 37 | continue 38 | } 39 | bins = append(bins, aerospike.NewBin(binName, binData)) 40 | } 41 | 42 | return bins 43 | } 44 | -------------------------------------------------------------------------------- /testloader/loader.go: -------------------------------------------------------------------------------- 1 | package testloader 2 | 3 | import ( 4 | "github.com/lamoda/gonkey/models" 5 | ) 6 | 7 | type LoaderInterface interface { 8 | Load() ([]models.TestInterface, error) 9 | } 10 | -------------------------------------------------------------------------------- /testloader/yaml_file/parser_test.go: -------------------------------------------------------------------------------- 1 | package yaml_file 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/lamoda/gonkey/models" 11 | ) 12 | 13 | var testsYAMLData = ` 14 | - method: POST 15 | path: /jsonrpc/v2/orders.nr 16 | request: 17 | '{ 18 | "jsonrpc": "2.0", 19 | "id": "550e8400-e29b-41d4-a716-446655440000", 20 | "method": "orders.nr", 21 | "params": [ 22 | { 23 | "amount": 1, 24 | "prefix": "ru" 25 | } 26 | ] 27 | }' 28 | response: 29 | 200: 30 | '{ 31 | "result": [ 32 | { 33 | "nr": "number", 34 | "prefix": "ru", 35 | "vc": "vc" 36 | } 37 | ], 38 | "id": "550e8400-e29b-41d4-a716-446655440000", 39 | "jsonrpc": "2.0" 40 | }' 41 | cases: 42 | - requestArgs: 43 | foo: 'Hello world' 44 | bar: 42 45 | responseArgs: 46 | 200: 47 | foo: 'Hello world' 48 | bar: 42 49 | - requestArgs: 50 | foo: 'Hello world' 51 | bar: 42 52 | responseArgs: 53 | 200: 54 | foo: 'Hello world' 55 | bar: 42 56 | variables: 57 | newVar: some_value 58 | ` 59 | 60 | func TestParseTestsWithCases(t *testing.T) { 61 | tmpfile, err := os.CreateTemp("../..", "tmpfile_") 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | defer os.Remove(tmpfile.Name()) 66 | 67 | if _, err := fmt.Fprint(tmpfile, testsYAMLData); err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | tests, err := parseTestDefinitionFile(tmpfile.Name()) 72 | if err != nil { 73 | t.Error(err) 74 | } 75 | if len(tests) != 2 { 76 | t.Errorf("wait len(tests) == 2, got len(tests) == %d", len(tests)) 77 | } 78 | } 79 | 80 | func TestParseTestsWithFixtures(t *testing.T) { 81 | tests, err := parseTestDefinitionFile("./testdata/with-fixtures.yaml") 82 | if err != nil { 83 | t.Error(err) 84 | } 85 | 86 | assert.Equal(t, 2, len(tests)) 87 | 88 | expectedSimple := []string{"path/fixture1.yaml", "path/fixture2.yaml"} 89 | assert.Equal(t, expectedSimple, tests[0].Fixtures()) 90 | 91 | expectedMultiDb := models.FixturesMultiDb([]models.Fixture{ 92 | {DbName: "conn1", Files: []string{"path/fixture3.yaml"}}, 93 | {DbName: "conn2", Files: []string{"path/fixture4.yaml"}}, 94 | }) 95 | assert.Equal(t, expectedMultiDb, tests[1].FixturesMultiDb()) 96 | } 97 | 98 | func TestParseTestsWithDbChecks(t *testing.T) { 99 | tests, err := parseTestDefinitionFile("./testdata/with-db-checks.yaml") 100 | if err != nil { 101 | t.Error(err) 102 | } 103 | 104 | assert.Equal(t, 2, len(tests)) 105 | assert.Equal(t, "", tests[0].GetDatabaseChecks()[0].DbNameString()) 106 | assert.Equal(t, "connection_name", tests[1].GetDatabaseChecks()[0].DbNameString()) 107 | } 108 | -------------------------------------------------------------------------------- /testloader/yaml_file/test.go: -------------------------------------------------------------------------------- 1 | package yaml_file 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/lamoda/gonkey/models" 7 | ) 8 | 9 | type dbCheck struct { 10 | dbName string 11 | query string 12 | response []string 13 | } 14 | 15 | func (c *dbCheck) DbNameString() string { return c.dbName } 16 | func (c *dbCheck) DbQueryString() string { return c.query } 17 | func (c *dbCheck) DbResponseJson() []string { return c.response } 18 | func (c *dbCheck) SetDbQueryString(q string) { c.query = q } 19 | func (c *dbCheck) SetDbResponseJson(r []string) { c.response = r } 20 | 21 | type Test struct { 22 | TestDefinition 23 | 24 | Filename string 25 | 26 | Request string 27 | Responses map[int]string 28 | ResponseHeaders map[int]map[string]string 29 | BeforeScript string 30 | AfterRequestScript string 31 | DbName string 32 | DbQuery string 33 | DbResponse []string 34 | 35 | CombinedVariables map[string]string 36 | 37 | DbChecks []models.DatabaseCheck 38 | } 39 | 40 | func (t *Test) ToQuery() string { 41 | return t.QueryParams 42 | } 43 | 44 | func (t *Test) GetMethod() string { 45 | return t.Method 46 | } 47 | 48 | func (t *Test) Path() string { 49 | return t.RequestURL 50 | } 51 | 52 | func (t *Test) GetRequest() string { 53 | return t.Request 54 | } 55 | 56 | func (t *Test) ToJSON() ([]byte, error) { 57 | return []byte(t.Request), nil 58 | } 59 | 60 | func (t *Test) GetResponses() map[int]string { 61 | return t.Responses 62 | } 63 | 64 | func (t *Test) GetResponse(code int) (string, bool) { 65 | val, ok := t.Responses[code] 66 | 67 | return val, ok 68 | } 69 | 70 | func (t *Test) GetResponseHeaders(code int) (map[string]string, bool) { 71 | val, ok := t.ResponseHeaders[code] 72 | 73 | return val, ok 74 | } 75 | 76 | func (t *Test) NeedsCheckingValues() bool { 77 | return !t.ComparisonParams.IgnoreValues 78 | } 79 | 80 | func (t *Test) GetName() string { 81 | return t.Name 82 | } 83 | 84 | func (t *Test) GetDescription() string { 85 | return t.Description 86 | } 87 | 88 | func (t *Test) GetStatus() string { 89 | return t.Status 90 | } 91 | 92 | func (t *Test) IgnoreArraysOrdering() bool { 93 | return t.ComparisonParams.IgnoreArraysOrdering 94 | } 95 | 96 | func (t *Test) DisallowExtraFields() bool { 97 | return t.ComparisonParams.DisallowExtraFields 98 | } 99 | 100 | func (t *Test) IgnoreDbOrdering() bool { 101 | return t.ComparisonParams.IgnoreDbOrdering 102 | } 103 | 104 | func (t *Test) Fixtures() []string { 105 | return t.FixtureFiles 106 | } 107 | 108 | func (t *Test) FixturesMultiDb() models.FixturesMultiDb { 109 | return t.FixturesListMultiDb 110 | } 111 | 112 | func (t *Test) ServiceMocks() map[string]interface{} { 113 | return t.MocksDefinition 114 | } 115 | 116 | func (t *Test) Pause() int { 117 | return t.PauseValue 118 | } 119 | 120 | func (t *Test) BeforeScriptPath() string { 121 | return t.BeforeScript 122 | } 123 | 124 | func (t *Test) BeforeScriptTimeout() int { 125 | return t.BeforeScriptParams.Timeout 126 | } 127 | 128 | func (t *Test) AfterRequestScriptPath() string { 129 | return t.AfterRequestScript 130 | } 131 | 132 | func (t *Test) AfterRequestScriptTimeout() int { 133 | return t.AfterRequestScriptParams.Timeout 134 | } 135 | 136 | func (t *Test) Cookies() map[string]string { 137 | return t.CookiesVal 138 | } 139 | 140 | func (t *Test) Headers() map[string]string { 141 | return t.HeadersVal 142 | } 143 | 144 | // TODO: it might make sense to do support of case-insensitive checking 145 | func (t *Test) ContentType() string { 146 | return t.HeadersVal["Content-Type"] 147 | } 148 | 149 | func (t *Test) DbNameString() string { 150 | return t.DbName 151 | } 152 | 153 | func (t *Test) DbQueryString() string { 154 | return t.DbQuery 155 | } 156 | 157 | func (t *Test) DbResponseJson() []string { 158 | return t.DbResponse 159 | } 160 | 161 | func (t *Test) GetDatabaseChecks() []models.DatabaseCheck { return t.DbChecks } 162 | func (t *Test) SetDatabaseChecks(checks []models.DatabaseCheck) { t.DbChecks = checks } 163 | 164 | func (t *Test) GetVariables() map[string]string { 165 | return t.Variables 166 | } 167 | 168 | func (t *Test) GetCombinedVariables() map[string]string { 169 | return t.CombinedVariables 170 | } 171 | 172 | func (t *Test) GetForm() *models.Form { 173 | return t.Form 174 | } 175 | 176 | func (t *Test) GetVariablesToSet() map[int]map[string]string { 177 | return t.VariablesToSet 178 | } 179 | 180 | func (t *Test) GetFileName() string { 181 | return t.Filename 182 | } 183 | 184 | func (t *Test) Clone() models.TestInterface { 185 | res := *t 186 | 187 | return &res 188 | } 189 | 190 | func (t *Test) SetQuery(val string) { 191 | var query strings.Builder 192 | query.Grow(len(val) + 1) 193 | if val != "" && val[0] != '?' { 194 | query.WriteString("?") 195 | } 196 | query.WriteString(val) 197 | t.QueryParams = query.String() 198 | } 199 | 200 | func (t *Test) SetMethod(val string) { 201 | t.Method = val 202 | } 203 | 204 | func (t *Test) SetPath(val string) { 205 | t.RequestURL = val 206 | } 207 | 208 | func (t *Test) SetRequest(val string) { 209 | t.Request = val 210 | } 211 | 212 | func (t *Test) SetForm(val *models.Form) { 213 | t.Form = val 214 | } 215 | 216 | func (t *Test) SetResponses(val map[int]string) { 217 | t.Responses = val 218 | } 219 | 220 | func (t *Test) SetHeaders(val map[string]string) { 221 | t.HeadersVal = val 222 | } 223 | 224 | func (t *Test) SetDbQueryString(query string) { 225 | t.DbQuery = query 226 | } 227 | 228 | func (t *Test) SetDbResponseJson(responses []string) { 229 | t.DbResponse = responses 230 | } 231 | 232 | func (t *Test) SetStatus(status string) { 233 | t.Status = status 234 | } 235 | -------------------------------------------------------------------------------- /testloader/yaml_file/test_definition.go: -------------------------------------------------------------------------------- 1 | package yaml_file 2 | 3 | import ( 4 | "github.com/lamoda/gonkey/compare" 5 | "github.com/lamoda/gonkey/models" 6 | ) 7 | 8 | type TestDefinition struct { 9 | Name string `json:"name" yaml:"name"` 10 | Description string `json:"description" yaml:"description"` 11 | Status string `json:"status" yaml:"status"` 12 | Variables map[string]string `json:"variables" yaml:"variables"` 13 | VariablesToSet VariablesToSet `json:"variables_to_set" yaml:"variables_to_set"` 14 | Form *models.Form `json:"form" yaml:"form"` 15 | Method string `json:"method" yaml:"method"` 16 | RequestURL string `json:"path" yaml:"path"` 17 | QueryParams string `json:"query" yaml:"query"` 18 | RequestTmpl string `json:"request" yaml:"request"` 19 | ResponseTmpls map[int]string `json:"response" yaml:"response"` 20 | ResponseHeaders map[int]map[string]string `json:"responseHeaders" yaml:"responseHeaders"` 21 | BeforeScriptParams scriptParams `json:"beforeScript" yaml:"beforeScript"` 22 | AfterRequestScriptParams scriptParams `json:"afterRequestScript" yaml:"afterRequestScript"` 23 | HeadersVal map[string]string `json:"headers" yaml:"headers"` 24 | CookiesVal map[string]string `json:"cookies" yaml:"cookies"` 25 | Cases []CaseData `json:"cases" yaml:"cases"` 26 | ComparisonParams compare.Params `json:"comparisonParams" yaml:"comparisonParams"` 27 | FixtureFiles []string `json:"fixtures" yaml:"fixtures"` 28 | FixturesListMultiDb models.FixturesMultiDb `json:"fixturesWithDb" yaml:"fixturesWithDb"` 29 | MocksDefinition map[string]interface{} `json:"mocks" yaml:"mocks"` 30 | PauseValue int `json:"pause" yaml:"pause"` 31 | DbQueryTmpl string `json:"dbQuery" yaml:"dbQuery"` 32 | DbResponseTmpl []string `json:"dbResponse" yaml:"dbResponse"` 33 | DatabaseChecks []DatabaseCheck `json:"dbChecks" yaml:"dbChecks"` 34 | } 35 | 36 | type CaseData struct { 37 | Name string `json:"name" yaml:"name"` 38 | Description string `json:"description" yaml:"description"` 39 | RequestArgs map[string]interface{} `json:"requestArgs" yaml:"requestArgs"` 40 | ResponseArgs map[int]map[string]interface{} `json:"responseArgs" yaml:"responseArgs"` 41 | BeforeScriptArgs map[string]interface{} `json:"beforeScriptArgs" yaml:"beforeScriptArgs"` 42 | AfterRequestScriptArgs map[string]interface{} `json:"afterRequestScriptArgs" yaml:"afterRequestScriptArgs"` 43 | DbQueryArgs map[string]interface{} `json:"dbQueryArgs" yaml:"dbQueryArgs"` 44 | DbResponseArgs map[string]interface{} `json:"dbResponseArgs" yaml:"dbResponseArgs"` 45 | DbResponse []string `json:"dbResponse" yaml:"dbResponse"` 46 | Variables map[string]interface{} `json:"variables" yaml:"variables"` 47 | } 48 | 49 | type DatabaseCheck struct { 50 | DbName string `json:"dbName" yaml:"dbName"` 51 | DbQueryTmpl string `json:"dbQuery" yaml:"dbQuery"` 52 | DbResponseTmpl []string `json:"dbResponse" yaml:"dbResponse"` 53 | } 54 | 55 | type scriptParams struct { 56 | PathTmpl string `json:"path" yaml:"path"` 57 | Timeout int `json:"timeout" yaml:"timeout"` 58 | } 59 | 60 | type VariablesToSet map[int]map[string]string 61 | 62 | /* 63 | There can be two types of data in yaml-file: 64 | 1. JSON-paths: 65 | VariablesToSet: 66 | : 67 | : 68 | : 69 | 2. Plain text: 70 | VariablesToSet: 71 | : 72 | : 73 | ... 74 | In this case we unmarshall values to format similar to JSON-paths format with empty paths: 75 | VariablesToSet: 76 | : 77 | : "" 78 | : 79 | : "" 80 | */ 81 | func (v *VariablesToSet) UnmarshalYAML(unmarshal func(interface{}) error) error { 82 | res := make(map[int]map[string]string) 83 | 84 | // try to unmarshall as plaint text 85 | var plain map[int]string 86 | if err := unmarshal(&plain); err == nil { 87 | 88 | for code, varName := range plain { 89 | res[code] = map[string]string{ 90 | varName: "", 91 | } 92 | } 93 | 94 | *v = res 95 | 96 | return nil 97 | } 98 | 99 | // json-paths 100 | if err := unmarshal(&res); err != nil { 101 | return err 102 | } 103 | 104 | *v = res 105 | 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /testloader/yaml_file/test_test.go: -------------------------------------------------------------------------------- 1 | package yaml_file 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestNewTestWithCases(t *testing.T) { 9 | data := TestDefinition{ 10 | RequestTmpl: `{"foo": "bar", "hello": {{ .hello }} }`, 11 | ResponseTmpls: map[int]string{ 12 | 200: `{"foo": "bar", "hello": {{ .hello }} }`, 13 | 400: `{"foo": "bar", "hello": {{ .hello }} }`, 14 | }, 15 | ResponseHeaders: map[int]map[string]string{ 16 | 200: { 17 | "hello": "world", 18 | "say": "hello", 19 | }, 20 | 400: { 21 | "hello": "world", 22 | "foo": "bar", 23 | }, 24 | }, 25 | Cases: []CaseData{ 26 | { 27 | RequestArgs: map[string]interface{}{ 28 | "hello": `"world"`, 29 | }, 30 | ResponseArgs: map[int]map[string]interface{}{ 31 | 200: { 32 | "hello": "world", 33 | }, 34 | 400: { 35 | "hello": "world", 36 | }, 37 | }, 38 | }, 39 | { 40 | RequestArgs: map[string]interface{}{ 41 | "hello": `"world2"`, 42 | }, 43 | ResponseArgs: map[int]map[string]interface{}{ 44 | 200: { 45 | "hello": "world2", 46 | }, 47 | 400: { 48 | "hello": "world2", 49 | }, 50 | }, 51 | }, 52 | }, 53 | } 54 | 55 | tests, err := makeTestFromDefinition("cases/example.yaml", data) 56 | 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | if len(tests) != 2 { 61 | t.Errorf("wait len(tests) == 2, got len(tests) == %d", len(tests)) 62 | } 63 | 64 | reqData, err := tests[0].ToJSON() 65 | if !reflect.DeepEqual(reqData, []byte(`{"foo": "bar", "hello": "world" }`)) { 66 | t.Errorf("want request %s, got %s", `{"foo": "bar", "hello": "world" }`, reqData) 67 | } 68 | 69 | filename := tests[0].GetFileName() 70 | if !reflect.DeepEqual(filename, "cases/example.yaml") { 71 | t.Errorf("want filename %s, got %s", "cases/example.yaml", filename) 72 | } 73 | 74 | reqData, err = tests[1].ToJSON() 75 | if !reflect.DeepEqual(reqData, []byte(`{"foo": "bar", "hello": "world2" }`)) { 76 | t.Errorf("want request %s, got %s", `{"foo": "bar", "hello": "world2" }`, reqData) 77 | } 78 | 79 | filename = tests[1].GetFileName() 80 | if !reflect.DeepEqual(filename, "cases/example.yaml") { 81 | t.Errorf("want filename %s, got %s", "cases/example.yaml", filename) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /testloader/yaml_file/test_variables_test.go: -------------------------------------------------------------------------------- 1 | package yaml_file 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/joho/godotenv" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/lamoda/gonkey/models" 11 | "github.com/lamoda/gonkey/variables" 12 | ) 13 | 14 | const requestOriginal = `{"reqParam": "{{ $reqParam }}"}` 15 | const requestApplied = `{"reqParam": "reqParam_value"}` 16 | 17 | const envFile = "testdata/test.env" 18 | 19 | func TestParse_EniromentVariables(t *testing.T) { 20 | err := godotenv.Load(envFile) 21 | require.NoError(t, err) 22 | 23 | tests, err := parseTestDefinitionFile("testdata/variables-enviroment.yaml") 24 | require.NoError(t, err) 25 | 26 | testOriginal := &tests[0] 27 | 28 | vars := variables.New() 29 | testApplied := vars.Apply(testOriginal) 30 | 31 | assert.Equal(t, "/some/path/path_value", testApplied.Path()) 32 | 33 | resp, ok := testApplied.GetResponse(200) 34 | assert.True(t, ok) 35 | assert.Equal(t, "resp_val", resp) 36 | 37 | } 38 | 39 | func TestParseTestsWithVariables(t *testing.T) { 40 | 41 | tests, err := parseTestDefinitionFile("testdata/variables.yaml") 42 | require.NoError(t, err) 43 | 44 | testOriginal := &tests[0] 45 | 46 | vars := variables.New() 47 | vars.Load(testOriginal.GetVariables()) 48 | assert.NoError(t, err) 49 | 50 | testApplied := vars.Apply(testOriginal) 51 | 52 | // check that original test is not changed 53 | checkOriginal(t, testOriginal, false) 54 | 55 | checkApplied(t, testApplied, false) 56 | } 57 | 58 | func TestParseTestsWithCombinedVariables(t *testing.T) { 59 | 60 | tests, err := parseTestDefinitionFile("testdata/combined-variables.yaml") 61 | require.NoError(t, err) 62 | 63 | testOriginal := &tests[0] 64 | 65 | vars := variables.New() 66 | vars.Load(testOriginal.GetCombinedVariables()) 67 | assert.NoError(t, err) 68 | 69 | testApplied := vars.Apply(testOriginal) 70 | 71 | // check that original test is not changed 72 | checkOriginal(t, testOriginal, true) 73 | 74 | checkApplied(t, testApplied, true) 75 | } 76 | 77 | func checkOriginal(t *testing.T, test models.TestInterface, combined bool) { 78 | 79 | t.Helper() 80 | 81 | req, err := test.ToJSON() 82 | assert.NoError(t, err) 83 | assert.Equal(t, requestOriginal, string(req)) 84 | 85 | assert.Equal(t, "{{ $method }}", test.GetMethod()) 86 | assert.Equal(t, "/some/path/{{ $pathPart }}", test.Path()) 87 | assert.Equal(t, "{{ $query }}", test.ToQuery()) 88 | assert.Equal(t, map[string]string{"header1": "{{ $header }}"}, test.Headers()) 89 | 90 | resp, ok := test.GetResponse(200) 91 | assert.True(t, ok) 92 | assert.Equal(t, "{{ $resp }}", resp) 93 | 94 | resp, ok = test.GetResponse(404) 95 | assert.True(t, ok) 96 | assert.Equal(t, "{{ $respRx }}", resp) 97 | 98 | if combined { 99 | resp, ok = test.GetResponse(501) 100 | assert.True(t, ok) 101 | assert.Equal(t, "{{ $newVar }} - {{ $redefinedVar }}", resp) 102 | } 103 | } 104 | 105 | func checkApplied(t *testing.T, test models.TestInterface, combined bool) { 106 | 107 | t.Helper() 108 | 109 | req, err := test.ToJSON() 110 | assert.NoError(t, err) 111 | assert.Equal(t, requestApplied, string(req)) 112 | 113 | assert.Equal(t, "POST", test.GetMethod()) 114 | assert.Equal(t, "/some/path/part_of_path", test.Path()) 115 | assert.Equal(t, "?query_val", test.ToQuery()) 116 | assert.Equal(t, map[string]string{"header1": "header_val"}, test.Headers()) 117 | 118 | resp, ok := test.GetResponse(200) 119 | assert.True(t, ok) 120 | assert.Equal(t, "resp_val", resp) 121 | 122 | resp, ok = test.GetResponse(404) 123 | assert.True(t, ok) 124 | assert.Equal(t, "$matchRegexp(^[0-9.]+$)", resp) 125 | 126 | resp, ok = test.GetResponse(500) 127 | assert.True(t, ok) 128 | assert.Equal(t, "existingVar_Value - {{ $notExistingVar }}", resp) 129 | 130 | raw, ok := test.ServiceMocks()["server"] 131 | assert.True(t, ok) 132 | mockMap, ok := raw.(map[interface{}]interface{}) 133 | assert.True(t, ok) 134 | mockBody, ok := mockMap["body"] 135 | assert.True(t, ok) 136 | assert.Equal(t, "{\"reqParam\": \"reqParam_value\"}", mockBody) 137 | 138 | if combined { 139 | resp, ok = test.GetResponse(501) 140 | assert.True(t, ok) 141 | t.Log(resp) 142 | assert.Equal(t, "some_value - redefined_value", resp) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /testloader/yaml_file/testdata/combined-variables.yaml: -------------------------------------------------------------------------------- 1 | - method: "{{ $method }}" 2 | path: "/some/path/{{ $pathPart }}" 3 | variables: 4 | tag: "some_tag" 5 | reqParam: "reqParam_value" 6 | method: "POST" 7 | pathPart: "part_of_path" 8 | query: "query_val" 9 | header: "header_val" 10 | resp: "resp_val" 11 | respRx: "$matchRegexp(^[0-9.]+$)" 12 | jsonParam: "jsonParam_val" 13 | existingVar: "existingVar_Value" 14 | redefinedVar: "initial_value" 15 | query: "{{ $query }}" 16 | headers: 17 | header1: "{{ $header }}" 18 | request: '{"reqParam": "{{ $reqParam }}"}' 19 | response: 20 | 200: "{{ $resp }}" 21 | 404: "{{ $respRx }}" 22 | 500: "{{ $existingVar }} - {{ $notExistingVar }}" 23 | 501: "{{ $newVar }} - {{ $redefinedVar }}" 24 | mocks: 25 | server: 26 | strategy: constant 27 | body: '{"reqParam": "{{ $reqParam }}"}' 28 | statusCode: 200 29 | cases: 30 | - variables: 31 | newVar: "some_value" 32 | redefinedVar: "redefined_value" 33 | -------------------------------------------------------------------------------- /testloader/yaml_file/testdata/test.env: -------------------------------------------------------------------------------- 1 | testResp=resp_val 2 | testPath=path_value -------------------------------------------------------------------------------- /testloader/yaml_file/testdata/variables-enviroment.yaml: -------------------------------------------------------------------------------- 1 | - method: "GET" 2 | path: "/some/path/{{ $testPath }}" 3 | response: 4 | 200: "{{ $testResp }}" -------------------------------------------------------------------------------- /testloader/yaml_file/testdata/variables.yaml: -------------------------------------------------------------------------------- 1 | - method: "{{ $method }}" 2 | path: "/some/path/{{ $pathPart }}" 3 | variables: 4 | tag: "some_tag" 5 | reqParam: "reqParam_value" 6 | method: "POST" 7 | pathPart: "part_of_path" 8 | query: "query_val" 9 | header: "header_val" 10 | resp: "resp_val" 11 | respRx: "$matchRegexp(^[0-9.]+$)" 12 | jsonParam: "jsonParam_val" 13 | existingVar: "existingVar_Value" 14 | query: "{{ $query }}" 15 | headers: 16 | header1: "{{ $header }}" 17 | request: '{"reqParam": "{{ $reqParam }}"}' 18 | response: 19 | 200: "{{ $resp }}" 20 | 404: "{{ $respRx }}" 21 | 500: "{{ $existingVar }} - {{ $notExistingVar }}" 22 | mocks: 23 | server: 24 | strategy: constant 25 | body: '{"reqParam": "{{ $reqParam }}"}' 26 | statusCode: 200 27 | -------------------------------------------------------------------------------- /testloader/yaml_file/testdata/with-db-checks.yaml: -------------------------------------------------------------------------------- 1 | - name: "with-db-checks: no named connection" 2 | method: POST 3 | path: /dontcare 4 | headers: 5 | Content-Type: multipart/form-data; boundary=somebound 6 | dbChecks: 7 | - dbQuery: 8 | SELECT id FROM user WHERE id=22; 9 | dbResponse: 10 | - '{"id":22}' 11 | 12 | - name: "with-db-checks: with named connection" 13 | method: POST 14 | path: /dontcare 15 | headers: 16 | Content-Type: multipart/form-data; boundary=somebound 17 | dbChecks: 18 | - dbName: connection_name 19 | dbQuery: 20 | SELECT id FROM user WHERE id=22; 21 | dbResponse: 22 | - '{"id":22}' 23 | -------------------------------------------------------------------------------- /testloader/yaml_file/testdata/with-fixtures.yaml: -------------------------------------------------------------------------------- 1 | - name: "with-fixtures: simple" 2 | method: POST 3 | path: /dontcare 4 | headers: 5 | Content-Type: multipart/form-data; boundary=somebound 6 | fixtures: 7 | - path/fixture1.yaml 8 | - path/fixture2.yaml 9 | 10 | - name: "with-fixtures: multidb" 11 | method: POST 12 | path: /dontcare 13 | headers: 14 | Content-Type: multipart/form-data; boundary=somebound 15 | fixturesWithDb: 16 | - dbName: conn1 17 | files: 18 | - path/fixture3.yaml 19 | - dbName: conn2 20 | files: 21 | - path/fixture4.yaml 22 | -------------------------------------------------------------------------------- /testloader/yaml_file/yaml_file.go: -------------------------------------------------------------------------------- 1 | package yaml_file 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/lamoda/gonkey/models" 8 | ) 9 | 10 | type YamlFileLoader struct { 11 | testsLocation string 12 | fileFilter string 13 | } 14 | 15 | func NewLoader(testsLocation string) *YamlFileLoader { 16 | return &YamlFileLoader{ 17 | testsLocation: testsLocation, 18 | } 19 | } 20 | 21 | func (l *YamlFileLoader) Load() ([]models.TestInterface, error) { 22 | fileTests, err := l.parseTestsWithCases(l.testsLocation) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | ret := make([]models.TestInterface, len(fileTests)) 28 | for i := range fileTests { 29 | test := fileTests[i] 30 | ret[i] = &test 31 | } 32 | 33 | return ret, nil 34 | } 35 | 36 | func (l *YamlFileLoader) SetFileFilter(f string) { 37 | l.fileFilter = f 38 | } 39 | 40 | func (l *YamlFileLoader) parseTestsWithCases(path string) ([]Test, error) { 41 | stat, err := os.Stat(path) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return l.lookupPath(path, stat) 47 | } 48 | 49 | // lookupPath recursively walks over the directory and parses YML files it finds 50 | func (l *YamlFileLoader) lookupPath(path string, fi os.FileInfo) ([]Test, error) { 51 | if !fi.IsDir() { 52 | if !l.fitsFilter(path) { 53 | return []Test{}, nil 54 | } 55 | 56 | return parseTestDefinitionFile(path) 57 | } 58 | files, err := os.ReadDir(path) 59 | if err != nil { 60 | return nil, err 61 | } 62 | var tests []Test 63 | for _, de := range files { 64 | if !de.IsDir() && !isYmlFile(de.Name()) { 65 | continue 66 | } 67 | 68 | fi, err = de.Info() 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | moreTests, err := l.lookupPath(path+"/"+fi.Name(), fi) 74 | if err != nil { 75 | return nil, err 76 | } 77 | tests = append(tests, moreTests...) 78 | } 79 | 80 | return tests, nil 81 | } 82 | 83 | func (l *YamlFileLoader) fitsFilter(fileName string) bool { 84 | if l.fileFilter == "" { 85 | return true 86 | } 87 | 88 | return strings.Contains(fileName, l.fileFilter) 89 | } 90 | 91 | func isYmlFile(name string) bool { 92 | return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") 93 | } 94 | -------------------------------------------------------------------------------- /variables/response_parser.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/tidwall/gjson" 7 | ) 8 | 9 | func FromResponse(varsToSet map[string]string, body string, isJSON bool) (vars *Variables, err error) { 10 | names, paths := split(varsToSet) 11 | 12 | switch { 13 | case isJSON: 14 | vars, err = fromJSON(names, paths, body) 15 | if err != nil { 16 | return nil, err 17 | } 18 | default: 19 | vars, err = fromPlainText(names, body) 20 | if err != nil { 21 | return nil, err 22 | } 23 | } 24 | 25 | return vars, nil 26 | } 27 | 28 | func fromJSON(names, paths []string, body string) (*Variables, error) { 29 | vars := New() 30 | 31 | results := gjson.GetMany(body, paths...) 32 | 33 | for n, res := range results { 34 | if !res.Exists() { 35 | return nil, 36 | fmt.Errorf("path '%s' doesn't exist in given json", paths[n]) 37 | } 38 | 39 | vars.Add(NewVariable(names[n], res.String())) 40 | } 41 | 42 | return vars, nil 43 | } 44 | 45 | func fromPlainText(names []string, body string) (*Variables, error) { 46 | if len(names) != 1 { 47 | return nil, 48 | fmt.Errorf( 49 | "count of variables for plain-text response should be 1, %d given", 50 | len(names), 51 | ) 52 | } 53 | 54 | return New().Add(NewVariable(names[0], body)), nil 55 | } 56 | 57 | // split returns keys and values of given map as separate slices 58 | func split(m map[string]string) (keys, values []string) { 59 | values = make([]string, 0, len(m)) 60 | keys = make([]string, 0, len(m)) 61 | 62 | for k, v := range m { 63 | keys = append(keys, k) 64 | values = append(values, v) 65 | } 66 | 67 | return keys, values 68 | } 69 | -------------------------------------------------------------------------------- /variables/variable.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | ) 8 | 9 | type Variable struct { 10 | name string 11 | value string 12 | defaultValue string 13 | rx *regexp.Regexp 14 | } 15 | 16 | // NewVariable creates new variable with given name and value 17 | func NewVariable(name, value string) *Variable { 18 | 19 | name = regexp.QuoteMeta(name) 20 | rx := regexp.MustCompile(fmt.Sprintf(`{{\s*\$%s\s*}}`, name)) 21 | 22 | return &Variable{ 23 | name: name, 24 | value: value, 25 | defaultValue: value, 26 | rx: rx, 27 | } 28 | } 29 | 30 | func NewFromEnvironment(name string) *Variable { 31 | val := os.Getenv(name) 32 | if val == "" { 33 | return nil 34 | } 35 | 36 | return NewVariable(name, val) 37 | } 38 | 39 | // perform replaces variable in str to its value 40 | // and returns result string 41 | func (v *Variable) Perform(str string) string { 42 | 43 | res := v.rx.ReplaceAllLiteral( 44 | []byte(str), 45 | []byte(v.value), 46 | ) 47 | 48 | return string(res) 49 | } 50 | -------------------------------------------------------------------------------- /variables/variables.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/lamoda/gonkey/models" 7 | ) 8 | 9 | type Variables struct { 10 | variables variables 11 | } 12 | 13 | type variables map[string]*Variable 14 | 15 | var variableRx = regexp.MustCompile(`{{\s*\$(\w+)\s*}}`) 16 | 17 | func New() *Variables { 18 | return &Variables{ 19 | variables: make(variables), 20 | } 21 | } 22 | 23 | // Load adds new variables and replaces values of existing 24 | func (vs *Variables) Load(variables map[string]string) { 25 | for n, v := range variables { 26 | variable := NewVariable(n, v) 27 | 28 | vs.variables[n] = variable 29 | } 30 | } 31 | 32 | // Load adds new variables and replaces values of existing 33 | func (vs *Variables) Set(name, value string) { 34 | v := NewVariable(name, value) 35 | 36 | vs.variables[name] = v 37 | } 38 | 39 | func (vs *Variables) Apply(t models.TestInterface) models.TestInterface { 40 | newTest := t.Clone() 41 | 42 | if vs == nil { 43 | return newTest 44 | } 45 | 46 | newTest.SetQuery(vs.perform(newTest.ToQuery())) 47 | newTest.SetMethod(vs.perform(newTest.GetMethod())) 48 | newTest.SetPath(vs.perform(newTest.Path())) 49 | newTest.SetRequest(vs.perform(newTest.GetRequest())) 50 | newTest.SetDbQueryString(vs.perform(newTest.DbQueryString())) 51 | newTest.SetDbResponseJson(vs.performDbResponses(newTest.DbResponseJson())) 52 | 53 | dbChecks := []models.DatabaseCheck{} 54 | for _, def := range newTest.GetDatabaseChecks() { 55 | def.SetDbQueryString(vs.perform(def.DbQueryString())) 56 | def.SetDbResponseJson(vs.performDbResponses(def.DbResponseJson())) 57 | dbChecks = append(dbChecks, def) 58 | } 59 | newTest.SetDatabaseChecks(dbChecks) 60 | 61 | newTest.SetResponses(vs.performResponses(newTest.GetResponses())) 62 | newTest.SetHeaders(vs.performHeaders(newTest.Headers())) 63 | 64 | if form := newTest.GetForm(); form != nil { 65 | newTest.SetForm(vs.performForm(form)) 66 | } 67 | 68 | for _, definition := range newTest.ServiceMocks() { 69 | vs.performInterface(definition) 70 | } 71 | 72 | return newTest 73 | } 74 | 75 | // Merge adds given variables to set or overrides existed 76 | func (vs *Variables) Merge(vars *Variables) { 77 | for k, v := range vars.variables { 78 | vs.variables[k] = v 79 | } 80 | } 81 | 82 | func (vs *Variables) Len() int { 83 | return len(vs.variables) 84 | } 85 | 86 | func usedVariables(str string) (res []string) { 87 | matches := variableRx.FindAllStringSubmatch(str, -1) 88 | for _, match := range matches { 89 | res = append(res, match[1]) 90 | } 91 | 92 | return res 93 | } 94 | 95 | // perform replaces all variables in str to their values 96 | // and returns result string 97 | func (vs *Variables) perform(str string) string { 98 | varNames := usedVariables(str) 99 | 100 | for _, k := range varNames { 101 | if v := vs.get(k); v != nil { 102 | str = v.Perform(str) 103 | } 104 | } 105 | 106 | return str 107 | } 108 | 109 | func (vs *Variables) performInterface(value interface{}) { 110 | if mapValue, ok := value.(map[interface{}]interface{}); ok { 111 | for key := range mapValue { 112 | if strValue, ok := mapValue[key].(string); ok { 113 | mapValue[key] = vs.perform(strValue) 114 | } else { 115 | vs.performInterface(mapValue[key]) 116 | } 117 | } 118 | } 119 | if arrValue, ok := value.([]interface{}); ok { 120 | for idx := range arrValue { 121 | if strValue, ok := arrValue[idx].(string); ok { 122 | arrValue[idx] = vs.perform(strValue) 123 | } else { 124 | vs.performInterface(arrValue[idx]) 125 | } 126 | } 127 | } 128 | } 129 | 130 | func (vs *Variables) get(name string) *Variable { 131 | v := vs.variables[name] 132 | if v == nil { 133 | v = NewFromEnvironment(name) 134 | } 135 | 136 | return v 137 | } 138 | 139 | func (vs *Variables) performForm(form *models.Form) *models.Form { 140 | files := make(map[string]string, len(form.Files)) 141 | 142 | for k, v := range form.Files { 143 | files[k] = vs.perform(v) 144 | } 145 | 146 | fields := make(map[string]string, len(form.Fields)) 147 | 148 | for k, v := range form.Fields { 149 | fields[k] = vs.perform(v) 150 | } 151 | 152 | return &models.Form{Files: files, Fields: fields} 153 | } 154 | 155 | func (vs *Variables) performHeaders(headers map[string]string) map[string]string { 156 | res := make(map[string]string) 157 | 158 | for k, v := range headers { 159 | res[k] = vs.perform(v) 160 | } 161 | 162 | return res 163 | } 164 | 165 | func (vs *Variables) performResponses(responses map[int]string) map[int]string { 166 | res := make(map[int]string) 167 | 168 | for k, v := range responses { 169 | res[k] = vs.perform(v) 170 | } 171 | 172 | return res 173 | } 174 | 175 | func (vs *Variables) performDbResponses(responses []string) []string { 176 | if responses == nil { 177 | return nil 178 | } 179 | 180 | res := make([]string, len(responses)) 181 | 182 | for idx, v := range responses { 183 | res[idx] = vs.perform(v) 184 | } 185 | 186 | return res 187 | } 188 | 189 | func (vs *Variables) Add(v *Variable) *Variables { 190 | vs.variables[v.name] = v 191 | 192 | return vs 193 | } 194 | -------------------------------------------------------------------------------- /xmlparsing/parser.go: -------------------------------------------------------------------------------- 1 | package xmlparsing 2 | 3 | import ( 4 | "encoding/xml" 5 | ) 6 | 7 | func Parse(rawXML string) (map[string]interface{}, error) { 8 | var n node 9 | if err := xml.Unmarshal([]byte(rawXML), &n); err != nil { 10 | return nil, err 11 | } 12 | 13 | return buildMap([]node{n}), nil 14 | } 15 | 16 | type node struct { 17 | XMLName xml.Name 18 | Attrs []xml.Attr `xml:",any,attr"` 19 | Content string `xml:",chardata"` 20 | Children []node `xml:",any"` 21 | } 22 | 23 | func buildMap(nodes []node) map[string]interface{} { 24 | result := make(map[string]interface{}) 25 | 26 | for name, group := range regroupNodesByName(nodes) { 27 | if len(group) == 0 { 28 | continue 29 | } 30 | 31 | if len(group) == 1 { 32 | result[name] = buildNode(group[0]) 33 | } else { 34 | result[name] = buildArray(group) 35 | } 36 | } 37 | 38 | return result 39 | } 40 | 41 | func buildArray(nodes []node) []interface{} { 42 | arr := make([]interface{}, len(nodes)) 43 | for i, n := range nodes { 44 | arr[i] = buildNode(n) 45 | } 46 | 47 | return arr 48 | } 49 | 50 | func buildNode(n node) interface{} { 51 | hasAttrs := len(n.Attrs) > 0 52 | hasChildren := len(n.Children) > 0 53 | 54 | if hasAttrs && hasChildren { 55 | result := buildMap(n.Children) 56 | result["-attrs"] = buildAttributes(n.Attrs) 57 | 58 | return result 59 | } 60 | 61 | if hasAttrs { 62 | return map[string]interface{}{ 63 | "-attrs": buildAttributes(n.Attrs), 64 | "content": n.Content, 65 | } 66 | } 67 | 68 | if hasChildren { 69 | return buildMap(n.Children) 70 | } 71 | 72 | return n.Content 73 | } 74 | 75 | func buildAttributes(attrs []xml.Attr) map[string]string { 76 | m := make(map[string]string, len(attrs)) 77 | for _, attr := range attrs { 78 | m[joinXMLName(attr.Name)] = attr.Value 79 | } 80 | 81 | return m 82 | } 83 | 84 | func regroupNodesByName(nodes []node) map[string][]node { 85 | grouped := make(map[string][]node) 86 | for _, n := range nodes { 87 | name := joinXMLName(n.XMLName) 88 | 89 | if _, ok := grouped[name]; !ok { 90 | grouped[name] = make([]node, 0) 91 | } 92 | 93 | grouped[name] = append(grouped[name], n) 94 | } 95 | 96 | return grouped 97 | } 98 | 99 | func joinXMLName(xmlName xml.Name) string { 100 | name := xmlName.Local 101 | if xmlName.Space != "" { 102 | name = xmlName.Space + ":" + name 103 | } 104 | 105 | return name 106 | } 107 | -------------------------------------------------------------------------------- /xmlparsing/parser_test.go: -------------------------------------------------------------------------------- 1 | package xmlparsing 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type parseTestCase struct { 11 | name string 12 | rawXml string 13 | expectedJson string 14 | } 15 | 16 | func (tc *parseTestCase) runTest(t *testing.T) { 17 | data, err := Parse(tc.rawXml) 18 | if assert.NoError(t, err) { 19 | j, _ := json.Marshal(data) 20 | assert.JSONEq(t, tc.expectedJson, string(j)) 21 | } 22 | } 23 | 24 | var parseTestCases = []parseTestCase{ 25 | { 26 | name: "TestCase#1", 27 | rawXml: ` 28 | 29 | 30 | 31 | Harry Potter 32 | hpotter@hog.gb 33 | hpotter@gmail.com 34 | 4 Privet Drive 35 | 36 | Hexes 37 | Jinxes 38 | 39 | 40 | `, 41 | expectedJson: ` 42 | { 43 | "Person": { 44 | "Company": "Hogwarts School of Witchcraft and Wizardry", 45 | "FullName": "Harry Potter", 46 | "Email": [ 47 | { 48 | "-attrs": {"where": "work"}, 49 | "content": "hpotter@hog.gb" 50 | }, 51 | { 52 | "-attrs": {"where": "home"}, 53 | "content": "hpotter@gmail.com" 54 | } 55 | ], 56 | "Addr": "4 Privet Drive", 57 | "Group": { 58 | "Value": ["Hexes", "Jinxes"] 59 | } 60 | } 61 | } 62 | `, 63 | }, 64 | { 65 | name: "TestCase#2_namespaces", 66 | rawXml: ` 67 | 68 | Eddie 69 | Dean 70 | 71 | `, 72 | expectedJson: ` 73 | { 74 | "ns1:person": { 75 | "ns2:name": "Eddie", 76 | "ns2:surname": "Dean" 77 | } 78 | } 79 | `, 80 | }, 81 | { 82 | name: "TestCase#3_emptytag", 83 | rawXml: "", 84 | expectedJson: `{ 85 | "body": { 86 | "emptytag": "" 87 | } 88 | } 89 | `, 90 | }, 91 | { 92 | name: "TestCase#4_onlyattributes", 93 | rawXml: ``, 94 | expectedJson: `{ 95 | "body": { 96 | "tag": { 97 | "-attrs": { 98 | "attr1": "attr1_value", 99 | "attr2": "attr2_value" 100 | }, 101 | "content": "" 102 | } 103 | } 104 | } 105 | `, 106 | }, 107 | } 108 | 109 | func TestParse(t *testing.T) { 110 | for _, tc := range parseTestCases { 111 | t.Run(tc.name, tc.runTest) 112 | } 113 | } 114 | --------------------------------------------------------------------------------