package httpbin

import (
	"bufio"
	"bytes"
	"compress/gzip"
	"compress/zlib"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"mime/multipart"
	"net"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
	"os"
	"reflect"
	"regexp"
	"runtime"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/mccutchen/go-httpbin/v2/internal/testing/assert"
	"github.com/mccutchen/go-httpbin/v2/internal/testing/must"
)

const (
	maxBodySize int64         = 1024
	maxDuration time.Duration = 1 * time.Second
)

// "Global" test app, server, & client to be reused across test cases.
// Initialized in TestMain.
var (
	app    *HTTPBin
	srv    *httptest.Server
	client *http.Client
)

func TestMain(m *testing.M) {
	// enable additional safety checks
	testMode = true

	app = New(
		WithAllowedRedirectDomains([]string{
			"httpbingo.org",
			"example.org",
			"www.example.com",
		}),
		WithDefaultParams(DefaultParams{
			DripDelay:    0,
			DripDuration: 100 * time.Millisecond,
			DripNumBytes: 10,
		}),
		WithMaxBodySize(maxBodySize),
		WithMaxDuration(maxDuration),
		WithObserver(StdLogObserver(log.New(io.Discard, "", 0))),
	)
	srv, client = newTestServer(app)
	defer srv.Close()
	os.Exit(m.Run())
}

func TestIndex(t *testing.T) {
	t.Run("ok", func(t *testing.T) {
		t.Parallel()

		req := newTestRequest(t, "GET", "/")
		resp := must.DoReq(t, client, req)

		assert.ContentType(t, resp, htmlContentType)
		assert.Header(t, resp, "Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' camo.githubusercontent.com")
		assert.BodyContains(t, resp, "go-httpbin")
	})

	t.Run("not found", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "GET", "/foo")
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusNotFound)
		assert.ContentType(t, resp, jsonContentType)
		got := must.Unmarshal[errorRespnose](t, resp.Body)
		want := errorRespnose{
			StatusCode: http.StatusNotFound,
			Error:      "Not Found",
		}
		assert.DeepEqual(t, got, want, "incorrect error response")
	})
}

func TestFormsPost(t *testing.T) {
	t.Parallel()

	req := newTestRequest(t, "GET", "/forms/post")
	resp := must.DoReq(t, client, req)

	assert.ContentType(t, resp, htmlContentType)
	assert.BodyContains(t, resp, `<form method="post" action="/post">`)
}

func TestUTF8(t *testing.T) {
	t.Parallel()

	req := newTestRequest(t, "GET", "/encoding/utf8")
	resp := must.DoReq(t, client, req)

	assert.ContentType(t, resp, htmlContentType)
	assert.BodyContains(t, resp, `Hello world, Καλημέρα κόσμε, コンニチハ`)
}

func TestGet(t *testing.T) {
	doGetRequest := func(t *testing.T, path string, params url.Values, headers http.Header) noBodyResponse {
		t.Helper()

		if params != nil {
			path = fmt.Sprintf("%s?%s", path, params.Encode())
		}
		req := newTestRequest(t, "GET", path)
		req.Header.Set("User-Agent", "test")
		for k, vs := range headers {
			for _, v := range vs {
				req.Header.Add(k, v)
			}
		}

		resp := must.DoReq(t, client, req)
		return mustParseResponse[noBodyResponse](t, resp)
	}

	t.Run("basic", func(t *testing.T) {
		t.Parallel()

		result := doGetRequest(t, "/get", nil, nil)
		assert.Equal(t, result.Method, "GET", "method mismatch")
		assert.Equal(t, result.Args.Encode(), "", "expected empty args")
		assert.Equal(t, result.URL, srv.URL+"/get", "url mismatch")

		if !strings.HasPrefix(result.Origin, "127.0.0.1") {
			t.Fatalf("expected 127.0.0.1 origin, got %q", result.Origin)
		}

		wantHeaders := map[string]string{
			"Content-Type": "",
			"User-Agent":   "test",
		}
		for key, val := range wantHeaders {
			assert.Equal(t, result.Headers.Get(key), val, "header mismatch for key %q", key)
		}
	})

	t.Run("with_query_params", func(t *testing.T) {
		t.Parallel()

		params := url.Values{}
		params.Set("foo", "foo")
		params.Add("bar", "bar1")
		params.Add("bar", "bar2")

		result := doGetRequest(t, "/get", params, nil)
		assert.Equal(t, result.Args.Encode(), params.Encode(), "args mismatch")
		assert.Equal(t, result.Method, "GET", "method mismatch")
	})

	t.Run("only_allows_gets", func(t *testing.T) {
		t.Parallel()

		req := newTestRequest(t, "POST", "/get")
		resp := must.DoReq(t, client, req)

		assert.StatusCode(t, resp, http.StatusMethodNotAllowed)
		assert.ContentType(t, resp, textContentType)
	})

	protoTests := []struct {
		key   string
		value string
	}{
		{"X-Forwarded-Proto", "https"},
		{"X-Forwarded-Protocol", "https"},
		{"X-Forwarded-Ssl", "on"},
	}
	for _, test := range protoTests {
		test := test
		t.Run(test.key, func(t *testing.T) {
			t.Parallel()
			headers := http.Header{}
			headers.Set(test.key, test.value)
			result := doGetRequest(t, "/get", nil, headers)
			if !strings.HasPrefix(result.URL, "https://") {
				t.Fatalf("%s=%s should result in https URL", test.key, test.value)
			}
		})
	}
}

func TestHead(t *testing.T) {
	testCases := []struct {
		verb     string
		path     string
		wantCode int
	}{
		{"HEAD", "/", http.StatusOK},
		{"HEAD", "/get", http.StatusOK},
		{"HEAD", "/head", http.StatusOK},
		{"HEAD", "/post", http.StatusMethodNotAllowed},
		{"GET", "/head", http.StatusMethodNotAllowed},
	}
	for _, tc := range testCases {
		tc := tc
		t.Run(fmt.Sprintf("%s %s", tc.verb, tc.path), func(t *testing.T) {
			t.Parallel()

			req := newTestRequest(t, tc.verb, tc.path)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, tc.wantCode)

			// we only do further validation when we get an OK response
			if tc.wantCode != http.StatusOK {
				return
			}

			assert.StatusCode(t, resp, http.StatusOK)
			assert.BodyEquals(t, resp, "")
			assert.Header(t, resp, "Content-Length", "") // content-length should be empty
		})
	}
}

func TestCORS(t *testing.T) {
	t.Run("CORS/no_request_origin", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "GET", "/get")
		resp := must.DoReq(t, client, req)
		assert.Header(t, resp, "Access-Control-Allow-Origin", "*")
	})

	t.Run("CORS/with_request_origin", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "GET", "/get")
		req.Header.Set("Origin", "origin")
		resp := must.DoReq(t, client, req)
		assert.Header(t, resp, "Access-Control-Allow-Origin", "origin")
	})

	t.Run("CORS/options_request", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "OPTIONS", "/get")
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, 200)

		headerTests := []struct {
			key      string
			expected string
		}{
			{"Access-Control-Allow-Origin", "*"},
			{"Access-Control-Allow-Credentials", "true"},
			{"Access-Control-Allow-Methods", "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS"},
			{"Access-Control-Max-Age", "3600"},
			{"Access-Control-Allow-Headers", ""},
		}
		for _, test := range headerTests {
			assert.Header(t, resp, test.key, test.expected)
		}
	})

	t.Run("CORS/allow_headers", func(t *testing.T) {
		t.Parallel()

		req := newTestRequest(t, "OPTIONS", "/get")
		req.Header.Set("Access-Control-Request-Headers", "X-Test-Header")
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, 200)

		headerTests := []struct {
			key      string
			expected string
		}{
			{"Access-Control-Allow-Headers", "X-Test-Header"},
		}
		for _, test := range headerTests {
			assert.Header(t, resp, test.key, test.expected)
		}
	})
}

func TestIP(t *testing.T) {
	testCases := map[string]struct {
		remoteAddr string
		headers    map[string]string
		wantOrigin string
	}{
		"remote addr used if no x-forwarded-for": {
			remoteAddr: "192.168.0.100",
			wantOrigin: "192.168.0.100",
		},
		"remote addr used if x-forwarded-for empty": {
			remoteAddr: "192.168.0.100",
			headers:    map[string]string{"X-Forwarded-For": ""},
			wantOrigin: "192.168.0.100",
		},
		"first entry in x-forwarded-for used if present": {
			remoteAddr: "192.168.0.100",
			headers:    map[string]string{"X-Forwarded-For": "10.1.1.1, 10.2.2.2, 10.3.3.3"},
			wantOrigin: "10.1.1.1",
		},
		"single entry x-forwarded-for ok": {
			remoteAddr: "192.168.0.100",
			headers:    map[string]string{"X-Forwarded-For": "10.1.1.1"},
			wantOrigin: "10.1.1.1",
		},
	}

	for name, tc := range testCases {
		tc := tc
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			req, _ := http.NewRequest("GET", "/ip", nil)
			req.RemoteAddr = tc.remoteAddr
			for k, v := range tc.headers {
				req.Header.Set(k, v)
			}

			// this test does not use a real server, because we need to control
			// the RemoteAddr field on the request object to make the test
			// deterministic.
			w := httptest.NewRecorder()
			app.ServeHTTP(w, req)

			if w.Code != http.StatusOK {
				t.Errorf("wanted status code %d, got %d", http.StatusOK, w.Code)
			}

			if ct := w.Header().Get("Content-Type"); ct != jsonContentType {
				t.Errorf("expected content type %q, got %q", jsonContentType, ct)
			}

			result := must.Unmarshal[ipResponse](t, w.Body)
			assert.Equal(t, result.Origin, tc.wantOrigin, "incorrect origin")
		})
	}
}

func TestUserAgent(t *testing.T) {
	t.Parallel()

	req := newTestRequest(t, "GET", "/user-agent")
	req.Header.Set("User-Agent", "test")

	resp := must.DoReq(t, client, req)
	result := mustParseResponse[userAgentResponse](t, resp)
	assert.Equal(t, "test", result.UserAgent, "incorrect user agent")
}

func TestHeaders(t *testing.T) {
	t.Parallel()

	req := newTestRequest(t, "GET", "/headers")
	req.Host = "test-host"
	req.Header.Set("User-Agent", "test")
	req.Header.Set("Foo-Header", "foo")
	req.Header.Add("Bar-Header", "bar1")
	req.Header.Add("Bar-Header", "bar2")

	resp := must.DoReq(t, client, req)
	result := mustParseResponse[headersResponse](t, resp)

	// Host header requires special treatment, because it's a field on the
	// http.Request struct itself, not part of its headers map
	host := result.Headers.Get("Host")
	assert.Equal(t, req.Host, host, "missing or incorrect Host header")

	for k, expectedValues := range req.Header {
		values := result.Headers.Values(k)
		assert.DeepEqual(t, expectedValues, values, "missing or incorrect header for key %q", k)
	}
}

func TestPost(t *testing.T) {
	testRequestWithBody(t, "POST", "/post")
}

func TestPut(t *testing.T) {
	testRequestWithBody(t, "PUT", "/put")
}

func TestDelete(t *testing.T) {
	testRequestWithBody(t, "DELETE", "/delete")
}

func TestPatch(t *testing.T) {
	testRequestWithBody(t, "PATCH", "/patch")
}

func TestAnything(t *testing.T) {
	var (
		verbs = []string{
			"GET",
			"DELETE",
			"PATCH",
			"POST",
			"PUT",
		}
		paths = []string{
			"/anything",
			"/anything/else",
		}
	)
	for _, path := range paths {
		for _, verb := range verbs {
			testRequestWithBody(t, verb, path)
		}
	}

	t.Run("HEAD", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "HEAD", "/anything")
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusOK)
		assert.BodyEquals(t, resp, "")
		assert.Header(t, resp, "Content-Length", "") // responses to HEAD requests should not have a Content-Length header
	})
}

func testRequestWithBody(t *testing.T, verb, path string) {
	// getFuncName uses runtime type reflection to get the name of the given
	// function.
	//
	// Cribbed from https://stackoverflow.com/a/70535822/151221
	getFuncName := func(f interface{}) string {
		parts := strings.Split((runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()), ".")
		return parts[len(parts)-1]
	}

	// getTestName expects a function named like testRequestWithBody__BodyTooBig
	// and returns only the trailing BodyTooBig part.
	getTestName := func(prefix string, f interface{}) string {
		name := strings.TrimPrefix(getFuncName(f), "testRequestWithBody")
		return fmt.Sprintf("%s/%s", prefix, name)
	}

	type testFunc func(t *testing.T, verb, path string)
	testFuncs := []testFunc{
		testRequestWithBodyBinaryBody,
		testRequestWithBodyBodyTooBig,
		testRequestWithBodyEmptyBody,
		testRequestWithBodyExpect100Continue,
		testRequestWithBodyFormEncodedBody,
		testRequestWithBodyFormEncodedBodyNoContentType,
		testRequestWithBodyHTML,
		testRequestWithBodyInvalidFormEncodedBody,
		testRequestWithBodyInvalidJSON,
		testRequestWithBodyInvalidMultiPartBody,
		testRequestWithBodyJSON,
		testRequestWithBodyMultiPartBody,
		testRequestWithBodyMultiPartBodyFiles,
		testRequestWithBodyQueryParams,
		testRequestWithBodyQueryParamsAndBody,
		testRequestWithBodyTransferEncoding,
	}
	for _, testFunc := range testFuncs {
		testFunc := testFunc
		t.Run(getTestName(verb, testFunc), func(t *testing.T) {
			t.Parallel()
			testFunc(t, verb, path)
		})
	}
}

func testRequestWithBodyBinaryBody(t *testing.T, verb string, path string) {
	tests := []struct {
		contentType string
		requestBody string
	}{
		{"application/octet-stream", "encodeMe"},
		{"image/png", "encodeMe-png"},
		{"image/webp", "encodeMe-webp"},
		{"image/jpeg", "encodeMe-jpeg"},
		{"unknown", "encodeMe-unknown"},
	}
	for _, test := range tests {
		test := test
		t.Run("content type/"+test.contentType, func(t *testing.T) {
			t.Parallel()

			req := newTestRequestWithBody(t, verb, path, bytes.NewReader([]byte(test.requestBody)))
			req.Header.Set("Content-Type", test.contentType)

			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)

			result := mustParseResponse[bodyResponse](t, resp)
			assert.Equal(t, result.Method, verb, "method mismatch")
			assert.DeepEqual(t, result.Args, nilValues, "expected empty args")
			assert.DeepEqual(t, result.Files, nilValues, "expected empty files")
			assert.DeepEqual(t, result.Form, nilValues, "expected empty form")
			assert.DeepEqual(t, result.JSON, nil, "expected nil json")

			expected := "data:" + test.contentType + ";base64," + base64.StdEncoding.EncodeToString([]byte(test.requestBody))
			assert.Equal(t, result.Data, expected, "expected binary encoded response data")
		})
	}
}

func testRequestWithBodyEmptyBody(t *testing.T, verb string, path string) {
	tests := []struct {
		contentType string
	}{
		{""},
		{"application/json; charset=utf-8"},
		{"application/x-www-form-urlencoded"},
		{"multipart/form-data; foo"},
	}
	for _, test := range tests {
		test := test
		t.Run("content type/"+test.contentType, func(t *testing.T) {
			t.Parallel()

			req := newTestRequest(t, verb, path)
			req.Header.Set("Content-Type", test.contentType)

			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)

			result := mustParseResponse[bodyResponse](t, resp)
			assert.Equal(t, result.Data, "", "expected empty response data")
			assert.Equal(t, result.Method, verb, "method mismatch")
			assert.DeepEqual(t, result.Args, nilValues, "expected empty args")
			assert.DeepEqual(t, result.Files, nilValues, "expected empty files")
			assert.DeepEqual(t, result.Form, nilValues, "expected empty form")
			assert.DeepEqual(t, result.JSON, nil, "expected nil JSON")
		})
	}
}

func testRequestWithBodyFormEncodedBody(t *testing.T, verb, path string) {
	params := url.Values{}
	params.Set("foo", "foo")
	params.Add("bar", "bar1")
	params.Add("bar", "bar2")

	req := newTestRequestWithBody(t, verb, path, strings.NewReader(params.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	resp := must.DoReq(t, client, req)
	result := mustParseResponse[bodyResponse](t, resp)

	assert.DeepEqual(t, result.Form, params, "form data mismatch")
	assert.Equal(t, result.Method, verb, "method mismatch")
	assert.DeepEqual(t, result.Args, nilValues, "expected empty args")
	assert.DeepEqual(t, result.Files, nilValues, "expected empty files")
	assert.DeepEqual(t, result.JSON, nil, "expected nil json")
}

func testRequestWithBodyHTML(t *testing.T, verb, path string) {
	data := "<html><body><h1>hello world</h1></body></html>"

	req := newTestRequestWithBody(t, verb, path, strings.NewReader(data))
	req.Header.Set("Content-Type", htmlContentType)

	resp := must.DoReq(t, client, req)
	assert.StatusCode(t, resp, http.StatusOK)
	assert.ContentType(t, resp, jsonContentType)
	assert.BodyContains(t, resp, data)
}

func testRequestWithBodyExpect100Continue(t *testing.T, verb, path string) {
	// The stdlib http client automagically handles 100 Continue responses
	// by continuing the request until a "final" 200 OK response is
	// received, which prevents us from confirming that a 100 Continue
	// response is sent when using the http client directly.
	//
	// So, here we instead manally write the request to the wire in two
	// steps, confirming that we receive a 100 Continue response before
	// sending the body and getting the normal expected response.

	t.Run("non-zero content-length okay", func(t *testing.T) {
		t.Parallel()

		conn, err := net.Dial("tcp", srv.Listener.Addr().String())
		assert.NilError(t, err)
		defer conn.Close()

		body := []byte("test body")

		req := newTestRequestWithBody(t, verb, path, bytes.NewReader(body))
		req.Header.Set("Expect", "100-continue")
		req.Header.Set("Content-Type", "text/plain")

		reqBytes, _ := httputil.DumpRequestOut(req, false)
		t.Logf("raw request:\n%q", reqBytes)

		if !strings.Contains(string(reqBytes), "Content-Length: 9") {
			t.Fatalf("expected request to contain Content-Length header")
		}

		// first, we write the request line and headers -- but NOT the body --
		// which should cause the server to respond with a 100 Continue
		// response.
		{
			n, err := conn.Write(reqBytes)
			assert.NilError(t, err)
			assert.Equal(t, n, len(reqBytes), "incorrect number of bytes written")

			resp, err := http.ReadResponse(bufio.NewReader(conn), req)
			assert.NilError(t, err)
			assert.StatusCode(t, resp, http.StatusContinue)
		}

		// Once we've gotten the 100 Continue response, we write the body. After
		// that, we should get a normal 200 OK response along with the expected
		// result.
		{
			n, err := conn.Write(body)
			assert.NilError(t, err)
			assert.Equal(t, n, len(body), "incorrect number of bytes written")

			resp, err := http.ReadResponse(bufio.NewReader(conn), req)
			assert.NilError(t, err)
			assert.StatusCode(t, resp, http.StatusOK)

			got := must.Unmarshal[bodyResponse](t, resp.Body)
			assert.Equal(t, got.Data, string(body), "incorrect body")
		}
	})

	t.Run("transfer-encoding:chunked okay", func(t *testing.T) {
		t.Parallel()

		conn, err := net.Dial("tcp", srv.Listener.Addr().String())
		assert.NilError(t, err)
		defer conn.Close()

		body := []byte("test body")

		reqParts := []string{
			fmt.Sprintf("%s %s HTTP/1.1", verb, path),
			"Host: test",
			"Content-Type: text/plain",
			"Expect: 100-continue",
			"Transfer-Encoding: chunked",
		}
		reqBytes := []byte(strings.Join(reqParts, "\r\n") + "\r\n\r\n")
		t.Logf("raw request:\n%q", reqBytes)

		// first, we write the request line and headers -- but NOT the body --
		// which should cause the server to respond with a 100 Continue
		// response.
		{
			n, err := conn.Write(reqBytes)
			assert.NilError(t, err)
			assert.Equal(t, n, len(reqBytes), "incorrect number of bytes written")

			resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
			assert.NilError(t, err)
			assert.StatusCode(t, resp, http.StatusContinue)
		}

		// Once we've gotten the 100 Continue response, we write the body. After
		// that, we should get a normal 200 OK response along with the expected
		// result.
		{
			// write chunk size
			_, err := conn.Write([]byte("9\r\n"))
			assert.NilError(t, err)

			// write chunk data
			n, err := conn.Write(append(body, "\r\n"...))
			assert.NilError(t, err)
			assert.Equal(t, n, len(body)+2, "incorrect number of bytes written")

			// write empty terminating chunk
			_, err = conn.Write([]byte("0\r\n\r\n"))
			assert.NilError(t, err)

			resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
			assert.NilError(t, err)
			assert.StatusCode(t, resp, http.StatusOK)

			got := must.Unmarshal[bodyResponse](t, resp.Body)
			assert.Equal(t, got.Data, string(body), "incorrect body")
		}
	})

	t.Run("zero content-length ignored", func(t *testing.T) {
		// The Go stdlib's Expect:100-continue handling requires either a a)
		// non-zero Content-Length header or b) Transfer-Encoding:chunked
		// header to be present.  Otherwise, the Expect header is ignored and
		// the request is processed normally.
		t.Parallel()

		conn, err := net.Dial("tcp", srv.Listener.Addr().String())
		assert.NilError(t, err)
		defer conn.Close()

		req := newTestRequest(t, verb, path)
		req.Header.Set("Expect", "100-continue")

		reqBytes, _ := httputil.DumpRequestOut(req, false)
		t.Logf("raw request:\n%q", reqBytes)

		// For GET and DELETE requests, it appears the Go stdlib does not
		// include a Content-Length:0 header, so we ensure that the header is
		// either missing or has a value of 0.
		switch verb {
		case "GET", "DELETE":
			if strings.Contains(string(reqBytes), "Content-Length:") {
				t.Fatalf("expected no Content-Length header for %s request", verb)
			}
		default:
			if !strings.Contains(string(reqBytes), "Content-Length: 0") {
				t.Fatalf("expected Content-Length:0 header for %s request", verb)
			}
		}

		n, err := conn.Write(reqBytes)
		assert.NilError(t, err)
		assert.Equal(t, n, len(reqBytes), "incorrect number of bytes written")

		resp, err := http.ReadResponse(bufio.NewReader(conn), req)
		assert.NilError(t, err)

		// Note: the server should NOT send a 100 Continue response here,
		// because we send a request without a Content-Length header or with a
		// Content-Length: 0 header.
		assert.StatusCode(t, resp, http.StatusOK)

		got := must.Unmarshal[bodyResponse](t, resp.Body)
		assert.Equal(t, got.Data, "", "incorrect body")
	})
}

func testRequestWithBodyFormEncodedBodyNoContentType(t *testing.T, verb, path string) {
	params := url.Values{}
	params.Set("foo", "foo")
	params.Add("bar", "bar1")
	params.Add("bar", "bar2")

	req := newTestRequestWithBody(t, verb, path, strings.NewReader(params.Encode()))
	resp := must.DoReq(t, client, req)
	result := mustParseResponse[bodyResponse](t, resp)

	assert.Equal(t, result.Method, verb, "method mismatch")
	assert.DeepEqual(t, result.Args, nilValues, "expected empty args")
	assert.DeepEqual(t, result.Files, nilValues, "expected empty files")
	assert.DeepEqual(t, result.Form, nilValues, "expected empty form")
	assert.DeepEqual(t, result.JSON, nil, "expected nil JSON")

	// Because we did not set an content type, httpbin will return the base64 encoded data.
	expectedBody := "data:application/octet-stream;base64," + base64.StdEncoding.EncodeToString([]byte(params.Encode()))
	assert.Equal(t, result.Data, expectedBody, "response data mismatch")
}

func testRequestWithBodyMultiPartBody(t *testing.T, verb, path string) {
	params := url.Values{
		"foo": {"foo"},
		"bar": {"bar1", "bar2"},
	}

	// Prepare a form that you will submit to that URL.
	var body bytes.Buffer
	mw := multipart.NewWriter(&body)

	for k, vs := range params {
		for _, v := range vs {
			fw, err := mw.CreateFormField(k)
			assert.NilError(t, err)
			_, err = fw.Write([]byte(v))
			assert.NilError(t, err)
		}
	}
	mw.Close()

	req := newTestRequestWithBody(t, verb, path, bytes.NewReader(body.Bytes()))
	req.Header.Set("Content-Type", mw.FormDataContentType())

	resp := must.DoReq(t, client, req)
	result := mustParseResponse[bodyResponse](t, resp)

	assert.Equal(t, result.Method, verb, "method mismatch")
	assert.DeepEqual(t, result.Args, nilValues, "expected empty args")
	assert.DeepEqual(t, result.Files, nilValues, "expected empty files")
	assert.DeepEqual(t, result.Form, params, "form values mismatch")
	assert.DeepEqual(t, result.JSON, nil, "expected nil JSON")
}

func testRequestWithBodyMultiPartBodyFiles(t *testing.T, verb, path string) {
	var body bytes.Buffer
	mw := multipart.NewWriter(&body)

	// Add a file to the multipart request
	part, _ := mw.CreateFormFile("fieldname", "filename")
	part.Write([]byte("hello world"))
	mw.Close()

	req := newTestRequestWithBody(t, verb, path, bytes.NewReader(body.Bytes()))
	req.Header.Set("Content-Type", mw.FormDataContentType())

	resp := must.DoReq(t, client, req)
	result := mustParseResponse[bodyResponse](t, resp)

	assert.Equal(t, result.Method, verb, "method mismatch")
	assert.DeepEqual(t, result.Args, nilValues, "expected empty args")
	assert.DeepEqual(t, result.Form, nilValues, "expected empty form")
	assert.DeepEqual(t, result.JSON, nil, "expected nil JSON")

	// verify that the file we added is present in the `files` attribute of the
	// response, with the field as key and content as value
	wantFiles := url.Values{
		"fieldname": {"hello world"},
	}
	assert.DeepEqual(t, result.Files, wantFiles, "files mismatch")
}

func testRequestWithBodyInvalidFormEncodedBody(t *testing.T, verb, path string) {
	req := newTestRequestWithBody(t, verb, path, strings.NewReader("%ZZ"))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp := must.DoReq(t, client, req)
	assert.StatusCode(t, resp, http.StatusBadRequest)
}

func testRequestWithBodyInvalidMultiPartBody(t *testing.T, verb, path string) {
	req := newTestRequestWithBody(t, verb, path, strings.NewReader("%ZZ"))
	req.Header.Set("Content-Type", "multipart/form-data; etc")
	resp := must.DoReq(t, client, req)
	assert.StatusCode(t, resp, http.StatusBadRequest)
}

func testRequestWithBodyJSON(t *testing.T, verb, path string) {
	type testInput struct {
		Foo  string
		Bar  int
		Baz  []float64
		Quux map[int]string
	}
	input := testInput{
		Foo:  "foo",
		Bar:  123,
		Baz:  []float64{1.0, 1.1, 1.2},
		Quux: map[int]string{1: "one", 2: "two", 3: "three"},
	}
	inputBody, _ := json.Marshal(input)

	req := newTestRequestWithBody(t, verb, path, bytes.NewReader(inputBody))
	req.Header.Set("Content-Type", "application/json; charset=utf-8")

	resp := must.DoReq(t, client, req)
	result := mustParseResponse[bodyResponse](t, resp)

	assert.Equal(t, result.Data, string(inputBody), "response data mismatch")
	assert.Equal(t, result.Method, verb, "method mismatch")
	assert.DeepEqual(t, result.Args, nilValues, "expected empty args")
	assert.DeepEqual(t, result.Files, nilValues, "expected empty files")
	assert.DeepEqual(t, result.Form, nilValues, "form values mismatch")

	// Need to re-marshall just the JSON field from the response in order to
	// re-unmarshall it into our expected type
	roundTrippedInputBytes, err := json.Marshal(result.JSON)
	assert.NilError(t, err)

	roundTrippedInput := must.Unmarshal[testInput](t, bytes.NewReader(roundTrippedInputBytes))
	assert.DeepEqual(t, roundTrippedInput, input, "round-tripped JSON mismatch")
}

func testRequestWithBodyInvalidJSON(t *testing.T, verb, path string) {
	req := newTestRequestWithBody(t, verb, path, strings.NewReader("foo"))
	req.Header.Set("Content-Type", "application/json; charset=utf-8")
	resp := must.DoReq(t, client, req)
	assert.StatusCode(t, resp, http.StatusBadRequest)
}

func testRequestWithBodyBodyTooBig(t *testing.T, verb, path string) {
	body := make([]byte, maxBodySize+1)
	req := newTestRequestWithBody(t, verb, path, bytes.NewReader(body))
	resp := must.DoReq(t, client, req)
	assert.StatusCode(t, resp, http.StatusBadRequest)
}

func testRequestWithBodyQueryParams(t *testing.T, verb, path string) {
	params := url.Values{}
	params.Set("foo", "foo")
	params.Add("bar", "bar1")
	params.Add("bar", "bar2")

	req := newTestRequest(t, verb, fmt.Sprintf("%s?%s", path, params.Encode()))
	resp := must.DoReq(t, client, req)
	result := mustParseResponse[bodyResponse](t, resp)

	assert.DeepEqual(t, result.Args, params, "args mismatch")

	// extra validation
	assert.Equal(t, result.Method, verb, "method mismatch")
	assert.DeepEqual(t, result.Files, nilValues, "expected empty files")
	assert.DeepEqual(t, result.Form, nilValues, "form values mismatch")
	assert.DeepEqual(t, result.JSON, nil, "expected nil JSON")
}

func testRequestWithBodyQueryParamsAndBody(t *testing.T, verb, path string) {
	args := url.Values{}
	args.Set("query1", "foo")
	args.Add("query2", "bar1")
	args.Add("query2", "bar2")

	form := url.Values{}
	form.Set("form1", "foo")
	form.Add("form2", "bar1")
	form.Add("form2", "bar2")

	url := fmt.Sprintf("%s?%s", path, args.Encode())
	req := newTestRequestWithBody(t, verb, url, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp := must.DoReq(t, client, req)

	result := mustParseResponse[bodyResponse](t, resp)
	assert.Equal(t, result.Method, verb, "method mismatch")
	assert.Equal(t, result.Args.Encode(), args.Encode(), "args mismatch")
	assert.Equal(t, result.Form.Encode(), form.Encode(), "form mismatch")
}

func testRequestWithBodyTransferEncoding(t *testing.T, verb, path string) {
	testCases := []struct {
		given string
		want  string
	}{
		{"", ""},
		{"identity", ""},
		{"chunked", "chunked"},
	}
	for _, tc := range testCases {
		tc := tc
		t.Run("transfer-encoding/"+tc.given, func(t *testing.T) {
			t.Parallel()

			req := newTestRequestWithBody(t, verb, path, bytes.NewReader([]byte("{}")))
			if tc.given != "" {
				req.TransferEncoding = []string{tc.given}
			}

			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)

			result := mustParseResponse[bodyResponse](t, resp)
			got := result.Headers.Get("Transfer-Encoding")
			assert.Equal(t, got, tc.want, "Transfer-Encoding header mismatch")
		})
	}
}

// TODO: implement and test more complex /status endpoint
func TestStatus(t *testing.T) {
	redirectHeaders := map[string]string{
		"Location": "/redirect/1",
	}
	unauthorizedHeaders := map[string]string{
		"WWW-Authenticate": `Basic realm="Fake Realm"`,
	}
	tests := []struct {
		code    int
		headers map[string]string
		body    string
	}{
		// 100 is tested as a special case below
		{200, nil, ""},
		{300, map[string]string{"Location": "/image/jpeg"}, `<!doctype html>
<head>
<title>Multiple Choices</title>
</head>
<body>
<ul>
<li><a href="/image/jpeg">/image/jpeg</a></li>
<li><a href="/image/png">/image/png</a></li>
<li><a href="/image/svg">/image/svg</a></li>
</body>
</html>`},
		{301, redirectHeaders, ""},
		{302, redirectHeaders, ""},
		{308, map[string]string{"Location": "/image/jpeg"}, `<!doctype html>
<head>
<title>Permanent Redirect</title>
</head>
<body>Permanently redirected to <a href="/image/jpeg">/image/jpeg</a>
</body>
</html>`},
		{401, unauthorizedHeaders, ""},
		{418, nil, "I'm a teapot!"},
		{500, nil, ""}, // maximum allowed status code
		{599, nil, ""}, // maximum allowed status code
	}

	for _, test := range tests {
		test := test
		t.Run(fmt.Sprintf("ok/status/%d", test.code), func(t *testing.T) {
			t.Parallel()
			req, _ := http.NewRequest("GET", srv.URL+fmt.Sprintf("/status/%d", test.code), nil)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.code)
			assert.BodyEquals(t, resp, test.body)
			for key, val := range test.headers {
				assert.Header(t, resp, key, val)
			}
		})
	}

	errorTests := []struct {
		url    string
		status int
	}{
		{"/status", http.StatusNotFound},
		{"/status/", http.StatusBadRequest},
		{"/status/200/foo", http.StatusNotFound},
		{"/status/3.14", http.StatusBadRequest},
		{"/status/foo", http.StatusBadRequest},
		{"/status/600", http.StatusBadRequest},
		{"/status/1024", http.StatusBadRequest},
	}

	for _, test := range errorTests {
		test := test
		t.Run("error"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.status)
		})
	}

	t.Run("HTTP 100 Continue status code supported", func(t *testing.T) {
		// The stdlib http client automagically handles 100 Continue responses
		// by continuing the request until a "final" 200 OK response is
		// received, which prevents us from confirming that a 100 Continue
		// response is sent when using the http client directly.
		//
		// So, here we instead manally write the request to the wire and read
		// the initial response, which will give us access to the 100 Continue
		// indication we need.
		t.Parallel()

		conn, err := net.Dial("tcp", srv.Listener.Addr().String())
		assert.NilError(t, err)
		defer conn.Close()

		req := newTestRequest(t, "GET", "/status/100")
		reqBytes, err := httputil.DumpRequestOut(req, false)
		assert.NilError(t, err)

		n, err := conn.Write(reqBytes)
		assert.NilError(t, err)
		assert.Equal(t, n, len(reqBytes), "incorrect number of bytes written")

		resp, err := http.ReadResponse(bufio.NewReader(conn), req)
		assert.NilError(t, err)
		assert.StatusCode(t, resp, http.StatusContinue)
	})
}

func TestUnstable(t *testing.T) {
	t.Run("ok_no_seed", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "GET", "/unstable")
		resp := must.DoReq(t, client, req)
		if resp.StatusCode != 200 && resp.StatusCode != 500 {
			t.Fatalf("expected status code 200 or 500, got %d", resp.StatusCode)
		}
	})

	tests := []struct {
		url    string
		status int
	}{
		// rand.NewSource(1234567890).Float64() => 0.08
		{"/unstable?seed=1234567890", 500},
		{"/unstable?seed=1234567890&failure_rate=0.07", 200},
	}
	for _, test := range tests {
		test := test
		t.Run("ok_"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.status)
		})
	}

	edgeCaseTests := []string{
		// strange but valid seed
		"/unstable?seed=-12345",
	}
	for _, test := range edgeCaseTests {
		test := test
		t.Run("bad"+test, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			if resp.StatusCode != 200 && resp.StatusCode != 500 {
				t.Fatalf("expected status code 200 or 500, got %d", resp.StatusCode)
			}
		})
	}

	badTests := []string{
		// bad failure_rate
		"/unstable?failure_rate=foo",
		"/unstable?failure_rate=-1",
		"/unstable?failure_rate=1.23",
		// bad seed
		"/unstable?seed=3.14",
		"/unstable?seed=foo",
	}
	for _, test := range badTests {
		test := test
		t.Run("bad"+test, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, http.StatusBadRequest)
		})
	}
}

func TestResponseHeaders(t *testing.T) {
	t.Run("ok", func(t *testing.T) {
		t.Parallel()

		wantHeaders := url.Values{
			"Foo": {"foo"},
			"Bar": {"bar1", "bar2"},
		}

		req, _ := http.NewRequest("GET", fmt.Sprintf("%s/response-headers?%s", srv.URL, wantHeaders.Encode()), nil)
		resp := must.DoReq(t, client, req)
		result := mustParseResponse[http.Header](t, resp)

		for k, expectedValues := range wantHeaders {
			// expected headers should be present in the HTTP response itself
			respValues := resp.Header[k]
			assert.DeepEqual(t, respValues, expectedValues, "HTTP response headers mismatch")

			// they should also be reflected in the decoded JSON resposne
			resultValues := result[k]
			assert.DeepEqual(t, resultValues, expectedValues, "JSON response headers mismatch")
		}
	})

	t.Run("override content-type", func(t *testing.T) {
		t.Parallel()

		contentType := "text/test"

		params := url.Values{}
		params.Set("Content-Type", contentType)

		req, _ := http.NewRequest("GET", fmt.Sprintf("%s/response-headers?%s", srv.URL, params.Encode()), nil)
		resp := must.DoReq(t, client, req)

		assert.StatusCode(t, resp, http.StatusOK)
		assert.ContentType(t, resp, contentType)
	})
}

func TestRedirects(t *testing.T) {
	tests := []struct {
		requestURL       string
		expectedLocation string
	}{
		{"/redirect/1", "/get"},
		{"/redirect/2", "/relative-redirect/1"},
		{"/redirect/100", "/relative-redirect/99"},

		{"/redirect/1?absolute=true", "http://host/get"},
		{"/redirect/2?absolute=TRUE", "http://host/absolute-redirect/1"},
		{"/redirect/100?absolute=True", "http://host/absolute-redirect/99"},

		{"/redirect/100?absolute=t", "/relative-redirect/99"},
		{"/redirect/100?absolute=1", "/relative-redirect/99"},
		{"/redirect/100?absolute=yes", "/relative-redirect/99"},

		{"/relative-redirect/1", "/get"},
		{"/relative-redirect/2", "/relative-redirect/1"},
		{"/relative-redirect/100", "/relative-redirect/99"},

		{"/absolute-redirect/1", "http://host/get"},
		{"/absolute-redirect/2", "http://host/absolute-redirect/1"},
		{"/absolute-redirect/100", "http://host/absolute-redirect/99"},
	}

	for _, test := range tests {
		test := test
		t.Run("ok"+test.requestURL, func(t *testing.T) {
			t.Parallel()

			req := newTestRequest(t, "GET", test.requestURL)
			req.Host = "host"
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)

			assert.StatusCode(t, resp, http.StatusFound)
			assert.Header(t, resp, "Location", test.expectedLocation)
		})
	}

	errorTests := []struct {
		requestURL     string
		expectedStatus int
	}{
		{"/redirect", http.StatusNotFound},
		{"/redirect/", http.StatusBadRequest},
		{"/redirect/-1", http.StatusBadRequest},
		{"/redirect/3.14", http.StatusBadRequest},
		{"/redirect/foo", http.StatusBadRequest},
		{"/redirect/10/foo", http.StatusNotFound},

		{"/relative-redirect", http.StatusNotFound},
		{"/relative-redirect/", http.StatusBadRequest},
		{"/relative-redirect/-1", http.StatusBadRequest},
		{"/relative-redirect/3.14", http.StatusBadRequest},
		{"/relative-redirect/foo", http.StatusBadRequest},
		{"/relative-redirect/10/foo", http.StatusNotFound},

		{"/absolute-redirect", http.StatusNotFound},
		{"/absolute-redirect/", http.StatusBadRequest},
		{"/absolute-redirect/-1", http.StatusBadRequest},
		{"/absolute-redirect/3.14", http.StatusBadRequest},
		{"/absolute-redirect/foo", http.StatusBadRequest},
		{"/absolute-redirect/10/foo", http.StatusNotFound},
	}

	for _, test := range errorTests {
		test := test
		t.Run("error"+test.requestURL, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.requestURL)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
		})
	}
}

func TestRedirectTo(t *testing.T) {
	okTests := []struct {
		url              string
		expectedLocation string
		expectedStatus   int
	}{
		{"/redirect-to?url=http://www.example.com/", "http://www.example.com/", http.StatusFound},
		{"/redirect-to?url=http://www.example.com/&status_code=307", "http://www.example.com/", http.StatusTemporaryRedirect},

		{"/redirect-to?url=/get", "/get", http.StatusFound},
		{"/redirect-to?url=/get&status_code=307", "/get", http.StatusTemporaryRedirect},

		{"/redirect-to?url=foo", "foo", http.StatusFound},
	}

	for _, test := range okTests {
		test := test
		t.Run("ok"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
			assert.Header(t, resp, "Location", test.expectedLocation)
		})
	}

	badTests := []struct {
		url            string
		expectedStatus int
	}{
		{"/redirect-to", http.StatusBadRequest},                                               // missing url
		{"/redirect-to?status_code=302", http.StatusBadRequest},                               // missing url
		{"/redirect-to?url=foo&status_code=201", http.StatusBadRequest},                       // invalid status code
		{"/redirect-to?url=foo&status_code=418", http.StatusBadRequest},                       // invalid status code
		{"/redirect-to?url=foo&status_code=foo", http.StatusBadRequest},                       // invalid status code
		{"/redirect-to?url=http%3A%2F%2Ffoo%25%25bar&status_code=418", http.StatusBadRequest}, // invalid URL
	}
	for _, test := range badTests {
		test := test
		t.Run("bad"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
		})
	}

	allowListTests := []struct {
		url            string
		expectedStatus int
	}{
		{"/redirect-to?url=http://httpbingo.org", http.StatusFound},                // allowlist ok
		{"/redirect-to?url=https://httpbingo.org", http.StatusFound},               // scheme doesn't matter
		{"/redirect-to?url=https://example.org/foo/bar", http.StatusFound},         // paths don't matter
		{"/redirect-to?url=https://foo.example.org/foo/bar", http.StatusForbidden}, // subdomains of allowed domains do not match
		{"/redirect-to?url=https://evil.com", http.StatusForbidden},                // not in allowlist
	}
	for _, test := range allowListTests {
		test := test
		t.Run("allowlist"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
			if test.expectedStatus >= 400 {
				assert.BodyEquals(t, resp, app.forbiddenRedirectError)
			}
		})
	}
}

func TestCookies(t *testing.T) {
	t.Run("get", func(t *testing.T) {
		testCases := map[string]struct {
			cookies cookiesResponse
		}{
			"ok/no cookies": {
				cookies: cookiesResponse{},
			},
			"ok/one cookie": {
				cookies: cookiesResponse{
					"k1": "v1",
				},
			},
			"ok/many cookies": {
				cookies: cookiesResponse{
					"k1": "v1",
					"k2": "v2",
					"k3": "v3",
				},
			},
		}

		for name, tc := range testCases {
			tc := tc
			t.Run(name, func(t *testing.T) {
				t.Parallel()

				req := newTestRequest(t, "GET", "/cookies")
				for k, v := range tc.cookies {
					req.AddCookie(&http.Cookie{
						Name:  k,
						Value: v,
					})
				}

				resp := must.DoReq(t, client, req)
				defer consumeAndCloseBody(resp)

				result := mustParseResponse[cookiesResponse](t, resp)
				assert.DeepEqual(t, result, tc.cookies, "cookies mismatch")
			})
		}
	})

	t.Run("set", func(t *testing.T) {
		t.Parallel()

		cookies := cookiesResponse{
			"k1": "v1",
			"k2": "v2",
		}
		params := &url.Values{}
		for k, v := range cookies {
			params.Set(k, v)
		}

		req := newTestRequest(t, "GET", "/cookies/set?"+params.Encode())
		resp := must.DoReq(t, client, req)

		assert.StatusCode(t, resp, http.StatusFound)
		assert.Header(t, resp, "Location", "/cookies")

		for _, c := range resp.Cookies() {
			v, ok := cookies[c.Name]
			if !ok {
				t.Fatalf("got unexpected cookie %s=%s", c.Name, c.Value)
			}
			assert.Equal(t, v, c.Value, "value mismatch for cookie %q", c.Name)
		}
	})

	t.Run("delete", func(t *testing.T) {
		t.Parallel()

		cookies := cookiesResponse{
			"k1": "v1",
			"k2": "v2",
		}

		toDelete := "k2"
		params := &url.Values{}
		params.Set(toDelete, "")

		req := newTestRequest(t, "GET", "/cookies/delete?"+params.Encode())
		for k, v := range cookies {
			req.AddCookie(&http.Cookie{
				Name:  k,
				Value: v,
			})
		}

		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusFound)
		assert.Header(t, resp, "Location", "/cookies")

		for _, c := range resp.Cookies() {
			if c.Name == toDelete {
				if time.Since(c.Expires) < (24*365-1)*time.Hour {
					t.Fatalf("expected cookie %s to be deleted; got %#v", toDelete, c)
				}
			}
		}
	})
}

func TestBasicAuth(t *testing.T) {
	t.Run("ok", func(t *testing.T) {
		t.Parallel()

		req := newTestRequest(t, "GET", "/basic-auth/user/pass")
		req.SetBasicAuth("user", "pass")

		resp := must.DoReq(t, client, req)
		result := mustParseResponse[authResponse](t, resp)
		expectedResult := authResponse{
			Authorized: true,
			User:       "user",
		}
		assert.DeepEqual(t, result, expectedResult, "expected authorized user")
	})

	t.Run("error/no auth", func(t *testing.T) {
		t.Parallel()

		req := newTestRequest(t, "GET", "/basic-auth/user/pass")
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusUnauthorized)
		assert.ContentType(t, resp, jsonContentType)
		assert.Header(t, resp, "WWW-Authenticate", `Basic realm="Fake Realm"`)

		result := must.Unmarshal[authResponse](t, resp.Body)
		expectedResult := authResponse{
			Authorized: false,
			User:       "",
		}
		assert.DeepEqual(t, result, expectedResult, "expected unauthorized user")
	})

	t.Run("error/bad auth", func(t *testing.T) {
		t.Parallel()

		req := newTestRequest(t, "GET", "/basic-auth/user/pass")
		req.SetBasicAuth("bad", "auth")

		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusUnauthorized)
		assert.ContentType(t, resp, jsonContentType)
		assert.Header(t, resp, "WWW-Authenticate", `Basic realm="Fake Realm"`)

		result := must.Unmarshal[authResponse](t, resp.Body)
		expectedResult := authResponse{
			Authorized: false,
			User:       "bad",
		}
		assert.DeepEqual(t, result, expectedResult, "expected unauthorized user")
	})

	errorTests := []struct {
		url    string
		status int
	}{
		{"/basic-auth", http.StatusNotFound},
		{"/basic-auth/user", http.StatusNotFound},
		{"/basic-auth/user/pass/extra", http.StatusNotFound},
	}
	for _, test := range errorTests {
		test := test
		t.Run("error"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			req.SetBasicAuth("foo", "bar")
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.status)
		})
	}
}

func TestHiddenBasicAuth(t *testing.T) {
	t.Run("ok", func(t *testing.T) {
		t.Parallel()

		req := newTestRequest(t, "GET", "/hidden-basic-auth/user/pass")
		req.SetBasicAuth("user", "pass")

		resp := must.DoReq(t, client, req)
		result := mustParseResponse[authResponse](t, resp)
		expectedResult := authResponse{
			Authorized: true,
			User:       "user",
		}
		assert.DeepEqual(t, result, expectedResult, "expected authorized user")
	})

	t.Run("error/no auth", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "GET", "/hidden-basic-auth/user/pass")
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusNotFound)
		assert.Header(t, resp, "WWW-Authenticate", "")
	})

	t.Run("error/bad auth", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "GET", "/hidden-basic-auth/user/pass")
		req.SetBasicAuth("bad", "auth")
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusNotFound)
		assert.Header(t, resp, "WWW-Authenticate", "")
	})

	errorTests := []struct {
		url    string
		status int
	}{
		{"/hidden-basic-auth", http.StatusNotFound},
		{"/hidden-basic-auth/user", http.StatusNotFound},
		{"/hidden-basic-auth/user/pass/extra", http.StatusNotFound},
	}
	for _, test := range errorTests {
		test := test
		t.Run("error"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			req.SetBasicAuth("foo", "bar")
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.status)
		})
	}
}

func TestDigestAuth(t *testing.T) {
	tests := []struct {
		url    string
		status int
	}{
		{"/digest-auth", http.StatusNotFound},
		{"/digest-auth/user", http.StatusNotFound},
		{"/digest-auth/user/pass", http.StatusNotFound},
		{"/digest-auth/auth/user/pass/MD5/foo", http.StatusNotFound},

		// valid but unauthenticated requests
		{"/digest-auth/auth/user/pass", http.StatusUnauthorized},
		{"/digest-auth/auth/user/pass/MD5", http.StatusUnauthorized},
		{"/digest-auth/auth/user/pass/SHA-256", http.StatusUnauthorized},

		// invalid requests
		{"/digest-auth/bad-qop/user/pass/MD5", http.StatusBadRequest},
		{"/digest-auth/auth/user/pass/SHA-512", http.StatusBadRequest},
	}
	for _, test := range tests {
		test := test
		t.Run("ok"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.status)
		})
	}

	t.Run("ok", func(t *testing.T) {
		t.Parallel()

		// Example captured from a successful login in a browser
		authorization := strings.Join([]string{
			`Digest username="user"`,
			`realm="go-httpbin"`,
			`nonce="6fb213c6593975c877bb1247370527ad"`,
			`uri="/digest-auth/auth/user/pass/MD5"`,
			`algorithm=MD5`,
			`response="9b7a05d78051b4f668356eedf32f55d6"`,
			`opaque="fd1c386a015a2bb7c41585f54329ce91"`,
			`qop=auth`,
			`nc=00000001`,
			`cnonce="aaab705226af5bd4"`,
		}, ", ")

		req := newTestRequest(t, "GET", "/digest-auth/auth/user/pass/MD5")
		req.Header.Set("Authorization", authorization)

		resp := must.DoReq(t, client, req)
		result := mustParseResponse[authResponse](t, resp)
		expectedResult := authResponse{
			Authorized: true,
			User:       "user",
		}
		assert.DeepEqual(t, result, expectedResult, "expected authorized user")
	})
}

func TestGzip(t *testing.T) {
	t.Parallel()

	req := newTestRequest(t, "GET", "/gzip")
	req.Header.Set("Accept-Encoding", "none") // disable automagic gzip decompression in default http client

	resp := must.DoReq(t, client, req)
	assert.Header(t, resp, "Content-Encoding", "gzip")
	assert.ContentType(t, resp, jsonContentType)
	assert.StatusCode(t, resp, http.StatusOK)

	zippedContentLengthStr := resp.Header.Get("Content-Length")
	if zippedContentLengthStr == "" {
		t.Fatalf("missing Content-Length header in response")
	}

	zippedContentLength, err := strconv.Atoi(zippedContentLengthStr)
	assert.NilError(t, err)

	gzipReader, err := gzip.NewReader(resp.Body)
	assert.NilError(t, err)

	unzippedBody, err := io.ReadAll(gzipReader)
	assert.NilError(t, err)

	result := must.Unmarshal[noBodyResponse](t, bytes.NewBuffer(unzippedBody))
	assert.Equal(t, result.Gzipped, true, "expected resp.Gzipped == true")

	if len(unzippedBody) <= zippedContentLength {
		t.Fatalf("expected compressed body")
	}
}

func TestDeflate(t *testing.T) {
	t.Parallel()

	req := newTestRequest(t, "GET", "/deflate")
	resp := must.DoReq(t, client, req)

	assert.ContentType(t, resp, jsonContentType)
	assert.Header(t, resp, "Content-Encoding", "deflate")
	assert.StatusCode(t, resp, http.StatusOK)

	contentLengthHeader := resp.Header.Get("Content-Length")
	if contentLengthHeader == "" {
		t.Fatalf("missing Content-Length header in response")
	}

	compressedContentLength, err := strconv.Atoi(contentLengthHeader)
	assert.NilError(t, err)

	reader, err := zlib.NewReader(resp.Body)
	assert.NilError(t, err)

	body, err := io.ReadAll(reader)
	assert.NilError(t, err)

	result := must.Unmarshal[noBodyResponse](t, bytes.NewBuffer(body))
	assert.Equal(t, result.Deflated, true, "expected result.Deflated == true")

	if len(body) <= compressedContentLength {
		t.Fatalf("expected compressed body")
	}
}

func TestStream(t *testing.T) {
	t.Parallel()

	okTests := []struct {
		url           string
		expectedLines int
	}{
		{"/stream/20", 20},
		{"/stream/100", 100},
		{"/stream/1000", 100},
		{"/stream/0", 1},
		{"/stream/-100", 1},
	}
	for _, test := range okTests {
		test := test
		t.Run("ok"+test.url, func(t *testing.T) {
			t.Parallel()

			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)

			// Expect empty content-length due to streaming response
			assert.Header(t, resp, "Content-Length", "")
			assert.DeepEqual(t, resp.TransferEncoding, []string{"chunked"}, "expected Transfer-Encoding: chunked")

			i := 0
			scanner := bufio.NewScanner(resp.Body)
			for scanner.Scan() {
				sr := must.Unmarshal[streamResponse](t, bytes.NewReader(scanner.Bytes()))
				assert.Equal(t, sr.ID, i, "bad id")
				i++
			}
			assert.NilError(t, scanner.Err())
		})
	}

	badTests := []struct {
		url  string
		code int
	}{
		{"/stream", http.StatusNotFound},
		{"/stream/foo", http.StatusBadRequest},
		{"/stream/3.1415", http.StatusBadRequest},
		{"/stream/10/foo", http.StatusNotFound},
	}

	for _, test := range badTests {
		test := test
		t.Run("bad"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.code)
		})
	}
}

func TestDelay(t *testing.T) {
	t.Parallel()

	okTests := []struct {
		url           string
		expectedDelay time.Duration
	}{
		// go-style durations are supported
		{"/delay/0ms", 0},
		{"/delay/500ms", 500 * time.Millisecond},

		// as are floating point seconds
		{"/delay/0", 0},
		{"/delay/0.5", 500 * time.Millisecond},
		{"/delay/1", maxDuration},
	}
	for _, test := range okTests {
		test := test
		t.Run("ok"+test.url, func(t *testing.T) {
			t.Parallel()

			start := time.Now()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			elapsed := time.Since(start)

			defer consumeAndCloseBody(resp)
			_ = mustParseResponse[bodyResponse](t, resp)

			if elapsed < test.expectedDelay {
				t.Fatalf("expected delay of %s, got %s", test.expectedDelay, elapsed)
			}
		})
	}

	t.Run("handle cancelation", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
		defer cancel()

		req := newTestRequest(t, "GET", "/delay/1").WithContext(ctx)
		_, err := client.Do(req)
		if !os.IsTimeout(err) {
			t.Errorf("expected timeout error, got %v", err)
		}
	})

	t.Run("cancelation causes 499", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
		defer cancel()

		// use httptest.NewRecorder rather than a live httptest.NewServer
		// because only the former will let us inspect the status code.
		w := httptest.NewRecorder()
		req, _ := http.NewRequestWithContext(ctx, "GET", "/delay/1s", nil)
		app.ServeHTTP(w, req)
		assert.Equal(t, w.Code, 499, "incorrect status code")
	})

	badTests := []struct {
		url  string
		code int
	}{
		{"/delay", http.StatusNotFound},
		{"/delay/foo", http.StatusBadRequest},
		{"/delay/1/foo", http.StatusNotFound},

		{"/delay/1.5s", http.StatusBadRequest},
		{"/delay/-1ms", http.StatusBadRequest},
		{"/delay/1.5", http.StatusBadRequest},
		{"/delay/-1", http.StatusBadRequest},
		{"/delay/-3.14", http.StatusBadRequest},
	}

	for _, test := range badTests {
		test := test
		t.Run("bad"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.code)
		})
	}
}

func TestDrip(t *testing.T) {
	t.Parallel()

	okTests := []struct {
		params   *url.Values
		duration time.Duration
		numbytes int
		code     int
	}{
		// there are useful defaults for all values
		{&url.Values{}, 0, 10, http.StatusOK},

		// go-style durations are accepted
		{&url.Values{"duration": {"5ms"}}, 5 * time.Millisecond, 10, http.StatusOK},
		{&url.Values{"duration": {"0h"}}, 0, 10, http.StatusOK},
		{&url.Values{"delay": {"5ms"}}, 5 * time.Millisecond, 10, http.StatusOK},
		{&url.Values{"delay": {"0h"}}, 0, 10, http.StatusOK},

		// or floating point seconds
		{&url.Values{"duration": {"0.25"}}, 250 * time.Millisecond, 10, http.StatusOK},
		{&url.Values{"duration": {"0"}}, 0, 10, http.StatusOK},
		{&url.Values{"duration": {"1"}}, 1 * time.Second, 10, http.StatusOK},
		{&url.Values{"delay": {"0.25"}}, 250 * time.Millisecond, 10, http.StatusOK},
		{&url.Values{"delay": {"0"}}, 0, 10, http.StatusOK},

		{&url.Values{"numbytes": {"1"}}, 0, 1, http.StatusOK},
		{&url.Values{"numbytes": {"101"}}, 0, 101, http.StatusOK},
		{&url.Values{"numbytes": {fmt.Sprintf("%d", maxBodySize)}}, 0, int(maxBodySize), http.StatusOK},

		{&url.Values{"code": {"404"}}, 0, 10, http.StatusNotFound},
		{&url.Values{"code": {"599"}}, 0, 10, 599},
		{&url.Values{"code": {"567"}}, 0, 10, 567},

		{&url.Values{"duration": {"250ms"}, "delay": {"250ms"}}, 500 * time.Millisecond, 10, http.StatusOK},
		{&url.Values{"duration": {"250ms"}, "delay": {"0.25s"}}, 500 * time.Millisecond, 10, http.StatusOK},
	}
	for _, test := range okTests {
		test := test
		t.Run(fmt.Sprintf("ok/%s", test.params.Encode()), func(t *testing.T) {
			t.Parallel()

			url := "/drip?" + test.params.Encode()

			start := time.Now()
			req := newTestRequest(t, "GET", url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.BodySize(t, resp, test.numbytes) // must read body before measuring elapsed time
			elapsed := time.Since(start)

			assert.StatusCode(t, resp, test.code)
			assert.ContentType(t, resp, binaryContentType)
			assert.Header(t, resp, "Content-Length", strconv.Itoa(test.numbytes))
			if elapsed < test.duration {
				t.Fatalf("expected minimum duration of %s, request took %s", test.duration, elapsed)
			}

			// Note: while the /drip endpoint seems like an ideal use case for
			// using chunked transfer encoding to stream data to the client, it
			// is actually intended to simulate a slow connection between
			// server and client, so it is important to ensure that it writes a
			// "regular," un-chunked response.
			assert.DeepEqual(t, resp.TransferEncoding, nil, "unexpected Transfer-Encoding header")
		})
	}

	t.Run("HTTP 100 Continue status code supported", func(t *testing.T) {
		// The stdlib http client automagically handles 100 Continue responses
		// by continuing the request until a "final" 200 OK response is
		// received, which prevents us from confirming that a 100 Continue
		// response is sent when using the http client directly.
		//
		// So, here we instead manally write the request to the wire and read
		// the initial response, which will give us access to the 100 Continue
		// indication we need.
		t.Parallel()

		req := newTestRequest(t, "GET", "/drip?code=100")
		reqBytes, err := httputil.DumpRequestOut(req, false)
		assert.NilError(t, err)

		conn, err := net.Dial("tcp", srv.Listener.Addr().String())
		assert.NilError(t, err)
		defer conn.Close()

		n, err := conn.Write(reqBytes)
		assert.NilError(t, err)
		assert.Equal(t, n, len(reqBytes), "incorrect number of bytes written")

		resp, err := http.ReadResponse(bufio.NewReader(conn), req)
		assert.NilError(t, err)
		assert.StatusCode(t, resp, 100)
	})

	t.Run("writes are actually incremmental", func(t *testing.T) {
		t.Parallel()

		var (
			duration = 100 * time.Millisecond
			numBytes = 3
			endpoint = fmt.Sprintf("/drip?duration=%s&numbytes=%d", duration, numBytes)

			// Match server logic for calculating the delay between writes
			wantPauseBetweenWrites = duration / time.Duration(numBytes-1)
		)
		req := newTestRequest(t, "GET", endpoint)
		resp := must.DoReq(t, client, req)
		defer consumeAndCloseBody(resp)

		// Here we read from the response one byte at a time, and ensure that
		// at least the expected delay occurs for each read.
		//
		// The request above includes an initial delay equal to the expected
		// wait between writes so that even the first iteration of this loop
		// expects to wait the same amount of time for a read.
		buf := make([]byte, 1024)
		gotBody := make([]byte, 0, numBytes)
		for i := 0; ; i++ {
			start := time.Now()
			n, err := resp.Body.Read(buf)
			gotPause := time.Since(start)

			// We expect to read exactly one byte on each iteration. On the
			// last iteration, we expct to hit EOF after reading the final
			// byte, because the server does not pause after the last write.
			assert.Equal(t, n, 1, "incorrect number of bytes read")
			assert.DeepEqual(t, buf[:n], []byte{'*'}, "unexpected bytes read")
			gotBody = append(gotBody, buf[:n]...)

			if err == io.EOF {
				break
			}

			assert.NilError(t, err)

			// only ensure that we pause for the expected time between writes
			// (allowing for minor mismatch in local timers and server timers)
			// after the first byte.
			if i > 0 {
				assert.RoughDuration(t, gotPause, wantPauseBetweenWrites, 3*time.Millisecond)
			}
		}

		wantBody := bytes.Repeat([]byte{'*'}, numBytes)
		assert.DeepEqual(t, gotBody, wantBody, "incorrect body")
	})

	t.Run("handle cancelation during initial delay", func(t *testing.T) {
		t.Parallel()

		// For this test, we expect the client to time out and cancel the
		// request after 10ms.  The handler should still be in its intitial
		// delay period, so this will result in a request error since no status
		// code will be written before the cancelation.
		ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
		defer cancel()

		req := newTestRequest(t, "GET", "/drip?duration=500ms&delay=500ms").WithContext(ctx)
		if _, err := client.Do(req); !os.IsTimeout(err) {
			t.Fatalf("expected timeout error, got %s", err)
		}
	})

	t.Run("handle cancelation during drip", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
		defer cancel()

		req := newTestRequest(t, "GET", "/drip?duration=900ms&delay=100ms").WithContext(ctx)
		resp := must.DoReq(t, client, req)
		defer consumeAndCloseBody(resp)

		// In this test, the server should have started an OK response before
		// our client timeout cancels the request, so we should get an OK here.
		assert.StatusCode(t, resp, http.StatusOK)

		// But, we should time out while trying to read the whole response
		// body.
		body, err := io.ReadAll(resp.Body)
		if !os.IsTimeout(err) {
			t.Fatalf("expected timeout reading body, got %s", err)
		}

		// And even though the request timed out, we should get a partial
		// response.
		assert.DeepEqual(t, body, []byte("**"), "incorrect partial body")
	})

	badTests := []struct {
		params *url.Values
		code   int
	}{
		{&url.Values{"duration": {"1m"}}, http.StatusBadRequest},
		{&url.Values{"duration": {"-1ms"}}, http.StatusBadRequest},
		{&url.Values{"duration": {"1001"}}, http.StatusBadRequest},
		{&url.Values{"duration": {"-1"}}, http.StatusBadRequest},
		{&url.Values{"duration": {"foo"}}, http.StatusBadRequest},

		{&url.Values{"delay": {"1m"}}, http.StatusBadRequest},
		{&url.Values{"delay": {"-1ms"}}, http.StatusBadRequest},
		{&url.Values{"delay": {"1001"}}, http.StatusBadRequest},
		{&url.Values{"delay": {"-1"}}, http.StatusBadRequest},
		{&url.Values{"delay": {"foo"}}, http.StatusBadRequest},

		{&url.Values{"numbytes": {"foo"}}, http.StatusBadRequest},
		{&url.Values{"numbytes": {"0"}}, http.StatusBadRequest},
		{&url.Values{"numbytes": {"-1"}}, http.StatusBadRequest},
		{&url.Values{"numbytes": {"0xff"}}, http.StatusBadRequest},
		{&url.Values{"numbytes": {fmt.Sprintf("%d", maxBodySize+1)}}, http.StatusBadRequest},

		{&url.Values{"code": {"foo"}}, http.StatusBadRequest},
		{&url.Values{"code": {"-1"}}, http.StatusBadRequest},
		{&url.Values{"code": {"25"}}, http.StatusBadRequest},
		{&url.Values{"code": {"600"}}, http.StatusBadRequest},

		// request would take too long
		{&url.Values{"duration": {"750ms"}, "delay": {"500ms"}}, http.StatusBadRequest},
	}
	for _, test := range badTests {
		test := test
		t.Run(fmt.Sprintf("bad/%s", test.params.Encode()), func(t *testing.T) {
			t.Parallel()
			url := "/drip?" + test.params.Encode()
			req := newTestRequest(t, "GET", url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.code)
		})
	}

	t.Run("ensure HEAD request works with streaming responses", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "HEAD", "/drip?duration=900ms&delay=100ms")
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusOK)
		assert.BodySize(t, resp, 0)
	})
}

func TestRange(t *testing.T) {
	t.Run("ok_no_range", func(t *testing.T) {
		t.Parallel()

		wantBytes := maxBodySize - 1
		url := fmt.Sprintf("/range/%d", wantBytes)
		req := newTestRequest(t, "GET", url)

		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusOK)
		assert.Header(t, resp, "ETag", fmt.Sprintf("range%d", wantBytes))
		assert.Header(t, resp, "Accept-Ranges", "bytes")
		assert.Header(t, resp, "Content-Length", strconv.Itoa(int(wantBytes)))
		assert.ContentType(t, resp, textContentType)
		assert.BodySize(t, resp, int(wantBytes))
	})

	t.Run("ok_range", func(t *testing.T) {
		t.Parallel()

		url := "/range/100"
		req := newTestRequest(t, "GET", url)
		req.Header.Add("Range", "bytes=10-24")

		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusPartialContent)
		assert.Header(t, resp, "ETag", "range100")
		assert.Header(t, resp, "Accept-Ranges", "bytes")
		assert.Header(t, resp, "Content-Length", "15")
		assert.Header(t, resp, "Content-Range", "bytes 10-24/100")
		assert.BodyEquals(t, resp, "klmnopqrstuvwxy")
	})

	t.Run("ok_range_first_16_bytes", func(t *testing.T) {
		t.Parallel()

		url := "/range/1000"
		req := newTestRequest(t, "GET", url)
		req.Header.Add("Range", "bytes=0-15")

		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusPartialContent)
		assert.Header(t, resp, "ETag", "range1000")
		assert.Header(t, resp, "Accept-Ranges", "bytes")
		assert.Header(t, resp, "Content-Length", "16")
		assert.Header(t, resp, "Content-Range", "bytes 0-15/1000")
		assert.BodyEquals(t, resp, "abcdefghijklmnop")
	})

	t.Run("ok_range_open_ended_last_6_bytes", func(t *testing.T) {
		t.Parallel()

		url := "/range/26"
		req := newTestRequest(t, "GET", url)
		req.Header.Add("Range", "bytes=20-")

		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusPartialContent)
		assert.Header(t, resp, "ETag", "range26")
		assert.Header(t, resp, "Content-Length", "6")
		assert.Header(t, resp, "Content-Range", "bytes 20-25/26")
		assert.BodyEquals(t, resp, "uvwxyz")
	})

	t.Run("ok_range_suffix", func(t *testing.T) {
		t.Parallel()

		url := "/range/26"
		req := newTestRequest(t, "GET", url)
		req.Header.Add("Range", "bytes=-5")

		resp := must.DoReq(t, client, req)
		t.Logf("headers = %v", resp.Header)
		assert.StatusCode(t, resp, http.StatusPartialContent)
		assert.Header(t, resp, "ETag", "range26")
		assert.Header(t, resp, "Content-Length", "5")
		assert.Header(t, resp, "Content-Range", "bytes 21-25/26")
		assert.BodyEquals(t, resp, "vwxyz")
	})

	t.Run("err_range_out_of_bounds", func(t *testing.T) {
		t.Parallel()

		url := "/range/26"
		req := newTestRequest(t, "GET", url)
		req.Header.Add("Range", "bytes=-5")

		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusPartialContent)
		assert.Header(t, resp, "ETag", "range26")
		assert.Header(t, resp, "Content-Length", "5")
		assert.Header(t, resp, "Content-Range", "bytes 21-25/26")
		assert.BodyEquals(t, resp, "vwxyz")
	})

	// Note: httpbin rejects these requests with invalid range headers, but the
	// go stdlib just ignores them.
	badRangeTests := []struct {
		url         string
		rangeHeader string
	}{
		{"/range/26", "bytes=10-5"},
		{"/range/26", "bytes=32-40"},
		{"/range/26", "bytes=0-40"},
	}
	for _, test := range badRangeTests {
		test := test
		t.Run(fmt.Sprintf("ok_bad_range_header/%s", test.rangeHeader), func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, http.StatusOK)
			assert.BodyEquals(t, resp, "abcdefghijklmnopqrstuvwxyz")
		})
	}

	badTests := []struct {
		url  string
		code int
	}{
		{"/range/1/foo", http.StatusNotFound},

		{"/range/", http.StatusBadRequest},
		{"/range/foo", http.StatusBadRequest},
		{"/range/1.5", http.StatusBadRequest},
		{"/range/-1", http.StatusBadRequest},
	}

	for _, test := range badTests {
		test := test
		t.Run("bad"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.code)
		})
	}
}

func TestHTML(t *testing.T) {
	t.Parallel()
	req := newTestRequest(t, "GET", "/html")
	resp := must.DoReq(t, client, req)
	assert.ContentType(t, resp, htmlContentType)
	assert.BodyContains(t, resp, `<h1>Herman Melville - Moby-Dick</h1>`)
}

func TestRobots(t *testing.T) {
	t.Parallel()
	req := newTestRequest(t, "GET", "/robots.txt")
	resp := must.DoReq(t, client, req)
	assert.ContentType(t, resp, textContentType)
	assert.BodyContains(t, resp, `Disallow: /deny`)
}

func TestDeny(t *testing.T) {
	t.Parallel()
	req := newTestRequest(t, "GET", "/deny")
	resp := must.DoReq(t, client, req)
	assert.ContentType(t, resp, textContentType)
	assert.BodyContains(t, resp, `YOU SHOULDN'T BE HERE`)
}

func TestCache(t *testing.T) {
	t.Run("ok_no_cache", func(t *testing.T) {
		t.Parallel()

		url := "/cache"
		req := newTestRequest(t, "GET", url)
		resp := must.DoReq(t, client, req)

		_ = mustParseResponse[noBodyResponse](t, resp)
		lastModified := resp.Header.Get("Last-Modified")
		if lastModified == "" {
			t.Fatalf("expected Last-Modified header")
		}
		assert.Header(t, resp, "ETag", sha1hash(lastModified))
	})

	tests := []struct {
		headerKey string
		headerVal string
	}{
		{"If-None-Match", "my-custom-etag"},
		{"If-Modified-Since", "my-custom-date"},
	}
	for _, test := range tests {
		test := test
		t.Run(fmt.Sprintf("ok_cache/%s", test.headerKey), func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", "/cache")
			req.Header.Add(test.headerKey, test.headerVal)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, http.StatusNotModified)
		})
	}
}

func TestCacheControl(t *testing.T) {
	t.Run("ok_cache_control", func(t *testing.T) {
		t.Parallel()

		url := "/cache/60"
		req := newTestRequest(t, "GET", url)
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusOK)
		assert.ContentType(t, resp, jsonContentType)
		assert.Header(t, resp, "Cache-Control", "public, max-age=60")
	})

	badTests := []struct {
		url            string
		expectedStatus int
	}{
		{"/cache/60/foo", http.StatusNotFound},
		{"/cache/foo", http.StatusBadRequest},
		{"/cache/3.14", http.StatusBadRequest},
	}
	for _, test := range badTests {
		test := test
		t.Run("bad"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
		})
	}
}

func TestETag(t *testing.T) {
	t.Run("ok_no_headers", func(t *testing.T) {
		t.Parallel()

		url := "/etag/abc"
		req := newTestRequest(t, "GET", url)
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusOK)
		assert.Header(t, resp, "ETag", `"abc"`)
	})

	tests := []struct {
		name           string
		etag           string
		headerKey      string
		headerVal      string
		expectedStatus int
	}{
		{"if_none_match_matches", "abc", "If-None-Match", `"abc"`, http.StatusNotModified},
		{"if_none_match_matches_list", "abc", "If-None-Match", `"123", "abc"`, http.StatusNotModified},
		{"if_none_match_matches_star", "abc", "If-None-Match", "*", http.StatusNotModified},
		{"if_none_match_matches_w_prefix", "c3piozzzz", "If-None-Match", `W/"xyzzy", W/"r2d2xxxx", W/"c3piozzzz"`, http.StatusNotModified},
		{"if_none_match_has_no_match", "abc", "If-None-Match", `"123"`, http.StatusOK},

		{"if_match_matches", "abc", "If-Match", `"abc"`, http.StatusOK},
		{"if_match_matches_list", "abc", "If-Match", `"123", "abc"`, http.StatusOK},
		{"if_match_matches_star", "abc", "If-Match", "*", http.StatusOK},
		{"if_match_has_no_match", "abc", "If-Match", `"xxxxxx"`, http.StatusPreconditionFailed},
	}
	for _, test := range tests {
		test := test
		t.Run("ok_"+test.name, func(t *testing.T) {
			t.Parallel()
			url := "/etag/" + test.etag
			req := newTestRequest(t, "GET", url)
			req.Header.Add(test.headerKey, test.headerVal)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
		})
	}

	badTests := []struct {
		url            string
		expectedStatus int
	}{
		{"/etag/foo/bar", http.StatusNotFound},
	}
	for _, test := range badTests {
		test := test
		t.Run(fmt.Sprintf("bad/%s", test.url), func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
		})
	}
}

func TestBytes(t *testing.T) {
	t.Run("ok_no_seed", func(t *testing.T) {
		t.Parallel()

		url := "/bytes/1024"
		req := newTestRequest(t, "GET", url)
		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusOK)
		assert.ContentType(t, resp, binaryContentType)
		assert.BodySize(t, resp, 1024)
	})

	t.Run("ok_seed", func(t *testing.T) {
		t.Parallel()

		url := "/bytes/16?seed=1234567890"
		req := newTestRequest(t, "GET", url)

		resp := must.DoReq(t, client, req)
		assert.StatusCode(t, resp, http.StatusOK)
		assert.ContentType(t, resp, binaryContentType)

		want := "\xbf\xcd*\xfa\x15\xa2\xb3r\xc7\a\x98Z\"\x02J\x8e"
		assert.BodyEquals(t, resp, want)
	})

	edgeCaseTests := []struct {
		url                   string
		expectedContentLength int
	}{
		{"/bytes/0", 0},
		{"/bytes/1", 1},
		{"/bytes/99999999", 100 * 1024},

		// negative seed allowed
		{"/bytes/16?seed=-12345", 16},
	}
	for _, test := range edgeCaseTests {
		test := test
		t.Run("edge"+test.url, func(t *testing.T) {
			t.Parallel()

			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)

			assert.StatusCode(t, resp, http.StatusOK)
			assert.Header(t, resp, "Content-Length", strconv.Itoa(test.expectedContentLength))
			assert.BodySize(t, resp, test.expectedContentLength)
		})
	}

	badTests := []struct {
		url            string
		expectedStatus int
	}{
		{"/bytes/-1", http.StatusBadRequest},

		{"/bytes", http.StatusNotFound},
		{"/bytes/16/foo", http.StatusNotFound},

		{"/bytes/foo", http.StatusBadRequest},
		{"/bytes/3.14", http.StatusBadRequest},

		{"/bytes/16?seed=12345678901234567890", http.StatusBadRequest}, // seed too big
		{"/bytes/16?seed=foo", http.StatusBadRequest},
		{"/bytes/16?seed=3.14", http.StatusBadRequest},
	}
	for _, test := range badTests {
		test := test
		t.Run("bad"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
		})
	}
}

func TestStreamBytes(t *testing.T) {
	okTests := []struct {
		url                   string
		expectedContentLength int
	}{
		{"/stream-bytes/256", 256},
		{"/stream-bytes/256?chunk_size=1", 256},
		{"/stream-bytes/256?chunk_size=256", 256},
		{"/stream-bytes/256?chunk_size=7", 256},

		// too-large chunk size is okay
		{"/stream-bytes/256?chunk_size=512", 256},

		// as is negative chunk size
		{"/stream-bytes/256?chunk_size=-10", 256},
	}
	for _, test := range okTests {
		test := test
		t.Run("ok"+test.url, func(t *testing.T) {
			t.Parallel()

			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)

			// Expect empty content-length due to streaming response
			assert.Header(t, resp, "Content-Length", "")
			assert.DeepEqual(t, resp.TransferEncoding, []string{"chunked"}, "incorrect Transfer-Encoding header")
			assert.BodySize(t, resp, test.expectedContentLength)
		})
	}

	badTests := []struct {
		url  string
		code int
	}{
		{"/stream-bytes", http.StatusNotFound},
		{"/stream-bytes/10/foo", http.StatusNotFound},

		{"/stream-bytes/foo", http.StatusBadRequest},
		{"/stream-bytes/3.1415", http.StatusBadRequest},

		{"/stream-bytes/16?chunk_size=foo", http.StatusBadRequest},
		{"/stream-bytes/16?chunk_size=3.14", http.StatusBadRequest},
	}
	for _, test := range badTests {
		test := test
		t.Run("bad"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.code)
		})
	}
}

func TestLinks(t *testing.T) {
	redirectTests := []struct {
		url              string
		expectedLocation string
	}{
		{"/links/1", "/links/1/0"},
		{"/links/100", "/links/100/0"},
	}

	for _, test := range redirectTests {
		test := test
		t.Run("ok"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, http.StatusFound)
			assert.Header(t, resp, "Location", test.expectedLocation)
		})
	}

	errorTests := []struct {
		url            string
		expectedStatus int
	}{
		{"/links/10/1/foo", http.StatusNotFound},

		// invalid N
		{"/links/3.14", http.StatusBadRequest},
		{"/links/-1", http.StatusBadRequest},
		{"/links/257", http.StatusBadRequest},

		// invalid offset
		{"/links/1/3.14", http.StatusBadRequest},
		{"/links/1/foo", http.StatusBadRequest},
	}

	for _, test := range errorTests {
		test := test
		t.Run("error"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
		})
	}

	linksPageTests := []struct {
		url             string
		expectedContent string
	}{
		{"/links/2/0", `<html><head><title>Links</title></head><body>0 <a href="/links/2/1">1</a> </body></html>`},
		{"/links/2/1", `<html><head><title>Links</title></head><body><a href="/links/2/0">0</a> 1 </body></html>`},

		// offsets too large and too small are ignored
		{"/links/2/2", `<html><head><title>Links</title></head><body><a href="/links/2/0">0</a> <a href="/links/2/1">1</a> </body></html>`},
		{"/links/2/10", `<html><head><title>Links</title></head><body><a href="/links/2/0">0</a> <a href="/links/2/1">1</a> </body></html>`},
		{"/links/2/-1", `<html><head><title>Links</title></head><body><a href="/links/2/0">0</a> <a href="/links/2/1">1</a> </body></html>`},
	}
	for _, test := range linksPageTests {
		test := test
		t.Run("ok"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, http.StatusOK)
			assert.ContentType(t, resp, htmlContentType)
			assert.BodyEquals(t, resp, test.expectedContent)
		})
	}
}

func TestImage(t *testing.T) {
	acceptTests := []struct {
		acceptHeader        string
		expectedContentType string
		expectedStatus      int
	}{
		{"", "image/png", http.StatusOK},
		{"image/*", "image/png", http.StatusOK},
		{"image/png", "image/png", http.StatusOK},
		{"image/jpeg", "image/jpeg", http.StatusOK},
		{"image/webp", "image/webp", http.StatusOK},
		{"image/svg+xml", "image/svg+xml", http.StatusOK},

		{"image/raw", "", http.StatusUnsupportedMediaType},
		{"image/jpg", "", http.StatusUnsupportedMediaType},
		{"image/svg", "", http.StatusUnsupportedMediaType},
	}

	for _, test := range acceptTests {
		test := test
		t.Run("ok/accept="+test.acceptHeader, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", "/image")
			req.Header.Set("Accept", test.acceptHeader)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
			if test.expectedContentType != "" {
				assert.ContentType(t, resp, test.expectedContentType)
			}
		})
	}

	imageTests := []struct {
		url            string
		expectedStatus int
	}{
		{"/image/png", http.StatusOK},
		{"/image/jpeg", http.StatusOK},
		{"/image/webp", http.StatusOK},
		{"/image/svg", http.StatusOK},

		{"/image/raw", http.StatusNotFound},
		{"/image/jpg", http.StatusNotFound},
		{"/image/png/foo", http.StatusNotFound},
	}

	for _, test := range imageTests {
		test := test
		t.Run("error"+test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, test.expectedStatus)
		})
	}
}

func TestXML(t *testing.T) {
	t.Parallel()
	req := newTestRequest(t, "GET", "/xml")
	resp := must.DoReq(t, client, req)
	assert.ContentType(t, resp, "application/xml")
	assert.BodyContains(t, resp, `<?xml version='1.0' encoding='us-ascii'?>`)
}

func testValidUUIDv4(t *testing.T, uuid string) {
	t.Helper()
	assert.Equal(t, len(uuid), 36, "incorrect uuid length")
	req := regexp.MustCompile("^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[8|9|a|b][a-f0-9]{3}-[a-f0-9]{12}$")
	if !req.MatchString(uuid) {
		t.Fatalf("invalid uuid %q", uuid)
	}
}

func TestUUID(t *testing.T) {
	t.Parallel()
	req := newTestRequest(t, "GET", "/uuid")
	resp := must.DoReq(t, client, req)
	result := mustParseResponse[uuidResponse](t, resp)
	testValidUUIDv4(t, result.UUID)
}

func TestBase64(t *testing.T) {
	okTests := []struct {
		requestURL string
		want       string
	}{
		{
			"/base64/dmFsaWRfYmFzZTY0X2VuY29kZWRfc3RyaW5n",
			"valid_base64_encoded_string",
		},
		{
			"/base64/decode/dmFsaWRfYmFzZTY0X2VuY29kZWRfc3RyaW5n",
			"valid_base64_encoded_string",
		},
		{
			"/base64/encode/valid_base64_encoded_string",
			"dmFsaWRfYmFzZTY0X2VuY29kZWRfc3RyaW5n",
		},
		{
			// make sure we correctly handle padding
			// https://github.com/mccutchen/go-httpbin/issues/118
			"/base64/dGVzdC1pbWFnZQ==",
			"test-image",
		},
		{
			// URL-safe base64 is used for decoding (note the - instead of + in
			// encoded input string)
			"/base64/decode/YWJjMTIzIT8kKiYoKSctPUB-",
			"abc123!?$*&()'-=@~",
		},
		{
			// URL-safe base64 is used for encoding (note the - instead of + in
			// encoded output string)
			"/base64/encode/abc123%21%3F%24%2A%26%28%29%27-%3D%40~",
			"YWJjMTIzIT8kKiYoKSctPUB-",
		},
	}

	for _, test := range okTests {
		test := test
		t.Run("ok"+test.requestURL, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.requestURL)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, http.StatusOK)
			assert.ContentType(t, resp, textContentType)
			assert.BodyEquals(t, resp, test.want)
		})
	}

	errorTests := []struct {
		requestURL           string
		expectedBodyContains string
	}{
		{
			"/base64/invalid_base64_encoded_string",
			"decode failed",
		},
		{
			"/base64/decode/invalid_base64_encoded_string",
			"decode failed",
		},
		{
			"/base64/decode/invalid_base64_encoded_string",
			"decode failed",
		},
		{
			"/base64/decode/" + strings.Repeat("X", Base64MaxLen+1),
			"Cannot handle input",
		},
		{
			"/base64/",
			"no input data",
		},
		{
			"/base64/decode/",
			"no input data",
		},
		{
			"/base64/decode/dmFsaWRfYmFzZTY0X2VuY29kZWRfc3RyaW5n/extra",
			"invalid URL",
		},
		{
			"/base64/unknown/dmFsaWRfYmFzZTY0X2VuY29kZWRfc3RyaW5n",
			"invalid operation: unknown",
		},
		{
			// we only support URL-safe base64 encoded strings (note the +
			// instead of - in encoded input string)
			"/base64/decode/YWJjMTIzIT8kKiYoKSctPUB+",
			"illegal base64 data",
		},
	}

	for _, test := range errorTests {
		test := test
		t.Run("error"+test.requestURL, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.requestURL)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, http.StatusBadRequest)
			assert.BodyContains(t, resp, test.expectedBodyContains)
		})
	}
}

func TestDumpRequest(t *testing.T) {
	t.Parallel()

	req := newTestRequest(t, "GET", "/dump/request?foo=bar")
	req.Host = "test-host"
	req.Header.Set("x-test-header2", "Test-Value2")
	req.Header.Set("x-test-header1", "Test-Value1")

	resp := must.DoReq(t, client, req)
	assert.ContentType(t, resp, textContentType)
	assert.BodyEquals(t, resp, "GET /dump/request?foo=bar HTTP/1.1\r\nHost: test-host\r\nAccept-Encoding: gzip\r\nUser-Agent: Go-http-client/1.1\r\nX-Test-Header1: Test-Value1\r\nX-Test-Header2: Test-Value2\r\n\r\n")
}

func TestJSON(t *testing.T) {
	t.Parallel()
	req := newTestRequest(t, "GET", "/json")
	resp := must.DoReq(t, client, req)
	assert.ContentType(t, resp, jsonContentType)
	assert.BodyContains(t, resp, `Wake up to WonderWidgets!`)
}

func TestBearer(t *testing.T) {
	requestURL := "/bearer"

	t.Run("valid_token", func(t *testing.T) {
		t.Parallel()

		token := "valid_token"
		req := newTestRequest(t, "GET", requestURL)
		req.Header.Set("Authorization", "Bearer "+token)

		resp := must.DoReq(t, client, req)
		result := mustParseResponse[bearerResponse](t, resp)
		want := bearerResponse{
			Authenticated: true,
			Token:         token,
		}
		assert.DeepEqual(t, result, want, "auth response mismatch")
	})

	errorTests := []struct {
		authorizationHeader string
	}{
		{
			"",
		},
		{
			"Bearer",
		},
		{
			"Bearer x y",
		},
		{
			"bearer x",
		},
		{
			"Bearer1 x",
		},
		{
			"xBearer x",
		},
	}
	for _, test := range errorTests {
		test := test
		t.Run("error"+test.authorizationHeader, func(t *testing.T) {
			t.Parallel()

			req := newTestRequest(t, "GET", requestURL)
			if test.authorizationHeader != "" {
				req.Header.Set("Authorization", test.authorizationHeader)
			}
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.Header(t, resp, "WWW-Authenticate", "Bearer")
			assert.StatusCode(t, resp, http.StatusUnauthorized)
		})
	}
}

func TestNotImplemented(t *testing.T) {
	tests := []struct {
		url string
	}{
		{"/brotli"},
	}
	for _, test := range tests {
		test := test
		t.Run(test.url, func(t *testing.T) {
			t.Parallel()
			req := newTestRequest(t, "GET", test.url)
			resp := must.DoReq(t, client, req)
			defer consumeAndCloseBody(resp)
			assert.StatusCode(t, resp, http.StatusNotImplemented)
		})
	}
}

func TestHostname(t *testing.T) {
	t.Run("default hostname", func(t *testing.T) {
		t.Parallel()
		req := newTestRequest(t, "GET", "/hostname")
		resp := must.DoReq(t, client, req)
		result := mustParseResponse[hostnameResponse](t, resp)
		assert.Equal(t, result.Hostname, DefaultHostname, "hostname mismatch")
	})

	t.Run("real hostname", func(t *testing.T) {
		t.Parallel()

		realHostname := "real-hostname"
		app := New(WithHostname(realHostname))
		srv, client := newTestServer(app)
		defer srv.Close()

		req, err := http.NewRequest("GET", srv.URL+"/hostname", nil)
		assert.NilError(t, err)

		resp, err := client.Do(req)
		assert.NilError(t, err)

		result := mustParseResponse[hostnameResponse](t, resp)
		assert.Equal(t, result.Hostname, realHostname, "hostname mismatch")
	})
}

func newTestServer(handler http.Handler) (*httptest.Server, *http.Client) {
	srv := httptest.NewServer(handler)
	client := srv.Client()
	client.Timeout = 5 * time.Second
	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}
	return srv, client
}

func newTestRequest(t *testing.T, verb, path string) *http.Request {
	t.Helper()
	return newTestRequestWithBody(t, verb, path, nil)
}

func newTestRequestWithBody(t *testing.T, verb, path string, body io.Reader) *http.Request {
	t.Helper()
	req, err := http.NewRequest(verb, srv.URL+path, body)
	assert.NilError(t, err)
	return req
}

func mustParseResponse[T any](t *testing.T, resp *http.Response) T {
	t.Helper()
	assert.StatusCode(t, resp, http.StatusOK)
	assert.ContentType(t, resp, jsonContentType)
	return must.Unmarshal[T](t, resp.Body)
}

func consumeAndCloseBody(resp *http.Response) {
	_, _ = io.Copy(io.Discard, resp.Body)
	resp.Body.Close()
}