unarr/internal/engine/manager_integration_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

601 lines
17 KiB
Go

package engine
import (
"context"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
)
// errorMockDownloader siempre falla en Download para simular fallo de método.
type errorMockDownloader struct {
method DownloadMethod
err error
}
func (m *errorMockDownloader) Method() DownloadMethod { return m.method }
func (m *errorMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *errorMockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
if m.err != nil {
return nil, m.err
}
return nil, fmt.Errorf("simulated download failure for %s", m.method)
}
func (m *errorMockDownloader) Pause(_ string) error { return nil }
func (m *errorMockDownloader) Cancel(_ string) error { return nil }
func (m *errorMockDownloader) Shutdown(_ context.Context) error { return nil }
// makeProgressReporter crea un ProgressReporter con mock de reporter para tests de integración.
func makeProgressReporter() *ProgressReporter {
reporter := &mockStatusReporter{}
return &ProgressReporter{
reporter: reporter,
interval: 100 * time.Millisecond,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
}
// TestManagerPipeline_FullSuccess verifica el pipeline completo:
// submit → download → verify → complete con archivo real en disco.
func TestManagerPipeline_FullSuccess(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 2048), 0o644); err != nil {
t.Fatal(err)
}
pr := makeProgressReporter()
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodTorrent,
Size: 2048,
},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "integration-full-123456",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Test Movie",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
}
// TestManagerPipeline_Fallback_TorrentFails_DebridSucceeds verifica que cuando
// torrent falla en modo "auto", el manager hace fallback a debrid.
func TestManagerPipeline_Fallback_TorrentFails_DebridSucceeds(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 2048), 0o644); err != nil {
t.Fatal(err)
}
pr := makeProgressReporter()
// Torrent siempre falla
torrentDl := &errorMockDownloader{method: MethodTorrent}
// Debrid tiene éxito
debridDl := &resultMockDownloader{
method: MethodDebrid,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodDebrid,
Size: 2048,
},
}
// Debrid debe declararse disponible — usamos mockDownloader para eso
debridAvailDl := struct {
*errorMockDownloader
*resultMockDownloader
}{torrentDl, debridDl}
_ = debridAvailDl // unused, kept for clarity
// Un mock que es available=true y retorna resultado exitoso
type debridFullMock struct {
resultMockDownloader
}
debridFull := &debridFullMock{
resultMockDownloader: resultMockDownloader{
method: MethodDebrid,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodDebrid,
Size: 2048,
},
},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, torrentDl, debridFull)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
// PreferredMethod: "auto" es necesario para que tryFallback funcione
task := agent.Task{
ID: "fallback-test-123456789",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Fallback Movie",
PreferredMethod: "auto",
}
mgr.Submit(ctx, task)
mgr.Wait()
// Si llegamos aquí sin timeout, el fallback funcionó (torrent falló, debrid tuvo éxito)
}
// TestManagerPipeline_AllMethodsFail verifica que cuando todos los downloaders
// fallan, la tarea termina en estado failed.
func TestManagerPipeline_AllMethodsFail(t *testing.T) {
dir := t.TempDir()
pr := makeProgressReporter()
torrentDl := &errorMockDownloader{method: MethodTorrent, err: fmt.Errorf("no peers")}
// En modo "torrent" específico no hay fallback
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, torrentDl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "fail-all-123456789012",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Failing Download",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
// Si llegamos aquí, el manager manejó el fallo sin panic ni deadlock
}
// TestManagerPipeline_MultiConcurrent verifica que múltiples descargas concurrentes
// completan todas correctamente.
func TestManagerPipeline_MultiConcurrent(t *testing.T) {
dir := t.TempDir()
const numTasks = 3
// Crear archivos para cada tarea
files := make([]string, numTasks)
for i := 0; i < numTasks; i++ {
files[i] = filepath.Join(dir, fmt.Sprintf("movie%d.mkv", i))
if err := os.WriteFile(files[i], make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
}
var submitCount atomic.Int32
pr := makeProgressReporter()
// Usar un mock que devuelve archivos distintos por tarea
dl := &multiResultMockDownloader{dir: dir, files: files}
mgr := NewManager(ManagerConfig{
MaxConcurrent: numTasks,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
go pr.Run(ctx)
for i := 0; i < numTasks; i++ {
submitCount.Add(1)
task := agent.Task{
ID: fmt.Sprintf("concurrent-task-%02d-123456", i),
InfoHash: fmt.Sprintf("abc%037d", i), // 40 hex chars
Title: fmt.Sprintf("Movie %d", i),
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
}
mgr.Wait()
if submitCount.Load() != int32(numTasks) {
t.Errorf("submitted %d tasks, want %d", submitCount.Load(), numTasks)
}
}
// multiResultMockDownloader devuelve archivos distintos según el orden de llamadas.
type multiResultMockDownloader struct {
dir string
files []string
callCount atomic.Int32
}
func (m *multiResultMockDownloader) Method() DownloadMethod { return MethodTorrent }
func (m *multiResultMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *multiResultMockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
idx := int(m.callCount.Add(1)) - 1
if idx >= len(m.files) {
return nil, fmt.Errorf("too many calls to multiResultMockDownloader")
}
return &Result{
FilePath: m.files[idx],
FileName: filepath.Base(m.files[idx]),
Method: MethodTorrent,
Size: 1024,
}, nil
}
func (m *multiResultMockDownloader) Pause(_ string) error { return nil }
func (m *multiResultMockDownloader) Cancel(_ string) error { return nil }
func (m *multiResultMockDownloader) Shutdown(_ context.Context) error { return nil }
// TestManagerPipeline_CancelTaskMidDownload verifica que CancelTask() durante una
// descarga activa libera el slot y no produce deadlock.
func TestManagerPipeline_CancelTaskMidDownload(t *testing.T) {
dir := t.TempDir()
pr := makeProgressReporter()
dl := &slowMockDownloader{method: MethodTorrent}
const taskID = "cancel-mid-test-12345"
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: taskID,
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Cancel Test",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
// Esperar a que la tarea esté activa
time.Sleep(100 * time.Millisecond)
// Cancelar la tarea específica (cancela su contexto interno)
mgr.CancelTask(taskID)
done := make(chan struct{})
go func() {
mgr.Wait()
close(done)
}()
select {
case <-done:
// OK — manager terminó limpiamente tras CancelTask
case <-time.After(5 * time.Second):
t.Error("Manager.Wait() timed out after CancelTask — possible deadlock")
}
}
// TestManagerPipeline_OnTaskDone_Called verifica que el callback OnTaskDone
// se llama exactamente una vez cuando una tarea completa.
func TestManagerPipeline_OnTaskDone_Called(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)
}
pr := makeProgressReporter()
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{FilePath: filePath, FileName: "movie.mkv", Method: MethodTorrent, Size: 1024},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, dl)
var callCount atomic.Int32
mgr.OnTaskDone = func() {
callCount.Add(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "ontaskdone-test-123456",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Done Callback Test",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
if callCount.Load() != 1 {
t.Errorf("OnTaskDone called %d times, want 1", callCount.Load())
}
}
// TestManagerPipeline_RecentFinished_DrainedOnSync verifica que TaskStates()
// incluye tareas recientemente finalizadas y las limpia en la siguiente llamada.
func TestManagerPipeline_RecentFinished_DrainedOnSync(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)
}
pr := makeProgressReporter()
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{FilePath: filePath, FileName: "movie.mkv", Method: MethodTorrent, Size: 1024},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "recent-finished-12345",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Recent Test",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
// Primera llamada a TaskStates() debe incluir la tarea finalizada
states := mgr.TaskStates()
// La tarea se eliminó del mapa active, pero debe estar en recentFinished
foundRecent := false
for _, s := range states {
if s.TaskID == task.ID {
foundRecent = true
break
}
}
if !foundRecent {
t.Error("TaskStates() should include recently finished task in first call")
}
// Segunda llamada: recentFinished debe estar vacío (ya se drenó)
states2 := mgr.TaskStates()
for _, s := range states2 {
if s.TaskID == task.ID {
t.Error("TaskStates() should NOT include finished task in second call (should be drained)")
break
}
}
}
// TestManagerPipeline_ForceStart_BypassesSemaphore verifica que ForceStart=true
// permite iniciar descargas aunque el semáforo esté lleno.
func TestManagerPipeline_ForceStart_BypassesSemaphore(t *testing.T) {
dir := t.TempDir()
pr := makeProgressReporter()
// slowMock bloqueará el semáforo
slowDl := &slowMockDownloader{method: MethodTorrent}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1, // semáforo de 1
OutputDir: dir,
}, pr, slowDl)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go pr.Run(ctx)
// Primera tarea: llena el semáforo
task1 := agent.Task{
ID: "force-start-slow-12345",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Slow Task",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task1)
// Pequeña pausa para que task1 adquiera el semáforo
time.Sleep(50 * time.Millisecond)
// Segunda tarea con ForceStart=true: debe empezar aunque semáforo lleno
filePath := filepath.Join(dir, "force.mkv")
if err := os.WriteFile(filePath, make([]byte, 512), 0o644); err != nil {
t.Fatal(err)
}
// Para ForceStart necesitamos un downloader que tenga éxito inmediato
// Usar resultMockDownloader pero ForceStart necesita el mismo downloader registrado
// Modificamos el test: verificar que ActiveCount() > MaxConcurrent con ForceStart
task2 := agent.Task{
ID: "force-start-fast-12345",
InfoHash: "def456abc123def456abc123def456abc123def4",
Title: "Force Task",
PreferredMethod: "torrent",
ForceStart: true,
}
mgr.Submit(ctx, task2)
// Verificar que hay más tareas activas que el límite del semáforo
time.Sleep(50 * time.Millisecond)
active := mgr.ActiveCount()
if active < 1 {
t.Errorf("expected at least 1 active task with ForceStart, got %d", active)
}
cancel() // terminar las tareas lentas
mgr.Wait()
}
// TestManagerPipeline_Organize_MoviesDir verifica que cuando organize está
// habilitado y ContentType es "movie", el archivo se mueve al directorio correcto.
func TestManagerPipeline_Organize_MoviesDir(t *testing.T) {
downloadDir := t.TempDir()
moviesDir := t.TempDir()
filePath := filepath.Join(downloadDir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
pr := makeProgressReporter()
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodTorrent,
Size: 1024,
},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: downloadDir,
Organize: OrganizeConfig{
Enabled: true,
MoviesDir: moviesDir,
OutputDir: downloadDir,
},
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "organize-test-1234567",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "The Matrix 1999",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
// El archivo debe haberse movido a moviesDir (o seguir en downloadDir si hay error de organización)
// Lo que nos importa es que no haya crash
}
// TestManagerPipeline_Shutdown_GracefulWithActiveDownloads verifica que Shutdown()
// espera a que terminen las descargas activas antes de salir.
func TestManagerPipeline_Shutdown_GracefulWithActiveDownloads(t *testing.T) {
dir := t.TempDir()
pr := makeProgressReporter()
// Downloader que tarda un poco pero termina
dl := &timedResultMockDownloader{
method: MethodTorrent,
delay: 100 * time.Millisecond,
dir: dir,
content: make([]byte, 512),
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "shutdown-graceful-123",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Graceful Test",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
// Dar tiempo a que la tarea empiece
time.Sleep(20 * time.Millisecond)
// Shutdown con timeout suficiente para que la tarea termine
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
start := time.Now()
mgr.Shutdown(shutCtx)
elapsed := time.Since(start)
if elapsed > 4*time.Second {
t.Errorf("Shutdown took too long: %v", elapsed)
}
}
// timedResultMockDownloader simula una descarga que tarda un tiempo específico.
type timedResultMockDownloader struct {
method DownloadMethod
delay time.Duration
dir string
content []byte
}
func (m *timedResultMockDownloader) Method() DownloadMethod { return m.method }
func (m *timedResultMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *timedResultMockDownloader) Download(ctx context.Context, task *Task, outputDir string, _ chan<- Progress) (*Result, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(m.delay):
}
filePath := filepath.Join(outputDir, "timed.mkv")
if err := os.WriteFile(filePath, m.content, 0o644); err != nil {
return nil, err
}
return &Result{
FilePath: filePath,
FileName: "timed.mkv",
Method: m.method,
Size: int64(len(m.content)),
}, nil
}
func (m *timedResultMockDownloader) Pause(_ string) error { return nil }
func (m *timedResultMockDownloader) Cancel(_ string) error { return nil }
func (m *timedResultMockDownloader) Shutdown(_ context.Context) error { return nil }
// TestManagerPipeline_FreeSlots verifica que FreeSlots() refleja el número
// correcto de slots disponibles.
func TestManagerPipeline_FreeSlots(t *testing.T) {
pr := makeProgressReporter()
mgr := NewManager(ManagerConfig{MaxConcurrent: 3}, pr)
if slots := mgr.FreeSlots(); slots != 3 {
t.Errorf("FreeSlots() = %d, want 3 when empty", slots)
}
}