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
396
internal/arr/client_test.go
Normal file
396
internal/arr/client_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue