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
419
internal/engine/progress_test.go
Normal file
419
internal/engine/progress_test.go
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/torrentclaw/unarr/internal/agent"
|
||||
)
|
||||
|
||||
// mockStatusReporter records calls to ReportStatus.
|
||||
type mockStatusReporter struct {
|
||||
mu sync.Mutex
|
||||
calls []agent.StatusUpdate
|
||||
resp *agent.StatusResponse
|
||||
respErr error
|
||||
}
|
||||
|
||||
func (m *mockStatusReporter) ReportStatus(_ context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.calls = append(m.calls, update)
|
||||
if m.resp != nil {
|
||||
return m.resp, m.respErr
|
||||
}
|
||||
return &agent.StatusResponse{}, m.respErr
|
||||
}
|
||||
|
||||
// mockBatchReporter records batch calls.
|
||||
type mockBatchReporter struct {
|
||||
mockStatusReporter
|
||||
batchCalls [][]agent.StatusUpdate
|
||||
batchResp *agent.BatchStatusResponse
|
||||
}
|
||||
|
||||
func (m *mockBatchReporter) BatchReportStatus(_ context.Context, updates []agent.StatusUpdate) (*agent.BatchStatusResponse, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.batchCalls = append(m.batchCalls, updates)
|
||||
if m.batchResp != nil {
|
||||
return m.batchResp, nil
|
||||
}
|
||||
results := make([]agent.StatusResponse, len(updates))
|
||||
return &agent.BatchStatusResponse{Results: results}, nil
|
||||
}
|
||||
|
||||
func TestProgressReporter_TrackUntrack(t *testing.T) {
|
||||
reporter := &mockStatusReporter{}
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
}
|
||||
|
||||
task := &Task{ID: "task-001", Status: StatusDownloading}
|
||||
pr.Track(task)
|
||||
|
||||
pr.mu.Lock()
|
||||
if _, ok := pr.latest["task-001"]; !ok {
|
||||
t.Error("task should be tracked")
|
||||
}
|
||||
pr.mu.Unlock()
|
||||
|
||||
pr.Untrack("task-001")
|
||||
|
||||
pr.mu.Lock()
|
||||
if _, ok := pr.latest["task-001"]; ok {
|
||||
t.Error("task should be untracked")
|
||||
}
|
||||
pr.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestProgressReporter_FlushReportsFinalStates(t *testing.T) {
|
||||
reporter := &mockStatusReporter{}
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
}
|
||||
|
||||
completed := &Task{ID: "task-completed-1234", Status: StatusCompleted}
|
||||
pr.Track(completed)
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
reporter.mu.Lock()
|
||||
defer reporter.mu.Unlock()
|
||||
if len(reporter.calls) != 1 {
|
||||
t.Fatalf("expected 1 report, got %d", len(reporter.calls))
|
||||
}
|
||||
if reporter.calls[0].TaskID != "task-completed-1234" {
|
||||
t.Errorf("reported wrong task: %s", reporter.calls[0].TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_FlushSkipsWhenNotWatching(t *testing.T) {
|
||||
reporter := &mockStatusReporter{}
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
isWatching: func() bool { return false },
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
lastCheckAt: time.Now(), // not due for control check
|
||||
}
|
||||
|
||||
// Active downloading task, already reported as downloading
|
||||
task := &Task{ID: "task-active-12345678", Status: StatusDownloading}
|
||||
pr.Track(task)
|
||||
pr.mu.Lock()
|
||||
pr.lastReported["task-active-12345678"] = StatusDownloading
|
||||
pr.mu.Unlock()
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
reporter.mu.Lock()
|
||||
defer reporter.mu.Unlock()
|
||||
if len(reporter.calls) != 0 {
|
||||
t.Errorf("expected 0 reports when not watching (no transition), got %d", len(reporter.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_FlushReportsTransitions(t *testing.T) {
|
||||
reporter := &mockStatusReporter{}
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
isWatching: func() bool { return false },
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
lastCheckAt: time.Now(),
|
||||
}
|
||||
|
||||
// Task transitioning from resolving to downloading
|
||||
task := &Task{ID: "task-trans-12345678", Status: StatusDownloading}
|
||||
pr.Track(task)
|
||||
pr.mu.Lock()
|
||||
pr.lastReported["task-trans-12345678"] = StatusResolving
|
||||
pr.mu.Unlock()
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
reporter.mu.Lock()
|
||||
defer reporter.mu.Unlock()
|
||||
if len(reporter.calls) != 1 {
|
||||
t.Fatalf("expected 1 report for transition, got %d", len(reporter.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_FlushActiveWhenWatching(t *testing.T) {
|
||||
reporter := &mockStatusReporter{}
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
isWatching: func() bool { return true },
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
}
|
||||
|
||||
task := &Task{ID: "task-watch-12345678", Status: StatusDownloading}
|
||||
pr.Track(task)
|
||||
pr.mu.Lock()
|
||||
pr.lastReported["task-watch-12345678"] = StatusDownloading
|
||||
pr.mu.Unlock()
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
reporter.mu.Lock()
|
||||
defer reporter.mu.Unlock()
|
||||
if len(reporter.calls) != 1 {
|
||||
t.Fatalf("expected 1 report when watching active task, got %d", len(reporter.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_HandleResponseCancel(t *testing.T) {
|
||||
reporter := &mockStatusReporter{
|
||||
resp: &agent.StatusResponse{Cancelled: true},
|
||||
}
|
||||
|
||||
var cancelledID string
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
onCancel: func(id string) { cancelledID = id },
|
||||
}
|
||||
|
||||
task := &Task{ID: "task-cancel-1234567", Status: StatusCompleted}
|
||||
pr.Track(task)
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
if cancelledID != "task-cancel-1234567" {
|
||||
t.Errorf("expected cancel handler called with task ID, got %q", cancelledID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_HandleResponsePause(t *testing.T) {
|
||||
reporter := &mockStatusReporter{
|
||||
resp: &agent.StatusResponse{Paused: true},
|
||||
}
|
||||
|
||||
var pausedID string
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
onPause: func(id string) { pausedID = id },
|
||||
}
|
||||
|
||||
task := &Task{ID: "task-paused-1234567", Status: StatusCompleted}
|
||||
pr.Track(task)
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
if pausedID != "task-paused-1234567" {
|
||||
t.Errorf("expected pause handler called, got %q", pausedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_HandleResponseDeleteFiles(t *testing.T) {
|
||||
reporter := &mockStatusReporter{
|
||||
resp: &agent.StatusResponse{Cancelled: true, DeleteFiles: true},
|
||||
}
|
||||
|
||||
var deletedID string
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
onDeleteFiles: func(id string) { deletedID = id },
|
||||
}
|
||||
|
||||
task := &Task{ID: "task-delete-1234567", Status: StatusCompleted}
|
||||
pr.Track(task)
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
if deletedID != "task-delete-1234567" {
|
||||
t.Errorf("expected deleteFiles handler called, got %q", deletedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_HandleResponseStream(t *testing.T) {
|
||||
reporter := &mockStatusReporter{
|
||||
resp: &agent.StatusResponse{StreamRequested: true},
|
||||
}
|
||||
|
||||
var streamID string
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
onStreamRequested: func(id string) { streamID = id },
|
||||
}
|
||||
|
||||
// Task with no stream URL yet
|
||||
task := &Task{ID: "task-stream-1234567", Status: StatusCompleted}
|
||||
pr.Track(task)
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
if streamID != "task-stream-1234567" {
|
||||
t.Errorf("expected stream handler called, got %q", streamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_HandleResponseWatchingChanged(t *testing.T) {
|
||||
reporter := &mockStatusReporter{
|
||||
resp: &agent.StatusResponse{Watching: true},
|
||||
}
|
||||
|
||||
var watchingValue bool
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
onWatchingChanged: func(w bool) { watchingValue = w },
|
||||
}
|
||||
|
||||
task := &Task{ID: "task-watch2-1234567", Status: StatusCompleted}
|
||||
pr.Track(task)
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
if !watchingValue {
|
||||
t.Error("expected watchingChanged called with true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_BatchFlush(t *testing.T) {
|
||||
batcher := &mockBatchReporter{
|
||||
batchResp: &agent.BatchStatusResponse{
|
||||
Results: []agent.StatusResponse{{}, {}},
|
||||
},
|
||||
}
|
||||
|
||||
pr := &ProgressReporter{
|
||||
reporter: batcher,
|
||||
interval: time.Second,
|
||||
isWatching: func() bool { return true },
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
}
|
||||
|
||||
pr.Track(&Task{ID: "task-batch1-1234567", Status: StatusDownloading})
|
||||
pr.Track(&Task{ID: "task-batch2-1234567", Status: StatusDownloading})
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
batcher.mu.Lock()
|
||||
defer batcher.mu.Unlock()
|
||||
|
||||
if len(batcher.batchCalls) != 1 {
|
||||
t.Fatalf("expected 1 batch call, got %d", len(batcher.batchCalls))
|
||||
}
|
||||
if len(batcher.batchCalls[0]) != 2 {
|
||||
t.Errorf("expected 2 updates in batch, got %d", len(batcher.batchCalls[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_RunStopsOnCancel(t *testing.T) {
|
||||
reporter := &mockStatusReporter{}
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: 50 * time.Millisecond,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := pr.Run(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Run should return nil on context cancel, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_ReportFinal(t *testing.T) {
|
||||
reporter := &mockStatusReporter{}
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
}
|
||||
|
||||
task := &Task{ID: "task-final-12345678", Status: StatusCompleted}
|
||||
pr.Track(task)
|
||||
|
||||
pr.ReportFinal(context.Background(), task)
|
||||
|
||||
reporter.mu.Lock()
|
||||
defer reporter.mu.Unlock()
|
||||
if len(reporter.calls) != 1 {
|
||||
t.Fatalf("expected 1 final report, got %d", len(reporter.calls))
|
||||
}
|
||||
|
||||
// Should be untracked after final report
|
||||
pr.mu.Lock()
|
||||
if _, ok := pr.latest["task-final-12345678"]; ok {
|
||||
t.Error("task should be untracked after ReportFinal")
|
||||
}
|
||||
pr.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestProgressReporter_SetHandlers(t *testing.T) {
|
||||
pr := &ProgressReporter{
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
}
|
||||
|
||||
pr.SetCancelHandler(func(id string) {})
|
||||
pr.SetPauseHandler(func(id string) {})
|
||||
pr.SetDeleteFilesHandler(func(id string) {})
|
||||
pr.SetStreamRequestedHandler(func(id string) {})
|
||||
pr.SetWatchingFunc(func() bool { return true })
|
||||
pr.SetWatchingChangedHandler(func(w bool) {})
|
||||
|
||||
if pr.onCancel == nil || pr.onPause == nil || pr.onDeleteFiles == nil ||
|
||||
pr.onStreamRequested == nil || pr.isWatching == nil || pr.onWatchingChanged == nil {
|
||||
t.Error("expected all handlers to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReporter_ControlCheckDue(t *testing.T) {
|
||||
reporter := &mockStatusReporter{}
|
||||
pr := &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: time.Second,
|
||||
isWatching: func() bool { return false },
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
lastCheckAt: time.Now().Add(-31 * time.Second), // 31s ago - due for control check
|
||||
}
|
||||
|
||||
task := &Task{ID: "task-ctrl-123456789", Status: StatusDownloading}
|
||||
pr.Track(task)
|
||||
pr.mu.Lock()
|
||||
pr.lastReported["task-ctrl-123456789"] = StatusDownloading
|
||||
pr.mu.Unlock()
|
||||
|
||||
pr.flush(context.Background())
|
||||
|
||||
reporter.mu.Lock()
|
||||
defer reporter.mu.Unlock()
|
||||
if len(reporter.calls) != 1 {
|
||||
t.Errorf("expected 1 report for control check, got %d", len(reporter.calls))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue