diff --git a/client.go b/client.go index 21eb1e2..6555d3f 100644 --- a/client.go +++ b/client.go @@ -153,7 +153,7 @@ func urlText(u *url.URL) string { return u.String() } -/* Copyright 2021-2025 Spiegel +/* Copyright 2021-2026 Spiegel * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/fetch_test.go b/fetch_test.go index 6bacdb9..f153a86 100644 --- a/fetch_test.go +++ b/fetch_test.go @@ -3,46 +3,104 @@ package fetch_test import ( "context" "errors" - "fmt" "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/goark/fetch" ) -func TestGet(t *testing.T) { - testCases := []struct { - s string - err1 error - err2 error - }{ - {s: "foo\nbar", err1: fetch.ErrInvalidURL, err2: fetch.ErrInvalidRequest}, - {s: "http://foo.bar", err1: nil, err2: fetch.ErrInvalidRequest}, - {s: "https://text.baldanders.info/not-exist/", err1: nil, err2: fetch.ErrInvalidRequest}, - {s: "https://github.com/spiegel-im-spiegel.gpg", err1: nil, err2: nil}, - } - for _, tc := range testCases { - u, err := fetch.URL(tc.s) - if err != nil { - if !errors.Is(err, tc.err1) { - t.Errorf("fetch.Client.URL(%s) is \"%v\", want \"%+v\"", tc.s, err, tc.err1) - } - fmt.Printf("Info: %+v\n", err) - } else { - resp, err := fetch.New( - fetch.WithHTTPClient(&http.Client{}), - ).GetWithContext(context.Background(), u) - if err != nil { - if !errors.Is(err, tc.err2) { - t.Errorf("fetch.Client.Get() is \"%v\", want \"%+v\"", err, tc.err2) - } - fmt.Printf("Info: %+v\n", err) - } else if cerr := resp.Close(); cerr != nil { - t.Errorf("resp.Close() is \"%v\", want nil", cerr) - } - } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestURL(t *testing.T) { + u, err := fetch.URL("foo\nbar") + if err == nil { + t.Fatal("fetch.URL() error is nil, want ErrInvalidURL") + } + if !errors.Is(err, fetch.ErrInvalidURL) { + t.Fatalf("fetch.URL() = %v, want ErrInvalidURL", err) + } + if u != nil { + t.Fatal("fetch.URL() returned non-nil URL for invalid input") + } +} + +func TestGetWithTransportError(t *testing.T) { + u, err := fetch.URL("http://example.test/") + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + cli := fetch.New(fetch.WithHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport failure") + })})) + + resp, err := cli.GetWithContext(context.Background(), u) + if err == nil { + t.Fatal("GetWithContext() error is nil, want ErrInvalidRequest") + } + if !errors.Is(err, fetch.ErrInvalidRequest) { + t.Fatalf("GetWithContext() = %v, want ErrInvalidRequest", err) + } + if resp != nil { + t.Fatal("response is not nil, want nil") + } +} + +func TestGetWithHTTPStatusError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, "not found") + })) + defer ts.Close() + + u, err := fetch.URL(ts.URL) + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + resp, err := fetch.New().GetWithContext(context.Background(), u) + if err == nil { + t.Fatal("GetWithContext() error is nil, want ErrInvalidRequest") + } + if !errors.Is(err, fetch.ErrInvalidRequest) { + t.Fatalf("GetWithContext() = %v, want ErrInvalidRequest", err) + } + if !errors.Is(err, fetch.ErrHTTPStatus) { + t.Fatalf("GetWithContext() = %v, want ErrHTTPStatus in cause chain", err) + } + if resp != nil { + t.Fatal("response is not nil, want nil") + } +} + +func TestGetWithSuccess(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer ts.Close() + + u, err := fetch.URL(ts.URL) + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + resp, err := fetch.New().GetWithContext(context.Background(), u) + if err != nil { + t.Fatalf("GetWithContext() error = %v", err) + } + if resp == nil { + t.Fatal("response is nil") + } + if cerr := resp.Close(); cerr != nil { + t.Fatalf("resp.Close() error = %v", cerr) } } @@ -83,7 +141,226 @@ func TestWithHTTPClientNilFallback(t *testing.T) { } } -/* Copyright 2023-2025 Spiegel +func TestPostWithTransportError(t *testing.T) { + u, err := fetch.URL("http://example.test/") + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + cli := fetch.New(fetch.WithHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport failure") + })})) + + resp, err := cli.PostWithContext(context.Background(), u, strings.NewReader("a=1")) + if err == nil { + t.Fatal("PostWithContext() error is nil, want ErrInvalidRequest") + } + if !errors.Is(err, fetch.ErrInvalidRequest) { + t.Fatalf("PostWithContext() = %v, want ErrInvalidRequest", err) + } + if resp != nil { + t.Fatal("response is not nil, want nil") + } +} + +func TestPostWithHTTPStatusError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, "bad request") + })) + defer ts.Close() + + u, err := fetch.URL(ts.URL) + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + resp, err := fetch.New().PostWithContext(context.Background(), u, strings.NewReader("a=1")) + if err == nil { + t.Fatal("PostWithContext() error is nil, want ErrInvalidRequest") + } + if !errors.Is(err, fetch.ErrInvalidRequest) { + t.Fatalf("PostWithContext() = %v, want ErrInvalidRequest", err) + } + if !errors.Is(err, fetch.ErrHTTPStatus) { + t.Fatalf("PostWithContext() = %v, want ErrHTTPStatus in cause chain", err) + } + if resp != nil { + t.Fatal("response is not nil, want nil") + } +} + +func TestPostWithSuccess(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + if string(body) != "a=1" { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer ts.Close() + + u, err := fetch.URL(ts.URL) + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + resp, err := fetch.New().PostWithContext(context.Background(), u, strings.NewReader("a=1")) + if err != nil { + t.Fatalf("PostWithContext() error = %v", err) + } + if resp == nil { + t.Fatal("response is nil") + } + if cerr := resp.Close(); cerr != nil { + t.Fatalf("resp.Close() error = %v", cerr) + } +} + +func TestPostWithNilURL(t *testing.T) { + resp, err := fetch.New().PostWithContext(context.Background(), nil, strings.NewReader("a=1")) + if err == nil { + t.Fatal("error is nil, want ErrInvalidURL") + } + if !errors.Is(err, fetch.ErrInvalidURL) { + t.Fatalf("PostWithContext(nil) is %v, want ErrInvalidURL", err) + } + if resp != nil { + t.Fatal("response is not nil, want nil") + } +} + +func TestRequestHeaderOptions(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + xVals := r.Header.Values("X-Test") + if len(xVals) != 2 || xVals[0] != "first" || xVals[1] != "second" { + w.WriteHeader(http.StatusBadRequest) + return + } + if got := r.Header.Get("X-Mode"); got != "final" { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer ts.Close() + + u, err := fetch.URL(ts.URL) + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + resp, err := fetch.New().GetWithContext(context.Background(), u, + fetch.WithRequestHeaderAdd("X-Test", "first"), + fetch.WithRequestHeaderAdd("X-Test", "second"), + fetch.WithRequestHeaderSet("X-Mode", "initial"), + fetch.WithRequestHeaderSet("X-Mode", "final"), + ) + if err != nil { + t.Fatalf("GetWithContext() error = %v", err) + } + if resp == nil { + t.Fatal("response is nil") + } + if cerr := resp.Close(); cerr != nil { + t.Fatalf("resp.Close() error = %v", cerr) + } +} + +func TestURLWithTrimmedInput(t *testing.T) { + u, err := fetch.URL(" https://example.test/path?q=1 ") + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + if got, want := u.String(), "https://example.test/path?q=1"; got != want { + t.Fatalf("fetch.URL() string = %q, want %q", got, want) + } +} + +func TestDeprecatedGetDelegatesToGetWithContext(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer ts.Close() + + u, err := fetch.URL(ts.URL) + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + resp, err := fetch.New().Get(u) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if resp == nil { + t.Fatal("response is nil") + } + if cerr := resp.Close(); cerr != nil { + t.Fatalf("resp.Close() error = %v", cerr) + } +} + +func TestDeprecatedPostDelegatesToPostWithContext(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer ts.Close() + + u, err := fetch.URL(ts.URL) + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + resp, err := fetch.New().Post(u, strings.NewReader("a=1")) + if err != nil { + t.Fatalf("Post() error = %v", err) + } + if resp == nil { + t.Fatal("response is nil") + } + if cerr := resp.Close(); cerr != nil { + t.Fatalf("resp.Close() error = %v", cerr) + } +} + +func TestGetWithHTTPStatus3xx(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotModified) + })) + defer ts.Close() + + u, err := fetch.URL(ts.URL) + if err != nil { + t.Fatalf("fetch.URL() error = %v", err) + } + + resp, err := fetch.New().GetWithContext(context.Background(), u) + if err != nil { + t.Fatalf("GetWithContext() error = %v", err) + } + if resp == nil { + t.Fatal("response is nil") + } + if cerr := resp.Close(); cerr != nil { + t.Fatalf("resp.Close() error = %v", cerr) + } +} + +/* Copyright 2023-2026 Spiegel * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/response.go b/response.go index 03a6ddf..4342344 100644 --- a/response.go +++ b/response.go @@ -73,7 +73,7 @@ func (resp *response) DumpBodyAndClose() (b []byte, err error) { return } -/* Copyright 2021-2025 Spiegel +/* Copyright 2021-2026 Spiegel * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/response_internal_test.go b/response_internal_test.go new file mode 100644 index 0000000..06897cb --- /dev/null +++ b/response_internal_test.go @@ -0,0 +1,49 @@ +package fetch + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" +) + +func TestDumpBodyAndClose(t *testing.T) { + r := &response{&http.Response{Body: io.NopCloser(strings.NewReader("payload"))}} + b, err := r.DumpBodyAndClose() + if err != nil { + t.Fatalf("DumpBodyAndClose() error = %v", err) + } + if string(b) != "payload" { + t.Fatalf("DumpBodyAndClose() body = %q, want %q", string(b), "payload") + } +} + +func TestDumpBodyAndCloseWithNullPointer(t *testing.T) { + var r *response + b, err := r.DumpBodyAndClose() + if err == nil { + t.Fatal("DumpBodyAndClose() error is nil, want ErrNullPointer") + } + if !errors.Is(err, ErrNullPointer) { + t.Fatalf("DumpBodyAndClose() error = %v, want ErrNullPointer", err) + } + if b != nil { + t.Fatalf("DumpBodyAndClose() body = %v, want nil", b) + } +} + +/* Copyright 2026 Spiegel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */