- Add plans for date-named episodes and Jellyfin autoscan compat - Update requests handlers, service, and UI hooks - Remove Makefile.local.example
1478 lines
44 KiB
Go
1478 lines
44 KiB
Go
package requests
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
|
|
)
|
|
|
|
func TestCreateRequestQuotaExceeded(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.settings.GlobalMaxRequests = 1
|
|
store.count = 1
|
|
service := newTestService(store)
|
|
|
|
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected quota error")
|
|
}
|
|
var quota QuotaError
|
|
if !errors.As(err, "a) {
|
|
t.Fatalf("error = %v, want QuotaError", err)
|
|
}
|
|
if len(store.created) != 0 {
|
|
t.Fatalf("created requests = %d, want 0", len(store.created))
|
|
}
|
|
}
|
|
|
|
func TestNormalizeListFilterCapsLimit(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in ListFilter
|
|
wantLim int
|
|
wantOff int
|
|
}{
|
|
{"zero defaults", ListFilter{}, defaultRequestListLimit, 0},
|
|
{"negative defaults", ListFilter{Limit: -10, Offset: -5}, defaultRequestListLimit, 0},
|
|
{"under cap preserved", ListFilter{Limit: 75, Offset: 10}, 75, 10},
|
|
{"at cap preserved", ListFilter{Limit: maxRequestListLimit, Offset: 0}, maxRequestListLimit, 0},
|
|
{"over cap clamped", ListFilter{Limit: 1_000_000}, maxRequestListLimit, 0},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := normalizeListFilter(tc.in)
|
|
if got.Limit != tc.wantLim {
|
|
t.Errorf("limit = %d, want %d", got.Limit, tc.wantLim)
|
|
}
|
|
if got.Offset != tc.wantOff {
|
|
t.Errorf("offset = %d, want %d", got.Offset, tc.wantOff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestConcurrentSubmissionsRespectQuota(t *testing.T) {
|
|
const (
|
|
maxRequests = 5
|
|
goroutines = 20
|
|
)
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.settings.GlobalMaxRequests = maxRequests
|
|
service := newTestService(store)
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
successMu sync.Mutex
|
|
successes int
|
|
quotaFails int
|
|
)
|
|
for i := 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func(tmdbID int) {
|
|
defer wg.Done()
|
|
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: tmdbID,
|
|
Title: "Title",
|
|
})
|
|
successMu.Lock()
|
|
defer successMu.Unlock()
|
|
if err == nil {
|
|
successes++
|
|
return
|
|
}
|
|
var quota QuotaError
|
|
if errors.As(err, "a) {
|
|
quotaFails++
|
|
}
|
|
}(1000 + i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if successes != maxRequests {
|
|
t.Fatalf("successful creations = %d, want %d", successes, maxRequests)
|
|
}
|
|
if successes+quotaFails != goroutines {
|
|
t.Fatalf("non-quota errors: successes=%d quotaFails=%d total=%d", successes, quotaFails, goroutines)
|
|
}
|
|
if len(store.created) != maxRequests {
|
|
t.Fatalf("stored creations = %d, want %d", len(store.created), maxRequests)
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestActiveDuplicateBlocks(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.count = 100
|
|
store.active[MediaTypeMovie][550] = &Request{
|
|
ID: "req-existing",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeActive,
|
|
}
|
|
service := newTestService(store)
|
|
|
|
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if !errors.Is(err, ErrAlreadyRequested) {
|
|
t.Fatalf("error = %v, want ErrAlreadyRequested", err)
|
|
}
|
|
if len(store.created) != 0 {
|
|
t.Fatalf("created requests = %d, want 0", len(store.created))
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestAutoApprovalRequiresConfiguredIntegration(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.settings.GlobalAutoApprovalEnabled = true
|
|
service := newTestService(store)
|
|
|
|
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRequest returned error: %v", err)
|
|
}
|
|
if req.Status != StatusPending {
|
|
t.Fatalf("status = %q, want pending", req.Status)
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.settings.GlobalAutoApprovalEnabled = true
|
|
qualityProfileID := 1
|
|
store.integrations = []Integration{{
|
|
Kind: "radarr",
|
|
Enabled: true,
|
|
BaseURL: "http://radarr.local",
|
|
APIKeyRef: "request.radarr.api_key",
|
|
RootFolder: "/movies",
|
|
QualityProfileID: &qualityProfileID,
|
|
}}
|
|
service := newTestService(store)
|
|
|
|
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRequest returned error: %v", err)
|
|
}
|
|
if req.Status != StatusApproved {
|
|
t.Fatalf("status = %q, want approved", req.Status)
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.settings.GlobalAutoApprovalEnabled = true
|
|
qualityProfileID := 1
|
|
store.integrations = []Integration{{
|
|
Kind: "radarr",
|
|
Enabled: true,
|
|
BaseURL: "http://radarr.local",
|
|
APIKeyRef: "requests.radarr.api_key",
|
|
RootFolder: "/movies",
|
|
QualityProfileID: &qualityProfileID,
|
|
}}
|
|
service := newTestService(store)
|
|
service.SetSecretResolver(fakeSecretError{err: errors.New("secret lookup unavailable")})
|
|
|
|
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRequest returned error: %v", err)
|
|
}
|
|
if req.Status != StatusPending {
|
|
t.Fatalf("status = %q, want pending", req.Status)
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.settings.GlobalAutoApprovalEnabled = true
|
|
qualityProfileID := 1
|
|
store.integrations = []Integration{{
|
|
Kind: "radarr",
|
|
Enabled: true,
|
|
BaseURL: "http://radarr.local",
|
|
APIKeyRef: "requests.radarr.api_key",
|
|
RootFolder: "/movies",
|
|
QualityProfileID: &qualityProfileID,
|
|
}}
|
|
adapter := &fakeMovieAdapter{result: FulfillmentResult{
|
|
IntegrationKind: "radarr",
|
|
ExternalID: "123",
|
|
ExternalStatus: "queued",
|
|
}}
|
|
service := newTestService(store)
|
|
service.SetSecretResolver(fakeSecrets{"requests.radarr.api_key": "radarr-key"})
|
|
service.SetFulfillmentAdapters(adapter, nil)
|
|
|
|
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRequest returned error: %v", err)
|
|
}
|
|
if req.Status != StatusQueued || req.IntegrationKind != "radarr" || req.ExternalID != "123" {
|
|
t.Fatalf("request = %+v, want queued radarr external id", req)
|
|
}
|
|
if adapter.calls != 1 {
|
|
t.Fatalf("adapter calls = %d, want 1", adapter.calls)
|
|
}
|
|
if got := adapter.gotIntegration.APIKeyRef; got != "radarr-key" {
|
|
t.Fatalf("adapter api key = %q, want resolved key", got)
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestSubmissionFailureMarksFailed(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.settings.GlobalAutoApprovalEnabled = true
|
|
qualityProfileID := 1
|
|
store.integrations = []Integration{{
|
|
Kind: "radarr",
|
|
Enabled: true,
|
|
BaseURL: "http://radarr.local",
|
|
APIKeyRef: "radarr-key",
|
|
RootFolder: "/movies",
|
|
QualityProfileID: &qualityProfileID,
|
|
}}
|
|
adapter := &fakeMovieAdapter{err: errors.New("radarr unavailable")}
|
|
service := newTestService(store)
|
|
service.SetFulfillmentAdapters(adapter, nil)
|
|
|
|
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRequest returned error: %v", err)
|
|
}
|
|
if req.Outcome != OutcomeFailed || req.LastError != "radarr unavailable" {
|
|
t.Fatalf("request = %+v, want failed outcome with adapter error", req)
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestEnrichesSeriesTVDBID(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
tmdbClient := &fakeTMDBClient{externalIDs: &tmdb.ExternalIDs{TVDBID: 12345}}
|
|
service := newTestServiceWithTMDB(store, tmdbClient)
|
|
|
|
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeSeries,
|
|
TMDBID: 1399,
|
|
Title: "Game of Thrones",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRequest returned error: %v", err)
|
|
}
|
|
if len(store.created) != 1 {
|
|
t.Fatalf("created requests = %d, want 1", len(store.created))
|
|
}
|
|
if store.created[0].Input.TVDBID == nil || *store.created[0].Input.TVDBID != 12345 {
|
|
t.Fatalf("tvdb_id = %v, want 12345", store.created[0].Input.TVDBID)
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
tmdbClient := &fakeTMDBClient{externalIDs: &tmdb.ExternalIDs{TVDBID: 420105, IMDbID: "tt18076310"}}
|
|
presence := &fakePresence{byTVDB: map[MediaType]map[int]int{
|
|
MediaTypeSeries: {420105: 201992},
|
|
}}
|
|
service := NewService(store, tmdbClient, presence)
|
|
|
|
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeSeries,
|
|
TMDBID: 201992,
|
|
Title: "The Rookie: Feds",
|
|
})
|
|
if !errors.Is(err, ErrAlreadyAvailable) {
|
|
t.Fatalf("err = %v, want ErrAlreadyAvailable", err)
|
|
}
|
|
if len(store.created) != 0 {
|
|
t.Fatalf("created requests = %d, want 0", len(store.created))
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestNoActiveDuplicateCreatesRequest(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
service := newTestService(store)
|
|
|
|
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRequest returned error: %v", err)
|
|
}
|
|
if req.Status != StatusPending {
|
|
t.Fatalf("status = %q, want pending", req.Status)
|
|
}
|
|
if len(store.created) != 1 {
|
|
t.Fatalf("created requests = %d, want 1", len(store.created))
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestClearsPriorFailedRequest(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.requests["req-prior-failed"] = &Request{
|
|
ID: "req-prior-failed",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Outcome: OutcomeFailed,
|
|
Status: StatusApproved,
|
|
LastError: "arr: decode response: json: cannot unmarshal object into Go value of type []radarr.movieResource",
|
|
}
|
|
store.requests["req-other-media-failed"] = &Request{
|
|
ID: "req-other-media-failed",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 999,
|
|
Outcome: OutcomeFailed,
|
|
}
|
|
service := newTestService(store)
|
|
|
|
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRequest returned error: %v", err)
|
|
}
|
|
if _, ok := store.requests["req-prior-failed"]; ok {
|
|
t.Fatal("prior failed request was not cleared")
|
|
}
|
|
if _, ok := store.requests["req-other-media-failed"]; !ok {
|
|
t.Fatal("failed request for different media should not be cleared")
|
|
}
|
|
}
|
|
|
|
func TestSearchMarksSeriesAvailableByHydratedTVDBID(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
tmdbClient := &fakeTMDBClient{
|
|
page: &tmdb.MediaPage{
|
|
Page: 1,
|
|
Results: []tmdb.MediaResult{{
|
|
ID: 201992,
|
|
MediaType: "series",
|
|
Title: "The Rookie: Feds",
|
|
Year: 2022,
|
|
}},
|
|
},
|
|
externalIDsByID: map[int]*tmdb.ExternalIDs{
|
|
201992: {TVDBID: 420105, IMDbID: "tt18076310"},
|
|
},
|
|
}
|
|
presence := &fakePresence{byTVDB: map[MediaType]map[int]int{
|
|
MediaTypeSeries: {420105: 201992},
|
|
}}
|
|
service := NewService(store, tmdbClient, presence)
|
|
|
|
page, err := service.Search(context.Background(), testViewer(1), "rookie feds", MediaTypeSeries, 1)
|
|
if err != nil {
|
|
t.Fatalf("Search returned error: %v", err)
|
|
}
|
|
if got := page.Results[0].Availability; got != AvailabilityAvailable {
|
|
t.Fatalf("availability = %q, want available", got)
|
|
}
|
|
if page.Results[0].Request.Reason != "already_available" {
|
|
t.Fatalf("request reason = %q, want already_available", page.Results[0].Request.Reason)
|
|
}
|
|
if len(presence.got) != 1 || presence.got[0].TVDBID == nil || *presence.got[0].TVDBID != 420105 {
|
|
t.Fatalf("presence candidates = %+v, want hydrated tvdb id", presence.got)
|
|
}
|
|
}
|
|
|
|
func TestSearchWithNilPresenceDoesNotHydrateExternalIDs(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{Results: []tmdb.MediaResult{{
|
|
ID: 201992,
|
|
MediaType: "series",
|
|
Title: "The Rookie: Feds",
|
|
}}}}
|
|
service := NewService(store, tmdbClient, nil)
|
|
|
|
_, err := service.Search(context.Background(), testViewer(1), "rookie feds", MediaTypeSeries, 1)
|
|
if err != nil {
|
|
t.Fatalf("Search returned error: %v", err)
|
|
}
|
|
if len(tmdbClient.externalIDCalls) != 0 {
|
|
t.Fatalf("external ID calls = %v, want none", tmdbClient.externalIDCalls)
|
|
}
|
|
}
|
|
|
|
func TestSearchHydratesMultipleResultsBeforePresenceLookup(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
tmdbClient := &fakeTMDBClient{
|
|
page: &tmdb.MediaPage{Results: []tmdb.MediaResult{
|
|
{ID: 201992, MediaType: "series", Title: "The Rookie: Feds"},
|
|
{ID: 1399, MediaType: "series", Title: "Game of Thrones"},
|
|
}},
|
|
externalIDsByID: map[int]*tmdb.ExternalIDs{
|
|
201992: {TVDBID: 420105, IMDbID: "tt18076310"},
|
|
1399: {TVDBID: 121361, IMDbID: "tt0944947"},
|
|
},
|
|
}
|
|
presence := &fakePresence{}
|
|
service := NewService(store, tmdbClient, presence)
|
|
|
|
_, err := service.Search(context.Background(), testViewer(1), "series", MediaTypeSeries, 1)
|
|
if err != nil {
|
|
t.Fatalf("Search returned error: %v", err)
|
|
}
|
|
if len(presence.got) != 2 {
|
|
t.Fatalf("presence candidates = %d, want 2", len(presence.got))
|
|
}
|
|
got := map[int]int{}
|
|
for _, candidate := range presence.got {
|
|
if candidate.TVDBID != nil {
|
|
got[candidate.TMDBID] = *candidate.TVDBID
|
|
}
|
|
}
|
|
if got[201992] != 420105 || got[1399] != 121361 {
|
|
t.Fatalf("hydrated tvdb ids = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchEnrichmentHidesOtherRequesterID(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.active[MediaTypeMovie][550] = &Request{
|
|
ID: "req-existing",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeActive,
|
|
RequestedByUserID: 2,
|
|
}
|
|
tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{
|
|
Page: 1,
|
|
TotalPages: 1,
|
|
TotalResults: 1,
|
|
Results: []tmdb.MediaResult{{
|
|
ID: 550,
|
|
MediaType: "movie",
|
|
Title: "Fight Club",
|
|
Year: 1999,
|
|
}},
|
|
}}
|
|
service := newTestServiceWithTMDB(store, tmdbClient)
|
|
|
|
result, err := service.Search(context.Background(), testViewer(1), "fight", MediaTypeMovie, 1)
|
|
if err != nil {
|
|
t.Fatalf("Search returned error: %v", err)
|
|
}
|
|
if len(result.Results) != 1 {
|
|
t.Fatalf("results = %d, want 1", len(result.Results))
|
|
}
|
|
state := result.Results[0].Request
|
|
if state.Status != StatusQueued || state.Requestable {
|
|
t.Fatalf("state = %+v, want queued non-requestable", state)
|
|
}
|
|
if state.RequestID != "" {
|
|
t.Fatalf("request id leaked as %q", state.RequestID)
|
|
}
|
|
}
|
|
|
|
func TestSearchEnrichmentShowsOwnRequestID(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.active[MediaTypeMovie][550] = &Request{
|
|
ID: "req-existing",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeActive,
|
|
RequestedByUserID: 2,
|
|
}
|
|
tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{
|
|
Page: 1,
|
|
TotalPages: 1,
|
|
TotalResults: 1,
|
|
Results: []tmdb.MediaResult{{
|
|
ID: 550,
|
|
MediaType: "movie",
|
|
Title: "Fight Club",
|
|
}},
|
|
}}
|
|
service := newTestServiceWithTMDB(store, tmdbClient)
|
|
|
|
result, err := service.Search(context.Background(), testViewer(2), "fight", MediaTypeMovie, 1)
|
|
if err != nil {
|
|
t.Fatalf("Search returned error: %v", err)
|
|
}
|
|
if got := result.Results[0].Request.RequestID; got != "req-existing" {
|
|
t.Fatalf("request id = %q, want req-existing", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchWithoutMediaTypeSearchesMoviesAndSeries(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = true
|
|
store.active[MediaTypeSeries][1399] = &Request{
|
|
ID: "req-series",
|
|
MediaType: MediaTypeSeries,
|
|
TMDBID: 1399,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeActive,
|
|
RequestedByUserID: 1,
|
|
}
|
|
tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{
|
|
Page: 1,
|
|
TotalPages: 1,
|
|
TotalResults: 2,
|
|
Results: []tmdb.MediaResult{
|
|
{
|
|
ID: 550,
|
|
MediaType: "movie",
|
|
Title: "Fight Club",
|
|
},
|
|
{
|
|
ID: 1399,
|
|
MediaType: "series",
|
|
Title: "Fight Club: The Series",
|
|
},
|
|
},
|
|
}}
|
|
service := newTestServiceWithTMDB(store, tmdbClient)
|
|
|
|
result, err := service.Search(context.Background(), testViewer(1), "fight", "", 1)
|
|
if err != nil {
|
|
t.Fatalf("Search returned error: %v", err)
|
|
}
|
|
if tmdbClient.searchMediaType != "all" {
|
|
t.Fatalf("search media type = %q, want all", tmdbClient.searchMediaType)
|
|
}
|
|
if len(result.Results) != 2 {
|
|
t.Fatalf("results = %d, want 2", len(result.Results))
|
|
}
|
|
if result.Results[0].MediaType != MediaTypeMovie {
|
|
t.Fatalf("results[0].MediaType = %q, want movie", result.Results[0].MediaType)
|
|
}
|
|
if result.Results[1].MediaType != MediaTypeSeries {
|
|
t.Fatalf("results[1].MediaType = %q, want series", result.Results[1].MediaType)
|
|
}
|
|
if result.Results[1].Request.RequestID != "req-series" {
|
|
t.Fatalf("series request id = %q, want req-series", result.Results[1].Request.RequestID)
|
|
}
|
|
}
|
|
|
|
func TestDisabledRequestsBlockUserSurfaces(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.settings.RequestsEnabled = false
|
|
store.requests["req-1"] = &Request{
|
|
ID: "req-1",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusPending,
|
|
Outcome: OutcomeActive,
|
|
RequestedByUserID: 1,
|
|
}
|
|
tmdbClient := &fakeTMDBClient{
|
|
page: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ID: 550, MediaType: "movie", Title: "Fight Club"}}},
|
|
detail: &tmdb.MediaDetail{ID: 550, MediaType: "movie", Title: "Fight Club"},
|
|
discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ID: 550, MediaType: "movie", Title: "Fight Club"}}},
|
|
}
|
|
service := newTestServiceWithTMDB(store, tmdbClient)
|
|
viewer := testViewer(1)
|
|
|
|
cases := []struct {
|
|
name string
|
|
call func() error
|
|
}{
|
|
{"search", func() error {
|
|
_, err := service.Search(context.Background(), viewer, "fight", MediaTypeMovie, 1)
|
|
return err
|
|
}},
|
|
{"discover all", func() error {
|
|
_, err := service.DiscoverAll(context.Background(), viewer)
|
|
return err
|
|
}},
|
|
{"discover section", func() error {
|
|
_, err := service.Discover(context.Background(), viewer, "popular_movies", 1)
|
|
return err
|
|
}},
|
|
{"detail", func() error {
|
|
_, err := service.GetDetail(context.Background(), viewer, MediaTypeMovie, 550)
|
|
return err
|
|
}},
|
|
{"create", func() error {
|
|
_, err := service.CreateRequest(context.Background(), viewer, CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
return err
|
|
}},
|
|
{"mine", func() error {
|
|
_, err := service.ListMine(context.Background(), viewer, ListFilter{})
|
|
return err
|
|
}},
|
|
{"get", func() error {
|
|
_, err := service.GetRequest(context.Background(), viewer, "req-1")
|
|
return err
|
|
}},
|
|
{"cancel", func() error {
|
|
_, err := service.Cancel(context.Background(), viewer, "req-1", "")
|
|
return err
|
|
}},
|
|
{"studios", func() error {
|
|
_, err := service.ListStudios(context.Background(), viewer)
|
|
return err
|
|
}},
|
|
{"networks", func() error {
|
|
_, err := service.ListNetworks(context.Background(), viewer)
|
|
return err
|
|
}},
|
|
{"genres", func() error {
|
|
_, err := service.ListGenres(context.Background(), viewer)
|
|
return err
|
|
}},
|
|
{"browse studio", func() error {
|
|
_, err := service.BrowseStudio(context.Background(), viewer, "marvel-studios", "popularity", 1)
|
|
return err
|
|
}},
|
|
{"browse network", func() error {
|
|
_, err := service.BrowseNetwork(context.Background(), viewer, "netflix", "popularity", 1)
|
|
return err
|
|
}},
|
|
{"browse genre", func() error {
|
|
_, err := service.BrowseGenre(context.Background(), viewer, "action", MediaTypeMovie, "popularity", 1)
|
|
return err
|
|
}},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if err := tc.call(); !errors.Is(err, ErrRequestsDisabled) {
|
|
t.Fatalf("err = %v, want ErrRequestsDisabled", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReconcileRequestsCompletesFromCatalogPresence(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.candidates = []*Request{{
|
|
ID: "req-1",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeActive,
|
|
}}
|
|
service := NewService(store, &fakeTMDBClient{}, &fakePresence{available: map[MediaType]map[int]bool{
|
|
MediaTypeMovie: {550: true},
|
|
}})
|
|
|
|
result, err := service.ReconcileRequests(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatalf("ReconcileRequests returned error: %v", err)
|
|
}
|
|
if result.Completed != 1 || len(store.statusUpdates) != 1 || store.statusUpdates[0] != StatusCompleted {
|
|
t.Fatalf("result = %+v statusUpdates = %+v, want one completed update", result, store.statusUpdates)
|
|
}
|
|
}
|
|
|
|
func TestReconcileRequestsCompletesByStoredTVDBID(t *testing.T) {
|
|
store := newFakeStore()
|
|
tvdbID := 420105
|
|
store.candidates = []*Request{{
|
|
ID: "req-1",
|
|
MediaType: MediaTypeSeries,
|
|
TMDBID: 201992,
|
|
TVDBID: &tvdbID,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeActive,
|
|
}}
|
|
presence := &fakePresence{byTVDB: map[MediaType]map[int]int{
|
|
MediaTypeSeries: {420105: 201992},
|
|
}}
|
|
service := NewService(store, &fakeTMDBClient{}, presence)
|
|
|
|
result, err := service.ReconcileRequests(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatalf("ReconcileRequests returned error: %v", err)
|
|
}
|
|
if result.Completed != 1 {
|
|
t.Fatalf("completed = %d, want 1", result.Completed)
|
|
}
|
|
}
|
|
|
|
func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) {
|
|
store := newFakeStore()
|
|
qualityProfileID := 1
|
|
store.integrations = []Integration{{
|
|
Kind: "radarr",
|
|
Enabled: true,
|
|
BaseURL: "http://radarr.local",
|
|
APIKeyRef: "radarr-key",
|
|
RootFolder: "/movies",
|
|
QualityProfileID: &qualityProfileID,
|
|
}}
|
|
store.candidates = []*Request{{
|
|
ID: "req-1",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeActive,
|
|
ExternalID: "123",
|
|
}}
|
|
adapter := &fakeMovieAdapter{status: FulfillmentStatus{
|
|
Status: StatusDownloading,
|
|
IntegrationKind: "radarr",
|
|
ExternalID: "123",
|
|
ExternalStatus: "downloading",
|
|
}}
|
|
service := newTestService(store)
|
|
service.SetFulfillmentAdapters(adapter, nil)
|
|
|
|
result, err := service.ReconcileRequests(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatalf("ReconcileRequests returned error: %v", err)
|
|
}
|
|
if result.Downloading != 1 || len(store.statusUpdates) != 1 || store.statusUpdates[0] != StatusDownloading {
|
|
t.Fatalf("result = %+v statusUpdates = %+v, want one downloading update", result, store.statusUpdates)
|
|
}
|
|
if adapter.statusCalls != 1 {
|
|
t.Fatalf("status adapter calls = %d, want 1", adapter.statusCalls)
|
|
}
|
|
}
|
|
|
|
func TestCancelOwnerCanWithdrawPendingRequest(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.requests["req-mine"] = &Request{
|
|
ID: "req-mine",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusPending,
|
|
Outcome: OutcomeActive,
|
|
RequestedByUserID: 7,
|
|
}
|
|
service := newTestService(store)
|
|
|
|
req, err := service.Cancel(context.Background(), Viewer{UserID: 7, ProfileID: "profile-1"}, "req-mine", "no longer want")
|
|
if err != nil {
|
|
t.Fatalf("Cancel returned error: %v", err)
|
|
}
|
|
if req.Outcome != OutcomeCancelled {
|
|
t.Fatalf("Outcome = %q, want cancelled", req.Outcome)
|
|
}
|
|
}
|
|
|
|
func TestCancelNonOwnerForbidden(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.requests["req-someone-else"] = &Request{
|
|
ID: "req-someone-else",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusPending,
|
|
Outcome: OutcomeActive,
|
|
RequestedByUserID: 7,
|
|
}
|
|
service := newTestService(store)
|
|
|
|
_, err := service.Cancel(context.Background(), Viewer{UserID: 8, ProfileID: "profile-2"}, "req-someone-else", "")
|
|
if !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("err = %v, want ErrForbidden", err)
|
|
}
|
|
}
|
|
|
|
func TestCancelAdminCanCancelAnyPending(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.requests["req-other"] = &Request{
|
|
ID: "req-other",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusPending,
|
|
Outcome: OutcomeActive,
|
|
RequestedByUserID: 7,
|
|
}
|
|
service := newTestService(store)
|
|
|
|
_, err := service.Cancel(context.Background(), Viewer{UserID: 99, IsAdmin: true}, "req-other", "house cleaning")
|
|
if err != nil {
|
|
t.Fatalf("admin Cancel returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCancelRejectsRequestsAlreadyInFulfillment(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
req Request
|
|
}{
|
|
{"approved", Request{Status: StatusApproved, Outcome: OutcomeActive}},
|
|
{"queued", Request{Status: StatusQueued, Outcome: OutcomeActive, IntegrationKind: "radarr", ExternalID: "42"}},
|
|
{"downloading", Request{Status: StatusDownloading, Outcome: OutcomeActive, IntegrationKind: "radarr", ExternalID: "42"}},
|
|
{"completed", Request{Status: StatusCompleted, Outcome: OutcomeActive}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
store := newFakeStore()
|
|
tc.req.ID = "req-x"
|
|
tc.req.RequestedByUserID = 7
|
|
store.requests["req-x"] = &tc.req
|
|
service := newTestService(store)
|
|
|
|
_, err := service.Cancel(context.Background(), Viewer{UserID: 7, ProfileID: "profile-1"}, "req-x", "")
|
|
if !errors.Is(err, ErrInvalidState) {
|
|
t.Fatalf("err = %v, want ErrInvalidState for %s", err, tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeclineRejectsApprovedRequests(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.requests["req-approved"] = &Request{
|
|
ID: "req-approved",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusApproved,
|
|
Outcome: OutcomeActive,
|
|
}
|
|
service := newTestService(store)
|
|
|
|
_, err := service.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-approved", "changed mind")
|
|
if !errors.Is(err, ErrInvalidState) {
|
|
t.Fatalf("err = %v, want ErrInvalidState (approved is owned by the reconciler)", err)
|
|
}
|
|
}
|
|
|
|
func TestDeclineRejectsQueuedRequests(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.requests["req-1"] = &Request{
|
|
ID: "req-1",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeActive,
|
|
IntegrationKind: "radarr",
|
|
ExternalID: "42",
|
|
}
|
|
service := newTestService(store)
|
|
|
|
_, err := service.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1", "not needed")
|
|
if !errors.Is(err, ErrInvalidState) {
|
|
t.Fatalf("err = %v, want ErrInvalidState", err)
|
|
}
|
|
}
|
|
|
|
func TestRetryResubmitsFailedQueuedRequest(t *testing.T) {
|
|
store := newFakeStore()
|
|
qualityProfileID := 1
|
|
store.integrations = []Integration{{
|
|
Kind: "radarr",
|
|
Enabled: true,
|
|
BaseURL: "http://radarr.local",
|
|
APIKeyRef: "radarr-key",
|
|
RootFolder: "/movies",
|
|
QualityProfileID: &qualityProfileID,
|
|
}}
|
|
store.requests["req-1"] = &Request{
|
|
ID: "req-1",
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Status: StatusQueued,
|
|
Outcome: OutcomeFailed,
|
|
}
|
|
adapter := &fakeMovieAdapter{result: FulfillmentResult{
|
|
IntegrationKind: "radarr",
|
|
ExternalID: "99",
|
|
ExternalStatus: "queued",
|
|
}}
|
|
service := newTestService(store)
|
|
service.SetFulfillmentAdapters(adapter, nil)
|
|
|
|
req, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1")
|
|
if err != nil {
|
|
t.Fatalf("Retry returned error: %v", err)
|
|
}
|
|
if adapter.calls != 1 {
|
|
t.Fatalf("adapter calls = %d, want 1", adapter.calls)
|
|
}
|
|
if adapter.gotReq.Status != StatusApproved {
|
|
t.Fatalf("submitted status = %q, want approved", adapter.gotReq.Status)
|
|
}
|
|
if req.Status != StatusQueued || req.ExternalID != "99" {
|
|
t.Fatalf("request = %+v, want re-queued with external id 99", req)
|
|
}
|
|
}
|
|
|
|
func newTestService(store *fakeStore) *Service {
|
|
return newTestServiceWithTMDB(store, &fakeTMDBClient{})
|
|
}
|
|
|
|
func newTestServiceWithTMDB(store *fakeStore, tmdbClient *fakeTMDBClient) *Service {
|
|
service := NewService(store, tmdbClient, &fakePresence{})
|
|
service.Now = func() time.Time { return time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) }
|
|
return service
|
|
}
|
|
|
|
func testViewer(userID int) Viewer {
|
|
return Viewer{UserID: userID, ProfileID: "profile-1"}
|
|
}
|
|
|
|
type fakeStore struct {
|
|
mu sync.Mutex
|
|
settings Settings
|
|
limit *UserLimit
|
|
count int
|
|
active map[MediaType]map[int]*Request
|
|
created []CreateRequestRecord
|
|
integrations []Integration
|
|
queued []QueueUpdate
|
|
candidates []*Request
|
|
statusUpdates []Status
|
|
requests map[string]*Request
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{
|
|
settings: Settings{
|
|
RequestsEnabled: true,
|
|
GlobalMaxRequests: 5,
|
|
GlobalWindowDays: 7,
|
|
},
|
|
active: map[MediaType]map[int]*Request{
|
|
MediaTypeMovie: {},
|
|
MediaTypeSeries: {},
|
|
},
|
|
requests: map[string]*Request{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeStore) GetSettings(context.Context) (Settings, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.settings, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpdateSettings(_ context.Context, settings Settings) (Settings, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.settings = settings
|
|
return settings, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetUserLimit(context.Context, int) (*UserLimit, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.limit, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpsertUserLimit(_ context.Context, limit UserLimit) (*UserLimit, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.limit = &limit
|
|
return &limit, nil
|
|
}
|
|
|
|
func (f *fakeStore) CountUserRequestsSince(_ context.Context, userID int, since time.Time) (int, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
used := f.count
|
|
for _, prior := range f.created {
|
|
if prior.Requester.UserID != userID {
|
|
continue
|
|
}
|
|
if prior.Now.Before(since) {
|
|
continue
|
|
}
|
|
used++
|
|
}
|
|
return used, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListActiveByTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := map[int]*Request{}
|
|
for _, id := range ids {
|
|
if req := f.active[mediaType][id]; req != nil {
|
|
out[id] = req
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) DeleteFailedByTMDB(_ context.Context, mediaType MediaType, tmdbID int) (int, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
deleted := 0
|
|
for id, req := range f.requests {
|
|
if req.MediaType == mediaType && req.TMDBID == tmdbID && req.Outcome == OutcomeFailed {
|
|
delete(f.requests, id)
|
|
deleted++
|
|
}
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) (*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if input.Quota != nil {
|
|
used := f.count
|
|
for _, prior := range f.created {
|
|
if prior.Requester.UserID != input.Quota.UserID {
|
|
continue
|
|
}
|
|
if prior.Now.Before(input.Quota.WindowStart) {
|
|
continue
|
|
}
|
|
used++
|
|
}
|
|
if used >= input.Quota.MaxRequests {
|
|
return nil, ErrQuotaExceeded
|
|
}
|
|
}
|
|
f.created = append(f.created, input)
|
|
return &Request{
|
|
ID: input.ID,
|
|
Provider: "tmdb",
|
|
MediaType: input.Input.MediaType,
|
|
TMDBID: input.Input.TMDBID,
|
|
TVDBID: input.Input.TVDBID,
|
|
IMDbID: input.Input.IMDbID,
|
|
Title: input.Input.Title,
|
|
Status: input.Status,
|
|
Outcome: input.Outcome,
|
|
RequestedByUserID: input.Requester.UserID,
|
|
RequestedByProfileID: input.Requester.ProfileID,
|
|
CreatedAt: input.Now,
|
|
UpdatedAt: input.Now,
|
|
}, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetRequest(_ context.Context, id string) (*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
req := f.requests[strings.TrimSpace(id)]
|
|
if req == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
copy := *req
|
|
return ©, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.candidates, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Viewer) (*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.statusUpdates = append(f.statusUpdates, status)
|
|
req := f.requests[id]
|
|
if req == nil {
|
|
req = &Request{ID: id, Outcome: OutcomeActive}
|
|
f.requests[id] = req
|
|
}
|
|
req.Status = status
|
|
copy := *req
|
|
return ©, nil
|
|
}
|
|
|
|
func (f *fakeStore) MarkQueued(_ context.Context, id string, update QueueUpdate, _ Viewer) (*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.queued = append(f.queued, update)
|
|
req := f.requests[id]
|
|
if req == nil {
|
|
req = &Request{ID: id}
|
|
f.requests[id] = req
|
|
}
|
|
req.Status = StatusQueued
|
|
req.Outcome = OutcomeActive
|
|
req.IntegrationKind = update.IntegrationKind
|
|
req.ExternalID = update.ExternalID
|
|
req.ExternalStatus = update.ExternalStatus
|
|
copy := *req
|
|
return ©, nil
|
|
}
|
|
|
|
func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
req := f.requests[id]
|
|
if req == nil {
|
|
req = &Request{ID: id}
|
|
f.requests[id] = req
|
|
}
|
|
req.Outcome = outcome
|
|
req.LastError = message
|
|
copy := *req
|
|
return ©, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.integrations, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integration, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpsertIntegrations(_ context.Context, integrations []Integration) ([]Integration, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.integrations = append([]Integration(nil), integrations...)
|
|
return append([]Integration(nil), integrations...), nil
|
|
}
|
|
|
|
func TestListStudiosReturnsBundleWithDuotoneLogos(t *testing.T) {
|
|
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
|
|
|
|
studios, err := service.ListStudios(context.Background(), testViewer(1))
|
|
if err != nil {
|
|
t.Fatalf("ListStudios: %v", err)
|
|
}
|
|
if len(studios) != len(BundledStudios) {
|
|
t.Fatalf("len = %d, want %d", len(studios), len(BundledStudios))
|
|
}
|
|
|
|
for _, s := range studios {
|
|
if s.LogoURL == nil || *s.LogoURL == "" {
|
|
t.Errorf("studio %q missing logo URL", s.Slug)
|
|
continue
|
|
}
|
|
if !strings.Contains(*s.LogoURL, "filter(duotone,ffffff,bababa)") {
|
|
t.Errorf("studio %q logo URL missing duotone filter: %s", s.Slug, *s.LogoURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListNetworksReturnsBundleWithDuotoneLogos(t *testing.T) {
|
|
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
|
|
|
|
networks, err := service.ListNetworks(context.Background(), testViewer(1))
|
|
if err != nil {
|
|
t.Fatalf("ListNetworks: %v", err)
|
|
}
|
|
if len(networks) != len(BundledNetworks) {
|
|
t.Fatalf("len = %d, want %d", len(networks), len(BundledNetworks))
|
|
}
|
|
for _, n := range networks {
|
|
if n.LogoURL == nil || *n.LogoURL == "" {
|
|
t.Errorf("network %q missing logo URL", n.Slug)
|
|
continue
|
|
}
|
|
if !strings.Contains(*n.LogoURL, "filter(duotone,ffffff,bababa)") {
|
|
t.Errorf("network %q logo URL missing duotone filter: %s", n.Slug, *n.LogoURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListGenresReturnsBundleWithSeriesSupportFlag(t *testing.T) {
|
|
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
|
|
|
|
genres, err := service.ListGenres(context.Background(), testViewer(1))
|
|
if err != nil {
|
|
t.Fatalf("ListGenres: %v", err)
|
|
}
|
|
if len(genres) != len(BundledGenres) {
|
|
t.Fatalf("len = %d, want %d", len(genres), len(BundledGenres))
|
|
}
|
|
for _, g := range genres {
|
|
switch g.Slug {
|
|
case "action", "comedy", "drama", "sci-fi", "animation", "documentary":
|
|
if !g.SeriesSupported {
|
|
t.Errorf("%s should support series", g.Slug)
|
|
}
|
|
case "horror", "romance":
|
|
if g.SeriesSupported {
|
|
t.Errorf("%s should not support series", g.Slug)
|
|
}
|
|
}
|
|
if g.GradientFrom == "" || g.GradientTo == "" {
|
|
t.Errorf("%s missing gradient", g.Slug)
|
|
}
|
|
if g.LogoURL != nil {
|
|
t.Errorf("%s should not have a logo URL", g.Slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBrowseStudioReturnsEnrichedMovies(t *testing.T) {
|
|
tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{
|
|
Page: 1,
|
|
TotalPages: 2,
|
|
TotalResults: 20,
|
|
Results: []tmdb.MediaResult{
|
|
{ID: 24428, MediaType: "movie", Title: "The Avengers", Year: 2012, Popularity: 100.5},
|
|
},
|
|
}}
|
|
service := newTestServiceWithTMDB(newFakeStore(), tmdbClient)
|
|
|
|
resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "popularity", 1)
|
|
if err != nil {
|
|
t.Fatalf("BrowseStudio: %v", err)
|
|
}
|
|
if resp.Kind != "studio" || resp.Slug != "marvel-studios" || resp.MediaType != MediaTypeMovie {
|
|
t.Errorf("resp = %+v", resp)
|
|
}
|
|
if resp.Page != 1 || resp.TotalPages != 2 {
|
|
t.Errorf("pagination = %d/%d", resp.Page, resp.TotalPages)
|
|
}
|
|
if len(resp.Results) != 1 || resp.Results[0].TMDBID != 24428 {
|
|
t.Errorf("results = %+v", resp.Results)
|
|
}
|
|
if resp.Results[0].Availability == "" {
|
|
t.Error("availability should be enriched")
|
|
}
|
|
}
|
|
|
|
func TestBrowseStudioUnknownSlugReturnsNotFound(t *testing.T) {
|
|
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
|
|
_, err := service.BrowseStudio(context.Background(), testViewer(1), "not-a-studio", "popularity", 1)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestBrowseStudioRejectsBadSort(t *testing.T) {
|
|
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
|
|
_, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "made-up-sort", 1)
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("err = %v, want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestBrowseStudioDefaultsBlankSortToPopularity(t *testing.T) {
|
|
tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{}}}
|
|
service := newTestServiceWithTMDB(newFakeStore(), tmdbClient)
|
|
|
|
resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "", 1)
|
|
if err != nil {
|
|
t.Fatalf("BrowseStudio: %v", err)
|
|
}
|
|
if resp.Sort != "popularity" {
|
|
t.Errorf("sort = %q, want popularity (default)", resp.Sort)
|
|
}
|
|
}
|
|
|
|
func TestBrowseNetworkReturnsSeries(t *testing.T) {
|
|
tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{
|
|
Page: 1, TotalPages: 1, TotalResults: 1,
|
|
Results: []tmdb.MediaResult{
|
|
{ID: 1399, MediaType: "series", Title: "Game of Thrones", Year: 2011},
|
|
},
|
|
}}
|
|
service := newTestServiceWithTMDB(newFakeStore(), tmdbClient)
|
|
|
|
resp, err := service.BrowseNetwork(context.Background(), testViewer(1), "netflix", "popularity", 1)
|
|
if err != nil {
|
|
t.Fatalf("BrowseNetwork: %v", err)
|
|
}
|
|
if resp.MediaType != MediaTypeSeries {
|
|
t.Errorf("media_type = %q, want series", resp.MediaType)
|
|
}
|
|
}
|
|
|
|
func TestBrowseGenreRequiresMediaType(t *testing.T) {
|
|
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
|
|
_, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "", "popularity", 1)
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("err = %v, want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestBrowseGenreSeriesRejectedWhenUnsupported(t *testing.T) {
|
|
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
|
|
_, err := service.BrowseGenre(context.Background(), testViewer(1), "horror", "series", "popularity", 1)
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("err = %v, want ErrInvalidInput for horror+series", err)
|
|
}
|
|
}
|
|
|
|
func TestBrowseGenreMovieReturnsResults(t *testing.T) {
|
|
tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{
|
|
Results: []tmdb.MediaResult{{ID: 1, MediaType: "movie", Title: "Movie"}},
|
|
}}
|
|
service := newTestServiceWithTMDB(newFakeStore(), tmdbClient)
|
|
|
|
resp, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "movie", "popularity", 1)
|
|
if err != nil {
|
|
t.Fatalf("BrowseGenre: %v", err)
|
|
}
|
|
if resp.Kind != "genre" || resp.Slug != "action" || resp.MediaType != MediaTypeMovie {
|
|
t.Errorf("resp = %+v", resp)
|
|
}
|
|
}
|
|
|
|
type fakePresence struct {
|
|
mu sync.Mutex
|
|
available map[MediaType]map[int]bool
|
|
byTVDB map[MediaType]map[int]int
|
|
got []PresenceCandidate
|
|
}
|
|
|
|
func (f *fakePresence) Lookup(_ context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := map[int]PresenceMatch{}
|
|
f.got = append(f.got, candidates...)
|
|
for _, candidate := range candidates {
|
|
if f.available != nil && f.available[mediaType][candidate.TMDBID] {
|
|
out[candidate.TMDBID] = PresenceMatch{Available: true, MatchedProvider: "tmdb"}
|
|
continue
|
|
}
|
|
if candidate.TVDBID != nil && f.byTVDB != nil {
|
|
if tmdbID, ok := f.byTVDB[mediaType][*candidate.TVDBID]; ok && tmdbID == candidate.TMDBID {
|
|
out[candidate.TMDBID] = PresenceMatch{Available: true, MatchedProvider: "tvdb"}
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]bool, error) {
|
|
out := map[int]bool{}
|
|
for _, id := range ids {
|
|
matches, err := f.Lookup(context.Background(), mediaType, []PresenceCandidate{{TMDBID: id}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if matches[id].Available {
|
|
out[id] = true
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type fakeTMDBClient struct {
|
|
mu sync.Mutex
|
|
page *tmdb.MediaPage
|
|
externalIDs *tmdb.ExternalIDs
|
|
externalIDsByID map[int]*tmdb.ExternalIDs
|
|
externalIDCalls []int
|
|
detail *tmdb.MediaDetail
|
|
discoverPage *tmdb.MediaPage
|
|
discoverErr error
|
|
searchMediaType string
|
|
}
|
|
|
|
func (f *fakeTMDBClient) SearchMedia(_ context.Context, mediaType, _ string, _ int) (*tmdb.MediaPage, error) {
|
|
f.searchMediaType = mediaType
|
|
return f.page, nil
|
|
}
|
|
|
|
func (f *fakeTMDBClient) DiscoverSection(context.Context, string, int) (*tmdb.MediaPage, error) {
|
|
return f.page, nil
|
|
}
|
|
|
|
func (f *fakeTMDBClient) DiscoverPage(context.Context, string, tmdb.DiscoverParams, int) (*tmdb.MediaPage, error) {
|
|
if f.discoverErr != nil {
|
|
return nil, f.discoverErr
|
|
}
|
|
if f.discoverPage != nil {
|
|
return f.discoverPage, nil
|
|
}
|
|
return &tmdb.MediaPage{Results: []tmdb.MediaResult{}}, nil
|
|
}
|
|
|
|
func (f *fakeTMDBClient) GetExternalIDs(_ context.Context, _ string, id int) (*tmdb.ExternalIDs, error) {
|
|
f.mu.Lock()
|
|
f.externalIDCalls = append(f.externalIDCalls, id)
|
|
f.mu.Unlock()
|
|
if f.externalIDsByID != nil {
|
|
return f.externalIDsByID[id], nil
|
|
}
|
|
return f.externalIDs, nil
|
|
}
|
|
|
|
func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.MediaDetail, error) {
|
|
return f.detail, nil
|
|
}
|
|
|
|
type fakeMovieAdapter struct {
|
|
result FulfillmentResult
|
|
status FulfillmentStatus
|
|
err error
|
|
statusErr error
|
|
calls int
|
|
statusCalls int
|
|
gotReq Request
|
|
gotIntegration Integration
|
|
}
|
|
|
|
func (f *fakeMovieAdapter) SubmitMovie(_ context.Context, req Request, integration Integration) (FulfillmentResult, error) {
|
|
f.calls++
|
|
f.gotReq = req
|
|
f.gotIntegration = integration
|
|
return f.result, f.err
|
|
}
|
|
|
|
func (f *fakeMovieAdapter) CheckMovieStatus(_ context.Context, req Request, integration Integration) (FulfillmentStatus, error) {
|
|
f.statusCalls++
|
|
f.gotReq = req
|
|
f.gotIntegration = integration
|
|
return f.status, f.statusErr
|
|
}
|
|
|
|
type fakeSecrets map[string]string
|
|
|
|
func (f fakeSecrets) Get(_ context.Context, key string) (string, error) {
|
|
return f[key], nil
|
|
}
|
|
|
|
type fakeSecretError struct {
|
|
err error
|
|
}
|
|
|
|
func (f fakeSecretError) Get(context.Context, string) (string, error) {
|
|
return "", f.err
|
|
}
|