Files
silo-server/internal/api/handlers/downloads_test.go
9cae868a27 feat(downloads): offline sync for mobile — downloads v2 (#258)
* feat(downloads): offline sync for mobile (downloads v2)

Replace internal/download with a unified internal/downloads package and add
fully-offline download + watch-sync support for mobile clients, across five
independently-shippable phases:

- Phase 0: reshape the downloads table and the /downloads contract to be
  device- and format-aware; add GET /downloads/capability; extend
  DownloadConfig (default-off keys); update the web download hooks/components in
  lockstep. This is the one approved pre-lock exception to the additive-only
  /api/v1 rule (the web app is the only consumer and is updated together).
- Phase 1: managed device-library entries (create/list/PATCH/delete/serve),
  keyed on the X-Silo-Device-Id header.
- Phase 2: offline playback manifest plus artwork/subtitle proxy endpoints that
  strip every presigned URL (inline thumbhashes + authenticated proxies).
- Phase 3: prepare-to-file (remux + transcode-to-single-file) as a durable,
  leased artifact queue with startup recovery, hosted on the task manager;
  playback.PrepareFile emits one +faststart MP4. Adds the admin transcode
  toggle and per-artifact LRU cleanup.
- Phase 4: offline progress reconciliation -- a clamped event_at LWW key plus a
  server-assigned synced_seq cursor on watch_progress; an optional clamped
  updated_at on POST /sync/progress and an opaque ?since= cursor on
  GET /progress (additive; existing callers unaffected).

Security & reliability invariants, each with an acceptance test:
1. Server-owned sync ordering: ?since= delta delivery is driven only by the
   server-assigned synced_seq; the client clock is bounded (event_at, clamped
   to now+skew) and used only for last-write-wins on the caller's own profile.
2. Full profile+device authorization on every managed endpoint, with a
   per-profile content/library access re-check before serving any bytes/assets.
3. Durable artifact recovery: a transactionally-claimed (FOR UPDATE SKIP
   LOCKED), lease-heartbeat, attempt-counted queue with a startup sweep, so no
   crash strands a download in preparing and concurrent workers never
   double-encode.

Migrations are timestamped Goose files: reshape downloads (device/format);
download_artifacts (durable queue); watch_progress event_at/synced_seq.

DB-backed acceptance tests skip without SILO_TEST_DATABASE_URL and run in CI;
the invariant-1 progress test also runs against the real SQLite backend locally.

Client repos (silo-android, silo-apple) consume the reshaped /downloads/*
contract and the updated_at/?since= progress fields and require coordinated
follow-up.

Implements the maintainer-approved v1 capability proposal for offline sync
(downloads v2).

AI-use disclosure: implemented by Claude (Claude Code) from the approved design
doc under docs/superpowers/specs, with human review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(downloads): series & season downloads + client-pull monitoring

Build season downloads and a "monitor a series" capability on top of the
downloads v2 (offline sync for mobile) work.

Season downloads:
- POST /downloads accepts season_number (with series:true) to download one
  season. CreateSeries/CreateSeason share one body via a listEpisodes closure
  and register managed entries under a shared batch_id (original-only). Episode
  files are resolved in a single batched query.

Series monitoring (auto-download), client-driven:
- New device-scoped download_subscriptions table with a Sonarr-style mode
  (all | future | latest_season | specific_seasons), a client-enforced
  delete_watched flag, and a max_storage_bytes cap. The server never deletes
  on-device files; retention and the hard cap are the client's, the server
  only soft-gates registration.
- The client calls POST /downloads/subscriptions/sync on open / background
  refresh; the server registers the in-scope, not-yet-downloaded episodes
  (idempotent via the managed-entry unique index) and the device pulls them on
  its own schedule. No background worker and no dependency on the notifications
  subsystem. latest_season follows new seasons (>= subscribe-time season);
  future excludes the back catalog via air date.
- Subscription CRUD + sync are profile+device authorized (device id from the
  X-Silo-Device-Id header only) with a per-request content-access re-check. The
  capability endpoint advertises season_download / series_monitoring /
  monitoring_modes.

Also lands the downloads-v2 work already present in the tree: durable artifact
(remux/transcode) preparation and offline watch-progress reconciliation, plus
the design-spec updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WIP: epitaxy pre-switch from feat/downloads-v2-offline-sync

* test(downloads): fix deterministic ID collision in reconcile test

Artifact IDs are time-sortable, so two artifacts created in the same
moment share their first 8 chars; combined with a captured timestamp the
two preparing-download IDs collided on downloads_pkey. Use the full
artifact ID, which is unique per row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): support sqlite userdb backend for managed downloads

With the sqlite userdb backend, profiles live only in per-user SQLite
stores and public.user_profiles stays empty, so user_devices'
profile FK made every managed create/subscription/offline-sync request
fail with an FK violation. Drop the FK (shared Postgres tables must not
FK profile tables — same rule as notifications) and replace the lost
cascade with an app-level purge on profile deletion, wired through
ProfileHandler for both backends. DB-backed regression tests cover the
no-Postgres-profile-row path and the purge cascade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): dispatch encode kick asynchronously

triggerDrain invoked the kick inline, and the kick (taskmanager RunTask)
executes the encode task on the caller's goroutine — so a POST
/api/v1/downloads with a bitrate quality blocked the HTTP request on the
entire queue drain, ffmpeg encodes included, delaying the 202 by minutes
on an idle queue. Dispatch the kick on a goroutine; the task manager
already serializes concurrent runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): enforce per-user quota on the encode pipeline

Two gaps let a user bypass MaxConcurrentPerUser entirely for prepared
downloads: artifact-backed rows are created in 'preparing' (never
'queued'/'downloading'), which CountActiveByUser didn't count, and
createArtifactDownload enqueued the encode job before limiter.Check, so
even a 429-rejected request left a job the worker would transcode.
Count 'preparing' as active and check the limiter before Ensure; managed
replacements stay quota-exempt since they don't add a row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): protect ephemeral artifact links from LRU eviction

HasActiveLink only counted managed (device_id IS NOT NULL) rows, so
under a byte budget Cleanup could delete an artifact still referenced by
a ready-but-unfetched ephemeral web download — permanently 404ing a row
the API kept listing as ready (the artifact row is gone, so recovery
can't re-queue it). Any non-terminal link now protects the artifact;
only artifacts whose links are all cancelled/failed/revoked are
evictable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): batch manifests skip bad entries instead of failing whole batch

One deleted or access-filtered episode made GET
/downloads/batches/{id}/manifests 404 for the entire season, so a
client could no longer fetch manifests for the still-valid entries.
Report unbuildable entries in a skipped[] array (revoked | not_found |
error) alongside the delivered manifests, mirroring the create path's
skip idiom. Also cut the batch cost: the shared series detail is
resolved once per batch instead of once per episode, and buildSubtitles
reuses the already-loaded media file instead of re-querying it per
manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): wrap DO block in StatementBegin/End markers

Under NO TRANSACTION goose splits statements on semicolons, so the
dollar-quoted DO block failed every fresh install with 'unterminated
dollar-quoted string' (SQLSTATE 42601). Already-applied databases are
unaffected. Same fix is being applied to main; identical content merges
cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): allow season 0 (Specials) in season downloads

season_number was a plain int dispatched with '> 0', so requesting the
Specials season was indistinguishable from omitting the field and
silently broadened to a full-series download. Dispatch on pointer
presence, treat 0 as the Specials season, and reject negatives with 400.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): capability quality_presets is never JSON null

PresetsFor returned a nil slice when downloads are disabled or the user
lacks the permission, and Capability's []string{} initialization was
immediately overwritten by it — so GET /downloads/capability serialized
"quality_presets": null where the contract documents an array.
Normalize at the source so every caller inherits the guarantee.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): subscription sync correctness + batched registration

Three subscription fixes:

- A paused subscription no longer syncs: PATCHing scope (or pausing and
  changing scope in one request) registered episodes for a monitor the
  user had just stopped, inconsistently with SyncSubscriptions' guard.
- SubModeFuture compares calendar days (UTC): air_date is date-only, so
  the strict instant comparison permanently excluded episodes airing the
  same day the user subscribed; episodes with no air date now fall back
  to their ingest time instead of never registering.
- Registration is one batched fetch (GetManagedEntriesByKeys) plus one
  batched INSERT ... ON CONFLICT DO NOTHING RETURNING
  (CreateManagedEntriesBatch) instead of a SELECT+INSERT per episode —
  a 300-episode series cost ~600 sequential round trips per request and
  every no-op sync re-walked the full set. RETURNING yields exactly the
  new rows, so the sync response's 'registered' count now honestly
  reports 0 in the steady state instead of the full in-scope count on
  every app open. The now-unused InsertManagedEntryIfAbsent is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(userstore): stamp triggers own the event_at LWW key

MarkProgressBatch (jellycompat series mark-played) advanced updated_at
but never event_at, and both stamp triggers only defaulted event_at when
NULL — so a queued offline event with a client time between the row's
old event_at and the mark could win SetProgressIfNewer and resurrect a
stale resume position that then re-synced to every device.

Make the triggers authoritative instead of adding a tenth hand-written
SET clause: whenever an UPDATE changes updated_at without explicitly
changing event_at, the trigger advances the LWW key; writes that do set
event_at (offline sync's clamped client event time) keep their value.
Postgres gets a CREATE OR REPLACE migration; SQLite gets a v12 userdb
migration that drops and reinstalls the trigger bodies (CREATE TRIGGER
IF NOT EXISTS never replaces). Conformance tests cover both batch paths,
the preserved-client-time invariant, and the v11→v12 upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): lifecycle hygiene — squash migrations, dead status, stale-row sweeps

Migrations: fold the 20260621 corrective migration back into the base
Downloads V2 migrations (its columns/constraints already exist there)
and fix the reshape Down, which re-added the narrow status CHECK without
collapsing managed-lifecycle rows first — rollback aborted on any DB
with preparing/ready/revoked rows; validated against a live row. Branch
databases that applied the corrective migration need its version row
removed: DELETE FROM goose_db_version WHERE version_id = 20260621020459.

Code: drop the dead 'registered' status (nothing ever wrote it; the
lifecycle is preparing -> ready; 'revoked' stays reserved for the
planned admin revoke flow) along with unused KindDirect and
ErrInvalidFormat.

Sweeps: Cleanup now runs an age-based hygiene pass independent of the
byte budget — cold terminally-failed artifacts (with .part leftovers),
orphaned ready artifacts no download row references, and ephemeral web
rows older than their convenience-record lifetime (also unpinning their
artifacts and bounding GET /downloads growth). The byte budget remains
the disk quota per the limits & restrictions design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(downloads): sync API doc with v2 fixes; HEAD on file route; Android handoff

Document the contract changes from the review fixes: batch-manifest
skipped[] shape, honest subscription 'registered' semantics, season 0 =
Specials, always-array quality_presets, bytes_sent actual behavior,
ephemeral 7-day retention, header-pairing requirement, progress-delta
deletion caveat, and the ready/failed push event schema (new §9.4).
Add an Android client handoff section (§11) mirroring the Apple one,
register HEAD on /downloads/{id}/file for download stacks that probe
before ranged GETs, and add season_number to the web create-request
type. Flag the /direct-download session-token-in-URL tradeoff; a
short-lived download-scoped URL is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: consolidate download/progress helpers, prune dead code, gate sweeps

Behavior-preserving consolidation from the Downloads V2 review:

- appendVideoFilterArgs: one home for the burn-in/hwaccel -vf selection,
  shared by the HLS builder and the single-file prepare builder (the
  drift pattern that already bit tone-mapping once).
- userstore.ResolveProgressState: one home for the min-resume/watched
  threshold rule, replacing five identical copies across both store
  backends and the offline-sync ingest.
- Download file selection ranks resolutions via access.CompareQuality
  (adds 4320p, agrees with playback) instead of a private switch.
- writeSubtitle uses the shared subtitles.SubtitleContentType mapping.
- config.DefaultTranscodeDir replaces three '/tmp/silo-transcode'
  literals.
- Read-side quality/revision defaulting helpers removed: insertArgs plus
  the NOT NULL/CHECK schema already guarantee the invariant.
- Dead code removed: Repository.ListByUser, SubscriptionRepository.
  ListActiveBySeries, and the stale auto-register-worker comments (the
  design is client-pull; no worker exists).
- Redundant left-prefix indexes dropped from the base migrations (their
  unique indexes serve the same prefixes).
- recover()'s disk-presence sweep and the stale-row hygiene sweep run on
  startup then hourly instead of every 30s tick (both are O(cache
  size)).
- gofmt/prettier fixes for pre-existing drift in handlers/playback.go
  and pages/Profiles.tsx.

Deferred (noted for follow-ups): quality-ladder preset table collides
with the drafted download limits & restrictions design, which specifies
its own ladder helper; Download-literal construction consolidation and
the managed-identity value object remain open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(downloads): draft download limits & restrictions design

Design input for the follow-up v1 capability proposal (quality ceiling,
batch size cap, per-user quantity/bandwidth overrides). Committed with
downloads v2 because the remediation work explicitly defers the quality
ladder refactor and revocation wiring to this spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(progress): reject malformed updated_at; clamp negative progress inputs

Review findings on #258:

- A malformed (non-RFC3339) updated_at in POST /sync/progress previously
  parsed to the zero time, which clampEventAt treated as "now" — letting a
  stale offline event win LWW as a fresh server-time write. The item is now
  rejected with a per-item error instead.
- ResolveProgressState now clamps negative position/duration before
  classification so no backend can persist negative progress through
  UpdateProgress/SetProgress.
- The online-write event_at invariant test is table-driven over both
  SetProgress and UpdateProgress, which share the same contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): close review gaps — permission gates, file-access recheck, artifact-true manifests

Review findings on #258:

- UpdateSubscription now applies the same feature/DownloadAllowed gate as
  CreateSubscription and SyncSubscriptions; a PATCH could previously
  re-activate or widen a monitor and register managed rows after an admin
  disabled downloads or revoked the user.
- Serving download bytes (managed and ephemeral) and /direct-download now
  mirror playback's per-file authorization via catalog.FileAllowedByAccess:
  library scope and the profile's max playback quality are re-checked at
  serve time, with artifact-backed rows checked against the artifact's
  resolution (a 720p transcode of a 4K source stays servable under a 1080p
  ceiling).
- Offline manifests for remux/transcode entries now describe the prepared
  artifact (container, codecs, resolution, single selected audio track)
  instead of the catalog source file the client never receives.
- ArtifactRepository.Requeue reports ErrNotFound when the row was
  concurrently swept; ArtifactManager.Ensure recreates the job in that case
  instead of linking downloads to a dead artifact id.
- "No downloadable episodes" is a sentinel (mapped to 404
  no_downloadable_episodes) rather than a bare error that surfaced as 500.
- Subscription season_numbers are bounds-checked (0–9999) before the int32
  narrowing in the repo could silently wrap them.
- HandlePatchDownload reuses requireManaged instead of hand-rolling the
  same managed-identity checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:05:36 -04:00

827 lines
32 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/downloads"
)
// fakeDownloadService is a programmable DownloadService for handler tests. It
// records the identity (user/profile/device) it was called with so tests can
// assert the handler threads header-only device authority correctly.
type fakeDownloadService struct {
capability downloads.Capability
created *downloads.Download
createErr error
series []*downloads.Download
seriesID string
seriesErr error
list []*downloads.Download
listErr error
deleteErr error
patchErr error
serveErr error
directErr error
manifest *downloads.OfflineManifest
batchManifests []*downloads.OfflineManifest
batchSkipped []downloads.SkippedManifest
manifestErr error
artworkErr error
subtitleErr error
gotCreateReq downloads.CreateRequest
gotSeriesReq downloads.CreateRequest
gotList identityCall
gotServe identityCall
gotDelete identityCall
gotPatch identityCall
gotPatchStatus string
gotManifest identityCall
gotBatchManifest identityCall
gotArtwork identityCall
gotArtworkKind string
gotSubtitle identityCall
gotSubtitleRef string
gotDirectFormat string
gotDirectFileID int
// Season download + series-monitoring (subscription) fakes.
season []*downloads.Download
seasonID string
seasonErr error
gotSeasonReq downloads.CreateRequest
gotSeasonNum int
seasonCalled bool
subResult *downloads.SubscriptionResult
subList []*downloads.Subscription
sub *downloads.Subscription
subErr error
subDeleteErr error
gotSubReq downloads.SubscriptionRequest
gotSubPatch downloads.SubscriptionPatch
gotSubIdent identityCall
syncRegistered int
}
type identityCall struct {
userID int
profileID string
deviceID string
downloadID string
}
func (f *fakeDownloadService) Capability(context.Context, int) (downloads.Capability, error) {
return f.capability, nil
}
func (f *fakeDownloadService) Create(_ context.Context, _ int, req downloads.CreateRequest, _ catalog.AccessFilter) (*downloads.Download, error) {
f.gotCreateReq = req
if f.createErr != nil {
return nil, f.createErr
}
return f.created, nil
}
func (f *fakeDownloadService) CreateSeries(_ context.Context, _ int, req downloads.CreateRequest, _ catalog.AccessFilter) ([]*downloads.Download, string, []downloads.SkippedDownload, error) {
f.gotSeriesReq = req
if f.seriesErr != nil {
return nil, "", nil, f.seriesErr
}
return f.series, f.seriesID, nil, nil
}
func (f *fakeDownloadService) CreateSeason(_ context.Context, _ int, req downloads.CreateRequest, seasonNumber int, _ catalog.AccessFilter) ([]*downloads.Download, string, []downloads.SkippedDownload, error) {
f.gotSeasonReq = req
f.gotSeasonNum = seasonNumber
f.seasonCalled = true
if f.seasonErr != nil {
return nil, "", nil, f.seasonErr
}
return f.season, f.seasonID, nil, nil
}
func (f *fakeDownloadService) CreateSubscription(_ context.Context, _ int, req downloads.SubscriptionRequest, _ catalog.AccessFilter) (*downloads.SubscriptionResult, error) {
f.gotSubReq = req
if f.subErr != nil {
return nil, f.subErr
}
return f.subResult, nil
}
func (f *fakeDownloadService) ListSubscriptions(_ context.Context, userID int, profileID, deviceID string) ([]*downloads.Subscription, error) {
f.gotSubIdent = identityCall{userID: userID, profileID: profileID, deviceID: deviceID}
return f.subList, f.subErr
}
func (f *fakeDownloadService) GetSubscription(_ context.Context, userID int, profileID, deviceID, id string) (*downloads.Subscription, error) {
f.gotSubIdent = identityCall{userID, profileID, deviceID, id}
if f.subErr != nil {
return nil, f.subErr
}
return f.sub, nil
}
func (f *fakeDownloadService) UpdateSubscription(_ context.Context, userID int, profileID, deviceID, id string, patch downloads.SubscriptionPatch, _ catalog.AccessFilter) (*downloads.SubscriptionResult, error) {
f.gotSubIdent = identityCall{userID, profileID, deviceID, id}
f.gotSubPatch = patch
if f.subErr != nil {
return nil, f.subErr
}
return f.subResult, nil
}
func (f *fakeDownloadService) DeleteSubscription(_ context.Context, userID int, profileID, deviceID, id string) error {
f.gotSubIdent = identityCall{userID, profileID, deviceID, id}
return f.subDeleteErr
}
func (f *fakeDownloadService) SyncSubscriptions(_ context.Context, userID int, profileID, deviceID string, _ catalog.AccessFilter) (int, error) {
f.gotSubIdent = identityCall{userID: userID, profileID: profileID, deviceID: deviceID}
if f.subErr != nil {
return 0, f.subErr
}
return f.syncRegistered, nil
}
func (f *fakeDownloadService) ServeDirect(_ context.Context, w http.ResponseWriter, _ *http.Request, _, fileID int, format string, _ catalog.AccessFilter) error {
f.gotDirectFileID = fileID
f.gotDirectFormat = format
if f.directErr != nil {
return f.directErr
}
_, _ = w.Write([]byte("served"))
return nil
}
func (f *fakeDownloadService) ServeFile(_ context.Context, w http.ResponseWriter, _ *http.Request, userID int, profileID, deviceID, downloadID string, _ catalog.AccessFilter) error {
f.gotServe = identityCall{userID, profileID, deviceID, downloadID}
if f.serveErr != nil {
return f.serveErr
}
_, _ = w.Write([]byte("served"))
return nil
}
func (f *fakeDownloadService) List(_ context.Context, userID int, profileID, deviceID string) ([]*downloads.Download, error) {
f.gotList = identityCall{userID: userID, profileID: profileID, deviceID: deviceID}
return f.list, f.listErr
}
func (f *fakeDownloadService) Delete(_ context.Context, userID int, profileID, deviceID, downloadID string) error {
f.gotDelete = identityCall{userID, profileID, deviceID, downloadID}
return f.deleteErr
}
func (f *fakeDownloadService) PatchStatus(_ context.Context, userID int, profileID, deviceID, downloadID, status string) error {
f.gotPatch = identityCall{userID, profileID, deviceID, downloadID}
f.gotPatchStatus = status
return f.patchErr
}
func (f *fakeDownloadService) BuildManifest(_ context.Context, userID int, profileID, deviceID, downloadID string, _ catalog.AccessFilter) (*downloads.OfflineManifest, error) {
f.gotManifest = identityCall{userID, profileID, deviceID, downloadID}
if f.manifestErr != nil {
return nil, f.manifestErr
}
return f.manifest, nil
}
func (f *fakeDownloadService) BuildBatchManifests(_ context.Context, userID int, profileID, deviceID, batchID string, _ catalog.AccessFilter) ([]*downloads.OfflineManifest, []downloads.SkippedManifest, error) {
f.gotBatchManifest = identityCall{userID, profileID, deviceID, batchID}
if f.manifestErr != nil {
return nil, nil, f.manifestErr
}
return f.batchManifests, f.batchSkipped, nil
}
func (f *fakeDownloadService) ServeArtwork(_ context.Context, w http.ResponseWriter, _ *http.Request, userID int, profileID, deviceID, downloadID, kind string, _ catalog.AccessFilter) error {
f.gotArtwork = identityCall{userID, profileID, deviceID, downloadID}
f.gotArtworkKind = kind
if f.artworkErr != nil {
return f.artworkErr
}
_, _ = w.Write([]byte("img"))
return nil
}
func (f *fakeDownloadService) ServeSubtitle(_ context.Context, w http.ResponseWriter, _ *http.Request, userID int, profileID, deviceID, downloadID, ref string, _ catalog.AccessFilter) error {
f.gotSubtitle = identityCall{userID, profileID, deviceID, downloadID}
f.gotSubtitleRef = ref
if f.subtitleErr != nil {
return f.subtitleErr
}
_, _ = w.Write([]byte("WEBVTT"))
return nil
}
// downloadTestRequest builds a request with auth claims and optional profile +
// device identity (as the viewer-access middleware / client headers would set).
func downloadTestRequest(method, target string, body []byte, userID int, profileID, deviceID string) *http.Request {
var r *http.Request
if body != nil {
r = httptest.NewRequest(method, target, bytes.NewReader(body))
} else {
r = httptest.NewRequest(method, target, nil)
}
if userID != 0 {
ctx := apimw.SetClaims(r.Context(), &auth.Claims{
UserID: userID,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
if profileID != "" {
ctx = apimw.SetProfileID(ctx, profileID)
}
r = r.WithContext(ctx)
}
if deviceID != "" {
r.Header.Set("X-Silo-Device-Id", deviceID)
}
return r
}
func withChiID(r *http.Request, id string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", id)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestHandleCapability(t *testing.T) {
svc := &fakeDownloadService{capability: downloads.Capability{
Enabled: true,
DownloadAllowed: true,
QualityPresets: []string{downloads.QualityOriginal},
TranscodeEnabled: false,
TranscodeUserAllowed: true,
}}
h := NewDownloadHandler(svc)
rec := httptest.NewRecorder()
h.HandleCapability(rec, downloadTestRequest(http.MethodGet, "/downloads/capability", nil, 7, "", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
var resp downloadCapabilityResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Enabled || !resp.DownloadAllowed || resp.TranscodeEnabled || !resp.TranscodeUserAllowed {
t.Fatalf("unexpected capability flags: %+v", resp)
}
if len(resp.QualityPresets) != 1 || resp.QualityPresets[0] != downloads.QualityOriginal {
t.Fatalf("quality presets = %v, want [original]", resp.QualityPresets)
}
}
func TestHandleCapabilityUnauthorized(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
rec := httptest.NewRecorder()
h.HandleCapability(rec, httptest.NewRequest(http.MethodGet, "/downloads/capability", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestHandleCapabilityNilService(t *testing.T) {
h := NewDownloadHandler(nil)
rec := httptest.NewRecorder()
h.HandleCapability(rec, downloadTestRequest(http.MethodGet, "/downloads/capability", nil, 7, "", ""))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
func TestHandleCreateDownloadThreadsQuality(t *testing.T) {
svc := &fakeDownloadService{created: &downloads.Download{
ID: "dl1", ContentID: "c1", Status: downloads.StatusQueued, Format: downloads.FormatTranscode,
Quality: downloads.Quality5Mbps, EffectiveQuality: downloads.Quality5Mbps, TargetBitrateKbps: 5000, Revision: 2,
}}
h := NewDownloadHandler(svc)
body, _ := json.Marshal(downloadRequest{ContentID: "c1", Quality: downloads.Quality5Mbps})
rec := httptest.NewRecorder()
h.HandleCreateDownload(rec, downloadTestRequest(http.MethodPost, "/downloads", body, 7, "", ""))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotCreateReq.Quality != downloads.Quality5Mbps {
t.Fatalf("service received quality %q, want 5mbps", svc.gotCreateReq.Quality)
}
var resp downloadResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Quality != downloads.Quality5Mbps || resp.DeliveryFormat != downloads.FormatTranscode ||
resp.TargetBitrateKbps != 5000 || resp.Revision != 2 {
t.Fatalf("response = %+v, want quality/delivery/bitrate/revision", resp)
}
}
func TestHandleCreateDownloadSeriesThreadsQuality(t *testing.T) {
svc := &fakeDownloadService{
series: []*downloads.Download{{ID: "dl1", ContentID: "s1", Format: downloads.FormatOriginal, Quality: downloads.QualityOriginal, EffectiveQuality: downloads.QualityOriginal, Revision: 1}},
seriesID: "batch1",
}
h := NewDownloadHandler(svc)
body, _ := json.Marshal(downloadRequest{ContentID: "s1", Series: true, Quality: downloads.QualityOriginal})
rec := httptest.NewRecorder()
h.HandleCreateDownload(rec, downloadTestRequest(http.MethodPost, "/downloads", body, 7, "", ""))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotSeriesReq.Quality != downloads.QualityOriginal {
t.Fatalf("series service received quality %q, want original", svc.gotSeriesReq.Quality)
}
}
func TestHandleCreateDownloadMissingContentID(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
body, _ := json.Marshal(downloadRequest{Quality: downloads.QualityOriginal})
rec := httptest.NewRecorder()
h.HandleCreateDownload(rec, downloadTestRequest(http.MethodPost, "/downloads", body, 7, "", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHandleCreateDownloadErrorMapping(t *testing.T) {
cases := []struct {
name string
err error
wantCode int
}{
{"feature disabled", downloads.ErrFeatureDisabled, http.StatusForbidden},
{"not allowed", downloads.ErrDownloadNotAllowed, http.StatusForbidden},
{"transcode disabled", downloads.ErrTranscodeDisabled, http.StatusForbidden},
{"invalid quality", downloads.ErrInvalidQuality, http.StatusBadRequest},
{"quality unavailable", downloads.ErrQualityUnavailable, http.StatusNotImplemented},
{"bulk quality unavailable", downloads.ErrBulkQualityUnavailable, http.StatusNotImplemented},
{"format unavailable", downloads.ErrFormatUnavailable, http.StatusNotImplemented},
{"profile required", downloads.ErrProfileRequired, http.StatusBadRequest},
{"concurrent limit", downloads.ErrConcurrentLimitReached, http.StatusTooManyRequests},
{"period limit", downloads.ErrPeriodLimitReached, http.StatusTooManyRequests},
{"item not found", catalog.ErrItemNotFound, http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{createErr: tc.err})
body, _ := json.Marshal(downloadRequest{ContentID: "c1"})
rec := httptest.NewRecorder()
h.HandleCreateDownload(rec, downloadTestRequest(http.MethodPost, "/downloads", body, 7, "", ""))
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
}
})
}
}
// TestManagedCreateUsesHeaderDeviceNotBody verifies invariant 2's "device_id
// authority is the header only": a device_id placed in the JSON body is ignored,
// and the service receives the X-Silo-Device-Id header value + the profile.
func TestManagedCreateUsesHeaderDeviceNotBody(t *testing.T) {
svc := &fakeDownloadService{created: &downloads.Download{ID: "dl1", ContentID: "c1", DeviceID: "devA"}}
h := NewDownloadHandler(svc)
// Body smuggles a device_id; it is not a field of downloadRequest, so it is
// structurally dropped and must never reach the service.
body := []byte(`{"content_id":"c1","device_id":"EVIL","profile_id":"EVIL"}`)
rec := httptest.NewRecorder()
h.HandleCreateDownload(rec, downloadTestRequest(http.MethodPost, "/downloads", body, 7, "pA", "devA"))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotCreateReq.DeviceID != "devA" {
t.Fatalf("service device id = %q, want header value devA", svc.gotCreateReq.DeviceID)
}
if svc.gotCreateReq.ProfileID != "pA" {
t.Fatalf("service profile id = %q, want context value pA", svc.gotCreateReq.ProfileID)
}
}
func TestManagedFileThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{}
h := NewDownloadHandler(svc)
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl1/file", nil, 7, "pA", "devA"), "dl1")
rec := httptest.NewRecorder()
h.HandleDownloadFile(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotServe != (identityCall{7, "pA", "devA", "dl1"}) {
t.Fatalf("serve identity = %+v, want {7 pA devA dl1}", svc.gotServe)
}
}
func TestManagedListThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{}
h := NewDownloadHandler(svc)
rec := httptest.NewRecorder()
h.HandleListDownloads(rec, downloadTestRequest(http.MethodGet, "/downloads", nil, 7, "pA", "devA"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if svc.gotList.deviceID != "devA" || svc.gotList.profileID != "pA" {
t.Fatalf("list identity = %+v, want profile pA device devA", svc.gotList)
}
}
func TestManagedDeleteThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{}
h := NewDownloadHandler(svc)
req := withChiID(downloadTestRequest(http.MethodDelete, "/downloads/dl1", nil, 7, "pA", "devA"), "dl1")
rec := httptest.NewRecorder()
h.HandleDeleteDownload(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotDelete != (identityCall{7, "pA", "devA", "dl1"}) {
t.Fatalf("delete identity = %+v, want {7 pA devA dl1}", svc.gotDelete)
}
}
func TestHandleDeleteDownloadNotFound(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{deleteErr: downloads.ErrNotFound})
rec := httptest.NewRecorder()
req := withChiID(downloadTestRequest(http.MethodDelete, "/downloads/dl1", nil, 7, "", ""), "dl1")
h.HandleDeleteDownload(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestManagedPatchRequiresDeviceHeader(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
// No device header → 400 device_id_required, even with a profile present.
req := withChiID(downloadTestRequest(http.MethodPatch, "/downloads/dl1", []byte(`{"status":"completed"}`), 7, "pA", ""), "dl1")
rec := httptest.NewRecorder()
h.HandlePatchDownload(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 device_id_required (body: %s)", rec.Code, rec.Body.String())
}
}
func TestManagedPatchThreadsIdentityAndStatus(t *testing.T) {
svc := &fakeDownloadService{}
h := NewDownloadHandler(svc)
req := withChiID(downloadTestRequest(http.MethodPatch, "/downloads/dl1", []byte(`{"status":"completed"}`), 7, "pA", "devA"), "dl1")
rec := httptest.NewRecorder()
h.HandlePatchDownload(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotPatch != (identityCall{7, "pA", "devA", "dl1"}) {
t.Fatalf("patch identity = %+v, want {7 pA devA dl1}", svc.gotPatch)
}
if svc.gotPatchStatus != downloads.StatusCompleted {
t.Fatalf("patch status = %q, want completed", svc.gotPatchStatus)
}
}
func TestHandleDirectDownloadThreadsOriginalFormat(t *testing.T) {
svc := &fakeDownloadService{}
h := NewDownloadHandler(svc)
rec := httptest.NewRecorder()
h.HandleDirectDownload(rec, downloadTestRequest(http.MethodGet, "/direct-download?file_id=42&format=original", nil, 7, "", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotDirectFileID != 42 {
t.Fatalf("file id = %d, want 42", svc.gotDirectFileID)
}
if svc.gotDirectFormat != downloads.FormatOriginal {
t.Fatalf("direct format = %q, want original", svc.gotDirectFormat)
}
}
func TestHandleDirectDownloadMissingFileID(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
rec := httptest.NewRecorder()
h.HandleDirectDownload(rec, downloadTestRequest(http.MethodGet, "/direct-download", nil, 7, "", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func withChiParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestManagedManifestRequiresDeviceHeader(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
// Profile present but no device header → 400 device_id_required.
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl1/manifest", nil, 7, "pA", ""), "dl1")
rec := httptest.NewRecorder()
h.HandleManifest(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body: %s)", rec.Code, rec.Body.String())
}
}
func TestManagedManifestThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{manifest: &downloads.OfflineManifest{DownloadID: "dl1", Title: "Movie"}}
h := NewDownloadHandler(svc)
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl1/manifest", nil, 7, "pA", "devA"), "dl1")
rec := httptest.NewRecorder()
h.HandleManifest(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotManifest != (identityCall{7, "pA", "devA", "dl1"}) {
t.Fatalf("manifest identity = %+v, want {7 pA devA dl1}", svc.gotManifest)
}
}
func TestManagedBatchManifestsThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{batchManifests: []*downloads.OfflineManifest{
{DownloadID: "dl1", Title: "Episode 1"},
}}
h := NewDownloadHandler(svc)
req := withChiParams(downloadTestRequest(http.MethodGet, "/downloads/batches/b1/manifests", nil, 7, "pA", "devA"),
map[string]string{"batch_id": "b1"})
rec := httptest.NewRecorder()
h.HandleBatchManifests(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotBatchManifest != (identityCall{7, "pA", "devA", "b1"}) {
t.Fatalf("batch manifest identity = %+v, want {7 pA devA b1}", svc.gotBatchManifest)
}
var resp batchManifestsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Manifests) != 1 || resp.Manifests[0].DownloadID != "dl1" {
t.Fatalf("manifests = %+v, want one dl1 manifest", resp.Manifests)
}
}
// TestManagedAssetsDenyRestrictedProfile is the Phase 2 acceptance test: a
// profile denied content access (the service returns ErrItemNotFound) gets a
// 404 from manifest, artwork, and subtitle — a download id never reveals
// out-of-scope content.
func TestManagedAssetsDenyRestrictedProfile(t *testing.T) {
t.Run("manifest", func(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{manifestErr: catalog.ErrItemNotFound})
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl1/manifest", nil, 7, "child", "devC"), "dl1")
rec := httptest.NewRecorder()
h.HandleManifest(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("manifest status = %d, want 404", rec.Code)
}
})
t.Run("artwork", func(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{artworkErr: catalog.ErrItemNotFound})
req := withChiParams(downloadTestRequest(http.MethodGet, "/downloads/dl1/artwork/poster", nil, 7, "child", "devC"),
map[string]string{"id": "dl1", "kind": "poster"})
rec := httptest.NewRecorder()
h.HandleArtwork(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("artwork status = %d, want 404", rec.Code)
}
})
t.Run("subtitle", func(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{subtitleErr: catalog.ErrItemNotFound})
req := withChiParams(downloadTestRequest(http.MethodGet, "/downloads/dl1/subtitles/external:0", nil, 7, "child", "devC"),
map[string]string{"id": "dl1", "ref": "external:0"})
rec := httptest.NewRecorder()
h.HandleSubtitle(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("subtitle status = %d, want 404", rec.Code)
}
})
}
func TestManagedArtworkThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{}
h := NewDownloadHandler(svc)
req := withChiParams(downloadTestRequest(http.MethodGet, "/downloads/dl1/artwork/backdrop", nil, 7, "pA", "devA"),
map[string]string{"id": "dl1", "kind": "backdrop"})
rec := httptest.NewRecorder()
h.HandleArtwork(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotArtwork != (identityCall{7, "pA", "devA", "dl1"}) || svc.gotArtworkKind != "backdrop" {
t.Fatalf("artwork identity = %+v kind = %q", svc.gotArtwork, svc.gotArtworkKind)
}
}
func TestManagedSubtitleInvalidRef(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{subtitleErr: downloads.ErrInvalidSubtitleRef})
req := withChiParams(downloadTestRequest(http.MethodGet, "/downloads/dl1/subtitles/bogus", nil, 7, "pA", "devA"),
map[string]string{"id": "dl1", "ref": "bogus"})
rec := httptest.NewRecorder()
h.HandleSubtitle(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body: %s)", rec.Code, rec.Body.String())
}
}
func intPtr(i int) *int { return &i }
// TestHandleCreateDownloadSeasonRoutes verifies that series=true + a season
// number routes to CreateSeason (not CreateSeries) with the season threaded.
func TestHandleCreateDownloadSeasonRoutes(t *testing.T) {
svc := &fakeDownloadService{
season: []*downloads.Download{{ID: "dl1", ContentID: "s1", EpisodeID: "e1", Format: downloads.FormatOriginal}},
seasonID: "batch1",
}
h := NewDownloadHandler(svc)
body, _ := json.Marshal(downloadRequest{ContentID: "s1", Series: true, Season: intPtr(2)})
rec := httptest.NewRecorder()
h.HandleCreateDownload(rec, downloadTestRequest(http.MethodPost, "/downloads", body, 7, "pA", "devA"))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotSeasonNum != 2 {
t.Fatalf("season number = %d, want 2", svc.gotSeasonNum)
}
if svc.gotSeasonReq.ContentID != "s1" {
t.Fatalf("season content id = %q, want s1", svc.gotSeasonReq.ContentID)
}
}
// TestHandleCreateDownloadSpecialsSeason pins the Specials boundary: season 0
// must route to CreateSeason(0), not silently broaden to a full-series
// download, and negative seasons are rejected.
func TestHandleCreateDownloadSpecialsSeason(t *testing.T) {
svc := &fakeDownloadService{
season: []*downloads.Download{{ID: "dl1", ContentID: "s1", EpisodeID: "e1", Format: downloads.FormatOriginal}},
seasonID: "batch1",
}
h := NewDownloadHandler(svc)
body, _ := json.Marshal(downloadRequest{ContentID: "s1", Series: true, Season: intPtr(0)})
rec := httptest.NewRecorder()
h.HandleCreateDownload(rec, downloadTestRequest(http.MethodPost, "/downloads", body, 7, "pA", "devA"))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202 (body: %s)", rec.Code, rec.Body.String())
}
if !svc.seasonCalled || svc.gotSeasonNum != 0 {
t.Fatalf("seasonCalled=%v num=%d, want CreateSeason(0)", svc.seasonCalled, svc.gotSeasonNum)
}
if svc.gotSeriesReq.ContentID != "" {
t.Fatal("season 0 must not fall through to CreateSeries")
}
rec = httptest.NewRecorder()
body, _ = json.Marshal(downloadRequest{ContentID: "s1", Series: true, Season: intPtr(-1)})
h.HandleCreateDownload(rec, downloadTestRequest(http.MethodPost, "/downloads", body, 7, "pA", "devA"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("negative season status = %d, want 400", rec.Code)
}
}
func TestHandleCreateSubscriptionThreadsRequest(t *testing.T) {
svc := &fakeDownloadService{subResult: &downloads.SubscriptionResult{
Subscription: &downloads.Subscription{ID: "sub1", SeriesID: "s1", Mode: downloads.SubModeLatestSeason, Active: true},
Registered: 3,
}}
h := NewDownloadHandler(svc)
body, _ := json.Marshal(subscriptionRequest{SeriesID: "s1", Mode: downloads.SubModeLatestSeason, DeleteWatched: true, MaxStorageBytes: 1024})
rec := httptest.NewRecorder()
h.HandleCreateSubscription(rec, downloadTestRequest(http.MethodPost, "/downloads/subscriptions", body, 7, "pA", "devA"))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotSubReq.SeriesID != "s1" || svc.gotSubReq.Mode != downloads.SubModeLatestSeason {
t.Fatalf("subscription request = %+v", svc.gotSubReq)
}
if svc.gotSubReq.DeviceID != "devA" || svc.gotSubReq.ProfileID != "pA" {
t.Fatalf("subscription identity = profile %q device %q, want pA/devA", svc.gotSubReq.ProfileID, svc.gotSubReq.DeviceID)
}
if !svc.gotSubReq.DeleteWatched || svc.gotSubReq.MaxStorageBytes != 1024 {
t.Fatalf("subscription options not threaded: %+v", svc.gotSubReq)
}
var resp subscriptionResultResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Registered != 3 || resp.Subscription.ID != "sub1" {
t.Fatalf("unexpected response: %+v", resp)
}
}
// TestHandleCreateSubscriptionRequiresDevice enforces that monitoring is
// device-scoped: a missing X-Silo-Device-Id header is a 400 even with a profile.
func TestHandleCreateSubscriptionRequiresDevice(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
body, _ := json.Marshal(subscriptionRequest{SeriesID: "s1", Mode: downloads.SubModeAll})
rec := httptest.NewRecorder()
h.HandleCreateSubscription(rec, downloadTestRequest(http.MethodPost, "/downloads/subscriptions", body, 7, "pA", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body: %s)", rec.Code, rec.Body.String())
}
}
func TestHandleListSubscriptionsThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{subList: []*downloads.Subscription{{ID: "sub1", SeriesID: "s1", Mode: downloads.SubModeAll, Active: true}}}
h := NewDownloadHandler(svc)
rec := httptest.NewRecorder()
h.HandleListSubscriptions(rec, downloadTestRequest(http.MethodGet, "/downloads/subscriptions", nil, 7, "pA", "devA"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotSubIdent.profileID != "pA" || svc.gotSubIdent.deviceID != "devA" {
t.Fatalf("list identity = %+v, want profile pA device devA", svc.gotSubIdent)
}
var resp subscriptionsListResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Subscriptions) != 1 || resp.Subscriptions[0].ID != "sub1" {
t.Fatalf("unexpected subscriptions: %+v", resp.Subscriptions)
}
}
func TestHandleSubscriptionErrorMapping(t *testing.T) {
cases := []struct {
name string
err error
wantCode int
}{
{"not found", downloads.ErrSubscriptionNotFound, http.StatusNotFound},
{"unavailable", downloads.ErrSubscriptionsUnavailable, http.StatusServiceUnavailable},
{"invalid mode", downloads.ErrInvalidSubscriptionMode, http.StatusBadRequest},
{"seasons required", downloads.ErrSeasonsRequired, http.StatusBadRequest},
{"not series", downloads.ErrNotSeries, http.StatusBadRequest},
{"not allowed", downloads.ErrDownloadNotAllowed, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{subErr: tc.err})
body, _ := json.Marshal(subscriptionRequest{SeriesID: "s1", Mode: downloads.SubModeAll})
rec := httptest.NewRecorder()
h.HandleCreateSubscription(rec, downloadTestRequest(http.MethodPost, "/downloads/subscriptions", body, 7, "pA", "devA"))
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
}
})
}
}
func TestHandleSyncSubscriptionsThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{syncRegistered: 5}
h := NewDownloadHandler(svc)
rec := httptest.NewRecorder()
h.HandleSyncSubscriptions(rec, downloadTestRequest(http.MethodPost, "/downloads/subscriptions/sync", nil, 7, "pA", "devA"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if svc.gotSubIdent.profileID != "pA" || svc.gotSubIdent.deviceID != "devA" {
t.Fatalf("sync identity = %+v, want profile pA device devA", svc.gotSubIdent)
}
var resp subscriptionSyncResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Registered != 5 {
t.Fatalf("registered = %d, want 5", resp.Registered)
}
}
func TestHandleSyncSubscriptionsRequiresDevice(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
rec := httptest.NewRecorder()
// Profile present, no device header → 400 device_id_required.
h.HandleSyncSubscriptions(rec, downloadTestRequest(http.MethodPost, "/downloads/subscriptions/sync", nil, 7, "pA", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body: %s)", rec.Code, rec.Body.String())
}
}