feat(cli): upgrade command, rich status, and version cache
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s

- 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
This commit is contained in:
Deivid Soto 2026-03-31 22:05:43 +02:00
parent 01d62ffa13
commit 3e0f3a5a64
33 changed files with 7084 additions and 65 deletions

View file

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

View file

@ -0,0 +1,131 @@
package nntp
import (
"bufio"
"bytes"
"testing"
)
func TestNewClient(t *testing.T) {
c := NewClient(Config{Host: "news.example.com", Port: 563, SSL: true})
if c.cfg.MaxConnections != 10 {
t.Errorf("default MaxConnections = %d, want 10", c.cfg.MaxConnections)
}
if c.cfg.Host != "news.example.com" {
t.Errorf("Host = %q", c.cfg.Host)
}
}
func TestNewClientCustomConnections(t *testing.T) {
c := NewClient(Config{Host: "news.example.com", Port: 563, MaxConnections: 20})
if c.cfg.MaxConnections != 20 {
t.Errorf("MaxConnections = %d, want 20", c.cfg.MaxConnections)
}
}
func TestNewClientZeroConnections(t *testing.T) {
c := NewClient(Config{Host: "news.example.com", Port: 563, MaxConnections: 0})
if c.cfg.MaxConnections != 10 {
t.Errorf("MaxConnections should default to 10, got %d", c.cfg.MaxConnections)
}
}
func TestNewClientNegativeConnections(t *testing.T) {
c := NewClient(Config{MaxConnections: -5})
if c.cfg.MaxConnections != 10 {
t.Errorf("MaxConnections should default to 10 for negative, got %d", c.cfg.MaxConnections)
}
}
func TestActiveConnections(t *testing.T) {
c := NewClient(Config{Host: "localhost", Port: 119})
if c.ActiveConnections() != 0 {
t.Errorf("ActiveConnections = %d, want 0", c.ActiveConnections())
}
}
func TestStatus(t *testing.T) {
c := NewClient(Config{Host: "news.example.com", Port: 563})
s := c.Status()
if s != "0 connections (0 pooled) to news.example.com:563" {
t.Errorf("Status = %q", s)
}
}
func TestCloseIdempotent(t *testing.T) {
c := NewClient(Config{Host: "localhost", Port: 119})
// Close should be idempotent
if err := c.Close(); err != nil {
t.Errorf("first Close: %v", err)
}
if err := c.Close(); err != nil {
t.Errorf("second Close: %v", err)
}
}
func TestArticleNotFoundError(t *testing.T) {
err := &ArticleNotFoundError{MessageID: "abc123@news.example.com"}
msg := err.Error()
if msg != "nntp: article not found: abc123@news.example.com" {
t.Errorf("Error() = %q", msg)
}
}
func TestReadDotBody(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
"simple body",
"Hello World\r\n.\r\n",
"Hello World\n",
},
{
"multiline",
"Line 1\r\nLine 2\r\nLine 3\r\n.\r\n",
"Line 1\nLine 2\nLine 3\n",
},
{
"dot-stuffed line",
"..This starts with a dot\r\n.\r\n",
".This starts with a dot\n",
},
{
"empty body",
".\r\n",
"",
},
{
"binary-like data",
"=ybegin line=128 size=1024 name=test.bin\r\nsome encoded data\r\n=yend\r\n.\r\n",
"=ybegin line=128 size=1024 name=test.bin\nsome encoded data\n=yend\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := bufio.NewReader(bytes.NewBufferString(tt.input))
got, err := readDotBody(r)
if err != nil {
t.Fatalf("readDotBody: %v", err)
}
if string(got) != tt.want {
t.Errorf("readDotBody = %q, want %q", string(got), tt.want)
}
})
}
}
func TestReadDotBodyEOF(t *testing.T) {
// No dot terminator — should read until EOF
r := bufio.NewReader(bytes.NewBufferString("partial data\r\n"))
got, err := readDotBody(r)
if err != nil {
t.Fatalf("readDotBody EOF: %v", err)
}
if string(got) != "partial data\n" {
t.Errorf("readDotBody EOF = %q", string(got))
}
}

View file

@ -267,3 +267,784 @@ func TestStripAngleBrackets(t *testing.T) {
t.Errorf("MessageID not stripped: got %q", nzb.Files[0].Segments[0].MessageID)
}
}
// --- Malformed / edge-case XML inputs ---
func TestParse_CompletelyEmpty(t *testing.T) {
_, err := Parse(strings.NewReader(""))
if err == nil {
t.Error("expected error for completely empty input")
}
}
func TestParse_OnlyWhitespace(t *testing.T) {
_, err := Parse(strings.NewReader(" \n\t "))
if err == nil {
t.Error("expected error for whitespace-only input")
}
}
func TestParse_ValidXMLButNotNZB(t *testing.T) {
_, err := Parse(strings.NewReader(`<?xml version="1.0"?><html><body>Hello</body></html>`))
if err == nil {
t.Error("expected error for non-NZB XML")
}
}
func TestParse_NZBWithNoSegments(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments></segments>
</file>
</nzb>`
_, err := Parse(strings.NewReader(xml))
if err == nil {
t.Error("expected error for file with no segments")
}
}
func TestParse_SegmentWithEmptyMessageID(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1"> </segment>
</segments>
</file>
</nzb>`
_, err := Parse(strings.NewReader(xml))
if err == nil {
t.Error("expected error: segment with empty/whitespace message ID should be skipped, leaving no valid files")
}
}
func TestParse_MixedValidAndEmptySegments(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1">valid@id</segment>
<segment bytes="200" number="2"> </segment>
<segment bytes="300" number="3">also-valid@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(nzb.Files[0].Segments) != 2 {
t.Errorf("expected 2 valid segments, got %d", len(nzb.Files[0].Segments))
}
}
// --- Metadata / Head parsing ---
func TestParse_MetaPassword(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<head>
<meta type="password">s3cr3t</meta>
<meta type="title">My Movie</meta>
<meta type="category">Movies</meta>
</head>
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if nzb.Password != "s3cr3t" {
t.Errorf("Password: got %q, want %q", nzb.Password, "s3cr3t")
}
if nzb.Meta["title"] != "My Movie" {
t.Errorf("Meta title: got %q", nzb.Meta["title"])
}
if nzb.Meta["category"] != "Movies" {
t.Errorf("Meta category: got %q", nzb.Meta["category"])
}
}
func TestParse_MetaPasswordWithWhitespace(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<head>
<meta type="password"> padded </meta>
</head>
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if nzb.Password != "padded" {
t.Errorf("Password should be trimmed: got %q", nzb.Password)
}
}
func TestParse_NoHead(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if nzb.Password != "" {
t.Errorf("Password should be empty: got %q", nzb.Password)
}
if len(nzb.Meta) != 0 {
t.Errorf("Meta should be empty: got %v", nzb.Meta)
}
}
func TestParse_MetaWithEmptyType(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<head>
<meta type="">ignored</meta>
<meta type="name">kept</meta>
</head>
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if _, ok := nzb.Meta[""]; ok {
t.Error("empty-type meta should not be stored")
}
if nzb.Meta["name"] != "kept" {
t.Errorf("Meta name: got %q", nzb.Meta["name"])
}
}
// --- Multiple files ---
func TestParse_MultipleFilesVariousTypes(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="bot" date="1700000000" subject="&quot;movie.mkv&quot; yEnc (1/100)">
<groups><group>alt.binaries.movies</group></groups>
<segments>
<segment bytes="768000" number="1">mkv001@ex</segment>
<segment bytes="768000" number="2">mkv002@ex</segment>
</segments>
</file>
<file poster="bot" date="1700000000" subject="&quot;movie.nfo&quot; yEnc (1/1)">
<groups><group>alt.binaries.movies</group></groups>
<segments>
<segment bytes="4096" number="1">nfo001@ex</segment>
</segments>
</file>
<file poster="bot" date="1700000000" subject="&quot;movie.par2&quot; yEnc (1/1)">
<groups><group>alt.binaries.movies</group></groups>
<segments>
<segment bytes="32768" number="1">par001@ex</segment>
</segments>
</file>
<file poster="bot" date="1700000000" subject="&quot;movie.vol0+1.par2&quot; yEnc (1/1)">
<groups><group>alt.binaries.movies</group></groups>
<segments>
<segment bytes="65536" number="1">parv001@ex</segment>
</segments>
</file>
<file poster="bot" date="1700000000" subject="&quot;sample.mkv&quot; yEnc (1/1)">
<groups><group>alt.binaries.movies</group></groups>
<segments>
<segment bytes="10000" number="1">sample001@ex</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(nzb.Files) != 5 {
t.Fatalf("expected 5 files, got %d", len(nzb.Files))
}
// ContentFiles should exclude nfo, par2, par2 vol, and sample
content := nzb.ContentFiles()
if len(content) != 1 {
t.Errorf("ContentFiles: got %d, want 1", len(content))
}
if len(content) > 0 && content[0].Filename() != "movie.mkv" {
t.Errorf("ContentFiles[0]: got %q, want movie.mkv", content[0].Filename())
}
// Par2Files
par2 := nzb.Par2Files()
if len(par2) != 2 {
t.Errorf("Par2Files: got %d, want 2", len(par2))
}
if !nzb.HasPar2() {
t.Error("HasPar2 should be true")
}
if nzb.HasRars() {
t.Error("HasRars should be false for this NZB")
}
}
// --- Segment ordering / number parsing ---
func TestParse_SegmentNumberParsing(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="3">c@id</segment>
<segment bytes="200" number="1">a@id</segment>
<segment bytes="300" number="2">b@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
segs := nzb.Files[0].Segments
if len(segs) != 3 {
t.Fatalf("expected 3 segments, got %d", len(segs))
}
// Parse preserves order from XML; sorting is done by the downloader
// Verify numbers are parsed correctly
numbers := make(map[int]bool)
for _, s := range segs {
numbers[s.Number] = true
}
for _, want := range []int{1, 2, 3} {
if !numbers[want] {
t.Errorf("missing segment number %d", want)
}
}
}
func TestParse_SegmentBytesZero(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="0" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if nzb.Files[0].Segments[0].Bytes != 0 {
t.Errorf("expected 0 bytes, got %d", nzb.Files[0].Segments[0].Bytes)
}
}
func TestParse_SegmentBytesNonNumeric(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="abc" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Non-numeric bytes should parse as 0
if nzb.Files[0].Segments[0].Bytes != 0 {
t.Errorf("non-numeric bytes should be 0, got %d", nzb.Files[0].Segments[0].Bytes)
}
}
// --- File helper methods ---
func TestFileTotalBytes(t *testing.T) {
f := File{
Segments: []Segment{
{Bytes: 100}, {Bytes: 200}, {Bytes: 300},
},
}
if got := f.TotalBytes(); got != 600 {
t.Errorf("TotalBytes: got %d, want 600", got)
}
}
func TestFileTotalBytes_Empty(t *testing.T) {
f := File{}
if got := f.TotalBytes(); got != 0 {
t.Errorf("TotalBytes of empty file: got %d, want 0", got)
}
}
func TestFileExtension_Various(t *testing.T) {
tests := []struct {
subject string
want string
}{
{`"file.MKV" yEnc`, ".mkv"},
{`"file.RAR" yEnc`, ".rar"},
{`"file.Par2" yEnc`, ".par2"},
{`"noext" yEnc`, ""},
{`"file.tar.gz" yEnc`, ".gz"},
}
for _, tt := range tests {
f := File{Subject: tt.subject}
if got := f.Extension(); got != tt.want {
t.Errorf("Extension(%q) = %q, want %q", tt.subject, got, tt.want)
}
}
}
// --- LargestFile edge cases ---
func TestLargestFile_EmptyNZB(t *testing.T) {
nzb := &NZB{}
if nzb.LargestFile() != nil {
t.Error("LargestFile should return nil for empty NZB")
}
}
func TestLargestFile_SingleFile(t *testing.T) {
nzb := &NZB{
Files: []File{
{Subject: `"only.bin"`, Segments: []Segment{{Bytes: 100}}},
},
}
largest := nzb.LargestFile()
if largest == nil {
t.Fatal("LargestFile should not be nil")
}
if largest.Filename() != "only.bin" {
t.Errorf("got %q", largest.Filename())
}
}
func TestLargestFile_MultipleSameSize(t *testing.T) {
nzb := &NZB{
Files: []File{
{Subject: `"a.bin"`, Segments: []Segment{{Bytes: 100}}},
{Subject: `"b.bin"`, Segments: []Segment{{Bytes: 100}}},
},
}
largest := nzb.LargestFile()
if largest == nil {
t.Fatal("LargestFile should not be nil")
}
// Should return the first one (stable)
if largest.Filename() != "a.bin" {
t.Errorf("got %q, expected first file for equal sizes", largest.Filename())
}
}
// --- IsObfuscated ---
func TestIsObfuscated_Normal(t *testing.T) {
nzb := &NZB{
Files: []File{
{Subject: `"Movie.2024.1080p.BluRay.x264-GROUP.mkv"`},
},
}
if nzb.IsObfuscated() {
t.Error("normal filename should not be obfuscated")
}
}
func TestIsObfuscated_HexName(t *testing.T) {
nzb := &NZB{
Files: []File{
{Subject: `"a1b2c3d4e5f6a7b8c9d0e1f2.mkv"`},
},
}
if !nzb.IsObfuscated() {
t.Error("hex-like filename should be obfuscated")
}
}
func TestIsObfuscated_EmptyFiles(t *testing.T) {
nzb := &NZB{}
if nzb.IsObfuscated() {
t.Error("empty NZB should not be obfuscated")
}
}
func TestIsObfuscated_ShortHex(t *testing.T) {
// Short name (<=10 chars) should not trigger obfuscation
nzb := &NZB{
Files: []File{
{Subject: `"abcdef.mkv"`},
},
}
if nzb.IsObfuscated() {
t.Error("short hex-like name should not be obfuscated")
}
}
// --- isMetadataFile ---
func TestIsMetadataFile(t *testing.T) {
tests := []struct {
name string
want bool
}{
{"file.par2", true},
{"file.nfo", true},
{"file.sfv", true},
{"file.nzb", true},
{"file.txt", true},
{"file.jpg", true},
{"file.png", true},
{"file.url", true},
{"file.mkv", false},
{"file.rar", false},
{"file.avi", false},
{"FILE.PAR2", true},
{"FILE.NFO", true},
}
for _, tt := range tests {
if got := isMetadataFile(tt.name); got != tt.want {
t.Errorf("isMetadataFile(%q) = %v, want %v", tt.name, got, tt.want)
}
}
}
// --- isSampleFile ---
func TestIsSampleFile(t *testing.T) {
tests := []struct {
name string
want bool
}{
{"movie.sample.mkv", true},
{"Sample.mkv", true},
{"SAMPLE.avi", true},
{"movie-sample-video.mkv", true},
{"movie_sample.mkv", true},
{"sample.mkv", true},
{"resampled.mkv", false}, // "sample" is part of "resampled"
{"movie.mkv", false},
{"my.samples.zip", false}, // "sample" followed by 's' (alphanumeric)
}
for _, tt := range tests {
if got := isSampleFile(tt.name); got != tt.want {
t.Errorf("isSampleFile(%q) = %v, want %v", tt.name, got, tt.want)
}
}
}
// --- isHexLike ---
func TestIsHexLike(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"abcdef0123456789", true},
{"ABCDEF", true},
{"Movie2024", false},
{"aabbccdd", true},
{"xyz_not_hex", false},
}
for _, tt := range tests {
if got := isHexLike(tt.input); got != tt.want {
t.Errorf("isHexLike(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
// --- sanitizeFilename ---
func TestSanitizeFilename(t *testing.T) {
tests := []struct {
input string
want string
}{
{"simple name", "simple name"},
{"name (1/50)", "name"},
{"file yEnc (01/99)", "file"},
{`path/with\special:chars*?`, `path_with_special_chars__`},
{`"quoted" text`, `_quoted_ text`},
{" spaces ", "spaces"},
}
for _, tt := range tests {
if got := sanitizeFilename(tt.input); got != tt.want {
t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// --- Filename fallback ---
func TestFilename_Fallback_NoQuotes(t *testing.T) {
f := File{Subject: "No quotes here yEnc (1/50)"}
got := f.Filename()
if got != "No quotes here" {
t.Errorf("Filename fallback: got %q, want %q", got, "No quotes here")
}
}
func TestFilename_EmptySubject(t *testing.T) {
f := File{Subject: ""}
got := f.Filename()
if got != "" {
t.Errorf("Filename empty subject: got %q, want empty", got)
}
}
// --- NZB aggregate methods on mixed content ---
func TestNZB_HasRars_NoRars(t *testing.T) {
nzb := &NZB{
Files: []File{
{Subject: `"movie.mkv"`},
{Subject: `"movie.par2"`},
},
}
if nzb.HasRars() {
t.Error("HasRars should be false")
}
}
func TestNZB_HasPar2_NoPar2(t *testing.T) {
nzb := &NZB{
Files: []File{
{Subject: `"movie.mkv"`},
{Subject: `"movie.rar"`},
},
}
if nzb.HasPar2() {
t.Error("HasPar2 should be false")
}
}
func TestNZB_TotalSegments_MultiFile(t *testing.T) {
nzb := &NZB{
Files: []File{
{Segments: []Segment{{}, {}, {}}},
{Segments: []Segment{{}, {}}},
},
}
if got := nzb.TotalSegments(); got != 5 {
t.Errorf("TotalSegments: got %d, want 5", got)
}
}
func TestNZB_TotalBytes_MultiFile(t *testing.T) {
nzb := &NZB{
Files: []File{
{Segments: []Segment{{Bytes: 100}, {Bytes: 200}}},
{Segments: []Segment{{Bytes: 300}}},
},
}
if got := nzb.TotalBytes(); got != 600 {
t.Errorf("TotalBytes: got %d, want 600", got)
}
}
// --- isRarFile extended ---
func TestIsRarFile_Extended(t *testing.T) {
tests := []struct {
name string
want bool
}{
{"file.RAR", true}, // case insensitive
{"file.Rar", true},
{"file.s01", true},
{"file.s99", true},
{"file.002", true},
{"file.999", true},
{"file.r0", false}, // too short extension
{"file.rXX", false}, // non-numeric
{"file", false}, // no extension
{"file.mp4", false},
}
for _, tt := range tests {
if got := isRarFile(tt.name); got != tt.want {
t.Errorf("isRarFile(%q) = %v, want %v", tt.name, got, tt.want)
}
}
}
// --- Parse with date edge cases ---
func TestParse_DateNonNumeric(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="not-a-number" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if nzb.Files[0].Date != 0 {
t.Errorf("non-numeric date should be 0, got %d", nzb.Files[0].Date)
}
}
func TestParse_DateEmpty(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="" subject="&quot;test.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if nzb.Files[0].Date != 0 {
t.Errorf("empty date should be 0, got %d", nzb.Files[0].Date)
}
}
// --- Parse: file with all segments having empty IDs should be excluded ---
func TestParse_AllEmptySegments(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;bad.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1"> </segment>
<segment bytes="200" number="2"></segment>
</segments>
</file>
<file poster="test" date="0" subject="&quot;good.bin&quot;">
<groups><group>alt.test</group></groups>
<segments>
<segment bytes="100" number="1">valid@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(nzb.Files) != 1 {
t.Fatalf("expected 1 valid file, got %d", len(nzb.Files))
}
if nzb.Files[0].Filename() != "good.bin" {
t.Errorf("expected good.bin, got %q", nzb.Files[0].Filename())
}
}
// --- Groups ---
func TestParse_NoGroups(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups></groups>
<segments>
<segment bytes="100" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(nzb.Files[0].Groups) != 0 {
t.Errorf("expected 0 groups, got %d", len(nzb.Files[0].Groups))
}
}
func TestParse_MultipleGroups(t *testing.T) {
xml := `<?xml version="1.0"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test" date="0" subject="&quot;test.bin&quot;">
<groups>
<group>alt.binaries.movies</group>
<group>alt.binaries.multimedia</group>
<group>alt.binaries.hdtv</group>
</groups>
<segments>
<segment bytes="100" number="1">seg@id</segment>
</segments>
</file>
</nzb>`
nzb, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(nzb.Files[0].Groups) != 3 {
t.Errorf("expected 3 groups, got %d", len(nzb.Files[0].Groups))
}
}
// --- ContentFiles with sample variations ---
func TestContentFiles_ExcludesSamples(t *testing.T) {
nzb := &NZB{
Files: []File{
{Subject: `"movie.mkv"`, Segments: []Segment{{Bytes: 1000, MessageID: "a"}}},
{Subject: `"movie.sample.mkv"`, Segments: []Segment{{Bytes: 100, MessageID: "b"}}},
{Subject: `"Sample/preview.mkv"`, Segments: []Segment{{Bytes: 100, MessageID: "c"}}},
},
}
content := nzb.ContentFiles()
if len(content) != 1 {
t.Errorf("ContentFiles should exclude samples: got %d, want 1", len(content))
}
}
// --- RarFiles with split naming ---
func TestRarFiles_SplitRars(t *testing.T) {
nzb := &NZB{
Files: []File{
{Subject: `"movie.rar"`, Segments: []Segment{{MessageID: "a"}}},
{Subject: `"movie.r00"`, Segments: []Segment{{MessageID: "b"}}},
{Subject: `"movie.r01"`, Segments: []Segment{{MessageID: "c"}}},
{Subject: `"movie.001"`, Segments: []Segment{{MessageID: "d"}}},
{Subject: `"movie.002"`, Segments: []Segment{{MessageID: "e"}}},
{Subject: `"movie.par2"`, Segments: []Segment{{MessageID: "f"}}},
{Subject: `"movie.mkv"`, Segments: []Segment{{MessageID: "g"}}},
},
}
rars := nzb.RarFiles()
if len(rars) != 5 {
t.Errorf("RarFiles: got %d, want 5", len(rars))
}
}

View file

@ -0,0 +1,170 @@
package postprocess
import (
"os"
"path/filepath"
"testing"
)
func TestIsArchiveFile(t *testing.T) {
tests := []struct {
name string
want bool
}{
{"movie.rar", true},
{"movie.RAR", true},
{"movie.part01.rar", true},
{"movie.r00", true},
{"movie.r99", true},
{"movie.s00", true},
{"movie.001", true},
{"movie.099", true},
{"movie.mkv", false},
{"movie.mp4", false},
{"movie.par2", false},
{"movie.nfo", false},
{"movie.txt", false},
{"movie.r", false},
{"movie.abc", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isArchiveFile(tt.name)
if got != tt.want {
t.Errorf("isArchiveFile(%q) = %v, want %v", tt.name, got, tt.want)
}
})
}
}
func TestIsCleanupTarget(t *testing.T) {
tests := []struct {
name string
want bool
}{
{"content.par2", true},
{"content.PAR2", true},
{"info.nfo", true},
{"checksum.sfv", true},
{"content.nzb", true},
{"content.srr", true},
{"content.srs", true},
{"cover.jpg", true},
{"cover.png", true},
{"readme.txt", true},
{"link.url", true},
{"movie.rar", true},
{"movie.r00", true},
{"movie.s01", true},
{"movie.001", true},
{"movie.mkv", false},
{"movie.mp4", false},
{"movie.avi", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isCleanupTarget(tt.name)
if got != tt.want {
t.Errorf("isCleanupTarget(%q) = %v, want %v", tt.name, got, tt.want)
}
})
}
}
func TestIsNumeric(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"", false},
{"0", true},
{"123", true},
{"00", true},
{"12a", false},
{"abc", false},
{" 1", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isNumeric(tt.input)
if got != tt.want {
t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestListExtractedFiles(t *testing.T) {
dir := t.TempDir()
// Create some files
os.WriteFile(filepath.Join(dir, "movie.mkv"), []byte("video"), 0o644)
os.WriteFile(filepath.Join(dir, "subs.srt"), []byte("subs"), 0o644)
os.WriteFile(filepath.Join(dir, "movie.rar"), []byte("archive"), 0o644)
os.WriteFile(filepath.Join(dir, "movie.r00"), []byte("archive part"), 0o644)
archivePath := filepath.Join(dir, "movie.rar")
files, err := listExtractedFiles(dir, archivePath)
if err != nil {
t.Fatalf("listExtractedFiles: %v", err)
}
// Should exclude .rar and .r00 (archive files in same dir)
// Should include movie.mkv and subs.srt
if len(files) != 2 {
t.Errorf("expected 2 files, got %d: %v", len(files), files)
}
for _, f := range files {
base := filepath.Base(f)
if base != "movie.mkv" && base != "subs.srt" {
t.Errorf("unexpected file: %s", base)
}
}
}
func TestCleanup(t *testing.T) {
dir := t.TempDir()
// Files that should be removed
cleanupFiles := []string{"content.par2", "info.nfo", "checksum.sfv", "movie.rar", "movie.r00"}
for _, name := range cleanupFiles {
os.WriteFile(filepath.Join(dir, name), []byte("data"), 0o644)
}
// Files that should be kept
keepFiles := []string{"movie.mkv", "subs.srt"}
for _, name := range keepFiles {
os.WriteFile(filepath.Join(dir, name), []byte("data"), 0o644)
}
err := Cleanup(dir)
if err != nil {
t.Fatalf("Cleanup: %v", err)
}
// Verify cleanup files are gone
for _, name := range cleanupFiles {
if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
t.Errorf("expected %s to be removed", name)
}
}
// Verify kept files still exist
for _, name := range keepFiles {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Errorf("expected %s to exist, got: %v", name, err)
}
}
}
func TestPasswordError(t *testing.T) {
err := &PasswordError{Archive: "/tmp/movie.rar"}
msg := err.Error()
if msg != "archive is password protected: /tmp/movie.rar" {
t.Errorf("PasswordError.Error() = %q", msg)
}
}

View file

@ -0,0 +1,156 @@
package postprocess
import (
"os"
"path/filepath"
"testing"
)
func TestFindPar2File(t *testing.T) {
dir := t.TempDir()
// Create par2 files of different sizes
mainPar2 := filepath.Join(dir, "content.par2")
vol1 := filepath.Join(dir, "content.vol000+01.par2")
vol2 := filepath.Join(dir, "content.vol001+02.par2")
os.WriteFile(mainPar2, make([]byte, 100), 0o644) // smallest
os.WriteFile(vol1, make([]byte, 10000), 0o644)
os.WriteFile(vol2, make([]byte, 50000), 0o644)
files := map[string]string{
"content.par2": mainPar2,
"content.vol000+01.par2": vol1,
"content.vol001+02.par2": vol2,
}
result := findPar2File(files)
if result != mainPar2 {
t.Errorf("findPar2File() = %q, want %q (smallest par2)", result, mainPar2)
}
}
func TestFindPar2FileNone(t *testing.T) {
files := map[string]string{
"video.mkv": "/tmp/video.mkv",
"subs.srt": "/tmp/subs.srt",
}
result := findPar2File(files)
if result != "" {
t.Errorf("findPar2File() = %q, want empty", result)
}
}
func TestFindPar2FileEmpty(t *testing.T) {
result := findPar2File(map[string]string{})
if result != "" {
t.Errorf("findPar2File() = %q, want empty", result)
}
}
func TestFindFirstRarPart01(t *testing.T) {
files := map[string]string{
"movie.part01.rar": "/tmp/movie.part01.rar",
"movie.part02.rar": "/tmp/movie.part02.rar",
"movie.part03.rar": "/tmp/movie.part03.rar",
}
result := findFirstRar(files)
if result != "/tmp/movie.part01.rar" {
t.Errorf("findFirstRar() = %q, want part01.rar", result)
}
}
func TestFindFirstRarSingle(t *testing.T) {
files := map[string]string{
"movie.rar": "/tmp/movie.rar",
"movie.r00": "/tmp/movie.r00",
"movie.r01": "/tmp/movie.r01",
}
result := findFirstRar(files)
if result != "/tmp/movie.rar" {
t.Errorf("findFirstRar() = %q, want movie.rar (shortest)", result)
}
}
func TestFindFirstRarSplitFormat(t *testing.T) {
files := map[string]string{
"movie.001": "/tmp/movie.001",
"movie.002": "/tmp/movie.002",
}
result := findFirstRar(files)
if result != "/tmp/movie.001" {
t.Errorf("findFirstRar() = %q, want movie.001", result)
}
}
func TestFindFirstRarNone(t *testing.T) {
files := map[string]string{
"video.mkv": "/tmp/video.mkv",
"subs.srt": "/tmp/subs.srt",
}
result := findFirstRar(files)
if result != "" {
t.Errorf("findFirstRar() = %q, want empty", result)
}
}
func TestFindMainFile(t *testing.T) {
dir := t.TempDir()
// Create video files of different sizes
small := filepath.Join(dir, "small.mkv")
large := filepath.Join(dir, "large.mkv")
nonVideo := filepath.Join(dir, "readme.txt")
os.WriteFile(small, make([]byte, 1000), 0o644)
os.WriteFile(large, make([]byte, 5000), 0o644)
os.WriteFile(nonVideo, make([]byte, 9000), 0o644)
result := findMainFile(dir, []string{small, large, nonVideo})
if result != large {
t.Errorf("findMainFile() = %q, want %q (largest video)", result, large)
}
}
func TestFindMainFileFallbackToDir(t *testing.T) {
dir := t.TempDir()
video := filepath.Join(dir, "movie.mp4")
os.WriteFile(video, make([]byte, 5000), 0o644)
// Pass empty file list — should fallback to scanning dir
result := findMainFile(dir, nil)
if result != video {
t.Errorf("findMainFile() = %q, want %q (dir scan fallback)", result, video)
}
}
func TestFindMainFileEmpty(t *testing.T) {
dir := t.TempDir()
result := findMainFile(dir, nil)
if result != "" {
t.Errorf("findMainFile() = %q, want empty", result)
}
}
func TestFindMainFileMultipleFormats(t *testing.T) {
dir := t.TempDir()
mkv := filepath.Join(dir, "movie.mkv")
mp4 := filepath.Join(dir, "movie.mp4")
avi := filepath.Join(dir, "movie.avi")
os.WriteFile(mkv, make([]byte, 3000), 0o644)
os.WriteFile(mp4, make([]byte, 5000), 0o644) // largest
os.WriteFile(avi, make([]byte, 2000), 0o644)
result := findMainFile(dir, []string{mkv, mp4, avi})
if result != mp4 {
t.Errorf("findMainFile() = %q, want %q", result, mp4)
}
}