Files
zpan/cmd/internal/engine/engine_test.go
T
saltbo fc6ee38e51 fix(downloader): request uploadLength/uploadSpeed/connections/numSeeders from aria2 tellStatus
aria2's tellStatus only returns the keys it's asked for, but aria2StatusKeys
omitted uploadLength, uploadSpeed, connections and numSeeders — while
aria2Detail reads status.UploadLength/UploadSpeed/Connections/NumSeeders. So the
runtime reported them all as 0: during seeding the Overview showed 0 B/s upload
and 0 B uploaded, even though the Peers list had per-peer upload activity (that
comes from a separate getPeers call). Confirmed on live aria2: the seeding
torrent reported uploadSpeed ~3.26MB/s and uploadLength ~2.5GB when those keys
were requested. Adds the missing keys + a guard test.
2026-06-19 15:10:43 -04:00

973 lines
30 KiB
Go

package engine
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Braurbeki/arigo"
qbittorrent "github.com/autobrr/go-qbittorrent"
"github.com/cenkalti/rpc2"
"github.com/saltbo/zpan/internal/client"
)
func downloadTask(id, sourceType, sourceURI string) client.DownloadTask {
return client.DownloadTask{
ID: id,
Spec: client.DownloadTaskSpec{
Source: client.DownloadTaskSource{Type: sourceType, URI: sourceURI},
Destination: client.DownloadTaskDestination{},
Labels: client.DownloadTaskLabels{Tags: []string{}},
},
Status: client.DownloadTaskStatus{},
}
}
func downloadTaskWithName(id, sourceType, sourceURI, name string) client.DownloadTask {
task := downloadTask(id, sourceType, sourceURI)
task.Spec.Destination.Name = name
return task
}
func completedDownloadTask(id, sourceType, sourceURI string, size int64) client.DownloadTask {
task := downloadTask(id, sourceType, sourceURI)
task.Status.Progress.Download = client.DownloadTaskTransferProgress{Bytes: size, TotalBytes: &size}
return task
}
func TestHTTPDownload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "11")
_, _ = w.Write([]byte("hello world"))
}))
defer server.Close()
dir := t.TempDir()
progressCalls := 0
var lastDetail *client.DownloadTaskRuntime
result, err := (HTTP{Dir: dir}).Download(
context.Background(),
downloadTask("task-1", "http", server.URL+"/file.txt"),
func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskRuntime) error {
progressCalls++
lastDetail = detail
if downloaded < 0 {
t.Fatalf("downloaded bytes must not be negative")
}
return nil
},
)
if err != nil {
t.Fatal(err)
}
if result.Name != "file.txt" {
t.Fatalf("expected file.txt, got %s", result.Name)
}
if result.Size != 11 {
t.Fatalf("expected size 11, got %d", result.Size)
}
data, err := os.ReadFile(result.Path)
if err != nil {
t.Fatal(err)
}
if string(data) != "hello world" {
t.Fatalf("unexpected file content: %q", string(data))
}
if progressCalls == 0 {
t.Fatal("expected progress callback")
}
if lastDetail == nil || lastDetail.Engine != "builtin" {
t.Fatalf("expected builtin progress detail, got %#v", lastDetail)
}
}
func TestHTTPRejectsMagnet(t *testing.T) {
_, err := (HTTP{Dir: t.TempDir()}).Download(
context.Background(),
downloadTask("task-1", "magnet", "magnet:?xt=urn:btih:abc"),
func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskRuntime) error { return nil },
)
if err == nil {
t.Fatal("expected magnet to be rejected by HTTP engine")
}
}
func TestAria2DelegatesHTTPToBuiltin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "11")
_, _ = w.Write([]byte("hello world"))
}))
defer server.Close()
result, err := (Aria2{
URL: "ws://127.0.0.1:1/jsonrpc",
Dir: t.TempDir(),
}).Download(
context.Background(),
downloadTask("task-1", "http", server.URL+"/file.txt"),
func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskRuntime) error {
if detail != nil && detail.Engine != "builtin" {
t.Fatalf("expected builtin HTTP runtime detail, got %#v", detail)
}
return nil
},
)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(result.Path)
if err != nil {
t.Fatal(err)
}
if string(data) != "hello world" {
t.Fatalf("unexpected file content: %q", string(data))
}
}
func TestAria2StartArgsForceSaveCompletedSeeds(t *testing.T) {
stateDir := t.TempDir()
args, err := (Aria2{Dir: t.TempDir(), StateDir: stateDir, ListenPort: 51413}).startArgs("6800")
if err != nil {
t.Fatal(err)
}
joined := strings.Join(args, "\n")
for _, expected := range []string{
"--input-file=" + filepath.Join(stateDir, "aria2.session"),
"--save-session=" + filepath.Join(stateDir, "aria2.session"),
"--save-session-interval=30",
"--force-save=true",
"--listen-port=51413",
} {
if !strings.Contains(joined, expected) {
t.Fatalf("expected aria2 args to contain %q, got %v", expected, args)
}
}
}
func TestAria2StartArgsSetsMaxConcurrentDownloads(t *testing.T) {
args, err := (Aria2{Dir: t.TempDir(), MaxConcurrentDownloads: 25}).startArgs("6800")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(args, "\n"), "--max-concurrent-downloads=25") {
t.Fatalf("expected aria2 args to set max-concurrent-downloads, got %v", args)
}
zeroArgs, err := (Aria2{Dir: t.TempDir()}).startArgs("6800")
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.Join(zeroArgs, "\n"), "--max-concurrent-downloads") {
t.Fatalf("expected no max-concurrent-downloads flag when unset, got %v", zeroArgs)
}
}
func TestAria2StatusKeysCoverReportedFields(t *testing.T) {
// aria2's tellStatus only returns the keys it's asked for. aria2Detail reads
// these fields, so they must be requested or the runtime reports zeros
// (e.g. seeding upload speed/total showing 0 while peers upload).
required := []string{
"status", "totalLength", "completedLength", "downloadSpeed",
"uploadLength", "uploadSpeed", "connections", "numSeeders",
}
have := map[string]bool{}
for _, key := range aria2StatusKeys {
have[key] = true
}
for _, key := range required {
if !have[key] {
t.Fatalf("aria2StatusKeys missing %q — tellStatus won't return it, so aria2Detail reports 0", key)
}
}
}
func TestAria2SeedTimeMinutes(t *testing.T) {
if got := aria2SeedTimeMinutes(time.Hour); got != 60 {
t.Fatalf("expected 1h to map to 60 minutes, got %d", got)
}
if got := aria2SeedTimeMinutes(0); got != aria2SeedForeverMinutes {
t.Fatalf("expected zero duration to seed indefinitely, got %d", got)
}
if got := aria2SeedTimeMinutes(30 * time.Second); got != 1 {
t.Fatalf("expected sub-minute duration to round up to 1, got %d", got)
}
}
func TestQBittorrentStartArgsWritesManagedListenPort(t *testing.T) {
stateDir := t.TempDir()
downloadDir := t.TempDir()
args, err := (QBittorrent{
URL: "http://127.0.0.1:8080",
Dir: downloadDir,
StateDir: stateDir,
ListenPort: 51413,
}).startArgs("/usr/bin/qbittorrent-nox")
if err != nil {
t.Fatal(err)
}
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--profile="+filepath.Join(stateDir, "qbittorrent")) {
t.Fatalf("expected qBittorrent args to contain managed profile, got %v", args)
}
if !strings.Contains(joined, "--webui-port=8080") {
t.Fatalf("expected qBittorrent args to contain webui port, got %v", args)
}
content, err := os.ReadFile(filepath.Join(stateDir, "qbittorrent", "qBittorrent", "config", "qBittorrent.conf"))
if err != nil {
t.Fatal(err)
}
text := string(content)
if !strings.Contains(text, `Connection\PortRangeMin=51413`) {
t.Fatalf("expected custom listen port, got:\n%s", text)
}
if !strings.Contains(text, `Downloads\SavePath=`+filepath.ToSlash(downloadDir)+`/`) {
t.Fatalf("expected custom download dir, got:\n%s", text)
}
}
func TestAria2ResetOperations(t *testing.T) {
tests := []struct {
name string
status arigo.DownloadStatus
wantRemoveActive bool
wantRemoveResult bool
}{
{name: "active", status: arigo.StatusActive, wantRemoveActive: true, wantRemoveResult: true},
{name: "waiting", status: arigo.StatusWaiting, wantRemoveActive: true, wantRemoveResult: true},
{name: "paused", status: arigo.StatusPaused, wantRemoveActive: true, wantRemoveResult: true},
{name: "completed", status: arigo.StatusCompleted, wantRemoveActive: false, wantRemoveResult: true},
{name: "error", status: arigo.StatusError, wantRemoveActive: false, wantRemoveResult: true},
{name: "removed", status: arigo.StatusRemoved, wantRemoveActive: false, wantRemoveResult: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
removeActive, removeResult := aria2ResetOperations(arigo.Status{Status: tt.status})
if removeActive != tt.wantRemoveActive || removeResult != tt.wantRemoveResult {
t.Fatalf(
"expected removeActive=%v removeResult=%v, got removeActive=%v removeResult=%v",
tt.wantRemoveActive,
tt.wantRemoveResult,
removeActive,
removeResult,
)
}
})
}
}
func TestIsAria2DownloadNotFound(t *testing.T) {
if !isAria2DownloadNotFound(errors.New("Active Download not found for GID#b384ccaa7eae88da")) {
t.Fatal("expected aria2 active download not found to be ignored during reset")
}
if !isAria2DownloadNotFound(errors.New("Download result not found for GID#b384ccaa7eae88da")) {
t.Fatal("expected aria2 download result not found to be ignored during reset")
}
if isAria2DownloadNotFound(errors.New("aria2 download ended with status error")) {
t.Fatal("expected ordinary aria2 download errors to stay visible")
}
}
func TestIsAria2GIDNotFound(t *testing.T) {
if !isAria2GIDNotFound(errors.New("GID 8bddd19e07ad6dc3 is not found")) {
t.Fatal("expected tellStatus GID-not-found to trigger re-discovery")
}
if !isAria2GIDNotFound(errors.New("Active Download not found for GID#b384ccaa7eae88da")) {
t.Fatal("expected aria2 active download not found to trigger re-discovery")
}
if isAria2GIDNotFound(errors.New("aria2 download ended with status error")) {
t.Fatal("expected ordinary aria2 download errors to stay visible")
}
}
func TestHTTPDownloadResumesExistingFile(t *testing.T) {
var rangeHeader string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rangeHeader = r.Header.Get("Range")
if rangeHeader != "bytes=5-" {
t.Fatalf("expected resume range bytes=5-, got %q", rangeHeader)
}
w.Header().Set("Content-Length", "6")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte(" world"))
}))
defer server.Close()
dir := t.TempDir()
taskDir := filepath.Join(dir, "task-1")
if err := os.MkdirAll(taskDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(taskDir, "file.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
result, err := (HTTP{Dir: dir}).Download(
context.Background(),
downloadTask("task-1", "http", server.URL+"/file.txt"),
func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskRuntime) error { return nil },
)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(result.Path)
if err != nil {
t.Fatal(err)
}
if string(data) != "hello world" {
t.Fatalf("expected resumed content, got %q", string(data))
}
if result.Size != 11 {
t.Fatalf("expected size 11, got %d", result.Size)
}
}
func TestHTTPInspectTaskUsesCompletedCheckpoint(t *testing.T) {
dir := t.TempDir()
taskDir := filepath.Join(dir, "task-1")
if err := os.MkdirAll(taskDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "payload.bin"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
total := int64(7)
snapshot, found, err := (HTTP{Dir: dir}).InspectTask(
context.Background(),
completedDownloadTask("task-1", "http", "https://example.com/payload.bin", total),
)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("expected completed http checkpoint to be found")
}
if snapshot.State != TaskStateCompleted {
t.Fatalf("expected completed state, got %#v", snapshot)
}
if snapshot.Result == nil || snapshot.Result.Path != filepath.Join(taskDir, "payload.bin") || snapshot.Result.Size != 7 {
t.Fatalf("unexpected completed result: %#v", snapshot.Result)
}
}
func TestHTTPInspectTaskDoesNotTrustLocalFileWithoutCompletedCheckpoint(t *testing.T) {
dir := t.TempDir()
taskDir := filepath.Join(dir, "task-1")
if err := os.MkdirAll(taskDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "payload.bin"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
snapshot, found, err := (HTTP{Dir: dir}).InspectTask(
context.Background(),
downloadTask("task-1", "http", "https://example.com/payload.bin"),
)
if err != nil {
t.Fatal(err)
}
if found {
t.Fatalf("expected local file without checkpoint not to be trusted, got %#v", snapshot)
}
}
func TestHTTPInspectTaskRejectsSizeMismatch(t *testing.T) {
dir := t.TempDir()
taskDir := filepath.Join(dir, "task-1")
if err := os.MkdirAll(taskDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "payload.bin"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
total := int64(8)
_, _, err := (HTTP{Dir: dir}).InspectTask(
context.Background(),
completedDownloadTask("task-1", "http", "https://example.com/payload.bin", total),
)
if err == nil || !strings.Contains(err.Error(), "size mismatch") {
t.Fatalf("expected size mismatch error, got %v", err)
}
}
func TestAria2StatusKeysRequestBittorrentPayload(t *testing.T) {
keys := strings.Join(aria2StatusKeys, ",")
if !strings.Contains(keys, "bittorrent") {
t.Fatalf("expected aria2 status keys to request bittorrent payload, got %v", aria2StatusKeys)
}
if strings.Contains(keys, "bitTorrent") {
t.Fatalf("aria2 status key is case-sensitive; use bittorrent, got %v", aria2StatusKeys)
}
}
func TestQBittorrentCheckUsesWebAPIVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/app/version" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
_, _ = w.Write([]byte("5.0.0"))
}))
defer server.Close()
if err := (QBittorrent{URL: server.URL, Dir: t.TempDir()}).Check(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestQBittorrentHTTPDelegatesToBuiltin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "11")
_, _ = w.Write([]byte("hello qbit!"))
}))
defer server.Close()
result, err := (QBittorrent{URL: "http://127.0.0.1:1", Dir: t.TempDir()}).Download(
context.Background(),
downloadTask("task-1", "http", server.URL+"/file.txt"),
func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskRuntime) error { return nil },
)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(result.Path)
if err != nil {
t.Fatal(err)
}
if string(data) != "hello qbit!" {
t.Fatalf("unexpected file content: %q", string(data))
}
}
func TestQBittorrentAddOptionsPassThroughTaskClassification(t *testing.T) {
options := qbittorrentAddOptions(
func() client.DownloadTask {
task := downloadTaskWithName("task-1", "magnet", "magnet:?xt=urn:btih:abc", "fixture")
task.Spec.Labels.Category = "movies"
task.Spec.Labels.Tags = []string{"4k", "private"}
return task
}(),
"/tmp/zpan/task-1",
qbittorrentTrackingTag("task-1"),
)
if options["category"] != "movies" {
t.Fatalf("expected task category, got %q", options["category"])
}
if options["tags"] != "ztid=task-1,4k,private" {
t.Fatalf("expected tracking and task tags, got %q", options["tags"])
}
if options["rename"] != "fixture" {
t.Fatalf("expected rename option, got %q", options["rename"])
}
}
func TestQBittorrentAddOptionsDefaultsCategory(t *testing.T) {
options := qbittorrentAddOptions(
client.DownloadTask{ID: "task-1"},
"/tmp/zpan/task-1",
qbittorrentTrackingTag("task-1"),
)
if options["category"] != "zpan" {
t.Fatalf("expected default category, got %q", options["category"])
}
if options["tags"] != "ztid=task-1" {
t.Fatalf("expected tracking tag, got %q", options["tags"])
}
}
func TestQBittorrentAddOptionsIgnoresTorrentTaskName(t *testing.T) {
options := qbittorrentAddOptions(
downloadTaskWithName("task-1", "magnet", "magnet:?xt=urn:btih:abc", "movie.torrent"),
"/tmp/zpan/task-1",
qbittorrentTrackingTag("task-1"),
)
if _, ok := options["rename"]; ok {
t.Fatalf("expected torrent task name to be ignored, got rename=%q", options["rename"])
}
}
func TestIsAria2RPCDisconnected(t *testing.T) {
for _, err := range []error{
rpc2.ErrShutdown,
io.ErrClosedPipe,
errors.New("connection is shut down"),
} {
if !isAria2RPCDisconnected(err) {
t.Fatalf("expected %v to be treated as aria2 rpc disconnect", err)
}
}
if isAria2RPCDisconnected(errors.New("aria2 download ended with status error")) {
t.Fatal("expected ordinary aria2 error to stay non-transient")
}
}
func TestAria2TaskInfoHash(t *testing.T) {
const infoHash = "0546769f209ec059284b47f68659791a6f75ca8e"
if got := aria2TaskInfoHash(downloadTask("task-1", "magnet", "magnet:?xt=urn:btih:"+infoHash+"&dn=fixture")); got != infoHash {
t.Fatalf("expected magnet infohash %s, got %s", infoHash, got)
}
taskWithRuntime := downloadTask("task-1", "magnet", "magnet:?xt=urn:btih:abc")
taskWithRuntime.Status.Runtime = &client.DownloadTaskRuntime{
Torrent: &client.DownloadTaskTorrentRuntime{InfoHash: "0546769F209EC059284B47F68659791A6F75CA8E"},
}
if got := aria2TaskInfoHash(taskWithRuntime); got != infoHash {
t.Fatalf("expected detail infohash %s, got %s", infoHash, got)
}
if got := aria2TaskInfoHash(downloadTask("task-1", "http", "https://example.com/file")); got != "" {
t.Fatalf("expected no infohash for http task, got %s", got)
}
}
func TestAria2StatusMatchesTaskByInfoHash(t *testing.T) {
const infoHash = "0546769f209ec059284b47f68659791a6f75ca8e"
if !aria2StatusMatchesTask(arigo.Status{InfoHash: strings.ToUpper(infoHash)}, "/tmp/zpan/task-1", "taskgid", infoHash) {
t.Fatal("expected status to match by infohash")
}
if aria2StatusMatchesTask(arigo.Status{InfoHash: infoHash}, "/tmp/zpan/task-1", "taskgid", "") {
t.Fatal("expected empty requested infohash not to match")
}
}
func TestIsAria2DownloadCompleteTreatsActiveFullTorrentAsComplete(t *testing.T) {
status := arigo.Status{
Status: arigo.StatusActive,
TotalLength: 100,
CompletedLength: 100,
Files: []arigo.File{
{Path: "/tmp/zpan/task-1/movie.mkv", Length: 100, CompletedLength: 100},
},
}
if !isAria2DownloadComplete(status) {
t.Fatal("expected active full torrent to be treated as completed")
}
}
func TestSelectAria2SeedStatusPrefersCompletedPayloadOverMetadata(t *testing.T) {
infoHash := "f8f8044d5dfeef2719dcda6ce42dba1c4eb9ea22"
taskDir := filepath.Join(t.TempDir(), "task-1")
metadata := arigo.Status{
GID: "metadata-gid",
Status: arigo.StatusCompleted,
InfoHash: infoHash,
Dir: taskDir,
TotalLength: 17596,
CompletedLength: 17596,
Files: []arigo.File{{
Path: "[METADATA]",
Length: 17596,
CompletedLength: 17596,
Selected: true,
}},
}
payload := arigo.Status{
GID: "payload-gid",
Status: arigo.StatusActive,
InfoHash: infoHash,
Dir: taskDir,
TotalLength: 100,
CompletedLength: 100,
Files: []arigo.File{{
Path: filepath.Join(taskDir, "album", "track.flac"),
Length: 100,
CompletedLength: 100,
Selected: true,
}},
}
got, ok := selectAria2SeedStatus([]arigo.Status{metadata, payload}, SeedRef{InfoHash: infoHash}, taskDir)
if !ok {
t.Fatal("expected seed status")
}
if got.GID != "payload-gid" {
t.Fatalf("expected payload seed, got %s", got.GID)
}
}
func TestAria2TaskState(t *testing.T) {
if got := aria2TaskState(arigo.Status{
Status: arigo.StatusActive,
TotalLength: 100,
CompletedLength: 100,
Files: []arigo.File{{Path: "/tmp/zpan/task-1/file.bin", Length: 100, CompletedLength: 100}},
}); got != TaskStateCompleted {
t.Fatalf("expected active full torrent to be completed, got %s", got)
}
if got := aria2TaskState(arigo.Status{Status: arigo.StatusActive, TotalLength: 100, CompletedLength: 10}); got != TaskStateDownloading {
t.Fatalf("expected active partial torrent to be downloading, got %s", got)
}
if got := aria2TaskState(arigo.Status{Status: arigo.StatusError}); got != TaskStateFailed {
t.Fatalf("expected error torrent to be failed, got %s", got)
}
}
func TestQBittorrentTaskState(t *testing.T) {
if got := qbittorrentTaskState(qbittorrent.Torrent{
State: qbittorrent.TorrentState("stalledUP"),
Progress: 1,
AmountLeft: 0,
TotalSize: 100,
}); got != TaskStateCompleted {
t.Fatalf("expected seeding torrent to be completed, got %s", got)
}
if got := qbittorrentTaskState(qbittorrent.Torrent{
State: qbittorrent.TorrentState("downloading"),
Progress: 0.5,
AmountLeft: 50,
TotalSize: 100,
}); got != TaskStateDownloading {
t.Fatalf("expected partial torrent to be downloading, got %s", got)
}
if got := qbittorrentTaskState(qbittorrent.Torrent{
State: qbittorrent.TorrentState("missingFiles"),
Progress: 0.5,
AmountLeft: 50,
TotalSize: 100,
}); got != TaskStateFailed {
t.Fatalf("expected missing files torrent to be failed, got %s", got)
}
}
func TestQBittorrentDetailOmitsSeedingETA(t *testing.T) {
detail := qbittorrentDetail(context.Background(), nil, qbittorrent.Torrent{
State: qbittorrent.TorrentState("stalledUP"),
ETA: 3600,
Progress: 1,
AmountLeft: 0,
TotalSize: 100,
}, nil)
if detail.Phase != "seeding" {
t.Fatalf("expected seeding phase, got %s", detail.Phase)
}
if detail.ETASeconds != nil {
t.Fatalf("expected seeding detail without ETA, got %#v", detail.ETASeconds)
}
}
func TestIsAria2InfoHashAlreadyRegistered(t *testing.T) {
err := errors.New("InfoHash 0546769f209ec059284b47f68659791a6f75ca8e is already registered.")
if !isAria2InfoHashAlreadyRegistered(err) {
t.Fatal("expected aria2 infohash conflict to be attachable")
}
if isAria2InfoHashAlreadyRegistered(errors.New("aria2 download ended with status error")) {
t.Fatal("expected ordinary aria2 error not to be treated as an infohash conflict")
}
}
func TestAria2FilesReportsRelativeTorrentPaths(t *testing.T) {
taskDir := filepath.Join(t.TempDir(), "task-1")
files := aria2Files(taskDir, "album", []arigo.File{
{
Path: filepath.Join(taskDir, "album", "disc-1", "track.flac"),
Length: 100,
CompletedLength: 50,
Selected: true,
},
{
Path: filepath.Join(t.TempDir(), "outside.flac"),
Length: 10,
CompletedLength: 10,
Selected: true,
},
{
Path: "[METADATA]info",
Length: 1,
},
})
if len(files) != 2 {
t.Fatalf("expected two visible files, got %#v", files)
}
if files[0].Path != "disc-1/track.flac" {
t.Fatalf("expected torrent root to be stripped, got %s", files[0].Path)
}
if files[1].Path != "outside.flac" {
t.Fatalf("expected outside path to fall back to basename, got %s", files[1].Path)
}
}
func TestStripTorrentRoot(t *testing.T) {
cases := []struct {
name string
path string
torrentName string
want string
}{
{name: "nested torrent root", path: "Album/Disc 1/track.flac", torrentName: "Album", want: "Disc 1/track.flac"},
{name: "single file named like torrent", path: "Album", torrentName: "Album", want: "Album"},
{name: "different root", path: "Other/track.flac", torrentName: "Album", want: "Other/track.flac"},
{name: "empty torrent name", path: "Album/track.flac", torrentName: "", want: "Album/track.flac"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stripTorrentRoot(tc.path, tc.torrentName); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestAria2DetailIncludesPeers(t *testing.T) {
detail := aria2Detail(
arigo.Status{
TotalLength: 1000,
CompletedLength: 250,
DownloadSpeed: 200,
Connections: 44,
NumSeeders: 4,
BitTorrent: arigo.BitTorrentStatus{
AnnounceList: [][]string{{"udp://tracker.example:1337/announce"}},
Info: arigo.BitTorrentStatusInfo{Name: "fixture.torrent"},
},
},
[]arigo.Peer{
{IP: "192.0.2.10", Port: 6881, DownloadSpeed: 1024, UploadSpeed: 256, Seeder: true},
{IP: "192.0.2.11", Port: 6882, DownloadSpeed: 2048, UploadSpeed: 512, Seeder: false},
},
nil,
)
if detail.Torrent == nil || detail.Torrent.Peers == nil || *detail.Torrent.Peers != 2 {
t.Fatalf("expected peer count 2, got %#v", detail.Torrent)
}
if detail.Torrent.Leechers == nil || *detail.Torrent.Leechers != 1 {
t.Fatalf("expected leecher count 1, got %#v", detail.Torrent.Leechers)
}
if len(detail.Peers) != 2 {
t.Fatalf("expected peer samples, got %#v", detail.Peers)
}
if detail.Peers[0].Address != "192.0.2.10:6881" {
t.Fatalf("unexpected peer address: %s", detail.Peers[0].Address)
}
if len(detail.Trackers) != 1 || detail.Trackers[0].Status != "announce" || detail.Trackers[0].Message == "" {
t.Fatalf("expected aria2 tracker limitation marker, got %#v", detail.Trackers)
}
if detail.ETASeconds == nil || *detail.ETASeconds != 4 {
t.Fatalf("expected ETA seconds 4, got %#v", detail.ETASeconds)
}
}
func TestAria2DetailOmitsETAWithoutUsableSpeed(t *testing.T) {
detail := aria2Detail(
arigo.Status{
TotalLength: 1000,
CompletedLength: 250,
DownloadSpeed: 0,
},
nil,
nil,
)
if detail.ETASeconds != nil {
t.Fatalf("expected empty ETA without download speed, got %#v", detail.ETASeconds)
}
}
type fakeGeoIPResolver struct{}
func (fakeGeoIPResolver) LookupPeerRegion(ip string) (string, string) {
if ip == "203.0.113.10" {
return "us", "ca"
}
return "", ""
}
func TestApplyPeerRegionUsesGeoIPAndFallbackCountry(t *testing.T) {
peer := client.DownloadTaskPeer{}
applyPeerRegion(&peer, "203.0.113.10", "", fakeGeoIPResolver{})
if peer.CountryCode != "US" || peer.RegionCode != "CA" {
t.Fatalf("expected US/CA from geoip, got %#v", peer)
}
fallback := client.DownloadTaskPeer{}
applyPeerRegion(&fallback, "198.51.100.10", "jp", fakeGeoIPResolver{})
if fallback.CountryCode != "JP" || fallback.RegionCode != "" {
t.Fatalf("expected JP fallback country, got %#v", fallback)
}
}
func TestResultFromPathReturnsDirectory(t *testing.T) {
dir := t.TempDir()
taskDir := filepath.Join(dir, "task-1")
if err := os.MkdirAll(filepath.Join(taskDir, "folder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "folder", "a.txt"), []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "folder", "b.txt"), []byte("b"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "fixture.torrent"), []byte("torrent"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "folder.aria2"), []byte("control"), 0o644); err != nil {
t.Fatal(err)
}
result, err := resultFromPath(downloadTaskWithName("task-1", "http", "https://example.com/bundle", "bundle"), taskDir, "bundle")
if err != nil {
t.Fatal(err)
}
if !result.IsDir {
t.Fatal("expected directory result")
}
if result.Name != "bundle" {
t.Fatalf("expected bundle, got %s", result.Name)
}
if result.Size != 2 {
t.Fatalf("expected directory size 2, got %d", result.Size)
}
if result.Path != filepath.Join(taskDir, "folder") {
t.Fatalf("expected content dir path, got %s", result.Path)
}
if _, err := os.Stat(filepath.Join(result.Path, "a.txt")); err != nil {
t.Fatal(err)
}
}
func TestResultFromAria2FilesUsesSingleTopLevelDirectory(t *testing.T) {
dir := t.TempDir()
taskDir := filepath.Join(dir, "task-1")
if err := os.MkdirAll(filepath.Join(taskDir, "payload"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "payload", "a.txt"), []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "payload", "b.txt"), []byte("b"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "payload.aria2"), []byte("control"), 0o644); err != nil {
t.Fatal(err)
}
result, err := resultFromAria2Files(
client.DownloadTask{ID: "task-1"},
taskDir,
"payload",
[]arigo.File{
{Path: filepath.Join(taskDir, "payload", "a.txt"), Length: 1, Selected: true},
{Path: filepath.Join(taskDir, "payload", "b.txt"), Length: 1, Selected: true},
},
)
if err != nil {
t.Fatal(err)
}
if result.Path != filepath.Join(taskDir, "payload") {
t.Fatalf("expected payload dir path, got %s", result.Path)
}
if result.Name != "payload" {
t.Fatalf("expected payload dir name, got %s", result.Name)
}
if result.Size != 2 {
t.Fatalf("expected payload size 2, got %d", result.Size)
}
}
func TestResultFromAria2FilesWrapsSingleFileBTTask(t *testing.T) {
dir := t.TempDir()
taskDir := filepath.Join(dir, "task-1")
if err := os.MkdirAll(taskDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "movie.mkv"), []byte("movie"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "movie.mkv.aria2"), []byte("control"), 0o644); err != nil {
t.Fatal(err)
}
result, err := resultFromAria2Files(
downloadTaskWithName("task-1", "magnet", "magnet:?xt=urn:btih:abc", "movie.torrent"),
taskDir,
"Iron.Lung.2026.1080p.WEBRip.10Bit.DDP.5.1.x265-NeoNoir",
[]arigo.File{{Path: filepath.Join(taskDir, "movie.mkv"), Length: 5, Selected: true}},
)
if err != nil {
t.Fatal(err)
}
if !result.IsDir {
t.Fatal("expected directory result")
}
if result.Path != taskDir {
t.Fatalf("expected task dir path, got %s", result.Path)
}
if result.Name != "Iron.Lung.2026.1080p.WEBRip.10Bit.DDP.5.1.x265-NeoNoir" {
t.Fatalf("expected torrent name wrapper dir, got %s", result.Name)
}
if result.Size != 5 {
t.Fatalf("expected payload-only size 5, got %d", result.Size)
}
}
func TestResultFromDownloadedFilesWrapsMultipleTopLevelEntries(t *testing.T) {
dir := t.TempDir()
taskDir := filepath.Join(dir, "task-1")
if err := os.MkdirAll(filepath.Join(taskDir, "folder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "folder", "a.txt"), []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "root.txt"), []byte("b"), 0o644); err != nil {
t.Fatal(err)
}
result, err := resultFromDownloadedFiles(downloadTaskWithName("task-1", "http", "https://example.com/bundle", "bundle"), taskDir, "fallback", []downloadedFile{
{path: filepath.Join(taskDir, "folder", "a.txt"), relativePath: filepath.Join("folder", "a.txt")},
{path: filepath.Join(taskDir, "root.txt"), relativePath: "root.txt"},
})
if err != nil {
t.Fatal(err)
}
if result.Path != taskDir {
t.Fatalf("expected task dir wrapper path, got %s", result.Path)
}
if result.Name != "bundle" {
t.Fatalf("expected bundle wrapper name, got %s", result.Name)
}
}
func TestOutputNameIgnoresTorrentTaskNameForBT(t *testing.T) {
name := outputName(
downloadTaskWithName("task-1", "magnet", "magnet:?xt=urn:btih:abc", "movie.torrent"),
"movie.mkv",
)
if name != "movie.mkv" {
t.Fatalf("expected payload fallback name, got %s", name)
}
}
func TestOutputNameAllowsHTTPDownloadName(t *testing.T) {
name := outputName(
downloadTaskWithName("task-1", "http", "https://example.com/movie.torrent", "movie.torrent"),
"download",
)
if name != "movie.torrent" {
t.Fatalf("expected HTTP task name to be preserved, got %s", name)
}
}