From 8842b11e39d9c071f9a05573c9f44ec8c24bc8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20Soul=C3=A9?= Date: Wed, 5 Oct 2022 14:16:56 +0200 Subject: [PATCH] wip template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maxime Soulé --- helpers/tdhttp/internal/any.go | 12 ++ helpers/tdhttp/internal/dump_response.go | 79 +++++++ helpers/tdhttp/internal/dump_response_test.go | 108 ++++++++++ helpers/tdhttp/internal/response.go | 83 +++----- helpers/tdhttp/internal/response_list.go | 75 +++++++ helpers/tdhttp/internal/response_list_test.go | 50 +++++ helpers/tdhttp/internal/response_test.go | 111 +++------- helpers/tdhttp/template.go | 167 +++++++++++++++ helpers/tdhttp/test_api.go | 201 ++++++++++++++++-- helpers/tdhttp/test_api_test.go | 160 ++++++++++++++ 10 files changed, 887 insertions(+), 159 deletions(-) create mode 100644 helpers/tdhttp/internal/any.go create mode 100644 helpers/tdhttp/internal/dump_response.go create mode 100644 helpers/tdhttp/internal/dump_response_test.go create mode 100644 helpers/tdhttp/internal/response_list.go create mode 100644 helpers/tdhttp/internal/response_list_test.go create mode 100644 helpers/tdhttp/template.go diff --git a/helpers/tdhttp/internal/any.go b/helpers/tdhttp/internal/any.go new file mode 100644 index 00000000..d1a3d40d --- /dev/null +++ b/helpers/tdhttp/internal/any.go @@ -0,0 +1,12 @@ +// Copyright (c) 2022, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +//go:build !go1.18 +// +build !go1.18 + +package internal + +type any = interface{} diff --git a/helpers/tdhttp/internal/dump_response.go b/helpers/tdhttp/internal/dump_response.go new file mode 100644 index 00000000..1cd192b8 --- /dev/null +++ b/helpers/tdhttp/internal/dump_response.go @@ -0,0 +1,79 @@ +// Copyright (c) 2021, 2022, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package internal + +import ( + "bytes" + "net/http" + "net/http/httputil" + "testing" + "unicode/utf8" +) + +// canBackquote is the same as strconv.CanBackquote but works on +// []byte and accepts '\n' and '\r'. +func canBackquote(b []byte) bool { + for len(b) > 0 { + r, wid := utf8.DecodeRune(b) + b = b[wid:] + if wid > 1 { + if r == '\ufeff' { + return false // BOMs are invisible and should not be quoted. + } + continue // All other multibyte runes are correctly encoded and assumed printable. + } + if r == utf8.RuneError { + return false + } + if (r < ' ' && r != '\t' && r != '\n' && r != '\r') || r == '`' || r == '\u007F' { + return false + } + } + return true +} + +func replaceCrLf(b []byte) []byte { + return bytes.Replace(b, []byte("\r\n"), []byte("\n"), -1) //nolint: gocritic +} + +func backquote(b []byte) ([]byte, bool) { + // if there is as many \r\n as \n, replace all occurrences by \n + // so we can conveniently print the buffer inside `…`. + crnl := bytes.Count(b, []byte("\r\n")) + cr := bytes.Count(b, []byte("\r")) + if crnl != 0 { + nl := bytes.Count(b, []byte("\n")) + if crnl != nl || crnl != cr { + return nil, false + } + return replaceCrLf(b), true + } + + return b, cr == 0 +} + +// DumpResponse logs resp using Logf method of t. +// +// It tries to produce a result as readable as possible first using +// backquotes then falling back to double-quotes. +func DumpResponse(t testing.TB, resp *http.Response) { + t.Helper() + + const label = "Received response:\n" + b, _ := httputil.DumpResponse(resp, true) + if canBackquote(b) { + bodyPos := bytes.Index(b, []byte("\r\n\r\n")) + + if body, ok := backquote(b[bodyPos+4:]); ok { + headers := replaceCrLf(b[:bodyPos]) + t.Logf(label+"`%s\n\n%s`", headers, body) + return + } + } + + t.Logf(label+"%q", b) +} diff --git a/helpers/tdhttp/internal/dump_response_test.go b/helpers/tdhttp/internal/dump_response_test.go new file mode 100644 index 00000000..95242620 --- /dev/null +++ b/helpers/tdhttp/internal/dump_response_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2021, 2022, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package internal_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/maxatome/go-testdeep/helpers/tdhttp/internal" + "github.com/maxatome/go-testdeep/internal/test" + "github.com/maxatome/go-testdeep/td" +) + +func newResponse(body string) *http.Response { + return &http.Response{ + Status: "200 OK", + StatusCode: 200, + Proto: "HTTP/1.0", + ProtoMajor: 1, + ProtoMinor: 0, + Header: http.Header{ + "A": []string{"foo"}, + "B": []string{"bar"}, + }, + Body: io.NopCloser(bytes.NewBufferString(body)), + } +} + +func inBQ(s string) string { + return "`" + s + "`" +} + +func TestDumpResponse(t *testing.T) { + tb := test.NewTestingTB("TestDumpResponse") + internal.DumpResponse(tb, newResponse("one-line")) + td.Cmp(t, tb.LastMessage(), + `Received response: +`+inBQ(`HTTP/1.0 200 OK +A: foo +B: bar + +one-line`)) + + tb.ResetMessages() + internal.DumpResponse(tb, newResponse("multi\r\nlines\r\nand\ttabs héhé")) + td.Cmp(t, tb.LastMessage(), + `Received response: +`+inBQ(`HTTP/1.0 200 OK +A: foo +B: bar + +multi +lines +`+"and\ttabs héhé")) + + tb.ResetMessages() + internal.DumpResponse(tb, newResponse("multi\nlines\nand\ttabs héhé")) + td.Cmp(t, tb.LastMessage(), + `Received response: +`+inBQ(`HTTP/1.0 200 OK +A: foo +B: bar + +multi +lines +`+"and\ttabs héhé")) + + // one \r more in body + tb.ResetMessages() + internal.DumpResponse(tb, newResponse("multi\r\nline\r")) + td.Cmp(t, tb.LastMessage(), + `Received response: +"HTTP/1.0 200 OK\r\nA: foo\r\nB: bar\r\n\r\nmulti\r\nline\r"`) + + // BOM + tb.ResetMessages() + internal.DumpResponse(tb, newResponse("\ufeff")) + td.Cmp(t, tb.LastMessage(), + `Received response: +"HTTP/1.0 200 OK\r\nA: foo\r\nB: bar\r\n\r\n\ufeff"`) + + // Rune error + tb.ResetMessages() + internal.DumpResponse(tb, newResponse("\xf4\x9f\xbf\xbf")) + td.Cmp(t, tb.LastMessage(), + `Received response: +"HTTP/1.0 200 OK\r\nA: foo\r\nB: bar\r\n\r\n\xf4\x9f\xbf\xbf"`) + + // ` + tb.ResetMessages() + internal.DumpResponse(tb, newResponse("he`o")) + td.Cmp(t, tb.LastMessage(), + `Received response: +"HTTP/1.0 200 OK\r\nA: foo\r\nB: bar\r\n\r\nhe`+"`"+`o"`) + + // 0x7f + tb.ResetMessages() + internal.DumpResponse(tb, newResponse("\x7f")) + td.Cmp(t, tb.LastMessage(), + td.Re(`Received response: +"HTTP/1.0 200 OK\\r\\nA: foo\\r\\nB: bar\\r\\n\\r\\n(\\u007f|\\x7f)"`)) +} diff --git a/helpers/tdhttp/internal/response.go b/helpers/tdhttp/internal/response.go index 97c9f2b9..8005e0c0 100644 --- a/helpers/tdhttp/internal/response.go +++ b/helpers/tdhttp/internal/response.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Maxime Soulé +// Copyright (c) 2022, Maxime Soulé // All rights reserved. // // This source code is licensed under the BSD-style license found in the @@ -7,73 +7,44 @@ package internal import ( - "bytes" + "encoding/json" "net/http" - "net/http/httputil" - "testing" - "unicode/utf8" + "net/http/httptest" + "sync" ) -// canBackquote is the same as strconv.CanBackquote but works on -// []byte and accepts '\n' and '\r'. -func canBackquote(b []byte) bool { - for len(b) > 0 { - r, wid := utf8.DecodeRune(b) - b = b[wid:] - if wid > 1 { - if r == '\ufeff' { - return false // BOMs are invisible and should not be quoted. - } - continue // All other multibyte runes are correctly encoded and assumed printable. - } - if r == utf8.RuneError { - return false - } - if (r < ' ' && r != '\t' && r != '\n' && r != '\r') || r == '`' || r == '\u007F' { - return false - } - } - return true -} +type Response struct { + sync.Mutex -func replaceCrLf(b []byte) []byte { - return bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")) + name string + response *httptest.ResponseRecorder + + asJSON any + jsonDecoded bool } -func backquote(b []byte) ([]byte, bool) { - // if there is as many \r\n as \n, replace all occurrences by \n - // so we can conveniently print the buffer inside `…`. - crnl := bytes.Count(b, []byte("\r\n")) - cr := bytes.Count(b, []byte("\r")) - if crnl != 0 { - nl := bytes.Count(b, []byte("\n")) - if crnl != nl || crnl != cr { - return nil, false - } - return replaceCrLf(b), true +func NewResponse(resp *httptest.ResponseRecorder) *Response { + return &Response{ + response: resp, } - - return b, cr == 0 } -// DumpResponse logs "resp" using Logf method of "t". -// -// It tries to produce a result as readable as possible first using -// backquotes then falling back to double-quotes. -func DumpResponse(t testing.TB, resp *http.Response) { - t.Helper() +func (r *Response) Response() *http.Response { + // No lock needed here + return r.response.Result() +} - const label = "Received response:\n" - b, _ := httputil.DumpResponse(resp, true) - if canBackquote(b) { - bodyPos := bytes.Index(b, []byte("\r\n\r\n")) +func (r *Response) UnmarshalJSON() (any, error) { + r.Lock() + defer r.Unlock() - if body, ok := backquote(b[bodyPos+4:]); ok { - headers := replaceCrLf(b[:bodyPos]) - t.Logf(label+"`%s\n\n%s`", headers, body) - return + if !r.jsonDecoded { + err := json.Unmarshal(r.response.Body.Bytes(), &r.asJSON) + if err != nil { + return nil, err } + r.jsonDecoded = true } - t.Logf(label+"%q", b) + return r.asJSON, nil } diff --git a/helpers/tdhttp/internal/response_list.go b/helpers/tdhttp/internal/response_list.go new file mode 100644 index 00000000..20724f29 --- /dev/null +++ b/helpers/tdhttp/internal/response_list.go @@ -0,0 +1,75 @@ +// Copyright (c) 2022, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package internal + +import ( + "errors" + "fmt" + "sync" +) + +type ResponseList struct { + sync.Mutex + responses map[string]*Response + last *Response +} + +func NewResponseList() *ResponseList { + return &ResponseList{ + responses: map[string]*Response{}, + } +} + +func (rl *ResponseList) SetLast(resp *Response) { + rl.Lock() + defer rl.Unlock() + + rl.last = resp +} + +func (rl *ResponseList) RecordLast(name string) error { + rl.Lock() + defer rl.Unlock() + + if rl.last == nil { + return errors.New("no last response to record") + } + + rl.last.Lock() + defer rl.last.Unlock() + + if rl.last.name != "" { + return fmt.Errorf("last response is already recorded as %q", rl.last.name) + } + + rl.responses[name] = rl.last + rl.last.name = name + + return nil +} + +func (rl *ResponseList) Reset() { + rl.Lock() + defer rl.Unlock() + + for name := range rl.responses { + delete(rl.responses, name) + } + rl.last = nil +} + +func (rl *ResponseList) Get(name string) *Response { + rl.Lock() + defer rl.Unlock() + return rl.responses[name] +} + +func (rl *ResponseList) Last() *Response { + rl.Lock() + defer rl.Unlock() + return rl.last +} diff --git a/helpers/tdhttp/internal/response_list_test.go b/helpers/tdhttp/internal/response_list_test.go new file mode 100644 index 00000000..fbbaf183 --- /dev/null +++ b/helpers/tdhttp/internal/response_list_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2022, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package internal_test + +import ( + "testing" + + "github.com/maxatome/go-testdeep/helpers/tdhttp/internal" + "github.com/maxatome/go-testdeep/td" +) + +func TestResponseList(t *testing.T) { + assert, require := td.AssertRequire(t) + + rList := internal.NewResponseList() + require.NotNil(rList) + + assert.Nil(rList.Last()) + assert.Nil(rList.Get("unknown")) + + assert.String(rList.RecordLast("first"), "no last response to record") + + r1 := internal.NewResponse(newResponseRecorder("12345")) + require.NotNil(r1) + rList.SetLast(r1) + assert.Shallow(rList.Last(), r1) + + assert.CmpNoError(rList.RecordLast("first")) + assert.Shallow(rList.Get("first"), r1) + + assert.String(rList.RecordLast("again"), `last response is already recorded as "first"`) + + r2 := internal.NewResponse(newResponseRecorder("body")) + require.NotNil(r2) + rList.SetLast(r2) + assert.Shallow(rList.Last(), r2) + + assert.CmpNoError(rList.RecordLast("second")) + assert.Shallow(rList.Get("second"), r2) + assert.Shallow(rList.Get("first"), r1) + + rList.Reset() + assert.Nil(rList.Last()) + assert.Nil(rList.Get("first")) + assert.Nil(rList.Get("second")) +} diff --git a/helpers/tdhttp/internal/response_test.go b/helpers/tdhttp/internal/response_test.go index cf4b85b3..2e33bc14 100644 --- a/helpers/tdhttp/internal/response_test.go +++ b/helpers/tdhttp/internal/response_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Maxime Soulé +// Copyright (c) 2022, Maxime Soulé // All rights reserved. // // This source code is licensed under the BSD-style license found in the @@ -7,102 +7,49 @@ package internal_test import ( - "bytes" "io" "net/http" + "net/http/httptest" "testing" "github.com/maxatome/go-testdeep/helpers/tdhttp/internal" - "github.com/maxatome/go-testdeep/internal/test" "github.com/maxatome/go-testdeep/td" ) -func newResponse(body string) *http.Response { - return &http.Response{ - Status: "200 OK", - StatusCode: 200, - Proto: "HTTP/1.0", - ProtoMajor: 1, - ProtoMinor: 0, - Header: http.Header{ - "A": []string{"foo"}, - "B": []string{"bar"}, - }, - Body: io.NopCloser(bytes.NewBufferString(body)), +func newResponseRecorder(body string) *httptest.ResponseRecorder { + handler := func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + io.WriteString(w, body) //nolint: errcheck } -} -func inBQ(s string) string { - return "`" + s + "`" + rec := httptest.NewRecorder() + handler(rec, &http.Request{}) + return rec } -func TestDumpResponse(t *testing.T) { - tb := test.NewTestingTB("TestDumpResponse") - internal.DumpResponse(tb, newResponse("one-line")) - td.Cmp(t, tb.LastMessage(), - `Received response: -`+inBQ(`HTTP/1.0 200 OK -A: foo -B: bar - -one-line`)) - - tb.ResetMessages() - internal.DumpResponse(tb, newResponse("multi\r\nlines\r\nand\ttabs héhé")) - td.Cmp(t, tb.LastMessage(), - `Received response: -`+inBQ(`HTTP/1.0 200 OK -A: foo -B: bar - -multi -lines -`+"and\ttabs héhé")) - - tb.ResetMessages() - internal.DumpResponse(tb, newResponse("multi\nlines\nand\ttabs héhé")) - td.Cmp(t, tb.LastMessage(), - `Received response: -`+inBQ(`HTTP/1.0 200 OK -A: foo -B: bar - -multi -lines -`+"and\ttabs héhé")) +func TestResponse(t *testing.T) { + assert, require := td.AssertRequire(t) - // one \r more in body - tb.ResetMessages() - internal.DumpResponse(tb, newResponse("multi\r\nline\r")) - td.Cmp(t, tb.LastMessage(), - `Received response: -"HTTP/1.0 200 OK\r\nA: foo\r\nB: bar\r\n\r\nmulti\r\nline\r"`) + r := internal.NewResponse(newResponseRecorder("12345")) + require.NotNil(r) - // BOM - tb.ResetMessages() - internal.DumpResponse(tb, newResponse("\ufeff")) - td.Cmp(t, tb.LastMessage(), - `Received response: -"HTTP/1.0 200 OK\r\nA: foo\r\nB: bar\r\n\r\n\ufeff"`) + resp := r.Response() + require.NotNil(resp) + assert.Cmp(resp.StatusCode, http.StatusOK) + assert.Cmp(resp.Body, td.Smuggle(io.ReadAll, td.String("12345"))) - // Rune error - tb.ResetMessages() - internal.DumpResponse(tb, newResponse("\xf4\x9f\xbf\xbf")) - td.Cmp(t, tb.LastMessage(), - `Received response: -"HTTP/1.0 200 OK\r\nA: foo\r\nB: bar\r\n\r\n\xf4\x9f\xbf\xbf"`) + v, err := r.UnmarshalJSON() + assert.CmpNoError(err) + assert.CmpLax(v, 12345) - // ` - tb.ResetMessages() - internal.DumpResponse(tb, newResponse("he`o")) - td.Cmp(t, tb.LastMessage(), - `Received response: -"HTTP/1.0 200 OK\r\nA: foo\r\nB: bar\r\n\r\nhe`+"`"+`o"`) + // Second call is cached + v, err = r.UnmarshalJSON() + assert.CmpNoError(err) + assert.CmpLax(v, 12345) - // 0x7f - tb.ResetMessages() - internal.DumpResponse(tb, newResponse("\x7f")) - td.Cmp(t, tb.LastMessage(), - td.Re(`Received response: -"HTTP/1.0 200 OK\\r\\nA: foo\\r\\nB: bar\\r\\n\\r\\n(\\u007f|\\x7f)"`)) + r = internal.NewResponse(newResponseRecorder("bad json")) + require.NotNil(r) + _, err = r.UnmarshalJSON() + assert.CmpError(err) } diff --git a/helpers/tdhttp/template.go b/helpers/tdhttp/template.go new file mode 100644 index 00000000..1d745d19 --- /dev/null +++ b/helpers/tdhttp/template.go @@ -0,0 +1,167 @@ +// Copyright (c) 2022, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package tdhttp + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "text/template" + + "github.com/maxatome/go-testdeep/helpers/tdhttp/internal" +) + +func getResp(ta *TestAPI, name ...string) (*internal.Response, error) { + var resp *internal.Response + if len(name) == 0 { + resp = ta.responses.Last() + if resp == nil { + return nil, errors.New("no last response found") + } + } else { + resp = ta.responses.Get(name[0]) + if resp == nil { + return nil, fmt.Errorf("no response recorded as %q found", name[0]) + } + } + return resp, nil +} + +func tmplFuncs(ta *TestAPI) template.FuncMap { + return template.FuncMap{ + // + // Functions working on previous responses + // + // jsonp "/json/pointer" → works on last response + // jsonp "/json/pointer" "name" → works on response recorded as "name" + "jsonp": func(pointer string, name ...string) (any, error) { + resp, err := getResp(ta, name...) + if err != nil { + return nil, err + } + val, _, err := ta.respJSONPointer(resp, pointer) + return val, err + }, + // json "name" → returns response recorded as "name" JSON decoded + "json": func(name string) (any, error) { + resp, err := getResp(ta, name) + if err != nil { + return nil, err + } + return resp.UnmarshalJSON() + }, + // header "X-Header-Foo" → works on last response + // header "X-Header-Foo" "name" → works on response recorded as "name" + "header": func(key string, name ...string) (string, error) { + resp, err := getResp(ta, name...) + if err != nil { + return "", err + } + return resp.Response().Header.Get(key), nil + }, + // trailer "X-Trailer-Foo" → works on last response + // trailer "X-Trailer-Foo" "name" → works on response recorded as "name" + "trailer": func(key string, name ...string) (string, error) { + resp, err := getResp(ta, name...) + if err != nil { + return "", err + } + return resp.Response().Trailer.Get(key), nil + }, + // + // Basic util functions + // + // toJson VAL → returns the JSON representation of VAL + "toJson": func(val any) (string, error) { + b, err := json.Marshal(val) + return string(b), err + }, + "quote": strconv.Quote, + "sub": func(a, b int) int { return a - b }, + "add": func(a, b int) int { return a + b }, + } +} + +type TemplateJSON interface { + io.ReadCloser + json.Marshaler + fmt.Stringer + Err() error +} + +type tmplJSON struct { + cache *bytes.Buffer + err error +} + +func newTemplateJSON(ta *TestAPI, s string) TemplateJSON { + last := ta.responses.Last() + if last == nil { + return &tmplJSON{err: errors.New("no last response found")} + } + + jlast, err := last.UnmarshalJSON() + if err != nil { + return &tmplJSON{err: fmt.Errorf("last response is not JSON formatted: %s", err)} + } + + tmpl, err := template.New("").Funcs(tmplFuncs(ta)).Parse(s) + if err != nil { + return &tmplJSON{err: fmt.Errorf("template parsing failed: %s", err)} + } + + var cache bytes.Buffer + err = tmpl.Execute(&cache, jlast) + if err != nil { + return &tmplJSON{err: fmt.Errorf("template execution failed: %s", err)} + } + return &tmplJSON{cache: &cache} +} + +// Read implements [io.ReadCloser] interface. +func (t *tmplJSON) Read(p []byte) (n int, err error) { + if t.err != nil { + return 0, t.err + } + n, err = t.cache.Read(p) + t.err = err + return +} + +// Close implements [io.ReadCloser] interface. It always returns nil here. +func (t *tmplJSON) Close() error { + return nil +} + +// MarshalJSON implements [json.Marshaler] interface. +func (t *tmplJSON) MarshalJSON() (b []byte, err error) { + if t.err != nil { + return nil, t.err + } + b, err = json.RawMessage(t.cache.Bytes()).MarshalJSON() + t.err = err + return +} + +// String implements [fmt.Stringer] interface. +func (t *tmplJSON) String() string { + if t.err != nil { + return "" + } + return t.cache.String() +} + +// Err returns an error if something got wrong during the construction +// of the instance, typically the last response unmarshalling, the +// template parsing or its execution, or during [MarshalJSON] or +// [Read] calls. +func (t *tmplJSON) Err() error { + return t.err +} diff --git a/helpers/tdhttp/test_api.go b/helpers/tdhttp/test_api.go index 40f608d2..88104ea9 100644 --- a/helpers/tdhttp/test_api.go +++ b/helpers/tdhttp/test_api.go @@ -9,6 +9,7 @@ package tdhttp import ( "encoding/json" "encoding/xml" + "errors" "fmt" "io" "net/http" @@ -24,6 +25,7 @@ import ( "github.com/maxatome/go-testdeep/internal/color" "github.com/maxatome/go-testdeep/internal/ctxerr" "github.com/maxatome/go-testdeep/internal/types" + "github.com/maxatome/go-testdeep/internal/util" "github.com/maxatome/go-testdeep/td" ) @@ -52,6 +54,11 @@ type TestAPI struct { // autoDumpResponse dumps the received response when a test fails. autoDumpResponse bool responseDumped bool + + autoTmplJSONTarget bool + autoTmplJSONBody bool + + responses *internal.ResponseList } // NewTestAPI creates a [TestAPI] that can be used to test routes of the @@ -77,8 +84,9 @@ type TestAPI struct { // Note that tb can be a [*testing.T] as well as a [*td.T]. func NewTestAPI(tb testing.TB, handler http.Handler) *TestAPI { return &TestAPI{ - t: td.NewT(tb), - handler: handler, + t: td.NewT(tb), + handler: handler, + responses: internal.NewResponseList(), } } @@ -113,6 +121,7 @@ func (ta *TestAPI) With(tb testing.TB) *TestAPI { t: td.NewT(tb), handler: ta.handler, autoDumpResponse: ta.autoDumpResponse, + responses: internal.NewResponseList(), } } @@ -140,6 +149,22 @@ func (ta *TestAPI) AutoDumpResponse(enable ...bool) *TestAPI { return ta } +func (ta *TestAPI) AutoTmplJSON(enable ...bool) *TestAPI { + ta.AutoTmplJSONBody(enable...) + ta.AutoTmplJSONTarget(enable...) + return ta +} + +func (ta *TestAPI) AutoTmplJSONBody(enable ...bool) *TestAPI { + ta.autoTmplJSONBody = len(enable) == 0 || enable[0] + return ta +} + +func (ta *TestAPI) AutoTmplJSONTarget(enable ...bool) *TestAPI { + ta.autoTmplJSONTarget = len(enable) == 0 || enable[0] + return ta +} + // Name allows to name the series of tests that follow. This name is // used as a prefix for all following tests, in case of failure to // qualify each test. If len(args) > 1 and the first item of args is @@ -153,6 +178,20 @@ func (ta *TestAPI) Name(args ...any) *TestAPI { return ta } +// RecordAs records the last response as name. +func (ta *TestAPI) RecordAs(name string) *TestAPI { + ta.t.Helper() + + if name == "" { + ta.t.Fatalf(color.Bad("RecordAs(NAME), NAME cannot be empty")) + } + + if err := ta.responses.RecordLast(name); err != nil { + ta.t.Fatalf(color.Bad("Cannot record last response as %q: %s", name, err)) + } + return ta +} + // Request sends a new HTTP request to the tested API. Any Cmp* or // [TestAPI.NoBody] methods can now be called. // @@ -164,8 +203,19 @@ func (ta *TestAPI) Request(req *http.Request) *TestAPI { ta.sentAt = time.Now().Truncate(0) ta.responseDumped = false + if ta.autoTmplJSONBody && req.Body != nil && ta.responses.Last() != nil { + b, err := io.ReadAll(req.Body) + if err != nil { + ta.t.Helper() + ta.t.Fatalf("Cannot apply template: %s", err) + } + req.Body = ta.TmplJSON(string(b)) + } + ta.handler.ServeHTTP(ta.response, req) + ta.responses.SetLast(internal.NewResponse(ta.response)) + return ta } @@ -193,6 +243,13 @@ func (ta *TestAPI) Failed() bool { return ta.failed != 0 } +func (ta *TestAPI) autoTmplTarget(target string) string { + if ta.autoTmplJSONTarget && ta.responses.Last() != nil { + return ta.TmplJSON(target).String() + } + return target +} + // Get sends a HTTP GET to the tested API. Any Cmp* or [TestAPI.NoBody] methods // can now be called. // @@ -201,7 +258,7 @@ func (ta *TestAPI) Failed() bool { // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) Get(target string, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := get(target, headersQueryParams...) + req, err := get(ta.autoTmplTarget(target), headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -216,7 +273,7 @@ func (ta *TestAPI) Get(target string, headersQueryParams ...any) *TestAPI { // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) Head(target string, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := head(target, headersQueryParams...) + req, err := head(ta.autoTmplTarget(target), headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -231,7 +288,7 @@ func (ta *TestAPI) Head(target string, headersQueryParams ...any) *TestAPI { // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) Options(target string, body io.Reader, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := options(target, body, headersQueryParams...) + req, err := options(ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -246,7 +303,7 @@ func (ta *TestAPI) Options(target string, body io.Reader, headersQueryParams ... // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) Post(target string, body io.Reader, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := post(target, body, headersQueryParams...) + req, err := post(ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -263,7 +320,7 @@ func (ta *TestAPI) Post(target string, body io.Reader, headersQueryParams ...any // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) PostForm(target string, data URLValuesEncoder, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := postForm(target, data, headersQueryParams...) + req, err := postForm(ta.autoTmplTarget(target), data, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -294,7 +351,7 @@ func (ta *TestAPI) PostForm(target string, data URLValuesEncoder, headersQueryPa // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) PostMultipartFormData(target string, data *MultipartBody, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := postMultipartFormData(target, data, headersQueryParams...) + req, err := postMultipartFormData(ta.autoTmplTarget(target), data, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -309,7 +366,7 @@ func (ta *TestAPI) PostMultipartFormData(target string, data *MultipartBody, hea // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) Put(target string, body io.Reader, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := put(target, body, headersQueryParams...) + req, err := put(ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -324,7 +381,7 @@ func (ta *TestAPI) Put(target string, body io.Reader, headersQueryParams ...any) // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) Patch(target string, body io.Reader, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := patch(target, body, headersQueryParams...) + req, err := patch(ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -339,7 +396,7 @@ func (ta *TestAPI) Patch(target string, body io.Reader, headersQueryParams ...an // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) Delete(target string, body io.Reader, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := del(target, body, headersQueryParams...) + req, err := del(ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -355,7 +412,7 @@ func (ta *TestAPI) Delete(target string, body io.Reader, headersQueryParams ...a // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) NewJSONRequest(method, target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newJSONRequest(method, target, body, headersQueryParams...) + req, err := newJSONRequest(method, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -371,7 +428,7 @@ func (ta *TestAPI) NewJSONRequest(method, target string, body any, headersQueryP // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) PostJSON(target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newJSONRequest(http.MethodPost, target, body, headersQueryParams...) + req, err := newJSONRequest(http.MethodPost, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -387,7 +444,7 @@ func (ta *TestAPI) PostJSON(target string, body any, headersQueryParams ...any) // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) PutJSON(target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newJSONRequest(http.MethodPut, target, body, headersQueryParams...) + req, err := newJSONRequest(http.MethodPut, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -403,7 +460,7 @@ func (ta *TestAPI) PutJSON(target string, body any, headersQueryParams ...any) * // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) PatchJSON(target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newJSONRequest(http.MethodPatch, target, body, headersQueryParams...) + req, err := newJSONRequest(http.MethodPatch, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -419,7 +476,7 @@ func (ta *TestAPI) PatchJSON(target string, body any, headersQueryParams ...any) // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) DeleteJSON(target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newJSONRequest(http.MethodDelete, target, body, headersQueryParams...) + req, err := newJSONRequest(http.MethodDelete, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -435,7 +492,7 @@ func (ta *TestAPI) DeleteJSON(target string, body any, headersQueryParams ...any // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) NewXMLRequest(method, target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newXMLRequest(method, target, body, headersQueryParams...) + req, err := newXMLRequest(method, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -451,7 +508,7 @@ func (ta *TestAPI) NewXMLRequest(method, target string, body any, headersQueryPa // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) PostXML(target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newXMLRequest(http.MethodPost, target, body, headersQueryParams...) + req, err := newXMLRequest(http.MethodPost, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -467,7 +524,7 @@ func (ta *TestAPI) PostXML(target string, body any, headersQueryParams ...any) * // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) PutXML(target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newXMLRequest(http.MethodPut, target, body, headersQueryParams...) + req, err := newXMLRequest(http.MethodPut, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -483,7 +540,7 @@ func (ta *TestAPI) PutXML(target string, body any, headersQueryParams ...any) *T // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) PatchXML(target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newXMLRequest(http.MethodPatch, target, body, headersQueryParams...) + req, err := newXMLRequest(http.MethodPatch, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -499,7 +556,7 @@ func (ta *TestAPI) PatchXML(target string, body any, headersQueryParams ...any) // See [NewRequest] for all possible formats accepted in headersQueryParams. func (ta *TestAPI) DeleteXML(target string, body any, headersQueryParams ...any) *TestAPI { ta.t.Helper() - req, err := newXMLRequest(http.MethodDelete, target, body, headersQueryParams...) + req, err := newXMLRequest(http.MethodDelete, ta.autoTmplTarget(target), body, headersQueryParams...) if err != nil { ta.t.Fatal(err) } @@ -1240,3 +1297,105 @@ func (ta *TestAPI) A(operator td.TestDeep, model ...any) any { func (ta *TestAPI) SentAt() time.Time { return ta.sentAt } + +func (ta *TestAPI) respJSONPointer(resp *internal.Response, pointer string, model ...any) (any, bool, error) { + body, err := resp.UnmarshalJSON() + if err != nil { + return nil, false, fmt.Errorf("Response cannot be json.Unmarshal'ed: %s", err) + } + + val, err := util.JSONPointer(body, pointer) + if err != nil { + return nil, false, fmt.Errorf("JSON pointer error: %s", err) + } + + if len(model) == 0 { + return val, false, nil + } + + typ, ok := model[0].(reflect.Type) + if !ok { + typ = reflect.TypeOf(model[0]) + if typ == nil { + return nil, true, errors.New("Untyped nil value is not valid as model") + } + } + + if reflect.TypeOf(val) == typ { + return val, false, nil + } + + vval := reflect.ValueOf(val) + if types.IsConvertible(vval, typ) { + return vval.Convert(typ).Interface(), false, nil + } + + b, _ := json.Marshal(val) // cannot fail + + vval = reflect.New(typ) + if err := json.Unmarshal(b, vval.Interface()); err != nil { + return nil, false, fmt.Errorf("Cannot json.Unmarshal JSON value pointed by %q into %s", pointer, typ) + } + + return vval.Elem().Interface(), false, nil +} + +// PrevJSONPointer returns the value corresponding to JSON pointer +// pointer in response recorded as name. If model is passed and +// len(model) == 1, the value is extracted in the same type as +// model[0]. Note that if model[0] is a [reflect.Type], the target type +// is the one represented by this [reflect.Type]. +func (ta *TestAPI) PrevJSONPointer(name string, pointer string, model ...any) any { + ta.t.Helper() + + if len(model) > 1 { + ta.t.Fatal(color.TooManyParams("PrevJSONPointer(NAME, JSON_POINTER[, MODEL])")) + } + + resp := ta.responses.Get(name) + if resp == nil { + ta.t.Error(color.Bad("There is no response recorded as %q", name)) + return nil + } + + val, fatal, err := ta.respJSONPointer(resp, pointer, model...) + if err != nil { + if fatal { + ta.t.Fatal(color.Bad(err.Error())) + } + ta.t.Error(color.Bad(err.Error())) + } + return val +} + +// LastJSONPointer returns the value corresponding to JSON pointer +// pointer in the last response. If model is passed and +// len(model) == 1, the value is extracted in the same type as +// model[0]. Note that if model[0] is a [reflect.Type], the target +// type is the one represented by this [reflect.Type]. +func (ta *TestAPI) LastJSONPointer(pointer string, model ...any) any { + ta.t.Helper() + + if len(model) > 1 { + ta.t.Fatal(color.TooManyParams("LastJSONPointer(JSON_POINTER[, MODEL])")) + } + + resp := ta.responses.Last() + if resp == nil { + ta.t.Error(color.Bad("There is no last response")) + return nil + } + + val, fatal, err := ta.respJSONPointer(resp, pointer, model...) + if err != nil { + if fatal { + ta.t.Fatal(color.Bad(err.Error())) + } + ta.t.Error(color.Bad(err.Error())) + } + return val +} + +func (ta *TestAPI) TmplJSON(s string) TemplateJSON { + return newTemplateJSON(ta, s) +} diff --git a/helpers/tdhttp/test_api_test.go b/helpers/tdhttp/test_api_test.go index d128386a..3b7b7845 100644 --- a/helpers/tdhttp/test_api_test.go +++ b/helpers/tdhttp/test_api_test.go @@ -13,6 +13,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" "time" @@ -1252,3 +1253,162 @@ func TestRun(t *testing.T) { }) td.CmpFalse(t, ok) } + +func TestPrevJSONPointer(t *testing.T) { + mux := server() + + ta := tdhttp.NewTestAPI(tdutil.NewT("test1"), mux) + + assert, require := td.AssertRequire(t) + + require.False( + ta.PostJSON("/mirror/json", + json.RawMessage(`[{"name":"Bob"},{"name":"Alice"}]`)). + RecordAs("test1"). + CmpStatus(200). + CmpJSONBody(td.JSON(`[{"name":"Bob"},{"name":"Alice"}]`)). + Failed()) + + assert.Run("Basic", func(assert *td.T) { + assert.Cmp(ta.PrevJSONPointer("test1", "/0/name"), "Bob") + assert.Cmp(ta.LastJSONPointer("/0/name"), "Bob") + }) + + assert.Run("With model", func(assert *td.T) { + assert.Cmp(ta.PrevJSONPointer("test1", "/0/name", "model"), "Bob") + assert.Cmp(ta.LastJSONPointer("/0/name", "model"), "Bob") + + type name string + assert.Cmp(ta.PrevJSONPointer("test1", "/0/name", name("")), name("Bob")) + assert.Cmp(ta.PrevJSONPointer("test1", "/0/name", reflect.TypeOf(name(""))), name("Bob")) + assert.Cmp(ta.LastJSONPointer("/0/name", name("")), name("Bob")) + assert.Cmp(ta.LastJSONPointer("/0/name", reflect.TypeOf(name(""))), name("Bob")) + + assert.Cmp( + ta.PrevJSONPointer("test1", "/1", map[string]string(nil)), + map[string]string{"name": "Alice"}) + assert.Cmp( + ta.LastJSONPointer("/1", map[string]string(nil)), + map[string]string{"name": "Alice"}) + + type personMap map[string]string + assert.Cmp( + ta.PrevJSONPointer("test1", "/1", personMap(nil)), + personMap{"name": "Alice"}) + assert.Cmp( + ta.LastJSONPointer("/1", personMap(nil)), + personMap{"name": "Alice"}) + + type personStruct struct { + Name string `json:"name"` + } + assert.Cmp( + ta.PrevJSONPointer("test1", "/1", personStruct{}), + personStruct{Name: "Alice"}) + assert.Cmp( + ta.PrevJSONPointer("test1", "/1", (*personStruct)(nil)), + &personStruct{Name: "Alice"}) + assert.Cmp( + ta.LastJSONPointer("/1", personStruct{}), + personStruct{Name: "Alice"}) + assert.Cmp( + ta.LastJSONPointer("/1", (*personStruct)(nil)), + &personStruct{Name: "Alice"}) + }) +} + +func TestRecordAs(t *testing.T) { + mux := server() + + mockT := tdutil.NewT("test") + td.CmpTrue(t, mockT.CatchFailNow(func() { + tdhttp.NewTestAPI(mockT, mux).Get("/any").RecordAs("") + })) + td.CmpContains(t, mockT.LogBuf(), "RecordAs(NAME), NAME cannot be empty") + + mockT = tdutil.NewT("test") + ta := tdhttp.NewTestAPI(mockT, mux).Get("/any").RecordAs("first") + td.CmpTrue(t, mockT.CatchFailNow(func() { ta.RecordAs("again") })) + td.CmpContains(t, + mockT.LogBuf(), + `Cannot record last response as "again": last response is already recorded as "first"`) +} + +func TestTemplate(t *testing.T) { + mux := server() + + require := td.Require(t) + + ta := tdhttp.NewTestAPI(require, mux) + + ta.PostJSON("/mirror/json", + json.RawMessage(`[{"name":"Bob"},{"name":"Alice"}]`)). + RecordAs("xxx"). + CmpStatus(200). + CmpJSONBody(td.JSON(`[{"name":"Bob"},{"name":"Alice"}]`)) + + ta.PostJSON("/mirror/json", ta.TmplJSON(`[ + {{ (index . 0).name | printf "%q" }}, + {{ (index (json "xxx") 1).name | quote }} +]`)). + CmpStatus(200). + CmpJSONBody(td.JSON(`["Bob","Alice"]`)) + + ta.PostJSON("/mirror/json", + json.RawMessage(`[{"name":"Bob"},{"name":"Alice"}]`)). + RecordAs("test1"). + CmpStatus(200). + CmpJSONBody(td.JSON(`[{"name":"Bob"},{"name":"Alice"}]`)) + + ta.PostJSON("/mirror/json", ta.TmplJSON(`[ + {{ jsonp "/0/name" | printf "%q" }}, + {{ jsonp "/1/name" | quote }} +]`)). + RecordAs("test2"). + CmpStatus(200). + CmpJSONBody(td.JSON(`["Bob","Alice"]`)) + + ta.PostJSON("/mirror/json", ta.TmplJSON(`[ + {{ jsonp "/1/name" "test1" | quote }}, + {{ jsonp "/0/name" "test1" | quote }} +]`)). + RecordAs("test3"). + CmpStatus(200). + CmpJSONBody(td.JSON(`["Alice","Bob"]`)) + + ta.PostJSON("/mirror/json", ta.TmplJSON(`[ + {"first_name": "{{ jsonp "/0/name" "test1" }}"}, + {"first_name": "{{ jsonp "/1/name" "test1" }}"} +]`)). + RecordAs("test4"). + CmpStatus(200). + CmpJSONBody(td.JSON(`[{"first_name":"Bob"},{"first_name":"Alice"}]`)) + + ta.PutJSON("/mirror/json", ta.TmplJSON(`[ +{{ range . }} +{{ .first_name | quote }}, +{{ end }} +"last" +]`)). + RecordAs("test5"). + CmpStatus(200). + CmpJSONBody(td.JSON(`["Bob","Alice","last"]`)) + + ta.PatchJSON("/mirror/json", ta.TmplJSON(`{ + "persons": [ +{{ $all := jsonp "" "test4" }} +{{ range $i, $person := $all }} +{{ $person.first_name | quote }}{{ if (lt $i (sub (len $all) 1)) }},{{ end }} +{{ end }} + ], + "method_last": {{ header "X-TestDeep-Method" | quote }}, + "method_test4": {{ header "X-TestDeep-Method" "test4" | quote }} +}`)). + RecordAs("test6"). + CmpStatus(200). + CmpJSONBody(td.JSON(`{ + "persons": ["Bob","Alice"], + "method_last": "PUT", + "method_test4": "POST", +}`)) +}