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
This commit is contained in:
Deivid Soto 2026-04-08 23:36:00 +02:00
parent b14ab98580
commit 78c16c295e
13 changed files with 2421 additions and 10 deletions

View file

@ -0,0 +1,601 @@
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)
}
}

View file

@ -0,0 +1,332 @@
package engine
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
)
// readSeekNopCloser envuelve un strings.Reader como ReadSeekCloser.
type readSeekNopCloser struct {
*strings.Reader
}
func (r *readSeekNopCloser) Close() error { return nil }
func newFakeProvider(name string, content []byte) FileProvider {
return &fakeFileProviderSeekable{name: name, content: content}
}
// fakeFileProviderSeekable implementa FileProvider con un reader buscable.
type fakeFileProviderSeekable struct {
name string
content []byte
}
func (f *fakeFileProviderSeekable) FileName() string { return f.name }
func (f *fakeFileProviderSeekable) FileSize() int64 { return int64(len(f.content)) }
func (f *fakeFileProviderSeekable) NewFileReader(_ context.Context) io.ReadSeekCloser {
return &readSeekNopCloser{strings.NewReader(string(f.content))}
}
// TestStreamServer_Listen_BindsPort verifica que Listen() enlaza a un puerto
// y URL() devuelve una URL accesible.
func TestStreamServer_Listen_BindsPort(t *testing.T) {
srv := NewStreamServer(0) // puerto aleatorio
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(context.Background())
url := srv.URL()
if url == "" {
t.Fatal("URL() returned empty string after Listen()")
}
if !strings.HasPrefix(url, "http://") {
t.Errorf("URL() = %q, want http:// prefix", url)
}
if srv.Port() == 0 {
t.Error("Port() should be non-zero after Listen()")
}
}
// TestStreamServer_Listen_RandomPort verifica que port=0 asigna un puerto disponible.
func TestStreamServer_Listen_RandomPort(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
port := srv.Port()
if port <= 0 || port > 65535 {
t.Errorf("Port() = %d, want valid port 1-65535", port)
}
}
// TestStreamServer_URL_Format verifica que la URL tiene el formato correcto
// con host y puerto.
func TestStreamServer_URL_Format(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
url := srv.URL()
port := srv.Port()
expectedSuffix := fmt.Sprintf(":%d/stream", port)
if !strings.Contains(url, expectedSuffix) {
t.Errorf("URL() = %q, want to contain %q", url, expectedSuffix)
}
}
// TestStreamServer_HasFile verifica que HasFile() refleja el estado correcto.
func TestStreamServer_HasFile(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
if srv.HasFile() {
t.Error("HasFile() = true before SetFile(), want false")
}
provider := newFakeProvider("test.mkv", []byte("fake video content"))
srv.SetFile(provider, "task-123")
if !srv.HasFile() {
t.Error("HasFile() = false after SetFile(), want true")
}
if srv.CurrentTaskID() != "task-123" {
t.Errorf("CurrentTaskID() = %q, want task-123", srv.CurrentTaskID())
}
}
// TestStreamServer_ClearFile verifica que ClearFile() elimina el provider actual.
func TestStreamServer_ClearFile(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("video.mkv", []byte("content"))
srv.SetFile(provider, "task-xyz")
srv.ClearFile()
if srv.HasFile() {
t.Error("HasFile() = true after ClearFile(), want false")
}
if srv.CurrentTaskID() != "" {
t.Errorf("CurrentTaskID() = %q, want empty after ClearFile()", srv.CurrentTaskID())
}
}
// TestStreamServer_NoFile_Returns404 verifica que sin archivo configurado
// el servidor devuelve 404.
func TestStreamServer_NoFile_Returns404(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
resp, err := http.Get(srv.URL())
if err != nil {
t.Fatalf("GET %s: %v", srv.URL(), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404 when no file set", resp.StatusCode)
}
}
// TestStreamServer_WithFile_Returns200 verifica que con archivo configurado
// el servidor sirve el contenido correctamente.
func TestStreamServer_WithFile_Returns200(t *testing.T) {
content := []byte("fake video bytes for testing")
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("movie.mkv", content)
srv.SetFile(provider, "task-abc")
resp, err := http.Get(srv.URL())
if err != nil {
t.Fatalf("GET %s: %v", srv.URL(), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if len(body) == 0 {
t.Error("response body is empty, expected file content")
}
}
// TestStreamServer_Shutdown_ReleasesPort verifica que después de Shutdown()
// el servidor no sigue respondiendo.
func TestStreamServer_Shutdown_ReleasesPort(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
url := srv.URL()
// Verificar que funciona antes de Shutdown
provider := newFakeProvider("test.mkv", []byte("data"))
srv.SetFile(provider, "t1")
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET before shutdown: %v", err)
}
resp.Body.Close()
// Shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
t.Errorf("Shutdown() error: %v", err)
}
// Después de shutdown, las conexiones deben fallar
client := &http.Client{Timeout: 500 * time.Millisecond}
if resp2, getErr := client.Get(url); getErr == nil {
resp2.Body.Close()
t.Error("expected error after Shutdown(), server should not be accessible")
}
}
// TestStreamServer_Concurrent verifica que múltiples requests concurrentes
// son manejados correctamente.
func TestStreamServer_Concurrent(t *testing.T) {
content := []byte("streaming content for concurrent access")
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("concurrent.mkv", content)
srv.SetFile(provider, "task-concurrent")
const numRequests = 5
var wg sync.WaitGroup
errors := make([]error, numRequests)
for i := 0; i < numRequests; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
resp, err := http.Get(srv.URL())
if err != nil {
errors[idx] = err
return
}
defer resp.Body.Close()
io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
errors[idx] = fmt.Errorf("request %d: status %d", idx, resp.StatusCode)
}
}(i)
}
wg.Wait()
for i, err := range errors {
if err != nil {
t.Errorf("concurrent request %d failed: %v", i, err)
}
}
}
// TestStreamServer_SetFile_SwapsProvider verifica que SetFile() reemplaza
// el provider anterior correctamente.
func TestStreamServer_SetFile_SwapsProvider(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
// Primer archivo
p1 := newFakeProvider("first.mkv", []byte("first content"))
srv.SetFile(p1, "task-1")
if srv.CurrentTaskID() != "task-1" {
t.Errorf("after first SetFile: taskID = %q, want task-1", srv.CurrentTaskID())
}
// Swap a segundo archivo
p2 := newFakeProvider("second.mkv", []byte("second content"))
srv.SetFile(p2, "task-2")
if srv.CurrentTaskID() != "task-2" {
t.Errorf("after second SetFile: taskID = %q, want task-2", srv.CurrentTaskID())
}
}
// TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv
// es el correcto.
func TestStreamServer_MKV_ContentType(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("movie.mkv", []byte("mkv content"))
srv.SetFile(provider, "task-mkv")
resp, err := http.Get(srv.URL())
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "matroska") && !strings.Contains(ct, "mkv") {
t.Errorf("Content-Type = %q, want matroska/mkv MIME type", ct)
}
}

