client: improve mocking responses

Make the mocked responses match the API closer;

- Add headers as returned by the daemon's VersionMiddleware
- By default handle "/_ping" requests to allow the client to
  perform API-version negotiation as part of tests.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn
2025-11-13 01:25:15 +01:00
parent ef588715b6
commit 701f2fdade
4 changed files with 77 additions and 13 deletions

View File

@@ -5,9 +5,11 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"runtime"
"strconv" "strconv"
"strings" "strings"
"github.com/moby/moby/api/types/build"
"github.com/moby/moby/api/types/common" "github.com/moby/moby/api/types/common"
"github.com/moby/moby/api/types/swarm" "github.com/moby/moby/api/types/swarm"
) )
@@ -60,8 +62,70 @@ func ensureBody(f func(req *http.Request) (*http.Response, error)) testRoundTrip
} }
} }
// WithMockClient is a test helper that allows you to inject a mock client for testing. // makeTestRoundTripper makes sure the response has a Body, using [http.NoBody] if
// none is present, and returns it as a testRoundTripper. If withDefaults is set,
// it also mocks the "/_ping" endpoint and sets default headers as returned
// by the daemon.
func makeTestRoundTripper(f func(req *http.Request) (*http.Response, error)) testRoundTripper {
return func(req *http.Request) (*http.Response, error) {
if req.URL.Path == "/_ping" {
return mockPingResponse(http.StatusOK, PingResult{
APIVersion: MaxAPIVersion,
OSType: runtime.GOOS,
Experimental: true,
BuilderVersion: build.BuilderBuildKit,
SwarmStatus: &SwarmStatus{
NodeState: swarm.LocalNodeStateActive,
ControlAvailable: true,
},
})(req)
}
resp, err := f(req)
if resp != nil {
if resp.Body == nil {
resp.Body = http.NoBody
}
if resp.Request == nil {
resp.Request = req
}
}
applyDefaultHeaders(resp)
return resp, err
}
}
// applyDefaultHeaders mocks the headers set by the daemon's VersionMiddleware.
func applyDefaultHeaders(resp *http.Response) {
if resp == nil {
return
}
if resp.Header == nil {
resp.Header = make(http.Header)
}
if resp.Header.Get("Server") == "" {
resp.Header.Set("Server", fmt.Sprintf("Docker/%s (%s)", "v99.99.99", runtime.GOOS))
}
if resp.Header.Get("Api-Version") == "" {
resp.Header.Set("Api-Version", MaxAPIVersion)
}
if resp.Header.Get("Ostype") == "" {
resp.Header.Set("Ostype", runtime.GOOS)
}
}
// WithMockClient is a test helper that allows you to inject a mock client for
// testing. By default, it mocks the "/_ping" endpoint, so allow the client
// to perform API-version negotiation. Other endpoints are handled by "doer".
func WithMockClient(doer func(*http.Request) (*http.Response, error)) Opt { func WithMockClient(doer func(*http.Request) (*http.Response, error)) Opt {
return WithHTTPClient(&http.Client{
Transport: makeTestRoundTripper(doer),
})
}
// WithBaseMockClient is a test helper that allows you to inject a mock client
// for testing. It is identical to [WithMockClient], but does not mock the "/_ping"
// endpoint, and doesn't set the default headers.
func WithBaseMockClient(doer func(*http.Request) (*http.Response, error)) Opt {
return WithHTTPClient(&http.Client{ return WithHTTPClient(&http.Client{
Transport: ensureBody(doer), Transport: ensureBody(doer),
}) })

View File

@@ -258,7 +258,7 @@ func TestNegotiateAPIVersionEmpty(t *testing.T) {
client, err := New(FromEnv, client, err := New(FromEnv,
WithAPIVersionNegotiation(), WithAPIVersionNegotiation(),
WithMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: expected})), WithBaseMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: expected})),
) )
assert.NilError(t, err) assert.NilError(t, err)
@@ -331,7 +331,7 @@ func TestNegotiateAPIVersion(t *testing.T) {
opts := []Opt{ opts := []Opt{
FromEnv, FromEnv,
WithAPIVersionNegotiation(), WithAPIVersionNegotiation(),
WithMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: tc.pingVersion})), WithBaseMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: tc.pingVersion})),
} }
if tc.clientVersion != "" { if tc.clientVersion != "" {
@@ -363,7 +363,7 @@ func TestNegotiateAPIVersionOverride(t *testing.T) {
client, err := New( client, err := New(
FromEnv, FromEnv,
WithMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: "1.45"})), WithBaseMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: "1.45"})),
) )
assert.NilError(t, err) assert.NilError(t, err)
@@ -393,7 +393,7 @@ func TestNegotiateAPIVersionAutomatic(t *testing.T) {
ctx := t.Context() ctx := t.Context()
client, err := New( client, err := New(
WithMockClient(func(req *http.Request) (*http.Response, error) { WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
return mockPingResponse(http.StatusOK, PingResult{APIVersion: pingVersion})(req) return mockPingResponse(http.StatusOK, PingResult{APIVersion: pingVersion})(req)
}), }),
WithAPIVersionNegotiation(), WithAPIVersionNegotiation(),
@@ -422,7 +422,7 @@ func TestNegotiateAPIVersionAutomatic(t *testing.T) {
func TestNegotiateAPIVersionWithEmptyVersion(t *testing.T) { func TestNegotiateAPIVersionWithEmptyVersion(t *testing.T) {
client, err := New( client, err := New(
WithAPIVersion(""), WithAPIVersion(""),
WithMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: "1.50"})), WithBaseMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: "1.50"})),
) )
assert.NilError(t, err) assert.NilError(t, err)
@@ -442,7 +442,7 @@ func TestNegotiateAPIVersionWithFixedVersion(t *testing.T) {
) )
client, err := New( client, err := New(
WithAPIVersion(customVersion), WithAPIVersion(customVersion),
WithMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: pingVersion})), WithBaseMockClient(mockPingResponse(http.StatusOK, PingResult{APIVersion: pingVersion})),
) )
assert.NilError(t, err) assert.NilError(t, err)

View File

@@ -18,7 +18,7 @@ import (
// panics. // panics.
func TestPingFail(t *testing.T) { func TestPingFail(t *testing.T) {
var withHeader bool var withHeader bool
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) { client, err := New(WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
var hdr http.Header var hdr http.Header
if withHeader { if withHeader {
hdr = http.Header{} hdr = http.Header{}
@@ -48,7 +48,7 @@ func TestPingFail(t *testing.T) {
// TestPingWithError tests the case where there is a protocol error in the ping. // TestPingWithError tests the case where there is a protocol error in the ping.
// This test is mostly just testing that there are no panics in this code path. // This test is mostly just testing that there are no panics in this code path.
func TestPingWithError(t *testing.T) { func TestPingWithError(t *testing.T) {
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) { client, err := New(WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("some connection error") return nil, errors.New("some connection error")
})) }))
assert.NilError(t, err) assert.NilError(t, err)
@@ -64,7 +64,7 @@ func TestPingWithError(t *testing.T) {
// TestPingSuccess tests that we are able to get the expected API headers/ping // TestPingSuccess tests that we are able to get the expected API headers/ping
// details on success. // details on success.
func TestPingSuccess(t *testing.T) { func TestPingSuccess(t *testing.T) {
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) { client, err := New(WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
hdr := http.Header{} hdr := http.Header{}
hdr.Set("Api-Version", "awesome") hdr.Set("Api-Version", "awesome")
hdr.Set("Docker-Experimental", "true") hdr.Set("Docker-Experimental", "true")
@@ -110,7 +110,7 @@ func TestPingHeadFallback(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(http.StatusText(tc.status), func(t *testing.T) { t.Run(http.StatusText(tc.status), func(t *testing.T) {
var reqs []string var reqs []string
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) { client, err := New(WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
if !strings.HasPrefix(req.URL.Path, expectedPath) { if !strings.HasPrefix(req.URL.Path, expectedPath) {
return nil, fmt.Errorf("expected URL '%s', got '%s'", expectedPath, req.URL.Path) return nil, fmt.Errorf("expected URL '%s', got '%s'", expectedPath, req.URL.Path)
} }

View File

@@ -194,7 +194,7 @@ func TestResponseErrors(t *testing.T) {
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) { t.Run(tc.doc, func(t *testing.T) {
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) { client, err := New(WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
return mockResponse(http.StatusBadRequest, http.Header{"Content-Type": []string{tc.contentType}}, tc.response)(req) return mockResponse(http.StatusBadRequest, http.Header{"Content-Type": []string{tc.contentType}}, tc.response)(req)
})) }))
if tc.apiVersion != "" { if tc.apiVersion != "" {
@@ -210,7 +210,7 @@ func TestResponseErrors(t *testing.T) {
func TestInfiniteError(t *testing.T) { func TestInfiniteError(t *testing.T) {
infinitR := rand.New(rand.NewSource(42)) infinitR := rand.New(rand.NewSource(42))
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) { client, err := New(WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
resp := &http.Response{ resp := &http.Response{
StatusCode: http.StatusInternalServerError, StatusCode: http.StatusInternalServerError,
Header: http.Header{}, Header: http.Header{},