unarr/internal/agent/taskstate_test.go

270 lines
6.7 KiB
Go

package agent
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestLocalState_UpdateAndSnapshot(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 50})
s.Update(TaskState{TaskID: "t2", Status: "completed", Progress: 100})
snap := s.Snapshot()
if len(snap) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(snap))
}
byID := make(map[string]TaskState, len(snap))
for _, ts := range snap {
byID[ts.TaskID] = ts
}
if byID["t1"].Progress != 50 {
t.Errorf("expected progress 50, got %d", byID["t1"].Progress)
}
if byID["t2"].Status != "completed" {
t.Errorf("expected completed, got %s", byID["t2"].Status)
}
}
func TestLocalState_UpdateOverwrites(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 30})
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 70})
snap := s.Snapshot()
if len(snap) != 1 {
t.Fatalf("expected 1 task, got %d", len(snap))
}
if snap[0].Progress != 70 {
t.Errorf("expected progress 70, got %d", snap[0].Progress)
}
}
func TestLocalState_Remove(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading"})
s.Update(TaskState{TaskID: "t2", Status: "downloading"})
s.Remove("t1")
snap := s.Snapshot()
if len(snap) != 1 {
t.Fatalf("expected 1 task, got %d", len(snap))
}
if snap[0].TaskID != "t2" {
t.Errorf("expected t2, got %s", snap[0].TaskID)
}
}
func TestLocalState_RemoveNonExistent(t *testing.T) {
s := NewLocalState()
s.Remove("nonexistent") // should not panic
}
func TestLocalState_SnapshotIsACopy(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 50})
snap := s.Snapshot()
snap[0].Progress = 999
snap2 := s.Snapshot()
if snap2[0].Progress != 50 {
t.Errorf("snapshot mutation leaked: got progress %d", snap2[0].Progress)
}
}
func TestLocalState_UpdateSetsTimestamp(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading"})
snap := s.Snapshot()
if snap[0].UpdatedAt == 0 {
t.Error("expected non-zero UpdatedAt")
}
}
func TestLocalState_ConcurrentAccess(t *testing.T) {
s := NewLocalState()
var wg sync.WaitGroup
for i := range 100 {
wg.Add(1)
go func(n int) {
defer wg.Done()
taskID := "t" + string(rune('0'+n%10))
s.Update(TaskState{TaskID: taskID, Status: "downloading", Progress: n})
s.Snapshot()
if n%3 == 0 {
s.Remove(taskID)
}
}(i)
}
wg.Wait()
// No race condition = test passes
}
func TestLocalState_WriteToDisk_ReadFromDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.json")
// Override the file path for testing
orig := taskStateFilePathFn
taskStateFilePathFn = func() string { return path }
defer func() { taskStateFilePathFn = orig }()
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 45})
s.Update(TaskState{TaskID: "t2", Status: "completed", Progress: 100, FilePath: "/tmp/movie.mkv"})
s.WriteToDisk()
// Verify file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("tasks.json was not created")
}
// Read into a new LocalState
s2 := NewLocalState()
s2.ReadFromDisk()
snap := s2.Snapshot()
if len(snap) != 2 {
t.Fatalf("expected 2 tasks after read, got %d", len(snap))
}
byID := make(map[string]TaskState, len(snap))
for _, ts := range snap {
byID[ts.TaskID] = ts
}
if byID["t1"].Progress != 45 {
t.Errorf("expected progress 45, got %d", byID["t1"].Progress)
}
if byID["t2"].FilePath != "/tmp/movie.mkv" {
t.Errorf("expected /tmp/movie.mkv, got %s", byID["t2"].FilePath)
}
}
func TestLocalState_ReadFromDisk_CorruptedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.json")
orig := taskStateFilePathFn
taskStateFilePathFn = func() string { return path }
defer func() { taskStateFilePathFn = orig }()
// Write corrupted JSON
os.WriteFile(path, []byte("{invalid json"), 0o644)
s := NewLocalState()
s.ReadFromDisk() // should not panic
snap := s.Snapshot()
if len(snap) != 0 {
t.Errorf("expected 0 tasks from corrupted file, got %d", len(snap))
}
}
func TestLocalState_ReadFromDisk_FileNotFound(t *testing.T) {
orig := taskStateFilePathFn
taskStateFilePathFn = func() string { return "/nonexistent/path/tasks.json" }
defer func() { taskStateFilePathFn = orig }()
s := NewLocalState()
s.ReadFromDisk() // should not panic
snap := s.Snapshot()
if len(snap) != 0 {
t.Errorf("expected 0 tasks, got %d", len(snap))
}
}
func TestLocalState_AtomicWrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.json")
orig := taskStateFilePathFn
taskStateFilePathFn = func() string { return path }
defer func() { taskStateFilePathFn = orig }()
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading"})
s.WriteToDisk()
// Verify no .tmp file remains
tmpPath := path + ".tmp"
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Error("temp file should not exist after write")
}
}
func TestLocalState_EmptySnapshot(t *testing.T) {
s := NewLocalState()
snap := s.Snapshot()
if snap == nil {
t.Error("snapshot should be non-nil empty slice")
}
if len(snap) != 0 {
t.Errorf("expected 0 tasks, got %d", len(snap))
}
}
func TestTaskStateFromUpdate(t *testing.T) {
u := StatusUpdate{
TaskID: "task-1",
Status: "downloading",
Progress: 42,
DownloadedBytes: 1024,
TotalBytes: 4096,
SpeedBps: 100,
ETA: 30,
ResolvedMethod: "torrent",
FileName: "movie.mkv",
FilePath: "/tmp/movie.mkv",
StreamURL: "http://localhost/stream",
ErrorMessage: "",
}
got := TaskStateFromUpdate(u)
if got.TaskID != "task-1" || got.Status != "downloading" || got.Progress != 42 {
t.Errorf("basic fields wrong: %+v", got)
}
if got.DownloadedBytes != 1024 || got.TotalBytes != 4096 || got.SpeedBps != 100 {
t.Errorf("byte fields wrong: %+v", got)
}
if got.ResolvedMethod != "torrent" || got.FileName != "movie.mkv" {
t.Errorf("method/name fields wrong: %+v", got)
}
}
func TestShortID(t *testing.T) {
if got := ShortID("abcdef1234567890"); got != "abcdef12" {
t.Errorf("ShortID = %q", got)
}
if got := ShortID("short"); got != "short" {
t.Errorf("ShortID short = %q", got)
}
if got := ShortID(""); got != "" {
t.Errorf("ShortID empty = %q", got)
}
}
func TestStateFilePath(t *testing.T) {
if got := StateFilePath(); got == "" {
t.Errorf("StateFilePath should not be empty")
}
}
func TestHTTPError(t *testing.T) {
e := &HTTPError{StatusCode: 404, Message: "not found"}
got := e.Error()
if got == "" || got == "API error 0: " {
t.Errorf("HTTPError.Error() unexpected: %q", got)
}
}