View file

@ -0,0 +1,266 @@
package engine
import (
"context"
"testing"
"time"
)
// TestNewTorrentDownloader_ValidConfig verifica que se puede crear un downloader
// con una configuración válida sin errores.
func TestNewTorrentDownloader_ValidConfig(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir})
if err != nil {
t.Fatalf("NewTorrentDownloader failed: %v", err)
}
defer dl.Shutdown(context.Background())
}
// TestTorrentDownloader_Method verifica que Method() devuelve "torrent".
func TestTorrentDownloader_Method(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if dl.Method() != MethodTorrent {
t.Errorf("Method() = %q, want %q", dl.Method(), MethodTorrent)
}
}
// TestTorrentDownloader_Available_WithInfoHash verifica que Available() devuelve
// true cuando la tarea tiene un infoHash.
func TestTorrentDownloader_Available_WithInfoHash(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
task := &Task{InfoHash: "abc123def456abc123def456abc123def456abc1"}
ok, err := dl.Available(context.Background(), task)
if err != nil {
t.Fatalf("Available: %v", err)
}
if !ok {
t.Error("Available() = false, want true when infoHash is set")
}
}
// TestTorrentDownloader_Available_WithoutInfoHash verifica que Available() devuelve
// false cuando la tarea no tiene infoHash.
func TestTorrentDownloader_Available_WithoutInfoHash(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
task := &Task{InfoHash: ""}
ok, err := dl.Available(context.Background(), task)
if err != nil {
t.Fatalf("Available: %v", err)
}
if ok {
t.Error("Available() = true, want false when infoHash is empty")
}
}
// TestTorrentDownloader_Shutdown_Clean verifica que Shutdown() no genera panics
// ni errores inesperados.
func TestTorrentDownloader_Shutdown_Clean(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := dl.Shutdown(ctx); err != nil {
t.Errorf("Shutdown() error = %v", err)
}
}
// TestTorrentDownloader_Cancel_NonExistent verifica que Cancel() no genera panic
// para un ID de tarea que no existe.
func TestTorrentDownloader_Cancel_NonExistent(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
// No debe hacer panic
if err := dl.Cancel("nonexistent-task-id"); err != nil {
t.Errorf("Cancel() unexpected error: %v", err)
}
}
// TestTorrentDownloader_Pause_NonExistent verifica que Pause() no genera panic
// para un ID de tarea que no existe.
func TestTorrentDownloader_Pause_NonExistent(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if err := dl.Pause("nonexistent-task-id"); err != nil {
t.Errorf("Pause() unexpected error: %v", err)
}
}
// TestTorrentDownloader_StallTimeout_Default verifica que StallTimeout se inicializa
// con el valor por defecto (30m) cuando se pasa 0.
func TestTorrentDownloader_StallTimeout_Default(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
StallTimeout: 0, // debe usar el default 30m
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if dl.cfg.StallTimeout != 30*time.Minute {
t.Errorf("StallTimeout = %v, want 30m", dl.cfg.StallTimeout)
}
}
// TestTorrentDownloader_StallTimeout_Custom verifica que un StallTimeout personalizado
// se respeta sin ser sobreescrito.
func TestTorrentDownloader_StallTimeout_Custom(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
StallTimeout: 5 * time.Minute,
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if dl.cfg.StallTimeout != 5*time.Minute {
t.Errorf("StallTimeout = %v, want 5m", dl.cfg.StallTimeout)
}
}
// TestTorrentDownloader_SeedDisabled verifica que cuando SeedEnabled=false,
// el downloader se crea correctamente (NoUpload implícito).
func TestTorrentDownloader_SeedDisabled(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
SeedEnabled: false,
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if dl.cfg.SeedEnabled {
t.Error("SeedEnabled should be false")
}
}
// TestTorrentDownloader_SeedEnabled verifica que cuando SeedEnabled=true,
// el downloader se crea correctamente.
func TestTorrentDownloader_SeedEnabled(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
SeedEnabled: true,
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if !dl.cfg.SeedEnabled {
t.Error("SeedEnabled should be true")
}
}
// TestTorrentDownloader_RateLimiting_Download verifica que crear un downloader
// con MaxDownloadRate > 0 no devuelve error.
func TestTorrentDownloader_RateLimiting_Download(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
MaxDownloadRate: 5 * 1024 * 1024, // 5 MB/s
})
if err != nil {
t.Fatalf("NewTorrentDownloader with download rate limit: %v", err)
}
defer dl.Shutdown(context.Background())
if dl.cfg.MaxDownloadRate != 5*1024*1024 {
t.Errorf("MaxDownloadRate = %d, want %d", dl.cfg.MaxDownloadRate, 5*1024*1024)
}
}
// TestTorrentDownloader_RateLimiting_Upload verifica que crear un downloader
// con MaxUploadRate > 0 no devuelve error.
func TestTorrentDownloader_RateLimiting_Upload(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
MaxUploadRate: 1 * 1024 * 1024, // 1 MB/s
})
if err != nil {
t.Fatalf("NewTorrentDownloader with upload rate limit: %v", err)
}
defer dl.Shutdown(context.Background())
if dl.cfg.MaxUploadRate != 1*1024*1024 {
t.Errorf("MaxUploadRate = %d, want %d", dl.cfg.MaxUploadRate, 1*1024*1024)
}
}
// TestTorrentDownloader_DownloadTimeout_MetadataCancel verifica que Download()
// respeta la cancelación de contexto durante la espera de metadata.
// No hay red real, así que el timeout de contexto debe terminar la operación.
func TestTorrentDownloader_DownloadTimeout_MetadataCancel(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
MetadataTimeout: 100 * time.Millisecond, // muy corto para que falle rápido
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
task := &Task{
ID: "timeout-test-1234567890123456",
InfoHash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
Title: "Non-existent Torrent",
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
progressCh := make(chan Progress, 16)
_, err = dl.Download(ctx, task, dir, progressCh)
close(progressCh)
if err == nil {
t.Error("expected error when metadata timeout with no peers")
}
}
// TestTorrentDownloader_ImplementsInterface verifica en tiempo de compilación
// que *TorrentDownloader implementa la interfaz Downloader.
func TestTorrentDownloader_ImplementsInterface(t *testing.T) {
var _ Downloader = (*TorrentDownloader)(nil)
}

View file

@ -0,0 +1,76 @@
package engine
import (
"context"
"sync"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/usenet/download"
"github.com/torrentclaw/unarr/internal/usenet/nzb"
)
// emptyNZB returns a minimal NZB with no files, suitable for test tracker creation.
func emptyNZB() *nzb.NZB { return &nzb.NZB{} }
// TestUsenetDownloader_Cancel_NoRace verifies that Cancel() reads tracker and taskDir
// under the mutex, avoiding a data race with Download() which writes them under the same lock.
// Run with -race to detect the race if it regresses.
func TestUsenetDownloader_Cancel_NoRace(t *testing.T) {
u := NewUsenetDownloader(agent.NewClient("http://localhost", "", "test"))
const taskID = "race-test-taskid-123456"
// Inject a fake activeDownload without tracker/taskDir set yet.
// We only need the cancel func; discard the context itself.
_, cancel := context.WithCancel(context.Background())
dl := &activeDownload{cancel: cancel}
u.mu.Lock()
u.active[taskID] = dl
u.mu.Unlock()
var wg sync.WaitGroup
// Goroutine 1: simulates Download() setting tracker and taskDir under lock.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
tracker := download.NewProgressTracker(taskID, emptyNZB(), t.TempDir())
u.mu.Lock()
dl.tracker = tracker
dl.taskDir = t.TempDir()
u.mu.Unlock()
time.Sleep(time.Microsecond)
}
}()
// Goroutine 2: calls Cancel() concurrently — must read under lock.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
u.Cancel(taskID) //nolint:errcheck
time.Sleep(time.Microsecond)
}
}()
wg.Wait()
}
// TestUsenetDownloader_Cancel_NonExistent verifies Cancel on unknown task returns nil.
func TestUsenetDownloader_Cancel_NonExistent(t *testing.T) {
u := NewUsenetDownloader(agent.NewClient("http://localhost", "", "test"))
if err := u.Cancel("no-such-task"); err != nil {
t.Errorf("Cancel non-existent task = %v, want nil", err)
}
}
// TestUsenetDownloader_Pause_NonExistent verifies Pause on unknown task returns nil.
func TestUsenetDownloader_Pause_NonExistent(t *testing.T) {
u := NewUsenetDownloader(agent.NewClient("http://localhost", "", "test"))
if err := u.Pause("no-such-task"); err != nil {
t.Errorf("Pause non-existent task = %v, want nil", err)
}
}