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

396
internal/arr/client_test.go Normal file
View file

@ -0,0 +1,396 @@
package arr
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func newTestServer(t *testing.T, handlers map[string]any) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check API key header
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
handler, ok := handlers[r.URL.Path]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(handler)
}))
}
func TestNewClient(t *testing.T) {
c := NewClient("http://localhost:8989/", "mykey")
if c.baseURL != "http://localhost:8989" {
t.Errorf("baseURL = %q, want trailing slash trimmed", c.baseURL)
}
if c.apiKey != "mykey" {
t.Errorf("apiKey = %q, want mykey", c.apiKey)
}
}
func TestSystemStatus(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/system/status": SystemStatus{AppName: "Radarr", Version: "4.0.0"},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
status, err := c.SystemStatus()
if err != nil {
t.Fatalf("SystemStatus: %v", err)
}
if status.AppName != "Radarr" {
t.Errorf("AppName = %q, want Radarr", status.AppName)
}
if status.Version != "4.0.0" {
t.Errorf("Version = %q, want 4.0.0", status.Version)
}
}
func TestSystemStatusFallbackV1(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/api/v3/system/status":
w.WriteHeader(http.StatusNotFound)
case "/api/v1/system/status":
json.NewEncoder(w).Encode(SystemStatus{AppName: "Prowlarr", Version: "1.0.0"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
status, err := c.SystemStatus()
if err != nil {
t.Fatalf("SystemStatus v1 fallback: %v", err)
}
if status.AppName != "Prowlarr" {
t.Errorf("AppName = %q, want Prowlarr", status.AppName)
}
}
func TestMovies(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/movie": []Movie{
{ID: 1, Title: "Inception", Year: 2010, TmdbID: 27205, Monitored: true},
{ID: 2, Title: "Tenet", Year: 2020, TmdbID: 577922, HasFile: true},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
movies, err := c.Movies()
if err != nil {
t.Fatalf("Movies: %v", err)
}
if len(movies) != 2 {
t.Fatalf("expected 2 movies, got %d", len(movies))
}
if movies[0].Title != "Inception" {
t.Errorf("movies[0].Title = %q, want Inception", movies[0].Title)
}
}
func TestSeries(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/series": []Series{
{ID: 1, Title: "Breaking Bad", Year: 2008, TvdbID: 81189},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
series, err := c.Series()
if err != nil {
t.Fatalf("Series: %v", err)
}
if len(series) != 1 {
t.Fatalf("expected 1 series, got %d", len(series))
}
if series[0].Title != "Breaking Bad" {
t.Errorf("series[0].Title = %q, want Breaking Bad", series[0].Title)
}
}
func TestQualityProfiles(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/qualityprofile": []QualityProfile{
{ID: 1, Name: "HD-1080p"},
{ID: 2, Name: "Ultra-HD"},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
profiles, err := c.QualityProfiles()
if err != nil {
t.Fatalf("QualityProfiles: %v", err)
}
if len(profiles) != 2 {
t.Fatalf("expected 2 profiles, got %d", len(profiles))
}
}
func TestRootFolders(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/rootfolder": []RootFolder{
{ID: 1, Path: "/movies", FreeSpace: 500000000000},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
folders, err := c.RootFolders()
if err != nil {
t.Fatalf("RootFolders: %v", err)
}
if len(folders) != 1 {
t.Fatalf("expected 1 folder, got %d", len(folders))
}
if folders[0].Path != "/movies" {
t.Errorf("path = %q, want /movies", folders[0].Path)
}
}
func TestDownloadClients(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/downloadclient": []DownloadClient{
{ID: 1, Name: "Transmission", Enable: true, Protocol: "torrent"},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
clients, err := c.DownloadClients()
if err != nil {
t.Fatalf("DownloadClients: %v", err)
}
if len(clients) != 1 || clients[0].Name != "Transmission" {
t.Errorf("unexpected clients: %+v", clients)
}
}
func TestDownloadClientDetails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path == "/api/v3/downloadclient/5" {
json.NewEncoder(w).Encode(struct {
Fields []Field `json:"fields"`
}{
Fields: []Field{
{Name: "host", Value: "localhost"},
{Name: "port", Value: 9091},
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
fields, err := c.DownloadClientDetails(5)
if err != nil {
t.Fatalf("DownloadClientDetails: %v", err)
}
if len(fields) != 2 {
t.Fatalf("expected 2 fields, got %d", len(fields))
}
if fields[0].Name != "host" {
t.Errorf("fields[0].Name = %q, want host", fields[0].Name)
}
}
func TestTags(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/tag": []Tag{
{ID: 1, Label: "unarr"},
{ID: 2, Label: "imported"},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
tags, err := c.Tags()
if err != nil {
t.Fatalf("Tags: %v", err)
}
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %d", len(tags))
}
}
func TestHistory(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path == "/api/v3/history" {
json.NewEncoder(w).Encode(HistoryResponse{
Records: []HistoryRecord{
{ID: 1, EventType: "grabbed", SourceTitle: "Inception.2010.1080p"},
},
TotalRecords: 1,
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
records, err := c.History(10)
if err != nil {
t.Fatalf("History: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if records[0].SourceTitle != "Inception.2010.1080p" {
t.Errorf("sourceTitle = %q", records[0].SourceTitle)
}
}
func TestHistoryDefaultPageSize(t *testing.T) {
var requestedPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
requestedPath = r.URL.String()
json.NewEncoder(w).Encode(HistoryResponse{})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
c.History(0) // should default to 250
if requestedPath == "" {
t.Fatal("no request made")
}
if !contains(requestedPath, "pageSize=250") {
t.Errorf("expected pageSize=250, got path: %s", requestedPath)
}
}
func TestBlocklist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path == "/api/v3/blocklist" {
json.NewEncoder(w).Encode(BlocklistResponse{
Records: []BlocklistItem{
{ID: 1, SourceTitle: "Bad.Release", Data: BlocklistData{InfoHash: "abc123"}},
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
items, err := c.Blocklist(50)
if err != nil {
t.Fatalf("Blocklist: %v", err)
}
if len(items) != 1 || items[0].Data.InfoHash != "abc123" {
t.Errorf("unexpected blocklist: %+v", items)
}
}
func TestIndexers(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v1/indexer": []Indexer{
{ID: 1, Name: "NZBGeek", Enable: true},
{ID: 2, Name: "Torznab", Enable: false},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
indexers, err := c.Indexers()
if err != nil {
t.Fatalf("Indexers: %v", err)
}
if len(indexers) != 2 {
t.Fatalf("expected 2 indexers, got %d", len(indexers))
}
}
func TestApplications(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v1/applications": []Application{
{ID: 1, Name: "Radarr"},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
apps, err := c.Applications()
if err != nil {
t.Fatalf("Applications: %v", err)
}
if len(apps) != 1 || apps[0].Name != "Radarr" {
t.Errorf("unexpected apps: %+v", apps)
}
}
func TestUnauthorized(t *testing.T) {
srv := newTestServer(t, map[string]any{})
defer srv.Close()
c := NewClient(srv.URL, "wrong-key")
_, err := c.SystemStatus()
if err == nil {
t.Error("expected error for unauthorized request")
}
}
func TestHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal error"))
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
_, err := c.Movies()
if err == nil {
t.Error("expected error for 500 response")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchStr(s, substr)
}
func searchStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View file

@ -1,6 +1,9 @@
package arr
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@ -82,3 +85,158 @@ func TestDetectApp(t *testing.T) {
})
}
}
func TestConfigDirs(t *testing.T) {
dirs := configDirs()
if len(dirs) == 0 {
t.Error("configDirs() returned empty")
}
}
func TestParseConfigXMLEmpty(t *testing.T) {
port, apiKey, urlBase := parseConfigXML(strings.NewReader(""))
if port != "" || apiKey != "" || urlBase != "" {
t.Error("empty input should return empty values")
}
}
func TestParseConfigXMLNoPort(t *testing.T) {
xml := `<Config><ApiKey>key123</ApiKey></Config>`
port, apiKey, _ := parseConfigXML(strings.NewReader(xml))
if port != "" {
t.Errorf("port = %q, want empty", port)
}
if apiKey != "key123" {
t.Errorf("apiKey = %q, want key123", apiKey)
}
}
func TestExtractHostPortMultipleMappings(t *testing.T) {
tests := []struct {
name string
ports string
container string
want string
}{
{"ipv6 only", ":::8989->8989/tcp", "8989", "8989"},
{"different host port", "0.0.0.0:9999->8989/tcp", "8989", "9999"},
{"port in string but no mapping", "something 8989 somewhere", "8989", "8989"},
{"no match at all", "0.0.0.0:3000->3000/tcp", "9999", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractHostPort(tt.ports, tt.container)
if got != tt.want {
t.Errorf("extractHostPort(%q, %q) = %q, want %q", tt.ports, tt.container, got, tt.want)
}
})
}
}
func TestDiscoverFromProwlarr(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/applications":
json.NewEncoder(w).Encode([]Application{
{
ID: 1,
Name: "Radarr",
Fields: []Field{
{Name: "baseUrl", Value: "http://localhost:7878"},
{Name: "apiKey", Value: "radarr-key-123"},
},
},
{
ID: 2,
Name: "Sonarr",
Fields: []Field{
{Name: "baseUrl", Value: "http://localhost:8989"},
{Name: "apiKey", Value: "sonarr-key-456"},
},
},
{
ID: 3,
Name: "Unknown App",
Fields: []Field{
{Name: "baseUrl", Value: "http://localhost:9000"},
{Name: "apiKey", Value: "unknown-key"},
},
},
{
ID: 4,
Name: "Incomplete",
Fields: []Field{
{Name: "baseUrl", Value: "http://localhost:5000"},
// no apiKey → should be skipped
},
},
})
case "/api/v3/system/status":
json.NewEncoder(w).Encode(SystemStatus{AppName: "Radarr", Version: "4.0.0"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
// DiscoverFromProwlarr will try to verify each instance, which will fail
// for localhost URLs (not our test server), but that's OK — we test the parsing
instances := DiscoverFromProwlarr(srv.URL, "prowlarr-key")
// Should find Radarr and Sonarr (Unknown and Incomplete skipped)
if len(instances) != 2 {
t.Fatalf("expected 2 instances, got %d: %+v", len(instances), instances)
}
found := map[string]bool{}
for _, inst := range instances {
found[inst.App] = true
if inst.Source != "prowlarr" {
t.Errorf("source = %q, want prowlarr", inst.Source)
}
}
if !found["radarr"] {
t.Error("expected radarr instance")
}
if !found["sonarr"] {
t.Error("expected sonarr instance")
}
}
func TestVerify(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "valid-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(SystemStatus{AppName: "Radarr", Version: "5.0.0"})
}))
defer srv.Close()
t.Run("valid", func(t *testing.T) {
inst := &Instance{App: "radarr", URL: srv.URL, APIKey: "valid-key"}
err := Verify(inst)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if inst.Version != "5.0.0" {
t.Errorf("version = %q, want 5.0.0", inst.Version)
}
})
t.Run("no api key", func(t *testing.T) {
inst := &Instance{App: "radarr", URL: srv.URL}
err := Verify(inst)
if err == nil {
t.Error("expected error for no API key")
}
})
t.Run("invalid key", func(t *testing.T) {
inst := &Instance{App: "radarr", URL: srv.URL, APIKey: "wrong-key"}
err := Verify(inst)
if err == nil {
t.Error("expected error for invalid API key")
}
})
}