* feat(metadata): user-triggered trailer refresh with weekly per-item cooldown
Adds POST /api/v1/items/{id}/trailers/refresh so any viewer with access to a
movie or series can ask the server to fetch its remote trailers, bounded by a
one-week per-item cooldown enforced server-side.
The cooldown lives in a new nullable media_items.trailers_refresh_requested_at
column rather than the refresh debt queue, whose last_attempt_at evaporates on
success (MarkTargetSuccess deletes the row when the reason mask clears). The
gate is a single UPDATE that writes NOW() only when the stored timestamp is
NULL or older than the window, so concurrent viewers cannot both win it; a
losing caller reads the stored timestamp back to compute next_allowed_at.
MetadataService.RequestTrailersRefresh resolves the per-library trailer_kinds
allow-list first: a non-nil empty map means every containing library disabled
remote videos, which answers "disabled" without consuming the cooldown slot
(a nil map is allow-all and must not short-circuit). On winning the gate it
reuses startOnDemandMetadataRefresh, whose scheduled mode merges fill-empty,
so this non-admin trigger cannot clobber unlocked admin edits while found
videos still persist.
The handler checks item access before calling the service, so an unauthorized
caller can never burn an item's slot, and rejects non movie/series types since
those detail responses never carry videos. cooldown and disabled are expected
client-rendered states and answer 200; 429 is reserved for the per-user
in-memory limiter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(metadata): release trailer-refresh slot on failed refresh; resolve episode ids to 400
Three review findings on the viewer-facing trailer fetch.
The weekly per-item slot was consumed unconditionally on winning the gate,
but the refresh it started ran detached and only logged on failure — nothing
ever put the slot back. A brief TMDb outage therefore answered 202 queued,
failed 30s later, and then answered cooldown for seven days over work that
never happened. The repository gains an equality-guarded release
(trailers_refresh_requested_at = NULL only while it still equals the
timestamp this request wrote, so a later claim is never clobbered), and
TryClaimTrailersRefresh now RETURNINGs the timestamp it stored so a winner
holds the key to its own slot. startOnDemandMetadataRefresh splits into a
claim step and runOnDemandMetadataRefresh, which takes an optional failure
hook; only the trailer path passes one, so the existing callers are
unchanged. A timeout counts as failure. A refresh that succeeds but finds
nothing still keeps the slot — that semantics was chosen deliberately.
The in-process dedup claim (shared with the item-detail view's stale nudge)
silently dropped the start while the slot had already been consumed, so the
caller was told queued for a refresh that never began. It is now taken
before the durable slot: a request landing while an equivalent refresh is
already in flight reports queued without consuming the slot, which is both
honest and retryable if that refresh fails.
Real episode and season content IDs answered 404 rather than the contracted
400, because neither is a media_items row and GetByID queries media_items
alone. The handler now falls through to the same season/episode lookups
HandleTranslateOnView uses, authorizing through the parent series, so a
genuine episode ID reports unsupported-type and only unknown content 404s.
The type-check test no longer fabricates a MediaItem{Type: "episode"} row
that production never writes; it covers the types that do exist as
media_items rows, with the episode and season paths tested through the
lookups.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(metadata): address PR review on the trailer refresh action
Six review findings on the viewer-facing trailer fetch, all verified against
the current code before changing anything.
Durable claim no longer rides the request context. A cancellation landing
after Postgres commits the gate UPDATE but before pgx returns would consume
the item's weekly slot with no refresh started and nothing holding the
timestamp needed to release it. The claim now runs on
context.WithoutCancel with its own deadline, mirroring the release.
The cooldown gate retries once when the follow-up read finds the slot free.
Classification spans two statements, so a concurrent failure-release can
land between them; the old code reported that as a cooldown with no
next_allowed_at while the slot was in fact free. A NULL read now retries the
claim, and the doubly-lost case answers "queued" (an equivalent refresh is
running) rather than an undateable cooldown.
A failed item_videos write now releases the slot. mergeAndPersist logs and
continues when the write fails, so the refresh reported success and the
viewer was locked out for a week having stored nothing. A context-scoped
observer, installed only by this action, surfaces that failure to the
existing release hook.
Winning the gate also records durable refresh debt, so a restart that kills
the detached goroutine leaves work the refresh worker picks up instead of a
consumed slot and no fetch. Uses a new reason bit rather than the generic
failure reason: nothing is wrong with the item, so it must not sit in the
failure band ahead of real debt or count as a failure in operator metrics.
Any library lookup failure now degrades the video-kind scope to unknown. An
item in two libraries where one resolved with trailers off and the other
could not be read reported "disabled" — a guess made on behalf of a library
that might be the one enabling trailers. A library that is genuinely gone is
still skipped.
Adds GET /api/v1/items/trailers/capability, following the existing
per-subsystem probe convention. The action route is registered conditionally,
so "this build has the feature" is not the same question as "this deployment
serves it", and a 404 on the POST is indistinguishable from a missing item.
The probe is registered unconditionally and answers refresh:false when
unwired.
Not changed: content-ID canonicalization mid-refresh stranding the cooldown
on the old row. The re-anchor path is manual-refresh only and this action
runs in scheduled mode, so only local-skeleton promotion can fire, and the
rename carries the timestamp and the debt row to the new id along with
everything else.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style(web): format overlays schema after merging main
The line came in over-length from main's card_overlays merge and the Web
CI format check runs prettier across all of src, not just changed files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(metadata): address second review round — recovery-debt lease, locked-videos preflight, shared limiter
The restart mitigation added in the first round reintroduced two of the
problems it was closing, and the reviewer was right to push again.
Lease the recovery debt behind the fast path. The row was enqueued due
now, so refresh_metadata could claim it while the detached goroutine was
still running the same refresh — RefreshScheduledTarget does not consult
the in-process claim, so both would fetch the item at once. It is now due
5 minutes out, comfortably past the 2-minute on-demand timeout, and the
goroutine settles the row on success so it fires only when the fast path
really did not finish. Settling clears just the trailers-requested bit,
keeping any real debt the item still carries.
Release the cooldown after a failed recovery. A recovery runs in a worker
that never saw the claim, so a failure left the viewer blocked for the
week having stored nothing. RefreshScheduledTarget now adopts the claim
when the debt row carries the trailers-requested reason, reading the
stored timestamp so the release stays equality-guarded, and hands the
slot back on the same failures the fast path's hook covers — including a
videos write that failed and was only logged.
Preflight the videos lock. locked_fields containing FieldVideos makes
mergeAndPersist skip the item_videos write, so the refresh "succeeded"
and kept the cooldown while never being able to save trailers. It now
answers disabled before consuming the claim; reusing that status rather
than adding one is deliberate, since clients treat an unknown status as a
dead end and "trailers cannot be fetched for this item" is what disabled
already means to a viewer.
Use the shared limiter. A private MemoryLimiter gave every instance an
independent per-user allowance on Redis deployments, and the per-item
cooldown cannot compensate — it bounds one item, while this budget bounds
how many distinct items a user can start refreshes for. The action now
takes the middleware's configured limiter, with namespaced keys, and
falls back to a private one only when rate limiting is off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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")
|
|
}
|
|
}
|