614 lines
19 KiB
Go
614 lines
19 KiB
Go
//go:build linux || freebsd
|
|
// +build linux freebsd
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"codeberg.org/petrbalvin/goget/internal/config"
|
|
"codeberg.org/petrbalvin/goget/internal/output"
|
|
)
|
|
|
|
func TestExtractConfigPath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
expected string
|
|
}{
|
|
{"no config flag", []string{"--url", "https://example.com"}, ""},
|
|
{"--config with value", []string{"--config", "/path/to/config.json"}, "/path/to/config.json"},
|
|
{"--config=value", []string{"--config=/path/to/config.json"}, "/path/to/config.json"},
|
|
{"config after url", []string{"--url", "https://example.com", "--config", "/custom/config.json"}, "/custom/config.json"},
|
|
{"empty args", []string{}, ""},
|
|
{"--config without value", []string{"--config"}, ""},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := extractConfigPath(tc.args)
|
|
if result != tc.expected {
|
|
t.Errorf("extractConfigPath(%v) = %q, want %q", tc.args, result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseRateLimit(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected int64
|
|
}{
|
|
{"", 0},
|
|
{"0", 0},
|
|
{"100", 100},
|
|
{"1KB/s", 1000},
|
|
{"1MB/s", 1000000},
|
|
{"1GB/s", 1000000000},
|
|
{"500KB/s", 500000},
|
|
{"10MB/s", 10000000},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
result, err := parseRateLimit(tc.input)
|
|
if err != nil {
|
|
t.Errorf("parseRateLimit(%q) returned unexpected error: %v", tc.input, err)
|
|
}
|
|
if result != tc.expected {
|
|
t.Errorf("parseRateLimit(%q) = %d, want %d", tc.input, result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseRateLimitInvalid is a regression guard for the BACKLOG entry
|
|
// "`parseRateLimit` silently returns 0 on parse error". The previous
|
|
// implementation fell back to 0 without telling the caller, so a typo
|
|
// like "1oMB/s" (letter 'o' instead of digit '0') silently disabled rate
|
|
// limiting. The fixed implementation returns a non-nil error so the caller
|
|
// can warn the user.
|
|
func TestParseRateLimitInvalid(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{"letter_o_instead_of_zero", "1oMB/s"},
|
|
{"pure_garbage", "abc"},
|
|
{"trailing_unit_only", "MB"},
|
|
{"only_unit_letter", "B"},
|
|
{"whitespace_around_digits", " abc "},
|
|
{"multiple_dots", "1.2.3MB"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result, err := parseRateLimit(tc.input)
|
|
if err == nil {
|
|
t.Errorf("parseRateLimit(%q) = %d, nil err; want non-nil error", tc.input, result)
|
|
}
|
|
if result != 0 {
|
|
t.Errorf("parseRateLimit(%q) = %d on error, want 0", tc.input, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractURLs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
expected int
|
|
}{
|
|
{"single url", []string{"--url", "https://example.com/file.zip"}, 1},
|
|
{"multiple urls", []string{"--url", "https://a.com", "--url", "https://b.com"}, 2},
|
|
{"with other flags", []string{"--url", "https://a.com", "--verbose", "--url", "https://b.com"}, 2},
|
|
{"url= format", []string{"--url=https://a.com", "--url=https://b.com"}, 2},
|
|
{"no url", []string{"--verbose", "--debug"}, 0},
|
|
{"empty args", []string{}, 0},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := extractURLs(tc.args)
|
|
if len(result) != tc.expected {
|
|
t.Errorf("extractURLs(%v) returned %d URLs, want %d", tc.args, len(result), tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBatchFileURLParsing(t *testing.T) {
|
|
// Test that the URL parsing logic in handleBatchFile would work
|
|
// by testing the individual steps
|
|
content := `https://example.com/file1.zip
|
|
https://example.com/file2.zip
|
|
# This is a comment
|
|
// This is also a comment
|
|
|
|
https://example.com/file3.zip`
|
|
|
|
lines := strings.Split(strings.TrimSpace(content), "\n")
|
|
var urls []string
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
|
|
continue
|
|
}
|
|
urls = append(urls, line)
|
|
}
|
|
|
|
if len(urls) != 3 {
|
|
t.Errorf("Expected 3 URLs, got %d: %v", len(urls), urls)
|
|
}
|
|
}
|
|
|
|
// TestConfigSetFilePermissions is a regression guard for the BACKLOG
|
|
// entry "os.WriteFile with 0644 on config containing OAuth tokens".
|
|
// The previous implementation used 0644, which leaves the config file
|
|
// (and any embedded access_token / refresh_token) world-readable on
|
|
// multi-user systems. After the fix, both handleConfigSet and
|
|
// handleConfigUnset must write the file with mode 0600.
|
|
func TestConfigFilePermissions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(t *testing.T, configPath string)
|
|
}{
|
|
{
|
|
name: "handleConfigSet writes 0600",
|
|
run: func(t *testing.T, configPath string) {
|
|
if rc := handleConfigSet(configPath, "timeout", []string{
|
|
"--config-set", "timeout", "30",
|
|
}); rc != 0 {
|
|
t.Fatalf("handleConfigSet returned %d", rc)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "handleConfigUnset writes 0600",
|
|
run: func(t *testing.T, configPath string) {
|
|
if rc := handleConfigSet(configPath, "timeout", []string{
|
|
"--config-set", "timeout", "30",
|
|
}); rc != 0 {
|
|
t.Fatalf("seed handleConfigSet returned %d", rc)
|
|
}
|
|
if rc := handleConfigUnset(configPath, "timeout"); rc != 0 {
|
|
t.Fatalf("handleConfigUnset returned %d", rc)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.toml")
|
|
// Seed a minimal config so config.Load succeeds.
|
|
seed := []byte("proxy = \"\"\nuser_agent = \"\"\ntimeout = 0\n")
|
|
if err := os.WriteFile(configPath, seed, 0600); err != nil {
|
|
t.Fatalf("seed write: %v", err)
|
|
}
|
|
|
|
tc.run(t, configPath)
|
|
|
|
info, err := os.Stat(configPath)
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
perm := info.Mode().Perm()
|
|
// Mask out the umask bits by checking against 0600; the file
|
|
// must not be group- or world-readable.
|
|
if perm != 0600 {
|
|
t.Errorf("config file mode = %04o, want 0600", perm)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNextCronTime(t *testing.T) {
|
|
// Use a fixed reference time for deterministic tests (UTC)
|
|
ref, _ := time.Parse("2006-01-02 15:04", "2026-06-01 10:00")
|
|
ref = ref.UTC()
|
|
|
|
t.Run("every minute", func(t *testing.T) {
|
|
next, err := nextCronTime("* * * * *", ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected := ref.Truncate(time.Minute).Add(time.Minute)
|
|
if !next.Equal(expected) {
|
|
t.Errorf("got %v, want %v", next, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("specific hour and minute", func(t *testing.T) {
|
|
next, err := nextCronTime("30 14 * * *", ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected := time.Date(2026, 6, 1, 14, 30, 0, 0, time.UTC)
|
|
if !next.Equal(expected) {
|
|
t.Errorf("got %v, want %v", next, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("daily at midnight", func(t *testing.T) {
|
|
next, err := nextCronTime("0 0 * * *", ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
|
|
if !next.Equal(expected) {
|
|
t.Errorf("got %v, want %v", next, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("specific day of week", func(t *testing.T) {
|
|
// June 1, 2026 is Monday (1)
|
|
next, err := nextCronTime("0 9 * * 3", ref) // Wednesday
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if next.Weekday() != time.Wednesday {
|
|
t.Errorf("expected Wednesday, got %v", next.Weekday())
|
|
}
|
|
})
|
|
|
|
t.Run("comma separated hours", func(t *testing.T) {
|
|
next, err := nextCronTime("0 8,16 * * *", ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected := time.Date(2026, 6, 1, 16, 0, 0, 0, time.UTC)
|
|
if !next.Equal(expected) {
|
|
t.Errorf("got %v, want %v", next, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("step values", func(t *testing.T) {
|
|
next, err := nextCronTime("*/15 * * * *", ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if next.Minute()%15 != 0 {
|
|
t.Errorf("minute not a multiple of 15: %d", next.Minute())
|
|
}
|
|
})
|
|
|
|
t.Run("invalid fields", func(t *testing.T) {
|
|
_, err := nextCronTime("invalid", ref)
|
|
if err == nil {
|
|
t.Error("expected error for invalid cron expression")
|
|
}
|
|
})
|
|
|
|
t.Run("too many fields", func(t *testing.T) {
|
|
_, err := nextCronTime("1 2 3 4 5 6", ref)
|
|
if err == nil {
|
|
t.Error("expected error for 6 fields")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExitCodeForRun covers the contract that graceful shutdown
|
|
// surfaces exit code 130 to the shell when the user interrupts the
|
|
// process. A clean exit (no signal) returns the inner code unchanged;
|
|
// a non-zero inner code (real error) is preserved even if the signal
|
|
// context was also cancelled.
|
|
func TestExitCodeForRun(t *testing.T) {
|
|
t.Run("clean exit on no signal returns inner code 0", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if got := exitCodeForRun(ctx, 0); got != 0 {
|
|
t.Errorf("got %d, want 0", got)
|
|
}
|
|
})
|
|
|
|
t.Run("cancelled signal context maps clean exit to 130", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // simulate SIGINT/SIGTERM propagation
|
|
if got := exitCodeForRun(ctx, 0); got != 130 {
|
|
t.Errorf("got %d, want 130", got)
|
|
}
|
|
})
|
|
|
|
t.Run("non-zero inner code is preserved when signal is also cancelled", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
// Even with the signal context cancelled, a real error (e.g.
|
|
// download failure) should not be masked by the 130 mapping.
|
|
if got := exitCodeForRun(ctx, 7); got != 7 {
|
|
t.Errorf("got %d, want 7 (inner code preserved)", got)
|
|
}
|
|
})
|
|
|
|
t.Run("non-zero inner code is returned unchanged with no signal", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if got := exitCodeForRun(ctx, 42); got != 42 {
|
|
t.Errorf("got %d, want 42", got)
|
|
}
|
|
})
|
|
|
|
t.Run("nil context returns inner code unchanged", func(t *testing.T) {
|
|
if got := exitCodeForRun(context.TODO(), 5); got != 5 {
|
|
t.Errorf("got %d, want 5 (nil context safe)", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCreateDirsFlagRegisteredAndDefaults is a regression guard for the
|
|
// BACKLOG entry "Add --create-dirs flag (curl / wget compatibility)". It
|
|
// verifies the flag is registered on the command-line FlagSet, defaults to
|
|
// true (matching curl / wget behaviour so common `goget --output
|
|
// ./downloads/file.zip` invocations just work), and can be flipped to
|
|
// false via the standard flag parser. We do not exercise the full
|
|
// download path here — that is covered by TestNewWriterCreateDirsMissingParent
|
|
// in internal/output/writer_test.go.
|
|
func TestCreateDirsFlagRegisteredAndDefaults(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
fs := newTestFlagSet("goget-test")
|
|
flags := defineFlags(fs, cfg)
|
|
|
|
if flags.CreateDirs == nil {
|
|
t.Fatal("CreateDirs flag was not registered on the FlagSet")
|
|
}
|
|
if !*flags.CreateDirs {
|
|
t.Errorf("CreateDirs default = false, want true (curl / wget parity)")
|
|
}
|
|
|
|
// Flip the flag through the standard flag parser to make sure the
|
|
// long name is wired up correctly and the value reaches the struct.
|
|
fs2 := newTestFlagSet("goget-test-2")
|
|
flags2 := defineFlags(fs2, cfg)
|
|
if err := fs2.Parse([]string{"--create-dirs=false"}); err != nil {
|
|
t.Fatalf("Parse --create-dirs=false: %v", err)
|
|
}
|
|
if *flags2.CreateDirs {
|
|
t.Errorf("CreateDirs after --create-dirs=false = true, want false")
|
|
}
|
|
}
|
|
|
|
// newTestFlagSet builds an isolated flag.FlagSet for CLI tests. We use
|
|
// flag.ContinueOnError so a bad flag in a test fails the test instead of
|
|
// calling os.Exit and tearing down the rest of the test binary.
|
|
func newTestFlagSet(name string) *flag.FlagSet {
|
|
return flag.NewFlagSet(name, flag.ContinueOnError)
|
|
}
|
|
|
|
// TestShowMetadataFlagRegistered is a regression guard for the BACKLOG entry
|
|
// "`--show-metadata` — display .goget.meta contents for debugging
|
|
// interrupted/resumed downloads". It verifies that the flag is wired up on
|
|
// the FlagSet, defaults to an empty string (the handler short-circuits on the
|
|
// empty value, so the flag is opt-in), and can be set via the standard
|
|
// flag parser so the long name reaches the dispatch layer.
|
|
func TestShowMetadataFlagRegistered(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
fs := newTestFlagSet("goget-test-show-metadata")
|
|
flags := defineFlags(fs, cfg)
|
|
|
|
if flags.ShowMetadata == nil {
|
|
t.Fatal("ShowMetadata flag was not registered on the FlagSet")
|
|
}
|
|
if *flags.ShowMetadata != "" {
|
|
t.Errorf("ShowMetadata default = %q, want empty (opt-in)", *flags.ShowMetadata)
|
|
}
|
|
|
|
fs2 := newTestFlagSet("goget-test-show-metadata-2")
|
|
flags2 := defineFlags(fs2, cfg)
|
|
if err := fs2.Parse([]string{"--show-metadata=/tmp/file.zip"}); err != nil {
|
|
t.Fatalf("Parse --show-metadata: %v", err)
|
|
}
|
|
if want := "/tmp/file.zip"; *flags2.ShowMetadata != want {
|
|
t.Errorf("ShowMetadata after parse = %q, want %q", *flags2.ShowMetadata, want)
|
|
}
|
|
}
|
|
|
|
// TestSafeMetadataURL ensures credentials embedded in resume metadata URLs
|
|
// are masked before the value is shown on the terminal. The .goget.meta
|
|
// sidecar may contain auth tokens in the query string; printing them raw
|
|
// during a debug session would defeat the 0600 file permissions.
|
|
func TestSafeMetadataURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"no credentials", "https://example.com/file.zip", "https://example.com/file.zip"},
|
|
{"with password", "https://user:secret@example.com/file.zip", "https://user:xxxxx@example.com/file.zip"},
|
|
{"with token query", "https://example.com/file.zip?sig=abc", "https://example.com/file.zip?sig=abc"},
|
|
{"unparsable passthrough", "://broken", "://broken"},
|
|
{"username only", "https://user@example.com/file.zip", "https://user@example.com/file.zip"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := safeMetadataURL(tc.in); got != tc.want {
|
|
t.Errorf("safeMetadataURL(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPercent guards the displayed progress math in --show-metadata: a zero
|
|
// or negative total (e.g. server did not advertise Content-Length) must not
|
|
// produce a confusing "Inf%" or "NaN%" reading.
|
|
func TestPercent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value int64
|
|
total int64
|
|
wantMin float64
|
|
wantMax float64
|
|
}{
|
|
{"zero total", 100, 0, 0, 0},
|
|
{"negative total", 100, -1, 0, 0},
|
|
{"full", 1000, 1000, 100, 100},
|
|
{"half", 500, 1000, 50, 50},
|
|
{"quarter", 250, 1000, 25, 25},
|
|
{"empty value", 0, 1000, 0, 0},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := percent(tc.value, tc.total)
|
|
if got < tc.wantMin || got > tc.wantMax {
|
|
t.Errorf("percent(%d, %d) = %v, want in [%v, %v]", tc.value, tc.total, got, tc.wantMin, tc.wantMax)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// captureStdoutStderr swaps os.Stdout and os.Stderr for pipes and returns a
|
|
// drain function that closes the write ends, copies everything written
|
|
// through the pipes into a buffer, and restores the original os.Stdout /
|
|
// os.Stderr. Callers run the code under test, then invoke drain (typically
|
|
// via defer) and read the returned string to assert against the rendered
|
|
// output. Synchronous drain is intentional — every entry point we exercise
|
|
// is well-bounded and we want deterministic test output.
|
|
func captureStdoutStderr(t *testing.T) func() string {
|
|
t.Helper()
|
|
|
|
origStdout, origStderr := os.Stdout, os.Stderr
|
|
stdoutR, stdoutW, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("stdout pipe: %v", err)
|
|
}
|
|
stderrR, stderrW, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("stderr pipe: %v", err)
|
|
}
|
|
os.Stdout = stdoutW
|
|
os.Stderr = stderrW
|
|
|
|
var buf strings.Builder
|
|
return func() string {
|
|
_ = stdoutW.Close()
|
|
_ = stderrW.Close()
|
|
_, _ = io.Copy(&buf, stdoutR)
|
|
_, _ = io.Copy(&buf, stderrR)
|
|
_ = stdoutR.Close()
|
|
_ = stderrR.Close()
|
|
os.Stdout = origStdout
|
|
os.Stderr = origStderr
|
|
return buf.String()
|
|
}
|
|
}
|
|
|
|
// TestHandleShowMetadata covers the four code paths of --show-metadata: a
|
|
// path pointing at the output file (the handler appends .goget.meta), a path
|
|
// pointing directly at the sidecar, a missing file (exit 1), and the empty
|
|
// argument guard. Each subtest also captures stdout + stderr via a pipe so
|
|
// the rendered summary is checked for the fields the user actually sees.
|
|
func TestHandleShowMetadata(t *testing.T) {
|
|
dir := t.TempDir()
|
|
meta := output.NewResumeMetadata(
|
|
"https://user:secret@example.com/large.bin",
|
|
"etag-abc",
|
|
"Thu, 01 Jan 2026 00:00:00 GMT",
|
|
4096, 16384,
|
|
)
|
|
outputPath := filepath.Join(dir, "large.bin")
|
|
if err := meta.Save(outputPath); err != nil {
|
|
t.Fatalf("seed save: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
pathArg string
|
|
asJSON bool
|
|
wantExit int
|
|
wantInOut []string
|
|
wantNotOut []string
|
|
}{
|
|
{
|
|
name: "output path appends suffix",
|
|
pathArg: outputPath,
|
|
asJSON: false,
|
|
wantExit: 0,
|
|
wantInOut: []string{"URL: https://user:xxxxx@example.com/large.bin", "ETag: etag-abc", "Last-Modified: Thu, 01 Jan 2026", "Progress: 25.0%", "Version: 1.0"},
|
|
// Raw secret must not leak through the password-masking helper.
|
|
wantNotOut: []string{"secret"},
|
|
},
|
|
{
|
|
name: "direct sidecar path",
|
|
pathArg: outputPath + output.ResumeMetaFileSuffix,
|
|
asJSON: false,
|
|
wantExit: 0,
|
|
wantInOut: []string{"Path: " + outputPath + output.ResumeMetaFileSuffix, "Downloaded: 4.1 KB (4096 bytes)"},
|
|
},
|
|
{
|
|
name: "json mode prints raw bytes",
|
|
pathArg: outputPath,
|
|
asJSON: true,
|
|
wantExit: 0,
|
|
wantInOut: []string{`"etag": "etag-abc"`, `"downloaded": 4096`, `"total": 16384`},
|
|
// Raw bytes contain the password — that's intentional, the user
|
|
// opted into JSON mode and the file is chmod 0600.
|
|
wantNotOut: nil,
|
|
},
|
|
{
|
|
name: "missing file returns 1",
|
|
pathArg: filepath.Join(dir, "never-downloaded.bin"),
|
|
asJSON: false,
|
|
wantExit: 1,
|
|
wantInOut: []string{"no resume metadata found at"},
|
|
},
|
|
{
|
|
name: "empty argument returns 1",
|
|
pathArg: "",
|
|
asJSON: false,
|
|
wantExit: 1,
|
|
wantInOut: []string{"--show-metadata requires a path"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
drain := captureStdoutStderr(t)
|
|
if got := handleShowMetadata(tc.pathArg, tc.asJSON); got != tc.wantExit {
|
|
t.Errorf("handleShowMetadata exit = %d, want %d", got, tc.wantExit)
|
|
}
|
|
got := drain()
|
|
for _, want := range tc.wantInOut {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("output missing %q\nfull output:\n%s", want, got)
|
|
}
|
|
}
|
|
for _, banned := range tc.wantNotOut {
|
|
if strings.Contains(got, banned) {
|
|
t.Errorf("output must not contain %q\nfull output:\n%s", banned, got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleShowMetadataCorruptJSON ensures a malformed .goget.meta sidecar
|
|
// does not produce a silent success. The user must see the raw bytes so they
|
|
// can diagnose why --resume later fails to parse the same file.
|
|
func TestHandleShowMetadataCorruptJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
outputPath := filepath.Join(dir, "broken.bin")
|
|
metaPath := outputPath + output.ResumeMetaFileSuffix
|
|
if err := os.WriteFile(metaPath, []byte("{not valid json"), 0600); err != nil {
|
|
t.Fatalf("seed write: %v", err)
|
|
}
|
|
|
|
drain := captureStdoutStderr(t)
|
|
if got := handleShowMetadata(outputPath, false); got != 0 {
|
|
t.Errorf("handleShowMetadata exit = %d, want 0 (still attempt to read)", got)
|
|
}
|
|
out := drain()
|
|
if !strings.Contains(out, "{not valid json") {
|
|
t.Errorf("expected raw metadata in output, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "not valid JSON") {
|
|
t.Errorf("expected warning about corrupt JSON, got: %s", out)
|
|
}
|
|
}
|