Files
got/main_test.go
Hojun-Cho b98c36acb7 main: fold the two infohash-dedup paths into one
dedupeTorrents and markInfoHash were the same map-of-infohash logic, one
batch and one incremental, with the same hazard explained in prose four
times. Both now go through a single seenInfoHash helper.
2026-06-22 08:27:34 +09:00

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 := seenInfoHash(seen, a, true); dup {
t.Fatalf("a is the first occurrence, wrongly flagged a duplicate of %s", first)
}
if first, dup := seenInfoHash(seen, b, true); !dup || first != a {
t.Errorf("b should duplicate a: got first=%q dup=%v, want %q true", first, dup, a)
}
if _, dup := seenInfoHash(seen, c, true); 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))
}
}