Files
silo-server/internal/requests/sonarr/client_test.go
T
Silo Server MigrationandClaude Opus 4.7 860978cf2f fix(requests): recover external id after empty arr add response
Radarr and Sonarr can return HTTP 201 with no body when a movie or
series is added. The previous code returned an "accepted_without_response"
result with an empty ExternalID, which trapped the reconciler: every
subsequent CheckStatus call short-circuited on the empty ID and the
request never advanced past queued.

When the add POST decodes empty, look the freshly-added record up by
TMDB or TVDB ID via the standard list endpoints and use the resulting
Arr ID. Fall back to the previous accepted-without-response result
only when the lookup also returns no match, preserving the original
behavior as a safety net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:12:53 -04:00

229 lines
7.3 KiB
Go

package sonarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
func TestSubmitSeriesAddsLookupResult(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
var posted seriesResource
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
switch r.URL.Path {
case "/api/v3/series":
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tvdbId"); got != "121361" {
t.Fatalf("tvdbId = %q, want 121361", got)
}
w.Write([]byte(`[]`))
return
}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
t.Fatalf("decode posted series: %v", err)
}
w.Write([]byte(`{"id":24,"tvdbId":121361}`))
return
}
case "/api/v3/series/lookup":
if got := r.URL.Query().Get("term"); got != "tvdb:121361" {
t.Fatalf("lookup term = %q, want tvdb:121361", got)
}
w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`))
return
}
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
Options: map[string]any{
"monitor": "future",
},
})
if err != nil {
t.Fatalf("SubmitSeries returned error: %v", err)
}
if result.ExternalID != "24" || result.IntegrationKind != "sonarr" {
t.Fatalf("result = %+v, want sonarr external id 24", result)
}
if posted.RootFolderPath != "/series" || posted.QualityProfileID != qualityProfileID {
t.Fatalf("posted series = %+v, missing root folder/quality profile", posted)
}
if posted.AddOptions.Monitor != "future" {
t.Fatalf("monitor = %q, want future", posted.AddOptions.Monitor)
}
}
func TestSubmitSeriesRecoversFromEmptyAddResponse(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/series/lookup":
w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`))
case "/api/v3/series":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tvdbId"); got != "121361" {
t.Fatalf("tvdbId = %q, want 121361", got)
}
w.Write([]byte(`[{"id":77,"tvdbId":121361,"title":"Game of Thrones"}]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitSeries returned error: %v", err)
}
if result.ExternalID != "77" {
t.Fatalf("ExternalID = %q, want 77 (recovered after empty 201)", result.ExternalID)
}
if result.ExternalStatus != "queued" {
t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus)
}
}
func TestSubmitSeriesRejectsNonExactTVDBLookupMatch(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/series/lookup" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
w.Write([]byte(`[{"title":"Wrong Show","tvdbId":999,"titleSlug":"wrong-show"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
_, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
})
if err == nil {
t.Fatal("expected error for non-exact TVDB lookup match")
}
}
func TestCheckSeriesStatusReadsQueueDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
if r.URL.Path != "/api/v3/queue/details" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("seriesId"); got != "24" {
t.Fatalf("seriesId = %q, want 24", got)
}
w.Write([]byte(`[{"seriesId":24,"status":"downloading","trackedDownloadState":"importing"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
status, err := client.CheckSeriesStatus(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
ExternalID: "24",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
})
if err != nil {
t.Fatalf("CheckSeriesStatus returned error: %v", err)
}
if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/importing" {
t.Fatalf("status = %+v, want downloading", status)
}
}
func TestListSeriesIntegrationOptionsLoadsChoices(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
switch r.URL.Path {
case "/api/v3/rootfolder":
w.Write([]byte(`[{"path":"/series","freeSpace":123,"totalSpace":456,"accessible":true}]`))
case "/api/v3/qualityprofile":
w.Write([]byte(`[{"id":3,"name":"HD-1080p"}]`))
case "/api/v3/tag":
w.Write([]byte(`[{"id":4,"label":"requests"}]`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(server.Client())
options, err := client.ListSeriesIntegrationOptions(context.Background(), mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
})
if err != nil {
t.Fatalf("ListSeriesIntegrationOptions returned error: %v", err)
}
if options.Kind != "sonarr" {
t.Fatalf("kind = %q, want sonarr", options.Kind)
}
if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/series" {
t.Fatalf("root folders = %+v, want /series", options.RootFolders)
}
if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 3 {
t.Fatalf("quality profiles = %+v, want id 3", options.QualityProfiles)
}
if len(options.Tags) != 1 || options.Tags[0].ID != 4 {
t.Fatalf("tags = %+v, want id 4", options.Tags)
}
}