* docs: design spec for multi-instance Sonarr/Radarr request routing Seerr-style multi-instance arr management inside Silo's request system: many instances per kind, HD/4K default routing, entitlement-driven dual-quality fan-out, per-instance anime overrides (keyword 210024), and a one-to-many media_request_targets model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plan for multi-instance arr request routing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(requests): migration for multi-instance arr routing Adds migration 169 to convert request_integrations from a one-row-per-kind table keyed on `kind` to a multi-instance table keyed on `id`, with HD/4K defaults, anime overrides, and a new one-to-many media_request_targets table for per-quality fulfillment tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(requests): instance, target, and dual-quality types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(requests): id-based integration CRUD Replace upsert-by-kind (UpsertIntegration/UpsertIntegrations) with GetIntegration, CreateIntegration, UpdateIntegration, DeleteIntegration, and ClearDefault. Rewrites scanIntegration and integrationColumns to cover all new multi-instance columns (id, name, is_4k, is_default, is_default_4k, anime_* fields). Updates the Store interface accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(requests): target persistence and aggregate status * feat(tmdb): expose keyword ids and original language on detail * feat(requests): Seerr-exact anime detection (keyword 210024) * feat(requests): quality/anime routing engine Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(requests): force_dual_quality setting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(requests): multi-target fulfillment, reconcile, retry, and instance CRUD Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(api): request integration CRUD endpoints, targets in responses, entitlement wiring Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): multi-instance request integration types and CRUD hooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): multi-instance arr manager, dual-quality toggle, per-target queue Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): UX review fixes for arr manager (delete confirm, switch hints, test feedback, dirty + target status) * fix(requests): address code-review findings (test-connection by id, HD-only default ceiling, retryable partial failure, idempotent submit, transactional defaults, presence/target reconcile, auto-approve gate) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review (anime override fallback, non-null slices, save gate, a11y, DeleteTarget not-found) - routing: anime fields only override standard root/profile/tags when set, so enabling anime with blank fields reuses standard values instead of clearing them into an invalid submission - api: normalize nil Tags/AnimeTags to [] so they serialize as arrays not null - web: require an API key before saving a NEW instance; add aria-expanded/ aria-controls to the anime-overrides disclosure toggle - repo: DeleteTarget returns ErrNotFound when no row was deleted Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(requests): address PR review findings --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
250 lines
8.8 KiB
Go
250 lines
8.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
|
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
|
)
|
|
|
|
type fakeRequestService struct {
|
|
listStudiosFn func() ([]mediarequests.DiscoverBrandCard, error)
|
|
listNetworksFn func() ([]mediarequests.DiscoverBrandCard, error)
|
|
listGenresFn func() ([]mediarequests.DiscoverBrandCard, error)
|
|
browseFn func(kind, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
|
|
}
|
|
|
|
func (f *fakeRequestService) ListStudios(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
|
|
if f.listStudiosFn != nil {
|
|
return f.listStudiosFn()
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) ListNetworks(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
|
|
if f.listNetworksFn != nil {
|
|
return f.listNetworksFn()
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) ListGenres(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
|
|
if f.listGenresFn != nil {
|
|
return f.listGenresFn()
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) BrowseStudio(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
|
|
return f.browseFn("studio", slug, mediarequests.MediaTypeMovie, sort, page)
|
|
}
|
|
|
|
func (f *fakeRequestService) BrowseNetwork(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
|
|
return f.browseFn("network", slug, mediarequests.MediaTypeSeries, sort, page)
|
|
}
|
|
|
|
func (f *fakeRequestService) BrowseGenre(_ context.Context, _ mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
|
|
return f.browseFn("genre", slug, mediaType, sort, page)
|
|
}
|
|
|
|
func (f *fakeRequestService) Search(context.Context, mediarequests.Viewer, string, mediarequests.MediaType, int) (*mediarequests.MediaPage, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) Discover(context.Context, mediarequests.Viewer, string, int) (*mediarequests.DiscoverySection, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) DiscoverAll(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverySection, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) GetDetail(context.Context, mediarequests.Viewer, mediarequests.MediaType, int) (*mediarequests.MediaDetail, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) CreateRequest(context.Context, mediarequests.Viewer, mediarequests.CreateRequestInput) (*mediarequests.Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) ListMine(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) ListAdmin(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) GetRequest(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) Approve(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) Decline(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) Cancel(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) Retry(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) GetFeatureStatus(context.Context, mediarequests.Viewer) (mediarequests.FeatureStatus, error) {
|
|
return mediarequests.FeatureStatus{}, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) GetSettings(context.Context, mediarequests.Viewer) (mediarequests.Settings, error) {
|
|
return mediarequests.Settings{}, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) UpdateSettings(context.Context, mediarequests.Viewer, mediarequests.Settings) (mediarequests.Settings, error) {
|
|
return mediarequests.Settings{}, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) GetUserLimit(context.Context, mediarequests.Viewer, int) (*mediarequests.UserLimit, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) UpsertUserLimit(context.Context, mediarequests.Viewer, mediarequests.UserLimit) (*mediarequests.UserLimit, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Viewer) ([]mediarequests.Integration, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) CreateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) {
|
|
return &integration, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) UpdateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) {
|
|
return &integration, nil
|
|
}
|
|
|
|
func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Viewer, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func authedRequest(method, target string) *http.Request {
|
|
req := httptest.NewRequest(method, target, nil)
|
|
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
|
|
UserID: 1,
|
|
Role: "user",
|
|
TokenType: auth.TokenTypeAccess,
|
|
})
|
|
ctx = apimw.SetProfileID(ctx, "profile-1")
|
|
return req.WithContext(ctx)
|
|
}
|
|
|
|
func TestHandleListStudiosReturnsJSON(t *testing.T) {
|
|
logo := "https://image.tmdb.org/t/p/w300/x.png"
|
|
svc := &fakeRequestService{
|
|
listStudiosFn: func() ([]mediarequests.DiscoverBrandCard, error) {
|
|
return []mediarequests.DiscoverBrandCard{
|
|
{TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", LogoURL: &logo},
|
|
}, nil
|
|
},
|
|
}
|
|
h := NewRequestsHandler(svc)
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.HandleListStudios(rec, authedRequest("GET", "/api/v1/requests/discover/studios"))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
var body struct {
|
|
Studios []mediarequests.DiscoverBrandCard `json:"studios"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(body.Studios) != 1 || body.Studios[0].Slug != "marvel-studios" {
|
|
t.Errorf("studios = %+v", body.Studios)
|
|
}
|
|
}
|
|
|
|
func TestHandleBrowseStudioRejectsUnknownSort(t *testing.T) {
|
|
svc := &fakeRequestService{
|
|
browseFn: func(kind, slug string, _ mediarequests.MediaType, sort string, _ int) (*mediarequests.DiscoverBrowseResponse, error) {
|
|
return nil, mediarequests.ErrInvalidInput
|
|
},
|
|
}
|
|
h := NewRequestsHandler(svc)
|
|
|
|
req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/marvel-studios?sort=garbage")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("slug", "marvel-studios")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.HandleBrowseStudio(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleBrowseStudioUnknownSlugReturns404(t *testing.T) {
|
|
svc := &fakeRequestService{
|
|
browseFn: func(string, string, mediarequests.MediaType, string, int) (*mediarequests.DiscoverBrowseResponse, error) {
|
|
return nil, mediarequests.ErrNotFound
|
|
},
|
|
}
|
|
h := NewRequestsHandler(svc)
|
|
|
|
req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/ghosts")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("slug", "ghosts")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.HandleBrowseStudio(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleBrowseGenreRequiresMediaType(t *testing.T) {
|
|
svc := &fakeRequestService{
|
|
browseFn: func(_ string, _ string, mt mediarequests.MediaType, _ string, _ int) (*mediarequests.DiscoverBrowseResponse, error) {
|
|
if strings.TrimSpace(string(mt)) == "" {
|
|
return nil, mediarequests.ErrInvalidInput
|
|
}
|
|
return &mediarequests.DiscoverBrowseResponse{Kind: "genre"}, nil
|
|
},
|
|
}
|
|
h := NewRequestsHandler(svc)
|
|
|
|
req := authedRequest("GET", "/api/v1/requests/discover/browse/genre/action")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("slug", "action")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.HandleBrowseGenre(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|