mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-13 07:47:52 +00:00
test: cover cmd/kportal helpers + version checker HTTP paths
cmd/kportal: 0% -> 17.4% (testable surface only — main() is a 274-statement monolith with OS signals, bubbletea TUI, kubeconfig loading, signal-loop goroutines, and config watcher all in one function. Hitting 70% requires extracting it into run(args,w) int, which is a non-trivial refactor — out of scope for a coverage pass.) Tests cover: promptCreateConfig (yes/no/EOF/empty inputs via os.Pipe stdin redirection), contains, resolveGenerateConfigPath (all 4 system dirs + abs/rel branches), runGenerate (missing flag, -h, unknown flag, invalid context, malformed YAML, no-TTY error path). internal/version: 45.7% -> 97.8%. Added httptest.NewServer + custom rewriteTransport to redirect GitHub API calls. Covered NewChecker, fetchLatestRelease (200/403/404/429/500, malformed JSON, empty tag_name), CheckForUpdate (newer/same/current-newer/error/cancelled), parseVersion edge cases (empty, single digit, alpha).
This commit is contained in:
@@ -0,0 +1,327 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeKubeconfig writes a minimal kubeconfig file to dir with a single context
|
||||||
|
// named contextName and returns the path.
|
||||||
|
func fakeKubeconfig(t *testing.T, dir, contextName string) string {
|
||||||
|
t.Helper()
|
||||||
|
content := `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://localhost:6443
|
||||||
|
name: fake-cluster
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: fake-cluster
|
||||||
|
namespace: default
|
||||||
|
user: fake-user
|
||||||
|
name: ` + contextName + `
|
||||||
|
current-context: ` + contextName + `
|
||||||
|
kind: Config
|
||||||
|
preferences: {}
|
||||||
|
users:
|
||||||
|
- name: fake-user
|
||||||
|
user: {}
|
||||||
|
`
|
||||||
|
path := filepath.Join(dir, "kubeconfig")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0600))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- promptCreateConfig ----
|
||||||
|
|
||||||
|
// redirectStdin replaces os.Stdin with a pipe that has the given text
|
||||||
|
// already written into it. Returns a cleanup function that restores
|
||||||
|
// the original Stdin and closes the pipe ends.
|
||||||
|
func redirectStdin(t *testing.T, text string) func() {
|
||||||
|
t.Helper()
|
||||||
|
origStdin := os.Stdin
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = io.WriteString(w, text)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
os.Stdin = r
|
||||||
|
return func() {
|
||||||
|
os.Stdin = origStdin
|
||||||
|
require.NoError(t, r.Close())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptCreateConfig_YesResponses(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{"empty enter", "\n"},
|
||||||
|
{"lowercase y", "y\n"},
|
||||||
|
{"uppercase Y", "Y\n"}, // ToLower normalises it
|
||||||
|
{"yes word", "yes\n"},
|
||||||
|
{"YES word", "YES\n"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
restore := redirectStdin(t, tc.input)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
result := promptCreateConfig("/some/path.yaml")
|
||||||
|
assert.True(t, result, "expected true for input %q", tc.input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptCreateConfig_NoResponses(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{"lowercase n", "n\n"},
|
||||||
|
{"uppercase N", "N\n"},
|
||||||
|
{"no word", "no\n"},
|
||||||
|
{"other text", "nope\n"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
restore := redirectStdin(t, tc.input)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
result := promptCreateConfig("/some/path.yaml")
|
||||||
|
assert.False(t, result, "expected false for input %q", tc.input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptCreateConfig_EOFReturnsFalse(t *testing.T) {
|
||||||
|
// Provide an empty pipe (write end immediately closed) → EOF → false.
|
||||||
|
origStdin := os.Stdin
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close()) // no data written → EOF on first read
|
||||||
|
os.Stdin = r
|
||||||
|
defer func() {
|
||||||
|
os.Stdin = origStdin
|
||||||
|
require.NoError(t, r.Close())
|
||||||
|
}()
|
||||||
|
|
||||||
|
result := promptCreateConfig("/some/path.yaml")
|
||||||
|
assert.False(t, result, "EOF should return false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- contains ----
|
||||||
|
|
||||||
|
func TestContains_Present(t *testing.T) {
|
||||||
|
assert.True(t, contains([]string{"a", "b", "c"}, "b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_Absent(t *testing.T) {
|
||||||
|
assert.False(t, contains([]string{"a", "b", "c"}, "d"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_EmptySlice(t *testing.T) {
|
||||||
|
assert.False(t, contains([]string{}, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_EmptyNeedle(t *testing.T) {
|
||||||
|
assert.True(t, contains([]string{"", "a"}, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resolveGenerateConfigPath ----
|
||||||
|
|
||||||
|
func TestResolveGenerateConfigPath_EmptyPath(t *testing.T) {
|
||||||
|
path, ok := resolveGenerateConfigPath("")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Empty(t, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateConfigPath_SystemDirs(t *testing.T) {
|
||||||
|
sysDirs := []string{
|
||||||
|
"/etc/passwd",
|
||||||
|
"/sys/kernel/config",
|
||||||
|
"/proc/cpuinfo",
|
||||||
|
"/dev/null",
|
||||||
|
}
|
||||||
|
for _, d := range sysDirs {
|
||||||
|
t.Run(d, func(t *testing.T) {
|
||||||
|
path, ok := resolveGenerateConfigPath(d)
|
||||||
|
assert.False(t, ok, "system path should be rejected: %s", d)
|
||||||
|
assert.Empty(t, path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateConfigPath_ValidPath(t *testing.T) {
|
||||||
|
// A relative path should be resolved to an absolute, cleaned path.
|
||||||
|
path, ok := resolveGenerateConfigPath("relative/config.yaml")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.True(t, strings.HasPrefix(path, "/"), "should be absolute")
|
||||||
|
assert.True(t, strings.HasSuffix(path, "relative/config.yaml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateConfigPath_AbsolutePath(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := tmpDir + "/kportal.yaml"
|
||||||
|
path, ok := resolveGenerateConfigPath(configPath)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, configPath, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- runGenerate ----
|
||||||
|
|
||||||
|
// captureStderr swaps os.Stderr for a pipe and returns a function that
|
||||||
|
// restores it and returns whatever was written.
|
||||||
|
func captureStderr(t *testing.T) func() string {
|
||||||
|
t.Helper()
|
||||||
|
origStderr := os.Stderr
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
return func() string {
|
||||||
|
_ = w.Close()
|
||||||
|
os.Stderr = origStderr
|
||||||
|
var sb strings.Builder
|
||||||
|
_, _ = io.Copy(&sb, r)
|
||||||
|
_ = r.Close()
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGenerate_MissingContextFlag(t *testing.T) {
|
||||||
|
// --context is required; omitting it should return exit-code 1.
|
||||||
|
stop := captureStderr(t)
|
||||||
|
code := runGenerate([]string{})
|
||||||
|
stderr := stop()
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "--context")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGenerate_HelpFlag(t *testing.T) {
|
||||||
|
// -h / --help should return exit-code 0 (flag.ContinueOnError + ErrHelp).
|
||||||
|
stop := captureStderr(t)
|
||||||
|
code := runGenerate([]string{"-h"})
|
||||||
|
_ = stop()
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGenerate_UnknownFlag(t *testing.T) {
|
||||||
|
// An unrecognised flag should return exit-code 1.
|
||||||
|
stop := captureStderr(t)
|
||||||
|
code := runGenerate([]string{"--unknown-flag=xyz"})
|
||||||
|
_ = stop()
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGenerate_SystemDirConfig(t *testing.T) {
|
||||||
|
// A config path inside a system directory should return exit-code 1.
|
||||||
|
stop := captureStderr(t)
|
||||||
|
code := runGenerate([]string{"--context=minikube", "--config=/etc/kportal.yaml"})
|
||||||
|
stderr := stop()
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "system directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGenerate_ContextNotInKubeconfig(t *testing.T) {
|
||||||
|
// A context that does not exist in kubeconfig should return exit-code 1.
|
||||||
|
// This relies on k8s.NewClientPool() succeeding (it reads ~/.kube/config or
|
||||||
|
// returns an empty pool) and ListContexts() returning a set that does not
|
||||||
|
// contain the requested name.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := tmpDir + "/kportal.yaml"
|
||||||
|
|
||||||
|
stop := captureStderr(t)
|
||||||
|
code := runGenerate([]string{
|
||||||
|
"--context=this-context-does-not-exist-in-any-kubeconfig-xyz",
|
||||||
|
"--config=" + configPath,
|
||||||
|
})
|
||||||
|
stderr := stop()
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
// Either the context was not found, OR k8s client setup failed — both are
|
||||||
|
// valid error paths that return 1.
|
||||||
|
assert.NotEmpty(t, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunGenerate_MalformedConfig verifies that a config file with invalid YAML
|
||||||
|
// causes runGenerate to return exit-code 1 before calling ui.RunGenerate.
|
||||||
|
func TestRunGenerate_MalformedConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a fake kubeconfig with a known context name.
|
||||||
|
kubecfgPath := fakeKubeconfig(t, tmpDir, "test-ctx")
|
||||||
|
t.Setenv("KUBECONFIG", kubecfgPath)
|
||||||
|
|
||||||
|
// Write an invalid YAML config file.
|
||||||
|
configPath := filepath.Join(tmpDir, "bad.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(configPath, []byte(":\t invalid yaml {{{\n"), 0600))
|
||||||
|
|
||||||
|
stop := captureStderr(t)
|
||||||
|
code := runGenerate([]string{
|
||||||
|
"--context=test-ctx",
|
||||||
|
"--config=" + configPath,
|
||||||
|
})
|
||||||
|
stderr := stop()
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "failed to load config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunGenerate_ValidContextNoUI verifies runGenerate error-handling when
|
||||||
|
// ui.RunGenerate cannot open a TTY (always the case in non-interactive test
|
||||||
|
// environments). The function should return exit-code 1 and print the error.
|
||||||
|
func TestRunGenerate_ValidContextNoUI(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
kubecfgPath := fakeKubeconfig(t, tmpDir, "test-ctx")
|
||||||
|
t.Setenv("KUBECONFIG", kubecfgPath)
|
||||||
|
|
||||||
|
// Config file does not exist — ErrConfigNotFound is acceptable; code
|
||||||
|
// proceeds to ui.RunGenerate which fails (no TTY in tests).
|
||||||
|
configPath := filepath.Join(tmpDir, "nonexistent.yaml")
|
||||||
|
|
||||||
|
stop := captureStderr(t)
|
||||||
|
code := runGenerate([]string{
|
||||||
|
"--context=test-ctx",
|
||||||
|
"--config=" + configPath,
|
||||||
|
})
|
||||||
|
stderr := stop()
|
||||||
|
// Either the UI failed (exit 1) or — on rare CI with a TTY — it was
|
||||||
|
// cancelled (also exit 1). Both are acceptable outcomes for this test.
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
_ = stderr // error message varies by environment
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- promptCreateConfig output via bufio path ----
|
||||||
|
|
||||||
|
// TestPromptCreateConfig_PathIncludedInOutput verifies the path is printed.
|
||||||
|
func TestPromptCreateConfig_PathIncludedInOutput(t *testing.T) {
|
||||||
|
// Capture stdout by swapping os.Stdout temporarily.
|
||||||
|
origStdout := os.Stdout
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
restore := redirectStdin(t, "n\n")
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
_ = promptCreateConfig("/my/special/config.yaml")
|
||||||
|
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
os.Stdout = origStdout
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
sb.WriteString(scanner.Text())
|
||||||
|
}
|
||||||
|
require.NoError(t, r.Close())
|
||||||
|
|
||||||
|
assert.Contains(t, sb.String(), "/my/special/config.yaml")
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeChecker builds a Checker whose HTTP client is wired to the given test
|
||||||
|
// server. Because fetchLatestRelease constructs its URL from owner+repo, we
|
||||||
|
// embed the server's base URL directly in the owner field so the final URL
|
||||||
|
// becomes "<serverURL>/<repo>/releases/latest" – fine for an httptest server
|
||||||
|
// that ignores the path.
|
||||||
|
func makeCheckerWithServer(t *testing.T, srv *httptest.Server, currentVersion string) *Checker {
|
||||||
|
t.Helper()
|
||||||
|
c := NewChecker("owner", "repo", currentVersion)
|
||||||
|
// Replace the HTTP client with one whose transport rewrites every outgoing
|
||||||
|
// request to the test server, regardless of the original URL. This is
|
||||||
|
// necessary because fetchLatestRelease hard-codes the GitHub API URL, so
|
||||||
|
// we cannot influence the host via owner/repo fields.
|
||||||
|
c.client = &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Transport: &rewriteTransport{inner: srv.Client().Transport, base: srv.URL},
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteTransport redirects every outgoing request to baseURL, preserving
|
||||||
|
// the path and query of the original request.
|
||||||
|
type rewriteTransport struct {
|
||||||
|
inner http.RoundTripper
|
||||||
|
base string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
// Clone the request and rewrite the host to our test server.
|
||||||
|
r2 := req.Clone(req.Context())
|
||||||
|
r2.URL.Scheme = "http"
|
||||||
|
// Parse just the host from base (strip scheme prefix).
|
||||||
|
host := strings.TrimPrefix(rt.base, "http://")
|
||||||
|
host = strings.TrimPrefix(host, "https://")
|
||||||
|
r2.URL.Host = host
|
||||||
|
return rt.inner.RoundTrip(r2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewChecker verifies the constructor sets fields correctly.
|
||||||
|
func TestNewChecker_FieldsSet(t *testing.T) {
|
||||||
|
c := NewChecker("myowner", "myrepo", "v1.2.3")
|
||||||
|
require.NotNil(t, c)
|
||||||
|
assert.Equal(t, "myowner", c.owner)
|
||||||
|
assert.Equal(t, "myrepo", c.repo)
|
||||||
|
assert.Equal(t, "1.2.3", c.current) // normalizeVersion strips the "v"
|
||||||
|
assert.NotNil(t, c.client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewChecker_NormalizesVersion ensures the v-prefix is stripped at construction.
|
||||||
|
func TestNewChecker_NormalizesVersion(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"v0.1.0", "0.1.0"},
|
||||||
|
{"V2.0.0", "2.0.0"},
|
||||||
|
{"3.0.0", "3.0.0"},
|
||||||
|
{" v1.0.0 ", "1.0.0"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
c := NewChecker("o", "r", tc.input)
|
||||||
|
assert.Equal(t, tc.expected, c.current)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckForUpdate_NewerVersionAvailable verifies an UpdateInfo is returned
|
||||||
|
// when the server reports a newer tag.
|
||||||
|
func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
release := ReleaseInfo{
|
||||||
|
TagName: "v2.0.0",
|
||||||
|
HTMLURL: "https://github.com/example/repo/releases/tag/v2.0.0",
|
||||||
|
Name: "Release v2.0.0",
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(release)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
info := c.CheckForUpdate(context.Background())
|
||||||
|
|
||||||
|
require.NotNil(t, info)
|
||||||
|
assert.Equal(t, "1.0.0", info.CurrentVersion)
|
||||||
|
assert.Equal(t, "2.0.0", info.LatestVersion)
|
||||||
|
assert.Equal(t, "https://github.com/example/repo/releases/tag/v2.0.0", info.ReleaseURL)
|
||||||
|
assert.Equal(t, "Release v2.0.0", info.ReleaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckForUpdate_CurrentIsLatest verifies nil is returned when already on
|
||||||
|
// the latest version.
|
||||||
|
func TestCheckForUpdate_CurrentIsLatest(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
release := ReleaseInfo{TagName: "v1.0.0", HTMLURL: "https://example.com"}
|
||||||
|
_ = json.NewEncoder(w).Encode(release)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
info := c.CheckForUpdate(context.Background())
|
||||||
|
assert.Nil(t, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckForUpdate_CurrentIsNewer verifies nil is returned when the running
|
||||||
|
// version is ahead of the released one.
|
||||||
|
func TestCheckForUpdate_CurrentIsNewer(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
release := ReleaseInfo{TagName: "v0.9.0", HTMLURL: "https://example.com"}
|
||||||
|
_ = json.NewEncoder(w).Encode(release)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
info := c.CheckForUpdate(context.Background())
|
||||||
|
assert.Nil(t, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckForUpdate_NetworkError verifies nil is returned on network failure
|
||||||
|
// (fail-silent contract).
|
||||||
|
func TestCheckForUpdate_NetworkError(t *testing.T) {
|
||||||
|
// Point at a server that is immediately closed.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||||
|
srv.Close() // close before the request is made
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
info := c.CheckForUpdate(context.Background())
|
||||||
|
assert.Nil(t, info, "network error should return nil (fail silent)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckForUpdate_CancelledContext verifies nil is returned when the
|
||||||
|
// context is already cancelled.
|
||||||
|
func TestCheckForUpdate_CancelledContext(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
release := ReleaseInfo{TagName: "v9.9.9"}
|
||||||
|
_ = json.NewEncoder(w).Encode(release)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // cancel immediately
|
||||||
|
|
||||||
|
info := c.CheckForUpdate(ctx)
|
||||||
|
assert.Nil(t, info, "cancelled context should return nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchLatestRelease_NonOKStatus verifies an error is returned for non-200
|
||||||
|
// responses (e.g. rate-limit 403, 404, 500).
|
||||||
|
func TestFetchLatestRelease_NonOKStatus(t *testing.T) {
|
||||||
|
codes := []int{http.StatusNotFound, http.StatusForbidden, http.StatusInternalServerError, http.StatusTooManyRequests}
|
||||||
|
for _, code := range codes {
|
||||||
|
code := code
|
||||||
|
t.Run(http.StatusText(code), func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(code)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
release, err := c.fetchLatestRelease(context.Background())
|
||||||
|
assert.Nil(t, release)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "status")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchLatestRelease_MalformedJSON verifies an error is returned when the
|
||||||
|
// response body is not valid JSON.
|
||||||
|
func TestFetchLatestRelease_MalformedJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{not valid json`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
release, err := c.fetchLatestRelease(context.Background())
|
||||||
|
assert.Nil(t, release)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchLatestRelease_EmptyTagName verifies that a response with no tag_name
|
||||||
|
// is parsed (returns a ReleaseInfo with empty TagName) without error.
|
||||||
|
func TestFetchLatestRelease_EmptyTagName(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"html_url":"https://example.com","name":"no tag"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
release, err := c.fetchLatestRelease(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, release)
|
||||||
|
assert.Empty(t, release.TagName)
|
||||||
|
assert.Equal(t, "https://example.com", release.HTMLURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchLatestRelease_RequestHeaders verifies the Accept and User-Agent
|
||||||
|
// headers are set on the outgoing request.
|
||||||
|
func TestFetchLatestRelease_RequestHeaders(t *testing.T) {
|
||||||
|
var gotAccept, gotUA string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAccept = r.Header.Get("Accept")
|
||||||
|
gotUA = r.Header.Get("User-Agent")
|
||||||
|
release := ReleaseInfo{TagName: "v1.0.0"}
|
||||||
|
_ = json.NewEncoder(w).Encode(release)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "1.0.0")
|
||||||
|
_, err := c.fetchLatestRelease(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "application/vnd.github.v3+json", gotAccept)
|
||||||
|
assert.Equal(t, "kportal-version-checker", gotUA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckForUpdate_WithVPrefix verifies that a tag like "v2.0.0" is
|
||||||
|
// normalised correctly before comparison.
|
||||||
|
func TestCheckForUpdate_WithVPrefix(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
release := ReleaseInfo{
|
||||||
|
TagName: "v1.1.0",
|
||||||
|
HTMLURL: "https://example.com/v1.1.0",
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(release)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := makeCheckerWithServer(t, srv, "v1.0.0")
|
||||||
|
info := c.CheckForUpdate(context.Background())
|
||||||
|
require.NotNil(t, info)
|
||||||
|
assert.Equal(t, "1.1.0", info.LatestVersion)
|
||||||
|
assert.Equal(t, "1.0.0", info.CurrentVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseVersion_EdgeCases covers inputs not exercised by the existing tests.
|
||||||
|
func TestParseVersion_EdgeCases(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected []int
|
||||||
|
}{
|
||||||
|
{"empty string", "", []int{0}},
|
||||||
|
{"single digit", "3", []int{3}},
|
||||||
|
{"non-numeric part", "abc", []int{0}},
|
||||||
|
{"mixed numeric and alpha", "1.abc.3", []int{1, 0, 3}},
|
||||||
|
{"build metadata only", "1.0.0+meta", []int{1, 0, 0}},
|
||||||
|
{"pre-release only", "1.0.0-alpha.1", []int{1, 0, 0}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := parseVersion(tc.input)
|
||||||
|
assert.Equal(t, tc.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsNewerVersion_EqualLength covers the equal-length tie case.
|
||||||
|
func TestIsNewerVersion_EqualLength(t *testing.T) {
|
||||||
|
// Equal versions with same length: not newer.
|
||||||
|
assert.False(t, isNewerVersion("1.2.3", "1.2.3"))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user