Files
silo-server/internal/requests/service_test.go
T
Silo Server MigrationandClaude Opus 4.7 fca23d77e9 fix(requests): serialize quota check and clamp list limits
CreateRequest previously read the user's request count outside the
insert transaction, so two concurrent submissions at MaxRequests-1
could both pass the quota gate and end up at MaxRequests+1. Move the
count inside the same transaction as the insert and acquire a per-user
advisory lock so concurrent inserts serialize. The store reports
ErrQuotaExceeded when the racing path catches the user at the limit
and the service maps it back to QuotaError.

normalizeListFilter previously reset limit to 50 when callers asked
for more than 100, which is surprising. Clamp to the cap instead so a
request for 150 returns 100 and a request for 1_000_000 still cannot
hit the database with an unbounded scan.

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

1235 lines
36 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, &quota) {
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, &quota) {
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 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 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 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{
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) 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 &copy, 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 &copy, 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 &copy, 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 &copy, 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
}