package main import ( "context" "errors" "fmt" "net" "os" "path/filepath" "reflect" "syscall" "testing" "github.com/anacrolix/torrent/bencode" "github.com/anacrolix/torrent/metainfo" "github.com/hanbok/got/bt" "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", fmt.Errorf("segment 0: %w (after 1m0s)", httpdl.ErrTimeout), 2}, {"metadata timed out", fmt.Errorf("timed out fetching metadata after 3s: %w", context.DeadlineExceeded), 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}, {"refused typed", refused, 6}, {"refused wrapped through retries", fmt.Errorf("http://x: %w (after 5 tries)", 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, and .torrent URLs 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)) } // Distinct .torrent URLs are separate downloads, not mirrors of one file // (follow-torrent defaults on, so each is fetched then followed). if ts := parse("http://a/x.torrent", "http://b/y.torrent", "http://c/z.torrent"); len(ts) != 3 { t.Errorf(".torrent URLs: got %d targets, want 3 separate downloads", len(ts)) } // A plain http URL still mirror-groups even alongside a .torrent URL: one // http group of 1 + one torrent target = 2. if ts := parse("http://a/x", "http://b/y.torrent"); len(ts) != 2 { t.Errorf("http + .torrent: got %d targets, want 2", 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) } } // writeTorrent creates a .torrent file at dir/name whose info dict is built from // a file of the given content and internal name. Two calls with the same // internalName and content produce the same infohash regardless of the output // filename, so the test can make two .torrent files that name one torrent. func writeTorrent(t *testing.T, dir, name, internalName string, content []byte) string { t.Helper() data := filepath.Join(dir, internalName) if err := os.WriteFile(data, content, 0o644); err != nil { t.Fatal(err) } info := metainfo.Info{PieceLength: 32 * 1024} if err := info.BuildFromFilePath(data); err != nil { t.Fatal(err) } b, err := bencode.Marshal(info) if err != nil { t.Fatal(err) } tp := filepath.Join(dir, name) f, err := os.Create(tp) if err != nil { t.Fatal(err) } defer f.Close() if err := (&metainfo.MetaInfo{InfoBytes: b}).Write(f); err != nil { t.Fatal(err) } return tp } // TestFollowUpInfoHashDedup covers the fix for two fetched .torrent files that // name the same torrent: the second must be flagged a duplicate so followUps // fails it rather than letting both share — and Drop — one torrent on the client. func TestFollowUpInfoHashDedup(t *testing.T) { dir := t.TempDir() a := writeTorrent(t, dir, "a.torrent", "same", []byte("AAAAAAAA")) b := writeTorrent(t, dir, "b.torrent", "same", []byte("AAAAAAAA")) // same infohash as a c := writeTorrent(t, dir, "c.torrent", "diff", []byte("BBBBBBBB")) // a distinct torrent // Sanity-check the fixture: a and b really share an infohash, c differs. ha, _ := bt.SourceInfoHash(a, true) hb, _ := bt.SourceInfoHash(b, true) hc, _ := bt.SourceInfoHash(c, true) if ha != hb || ha == hc { t.Fatalf("fixture: want a==b!=c infohashes, got a=%v b=%v c=%v", ha, hb, hc) } seen := map[metainfo.Hash]string{} if first, dup := markInfoHash(seen, a); dup { t.Fatalf("a is the first occurrence, wrongly flagged a duplicate of %s", first) } if first, dup := markInfoHash(seen, b); !dup || first != a { t.Errorf("b should duplicate a: got first=%q dup=%v, want %q true", first, dup, a) } if _, dup := markInfoHash(seen, c); dup { t.Errorf("c is a distinct torrent, wrongly flagged a duplicate") } } 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: httpdl.ErrTimeout} 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)) } }