572 lines
22 KiB
Go
572 lines
22 KiB
Go
package handlers
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
||
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/metadata"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||
|
|
)
|
||
|
|
|
||
|
|
type fakeTrailerItemAccess struct {
|
||
|
|
items map[string]*models.MediaItem
|
||
|
|
ensureErr map[string]error
|
||
|
|
getErr map[string]error
|
||
|
|
checked []string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeTrailerItemAccess) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
|
||
|
|
if err := f.getErr[contentID]; err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if item := f.items[contentID]; item != nil {
|
||
|
|
return item, nil
|
||
|
|
}
|
||
|
|
return nil, catalog.ErrItemNotFound
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeTrailerItemAccess) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
|
||
|
|
f.checked = append(f.checked, contentID)
|
||
|
|
return f.ensureErr[contentID]
|
||
|
|
}
|
||
|
|
|
||
|
|
type fakeTrailerRefreshRequester struct {
|
||
|
|
outcome metadata.TrailerRefreshOutcome
|
||
|
|
err error
|
||
|
|
requests []string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeTrailerRefreshRequester) RequestTrailersRefresh(_ context.Context, contentID string) (metadata.TrailerRefreshOutcome, error) {
|
||
|
|
f.requests = append(f.requests, contentID)
|
||
|
|
if f.err != nil {
|
||
|
|
return metadata.TrailerRefreshOutcome{}, f.err
|
||
|
|
}
|
||
|
|
return f.outcome, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// fakeTrailerSeasonLookup and fakeTrailerEpisodeLookup stand in for the season
|
||
|
|
// and episode tables. Their content IDs are real and resolvable, they are just
|
||
|
|
// not media_items rows — which is exactly why the route needs them.
|
||
|
|
type fakeTrailerSeasonLookup map[string]*models.Season
|
||
|
|
|
||
|
|
func (f fakeTrailerSeasonLookup) GetByID(_ context.Context, contentID string) (*models.Season, error) {
|
||
|
|
if season := f[contentID]; season != nil {
|
||
|
|
return season, nil
|
||
|
|
}
|
||
|
|
return nil, catalog.ErrSeasonNotFound
|
||
|
|
}
|
||
|
|
|
||
|
|
type fakeTrailerEpisodeLookup map[string]*models.Episode
|
||
|
|
|
||
|
|
func (f fakeTrailerEpisodeLookup) GetByID(_ context.Context, contentID string) (*models.Episode, error) {
|
||
|
|
if episode := f[contentID]; episode != nil {
|
||
|
|
return episode, nil
|
||
|
|
}
|
||
|
|
return nil, catalog.ErrEpisodeNotFound
|
||
|
|
}
|
||
|
|
|
||
|
|
func newTrailerRefreshHandler(
|
||
|
|
access *fakeTrailerItemAccess,
|
||
|
|
requester *fakeTrailerRefreshRequester,
|
||
|
|
) *ItemsHandler {
|
||
|
|
return &ItemsHandler{
|
||
|
|
trailerItemAccess: access,
|
||
|
|
trailerRefreshRequester: requester,
|
||
|
|
trailerRefreshLimiter: ratelimit.NewMemoryLimiter(),
|
||
|
|
trailerSeasonLookup: fakeTrailerSeasonLookup{},
|
||
|
|
trailerEpisodeLookup: fakeTrailerEpisodeLookup{},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func newTrailerRefreshRequest(contentID string, userID int) *http.Request {
|
||
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/items/"+contentID+"/trailers/refresh", nil)
|
||
|
|
routeCtx := chi.NewRouteContext()
|
||
|
|
routeCtx.URLParams.Add("id", contentID)
|
||
|
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
|
||
|
|
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: userID, Role: "user", TokenType: auth.TokenTypeAccess})
|
||
|
|
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||
|
|
ctx = access.SetScope(ctx, access.Scope{UserID: userID, ProfileID: "profile-1"})
|
||
|
|
return req.WithContext(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeTrailerResponse(t *testing.T, rr *httptest.ResponseRecorder) map[string]any {
|
||
|
|
t.Helper()
|
||
|
|
var body map[string]any
|
||
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||
|
|
t.Fatalf("decode body %q: %v", rr.Body.String(), err)
|
||
|
|
}
|
||
|
|
return body
|
||
|
|
}
|
||
|
|
|
||
|
|
// The router discovers both seams by type assertion, so a signature drift
|
||
|
|
// would silently unregister the route rather than fail the build.
|
||
|
|
func TestTrailerRefreshWiringAssertionsHold(t *testing.T) {
|
||
|
|
var svc any = (*metadata.MetadataService)(nil)
|
||
|
|
if _, ok := svc.(TrailerRefreshRequester); !ok {
|
||
|
|
t.Fatal("*metadata.MetadataService must satisfy handlers.TrailerRefreshRequester")
|
||
|
|
}
|
||
|
|
var repo any = (*catalog.ItemRepository)(nil)
|
||
|
|
if _, ok := repo.(trailerItemAccess); !ok {
|
||
|
|
t.Fatal("*catalog.ItemRepository must satisfy trailerItemAccess")
|
||
|
|
}
|
||
|
|
// SetTrailerRefreshRequester adopts these from the handler's own repos, so
|
||
|
|
// drift here would silently downgrade every episode ID back to a 404.
|
||
|
|
var seasons any = (*catalog.SeasonRepository)(nil)
|
||
|
|
if _, ok := seasons.(trailerSeasonLookup); !ok {
|
||
|
|
t.Fatal("*catalog.SeasonRepository must satisfy trailerSeasonLookup")
|
||
|
|
}
|
||
|
|
var episodes any = (*catalog.EpisodeRepository)(nil)
|
||
|
|
if _, ok := episodes.(trailerEpisodeLookup); !ok {
|
||
|
|
t.Fatal("*catalog.EpisodeRepository must satisfy trailerEpisodeLookup")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTrailersRefreshReturnsQueued(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{
|
||
|
|
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
|
||
|
|
}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusAccepted {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusAccepted, rr.Body.String())
|
||
|
|
}
|
||
|
|
body := decodeTrailerResponse(t, rr)
|
||
|
|
if body["status"] != "queued" {
|
||
|
|
t.Fatalf("status field = %v, want queued", body["status"])
|
||
|
|
}
|
||
|
|
if _, ok := body["next_allowed_at"]; ok {
|
||
|
|
t.Fatalf("queued response must omit next_allowed_at, got %v", body)
|
||
|
|
}
|
||
|
|
if len(requester.requests) != 1 || requester.requests[0] != "movie-1" {
|
||
|
|
t.Fatalf("requests = %v, want [movie-1]", requester.requests)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTrailersRefreshReturnsCooldownWithNextAllowedAt(t *testing.T) {
|
||
|
|
next := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{
|
||
|
|
outcome: metadata.TrailerRefreshOutcome{
|
||
|
|
Status: metadata.TrailerRefreshStatusCooldown,
|
||
|
|
NextAllowedAt: &next,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("series-1", 7))
|
||
|
|
|
||
|
|
// Cooldown is an expected client-rendered state, not an error: 200, and
|
||
|
|
// 429 stays reserved for the per-user limiter.
|
||
|
|
if rr.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusOK, rr.Body.String())
|
||
|
|
}
|
||
|
|
body := decodeTrailerResponse(t, rr)
|
||
|
|
if body["status"] != "cooldown" {
|
||
|
|
t.Fatalf("status field = %v, want cooldown", body["status"])
|
||
|
|
}
|
||
|
|
if got := body["next_allowed_at"]; got != next.Format(time.RFC3339) {
|
||
|
|
t.Fatalf("next_allowed_at = %v, want %s", got, next.Format(time.RFC3339))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTrailersRefreshReturnsDisabled(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{
|
||
|
|
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusDisabled},
|
||
|
|
}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusOK, rr.Body.String())
|
||
|
|
}
|
||
|
|
body := decodeTrailerResponse(t, rr)
|
||
|
|
if body["status"] != "disabled" {
|
||
|
|
t.Fatalf("status field = %v, want disabled", body["status"])
|
||
|
|
}
|
||
|
|
if _, ok := body["next_allowed_at"]; ok {
|
||
|
|
t.Fatalf("disabled response must omit next_allowed_at, got %v", body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only movie and series detail responses carry videos, so any other
|
||
|
|
// media_items type is a client bug rather than an empty result. These are the
|
||
|
|
// types that actually exist as media_items rows; episodes and seasons live in
|
||
|
|
// their own tables and are covered separately below.
|
||
|
|
func TestTrailersRefreshRejectsNonMovieSeriesTypes(t *testing.T) {
|
||
|
|
for _, itemType := range []string{"audiobook", "ebook", "manga"} {
|
||
|
|
t.Run(itemType, func(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"item-1": {ContentID: "item-1", Type: itemType}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("item-1", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusBadRequest {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusBadRequest, rr.Body.String())
|
||
|
|
}
|
||
|
|
if len(requester.requests) != 0 {
|
||
|
|
t.Fatalf("unsupported type must not reach the service, got %v", requester.requests)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Episodes and seasons are not media_items rows, so the item lookup misses on
|
||
|
|
// their real content IDs. Without the fallbacks the route would answer 404
|
||
|
|
// "Item not found" for content that plainly exists; the contract is 400
|
||
|
|
// unsupported-type. Authorization runs against the parent series, as on the
|
||
|
|
// on-view translation route.
|
||
|
|
func TestTrailersRefreshRejectsEpisodeAndSeasonIDsWith400(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
contentID string
|
||
|
|
wantAccess string
|
||
|
|
}{
|
||
|
|
{name: "episode", contentID: "episode-1", wantAccess: "series-1"},
|
||
|
|
{name: "season", contentID: "season-1", wantAccess: "series-1"},
|
||
|
|
}
|
||
|
|
for _, tc := range tests {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
handler.trailerSeasonLookup = fakeTrailerSeasonLookup{
|
||
|
|
"season-1": {ContentID: "season-1", SeriesID: "series-1"},
|
||
|
|
}
|
||
|
|
handler.trailerEpisodeLookup = fakeTrailerEpisodeLookup{
|
||
|
|
"episode-1": {ContentID: "episode-1", SeriesID: "series-1"},
|
||
|
|
}
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest(tc.contentID, 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusBadRequest {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusBadRequest, rr.Body.String())
|
||
|
|
}
|
||
|
|
body := decodeTrailerResponse(t, rr)
|
||
|
|
if code, _ := body["error"].(string); code != "unsupported_type" {
|
||
|
|
t.Fatalf("error code = %v, want unsupported_type (%s)", body["error"], rr.Body.String())
|
||
|
|
}
|
||
|
|
if len(itemAccess.checked) != 1 || itemAccess.checked[0] != tc.wantAccess {
|
||
|
|
t.Fatalf("access checks = %v, want [%s]", itemAccess.checked, tc.wantAccess)
|
||
|
|
}
|
||
|
|
if len(requester.requests) != 0 {
|
||
|
|
t.Fatalf("unsupported type must not reach the service, got %v", requester.requests)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// An episode inside a series the caller cannot see must not be distinguishable
|
||
|
|
// from content that does not exist, so the access check runs before the type
|
||
|
|
// answer.
|
||
|
|
func TestTrailersRefreshEpisodeInInaccessibleSeriesReturns404(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
|
||
|
|
ensureErr: map[string]error{"series-1": catalog.ErrItemNotFound},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
handler.trailerEpisodeLookup = fakeTrailerEpisodeLookup{
|
||
|
|
"episode-1": {ContentID: "episode-1", SeriesID: "series-1"},
|
||
|
|
}
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("episode-1", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusNotFound {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
|
||
|
|
}
|
||
|
|
if len(requester.requests) != 0 {
|
||
|
|
t.Fatalf("denied request must not reach the service, got %v", requester.requests)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// An unauthorized caller must be turned away before the metadata service is
|
||
|
|
// asked, so it can never burn the item's cooldown slot.
|
||
|
|
func TestTrailersRefreshDeniedAccessReturns404WithoutConsumingCooldown(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
|
||
|
|
ensureErr: map[string]error{"movie-1": catalog.ErrItemNotFound},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusNotFound {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
|
||
|
|
}
|
||
|
|
if len(itemAccess.checked) != 1 {
|
||
|
|
t.Fatalf("access checks = %v, want one check", itemAccess.checked)
|
||
|
|
}
|
||
|
|
if len(requester.requests) != 0 {
|
||
|
|
t.Fatalf("denied request must not reach the service, got %v", requester.requests)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTrailersRefreshMissingItemReturns404(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("missing", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusNotFound {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
|
||
|
|
}
|
||
|
|
if len(requester.requests) != 0 {
|
||
|
|
t.Fatalf("missing item must not reach the service, got %v", requester.requests)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTrailersRefreshRequiresAuthentication(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/items/movie-1/trailers/refresh", nil)
|
||
|
|
routeCtx := chi.NewRouteContext()
|
||
|
|
routeCtx.URLParams.Add("id", "movie-1")
|
||
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, req)
|
||
|
|
|
||
|
|
if rr.Code != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusUnauthorized, rr.Body.String())
|
||
|
|
}
|
||
|
|
if len(requester.requests) != 0 {
|
||
|
|
t.Fatalf("unauthenticated request must not reach the service, got %v", requester.requests)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The per-user limiter is the abuse guard in front of the per-item cooldown:
|
||
|
|
// once a user exhausts the burst it answers 429 with Retry-After.
|
||
|
|
func TestTrailersRefreshRateLimitsPerUser(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{
|
||
|
|
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
|
||
|
|
}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
limited := false
|
||
|
|
for i := 0; i < int(trailerRefreshRate.RequestsPerMinute)+5; i++ {
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
|
||
|
|
if rr.Code == http.StatusTooManyRequests {
|
||
|
|
limited = true
|
||
|
|
if rr.Header().Get("Retry-After") == "" {
|
||
|
|
t.Fatal("429 response must carry Retry-After")
|
||
|
|
}
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !limited {
|
||
|
|
t.Fatal("expected the per-user limiter to reject a burst of requests")
|
||
|
|
}
|
||
|
|
|
||
|
|
// A different user is unaffected — the limiter keys on the user id.
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 8))
|
||
|
|
if rr.Code != http.StatusAccepted {
|
||
|
|
t.Fatalf("second user status = %d, want %d (%s)", rr.Code, http.StatusAccepted, rr.Body.String())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTrailersRefreshUnconfiguredReturns503(t *testing.T) {
|
||
|
|
handler := &ItemsHandler{}
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusServiceUnavailable {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusServiceUnavailable, rr.Body.String())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTrailersRefreshServiceErrorReturns500(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{err: errors.New("database is down")}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusInternalServerError {
|
||
|
|
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusInternalServerError, rr.Body.String())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The capability probe is what lets a client tell "this server does not have
|
||
|
|
// the trailer action" from "that item does not exist", so it must answer on
|
||
|
|
// both a wired and an unwired handler.
|
||
|
|
func TestTrailerRefreshCapability(t *testing.T) {
|
||
|
|
t.Run("wired", func(t *testing.T) {
|
||
|
|
h := newTrailerRefreshHandler(&fakeTrailerItemAccess{}, &fakeTrailerRefreshRequester{})
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
h.HandleTrailerRefreshCapability(rr, httptest.NewRequest(http.MethodGet, "/items/trailers/capability", nil))
|
||
|
|
if rr.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||
|
|
}
|
||
|
|
body := decodeTrailerResponse(t, rr)
|
||
|
|
if body["refresh"] != true {
|
||
|
|
t.Fatalf("refresh = %v, want true", body["refresh"])
|
||
|
|
}
|
||
|
|
if got, want := body["cooldown_seconds"], float64(metadata.TrailerRefreshCooldown/time.Second); got != want {
|
||
|
|
t.Fatalf("cooldown_seconds = %v, want %v", got, want)
|
||
|
|
}
|
||
|
|
// The advertised statuses are the contract the client switches on, so
|
||
|
|
// they must be the service's constants rather than a stale copy.
|
||
|
|
statuses, _ := body["statuses"].([]any)
|
||
|
|
want := []string{
|
||
|
|
metadata.TrailerRefreshStatusQueued,
|
||
|
|
metadata.TrailerRefreshStatusCooldown,
|
||
|
|
metadata.TrailerRefreshStatusDisabled,
|
||
|
|
}
|
||
|
|
if len(statuses) != len(want) {
|
||
|
|
t.Fatalf("statuses = %v, want %v", statuses, want)
|
||
|
|
}
|
||
|
|
for i, status := range want {
|
||
|
|
if statuses[i] != status {
|
||
|
|
t.Fatalf("statuses[%d] = %v, want %q", i, statuses[i], status)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("unwired", func(t *testing.T) {
|
||
|
|
h := &ItemsHandler{}
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
h.HandleTrailerRefreshCapability(rr, httptest.NewRequest(http.MethodGet, "/items/trailers/capability", nil))
|
||
|
|
if rr.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d, want 200 — the probe itself must never 404", rr.Code)
|
||
|
|
}
|
||
|
|
body := decodeTrailerResponse(t, rr)
|
||
|
|
if body["refresh"] != false {
|
||
|
|
t.Fatalf("refresh = %v, want false", body["refresh"])
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// recordingLimiter captures the keys an action limiter is called with.
|
||
|
|
type recordingLimiter struct {
|
||
|
|
keys []string
|
||
|
|
allowed bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *recordingLimiter) Allow(_ context.Context, key string, _ ratelimit.Rate) ratelimit.AllowResult {
|
||
|
|
l.keys = append(l.keys, key)
|
||
|
|
return ratelimit.AllowResult{Allowed: l.allowed, RetryAfter: time.Second}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *recordingLimiter) Close() {}
|
||
|
|
|
||
|
|
// The action's budget must be enforced by the process's configured limiter, or
|
||
|
|
// a Redis deployment gives every instance an independent allowance for the same
|
||
|
|
// user and multiplies the stated budget by the instance count. The per-item
|
||
|
|
// database cooldown cannot compensate: it bounds one item, while this bounds
|
||
|
|
// how many distinct items a user can start refreshes for.
|
||
|
|
func TestTrailersRefreshUsesTheInjectedSharedLimiter(t *testing.T) {
|
||
|
|
itemAccess := &fakeTrailerItemAccess{
|
||
|
|
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
|
||
|
|
ensureErr: map[string]error{},
|
||
|
|
}
|
||
|
|
requester := &fakeTrailerRefreshRequester{
|
||
|
|
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
|
||
|
|
}
|
||
|
|
handler := newTrailerRefreshHandler(itemAccess, requester)
|
||
|
|
shared := &recordingLimiter{allowed: false}
|
||
|
|
handler.SetTrailerRefreshLimiter(shared)
|
||
|
|
// The requester wiring must not replace an injected limiter with a private
|
||
|
|
// in-memory one, which is the whole point of injecting it.
|
||
|
|
handler.SetTrailerRefreshRequester(requester)
|
||
|
|
|
||
|
|
rr := httptest.NewRecorder()
|
||
|
|
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
|
||
|
|
|
||
|
|
if rr.Code != http.StatusTooManyRequests {
|
||
|
|
t.Fatalf("status = %d, want %d — the injected limiter's verdict was ignored (%s)",
|
||
|
|
rr.Code, http.StatusTooManyRequests, rr.Body.String())
|
||
|
|
}
|
||
|
|
if len(shared.keys) != 1 {
|
||
|
|
t.Fatalf("shared limiter consulted %d times, want 1", len(shared.keys))
|
||
|
|
}
|
||
|
|
// The limiter may be the process-wide one, whose keyspace is shared with
|
||
|
|
// the rate-limit middleware ("ip:", "key:"), so this action's keys have to
|
||
|
|
// be namespaced too.
|
||
|
|
if shared.keys[0] != trailerRefreshLimiterKey(7) {
|
||
|
|
t.Fatalf("limiter key = %q, want the namespaced %q", shared.keys[0], trailerRefreshLimiterKey(7))
|
||
|
|
}
|
||
|
|
if shared.keys[0] == "7" {
|
||
|
|
t.Fatal("an unprefixed user id would collide with other keyspaces in a shared limiter")
|
||
|
|
}
|
||
|
|
if len(requester.requests) != 0 {
|
||
|
|
t.Fatalf("a rate-limited request must not reach the service, got %v", requester.requests)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Rate limiting can be disabled outright (or the database unavailable), in
|
||
|
|
// which case there is no shared limiter to inject. The action keeps its own
|
||
|
|
// in-memory guard rather than running unbounded.
|
||
|
|
func TestTrailersRefreshFallsBackToAPrivateLimiter(t *testing.T) {
|
||
|
|
handler := &ItemsHandler{}
|
||
|
|
handler.SetTrailerRefreshLimiter(nil)
|
||
|
|
handler.SetTrailerRefreshRequester(&fakeTrailerRefreshRequester{
|
||
|
|
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
|
||
|
|
})
|
||
|
|
|
||
|
|
if handler.trailerRefreshLimiter == nil {
|
||
|
|
t.Fatal("the action must keep a limiter even when no shared one is configured")
|
||
|
|
}
|
||
|
|
}
|