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
|
|
@ -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