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
|
|
@ -324,3 +324,265 @@ func TestHeartbeatWithoutUpgradeSignal(t *testing.T) {
|
|||
t.Errorf("expected no upgrade signal, got %+v", resp.Upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeregister(t *testing.T) {
|
||||
var received struct {
|
||||
AgentID string `json:"agentId"`
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/deregister" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&received)
|
||||
json.NewEncoder(w).Encode(StatusResponse{Success: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
err := c.Deregister(context.Background(), "agent-42")
|
||||
if err != nil {
|
||||
t.Fatalf("Deregister failed: %v", err)
|
||||
}
|
||||
if received.AgentID != "agent-42" {
|
||||
t.Errorf("agentId = %q, want agent-42", received.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchReportStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/status" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
var req BatchStatusRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if len(req.Updates) != 2 {
|
||||
t.Errorf("expected 2 updates, got %d", len(req.Updates))
|
||||
}
|
||||
json.NewEncoder(w).Encode(BatchStatusResponse{
|
||||
Results: []StatusResponse{
|
||||
{Success: true},
|
||||
{Success: true, Cancelled: true},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
resp, err := c.BatchReportStatus(context.Background(), []StatusUpdate{
|
||||
{TaskID: "t1", Status: "downloading"},
|
||||
{TaskID: "t2", Status: "completed"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReportStatus failed: %v", err)
|
||||
}
|
||||
if len(resp.Results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(resp.Results))
|
||||
}
|
||||
if !resp.Results[1].Cancelled {
|
||||
t.Error("expected result[1].Cancelled=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchNzbs(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/nzb-search" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(NzbSearchResponse{
|
||||
Results: []NzbSearchResult{
|
||||
{NzbID: "nzb-1", Title: "Movie.2023.1080p"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
resp, err := c.SearchNzbs(context.Background(), NzbSearchParams{Query: "Movie"})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchNzbs failed: %v", err)
|
||||
}
|
||||
if len(resp.Results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(resp.Results))
|
||||
}
|
||||
if resp.Results[0].NzbID != "nzb-1" {
|
||||
t.Errorf("nzb ID = %q, want nzb-1", resp.Results[0].NzbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadNzb(t *testing.T) {
|
||||
nzbContent := []byte(`<?xml version="1.0"?><nzb><file>test</file></nzb>`)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/nzb-download" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("nzbId") != "nzb-42" {
|
||||
t.Errorf("nzbId = %q, want nzb-42", r.URL.Query().Get("nzbId"))
|
||||
}
|
||||
w.Write(nzbContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
data, err := c.DownloadNzb(context.Background(), "nzb-42")
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadNzb failed: %v", err)
|
||||
}
|
||||
if string(data) != string(nzbContent) {
|
||||
t.Errorf("nzb content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadNzbError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("NZB not found"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
_, err := c.DownloadNzb(context.Background(), "bad-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsenetCredentials(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/usenet-credentials" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(UsenetCredentials{
|
||||
Host: "news.example.com",
|
||||
Port: 563,
|
||||
SSL: true,
|
||||
Username: "user1",
|
||||
Password: "pass1",
|
||||
MaxConnections: 10,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
creds, err := c.GetUsenetCredentials(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetUsenetCredentials failed: %v", err)
|
||||
}
|
||||
if creds.Host != "news.example.com" {
|
||||
t.Errorf("host = %q, want news.example.com", creds.Host)
|
||||
}
|
||||
if creds.Username != "user1" {
|
||||
t.Errorf("username = %q, want user1", creds.Username)
|
||||
}
|
||||
if creds.MaxConnections != 10 {
|
||||
t.Errorf("maxConnections = %d, want 10", creds.MaxConnections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsenetUsage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/usenet-usage" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(UsenetUsageResponse{
|
||||
UsedBytes: 5368709120,
|
||||
QuotaBytes: 10737418240,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
usage, err := c.GetUsenetUsage(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetUsenetUsage failed: %v", err)
|
||||
}
|
||||
if usage.UsedBytes != 5368709120 {
|
||||
t.Errorf("usedBytes = %d", usage.UsedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureDebrid(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/debrid-config" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(ConfigureDebridResponse{Success: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
resp, err := c.ConfigureDebrid(context.Background(), ConfigureDebridRequest{
|
||||
Provider: "real-debrid",
|
||||
Token: "rd-token-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigureDebrid failed: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Error("expected success=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDownload(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/batch-download" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(BatchDownloadResponse{
|
||||
Queued: 3,
|
||||
NotFound: 1,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
resp, err := c.BatchDownload(context.Background(), BatchDownloadRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchDownload failed: %v", err)
|
||||
}
|
||||
if resp.Queued != 3 {
|
||||
t.Errorf("queued = %d, want 3", resp.Queued)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLibrary(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/internal/agent/library-sync" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(LibrarySyncResponse{
|
||||
Matched: 10,
|
||||
Synced: 15,
|
||||
Removed: 2,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
resp, err := c.SyncLibrary(context.Background(), LibrarySyncRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("SyncLibrary failed: %v", err)
|
||||
}
|
||||
if resp.Matched != 10 {
|
||||
t.Errorf("matched = %d, want 10", resp.Matched)
|
||||
}
|
||||
if resp.Synced != 15 {
|
||||
t.Errorf("synced = %d, want 15", resp.Synced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLErrorResponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("<html><body>502 Bad Gateway</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "unarr-test")
|
||||
_, err := c.Register(context.Background(), RegisterRequest{AgentID: "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTML error page")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue