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) } }