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
This commit is contained in:
parent
01d62ffa13
commit
3e0f3a5a64
33 changed files with 7084 additions and 65 deletions
632
internal/usenet/download/progress_expand_test.go
Normal file
632
internal/usenet/download/progress_expand_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
131
internal/usenet/nntp/client_test.go
Normal file
131
internal/usenet/nntp/client_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""movie.mkv" 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=""movie.nfo" 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=""movie.par2" 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=""movie.vol0+1.par2" 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=""sample.mkv" 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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""bad.bin"">
|
||||
<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=""good.bin"">
|
||||
<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=""test.bin"">
|
||||
<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=""test.bin"">
|
||||
<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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
170
internal/usenet/postprocess/extract_test.go
Normal file
170
internal/usenet/postprocess/extract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
156
internal/usenet/postprocess/pipeline_test.go
Normal file
156
internal/usenet/postprocess/pipeline_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue