Comments (and one test comment) now describe the rules on their own terms instead of by comparison to another downloader. No behavior change.
300 lines
11 KiB
Go
300 lines
11 KiB
Go
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))
|
|
}
|
|
}
|
|
|
|
// TestHTTPConnDefault locks the parallelism default: a bare `got URL` must use
|
|
// several connections, not one. With -x unset, --split drives the per-host
|
|
// connection count (capped at the -x ceiling of 16); an explicit -x is honored
|
|
// verbatim, including -x1 for a single connection.
|
|
func TestHTTPConnDefault(t *testing.T) {
|
|
cfg := func(args ...string) httpdl.Config {
|
|
res, err := cli.Parse(append([]string{"--no-conf"}, args...))
|
|
if err != nil {
|
|
t.Fatalf("parse %v: %v", args, err)
|
|
}
|
|
return httpConfig(res.Options, true, nil)
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want int
|
|
}{
|
|
{"bare URL uses split default", []string{"http://a/x"}, 5},
|
|
{"-s16 means 16", []string{"-s16", "http://a/x"}, 16},
|
|
{"-s100 capped at 16 per host", []string{"-s100", "http://a/x"}, 16},
|
|
{"explicit -x4 honored", []string{"-x4", "http://a/x"}, 4},
|
|
{"explicit -x1 stays single", []string{"-x1", "http://a/x"}, 1},
|
|
}
|
|
for _, tc := range tests {
|
|
if c := cfg(tc.args...); c.MaxConnPerServer != tc.want {
|
|
t.Errorf("%s: MaxConnPerServer = %d, want %d", tc.name, c.MaxConnPerServer, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|