a .torrent fetched over http is added to the shared client in a second pass (followUps); two that name the same torrent would share one torrent on the refcount-less client, and the first to finish would Drop it out from under the other, stranding it at 0%. dedupe the followed files by infohash, the guard build() already applies to pass-1 sources.
272 lines
9.8 KiB
Go
272 lines
9.8 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", 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, 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: 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))
|
|
}
|
|
}
|