Debrid downloads to a network mount (the prod download dir is an NFS share at /mnt/nas/peliculas) could fail verification with "size mismatch: expected N, got M" where the file on disk was smaller than the bytes streamed. Every Write() succeeded into the client page cache (progress reached 100%) but the function returned before the asynchronous write-back to the NAS completed, so the caller's verify() stat'd a half-flushed file and rejected it. Concurrent downloads to the same mount made the contention worse; a lone retry succeeded because there was no flush pressure. - fsync the file (file.Sync) and check the error before reporting success, so a write-back failure surfaces here instead of silently truncating. - Stop swallowing the file.Close() error via a guarded close: error paths still clean up through defer, the success path closes explicitly and inspects the error. - Guard against a premature end-of-stream (downloaded < Content-Length). - Safety-net re-stat after the flush: if the on-disk size is short, remove the corrupt partial and return a retryable error. This is also the only integrity check when the server sends no Content-Length. Add TestDebridDownloadTruncatedStream.
460 lines
13 KiB
Go
460 lines
13 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/torrentclaw/unarr/internal/agent"
|
|
)
|
|
|
|
func TestDebridAvailable(t *testing.T) {
|
|
d := NewDebridDownloader()
|
|
|
|
t.Run("available when DirectURL is set", func(t *testing.T) {
|
|
task := &Task{DirectURL: "https://cdn.example.com/file.mkv"}
|
|
ok, err := d.Available(context.Background(), task)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("should be available when DirectURL is set")
|
|
}
|
|
})
|
|
|
|
t.Run("not available when DirectURL is empty", func(t *testing.T) {
|
|
task := &Task{DirectURL: ""}
|
|
ok, err := d.Available(context.Background(), task)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("should not be available when DirectURL is empty")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDebridDownloadSuccess(t *testing.T) {
|
|
fileContent := strings.Repeat("x", 1024*100) // 100KB file
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(fileContent)))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(fileContent))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
outputDir := t.TempDir()
|
|
|
|
task := &Task{
|
|
ID: "debrid-test-001",
|
|
InfoHash: "abc123def456abc123def456abc123def456abc1",
|
|
Title: "Test Movie",
|
|
DirectURL: srv.URL + "/file.mkv",
|
|
DirectFileName: "Test.Movie.2026.1080p.mkv",
|
|
Status: StatusDownloading,
|
|
}
|
|
|
|
progressCh := make(chan Progress, 100)
|
|
result, err := d.Download(context.Background(), task, outputDir, progressCh)
|
|
close(progressCh)
|
|
|
|
if err != nil {
|
|
t.Fatalf("Download failed: %v", err)
|
|
}
|
|
|
|
if result.Method != MethodDebrid {
|
|
t.Errorf("Method = %q, want debrid", result.Method)
|
|
}
|
|
if result.FileName != "Test.Movie.2026.1080p.mkv" {
|
|
t.Errorf("FileName = %q, want Test.Movie.2026.1080p.mkv", result.FileName)
|
|
}
|
|
if result.Size != int64(len(fileContent)) {
|
|
t.Errorf("Size = %d, want %d", result.Size, len(fileContent))
|
|
}
|
|
|
|
// Verify file exists on disk
|
|
data, err := os.ReadFile(result.FilePath)
|
|
if err != nil {
|
|
t.Fatalf("read downloaded file: %v", err)
|
|
}
|
|
if len(data) != len(fileContent) {
|
|
t.Errorf("file size = %d, want %d", len(data), len(fileContent))
|
|
}
|
|
|
|
// Verify task progress was updated
|
|
if task.DownloadedBytes != int64(len(fileContent)) {
|
|
t.Errorf("task.DownloadedBytes = %d, want %d", task.DownloadedBytes, len(fileContent))
|
|
}
|
|
}
|
|
|
|
// TestDebridDownloadTruncatedStream reproduces the 2026-06-15 incident shape: the
|
|
// server advertises a full Content-Length but delivers a short body (a CDN edge
|
|
// closing the connection early). The download must surface an error and return NO
|
|
// success Result — otherwise verify() would receive a half file. A Content-Length
|
|
// shortfall is caught by the read layer (io.ErrUnexpectedEOF); the post-loop length
|
|
// guard, Sync, and on-disk re-stat are the added defenses for the clean-EOF and
|
|
// write-back-loss variants the read layer can't see.
|
|
func TestDebridDownloadTruncatedStream(t *testing.T) {
|
|
full := 1024 * 100 // advertised length
|
|
delivered := full / 4 // bytes actually written before the handler returns
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", full))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(strings.Repeat("x", delivered)))
|
|
// Handler returns without writing the rest → client sees a short body.
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
task := &Task{
|
|
ID: "debrid-trunc-001",
|
|
InfoHash: "abc123def456abc123def456abc123def456abc1",
|
|
Title: "Truncated",
|
|
DirectURL: srv.URL + "/file.mkv",
|
|
DirectFileName: "Truncated.1080p.mkv",
|
|
Status: StatusDownloading,
|
|
}
|
|
|
|
progressCh := make(chan Progress, 100)
|
|
result, err := d.Download(context.Background(), task, t.TempDir(), progressCh)
|
|
close(progressCh)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for a truncated stream, got nil (a short file would be passed to verify as complete)")
|
|
}
|
|
if result != nil {
|
|
t.Errorf("expected nil result on a truncated stream, got %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestDebridDownloadNoURL(t *testing.T) {
|
|
d := NewDebridDownloader()
|
|
task := &Task{ID: "no-url-001", DirectURL: ""}
|
|
progressCh := make(chan Progress, 10)
|
|
|
|
_, err := d.Download(context.Background(), task, t.TempDir(), progressCh)
|
|
if err == nil {
|
|
t.Error("expected error for empty DirectURL")
|
|
}
|
|
if !strings.Contains(err.Error(), "no direct URL") {
|
|
t.Errorf("error = %q, should mention no direct URL", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDebridDownloadHTTPError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
task := &Task{
|
|
ID: "http-err-001",
|
|
DirectURL: srv.URL + "/expired",
|
|
DirectFileName: "expired.mkv",
|
|
}
|
|
progressCh := make(chan Progress, 10)
|
|
|
|
_, err := d.Download(context.Background(), task, t.TempDir(), progressCh)
|
|
if err == nil {
|
|
t.Error("expected error for HTTP 403")
|
|
}
|
|
if !strings.Contains(err.Error(), "403") {
|
|
t.Errorf("error = %q, should contain 403", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDebridDownloadExpiredURL(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusGone) // 410 — URL expired
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
task := &Task{
|
|
ID: "expired-001",
|
|
DirectURL: srv.URL + "/expired",
|
|
DirectFileName: "expired.mkv",
|
|
}
|
|
progressCh := make(chan Progress, 10)
|
|
|
|
_, err := d.Download(context.Background(), task, t.TempDir(), progressCh)
|
|
if err == nil {
|
|
t.Error("expected error for HTTP 410 (expired URL)")
|
|
}
|
|
if !strings.Contains(err.Error(), "410") {
|
|
t.Errorf("error = %q, should contain 410", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDebridDownloadUnauthorized(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
task := &Task{
|
|
ID: "unauth-001",
|
|
DirectURL: srv.URL + "/unauth",
|
|
DirectFileName: "unauth.mkv",
|
|
}
|
|
progressCh := make(chan Progress, 10)
|
|
|
|
_, err := d.Download(context.Background(), task, t.TempDir(), progressCh)
|
|
if err == nil {
|
|
t.Error("expected error for HTTP 401")
|
|
}
|
|
if !strings.Contains(err.Error(), "401") {
|
|
t.Errorf("error = %q, should contain 401", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDebridDownloadResume(t *testing.T) {
|
|
fullContent := "HEADER_ALREADY_DOWNLOADED_REST_OF_FILE"
|
|
alreadyDownloaded := "HEADER_ALREADY_DOWNLOADED_"
|
|
remaining := "REST_OF_FILE"
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rangeHeader := r.Header.Get("Range")
|
|
if rangeHeader != "" {
|
|
// Parse "bytes=26-"
|
|
var start int64
|
|
fmt.Sscanf(rangeHeader, "bytes=%d-", &start)
|
|
if start == int64(len(alreadyDownloaded)) {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(remaining)))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
w.Write([]byte(remaining))
|
|
return
|
|
}
|
|
}
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(fullContent)))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(fullContent))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
outputDir := t.TempDir()
|
|
fileName := "resume-test.mkv"
|
|
|
|
// Create partial file
|
|
partialPath := filepath.Join(outputDir, fileName)
|
|
if err := os.WriteFile(partialPath, []byte(alreadyDownloaded), 0o644); err != nil {
|
|
t.Fatalf("write partial file: %v", err)
|
|
}
|
|
|
|
task := &Task{
|
|
ID: "resume-001",
|
|
DirectURL: srv.URL + "/file.mkv",
|
|
DirectFileName: fileName,
|
|
Status: StatusDownloading,
|
|
}
|
|
|
|
progressCh := make(chan Progress, 100)
|
|
result, err := d.Download(context.Background(), task, outputDir, progressCh)
|
|
if err != nil {
|
|
t.Fatalf("Download failed: %v", err)
|
|
}
|
|
|
|
// Verify total size includes both parts
|
|
if result.Size != int64(len(fullContent)) {
|
|
t.Errorf("Size = %d, want %d", result.Size, len(fullContent))
|
|
}
|
|
|
|
// Verify file content
|
|
data, err := os.ReadFile(result.FilePath)
|
|
if err != nil {
|
|
t.Fatalf("read file: %v", err)
|
|
}
|
|
if string(data) != fullContent {
|
|
t.Errorf("file content = %q, want %q", string(data), fullContent)
|
|
}
|
|
}
|
|
|
|
func TestDebridDownloadCancel(t *testing.T) {
|
|
// Server that sends a chunk then waits
|
|
started := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", "1000000")
|
|
w.WriteHeader(http.StatusOK)
|
|
// Write some data so the download starts
|
|
w.Write([]byte(strings.Repeat("x", 4096)))
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
close(started)
|
|
// Block until client disconnects
|
|
<-r.Context().Done()
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
task := &Task{
|
|
ID: "cancel-001",
|
|
DirectURL: srv.URL + "/slow",
|
|
DirectFileName: "slow.mkv",
|
|
Status: StatusDownloading,
|
|
}
|
|
|
|
progressCh := make(chan Progress, 100)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := d.Download(context.Background(), task, t.TempDir(), progressCh)
|
|
errCh <- err
|
|
}()
|
|
|
|
// Wait for server to confirm download started, then cancel
|
|
<-started
|
|
d.Cancel("cancel-001")
|
|
|
|
err := <-errCh
|
|
if err == nil {
|
|
t.Error("expected error after cancel")
|
|
}
|
|
}
|
|
|
|
func TestDebridDownloadPause(t *testing.T) {
|
|
// Server that sends a chunk then waits
|
|
started := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", "1000000")
|
|
w.WriteHeader(http.StatusOK)
|
|
// Write enough data to create file
|
|
w.Write([]byte(strings.Repeat("x", 8192)))
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
close(started)
|
|
// Block until client disconnects
|
|
<-r.Context().Done()
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
outputDir := t.TempDir()
|
|
task := &Task{
|
|
ID: "pause-001",
|
|
DirectURL: srv.URL + "/slow",
|
|
DirectFileName: "pauseable.mkv",
|
|
Status: StatusDownloading,
|
|
}
|
|
|
|
progressCh := make(chan Progress, 100)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := d.Download(context.Background(), task, outputDir, progressCh)
|
|
errCh <- err
|
|
}()
|
|
|
|
// Wait for server to confirm data was sent, then pause
|
|
<-started
|
|
time.Sleep(50 * time.Millisecond) // small delay for file write
|
|
d.Pause("pause-001")
|
|
|
|
<-errCh
|
|
|
|
// Verify partial file exists on disk (pause keeps files)
|
|
partialPath := filepath.Join(outputDir, "pauseable.mkv")
|
|
fi, err := os.Stat(partialPath)
|
|
if err != nil {
|
|
t.Fatalf("partial file should exist after pause: %v", err)
|
|
}
|
|
if fi.Size() == 0 {
|
|
t.Error("partial file should have some bytes")
|
|
}
|
|
}
|
|
|
|
func TestDebridDownloadFallbackFilename(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", "5")
|
|
w.WriteHeader(http.StatusOK)
|
|
io.WriteString(w, "hello")
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDebridDownloader()
|
|
|
|
t.Run("uses Title when DirectFileName is empty", func(t *testing.T) {
|
|
task := &Task{
|
|
ID: "fallback-001",
|
|
Title: "My Movie Title",
|
|
DirectURL: srv.URL + "/file",
|
|
Status: StatusDownloading,
|
|
}
|
|
progressCh := make(chan Progress, 10)
|
|
result, err := d.Download(context.Background(), task, t.TempDir(), progressCh)
|
|
if err != nil {
|
|
t.Fatalf("Download failed: %v", err)
|
|
}
|
|
if result.FileName != "My Movie Title" {
|
|
t.Errorf("FileName = %q, want 'My Movie Title'", result.FileName)
|
|
}
|
|
})
|
|
|
|
t.Run("uses InfoHash when both are empty", func(t *testing.T) {
|
|
task := &Task{
|
|
ID: "fallback-002",
|
|
InfoHash: "abc123",
|
|
DirectURL: srv.URL + "/file",
|
|
Status: StatusDownloading,
|
|
}
|
|
progressCh := make(chan Progress, 10)
|
|
result, err := d.Download(context.Background(), task, t.TempDir(), progressCh)
|
|
if err != nil {
|
|
t.Fatalf("Download failed: %v", err)
|
|
}
|
|
if result.FileName != "abc123" {
|
|
t.Errorf("FileName = %q, want 'abc123'", result.FileName)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDebridShutdown(t *testing.T) {
|
|
d := NewDebridDownloader()
|
|
err := d.Shutdown(context.Background())
|
|
if err != nil {
|
|
t.Errorf("Shutdown should not error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewTaskFromAgentWithDirectURL(t *testing.T) {
|
|
at := agent.Task{
|
|
ID: "uuid-debrid",
|
|
InfoHash: "abc123def456abc123def456abc123def456abc1",
|
|
Title: "Debrid Movie",
|
|
PreferredMethod: "debrid",
|
|
DirectURL: "https://cdn.torbox.app/dl/abc123/movie.mkv",
|
|
DirectFileName: "Movie.2026.1080p.mkv",
|
|
}
|
|
|
|
task := NewTaskFromAgent(at)
|
|
|
|
if task.DirectURL != "https://cdn.torbox.app/dl/abc123/movie.mkv" {
|
|
t.Errorf("DirectURL = %q", task.DirectURL)
|
|
}
|
|
if task.DirectFileName != "Movie.2026.1080p.mkv" {
|
|
t.Errorf("DirectFileName = %q", task.DirectFileName)
|
|
}
|
|
if task.PreferredMethod != "debrid" {
|
|
t.Errorf("PreferredMethod = %q", task.PreferredMethod)
|
|
}
|
|
}
|
|
|
|
func TestDebridMethod(t *testing.T) {
|
|
d := NewDebridDownloader()
|
|
if d.Method() != MethodDebrid {
|
|
t.Errorf("Method = %q, want debrid", d.Method())
|
|
}
|
|
}
|