unarr/internal/arr/client_test.go
Deivid Soto 3e0f3a5a64
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
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
2026-03-31 22:05:43 +02:00

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
}