Files
got/main_test.go
2026-06-19 12:32:14 +09:00

200 lines
7.0 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"reflect"
"syscall"
"testing"
"github.com/hanbok/got/cli"
"github.com/hanbok/got/download"
"github.com/hanbok/got/httpdl"
)
// fakeDL is a stand-in Download whose status we control.
type fakeDL struct {
name string
status download.Status
}
func (f fakeDL) Name() string { return f.name }
func (f fakeDL) Run(ctx context.Context) error { return nil }
func (f fakeDL) Stat() download.Stat { return download.Stat{Name: f.name, Status: f.status} }
func TestUnfinishedSources(t *testing.T) {
jobs := []job{
{source: "http://a/done", dl: fakeDL{status: download.Complete}},
{source: "http://b/failed", dl: fakeDL{status: download.Errored}},
{source: "magnet:c", dl: fakeDL{status: download.Active}},
{source: "http://d/done", dl: fakeDL{status: download.Complete}},
}
got := unfinishedSources(jobs)
want := []string{"http://b/failed", "magnet:c"}
if !reflect.DeepEqual(got, want) {
t.Errorf("unfinishedSources = %v, want %v", got, want)
}
}
func TestParseSelect(t *testing.T) {
tests := []struct {
in string
want map[int]bool
}{
{"", nil},
{"1", map[int]bool{1: true}},
{"1,3", map[int]bool{1: true, 3: true}},
{"3-5", map[int]bool{3: true, 4: true, 5: true}},
{"1,3-5,8", map[int]bool{1: true, 3: true, 4: true, 5: true, 8: true}},
}
for _, tc := range tests {
if got := parseSelect(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("parseSelect(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestExitCode(t *testing.T) {
dnsErr := &net.DNSError{Err: "no such host", Name: "nope.invalid", IsNotFound: true}
refused := &net.OpError{Op: "dial", Net: "tcp", Err: os.NewSyscallError("connect", syscall.ECONNREFUSED)}
tests := []struct {
name string
err error
want int
}{
{"idle timeout", errors.New("idle timeout: no data received"), 2},
{"metadata timed out", errors.New("timed out fetching metadata after 3s"), 2},
{"deadline", context.DeadlineExceeded, 2},
{"file exists", fmt.Errorf("/tmp/x: %w (use --allow-overwrite or -c)", httpdl.ErrOutputExists), 13},
{"dns typed", dnsErr, 19},
{"dns wrapped through retries", fmt.Errorf("http://x: %w (after 5 tries)", dnsErr), 19},
{"dns by message", errors.New("dial tcp: lookup nope.invalid: no such host"), 19},
{"refused typed", refused, 6},
{"refused wrapped through retries", fmt.Errorf("http://x: %w (after 5 tries)", refused), 6},
{"refused by message", errors.New("dial tcp 127.0.0.1:1: connect: connection refused"), 6},
{"not found", fmt.Errorf("%w: http://x/y: 404 Not Found", httpdl.ErrNotFound), 3},
{"too slow", fmt.Errorf("http://x: %w (<= %d bytes/sec)", httpdl.ErrTooSlow, 1024), 5},
{"checksum", fmt.Errorf("/tmp/x: %w (want a, got b)", httpdl.ErrChecksum), 32},
{"duplicate", fmt.Errorf("%w: same torrent as y", errDuplicate), 12},
{"unknown", errors.New("disk full"), 1},
}
for _, tc := range tests {
if got := exitCode(tc.err); got != tc.want {
t.Errorf("%s: exitCode = %d, want %d", tc.name, got, tc.want)
}
}
// The whole point: a local DNS failure and a dead remote must not look alike.
if dns, nw := exitCode(dnsErr), exitCode(refused); dns == nw {
t.Errorf("DNS (%d) and refused (%d) must map to different codes", dns, nw)
}
}
// TestDedupeTorrents guards the same-infohash hazard: the shared client does not
// refcount torrents, so two sources naming one torrent must collapse to a single
// download or the first to finish would Drop it out from under the other.
func TestDedupeTorrents(t *testing.T) {
const h = "c892b8de86be44003d92041a16787911c139ab1e"
a := "magnet:?xt=urn:btih:" + h + "&dn=one"
b := "magnet:?xt=urn:btih:" + h + "&dn=two" // same infohash as a
c := "magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=other"
web := "http://example.com/file.iso"
dup := dedupeTorrents([]string{a, web, b, c}, true)
// b is the only duplicate (of a); a, web and c are not duplicates.
if len(dup) != 1 || dup[b] != a {
t.Errorf("dup = %v, want {b: a}", dup)
}
}
// TestGatherURIsGrouping checks the command-line grouping: plain http URIs
// become one mirrored download by default, -Z splits them, and torrents/magnets
// stay separate.
func TestGatherURIsGrouping(t *testing.T) {
parse := func(args ...string) []target {
res, err := cli.Parse(append([]string{"--no-conf"}, args...))
if err != nil {
t.Fatalf("parse %v: %v", args, err)
}
return gatherURIs(res.Options, res.URIs)
}
if ts := parse("http://a/x", "http://b/x"); len(ts) != 1 || len(ts[0].uris) != 2 {
t.Errorf("two http args: got %v, want one 2-mirror group", ts)
}
if ts := parse("-Z", "http://a/x", "http://b/x"); len(ts) != 2 {
t.Errorf("-Z: got %d targets, want 2 separate downloads", len(ts))
}
if ts := parse("http://a/x", "magnet:?xt=urn:btih:abc"); len(ts) != 2 {
t.Errorf("http + magnet: got %d targets, want 2 (magnet never mirrors)", len(ts))
}
}
// TestReadInputFileTAB checks the input-file grouping: TAB-separated URIs on
// a line are mirrors of one download; a plain line is one download.
func TestReadInputFileTAB(t *testing.T) {
p := filepath.Join(t.TempDir(), "in.txt")
if err := os.WriteFile(p, []byte("# comment\nhttp://a/x\thttp://b/x\nhttp://c/y\n\n"), 0o644); err != nil {
t.Fatal(err)
}
ts := readInputFile(p)
if len(ts) != 2 {
t.Fatalf("got %d targets, want 2: %v", len(ts), ts)
}
if want := []string{"http://a/x", "http://b/x"}; !reflect.DeepEqual(ts[0].uris, want) {
t.Errorf("line 1 mirrors = %v, want %v", ts[0].uris, want)
}
if want := []string{"http://c/y"}; !reflect.DeepEqual(ts[1].uris, want) {
t.Errorf("line 2 = %v, want %v", ts[1].uris, want)
}
}
func TestReportExit(t *testing.T) {
canceled := download.Result{Name: "a", Err: context.Canceled}
done := download.Result{Name: "b", Err: nil}
timeout := download.Result{Name: "c", Err: errors.New("idle timeout")}
tests := []struct {
name string
results []download.Result
want int
}{
{"all ok", []download.Result{done}, 0},
{"cancelled with a completion", []download.Result{done, canceled}, 7},
{"cancelled only", []download.Result{canceled}, 1},
{"real error wins over cancel", []download.Result{done, canceled, timeout}, 2},
}
for _, tc := range tests {
if got := report(tc.results); got != tc.want {
t.Errorf("%s: report = %d, want %d", tc.name, got, tc.want)
}
}
}
func TestSaveSessionAtomic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.txt")
jobs := []job{
{source: "http://a/done", dl: fakeDL{status: download.Complete}},
{source: "magnet:c", dl: fakeDL{status: download.Active}},
}
if err := saveSession(path, jobs); err != nil {
t.Fatalf("saveSession: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if got, want := string(data), "magnet:c\n"; got != want {
t.Errorf("session = %q, want %q", got, want)
}
// No temp files should be left behind by an atomic write.
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Errorf("expected only the session file, got %d entries", len(entries))
}
}