unarr/internal/usenet/download/progress_expand_test.go
Deivid Soto 3e0f3a5a64
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
feat(cli): upgrade command, rich status, and version cache
- Replace `upgrade` stub with real command (alias for `self-update`)
- Also register `update` as alias: `unarr update` works too
- Rewrite `status` to show full config, disk usage, daemon state, and
  update availability with colored sections
- Add version check cache (1h TTL) so `status` is instant on repeat runs
- Guard against division by zero on empty filesystems
- Guard against negative durations from clock skew
- Guard against stale PID via heartbeat recency check (2 min)
- Add comprehensive test coverage across agent, engine, upgrade, usenet,
  arr, library, mediaserver, and UI packages
- Improve Makefile coverage target to exclude cmd/ glue code
- Fix stream handler resource cleanup and ffprobe error handling
2026-03-31 22:05:43 +02:00

632 lines
16 KiB
Go

package download
import (
"encoding/binary"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/usenet/nzb"
)
// --- Fingerprint ---
func TestFingerprint_EmptyNZB(t *testing.T) {
n := &nzb.NZB{}
fp := Fingerprint(n)
// Empty NZB should still produce a deterministic hash (of zero message IDs).
fp2 := Fingerprint(n)
if fp != fp2 {
t.Fatal("fingerprint of empty NZB should be deterministic")
}
}
func TestFingerprint_OrderIndependent(t *testing.T) {
// Fingerprint sorts IDs, so different file order should produce the same hash.
n1 := &nzb.NZB{
Files: []nzb.File{
{Segments: []nzb.Segment{{MessageID: "a@x"}, {MessageID: "b@x"}}},
{Segments: []nzb.Segment{{MessageID: "c@x"}}},
},
}
n2 := &nzb.NZB{
Files: []nzb.File{
{Segments: []nzb.Segment{{MessageID: "c@x"}}},
{Segments: []nzb.Segment{{MessageID: "b@x"}, {MessageID: "a@x"}}},
},
}
if Fingerprint(n1) != Fingerprint(n2) {
t.Fatal("fingerprint should be order-independent (sorted by message ID)")
}
}
func TestFingerprint_SingleSegment(t *testing.T) {
n := &nzb.NZB{
Files: []nzb.File{
{Segments: []nzb.Segment{{MessageID: "only@one"}}},
},
}
fp := Fingerprint(n)
if fp == [32]byte{} {
t.Fatal("fingerprint should not be zero for a non-empty NZB")
}
}
// --- ProgressTracker MarkDone idempotency ---
func TestProgressTracker_MarkDoneIdempotent(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 5)
tracker := NewProgressTracker("idem", n, dir)
tracker.MarkDone(0, 2)
if tracker.CompletedSegments(0) != 1 {
t.Fatalf("expected 1, got %d", tracker.CompletedSegments(0))
}
// Mark the same segment again — count should not increase.
tracker.MarkDone(0, 2)
if tracker.CompletedSegments(0) != 1 {
t.Fatalf("idempotent mark: expected 1, got %d", tracker.CompletedSegments(0))
}
}
// --- ProgressTracker TotalCompleted ---
func TestProgressTracker_TotalCompleted(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(3, 4) // 3 files, 4 segs each
tracker := NewProgressTracker("total", n, dir)
tracker.MarkDone(0, 0)
tracker.MarkDone(0, 1)
tracker.MarkDone(1, 3)
tracker.MarkDone(2, 0)
tracker.MarkDone(2, 1)
tracker.MarkDone(2, 2)
if got := tracker.TotalCompleted(); got != 6 {
t.Errorf("TotalCompleted: got %d, want 6", got)
}
}
func TestProgressTracker_TotalCompleted_Empty(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(2, 3)
tracker := NewProgressTracker("empty-total", n, dir)
if got := tracker.TotalCompleted(); got != 0 {
t.Errorf("TotalCompleted on fresh tracker: got %d, want 0", got)
}
}
// --- CompletedBytes edge cases ---
func TestProgressTracker_CompletedBytes_OutOfBounds(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("cb-oob", n, dir)
if got := tracker.CompletedBytes(-1, n.Files[0].Segments); got != 0 {
t.Errorf("CompletedBytes with file -1: got %d, want 0", got)
}
if got := tracker.CompletedBytes(5, n.Files[0].Segments); got != 0 {
t.Errorf("CompletedBytes with file 5: got %d, want 0", got)
}
}
func TestProgressTracker_CompletedBytes_AllDone(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("cb-all", n, dir)
for i := 0; i < 3; i++ {
tracker.MarkDone(0, i)
}
got := tracker.CompletedBytes(0, n.Files[0].Segments)
expected := int64(3 * 750 * 1024)
if got != expected {
t.Errorf("CompletedBytes all done: got %d, want %d", got, expected)
}
}
// --- CompletedSegments out of bounds ---
func TestProgressTracker_CompletedSegments_OutOfBounds(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("cs-oob", n, dir)
if got := tracker.CompletedSegments(-1); got != 0 {
t.Errorf("CompletedSegments(-1) = %d, want 0", got)
}
if got := tracker.CompletedSegments(99); got != 0 {
t.Errorf("CompletedSegments(99) = %d, want 0", got)
}
}
// --- Load with corrupted / truncated data ---
func TestProgressTracker_Load_TruncatedHeader(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("trunc", n, dir)
// Write too-short data
os.WriteFile(tracker.progressPath(), []byte("UNR"), 0o644)
loaded, err := tracker.Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if loaded {
t.Error("truncated header should not load")
}
}
func TestProgressTracker_Load_BadMagic(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("badmagic", n, dir)
// Write data with wrong magic bytes
data := make([]byte, headerSize+10)
copy(data[0:4], []byte("BAAD"))
os.WriteFile(tracker.progressPath(), data, 0o644)
loaded, err := tracker.Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if loaded {
t.Error("bad magic should not load")
}
}
func TestProgressTracker_Load_BadVersion(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("badver", n, dir)
data := make([]byte, headerSize+10)
copy(data[0:4], progressMagic[:])
data[4] = 99 // unsupported version
os.WriteFile(tracker.progressPath(), data, 0o644)
loaded, err := tracker.Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if loaded {
t.Error("bad version should not load")
}
}
func TestProgressTracker_Load_WrongFileCount(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(2, 3)
tracker := NewProgressTracker("wrongfc", n, dir)
data := make([]byte, headerSize+20)
copy(data[0:4], progressMagic[:])
data[4] = progressVersion
binary.LittleEndian.PutUint16(data[6:8], 99) // wrong file count
copy(data[8:40], tracker.fingerprint[:])
os.WriteFile(tracker.progressPath(), data, 0o644)
loaded, err := tracker.Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if loaded {
t.Error("wrong file count should not load")
}
}
func TestProgressTracker_Load_TruncatedBitset(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 16)
tracker := NewProgressTracker("truncbit", n, dir)
// Build a valid header but truncate the bitset data
data := make([]byte, headerSize+4) // header + segCount but no bitset
copy(data[0:4], progressMagic[:])
data[4] = progressVersion
binary.LittleEndian.PutUint16(data[6:8], 1) // 1 file
copy(data[8:40], tracker.fingerprint[:])
binary.LittleEndian.PutUint32(data[headerSize:headerSize+4], 16) // 16 segs
// No bitset data follows — truncated
os.WriteFile(tracker.progressPath(), data, 0o644)
loaded, err := tracker.Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if loaded {
t.Error("truncated bitset should not load")
}
}
func TestProgressTracker_Load_SegCountMismatch(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 5)
tracker := NewProgressTracker("segmis", n, dir)
// Build valid header with correct file count and fingerprint, but wrong segCount
bitsetLen := (999 + 7) / 8
data := make([]byte, headerSize+4+bitsetLen)
copy(data[0:4], progressMagic[:])
data[4] = progressVersion
binary.LittleEndian.PutUint16(data[6:8], 1)
copy(data[8:40], tracker.fingerprint[:])
binary.LittleEndian.PutUint32(data[headerSize:headerSize+4], 999) // wrong seg count
os.WriteFile(tracker.progressPath(), data, 0o644)
loaded, err := tracker.Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if loaded {
t.Error("segment count mismatch should not load")
}
}
func TestProgressTracker_Load_NonexistentFile(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("nofile", n, dir)
loaded, err := tracker.Load()
if err != nil {
t.Fatalf("unexpected error for missing file: %v", err)
}
if loaded {
t.Error("nonexistent file should return false")
}
}
// --- Flush and Load round-trip with multiple files ---
func TestProgressTracker_MultiFileRoundTrip(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(5, 10) // 5 files, 10 segments each
tracker := NewProgressTracker("multi-rt", n, dir)
// Mark various segments across files
tracker.MarkDone(0, 0)
tracker.MarkDone(0, 9)
tracker.MarkDone(1, 5)
tracker.MarkDone(2, 0)
tracker.MarkDone(2, 1)
tracker.MarkDone(2, 2)
tracker.MarkDone(2, 3)
tracker.MarkDone(2, 4)
tracker.MarkDone(2, 5)
tracker.MarkDone(2, 6)
tracker.MarkDone(2, 7)
tracker.MarkDone(2, 8)
tracker.MarkDone(2, 9) // file 2 fully done
tracker.MarkDone(4, 7)
if err := tracker.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
// Reload
tracker2 := NewProgressTracker("multi-rt", n, dir)
loaded, err := tracker2.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if !loaded {
t.Fatal("should load")
}
if tracker2.CompletedSegments(0) != 2 {
t.Errorf("file 0: got %d, want 2", tracker2.CompletedSegments(0))
}
if tracker2.CompletedSegments(1) != 1 {
t.Errorf("file 1: got %d, want 1", tracker2.CompletedSegments(1))
}
if !tracker2.IsFileDone(2) {
t.Error("file 2 should be done")
}
if tracker2.CompletedSegments(3) != 0 {
t.Errorf("file 3: got %d, want 0", tracker2.CompletedSegments(3))
}
if tracker2.CompletedSegments(4) != 1 {
t.Errorf("file 4: got %d, want 1", tracker2.CompletedSegments(4))
}
if tracker2.TotalCompleted() != 14 {
t.Errorf("TotalCompleted: got %d, want 14", tracker2.TotalCompleted())
}
}
// --- Concurrent mark + IsDone reads ---
func TestProgressTracker_ConcurrentMarkAndRead(t *testing.T) {
dir := t.TempDir()
segCount := 500
n := makeTestNZB(2, segCount)
tracker := NewProgressTracker("conc-rw", n, dir)
// Use separate WaitGroups for writers and readers
var writerWg sync.WaitGroup
stop := make(chan struct{})
// Writers
for file := 0; file < 2; file++ {
for seg := 0; seg < segCount; seg++ {
writerWg.Add(1)
go func(f, s int) {
defer writerWg.Done()
tracker.MarkDone(f, s)
}(file, seg)
}
}
// Readers — continuously read while writes happen
var readerWg sync.WaitGroup
for i := 0; i < 4; i++ {
readerWg.Add(1)
go func() {
defer readerWg.Done()
for {
select {
case <-stop:
return
default:
// These should never panic
tracker.IsDone(0, 0)
tracker.IsDone(1, segCount-1)
tracker.IsFileDone(0)
tracker.CompletedSegments(1)
tracker.TotalCompleted()
}
}
}()
}
// Wait for all writers to finish, then stop readers
writerWg.Wait()
close(stop)
readerWg.Wait()
// After all goroutines complete, everything should be done
if !tracker.IsFileDone(0) {
t.Errorf("file 0 should be done, got %d/%d", tracker.CompletedSegments(0), segCount)
}
if !tracker.IsFileDone(1) {
t.Errorf("file 1 should be done, got %d/%d", tracker.CompletedSegments(1), segCount)
}
}
// --- Concurrent flush safety ---
func TestProgressTracker_ConcurrentFlush(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 100)
tracker := NewProgressTracker("conc-flush", n, dir)
// Mark some segments
for i := 0; i < 50; i++ {
tracker.MarkDone(0, i)
}
// Multiple concurrent flushes should not panic or corrupt
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
tracker.Flush()
}()
}
wg.Wait()
// Verify state is loadable
tracker2 := NewProgressTracker("conc-flush", n, dir)
loaded, err := tracker2.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if !loaded {
t.Fatal("should load after concurrent flushes")
}
if tracker2.CompletedSegments(0) != 50 {
t.Errorf("after concurrent flush: got %d, want 50", tracker2.CompletedSegments(0))
}
}
// --- Remove with .tmp file ---
func TestProgressTracker_Remove_WithTmpFile(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("rm-tmp", n, dir)
// Create all three files that Remove should clean up
os.WriteFile(tracker.progressPath(), []byte("data"), 0o644)
os.WriteFile(tracker.nzbPath(), []byte("<nzb/>"), 0o644)
os.WriteFile(tracker.progressPath()+".tmp", []byte("tmp"), 0o644)
tracker.Remove()
for _, p := range []string{tracker.progressPath(), tracker.nzbPath(), tracker.progressPath() + ".tmp"} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("file should be removed: %s", p)
}
}
}
// --- CleanStaleFiles edge cases ---
func TestCleanStaleFiles_EmptyDir(t *testing.T) {
dir := t.TempDir()
if got := CleanStaleFiles(dir, time.Hour); got != 0 {
t.Errorf("empty dir: got %d removed, want 0", got)
}
}
func TestCleanStaleFiles_NonexistentDir(t *testing.T) {
if got := CleanStaleFiles("/nonexistent/path/that/does/not/exist", time.Hour); got != 0 {
t.Errorf("nonexistent dir: got %d removed, want 0", got)
}
}
func TestCleanStaleFiles_AllFresh(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "a.progress"), []byte("a"), 0o644)
os.WriteFile(filepath.Join(dir, "b.progress"), []byte("b"), 0o644)
if got := CleanStaleFiles(dir, 24*time.Hour); got != 0 {
t.Errorf("all fresh: got %d removed, want 0", got)
}
}
func TestCleanStaleFiles_SkipsSubdirs(t *testing.T) {
dir := t.TempDir()
subDir := filepath.Join(dir, "subdir")
os.MkdirAll(subDir, 0o755)
// Backdate the subdir (it should not be removed)
os.Chtimes(subDir, fixedPast, fixedPast)
if got := CleanStaleFiles(dir, 24*time.Hour); got != 0 {
t.Errorf("should skip subdirs: got %d removed, want 0", got)
}
if _, err := os.Stat(subDir); err != nil {
t.Error("subdir should still exist")
}
}
func TestCleanStaleFiles_MixedAges(t *testing.T) {
dir := t.TempDir()
stale1 := filepath.Join(dir, "old1.progress")
stale2 := filepath.Join(dir, "old2.nzb")
fresh := filepath.Join(dir, "new.progress")
os.WriteFile(stale1, []byte("x"), 0o644)
os.WriteFile(stale2, []byte("x"), 0o644)
os.WriteFile(fresh, []byte("x"), 0o644)
os.Chtimes(stale1, fixedPast, fixedPast)
os.Chtimes(stale2, fixedPast, fixedPast)
if got := CleanStaleFiles(dir, 7*24*time.Hour); got != 2 {
t.Errorf("mixed ages: got %d removed, want 2", got)
}
if _, err := os.Stat(fresh); err != nil {
t.Error("fresh file should still exist")
}
}
// --- progressPath / nzbPath ---
func TestProgressTracker_Paths(t *testing.T) {
dir := "/some/dir"
n := makeTestNZB(1, 1)
tracker := NewProgressTracker("my-task", n, dir)
if got := tracker.progressPath(); got != filepath.Join(dir, "my-task.progress") {
t.Errorf("progressPath: got %q", got)
}
if got := tracker.nzbPath(); got != filepath.Join(dir, "my-task.nzb") {
t.Errorf("nzbPath: got %q", got)
}
}
// --- formatBytes ---
func TestFormatBytes(t *testing.T) {
tests := []struct {
input int64
want string
}{
{0, "0 B"},
{500, "500 B"},
{1023, "1023 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{1048576, "1.0 MB"},
{1073741824, "1.0 GB"},
{1099511627776, "1.0 TB"},
}
for _, tt := range tests {
got := formatBytes(tt.input)
if got != tt.want {
t.Errorf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want)
}
}
}
// --- Single file with 1 segment (boundary) ---
func TestProgressTracker_SingleSegment(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 1)
tracker := NewProgressTracker("single-seg", n, dir)
if tracker.IsFileDone(0) {
t.Error("should not be done initially")
}
tracker.MarkDone(0, 0)
if !tracker.IsFileDone(0) {
t.Error("should be done after marking the only segment")
}
if err := tracker.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
tracker2 := NewProgressTracker("single-seg", n, dir)
loaded, _ := tracker2.Load()
if !loaded {
t.Fatal("should load")
}
if !tracker2.IsFileDone(0) {
t.Error("should be done after reload")
}
}
// --- Flush creates directory if missing ---
func TestProgressTracker_FlushCreatesDir(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "nested", "resume")
n := makeTestNZB(1, 2)
tracker := NewProgressTracker("mkdir-test", n, dir)
tracker.MarkDone(0, 0)
if err := tracker.Flush(); err != nil {
t.Fatalf("flush should create dir: %v", err)
}
if _, err := os.Stat(tracker.progressPath()); err != nil {
t.Fatalf("progress file should exist: %v", err)
}
}
// --- Double flush after no new marks ---
func TestProgressTracker_DoubleFlush(t *testing.T) {
dir := t.TempDir()
n := makeTestNZB(1, 3)
tracker := NewProgressTracker("dbl-flush", n, dir)
tracker.MarkDone(0, 0)
if err := tracker.Flush(); err != nil {
t.Fatalf("first flush: %v", err)
}
// Second flush without new marks should be a no-op (dirty=false)
if err := tracker.Flush(); err != nil {
t.Fatalf("second flush: %v", err)
}
}