unarr/internal/engine/manager_integrity_test.go
Deivid Soto a5f3f0914a fix(engine): cross-backend integrity guard with retry-then-damaged
A truncated debrid download (in-memory byte counter hit 100% while the
NFS write-back silently dropped most of the bytes) was marked completed.
The 1.1.6 fsync fix closed the debrid-specific hole; this generalizes the
guarantee so "completed" never means a corrupt file on ANY backend.

- IntegrityError + bounded retry: on a corrupt/short result the manager
  re-downloads the same source up to 3x (clean start), then surfaces the
  task as damaged ("corrupt download:" prefix) instead of completing it.
- verify (size mismatch / empty), debrid (incomplete / post-write / flush),
  torrent (BytesMissing), usenet (par2 unrepairable / repair-failed) all
  classify integrity failures so they route through the retry/damaged path.
- scanner: a file ffprobe can't read is emitted as a damaged library_item
  (reason "unreadable") instead of being silently dropped from the sync.
- tests: manager retry-then-success + retry-exhausted-then-damaged,
  verifying->resolving transition, damaged sync item.
2026-06-17 12:58:43 +02:00

128 lines
4.5 KiB
Go

package engine
import (
"context"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
)
// truncatingMockDownloader writes a SHORT file (failing the on-disk verify) until
// goodOnAttempt, then writes a full file. reportedSize is what each Result claims,
// so verify() compares the advertised size against the (initially truncated) bytes
// on disk — the exact shape of the 2026-06-15 debrid NFS truncation.
type truncatingMockDownloader struct {
dir string
reportedSize int64
goodOnAttempt int // 1-based attempt that finally writes a full file; 0 = never
callCount atomic.Int32
}
func (m *truncatingMockDownloader) Method() DownloadMethod { return MethodTorrent }
func (m *truncatingMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *truncatingMockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
n := int(m.callCount.Add(1))
path := filepath.Join(m.dir, "movie.mkv")
size := int64(10) // truncated stub
if m.goodOnAttempt > 0 && n >= m.goodOnAttempt {
size = m.reportedSize
}
if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil {
return nil, err
}
return &Result{FilePath: path, FileName: "movie.mkv", Method: MethodTorrent, Size: m.reportedSize}, nil
}
func (m *truncatingMockDownloader) Pause(_ string) error { return nil }
func (m *truncatingMockDownloader) Cancel(_ string) error { return nil }
func (m *truncatingMockDownloader) Shutdown(_ context.Context) error { return nil }
// captureReporter builds a ProgressReporter over a mockStatusReporter we keep a
// handle to, so the test can read the final reported StatusUpdate.
func captureReporter() (*ProgressReporter, *mockStatusReporter) {
reporter := &mockStatusReporter{}
return &ProgressReporter{
reporter: reporter,
interval: 50 * time.Millisecond,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}, reporter
}
func terminalUpdate(t *testing.T, r *mockStatusReporter, taskID string) agent.StatusUpdate {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
for i := len(r.calls) - 1; i >= 0; i-- {
c := r.calls[i]
if c.TaskID == taskID && (c.Status == "completed" || c.Status == "failed") {
return c
}
}
t.Fatalf("no terminal (completed/failed) status update for %s", taskID)
return agent.StatusUpdate{}
}
// A truncated download is re-tried clean and, once it lands intact, completes —
// "completed" is never reported for the corrupt attempt.
func TestManagerPipeline_IntegrityRetry_ThenSucceeds(t *testing.T) {
dir := t.TempDir()
pr, reporter := captureReporter()
dl := &truncatingMockDownloader{dir: dir, reportedSize: 10000, goodOnAttempt: 2}
mgr := NewManager(ManagerConfig{MaxConcurrent: 1, OutputDir: dir}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
const taskID = "integrity-retry-ok-123456"
mgr.Submit(ctx, agent.Task{
ID: taskID, InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Retry Test", PreferredMethod: "torrent",
})
mgr.Wait()
if got := dl.callCount.Load(); got != 2 {
t.Errorf("download attempts = %d, want 2 (1 truncated + 1 clean)", got)
}
if u := terminalUpdate(t, reporter, taskID); u.Status != "completed" {
t.Errorf("final status = %q (%s), want completed", u.Status, u.ErrorMessage)
}
}
// A persistently-truncated download exhausts the bounded retries and is surfaced
// as damaged (failed + the stable corrupt-download marker), never completed.
func TestManagerPipeline_IntegrityRetry_ExhaustsThenDamaged(t *testing.T) {
dir := t.TempDir()
pr, reporter := captureReporter()
dl := &truncatingMockDownloader{dir: dir, reportedSize: 10000, goodOnAttempt: 0}
mgr := NewManager(ManagerConfig{MaxConcurrent: 1, OutputDir: dir}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
const taskID = "integrity-retry-bad-123456"
mgr.Submit(ctx, agent.Task{
ID: taskID, InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Damaged Test", PreferredMethod: "torrent",
})
mgr.Wait()
if got := dl.callCount.Load(); got != 3 {
t.Errorf("download attempts = %d, want 3 (bounded retries)", got)
}
u := terminalUpdate(t, reporter, taskID)
if u.Status != "failed" {
t.Fatalf("final status = %q, want failed", u.Status)
}
if !strings.HasPrefix(u.ErrorMessage, damagedErrorPrefix) {
t.Errorf("error message = %q, want prefix %q", u.ErrorMessage, damagedErrorPrefix)
}
}