- 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
396 lines
9.5 KiB
Go
396 lines
9.5 KiB
Go
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
|
|
}
|