Ctrl K
ComunidadGuías…

src/cli/updatecheck_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"bytes"
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"runtime"
	"sync/atomic"
	"testing"
	"time"

	internalcli "github.com/schollz/croc/v11/internal/cli"
	"github.com/schollz/croc/v11/src/croc"
)

func TestParseReleaseVersion(t *testing.T) {
	for _, test := range []struct {
		value string
		want  string
		ok    bool
	}{
		{value: "11.2.3", want: "11.2.3", ok: true},
		{value: "v11.2.3", want: "11.2.3", ok: true},
		{value: "0.0.0", want: "0.0.0", ok: true},
		{value: "11.2", ok: false},
		{value: "11.02.3", ok: false},
		{value: "v11.2.3-rc.1", ok: false},
		{value: " 11.2.3", ok: false},
		{value: "11.2.3 ", ok: false},
		{value: "18446744073709551616.0.0", ok: false},
	} {
		t.Run(test.value, func(t *testing.T) {
			got, ok := parseReleaseVersion(test.value)
			if ok != test.ok {
				t.Fatalf("parseReleaseVersion(%q) ok = %v, want %v", test.value, ok, test.ok)
			}
			if ok && got.String() != test.want {
				t.Fatalf("parseReleaseVersion(%q) = %q, want %q", test.value, got.String(), test.want)
			}
		})
	}
}

func TestNewerRelease(t *testing.T) {
	for _, test := range []struct {
		name    string
		latest  string
		current string
		want    bool
	}{
		{name: "major", latest: "12.0.0", current: "11.9.9", want: true},
		{name: "minor", latest: "11.3.0", current: "11.2.9", want: true},
		{name: "patch", latest: "v11.2.4", current: "11.2.3", want: true},
		{name: "equal", latest: "11.2.3", current: "11.2.3"},
		{name: "older", latest: "10.9.9", current: "11.0.0"},
		{name: "invalid latest", latest: "latest", current: "11.2.3"},
		{name: "invalid current", latest: "11.2.3", current: "development"},
	} {
		t.Run(test.name, func(t *testing.T) {
			if got := newerRelease(test.latest, test.current); got != test.want {
				t.Fatalf("newerRelease(%q, %q) = %v, want %v", test.latest, test.current, got, test.want)
			}
		})
	}
}

func TestFetchLatestRelease(t *testing.T) {
	for _, test := range []struct {
		name       string
		statusCode int
		body       string
		want       string
		wantErr    bool
	}{
		{name: "valid", statusCode: http.StatusOK, body: `{"tag_name":"v11.2.3"}`, want: "11.2.3"},
		{name: "invalid json", statusCode: http.StatusOK, body: `{`, wantErr: true},
		{name: "invalid tag", statusCode: http.StatusOK, body: `{"tag_name":"v11.2.3-rc.1"}`, wantErr: true},
		{name: "missing tag", statusCode: http.StatusOK, body: `{}`, wantErr: true},
		{name: "http failure", statusCode: http.StatusTooManyRequests, body: `{}`, wantErr: true},
	} {
		t.Run(test.name, func(t *testing.T) {
			server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
				if got := request.Header.Get("Accept"); got != "application/vnd.github+json" {
					t.Errorf("Accept header = %q", got)
				}
				if got := request.Header.Get("User-Agent"); got != "croc/11.2.2" {
					t.Errorf("User-Agent header = %q", got)
				}
				response.WriteHeader(test.statusCode)
				_, _ = io.WriteString(response, test.body)
			}))
			defer server.Close()

			checker := transferVersionChecker{
				currentVersion: "11.2.2",
				endpoint:       server.URL,
				client:         server.Client(),
			}
			got, err := checker.fetchLatest(context.Background())
			if test.wantErr {
				if err == nil {
					t.Fatalf("fetchLatest() = %q, want error", got)
				}
				return
			}
			if err != nil {
				t.Fatal(err)
			}
			if got != test.want {
				t.Fatalf("fetchLatest() = %q, want %q", got, test.want)
			}
		})
	}
}

func TestFetchLatestReleaseHonorsCancellation(t *testing.T) {
	started := make(chan struct{})
	stopped := make(chan struct{})
	client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
		close(started)
		<-request.Context().Done()
		close(stopped)
		return nil, request.Context().Err()
	})}
	checker := transferVersionChecker{
		currentVersion: "11.2.2",
		endpoint:       "https://example.test/latest",
		client:         client,
	}
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan error, 1)
	go func() {
		_, err := checker.fetchLatest(ctx)
		done <- err
	}()
	<-started
	cancel()
	select {
	case err := <-done:
		if err == nil {
			t.Fatal("fetchLatest succeeded after cancellation")
		}
	case <-time.After(time.Second):
		t.Fatal("fetchLatest did not stop after cancellation")
	}
	<-stopped
}

func TestVersionCheckUsesFreshCache(t *testing.T) {
	now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
	cachePath := filepath.Join(t.TempDir(), versionCheckCacheName)
	if err := writeVersionCheckCache(cachePath, versionCheckCache{
		CheckedAt:     now.Add(-time.Hour),
		LatestVersion: "11.2.3",
	}); err != nil {
		t.Fatal(err)
	}

	var requests atomic.Int32
	checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusOK, `{"tag_name":"v99.0.0"}`)
	defer closeServer()
	handle := checker.start(context.Background(), false)
	result, ready := handle.finish()
	if !ready || result.latestVersion != "11.2.3" {
		t.Fatalf("fresh result = (%q, %v), want (11.2.3, true)", result.latestVersion, ready)
	}
	if got := requests.Load(); got != 0 {
		t.Fatalf("fresh cache made %d requests, want 0", got)
	}
}

func TestVersionCheckRefreshesStaleAndCorruptCaches(t *testing.T) {
	now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
	for _, test := range []struct {
		name     string
		contents []byte
	}{
		{name: "missing"},
		{name: "stale", contents: mustMarshalCache(t, versionCheckCache{CheckedAt: now.Add(-25 * time.Hour), LatestVersion: "11.2.2"})},
		{name: "future", contents: mustMarshalCache(t, versionCheckCache{CheckedAt: now.Add(time.Hour), LatestVersion: "11.2.2"})},
		{name: "corrupt", contents: []byte(`not-json`)},
	} {
		t.Run(test.name, func(t *testing.T) {
			cachePath := filepath.Join(t.TempDir(), versionCheckCacheName)
			if test.contents != nil {
				if err := os.WriteFile(cachePath, test.contents, 0o600); err != nil {
					t.Fatal(err)
				}
			}
			var requests atomic.Int32
			checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusOK, `{"tag_name":"v11.2.3"}`)
			defer closeServer()
			result := awaitVersionCheck(t, checker.start(context.Background(), false))
			if result.latestVersion != "11.2.3" {
				t.Fatalf("latest version = %q", result.latestVersion)
			}
			if got := requests.Load(); got != 1 {
				t.Fatalf("requests = %d, want 1", got)
			}
			cached, err := readVersionCheckCache(cachePath)
			if err != nil {
				t.Fatal(err)
			}
			if cached.CheckedAt != now || cached.LatestVersion != "11.2.3" {
				t.Fatalf("updated cache = %#v", cached)
			}
		})
	}
}

func TestForcedVersionCheckBypassesFreshCache(t *testing.T) {
	now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
	cachePath := filepath.Join(t.TempDir(), versionCheckCacheName)
	if err := writeVersionCheckCache(cachePath, versionCheckCache{
		CheckedAt:     now,
		LatestVersion: "11.2.2",
	}); err != nil {
		t.Fatal(err)
	}
	var requests atomic.Int32
	checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusOK, `{"tag_name":"v11.2.3"}`)
	defer closeServer()
	result := awaitVersionCheck(t, checker.start(context.Background(), true))
	if result.latestVersion != "11.2.3" || requests.Load() != 1 {
		t.Fatalf("forced result = %q with %d requests", result.latestVersion, requests.Load())
	}
}

func TestFailedVersionCheckIsCachedSilently(t *testing.T) {
	now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
	cachePath := filepath.Join(t.TempDir(), versionCheckCacheName)
	var requests atomic.Int32
	checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusServiceUnavailable, `{}`)
	defer closeServer()
	result := awaitVersionCheck(t, checker.start(context.Background(), true))
	if result.latestVersion != "" {
		t.Fatalf("failed check returned version %q", result.latestVersion)
	}
	cached, err := readVersionCheckCache(cachePath)
	if err != nil {
		t.Fatal(err)
	}
	if cached.CheckedAt != now || cached.LatestVersion != "" {
		t.Fatalf("failed check cache = %#v", cached)
	}
	if runtime.GOOS != "windows" {
		info, err := os.Stat(cachePath)
		if err != nil {
			t.Fatal(err)
		}
		if got := info.Mode().Perm(); got != 0o600 {
			t.Fatalf("cache permissions = %o, want 600", got)
		}
	}
}

func TestVersionCheckCacheWriteFailureDoesNotHideResult(t *testing.T) {
	now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
	cachePath := t.TempDir()
	var requests atomic.Int32
	checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusOK, `{"tag_name":"v11.2.3"}`)
	defer closeServer()
	result := awaitVersionCheck(t, checker.start(context.Background(), true))
	if result.latestVersion != "11.2.3" {
		t.Fatalf("result after cache write failure = %q", result.latestVersion)
	}
}

func TestFinishingVersionCheckNeverWaitsForNetwork(t *testing.T) {
	started := make(chan struct{})
	stopped := make(chan struct{})
	client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
		close(started)
		<-request.Context().Done()
		close(stopped)
		return nil, request.Context().Err()
	})}
	checker := transferVersionChecker{
		cachePath:      filepath.Join(t.TempDir(), versionCheckCacheName),
		currentVersion: "11.2.2",
		endpoint:       "https://example.test/latest",
		client:         client,
		now:            time.Now,
		timeout:        time.Minute,
	}
	handle := checker.start(context.Background(), true)
	<-started
	start := time.Now()
	if _, ready := handle.finish(); ready {
		t.Fatal("unfinished check unexpectedly had a result")
	}
	if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
		t.Fatalf("finish blocked for %s", elapsed)
	}
	select {
	case <-stopped:
	case <-time.After(time.Second):
		t.Fatal("unfinished request was not canceled")
	}
}

func TestCROCDoCheckRequiresExactValue(t *testing.T) {
	for _, test := range []struct {
		value string
		want  bool
	}{
		{value: ""},
		{value: "true"},
		{value: "01"},
		{value: "1", want: true},
	} {
		t.Run(test.value, func(t *testing.T) {
			t.Setenv("CROC_DO_CHECK", test.value)
			if got := forceTransferVersionCheck(); got != test.want {
				t.Fatalf("forceTransferVersionCheck() = %v, want %v", got, test.want)
			}
		})
	}
}

func TestCROCDoCheckZeroSkipsTransferVersionCheck(t *testing.T) {
	var requests atomic.Int32
	oldTransport := http.DefaultTransport
	t.Cleanup(func() { http.DefaultTransport = oldTransport })
	http.DefaultTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
		requests.Add(1)
		return nil, io.ErrUnexpectedEOF
	})

	for _, test := range []struct {
		name   string
		cached bool
	}{
		{name: "missing cache"},
		{name: "fresh cache", cached: true},
	} {
		t.Run(test.name, func(t *testing.T) {
			requests.Store(0)
			configDir := t.TempDir()
			cachePath := filepath.Join(configDir, versionCheckCacheName)
			t.Setenv("CROC_CONFIG_DIR", configDir)
			t.Setenv("CROC_DO_CHECK", "0")
			if test.cached {
				if err := writeVersionCheckCache(cachePath, versionCheckCache{
					CheckedAt:     time.Now(),
					LatestVersion: "999.0.0",
				}); err != nil {
					t.Fatal(err)
				}
			}

			set := flag.NewFlagSet("test", flag.ContinueOnError)
			set.Bool("quiet", false, "")
			app := internalcli.NewApp()
			var output bytes.Buffer
			app.ErrWriter = &output
			finish := startTransferVersionCheck(internalcli.NewContext(app, set, nil), "")
			// A started check runs in a goroutine, so give it time to do its work.
			time.Sleep(100 * time.Millisecond)
			finish()

			if got := requests.Load(); got != 0 {
				t.Errorf("requests = %d, want 0", got)
			}
			if _, err := os.Stat(cachePath); !test.cached && !os.IsNotExist(err) {
				t.Errorf("version check cache was written: stat error = %v", err)
			}
			if output.Len() != 0 {
				t.Errorf("notice = %q, want none", output.String())
			}
		})
	}
}

