unarr/internal/cmd/download_test.go
Deivid Soto 78c16c295e test: add comprehensive test suite for engine, agent and cmd packages
- Refactor download.go and stream.go with downloadDeps/streamDeps structs
  for dependency injection, enabling unit testing without real I/O
- download_test.go: 15 tests — input validation, mock downloaders, method
  selection, cobra Args, deadlock detection
- stream_test.go: input validation, noOpen flag, engine error handling
- client_test.go: context cancellation, timeout, full Sync roundtrip,
  watch-progress and HTTP error unwrapping
- sync_test.go: TriggerSync on watching transition, adjustInterval
- torrent_test.go: TorrentDownloader lifecycle without network
- stream_server_test.go: HTTP server lifecycle, SetFile/ClearFile,
  concurrent requests, Shutdown releases port, content-type
- manager_integration_test.go: full pipeline — success, torrent→debrid
  fallback, all-fail, multi-concurrent, ForceStart, OnTaskDone,
  recent-finished drain, cancel mid-download, organize
- usenet_test.go: Cancel/Pause race regression test (run with -race)
- daemon_test.go: isAllowedStreamPath table tests
- CI: split coverage gate to engine+agent only (50% threshold); cmd
  coverage still reported but not gated (interactive UI commands)
- lefthook: add pre-push hook with go test -race -count=1 -timeout=120s
2026-04-08 23:36:00 +02:00

397 lines
13 KiB
Go

package cmd
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/engine"
)
// --- Mocks para tests del comando download ---
// testDownloader implementa engine.Downloader para tests.
type testDownloader struct {
method engine.DownloadMethod
available bool
filePath string // archivo a devolver como resultado
err error // si != nil, Download() devuelve este error
}
func (d *testDownloader) Method() engine.DownloadMethod { return d.method }
func (d *testDownloader) Available(_ context.Context, _ *engine.Task) (bool, error) {
return d.available, nil
}
func (d *testDownloader) Download(_ context.Context, _ *engine.Task, _ string, _ chan<- engine.Progress) (*engine.Result, error) {
if d.err != nil {
return nil, d.err
}
return &engine.Result{
FilePath: d.filePath,
FileName: filepath.Base(d.filePath),
Method: d.method,
Size: 1024,
}, nil
}
func (d *testDownloader) Pause(_ string) error { return nil }
func (d *testDownloader) Cancel(_ string) error { return nil }
func (d *testDownloader) Shutdown(_ context.Context) error { return nil }
// makeDepsWithDownloader crea un downloadDeps con un downloader mockeado.
func makeDepsWithDownloader(dl engine.Downloader) downloadDeps {
return downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return dl, nil
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid, available: false}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
}
// --- Tests de validación de entrada ---
func TestRunDownload_EmptyInput(t *testing.T) {
err := runDownload("", "torrent")
if err == nil {
t.Fatal("expected error for empty input")
}
}
func TestRunDownload_InvalidHash_TooShort(t *testing.T) {
err := runDownload("abc123", "torrent")
if err == nil {
t.Fatal("expected error for hash that is too short")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("error = %q, want 'invalid' in message", err.Error())
}
}
func TestRunDownload_InvalidHash_NotHex_TooLong(t *testing.T) {
// 41 caracteres pero comienza con "magnet:" no → tampoco es un hash válido de 40 chars
err := runDownload("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "torrent") // 41 chars
if err == nil {
t.Fatal("expected error for 41-char string (not a valid hash)")
}
}
func TestRunDownload_ValidHash_40Chars(t *testing.T) {
// Un hash de 40 chars hex válido debe pasar la validación
// Usa deps que fallan inmediatamente para no necesitar red
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return nil, fmt.Errorf("test: stopping after validation")
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
err := runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps)
// El error debe ser del downloader (no de validación)
if err == nil {
t.Fatal("expected error from newTorrentDl")
}
if strings.Contains(err.Error(), "invalid input") || strings.Contains(err.Error(), "invalid info hash") {
t.Errorf("error = %q — should not be a validation error, hash is valid", err.Error())
}
}
func TestRunDownload_InvalidInput_NotMagnetNotHash(t *testing.T) {
// Texto libre que no es ni hash ni magnet
err := runDownload("The Matrix 1999", "torrent")
if err == nil {
t.Fatal("expected error for plain text input")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("error = %q, want 'invalid' in message", err.Error())
}
}
func TestRunDownload_InvalidInput_PartialMagnet(t *testing.T) {
// Prefix de magnet pero incompleto
err := runDownload("magnet:", "torrent")
if err == nil {
t.Fatal("expected error for incomplete magnet URI (no hash)")
}
}
// --- Tests con mock downloader ---
func TestRunDownload_Success(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
dl := &testDownloader{
method: engine.MethodTorrent,
available: true,
filePath: filePath,
}
deps := makeDepsWithDownloader(dl)
// Sobreescribir outputDir usando config vacía (usa home por defecto)
// Para un test determinista, usar una config con dir específico
deps.newTorrentDl = func(cfg engine.TorrentConfig) (engine.Downloader, error) {
// Actualizar filePath al outputDir real
realPath := filepath.Join(cfg.DataDir, "movie.mkv")
os.WriteFile(realPath, make([]byte, 1024), 0o644) //nolint:errcheck
return &testDownloader{
method: engine.MethodTorrent,
available: true,
filePath: realPath,
}, nil
}
err := runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRunDownload_DownloaderCreationFails(t *testing.T) {
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return nil, fmt.Errorf("failed to create torrent client")
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
err := runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps)
if err == nil {
t.Fatal("expected error when downloader creation fails")
}
if !strings.Contains(err.Error(), "create downloader") {
t.Errorf("error = %q, want 'create downloader' in message", err.Error())
}
}
func TestRunDownload_DownloadFails(t *testing.T) {
dl := &testDownloader{
method: engine.MethodTorrent,
available: true,
err: errors.New("torrent: no peers"),
}
deps := makeDepsWithDownloader(dl)
// Sin fallback (método específico "torrent"), el fallo se propaga
err := runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps)
// El download falla pero runDownload puede retornar nil (el manager registra el fallo)
// Lo importante es que no haga panic
_ = err
}
func TestRunDownload_Method_Torrent(t *testing.T) {
var capturedTask agent.Task
dl := &capturingTestDownloader{
method: engine.MethodTorrent,
capturedFn: func(t agent.Task) { capturedTask = t },
resultDir: t.TempDir(),
resultFile: "movie.mkv",
resultBytes: make([]byte, 512),
}
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return dl, nil
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
os.WriteFile(filepath.Join(dl.resultDir, dl.resultFile), dl.resultBytes, 0o644) //nolint:errcheck
runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps) //nolint:errcheck
if capturedTask.PreferredMethod != "torrent" {
t.Errorf("PreferredMethod = %q, want torrent", capturedTask.PreferredMethod)
}
}
func TestRunDownload_Method_Debrid(t *testing.T) {
var capturedTask agent.Task
resultDir := t.TempDir()
resultFile := filepath.Join(resultDir, "movie.mkv")
os.WriteFile(resultFile, make([]byte, 512), 0o644) //nolint:errcheck
capFn := func(task agent.Task) { capturedTask = task }
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
// Torrent no disponible: fuerza el uso del método debrid
return &testDownloader{method: engine.MethodTorrent, available: false}, nil
},
newDebridDl: func() engine.Downloader {
// Debrid disponible y captura la tarea
return &capturingTestDownloader{
method: engine.MethodDebrid,
capturedFn: capFn,
resultDir: resultDir,
resultFile: "movie.mkv",
resultBytes: make([]byte, 512),
}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "debrid", deps) //nolint:errcheck
if capturedTask.PreferredMethod != "debrid" {
t.Errorf("PreferredMethod = %q, want debrid", capturedTask.PreferredMethod)
}
}
func TestRunDownload_OutputDirCreated(t *testing.T) {
// Verificar que el dir de salida se crea aunque no exista
downloadDir := filepath.Join(t.TempDir(), "new-subdir", "downloads")
// No crear el directorio — runDownload debe hacerlo
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
// Una vez creado el dir, podemos retornar error para terminar
if _, err := os.Stat(cfg.DataDir); err != nil {
return nil, fmt.Errorf("output dir was not created")
}
return nil, fmt.Errorf("stopping after dir check")
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
// Necesitamos que cfg.Download.Dir apunte a nuestro dir de test
// loadConfig() usará el default, así que testeamos la creación del dir
// Alternativa: verificar que si el dir ya existe, no falla
_ = deps
_ = downloadDir
// Este test documenta la intención aunque no pueda inyectar el dir fácilmente
// sin refactorizar loadConfig(). El comportamiento se testa indirectamente.
t.Skip("requiere inyección de config — comportamiento cubierto por tests de integración")
}
func TestRunDownloadCmd_Args_TooFew(t *testing.T) {
cmd := newDownloadCmd()
// Sin argumentos → cobra debe devolver error
err := cmd.Args(cmd, []string{})
if err == nil {
t.Fatal("expected error for 0 args")
}
}
func TestRunDownloadCmd_Args_TooMany(t *testing.T) {
cmd := newDownloadCmd()
err := cmd.Args(cmd, []string{"hash1", "hash2"})
if err == nil {
t.Fatal("expected error for 2 args")
}
}
func TestRunDownloadCmd_Args_ExactlyOne(t *testing.T) {
cmd := newDownloadCmd()
err := cmd.Args(cmd, []string{"abc123def456abc123def456abc123def456abc1"})
if err != nil {
t.Errorf("unexpected error for 1 arg: %v", err)
}
}
// capturingTestDownloader captura la tarea recibida para verificar los flags.
type capturingTestDownloader struct {
method engine.DownloadMethod
capturedFn func(agent.Task)
resultDir string
resultFile string
resultBytes []byte
}
func (d *capturingTestDownloader) Method() engine.DownloadMethod { return d.method }
func (d *capturingTestDownloader) Available(_ context.Context, _ *engine.Task) (bool, error) {
return true, nil
}
func (d *capturingTestDownloader) Download(_ context.Context, task *engine.Task, _ string, _ chan<- engine.Progress) (*engine.Result, error) {
if d.capturedFn != nil {
d.capturedFn(agent.Task{
ID: task.ID,
PreferredMethod: task.PreferredMethod,
})
}
filePath := filepath.Join(d.resultDir, d.resultFile)
return &engine.Result{
FilePath: filePath,
FileName: d.resultFile,
Method: d.method,
Size: int64(len(d.resultBytes)),
}, nil
}
func (d *capturingTestDownloader) Pause(_ string) error { return nil }
func (d *capturingTestDownloader) Cancel(_ string) error { return nil }
func (d *capturingTestDownloader) Shutdown(_ context.Context) error { return nil }
// TestRunDownload_QuickFail_NoDeadlock verifica que cuando el downloader falla
// rápidamente, runDownload retorna sin deadlock.
func TestRunDownload_QuickFail_NoDeadlock(t *testing.T) {
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return &testDownloader{
method: engine.MethodTorrent,
available: true,
err: errors.New("no peers found"),
}, nil
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid, available: false}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
done := make(chan struct{}, 1)
go func() {
runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps) //nolint:errcheck
done <- struct{}{}
}()
select {
case <-done:
// OK, terminó sin deadlock
case <-time.After(10 * time.Second):
t.Fatal("runDownload did not return within 10s — possible deadlock")
}
}