From 945d4caf81a7389f9000d70f4a329a49545955cb Mon Sep 17 00:00:00 2001 From: Victor Alves Date: Wed, 21 Aug 2019 09:47:22 +0200 Subject: [PATCH] add log, slugify, herror and test packages Adds log, slugify, herror and test packages initially created for Hungry Hippo project. This also includes a Makefile to help testing all packages, consolidate coverage report, exame Go source and ensure format, a gitlab-cy.yml file. HH-86 --- .gitignore | 4 + .gitlab-ci.yml | 13 ++ Makefile | 50 ++++++ README.md | 2 +- coverage/merge.awk | 5 + coverage/total_coverage.awk | 4 + go.mod | 11 ++ go.sum | 12 ++ herror/example_err_test.go | 29 ++++ herror/herror.go | 155 +++++++++++++++++++ herror/herror_test.go | 150 ++++++++++++++++++ herror/stacktrace.go | 151 ++++++++++++++++++ herror/stacktrace_test.go | 130 ++++++++++++++++ log/example_interface_test.go | 34 ++++ log/gokit.go | 76 +++++++++ log/gokit_test.go | 238 ++++++++++++++++++++++++++++ log/log.go | 55 +++++++ log/log_test.go | 72 +++++++++ log/logctx.go | 37 +++++ log/logctx_test.go | 66 ++++++++ slugify/slugify.go | 50 ++++++ slugify/slugify_test.go | 33 ++++ test/http_mock.go | 187 ++++++++++++++++++++++ test/http_mock_test.go | 283 ++++++++++++++++++++++++++++++++++ test/test.go | 219 ++++++++++++++++++++++++++ test/test_test.go | 164 ++++++++++++++++++++ 26 files changed, 2229 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 Makefile create mode 100644 coverage/merge.awk create mode 100644 coverage/total_coverage.awk create mode 100644 go.mod create mode 100644 go.sum create mode 100644 herror/example_err_test.go create mode 100644 herror/herror.go create mode 100644 herror/herror_test.go create mode 100644 herror/stacktrace.go create mode 100644 herror/stacktrace_test.go create mode 100644 log/example_interface_test.go create mode 100644 log/gokit.go create mode 100644 log/gokit_test.go create mode 100644 log/log.go create mode 100644 log/log_test.go create mode 100644 log/logctx.go create mode 100644 log/logctx_test.go create mode 100644 slugify/slugify.go create mode 100644 slugify/slugify_test.go create mode 100644 test/http_mock.go create mode 100644 test/http_mock_test.go create mode 100644 test/test.go create mode 100644 test/test_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..859e887 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +debug.* +coverage/coverage.* +coverage/*cover.out diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..3cc12db --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,13 @@ +image: golang:1.12 + +stages: + - test + +variables: + GO111MODULE: "on" + +test: + stage: test + script: + - cd ${CI_PROJECT_DIR} + - make diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d258950 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +# this Makefile purpose is to help testing all packages, consolidate coverage +# report, exame Go source and ensure format. +SRC = $(shell find . -type f -name '*.go' | \ + awk -F'__' '{ sub ("/[^/]*$$", "/", $$1); print $1 }' | sort | uniq) + +PACKAGES = log slugify herror test + +all: dep fmt vet test_all + +dep: + @for pkg in $(PACKAGES); do \ + echo "- Checking dependencies for $$pkg"; \ + cd $$pkg && go get && cd ..; \ + done + +fmt: + @echo "- Checking code format" + @GO_FMT=$$(gofmt -e -l ${SRC}) && \ + if [ -n "$$GO_FMT" ]; then \ + echo '$@: Incorrect format has been detected in your code run `make fmt-fix`'; \ + exit 1; \ + fi + +fmt-fix: + @echo "- Checking code format" + @for file in $$(go fmt ${SRC}) ; do \ + echo "$@: $$file fixed and staged"; \ + git add "./${file}"; \ + done + +vet: + @for pkg in $(PACKAGES); do \ + echo "- Examine source code for $$pkg"; \ + cd $$pkg && go vet . && cd ..; \ + done + +test_all: + @rm -f ./coverage/*.out ./coverage/*.html + @for pkg in $(PACKAGES); do \ + echo "- Testing package $$pkg"; \ + go test ./$$pkg -coverprofile=./coverage/$$pkg.cover.out; \ + done + @echo "- Merging coverage output files" + @echo "mode: set" > ./coverage/coverage.out && \ + cat ./coverage/*.cover.out | grep -v mode: | sort -r | \ + awk -f ./coverage/merge.awk >> ./coverage/coverage.out + @go tool cover -html=./coverage/coverage.out \ + -o ./coverage/coverage.html + @go tool cover --func=./coverage/coverage.out | \ + awk -f ./coverage/total_coverage.awk diff --git a/README.md b/README.md index 7137368..ac58b18 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# KIT +# KIT [![pipeline status](https://dev-git.sentia.com/go/kit/badges/master/pipeline.svg)](https://dev-git.sentia.com/go/kit/commits/master) [![coverage report](https://dev-git.sentia.com/go/kit/badges/master/coverage.svg)](https://dev-git.sentia.com/go/kit/commits/master) Bundle with most used packages for development. diff --git a/coverage/merge.awk b/coverage/merge.awk new file mode 100644 index 0000000..4ee31b9 --- /dev/null +++ b/coverage/merge.awk @@ -0,0 +1,5 @@ +{ + if (last != $1) + print $0 + last = $1 +} diff --git a/coverage/total_coverage.awk b/coverage/total_coverage.awk new file mode 100644 index 0000000..c01db2e --- /dev/null +++ b/coverage/total_coverage.awk @@ -0,0 +1,4 @@ +{ + if ($0 ~ /^total\:/) + print "coverage: " $3 " of statements"; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..36c5272 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module dev-git.sentia.com/go/kit + +go 1.12 + +require ( + github.com/davecgh/go-spew v1.1.1 + github.com/go-kit/kit v0.9.0 + github.com/go-logfmt/logfmt v0.4.0 // indirect + golang.org/x/text v0.3.2 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d35ff3b --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-kit/kit v0.9.0 h1:wDJmvq38kDhkVxi50ni9ykkdUr1PKgqKOoi01fa0Mdk= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.4.0 h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/herror/example_err_test.go b/herror/example_err_test.go new file mode 100644 index 0000000..6fd1f2b --- /dev/null +++ b/herror/example_err_test.go @@ -0,0 +1,29 @@ +package herror_test + +import ( + "fmt" + + "dev-git.sentia.com/go/kit/herror" +) + +var ErrTaskFailed = herror.New("task has failed") + +func step() error { + return fmt.Errorf("cannot move") +} + +func performTask() error { + if err := step(); err != nil { + return ErrTaskFailed.Wrap(err) + } + return nil +} + +func Example() { + if err := performTask(); err != nil { + fmt.Print(err) + return + } + // Output: task has failed + //-> cannot move +} diff --git a/herror/herror.go b/herror/herror.go new file mode 100644 index 0000000..9730d9b --- /dev/null +++ b/herror/herror.go @@ -0,0 +1,155 @@ +package herror + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/davecgh/go-spew/spew" + "golang.org/x/xerrors" +) + +// Err represents an error +type Err struct { + error string + wrapped *Err + details string + stack *Stacktrace +} + +type errJSON struct { + E string `json:"error"` + W *Err `json:"wrapped"` + D string `json:"details"` + S *Stacktrace `json:"stack"` +} + +// New returns a new instance for Err type with assigned error +func New(err interface{}) *Err { + newerror := new(Err) + + switch e := err.(type) { + case string: + newerror.error = e + + case error: + if castErr, ok := e.(*Err); ok { + return castErr + } + newerror.error = e.Error() + } + + return newerror +} + +// Wrap set an error that is wrapped by Err +func Wrap(err, errwrapped error) error { + newerr := New(err) + return newerr.Wrap(errwrapped) +} + +// Unwrap returns a wrapped error if present +func Unwrap(err error) error { + return xerrors.Unwrap(err) +} + +// Is reports whether any error in err's chain matches target. +func Is(err, target error) bool { + return xerrors.Is(err, target) +} + +// Wrap set an error that is wrapped by Err +func (e *Err) Wrap(err error) *Err { + wrapped := New(err) + + if deeper := xerrors.Unwrap(err); deeper != nil { + Wrap(wrapped, deeper) + } + + newerr := &Err{ + error: e.error, + wrapped: e.wrapped, + details: e.details, + stack: e.stack, + } + newerr.wrapped = wrapped + return newerr +} + +// Unwrap returns wrapped error +func (e *Err) Unwrap() error { + if e.wrapped == nil { + return nil + } + return e.wrapped +} + +// Is reports whether an error matches. +func (e *Err) Is(err error) bool { + if e.wrapped != nil { + return e.error == err.Error() || e.wrapped.Is(err) + } + return e.error == err.Error() +} + +// CaptureStack sets stack traces when the method is called +func (e *Err) CaptureStack() *Err { + e.stack = NewStacktrace() + return e +} + +// Stack returns full stack traces +func (e *Err) Stack() *Stacktrace { + return e.stack +} + +// AddDetails records variable info to the error mostly for debugging purposes +func (e *Err) AddDetails(v ...interface{}) *Err { + buff := new(bytes.Buffer) + fmt.Fprintln(buff, e.details) + spew.Fdump(buff, v...) + e.details = buff.String() + + return e +} + +// Details returns error's details +func (e *Err) Details() string { + return e.details +} + +// Errors return a composed message of the assigned error e wrapped error +func (e *Err) Error() string { + if e.wrapped == nil { + return e.error + } + + return fmt.Sprintf("%s\n-> %s", e.error, e.wrapped.Error()) +} + +// UnmarshalJSON +func (e *Err) UnmarshalJSON(b []byte) error { + var errJSON errJSON + if err := json.Unmarshal(b, &errJSON); err != nil { + return err + } + + *e = Err{ + error: errJSON.E, + wrapped: errJSON.W, + details: errJSON.D, + stack: errJSON.S, + } + + return nil +} + +// MarshalJSON +func (e *Err) MarshalJSON() ([]byte, error) { + return json.Marshal(errJSON{ + E: e.error, + W: e.wrapped, + D: e.details, + S: e.stack, + }) +} diff --git a/herror/herror_test.go b/herror/herror_test.go new file mode 100644 index 0000000..f88a8dd --- /dev/null +++ b/herror/herror_test.go @@ -0,0 +1,150 @@ +package herror_test + +import ( + "encoding/json" + "fmt" + "testing" + + "dev-git.sentia.com/go/kit/herror" + "dev-git.sentia.com/go/kit/test" +) + +func TestHError(t *testing.T) { + + t.Run("new error", func(t *testing.T) { + errDefault := "this is an error" + for _, tc := range []struct { + m string + input interface{} + expected string + }{ + { + m: "empty", + }, + { + m: "string", + input: errDefault, + expected: errDefault, + }, + { + m: "error", + input: fmt.Errorf(errDefault), + expected: errDefault, + }, + { + m: "herror.Err", + input: herror.New(errDefault), + expected: errDefault, + }, + { + m: "invalid type", + input: 123456789, + expected: "", + }, + } { + t.Run(tc.m, func(t *testing.T) { + test.Equals(t, tc.expected, herror.New(tc.input).Error()) + }) + } + }) + + t.Run("wrap", func(t *testing.T) { + errmain := herror.New("MAIN ERROR") + errfmt := fmt.Errorf("ERROR FORMATTED") + errA := herror.New("ERR A") + errB := herror.New("ERR B") + errC := herror.New("ERR C") + errD := herror.New("ERR D") + errNested := errmain.Wrap( + errA.Wrap( + errB.Wrap( + errC.Wrap(errD), + ), + ), + ) + + for _, tc := range []struct { + m string + err error + expected []error + }{ + { + m: "error", + err: errfmt, + expected: []error{ + errfmt, + }, + }, + { + m: "deeper nested wrap", + err: errNested, + expected: []error{ + errA, errB, errC, errD, + }, + }, + } { + t.Run(tc.m, func(t *testing.T) { + newerr := errmain.Wrap(tc.err) + + for _, e := range tc.expected { + test.Equals(t, true, newerr.Is(e)) + } + }) + } + }) + + t.Run("json marshalling", func(t *testing.T) { + hError := herror.New("this is an error"). + Wrap(fmt.Errorf("this is another error")). + CaptureStack() + marshalled, err := json.Marshal(hError) + test.OK(t, err) + + var unmarshalled *herror.Err + test.OK(t, json.Unmarshal(marshalled, &unmarshalled)) + test.Equals(t, hError, unmarshalled) + }) +} + +func ExampleErr_Wrap() { + errA := herror.New("something went wrong") + errB := fmt.Errorf("because of this error") + newerr := herror.Wrap(errA, errB) + + fmt.Print(herror.Unwrap(newerr), "\n", newerr) + // Output: because of this error + // something went wrong + // -> because of this error +} + +func ExampleErr_Is() { + errA := herror.New("something went wrong") + errB := func() error { + return errA + }() + + fmt.Print(herror.Is(errA, errB)) + // Output: true +} + +func ExampleErr_CaptureStack() { + err := herror.New("something went wrong") + err.CaptureStack() + + fmt.Print(err, "\n", err.Stack().Frames[2].Function) + // Output: something went wrong + // ExampleErr_CaptureStack +} + +func ExampleErr_AddDetails() { + err := herror.New("something went wrong") + err.AddDetails(struct { + number int + }{123}) + + fmt.Print(err, err.Details()) + // Output: something went wrong + // (struct { number int }) { + // number: (int) 123 + // } +} diff --git a/herror/stacktrace.go b/herror/stacktrace.go new file mode 100644 index 0000000..6483e84 --- /dev/null +++ b/herror/stacktrace.go @@ -0,0 +1,151 @@ +package herror + +import ( + "fmt" + "go/build" + "path/filepath" + "runtime" + "strings" +) + +// Stacktrace holds information about the frames of the stack. +type Stacktrace struct { + Frames []Frame `json:"frames,omitempty"` +} + +// Frame represents parsed information from runtime.Frame +type Frame struct { + Function string `json:"function,omitempty"` + Type string `json:"type,omitempty"` + Package string `json:"package,omitempty"` + Filename string `json:"filename,omitempty"` + AbsPath string `json:"abs_path,omitempty"` + Line int `json:"line,omitempty"` + InApp bool `json:"in_app,omitempty"` +} + +// FrameFilter represents function to filter frames +type FrameFilter func(Frame) bool + +const unknown string = "unknown" + +// NewStacktrace creates a stacktrace using `runtime.Callers`. +func NewStacktrace(filters ...FrameFilter) *Stacktrace { + pcs := make([]uintptr, 100) + n := runtime.Callers(1, pcs) + + if n == 0 { + return nil + } + frames := extractFrames(pcs[:n]) + + // default filter + frames = filterFrames(frames, func(f Frame) bool { + return f.Package == "runtime" || f.Package == "testing" || + strings.HasSuffix(f.Package, "/herror") + }) + + for _, filter := range filters { + frames = filterFrames(frames, filter) + } + + stacktrace := Stacktrace{ + Frames: frames, + } + + return &stacktrace +} + +// NewFrame assembles a stacktrace frame out of `runtime.Frame`. +func NewFrame(f runtime.Frame) Frame { + abspath := unknown + filename := unknown + if f.File != "" { + abspath = f.File + _, filename = filepath.Split(f.File) + } + + function := unknown + pkgname := unknown + typer := "" + if f.Function != "" { + pkgname, typer, function = deconstructFunctionName(f.Function) + } + + inApp := func() bool { + out := strings.HasPrefix(abspath, build.Default.GOROOT) || + strings.Contains(pkgname, "vendor") + return !out + }() + + return Frame{ + AbsPath: abspath, + Filename: filename, + Line: f.Line, + Package: pkgname, + Type: typer, + Function: function, + InApp: inApp, + } +} + +func filterFrames(frames []Frame, filter FrameFilter) []Frame { + filtered := make([]Frame, 0, len(frames)) + + for _, frame := range frames { + if filter(frame) { + continue + } + filtered = append(filtered, frame) + } + + return filtered +} + +func extractFrames(pcs []uintptr) []Frame { + frames := make([]Frame, 0, len(pcs)) + callersFrames := runtime.CallersFrames(pcs) + + for { + callerFrame, more := callersFrames.Next() + frames = append([]Frame{ + NewFrame(callerFrame), + }, frames...) + + if !more { + break + } + } + + return frames +} + +func deconstructFunctionName(name string) (pkg string, typer string, function string) { + if i := strings.LastIndex(name, "/"); i != -1 { + pkg = name[:i] + function = name[i+1:] + + if d := strings.Index(function, "."); d != -1 { + pkg = fmt.Sprint(pkg, "/", function[:d]) + function = function[d+1:] + } + + if o, c := strings.LastIndex(name, ".("), strings.LastIndex(name, ")."); o != -1 && c != -1 { + pkg = name[:o] + function = name[c+2:] + + typer = name[o+2 : c] + if i := strings.Index(typer, "*"); i != -1 { + typer = typer[1:] + } + } + return + } + + if i := strings.LastIndex(name, "."); i != -1 { + pkg = name[:i] + function = name[i+1:] + } + + return +} diff --git a/herror/stacktrace_test.go b/herror/stacktrace_test.go new file mode 100644 index 0000000..73ec109 --- /dev/null +++ b/herror/stacktrace_test.go @@ -0,0 +1,130 @@ +package herror_test + +import ( + "runtime" + "testing" + + "dev-git.sentia.com/go/kit/herror" + "dev-git.sentia.com/go/kit/test" +) + +func trace() *herror.Stacktrace { + return herror.NewStacktrace() +} + +func traceStepIn(f []herror.FrameFilter) *herror.Stacktrace { + return traceWithFilter(f) +} + +func traceWithFilter(f []herror.FrameFilter) *herror.Stacktrace { + return herror.NewStacktrace(f...) +} + +func TestStacktrace(t *testing.T) { + t.Run("new", func(t *testing.T) { + stack := trace() + + expectedFrames := []herror.Frame{ + herror.Frame{ + Function: "TestStacktrace.func1", + }, + herror.Frame{ + Function: "trace", + }, + } + + test.Equals(t, len(expectedFrames), len(stack.Frames)) + for i, frame := range expectedFrames { + test.Equals(t, frame.Function, stack.Frames[i].Function) + test.Equals(t, "dev-git.sentia.com/go/kit/herror_test", stack.Frames[i].Package) + test.Equals(t, "stacktrace_test.go", stack.Frames[i].Filename) + } + }) + + t.Run("filter frames", func(t *testing.T) { + + for _, tc := range []struct { + m string + filters []herror.FrameFilter + expected []herror.Frame + }{ + { + m: "no filter", + expected: []herror.Frame{ + herror.Frame{ + Function: "TestStacktrace.func2", + }, + herror.Frame{ + Function: "traceStepIn", + }, + herror.Frame{ + Function: "traceWithFilter", + }, + }, + }, + { + m: "single filter", + expected: []herror.Frame{ + herror.Frame{ + Function: "traceStepIn", + }, + herror.Frame{ + Function: "traceWithFilter", + }, + }, + filters: []herror.FrameFilter{ + func(f herror.Frame) bool { + return f.Function == "TestStacktrace.func2" + }, + }, + }, + { + m: "multiple filters", + expected: []herror.Frame{ + herror.Frame{ + Function: "traceWithFilter", + }, + }, + filters: []herror.FrameFilter{ + func(f herror.Frame) bool { + return f.Function == "TestStacktrace.func2" + }, + func(f herror.Frame) bool { + return f.Function == "traceStepIn" + }, + }, + }, + } { + stack := traceStepIn(tc.filters) + + t.Run(tc.m, func(t *testing.T) { + test.Equals(t, len(tc.expected), len(stack.Frames)) + + for i, frame := range tc.expected { + test.Equals(t, frame.Function, stack.Frames[i].Function) + test.Equals(t, "dev-git.sentia.com/go/kit/herror_test", stack.Frames[i].Package) + test.Equals(t, "stacktrace_test.go", stack.Frames[i].Filename) + } + }) + } + }) +} + +func TestFrame(t *testing.T) { + t.Run("new", func(t *testing.T) { + f := func() herror.Frame { + pc := make([]uintptr, 1) + n := runtime.Callers(0, pc) + test.Assert(t, n == 1, "expected available pcs") + + frames := runtime.CallersFrames(pc) + runtimeframe, _ := frames.Next() + return herror.NewFrame(runtimeframe) + } + + frame := f() + test.Equals(t, "Callers", frame.Function) + test.Equals(t, "runtime", frame.Package) + test.Equals(t, "extern.go", frame.Filename) + }) +} diff --git a/log/example_interface_test.go b/log/example_interface_test.go new file mode 100644 index 0000000..e72a07d --- /dev/null +++ b/log/example_interface_test.go @@ -0,0 +1,34 @@ +package log_test + +import ( + "bytes" + "fmt" + + "dev-git.sentia.com/go/kit/log" +) + +// LoggerTestable represents a data structure for a log context +type LoggerTestable struct { + TestKey string `json:"key"` +} + +func (l LoggerTestable) ContextName() string { return "test" } + +func Example() { + var buff bytes.Buffer + var logger log.Logger + + logger = log.NewLogger(&buff) + + // Please ignore the following line, it was added to allow better + // assertion of the results when logging. + logger = logger.AddContext("time", "-") + + logger = log.Add(logger, LoggerTestable{ + TestKey: "value", + }) + logger.Info("this is an example.") + + fmt.Println(buff.String()) + // Output: {"message":"this is an example.","test":{"key":"value"},"time":"-"} +} diff --git a/log/gokit.go b/log/gokit.go new file mode 100644 index 0000000..04e38a0 --- /dev/null +++ b/log/gokit.go @@ -0,0 +1,76 @@ +package log + +import ( + "io" + "runtime" + "strconv" + "strings" + "time" + + kitlog "github.com/go-kit/kit/log" +) + +type GoKit struct { + debugEnabled bool + info kitlog.Logger + debug kitlog.Logger +} + +func caller(depth int) kitlog.Valuer { + return func() interface{} { + _, file, line, _ := runtime.Caller(depth) + return file + ":" + strconv.Itoa(line) + } +} + +func newGoKitLogger(logWriter io.Writer) Logger { + w := kitlog.NewSyncWriter(logWriter) + t := kitlog.TimestampFormat(time.Now, time.RFC3339) + info := kitlog.With(kitlog.NewJSONLogger(w), "time", t) + debug := kitlog.With(info, "debug", true, "caller", caller(4)) + + return &GoKit{ + info: info, + debug: debug, + } +} + +// AddContext attaches a key-value information to the log message +func (gk *GoKit) AddContext(contextKey string, contextValue interface{}) Logger { + return &GoKit{ + debugEnabled: gk.debugEnabled, + info: kitlog.With(gk.info, contextKey, contextValue), + debug: kitlog.With(gk.debug, contextKey, contextValue), + } +} + +// Info writes out the log message +func (gk *GoKit) Info(message string) error { + return gk.info.Log("message", normalizeString(message)) +} + +// Debug writes out the log message when debug is enabled +func (gk *GoKit) Debug(message string) error { + if gk.debugEnabled { + return gk.debug.Log("message", normalizeString(message)) + } + return nil +} + +// DebugEnabled sets debug flag to enable or disabled +func (gk *GoKit) DebugEnabled(enable bool) { + gk.debugEnabled = enable +} + +// DebugStatus returns whether or not debug is enabled +func (gk *GoKit) DebugStatus() bool { + return gk.debugEnabled +} + +func normalizeString(s string) string { + ss := strings.Fields(s) + if len(ss) == 0 { + return "(MISSING)" + } + return strings.Join(ss, " ") +} diff --git a/log/gokit_test.go b/log/gokit_test.go new file mode 100644 index 0000000..56b9331 --- /dev/null +++ b/log/gokit_test.go @@ -0,0 +1,238 @@ +package log_test + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + + "dev-git.sentia.com/go/kit/log" + "dev-git.sentia.com/go/kit/test" +) + +type testLogWriter struct { + Logs []string +} + +func newLogWriter() *testLogWriter { + return &testLogWriter{} +} + +func (t *testLogWriter) Write(p []byte) (n int, err error) { + t.Logs = append(t.Logs, string(p)) + return +} + +func (t *testLogWriter) count() int { + return len(t.Logs) +} + +func (t *testLogWriter) last() string { + if len(t.Logs) == 0 { + return "" + } + + return t.Logs[len(t.Logs)-1] +} + +func TestGoKit(t *testing.T) { + t.Run("new-logger", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := log.NewLogger(&buf) + test.NotZero(t, logger) + }) + + t.Run("info", func(t *testing.T) { + t.Parallel() + + logWriter := newLogWriter() + logger := log.NewLogger(logWriter) + test.NotZero(t, logger) + test.Equals(t, 0, logWriter.count()) + + msg := "log this" + test.OK(t, logger.Info(msg)) + test.Equals(t, 1, logWriter.count()) + testLogLine(t, false, msg, logWriter.last()) + + msg = "log again" + test.OK(t, logger.Info(msg)) + test.Equals(t, 2, logWriter.count()) + testLogLine(t, false, msg, logWriter.last()) + }) + + t.Run("debug", func(t *testing.T) { + t.Parallel() + + logWriter := newLogWriter() + logger := log.NewLogger(logWriter) + test.NotZero(t, logger) + + // starts with debug disabled + test.Equals(t, false, logger.DebugStatus()) + + msg := "log this" + logger.DebugEnabled(true) + test.Equals(t, true, logger.DebugStatus()) + logger.Debug(msg) + test.Equals(t, 1, logWriter.count()) + testLogLine(t, true, msg, logWriter.last()) + + msg = "log again" + logger.DebugEnabled(false) + test.Equals(t, false, logger.DebugStatus()) + logger.Debug(msg) + test.Equals(t, 1, logWriter.count()) + }) + + t.Run("normalize-string", func(t *testing.T) { + t.Parallel() + + missingMsg := "(MISSING)" + for _, tc := range []struct { + context string + logMessage string + expResult string + }{ + { + context: "empty string", + logMessage: "", + expResult: missingMsg, + }, + { + context: "single whitespace", + logMessage: " ", + expResult: missingMsg, + }, + { + context: "multiple whitespace", + logMessage: "\t ", + expResult: missingMsg, + }, + { + context: "simple line", + logMessage: "just some text", + expResult: "just some text", + }, + { + context: "multiline message", + logMessage: "one\ntwo\nthree", + expResult: "one two three", + }, + } { + t.Log(tc.context) + + logWriter := newLogWriter() + logger := log.NewLogger(logWriter) + test.NotZero(t, logger) + logger.DebugEnabled(true) + + test.OK(t, logger.Info(tc.logMessage)) + testLogLine(t, false, tc.expResult, logWriter.last()) + + test.OK(t, logger.Debug(tc.logMessage)) + testLogLine(t, true, tc.expResult, logWriter.last()) + } + }) + + t.Run("add-context", func(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + message string + contextKey string + contextValue interface{} + }{ + { + message: "empty key", + contextValue: "value", + }, + { + message: "empty value", + contextKey: "a key", + }, + + { + message: "not empty", + contextKey: "a key", + contextValue: "value", + }, + { + message: "slice", + contextKey: "a key", + contextValue: []string{"one", "two"}, + }, + { + message: "map", + contextValue: map[string]interface{}{ + "key1": "value1", + "key2": 2, + "key3": nil, + }, + }, + { + message: "struct", + contextValue: struct { + Key1 string + Key2 int + Key3 interface{} + key4 string + }{ + Key1: "value1", + Key2: 2, + Key3: nil, + key4: "unexported", + }, + }, + } { + t.Log(tc.message) + + logWriter := newLogWriter() + logger := log.NewLogger(logWriter) + test.NotZero(t, logger) + + test.OK(t, logger.AddContext(tc.contextKey, tc.contextValue).Info("log message")) + test.Equals(t, 1, logWriter.count()) + testContext(t, tc.contextKey, tc.contextValue, logWriter.last()) + + } + }) +} + +func testLogLine(t *testing.T, debug bool, message, line string) { + test.Equals(t, true, isValidJSON(line)) + test.Includes(t, `"time":`, line) + test.Includes(t, fmt.Sprintf(`"message":%q`, message), line) + + if debug { + test.Includes(t, `"debug":true`, line) + test.Includes(t, `"caller":"`, line) + } + +} + +func testContext(t *testing.T, key string, value interface{}, line string) { + value, err := toJSON(value) + test.OK(t, err) + test.Includes(t, fmt.Sprintf(`%q:%s`, key, value), line) +} + +func toJSON(i interface{}) (string, error) { + j, err := json.Marshal(i) + if err != nil { + return "", err + } + + return string(j), nil +} + +func isValidJSON(s string) bool { + var js map[string]interface{} + err := json.Unmarshal([]byte(s), &js) + if err != nil { + return false + } + + return true +} diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..af2bd21 --- /dev/null +++ b/log/log.go @@ -0,0 +1,55 @@ +// Package log implements a generic interface to log +package log + +import ( + "encoding/json" + "io" +) + +// Logger represents a log implementation +type Logger interface { + AddContext(string, interface{}) Logger + Info(string) error + Debug(string) error + DebugEnabled(bool) + DebugStatus() bool +} + +// NewLogger returns a Logger implementation +func NewLogger(logWriter io.Writer) Logger { + return newGoKitLogger(logWriter) +} + +type loggerWriter struct { + Logger +} + +func (l *loggerWriter) Write(p []byte) (n int, err error) { + var fields map[string]interface{} + + if err = json.Unmarshal(p, &fields); err != nil { + l.Logger.Info(string(p)) + return + } + + delete(fields, "time") + + var message string + if m, ok := fields["message"]; ok { + message = m.(string) + delete(fields, "message") + } + + if len(fields) == 0 { + l.Logger.Info(message) + return + } + + l.Logger.AddContext("fields", fields).Info(message) + return +} + +// NewWriter returns io.Writer implementation based on a logger +func NewWriter(l Logger) io.Writer { + return &loggerWriter{l} +} diff --git a/log/log_test.go b/log/log_test.go new file mode 100644 index 0000000..8935747 --- /dev/null +++ b/log/log_test.go @@ -0,0 +1,72 @@ +package log_test + +import ( + "bytes" + "fmt" + "testing" + + "dev-git.sentia.com/go/kit/log" + "dev-git.sentia.com/go/kit/test" +) + +func TestNewWriter(t *testing.T) { + + defaultMessage := "this is a test" + for _, tc := range []struct { + m string + message string + expected []string + notExpected []string + }{ + { + m: "string input", + message: defaultMessage, + expected: []string{ + fmt.Sprintf(`"message":%q`, defaultMessage), + }, + }, + { + m: "json map", + message: fmt.Sprintf(`{"message":%q, "custom": "value"}`, defaultMessage), + expected: []string{ + fmt.Sprintf(`"message":%q`, defaultMessage), + `"fields":{`, + `"custom":"value"`, + }, + }, + { + m: "json map correct time", + message: fmt.Sprintf(`{"message":%q, "time": "value"}`, defaultMessage), + expected: []string{ + fmt.Sprintf(`"message":%q`, defaultMessage), + `"time":"`, + }, + notExpected: []string{ + `"fields":{`, + `"time": "value"`, + }, + }, + } { + var buf bytes.Buffer + logger := log.NewLogger(&buf) + + t.Run(tc.m, func(t *testing.T) { + w := log.NewWriter(logger) + w.Write([]byte(tc.message)) + for _, e := range tc.expected { + test.Includes(t, e, buf.String()) + } + for _, e := range tc.notExpected { + test.NotIncludes(t, e, buf.String()) + } + }) + } +} + +func TestLog(t *testing.T) { + t.Run("new-logger", func(t *testing.T) { + var buf bytes.Buffer + logger := log.NewLogger(&buf) + test.NotZero(t, logger) + }) +} diff --git a/log/logctx.go b/log/logctx.go new file mode 100644 index 0000000..ecf60f7 --- /dev/null +++ b/log/logctx.go @@ -0,0 +1,37 @@ +package log + +import ( + "runtime" + "strconv" +) + +// Contexter ensures type is intentionally a log context +type Contexter interface { + ContextName() string +} + +// Caller represents a runtime file:line caller for log context +type Caller func() string + +// ContextName returns the key for the log context +func (c Caller) ContextName() string { return "caller" } + +// NewCaller returns a log context for runtime file caller with full path +func NewCaller(depth int) Caller { + return func() string { + _, file, line, _ := runtime.Caller(depth) + return file + ":" + strconv.Itoa(line) + } +} + +// Add adds a contexter interface to a Logger +func Add(l Logger, cc ...Contexter) Logger { + for _, c := range cc { + if caller, ok := c.(Caller); ok { + l = l.AddContext(c.ContextName(), caller()) + continue + } + l = l.AddContext(c.ContextName(), c) + } + return l +} diff --git a/log/logctx_test.go b/log/logctx_test.go new file mode 100644 index 0000000..69d4360 --- /dev/null +++ b/log/logctx_test.go @@ -0,0 +1,66 @@ +package log_test + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "dev-git.sentia.com/go/kit/log" + "dev-git.sentia.com/go/kit/test" +) + +type ( + ContextA string + ContextB string +) + +func (l ContextA) ContextName() string { return "context_a" } +func (l ContextB) ContextName() string { return "context_b" } + +func TestLogContext(t *testing.T) { + t.Run("new caller", func(t *testing.T) { + caller := log.NewCaller(1) + s := caller() + test.Includes(t, "logctx_test.go:", s) + }) + + t.Run("add context", func(t *testing.T) { + var buff bytes.Buffer + logger := log.NewLogger(&buff) + + for _, tc := range []struct { + m string + cc []log.Contexter + }{ + { + m: "single context", + cc: []log.Contexter{ContextA("AA")}, + }, + { + m: "multiple context", + cc: []log.Contexter{ContextA("AA"), ContextB("BB")}, + }, + { + m: "with caller context", + cc: []log.Contexter{ContextA("AA"), log.NewCaller(0)}, + }, + } { + t.Run(tc.m, func(t *testing.T) { + log.Add(logger, tc.cc...).Info("something") + for _, context := range tc.cc { + switch s := context.(type) { + case ContextA, ContextB: + test.Includes(t, fmt.Sprintf("%q:%q", s.ContextName(), s), buff.String()) + + case log.Caller: + file := s() + i := strings.LastIndexByte(file, ':') + test.Includes(t, fmt.Sprintf(`%q:"%s`, s.ContextName(), file[:i+1]), buff.String()) + + } + } + }) + } + }) +} diff --git a/slugify/slugify.go b/slugify/slugify.go new file mode 100644 index 0000000..858041b --- /dev/null +++ b/slugify/slugify.go @@ -0,0 +1,50 @@ +package slugify + +import ( + "fmt" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +var SKIP = []*unicode.RangeTable{ + unicode.Mark, + unicode.Sk, + unicode.Lm, +} + +var SAFE = []*unicode.RangeTable{ + unicode.Letter, + unicode.Number, +} + +// Slugify a string. The result will only contain lowercase letters, +// digits and dashes. It will not begin or end with a dash, and it +// will not contain runs of multiple dashes. +// +// It is NOT forced into being ASCII, but may contain any Unicode +// characters, with the above restrictions. +func Slugify(text string) string { + buf := make([]rune, 0, len(text)) + dash := false + for _, r := range norm.NFKD.String(text) { + switch { + case unicode.IsOneOf(SAFE, r): + buf = append(buf, unicode.ToLower(r)) + dash = true + case unicode.IsOneOf(SKIP, r): + case dash: + buf = append(buf, '-') + dash = false + } + } + if i := len(buf) - 1; i >= 0 && buf[i] == '-' { + buf = buf[:i] + } + return string(buf) +} + +// Slugifyf slugfy a formated string +func Slugifyf(format string, a ...interface{}) string { + return Slugify(fmt.Sprintf(format, a...)) +} diff --git a/slugify/slugify_test.go b/slugify/slugify_test.go new file mode 100644 index 0000000..027a50c --- /dev/null +++ b/slugify/slugify_test.go @@ -0,0 +1,33 @@ +package slugify_test + +import ( + "testing" + + "dev-git.sentia.com/go/kit/slugify" +) + +var tests = []struct{ in, out string }{ + {"simple test", "simple-test"}, + {"I'm go developer", "i-m-go-developer"}, + {"Simples código em go", "simples-codigo-em-go"}, + {"日本語の手紙をテスト", "日本語の手紙をテスト"}, + {"--->simple test<---", "simple-test"}, +} + +func TestSlugify(t *testing.T) { + for _, test := range tests { + if out := slugify.Slugify(test.in); out != test.out { + t.Errorf("%q: %q != %q", test.in, out, test.out) + } + } +} + +func TestSlugifyf(t *testing.T) { + for _, test := range tests { + t.Run(test.out, func(t *testing.T) { + if out := slugify.Slugifyf("%s", test.in); out != test.out { + t.Errorf("%q: %q != %q", test.in, out, test.out) + } + }) + } +} diff --git a/test/http_mock.go b/test/http_mock.go new file mode 100644 index 0000000..3a70ff5 --- /dev/null +++ b/test/http_mock.go @@ -0,0 +1,187 @@ +package test + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" +) + +// MockResponse represents a response for the mock server to serve +type MockResponse struct { + StatusCode int + Headers http.Header + Body []byte +} + +type MockServerProcedure struct { + URL string + HTTPMethod string + Response MockResponse +} + +// MockRecorder provides a way to record request information from every +// successful request. +type MockRecorder interface { + Record(r *http.Request) +} + +// recordedRequest represents recorded structured information about each request +type recordedRequest struct { + hits int + requests []*http.Request + bodies [][]byte +} + +// MockAssertion represents a common assertion for requests +type MockAssertion struct { + indexes map[string]int // indexation for key + recs []recordedRequest // request catalog +} + +// Record records request hit information +func (m *MockAssertion) Record(r *http.Request) { + k := m.index(r.RequestURI, r.Method) + + b, _ := ioutil.ReadAll(r.Body) + if len(b) == 0 { + b = nil + } + + if k < 0 { + m.newIndex(r.RequestURI, r.Method) + m.recs = append(m.recs, recordedRequest{ + hits: 1, + requests: []*http.Request{r}, + bodies: [][]byte{b}, + }) + return + } + + m.recs[k].hits++ + m.recs[k].requests = append(m.recs[k].requests, r) + m.recs[k].bodies = append(m.recs[k].bodies, b) +} + +// Hits returns the number of hits for a uri and method +func (m *MockAssertion) Hits(uri, method string) int { + k := m.index(uri, method) + if k < 0 { + return 0 + } + + return m.recs[k].hits +} + +// Headers returns a slice of request headers +func (m *MockAssertion) Headers(uri, method string) []http.Header { + k := m.index(uri, method) + if k < 0 { + return nil + } + + headers := make([]http.Header, len(m.recs[k].requests)) + for i, r := range m.recs[k].requests { + + // remove default headers + if _, ok := r.Header["Content-Length"]; ok { + r.Header.Del("Content-Length") + } + + if v, ok := r.Header["User-Agent"]; ok { + if _, yes := equals([]string{"Go-http-client/1.1"}, v); yes { + r.Header.Del("User-Agent") + } + } + + if v, ok := r.Header["Accept-Encoding"]; ok { + if _, yes := equals([]string{"gzip"}, v); yes { + r.Header.Del("Accept-Encoding") + } + } + + if len(r.Header) == 0 { + continue + } + + headers[i] = r.Header + } + return headers +} + +// Body returns request body +func (m *MockAssertion) Body(uri, method string) [][]byte { + k := m.index(uri, method) + if k < 0 { + return nil + } + + return m.recs[k].bodies +} + +// Reset sets all unexpected properties to their zero value +func (m *MockAssertion) Reset() error { + m.indexes = make(map[string]int) + m.recs = make([]recordedRequest, 0) + return nil +} + +// index indexes a key composed of the uri and method and returns the position +// for this key in a list if it was indexed before. +func (m *MockAssertion) index(uri, method string) int { + if isZero(m.indexes) { + m.indexes = make(map[string]int) + } + + k := strings.ToLower(uri + method) + + if i, ok := m.indexes[k]; ok { + return i + } + + return -1 +} + +func (m *MockAssertion) newIndex(uri, method string) int { + k := strings.ToLower(uri + method) + m.indexes[k] = len(m.indexes) + return m.indexes[k] +} + +// NewMockServer return a mock HTTP server to test requests +func NewMockServer(rec MockRecorder, procedures ...MockServerProcedure) *httptest.Server { + var handler http.Handler + + handler = http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + + for _, proc := range procedures { + + if proc.URL == r.URL.RequestURI() && proc.HTTPMethod == r.Method { + + headers := w.Header() + for hkey, hvalue := range proc.Response.Headers { + headers[hkey] = hvalue + } + + code := proc.Response.StatusCode + if code == 0 { + code = http.StatusOK + } + + w.WriteHeader(code) + w.Write(proc.Response.Body) + + if rec != nil { + rec.Record(r) + } + return + } + } + + w.WriteHeader(http.StatusNotFound) + return + }) + + return httptest.NewServer(handler) +} diff --git a/test/http_mock_test.go b/test/http_mock_test.go new file mode 100644 index 0000000..2a24e92 --- /dev/null +++ b/test/http_mock_test.go @@ -0,0 +1,283 @@ +package test_test + +import ( + "bytes" + "encoding/base64" + "fmt" + "io/ioutil" + "net/http" + "net/textproto" + "net/url" + "testing" + + "dev-git.sentia.com/go/kit/test" +) + +func TestHTTPMock(t *testing.T) { + + procs := []test.MockServerProcedure{ + test.MockServerProcedure{ + URL: "/", + HTTPMethod: "GET", + Response: test.MockResponse{ + Body: []byte("getRoot"), + }, + }, + test.MockServerProcedure{ + URL: "/", + HTTPMethod: "POST", + Response: test.MockResponse{ + Body: []byte("postRoot"), + }, + }, + test.MockServerProcedure{ + URL: "/get/header", + HTTPMethod: "GET", + Response: test.MockResponse{ + StatusCode: http.StatusAccepted, + Headers: http.Header{ + "some-key": []string{"some-value"}, + }, + Body: []byte("getResponseHeader"), + }, + }, + test.MockServerProcedure{ + URL: "/get/auth", + HTTPMethod: "GET", + Response: test.MockResponse{ + Body: []byte("getRootAuth"), + }, + }, + test.MockServerProcedure{ + URL: "/my_account", + HTTPMethod: "GET", + Response: test.MockResponse{ + Body: []byte("getAccount"), + }, + }, + test.MockServerProcedure{ + URL: "/my_account.json", + HTTPMethod: "GET", + Response: test.MockResponse{ + Body: []byte("getAccountJSON"), + }, + }, + } + + var record test.MockAssertion + testMockServer := test.NewMockServer(&record, procs...) + + type mockRequest struct { + uri string + method string + user, password string + header http.Header + body []byte + hits int + } + + canonical := textproto.CanonicalMIMEHeaderKey + + for _, tc := range []struct { + m string + request mockRequest + response test.MockResponse + }{ + { + m: "method get root path", + request: mockRequest{ + uri: "/", + method: http.MethodGet, + hits: 2, + }, + response: test.MockResponse{ + StatusCode: http.StatusOK, + Body: []byte("getRoot"), + }, + }, + { + m: "method get root path with headers", + request: mockRequest{ + uri: "/", + method: http.MethodGet, + header: http.Header{ + canonical("input-header-key"): []string{"Just the Value"}, + }, + hits: 2, + }, + response: test.MockResponse{ + StatusCode: http.StatusOK, + Body: []byte("getRoot"), + }, + }, + { + m: "method get root path with body", + request: mockRequest{ + uri: "/", + method: http.MethodGet, + body: []byte("input"), + hits: 2, + }, + response: test.MockResponse{ + StatusCode: http.StatusOK, + Body: []byte("getRoot"), + }, + }, + { + m: "method get root path with headers and body", + request: mockRequest{ + uri: "/", + method: http.MethodGet, + header: http.Header{ + canonical("input-header-key"): []string{"Just the Value"}, + }, + body: []byte("input"), + hits: 2, + }, + response: test.MockResponse{ + StatusCode: http.StatusOK, + Body: []byte("getRoot"), + }, + }, + { + m: "method post root path", + request: mockRequest{ + uri: "/", + method: http.MethodPost, + hits: 2, + }, + response: test.MockResponse{ + StatusCode: http.StatusOK, + Body: []byte("postRoot"), + }, + }, + { + m: "method post root path with basic authentication", + request: mockRequest{ + uri: "/", + method: http.MethodPost, + user: "my-user", + password: "my-password", + hits: 1, + }, + response: test.MockResponse{ + StatusCode: http.StatusOK, + Body: []byte("postRoot"), + }, + }, + { + m: "unmatched uri path", + request: mockRequest{ + uri: "/unmatched", + method: http.MethodGet, + hits: 0, + }, + response: test.MockResponse{ + StatusCode: http.StatusNotFound, + Body: []byte{}, + }, + }, + } { + t.Run(tc.m, func(t *testing.T) { + test.OK(t, record.Reset()) + + for _ = range make([]int, tc.request.hits) { + url, errU := url.Parse(testMockServer.URL + tc.request.uri) + test.OK(t, errU) + + req, errReq := http.NewRequest( + tc.request.method, + url.String(), + bytes.NewReader(tc.request.body), + ) + test.OK(t, errReq) + + for k, v := range tc.request.header { + req.Header[k] = v + } + + // testing authentication in the request + if len(tc.request.user) > 0 || len(tc.request.password) > 0 { + req.SetBasicAuth(tc.request.user, tc.request.password) + + if tc.request.header == nil { + tc.request.header = make(http.Header) + } + + auth := tc.request.user + ":" + tc.request.password + tc.request.header["Authorization"] = []string{ + fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(auth)))} + } + + client := new(http.Client) + resp, errResp := client.Do(req) + test.OK(t, errResp) + + actualBody, err := ioutil.ReadAll(resp.Body) + test.OK(t, err) + defer resp.Body.Close() + + test.Equals(t, tc.response.StatusCode, resp.StatusCode) + test.Equals(t, tc.response.Body, actualBody) + } + test.Equals(t, tc.request.hits, record.Hits(tc.request.uri, tc.request.method)) + + // assert if all request had the correct header + for _, h := range record.Headers(tc.request.uri, tc.request.method) { + test.Equals(t, tc.request.header, h) + } + + // assert if all request had the correct body + for _, b := range record.Body(tc.request.uri, tc.request.method) { + test.Equals(t, tc.request.body, b) + } + }) + } +} + +func ExampleMockAssertion_Hits() { + var record test.MockAssertion + uri := "/" + + server := test.NewMockServer(&record, test.MockServerProcedure{ + URL: uri, + HTTPMethod: http.MethodGet, + }) + + http.Get(server.URL) + + fmt.Println(record.Hits(uri, http.MethodGet)) + // Output: 1 +} + +func ExampleMockAssertion_Headers() { + var record test.MockAssertion + uri := "/" + + server := test.NewMockServer(&record, test.MockServerProcedure{ + URL: uri, + HTTPMethod: http.MethodPost, + }) + + http.Post(server.URL, "application/json", nil) + + fmt.Println(record.Headers(uri, http.MethodPost)) + // Output: [map[Content-Type:[application/json]]] +} + +func ExampleMockAssertion_Body() { + var record test.MockAssertion + uri := "/" + + server := test.NewMockServer(&record, test.MockServerProcedure{ + URL: uri, + HTTPMethod: http.MethodPost, + }) + + http.Post(server.URL, "text/plain", bytes.NewBufferString("hi there")) + + for _, b := range record.Body(uri, http.MethodPost) { + fmt.Println(string(b)) + } + // Output: hi there +} diff --git a/test/test.go b/test/test.go new file mode 100644 index 0000000..204cb9f --- /dev/null +++ b/test/test.go @@ -0,0 +1,219 @@ +package test + +import ( + "bytes" + "fmt" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +// Assert fails the test if the condition is false. +func Assert(tb testing.TB, condition bool, msg string, v ...interface{}) { + if !condition { + b := bytes.NewBufferString("\t" + msg + "\n") + fmt.Fprintln(b, v...) + print(b) + tb.FailNow() + } +} + +// OK fails the test if an err is not nil. +func OK(tb testing.TB, err error) { + if err != nil { + print(bytes.NewBufferString( + fmt.Sprintf("\tUnexpected error: %v", err))) + tb.FailNow() + } +} + +// NotNil fails the test if anything is nil. +func NotNil(tb testing.TB, anything interface{}) { + if isNil(anything) { + print(bytes.NewBufferString("\tExpected non-nil value")) + tb.FailNow() + } +} + +// Nil fails the test if something is NOT nil. +func Nil(tb testing.TB, something interface{}) { + if !isNil(something) { + print(bytes.NewBufferString( + fmt.Sprintf("\tExpected value to be nil\n\n\tgot: %#v", something))) + tb.FailNow() + } +} + +// Equals fails the test if exp is not equal to act. +func Equals(tb testing.TB, exp, act interface{}) { + if b, ok := equals(exp, act); !ok { + print(b) + tb.FailNow() + } +} + +func equals(exp, act interface{}) (b *bytes.Buffer, ok bool) { + b = new(bytes.Buffer) + fmt.Fprintf(b, "\texp: %s\n\n\tgot: %s", stringer(exp), stringer(act)) + return b, reflect.DeepEqual(exp, act) +} + +// Includes fails if expected string is NOT included in the actual string +func Includes(tb testing.TB, exp string, act ...string) { + for _, a := range act { + if strings.Index(a, exp) >= 0 { + return + } + } + + print(bytes.NewBufferString( + fmt.Sprintf("\tExpected to include: %s\n\n\tgot: %s", exp, act))) + tb.FailNow() +} + +// NotIncludes fails if expected string is included in the actual string +func NotIncludes(tb testing.TB, exp string, act ...string) { + for _, a := range act { + if strings.Index(a, exp) >= 0 { + print(bytes.NewBufferString( + fmt.Sprintf("\tNOT expected to include: %#v\n\n\tgot: %#v", exp, act))) + tb.FailNow() + } + } +} + +// IncludesI fails if expected string is NOT included in the actuall string (ignore case) +func IncludesI(tb testing.TB, exp string, act ...string) { + for _, a := range act { + if strings.Index(strings.ToLower(a), strings.ToLower(exp)) >= 0 { + return + } + } + + print(bytes.NewBufferString( + fmt.Sprintf("\tExpected to include: %s\n\n\tgot: %s", exp, act))) + tb.FailNow() +} + +// IncludesSlice fails if all of expected items is NOT included in the actual slice +func IncludesSlice(tb testing.TB, exp, act interface{}) { + if reflect.ValueOf(exp).Kind() != reflect.Slice { + panic("IncludesSlice requires a expected slice") + } + + if reflect.ValueOf(act).Kind() != reflect.Slice { + panic("IncludesSlice requires a actual slice") + } + + expSlice := reflect.ValueOf(exp) + actSlice := reflect.ValueOf(act) + + expLen := expSlice.Len() + actLen := actSlice.Len() + + if expLen <= actLen { + var score int + for idxA := 0; idxA < actLen; idxA++ { + for idxE := 0; idxE < expLen; idxE++ { + if reflect.DeepEqual(expSlice.Index(idxE).Interface(), actSlice.Index(idxA).Interface()) { + score++ + } + } + } + if score == expLen { + return + } + } + + print(bytes.NewBufferString( + fmt.Sprintf("\tExpected to all items to be included: %+v\n\n\tIn: %+v", exp, act))) + tb.FailNow() +} + +// IncludesMap fails if all of expected map entries are NOT included in the actuall map +func IncludesMap(tb testing.TB, exp, act interface{}) { + if b, ok := includesMap(exp, act); !ok { + print(b) + tb.FailNow() + } +} + +func includesMap(exp, act interface{}) (b *bytes.Buffer, ok bool) { + if reflect.ValueOf(exp).Kind() != reflect.Map { + panic("IncludesMap requires a expected map") + } + + if reflect.ValueOf(act).Kind() != reflect.Map { + panic("IncludesMap requires a actual map") + } + + expMap := reflect.ValueOf(exp) + actMap := reflect.ValueOf(act) + + expLen := len(expMap.MapKeys()) + actLen := len(actMap.MapKeys()) + + if expLen <= actLen { + var score int + for _, actKey := range actMap.MapKeys() { + for _, expKey := range expMap.MapKeys() { + if reflect.DeepEqual(expKey.Interface(), actKey.Interface()) && + reflect.DeepEqual(expMap.MapIndex(expKey).Interface(), actMap.MapIndex(actKey).Interface()) { + score++ + } + } + } + + if score == expLen { + return b, true + } + } + + fmt.Fprintf(b, "\tExpected to all items to be included: %+v\n\n\tIn: %+v", exp, act) + return b, false +} + +// Zero fails the test if anything is NOT nil. +func Zero(tb testing.TB, anything interface{}) { + if !isZero(anything) { + print(bytes.NewBufferString("\tExpected zero value")) + tb.FailNow() + } +} + +// NotZero fails the test if anything is NOT nil. +func NotZero(tb testing.TB, anything interface{}) { + if isZero(anything) { + print(bytes.NewBufferString("\tExpected non-zero value")) + tb.FailNow() + } +} + +func isZero(anything interface{}) bool { + refZero := reflect.Zero(reflect.ValueOf(anything).Type()) + return reflect.DeepEqual(refZero.Interface(), anything) +} + +func isNil(anything interface{}) bool { + return reflect.DeepEqual(reflect.ValueOf(nil), reflect.ValueOf(anything)) || + reflect.ValueOf(anything).IsNil() +} + +func print(b *bytes.Buffer) { + _, file, line, _ := runtime.Caller(2) + fmt.Printf("\033[31m%s:%d:\n\n%s\033[39m\n\n", + filepath.Base(file), line, b.String()) +} + +func stringer(a interface{}) string { + switch s := a.(type) { + case string: + return s + case []byte: + return string(s) + default: + return fmt.Sprintf("%#v", s) + } +} diff --git a/test/test_test.go b/test/test_test.go new file mode 100644 index 0000000..a942102 --- /dev/null +++ b/test/test_test.go @@ -0,0 +1,164 @@ +package test_test + +import ( + "errors" + "testing" + + "dev-git.sentia.com/go/kit/test" +) + +func TestTest(t *testing.T) { + t.Run("assert", func(t *testing.T) { + condition := true + test.Assert(t, condition, "expected condition to be true") + }) + + t.Run("ok", func(t *testing.T) { + var condition error + test.Assert(t, condition == nil, "expected condition to be true") + test.OK(t, condition) + }) + + t.Run("not-nil", func(t *testing.T) { + var condition error + condition = errors.New("some error here") + test.NotNil(t, condition) + }) + + t.Run("nil", func(t *testing.T) { + var condition error + test.Nil(t, condition) + }) + + t.Run("equals", func(t *testing.T) { + for _, tc := range []struct { + message string + expected interface{} + result interface{} + }{ + { + message: "when expected is zero value", + }, + { + message: "when expected is nil", + expected: nil, + }, + { + message: "when expected and result are struct", + expected: struct{ test string }{"testing"}, + result: struct{ test string }{"testing"}, + }, + { + message: "when expected and result are strings", + expected: "testing", + result: "testing", + }, + } { + t.Log(tc.message) + { + test.Equals(t, tc.expected, tc.result) + } + } + }) + + t.Run("not-zero", func(t *testing.T) { + for _, tc := range []struct { + message string + expected interface{} + }{ + { + message: "when expected and result are struct", + expected: struct{ test string }{"testing"}, + }, + { + message: "when expected and result are strings", + expected: "testing", + }, + { + message: "when expected and result are integers", + expected: 1, + }, + } { + t.Log(tc.message) + { + test.NotZero(t, tc.expected) + } + } + }) + + t.Run("zero", func(t *testing.T) { + for _, tc := range []struct { + message string + expected interface{} + }{ + { + message: "when expected and result are struct", + expected: struct{ test string }{}, + }, + { + message: "when expected and result are strings", + expected: "", + }, + { + message: "when expected and result are integers", + expected: 0, + }, + } { + t.Log(tc.message) + { + test.Zero(t, tc.expected) + } + } + }) + + t.Run("includes", func(t *testing.T) { + result := "The quick brown fox jumps over the lazy dog" + expected := "jumps" + test.Includes(t, expected, result) + + resultList := []string{"The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"} + test.Includes(t, expected, resultList...) + }) + + t.Run("includes-i", func(t *testing.T) { + result := "The quick brown fox jumps over the lazy dog" + expected := "JUMPS" + test.IncludesI(t, expected, result) + + resultList := []string{"The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"} + test.IncludesI(t, expected, resultList...) + }) + + t.Run("not-includes", func(t *testing.T) { + result := "The quick brown fox jumps over the lazy dog" + expected := "hippo" + test.NotIncludes(t, expected, result) + + resultList := []string{"The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"} + test.NotIncludes(t, expected, resultList...) + }) + + t.Run("includes-slice", func(t *testing.T) { + expected := []string{"B"} + original := []string{"A", "B", "C"} + test.IncludesSlice(t, expected, original) + + expectedI := []int{5} + originalI := []int{1, 2, 3, 4, 5, 6, 7} + test.IncludesSlice(t, expectedI, originalI) + + expectedE := []interface{}{5, "B"} + originalE := []interface{}{1, 2, 3, 4, 5, 6, 7, "A", "B", "C"} + test.IncludesSlice(t, expectedE, originalE) + }) + + t.Run("includes-map", func(t *testing.T) { + expected := map[string]string{"B": "B"} + original := map[string]string{ + "A": "A", + "B": "B", + "C": "C", + } + test.IncludesMap(t, expected, original) + }) +}