func TestTransferVersionNoticeIsDeferredAndHonorsQuiet(t *testing.T) {
	current, ok := parseReleaseVersion(Version)
	if !ok {
		t.Fatalf("test requires a valid current version, got %q", Version)
	}
	latest := fmt.Sprintf("%d.0.0", current.major+1)
	want := fmt.Sprintf(
		"A newer croc version is available: v%s (current: v%s).\nRun: croc update\n",
		latest,
		Version,
	)

	for _, test := range []struct {
		name  string
		quiet bool
		want  string
	}{
		{name: "notice", want: want},
		{name: "quiet", quiet: true},
	} {
		t.Run(test.name, func(t *testing.T) {
			configDir := t.TempDir()
			t.Setenv("CROC_CONFIG_DIR", configDir)
			t.Setenv("CROC_DO_CHECK", "")
			if err := writeVersionCheckCache(filepath.Join(configDir, versionCheckCacheName), versionCheckCache{
				CheckedAt:     time.Now(),
				LatestVersion: latest,
			}); err != nil {
				t.Fatal(err)
			}

			set := flag.NewFlagSet("test", flag.ContinueOnError)
			set.Bool("quiet", test.quiet, "")
			app := internalcli.NewApp()
			var output bytes.Buffer
			app.ErrWriter = &output
			ctx := internalcli.NewContext(app, set, nil)
			finish := startTransferVersionCheck(ctx, "")
			if output.Len() != 0 {
				t.Fatalf("notice was written before finish: %q", output.String())
			}
			finish()
			if got := output.String(); got != test.want {
				t.Fatalf("notice = %q, want %q", got, test.want)
			}
		})
	}
}

func TestForcedTransferVersionCheckReportsBeforeTransfer(t *testing.T) {
	for _, test := range []struct {
		name       string
		statusCode int
		body       string
		quiet      bool
		want       string
	}{
		{
			name:       "current",
			statusCode: http.StatusOK,
			body:       `{"tag_name":"v11.2.3"}`,
			want:       "No newer croc version is available (current: v11.2.3, latest: v11.2.3).\n",
		},
		{
			name:       "newer",
			statusCode: http.StatusOK,
			body:       `{"tag_name":"v11.2.4"}`,
			want:       "A newer croc version is available: v11.2.4 (current: v11.2.3).\nRun: croc update\n",
		},
		{
			name:       "unavailable",
			statusCode: http.StatusServiceUnavailable,
			body:       `{}`,
			want:       "Could not check for a newer croc version (current: v11.2.3).\n",
		},
		{
			name:       "quiet",
			statusCode: http.StatusOK,
			body:       `{"tag_name":"v11.2.3"}`,
			quiet:      true,
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			var requests atomic.Int32
			checker, closeServer := newTestVersionChecker(
				t,
				filepath.Join(t.TempDir(), versionCheckCacheName),
				time.Now(),
				&requests,
				test.statusCode,
				test.body,
			)
			defer closeServer()
			checker.currentVersion = "11.2.3"

			set := flag.NewFlagSet("test", flag.ContinueOnError)
			set.Bool("quiet", test.quiet, "")
			app := internalcli.NewApp()
			var output bytes.Buffer
			app.ErrWriter = &output
			ctx := internalcli.NewContext(app, set, nil)

			finish := startTransferVersionCheckWithChecker(ctx, checker, true)
			if got := output.String(); got != test.want {
				t.Fatalf("notice before transfer = %q, want %q", got, test.want)
			}
			if got := requests.Load(); got != 1 {
				t.Fatalf("requests = %d, want 1", got)
			}
			finish()
			if got := output.String(); got != test.want {
				t.Fatalf("notice after finish = %q, want no duplicate", got)
			}
		})
	}
}

func TestFailedSendAndReceiveRunDeferredVersionNotice(t *testing.T) {
	current, ok := parseReleaseVersion(Version)
	if !ok {
		t.Fatalf("test requires a valid current version, got %q", Version)
	}
	latest := fmt.Sprintf("%d.0.0", current.major+1)
	for _, test := range []struct {
		name        string
		args        []string
		storedToken string
	}{
		{name: "send", args: []string{"croc", "--ignore-stdin", "send"}},
		{name: "stored send", args: []string{"croc", "--ignore-stdin", "send", "--store"}},
		{name: "stored receive", args: []string{"croc", "--ignore-stdin"}, storedToken: "invalid-stored-token"},
	} {
		t.Run(test.name, func(t *testing.T) {
			configDir := t.TempDir()
			t.Setenv("CROC_CONFIG_DIR", configDir)
			t.Setenv("CROC_DO_CHECK", "")
			t.Setenv("CROC_NO_UPDATE_CHECK", "")
			t.Setenv("CROC_STORE_TOKEN", test.storedToken)
			if err := writeVersionCheckCache(filepath.Join(configDir, versionCheckCacheName), versionCheckCache{
				CheckedAt:     time.Now(),
				LatestVersion: latest,
			}); err != nil {
				t.Fatal(err)
			}
			app := newApp()
			app.Writer = io.Discard
			var errors bytes.Buffer
			app.ErrWriter = &errors
			if err := app.Run(test.args); err == nil {
				t.Fatalf("%v unexpectedly succeeded", test.args)
			}
			if errors.Len() == 0 {
				t.Fatalf("%v did not run the deferred notice", test.args)
			}
		})
	}
}

func TestNoUpdateCheck(t *testing.T) {
	oldTransport := http.DefaultTransport
	t.Cleanup(func() { http.DefaultTransport = oldTransport })
	http.DefaultTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
		return nil, io.ErrUnexpectedEOF
	})
	current, ok := parseReleaseVersion(Version)
	if !ok {
		t.Fatalf("test requires a valid current version, got %q", Version)
	}
	latest := fmt.Sprintf("%d.0.0", current.major+1)
	send := []string{"croc", "--ignore-stdin", "send"}

	for _, test := range []struct {
		name        string
		args        []string
		env         string
		doCheck     string
		remembered  string
		storedToken string
		wantCheck   bool
	}{
		{name: "default", args: send, wantCheck: true},
		{name: "flag", args: []string{"croc", "--no-update-check", "--ignore-stdin", "send"}},
		{name: "env", args: send, env: "1"},
		{name: "remembered send", args: send, remembered: "send.json"},
		{
			name:        "remembered stored receive",
			args:        []string{"croc", "--ignore-stdin"},
			remembered:  "receive.json",
			storedToken: "invalid-stored-token",
		},
		{
			name:       "explicit false overrides remembered",
			args:       []string{"croc", "--no-update-check=false", "--ignore-stdin", "send"},
			remembered: "send.json",
			wantCheck:  true,
		},
		{name: "env false overrides remembered", args: send, env: "0", remembered: "send.json", wantCheck: true},
		{
			name:       "remember ignores the stored value",
			args:       []string{"croc", "--remember", "--ignore-stdin", "send"},
			remembered: "send.json",
			wantCheck:  true,
		},
		{name: "forced check beats remembered", args: send, doCheck: "1", remembered: "send.json", wantCheck: true},
	} {
		t.Run(test.name, func(t *testing.T) {
			configDir := t.TempDir()
			t.Setenv("CROC_CONFIG_DIR", configDir)
			t.Setenv("CROC_DO_CHECK", test.doCheck)
			t.Setenv("CROC_NO_UPDATE_CHECK", test.env)
			t.Setenv("CROC_STORE_TOKEN", test.storedToken)
			cachePath := filepath.Join(configDir, versionCheckCacheName)
			// A skipped check must not touch the cache, so only checks get a seed.
			if test.wantCheck {
				if err := writeVersionCheckCache(cachePath, versionCheckCache{
					CheckedAt:     time.Now(),
					LatestVersion: latest,
				}); err != nil {
					t.Fatal(err)
				}
			}
			if test.remembered != "" {
				config := []byte(`{"NoUpdateCheck":true}`)
				if err := os.WriteFile(filepath.Join(configDir, test.remembered), config, 0o600); err != nil {
					t.Fatal(err)
				}
			}
			app := newApp()
			app.Writer = io.Discard
			var output bytes.Buffer
			app.ErrWriter = &output
			if err := app.Run(test.args); err == nil {
				t.Fatalf("%v unexpectedly succeeded", test.args)
			}
			if got := output.Len() != 0; got != test.wantCheck {
				t.Fatalf("checked = %v, want %v (output %q)", got, test.wantCheck, output.String())
			}
			if !test.wantCheck {
				// A started check runs in a goroutine, so give it time to do its work.
				time.Sleep(100 * time.Millisecond)
				if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
					t.Fatalf("version check cache was written: stat error = %v", err)
				}
			}
		})
	}
}

func TestRememberSavesNoUpdateCheck(t *testing.T) {
	file := filepath.Join(t.TempDir(), "file.txt")
	if err := os.WriteFile(file, []byte("data"), 0o600); err != nil {
		t.Fatal(err)
	}
	// The relay is unreachable: both sides save their config before connecting.
	// --ip keeps the receiver from discovering real senders on the network.
	args := []string{
		"croc", "--remember", "--no-update-check", "--yes", "--ignore-stdin", "--disable-clipboard",
		"--relay", "127.0.0.1:1", "--ip", "127.0.0.1:1",
	}
	for _, test := range []struct {
		config string
		args   []string
	}{
		{config: "send.json", args: append(args, "send", "--no-local", file)},
		{config: "receive.json", args: args},
	} {
		t.Run(test.config, func(t *testing.T) {
			configDir := t.TempDir()
			t.Setenv("CROC_CONFIG_DIR", configDir)
			t.Setenv("CROC_DO_CHECK", "")
			t.Setenv("CROC_SECRET", "1234-remember-test")
			t.Setenv("CROC_STORE_TOKEN", "")
			app := newApp()
			app.Writer = io.Discard
			if err := app.Run(test.args); err == nil {
				t.Fatalf("%v unexpectedly succeeded", test.args)
			}
			b, err := os.ReadFile(filepath.Join(configDir, test.config))
			if err != nil {
				t.Fatal(err)
			}
			var remembered croc.Options
			if err := json.Unmarshal(b, &remembered); err != nil {
				t.Fatal(err)
			}
			if !remembered.NoUpdateCheck {
				t.Fatalf("%s did not save NoUpdateCheck: %s", test.config, b)
			}
		})
	}
}

func newTestVersionChecker(
	t *testing.T,
	cachePath string,
	now time.Time,
	requests *atomic.Int32,
	statusCode int,
	body string,
) (transferVersionChecker, func()) {
	t.Helper()
	server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
		requests.Add(1)
		response.WriteHeader(statusCode)
		_, _ = io.WriteString(response, body)
	}))
	return transferVersionChecker{
		cachePath:      cachePath,
		currentVersion: "11.2.2",
		endpoint:       server.URL,
		client:         server.Client(),
		now:            func() time.Time { return now },
		timeout:        time.Second,
	}, server.Close
}

func awaitVersionCheck(t *testing.T, handle versionCheckHandle) versionCheckResult {
	t.Helper()
	select {
	case result := <-handle.result:
		handle.cancel()
		return result
	case <-time.After(2 * time.Second):
		handle.cancel()
		t.Fatal("version check did not finish")
		return versionCheckResult{}
	}
}

func mustMarshalCache(t *testing.T, cached versionCheckCache) []byte {
	t.Helper()
	contents, err := json.Marshal(cached)
	if err != nil {
		t.Fatal(err)
	}
	return contents
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
	return fn(request)
}