Files
ghtracker/internal/gh/gh_test.go
T
lukaszraczylo 0c7c782d86 fix(gh): confirm failing workflows on a new connection
GitHub sometimes answers a workflow run listing from a lagging replica. For one workflow the answer held 135 runs and ended three weeks ago, while the current listing held 414 runs. The client reuses one connection, so a stale replica could answer every request of a scan and the dashboard showed a failure that a newer run had already replaced.

A failing workflow is now requested again on a new connection that skips the response cache. The client keeps the union of both answers, so the newest run decides.
2026-10-02 05:24:46 +01:00

556 lines
20 KiB
Go

package gh
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
func testKey(t *testing.T) (*rsa.PrivateKey, string) {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
p := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})
return k, string(p)
}
// fakeGitHub serves canned JSON by path and counts hits per path.
type fakeGitHub struct {
srv *httptest.Server
routes map[string]func(w http.ResponseWriter, r *http.Request)
hits map[string]*atomic.Int64
appKeyPub *rsa.PublicKey
t *testing.T
installs atomic.Int64
tokens atomic.Int64
}
func newFake(t *testing.T) *fakeGitHub {
t.Helper()
f := &fakeGitHub{t: t, routes: map[string]func(http.ResponseWriter, *http.Request){}, hits: map[string]*atomic.Int64{}}
f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeGitHub) serve(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/installation"):
f.installs.Add(1)
f.checkJWT(r)
_, _ = w.Write([]byte(`{"id":42}`))
return
case strings.HasPrefix(r.URL.Path, "/app/installations/"):
f.tokens.Add(1)
f.checkJWT(r)
exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
_, _ = fmt.Fprintf(w, `{"token":"inst-token","expires_at":%q}`, exp)
return
}
if r.Header.Get("Authorization") != "Bearer inst-token" {
http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized)
return
}
h, ok := f.routes[r.URL.Path]
if !ok {
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
return
}
c := f.hits[r.URL.Path]
if c == nil {
c = &atomic.Int64{}
f.hits[r.URL.Path] = c
}
c.Add(1)
h(w, r)
}
func (f *fakeGitHub) checkJWT(r *http.Request) {
if f.appKeyPub == nil {
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
parts := strings.Split(tok, ".")
if len(parts) != 3 {
f.t.Errorf("jwt has %d parts", len(parts))
return
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(f.appKeyPub, crypto.SHA256, sum[:], sig); err != nil {
f.t.Errorf("jwt signature invalid: %v", err)
}
}
func (f *fakeGitHub) json(path, body string) {
f.routes[path] = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }
}
func (f *fakeGitHub) client(t *testing.T, maxItems int) *Client {
t.Helper()
key, pemStr := testKey(t)
f.appKeyPub = &key.PublicKey
auth, err := NewAppAuth(7, pemStr, f.srv.URL, 0, f.srv.Client())
if err != nil {
t.Fatal(err)
}
c := NewClient(f.srv.URL, auth, f.srv.Client(), maxItems)
c.sleep = func(context.Context, time.Duration) error { return nil }
return c
}
func TestJWTClaimsAndKeyFormats(t *testing.T) {
key, pemStr := testKey(t)
a, err := NewAppAuth(99, pemStr, "http://x", 0, http.DefaultClient)
if err != nil {
t.Fatal(err)
}
fixed := time.Unix(1_700_000_000, 0)
a.now = func() time.Time { return fixed }
jwt, err := a.appJWT()
if err != nil {
t.Fatal(err)
}
parts := strings.Split(jwt, ".")
claimsRaw, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(claimsRaw, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "99" || int64(claims["iat"].(float64)) != fixed.Unix()-60 || int64(claims["exp"].(float64)) != fixed.Unix()+540 {
t.Fatalf("claims = %v", claims)
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, sum[:], sig); err != nil {
t.Fatal(err)
}
der, _ := x509.MarshalPKCS8PrivateKey(key)
if _, err := ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})); err != nil {
t.Fatalf("pkcs8: %v", err)
}
if _, err := ParsePrivateKey([]byte("garbage")); err == nil {
t.Fatal("garbage key must fail")
}
}
func TestTokenCachedAndInstallationDiscoveredOnce(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","default_branch":"main"}`)
for range 3 {
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
}
if f.installs.Load() != 1 || f.tokens.Load() != 1 {
t.Fatalf("installs=%d tokens=%d, want 1 each", f.installs.Load(), f.tokens.Load())
}
}
func TestPaginationStopsAtLimit(t *testing.T) {
f := newFake(t)
c := f.client(t, 3)
f.routes["/repos/o/r/issues"] = func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("page") == "2" {
_, _ = w.Write([]byte(`[{"number":3,"title":"c"},{"number":4,"title":"d"}]`))
return
}
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues?page=2>; rel="next", <x>; rel="last"`, f.srv.URL))
_, _ = w.Write([]byte(`[{"number":1,"title":"a"},{"number":2,"title":"b"}]`))
}
got, err := c.openIssues(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("got %d issues, want 3 (limit)", len(got))
}
}
func TestNextLink(t *testing.T) {
h := `<https://api/x?page=2>; rel="next", <https://api/x?page=9>; rel="last"`
if got := nextLink(h); got != "https://api/x?page=2" {
t.Fatal(got)
}
if nextLink(`<https://api/x?page=1>; rel="prev"`) != "" || nextLink("") != "" {
t.Fatal("no next link expected")
}
}
func TestETagConditionalRequestServedFromCache(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
var conditional atomic.Int64
f.routes["/repos/o/r"] = func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == `"v1"` {
conditional.Add(1)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", `"v1"`)
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
}
for range 2 {
var out struct {
DefaultBranch string `json:"default_branch"`
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &out); err != nil || out.DefaultBranch != "main" {
t.Fatalf("out=%+v err=%v", out, err)
}
}
if conditional.Load() != 1 {
t.Fatalf("conditional requests = %d, want 1", conditional.Load())
}
}
func TestRetriesServerErrorsThenSucceeds(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
var n atomic.Int64
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
if n.Add(1) < 3 {
http.Error(w, `{"message":"boom"}`, http.StatusBadGateway)
return
}
_, _ = w.Write([]byte(`{}`))
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
if n.Load() != 3 {
t.Fatalf("attempts = %d", n.Load())
}
}
func TestNoRetryOnClientError(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
err := c.getJSON(context.Background(), "o/r", "/missing", nil, &struct{}{})
if !isNotFound(err) {
t.Fatalf("err = %v", err)
}
}
func TestPrimaryRateLimitBlocksFurtherRequests(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
reset := time.Now().Add(30 * time.Minute)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(reset.Unix()))
http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden)
}
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
if !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
hits := f.hits["/repos/o/r"].Load()
err = c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
if !errors.Is(err, model.ErrRateLimited) || f.hits["/repos/o/r"].Load() != hits {
t.Fatalf("second call must be refused locally; err=%v hits=%d->%d", err, hits, f.hits["/repos/o/r"].Load())
}
}
func TestSecondaryRateLimitHonoursRetryAfter(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
now := time.Now()
c.now = func() time.Time { return now }
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "120")
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusTooManyRequests)
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
c.now = func() time.Time { return now.Add(119 * time.Second) }
if err := c.guard(); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("still blocked at 119s, got %v", err)
}
c.now = func() time.Time { return now.Add(121 * time.Second) }
if err := c.guard(); err != nil {
t.Fatalf("unblocked at 121s, got %v", err)
}
}
func TestLowBudgetStopsBeforeQuotaExhausted(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "50")
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(time.Now().Add(time.Hour).Unix()))
_, _ = w.Write([]byte(`{}`))
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("budget below reserve must refuse, got %v", err)
}
if rem, ok := c.RateLimitRemaining(); !ok || rem != 50 {
t.Fatalf("remaining = %d %v", rem, ok)
}
}
func TestPlain403IsNotTreatedAsRateLimit(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
}
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
var ae *APIError
if errors.Is(err, model.ErrRateLimited) || !errors.As(err, &ae) || ae.Status != 403 {
t.Fatalf("err = %v", err)
}
if err := c.guard(); err != nil {
t.Fatalf("plain 403 must not block: %v", err)
}
}
func registerRepo(f *fakeGitHub) {
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","description":"d","default_branch":"main"}`)
f.json("/repos/o/r/issues", `[
{"number":1,"title":"real issue","html_url":"u1","user":{"login":"a"},"labels":[{"name":"bug"}],"comments":2,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"},
{"number":2,"title":"a PR","html_url":"u2","pull_request":{"url":"x"}}]`)
f.json("/repos/o/r/pulls", `[{"number":2,"title":"a PR","html_url":"u2","user":{"login":"b"},"draft":false,"head":{"sha":"abc"},"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-03T00:00:00Z"}]`)
f.json("/repos/o/r/commits/abc/check-runs", `{"check_runs":[{"name":"build","status":"completed","conclusion":"failure"},{"name":"lint","status":"completed","conclusion":"success"},{"name":"slow","status":"in_progress"}]}`)
f.json("/repos/o/r/commits/abc/status", `{"total_count":1,"statuses":[{"context":"ci/ext","state":"error"}]}`)
f.json("/repos/o/r/actions/workflows", `{"workflows":[
{"id":1,"name":"CI","html_url":"w1","state":"active"},
{"id":2,"name":"Nightly","html_url":"w2","state":"active"},
{"id":3,"name":"Old","html_url":"w3","state":"disabled_manually"},
{"id":4,"name":"Never ran","html_url":"w4","state":"active"}]}`)
f.json("/repos/o/r/actions/workflows/1/runs", `{"workflow_runs":[
{"id":11,"html_url":"r11","event":"push","head_branch":"main","status":"in_progress","created_at":"2026-01-05T00:00:00Z"},
{"id":10,"html_url":"r10","event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-01-04T00:00:00Z"}]}`)
f.json("/repos/o/r/actions/workflows/2/runs", `{"workflow_runs":[
{"id":20,"html_url":"r20","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-01-05T00:00:00Z"}]}`)
f.json("/repos/o/r/actions/workflows/4/runs", `{"workflow_runs":[]}`)
f.json("/repos/o/r/releases/latest", `{"tag_name":"v1.2.3","name":"x","html_url":"rel","published_at":"2026-01-01T00:00:00Z"}`)
}
func TestFetchRepoEndToEnd(t *testing.T) {
f := newFake(t)
c := f.client(t, 50)
registerRepo(f)
r, err := c.FetchRepo(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(r.Warnings) != 0 {
t.Fatalf("warnings: %v", r.Warnings)
}
if len(r.Issues) != 1 || r.Issues[0].Number != 1 || r.Issues[0].Labels[0] != "bug" {
t.Fatalf("issues must exclude PRs: %+v", r.Issues)
}
if len(r.PRs) != 1 || r.PRs[0].Checks.State != model.ChecksFail || r.PRs[0].Checks.Failed != 2 || r.PRs[0].Checks.Pending != 1 {
t.Fatalf("pr checks: %+v", r.PRs)
}
if got := r.PRs[0].Checks.Failing; len(got) != 2 || got[0] != "build" || got[1] != "ci/ext" {
t.Fatalf("failing names: %v", got)
}
if len(r.Workflows) != 2 {
t.Fatalf("want CI and Nightly only, got %+v", r.Workflows)
}
ci, nightly := r.Workflows[0], r.Workflows[1]
if !ci.Latest.Running() || ci.LastDone == nil || ci.LastDone.ID != 10 || ci.Background {
t.Fatalf("ci: %+v", ci)
}
if !nightly.Background || nightly.LastDone == nil || !nightly.LastDone.Failed() {
t.Fatalf("nightly: %+v", nightly)
}
if r.Release == nil || r.Release.Tag != "v1.2.3" || r.Release.URL != "rel" || r.ReleasesURL != "https://github.com/o/r/releases" {
t.Fatalf("release: %+v %s", r.Release, r.ReleasesURL)
}
}
func TestReleaseFallsBackToTag(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/tags", `[{"name":"v0.1.0"}]`)
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
if err != nil {
t.Fatal(err)
}
if !rel.FromTag || rel.Tag != "v0.1.0" || rel.URL != "https://github.com/o/r/releases/tag/v0.1.0" {
t.Fatalf("%+v", rel)
}
}
func TestReleaseNoneAtAll(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/tags", `[]`)
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
if err != nil || rel != nil {
t.Fatalf("rel=%+v err=%v", rel, err)
}
}
func TestSectionFailureDegradesToWarning(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
registerRepo(f)
f.routes["/repos/o/r/actions/workflows"] = func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
}
r, err := c.FetchRepo(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "workflows") || len(r.Issues) != 1 {
t.Fatalf("warnings=%v issues=%d", r.Warnings, len(r.Issues))
}
}
func TestRateLimitInSectionFailsWholeRepo(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
registerRepo(f)
f.routes["/repos/o/r/pulls"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "60")
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusForbidden)
}
if _, err := c.FetchRepo(context.Background(), "o/r"); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
}
func TestBuildWorkflowIgnoresPullRequestRuns(t *testing.T) {
runs := []rawRun{
{ID: 2, Event: "pull_request", Status: "completed", Conclusion: "failure", CreatedAt: time.Unix(200, 0)},
{ID: 1, Event: "push", Status: "completed", Conclusion: "success", CreatedAt: time.Unix(100, 0)},
}
wf, ok := buildWorkflow("CI", "u", runs)
if !ok || wf.Latest.ID != 1 || wf.LastDone.ID != 1 {
t.Fatalf("%+v", wf)
}
if _, ok := buildWorkflow("CI", "u", runs[:1]); ok {
t.Fatal("only PR runs must yield no workflow")
}
}
func TestAPIErrorMessageParsing(t *testing.T) {
if got := apiMessage([]byte(`{"message":"nope"}`)); got != "nope" {
t.Fatal(got)
}
if got := apiMessage([]byte(strings.Repeat("x", 500))); len(got) != 200 {
t.Fatalf("len=%d", len(got))
}
}
// staleThenFresh serves an old failing run on the first request and the current runs afterwards,
// like a lagging GitHub replica followed by a healthy one.
func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
var calls atomic.Int64
f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
if n == 1 {
_, _ = w.Write([]byte(`{"workflow_runs":[
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
return
}
if r.Header.Get("If-None-Match") != "" {
http.Error(w, "fresh requests must not be conditional", http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(`{"workflow_runs":[
{"id":3,"html_url":"new","event":"schedule","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"},
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
}
return &calls
}
func TestStaleFailureIsConfirmedOnANewConnection(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"Autoupdate","html_url":"w","state":"active"}]}`)
calls := staleThenFresh(f, "/repos/o/r/actions/workflows/7/runs")
wfs, err := c.workflows(context.Background(), "o/r", "main")
if err != nil {
t.Fatal(err)
}
if len(wfs) != 1 || wfs[0].Latest.ID != 3 || wfs[0].LastDone == nil || wfs[0].LastDone.Failed() {
t.Fatalf("the newer success must supersede the stale failure: %+v", wfs)
}
if calls.Load() != 2 {
t.Fatalf("runs requested %d times, want 2", calls.Load())
}
}
func TestHealthyWorkflowIsNotRequestedTwice(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
var calls atomic.Int64
f.routes["/repos/o/r/actions/workflows/7/runs"] = func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"}]}`))
}
if _, err := c.workflows(context.Background(), "o/r", "main"); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("a passing workflow needs one request, got %d", calls.Load())
}
}
func TestConfirmedFailureStaysFailing(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
f.json("/repos/o/r/actions/workflows/7/runs", `{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-10-02T03:03:00Z"}]}`)
wfs, err := c.workflows(context.Background(), "o/r", "main")
if err != nil || len(wfs) != 1 || wfs[0].LastDone == nil || !wfs[0].LastDone.Failed() {
t.Fatalf("a failure that both answers agree on must stay: %+v %v", wfs, err)
}
}
func TestMergeRunsKeepsEachRunOnce(t *testing.T) {
got := mergeRuns([]rawRun{{ID: 1}, {ID: 2}}, []rawRun{{ID: 2}, {ID: 3}})
if len(got) != 3 || got[0].ID != 1 || got[1].ID != 2 || got[2].ID != 3 {
t.Fatalf("%+v", got)
}
}
func TestFreshClientDisablesConnectionReuse(t *testing.T) {
base := &http.Client{Transport: &http.Transport{}, Timeout: 5 * time.Second}
fc := freshClient(base)
tr, ok := fc.Transport.(*http.Transport)
if !ok || !tr.DisableKeepAlives || fc.Timeout != 5*time.Second {
t.Fatalf("fresh client: %+v", fc)
}
if base.Transport.(*http.Transport).DisableKeepAlives {
t.Fatal("the original client must keep its connections")
}
custom := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("x") })}
if freshClient(custom) != custom {
t.Fatal("non-standard transports are used as they are")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }