Files
silo-server/internal/requests/service_test.go
T
2c714e4ef2 feat(requests): pluginize request fulfillment behind request_router.v1 (#104)
* docs: design spec for pluginizing requests fulfillment

Pluginize the requests fulfillment backend behind an agnostic
request_router.v1 capability (high seam: whole-request fulfiller).
Host keeps lifecycle/quota/policy/quality-governance and a generic
two-tier connection registry; plugins own routing+submission+status.
First plugin extracts multi-instance Sonarr/Radarr; Seerr follows in
a separate spec. Preserves autoscan reuse of arr connection rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for requests pluginization

Three-phase plan: (1) request_router.v1 SDK capability, (2) new
silo-plugin-requests-arr plugin extracting multi-instance Sonarr/Radarr,
(3) host refactor routing fulfillment through the plugin while keeping
quality governance, target records, and autoscan connection reuse host-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db): generalize request_integrations into a two-tier connection registry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): add generic connection fields to Integration + repo mapping

* feat(pluginhost): typed RequestRouter capability client + resolver

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): plugin-backed RequestRouterProvider seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): route fulfillment through RequestRouterProvider; host keeps quality governance

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): base auto-approve gate on router connection model

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): wire plugin-backed request router at both service sites

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(requests): remove in-host Sonarr/Radarr fulfillment code

* test(autoscan): lock request-integration reuse after connection generalization

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): plugin-driven request integration config form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): echo router connection fields in integration response

* fix(requests): retry dropped qualities, contain to one router installation, dedupe targets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): harden plugin trust boundary (validate targets, contain bad connections, media-type routing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): tighten auto-approve gate, restore default/4k validation, propagate config-encode error, drop itoa wrapper, test status/options translation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(requests): resolve integrations/settings/secrets once per reconcile cycle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): dedupe config helpers, preserve zero profile id, stabilize installation default, drop redundant options write

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for schema-driven plugin config form

Extends AdminFormDescriptor into a full form-description language (dynamic
options, multi-select, conditional visibility, sections, validation) + a
plugin Validate RPC, rendered by one reusable SchemaForm engine. Retires the
bespoke arr connection form and integrationOptionsFromRouter so any
request_router backend renders its config UI from manifest data with zero
host changes. Addresses code-review finding #9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for schema-driven plugin config form

Six phases: SDK AdminFormDescriptor extensions + Validate RPC; reusable
SchemaForm renderer (refactor PluginConfigForm onto it); host Validate
plumbing + generic options + legacy-column derivation + retire
integrationOptionsFromRouter; requests admin page swap to SchemaForm with
per-plugin grouping; arr manifest enrichment + Validate impl; verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): extend plugin admin-form TS types (sections, conditions, validation, multi-select)

* feat(web): schema-form pure utils (show_when, validation, value coercion)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): SchemaForm renderer (controls, sections, show_when, dynamic options, errors)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): render PluginConfigForm via the shared SchemaForm engine

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): RequestRouter Validate client + provider seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): plugin Validate on save, generic options, derive legacy columns from plugin_config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): generic options response + 400 field_errors on plugin validation failure

* feat(web): generic request-integration options type + surface validation field_errors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): render request connections via SchemaForm; per-plugin grouping; retire bespoke arr form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): silent connection-options probe with inline failure status (no toast spam)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): serialize admin_form sections/show_when/dynamic_options/validation to the client

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): drop show_when-hidden fields from buildSchemaValues payload

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): pass requester user id as int64 (no truncation)

* refactor(requests): drop legacy arr columns; plugin_config is sole source of truth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): backfill api key in plugin validate; centralize validation 400; drop duplicate host cross-field check; guard admin-form serializer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): refuse stored api key reuse when base_url changes (security hardening)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): SchemaForm regex-guard, default_value, type-driven coercion, validity callback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): connection-options latest-wins + narrowed deps + clear stale errors; auto-select; type-driven persist; reuse types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for silo-plugin-requests-seerr (request_router.v1 backend)

* docs: implementation plan for silo-plugin-requests-seerr

* docs(spec): FindExistingRequest uses /api/v1/request (carries request id)

* docs(spec): seerr hardening — id-recovery, 404 terminal, media-status, sort pin, single missing-tmdb message

* docs: design spec for shared plugin-platform SDK helpers (code-review #10)

* docs: plan for plugin-platform SDK helpers (#10) + spec fix (inline broker wiring, no import cycle)

* docs: design spec for typed 4K quality-tier signal (code-review #9)

* docs: implementation plan for typed 4K quality-tier signal (#9)

* feat(requests): stamp is4k per quality (host owns the 4K-tier fact)

* fix(requests): store capability sub-id, not the type, in request_integrations

request_integrations.capability_id carried the capability TYPE
("request_router.v1") instead of the capability sub-id ("arr"/"seerr").
The host resolves a router plugin via
requireCapability("request_router.v1", id), which keys on (type, id), so
storing the type resolved to no capability: every save/options/fulfill
500'd ("Request operation failed" / "no fulfillment backend configured")
in ~1ms, before the arr/Seerr API was ever contacted. The path was
internally split-brained (the fulfillment filter matched the type while
the dispatcher needed the sub-id), so it never worked end-to-end; the
unit tests hid it behind a fake provider that skips requireCapability.

Align capability_id with the scan_source/metadata convention (sub-id):
- validateInstance: require a non-empty sub-id; drop the default-to-type
  and the "!= request_router.v1" reject.
- resolveRouterConnections / integrationConfigured / unbound-guidance:
  match on a non-empty capability, not type equality.
- repository: persist capability_id verbatim (never default to the type).
- web AdminRequests: send the selected plugin's capability.id in both the
  options probe and the save payload (was a hardcoded type constant).
- migration 20260608131649: backfill capability_id from each bound
  installation's request_router.v1 capability and drop the column's
  misleading default. Unbound legacy rows are left for admin re-save.

Tests: validateInstance now requires the sub-id, and the selected sub-id
must reach the plugin Validate RPC (fakeRouterProvider records it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): polish request connection cards (grouped toggles + option loading states)

The schema-driven connection cards rendered each boolean as its own
bordered, double-labeled box and showed dynamic SELECTs (root folder,
quality profile, tags) as empty controls with a single "Loading options…"
line while the host probed the service.

- Toggles render as a cohesive settings list: consecutive switches collapse
  into one bordered, divided container; each row is toggle-first with the
  label + description hugging beside it (no stranded whitespace between a
  short label and its switch). Honors show_when, so conditional toggles
  still group correctly.
- Dynamic SELECT/MULTI_SELECT fields show a per-field spinner + shimmer
  skeleton while options load, and only when there's nothing to show yet —
  a background re-probe never flashes over the operator's current value.
- Sections get a softer surface and clearer titles; the card's enable
  switch is labeled Enabled/Disabled; the options-load failure is a proper
  inline alert with retry guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): treat "Any"/no-cap playback ceiling as 4K-allowed

allowedQualities decided whether to also request 2160p with
`CompareQuality(ceiling, PlaybackQuality4K) >= 0`. But an "Any" max
playback quality resolves to an empty ceiling ("no cap"), and in
qualityRank "" is the LOWEST rank (0) — so CompareQuality("", "2160p")
returns -1 and 4K was dropped. A requester with unlimited playback quality
only got a 1080p request, never the 4K one.

Use access.QualityAllowed(PlaybackQuality4K, ceiling), which already
encodes "empty ceiling == no cap == allows everything". Now:
- "" / "Any"  -> 1080p + 2160p
- "2160p"     -> 1080p + 2160p
- "1080p"     -> 1080p only
- resolver error still fails safe to the HD ceiling.

Tests: add an "any/no-cap ceiling adds 2160p" case; the unknown-quality,
status-coercion, dedup, and per-quality-idempotency submit tests now pin
an explicit HD ceiling (they relied on the old empty-default == HD-only
behavior and were not about 4K entitlement).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for collapsible Library + anime gate/nesting (request card UI, Spec A)

Spec A of two for the request connection card UX: Library section becomes
collapsible/collapsed (auto-expanding on validation errors) and the anime
override fields move into a single gated section below Library instead of
popping out as a detached sibling card. Single-default enforcement is Spec B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for collapsible Library + anime gate/nesting (Spec A)

Task-by-task TDD plan: SchemaForm auto-expand-on-error + nested-field
affordance (silo-server), arr manifest regroup (collapsible Library, anime
gate section), then build/deploy/reinstall + manual verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): auto-expand collapsible schema sections that have validation errors

SchemaFormSection now accepts a forceOpen prop; when any field in the section
has a mergedError (client validation or server error), the section expands
automatically so required-field setup can never be hidden behind a collapsed
accordion. The operator's manual toggle is preserved via a nullable userOpen
state that only takes effect when forceOpen is false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): indent show_when-revealed schema fields to read as nested

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for schema-driven single-default exclusivity enforcement (Spec B)

At most one connection per service_kind may be the HD default (is_default) or
4K default (is_default_4k). Generic exclusivity: a new AdminFormField
exclusive_group_field declares the rule, the plugin Validate enforces it
against host-supplied siblings (config only, no creds), and the admin UI
auto-clears conflicts as you toggle. Host stays plugin-agnostic. Forward-only;
no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for single-default exclusivity enforcement (Spec B)

Five TDD tasks across 3 repos: SDK proto (siblings + exclusive_group_field)
+ buf regen; arr Validate cross-sibling + manifest; host gathers siblings
(config-only) into Validate; frontend generic mutual-exclusion helper; then
re-vendor/rebuild/redeploy + plugininstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): pass sibling connections to plugin Validate for cross-connection rules

Adds siblings []ResolvedRouterConnection to RequestRouterProvider.Validate so
the plugin can enforce cross-connection invariants (e.g. one default per
service_kind) without the host resolving sibling credentials. The new
siblingConnections helper gathers other connections on the same installation,
carrying only ID + PluginConfig. Vendor updated to the Task 1 SDK version that
carries ValidateRequest.Siblings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): auto-clear mutually-exclusive defaults across request connection cards

Adds generic applyExclusivity helper and wires it into updateCardConfig so
turning on a field with exclusive_group_field proactively clears the same
field on sibling cards sharing the same group value, matching server-side
enforcement with a proactive UX.

* docs: design spec for single-flighting plugin client launch (cold-start herd fix)

Concurrent ensureClient calls for a cold installation each spawn a redundant
plugin process (Host.Start releases its lock during launch). Wrap ensureClient
in a per-installation singleflight.Group so concurrent first-use collapses to
one launch. Host-only fix; surfaced while testing the request-router feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for single-flighting plugin client launch

TDD: concurrency tests (herd collapses to one launch, warm-cache reuse,
distinct installations stay parallel, failed launch propagates) + the
singleflight wrapper around ensureClient; then rebuild/redeploy + verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): single-flight ensureClient to prevent cold-start launch herd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(requests): harden capability containment + dedupe eligibility; UI/migration cleanups

Addresses /code-review high findings on the previously-unreviewed commits:
- resolveRouterConnections contains fulfillment to the first chosen
  (installation, capability) and locks only after a connection's key resolves,
  so a plugin exposing >1 request_router capability never mixes connections and
  a skipped bad-key connection never pins the capability (+ test).
- extract eligibleRouterConnection, shared by resolveRouterConnections and
  integrationConfigured so the auto-approval gate and fulfillment filter can't
  drift.
- SchemaForm: shared FieldDescription helper (field/switch/section); key switch
  groups by position so a show_when reveal doesn't remount the group (focus loss).
- migration backfill uses a deterministic correlated subquery instead of a join
  cross-product when an installation exposes multiple request_router capabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for opt-in Seerr per-user requester mapping

Per-connection requester_mode (admin default | mapped). In mapped mode the host
pushes the requester email/username into the Fulfill descriptor and the seerr
plugin resolves/creates the matching Seerr user by email with operator-chosen
default permissions, attributing the request (and gating Seerr-side approval via
the auto-approve permission). Spans SDK (descriptor fields), host (extend
UserIdentityLookup with email + a requester resolver), and the seerr plugin
(Seerr user API + mapping). Fallback to admin on any failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for Seerr per-user requester mapping

Five TDD tasks across 3 repos: SDK descriptor fields (requester_email/username);
host resolves identity (UserIdentityLookup+email, RequesterIdentityResolver,
populate descriptor at both Fulfill sites); seerr config+user API (find/create
by email, exported PermissionBits); seerr Fulfill mapping + admin_form; then
re-vendor/rebuild/redeploy + plugininstall (installation 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: make Seerr unmapped-requester behavior a toggle (admin fallback | fail request)

Per user feedback: require_mapped_user switch (default off = admin fallback,
on = fail the request). Updates spec + plan Tasks 3/4 (config field, Fulfill
honoring the toggle via a mapFailed signal, a new test, and the manifest switch).

* feat(requests): resolve requester email/username into the Fulfill descriptor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for simplified Seerr mapped-user permissions

Reduce the 5 permission toggles to two (request_4k_all + auto_approve);
1080p always granted; remove manage_requests; 4K eligibility per-user from the
request's qualities (host-decided, same as arr) with a blanket override toggle.
Seerr-plugin-only; permission-only override (host still gates 4K requests).

* docs: implementation plan for simplified Seerr mapped-user permissions

Two tasks (seerr-plugin-only): replace the 5 perm toggles with request_4k_all +
auto_approve (1080p always; 4K from request qualities via userPermissions;
remove PermManageRequests/PermissionBits; manifest + json_schema), then rebuild
+ reinstall (installation 6). No host/SDK change.

* docs: design spec for host rebase onto main + #95 credential-model adoption

Per-commit rebase of our 68 request-router commits onto the force-pushed
origin/main (drops 188 patch-equivalent). At the credential-path conflicts, adopt
#95's inline secret.Cipher model: keep our plugin columns + #95's encrypt/decrypt
in repository.go; drop our SecretResolver and read in.APIKeyRef directly in
service.go; wire NewRepository(pool, dataCipher). #39-area conflicts take ours
(our pluginization supersedes it). Security review + SECRET_KEY deploy note.

* docs: implementation plan for host rebase + #95 credential adoption

Four tasks: (1) guided per-commit rebase onto origin/main, take-ours on
credential files so it builds; (2) TDD integration commit adopting #95's
secret.Cipher (encrypt/decrypt in repository.go, drop SecretResolver, read
APIKeyRef directly, wire NewRepository(pool, cipher)); (3) security review;
(4) pin published SDK v0.6.0, push fork, open host PR with SECRET_KEY deploy note.

* chore(rebase): restore scan-source service methods + temp requests-repo arity

Post-rebase conflict fixups: take-ours on internal/plugins/service.go dropped
origin's ScanSourceClientByPluginID (independent upstream capability) — restored.
mediarequests.NewRepository temporarily 1-arg to match our pre-#95 repo; Task 2
restores the cipher arg when adopting #95's at-rest credential model.

* feat(requests): adopt at-rest credential cipher (#95) for plugin api keys; drop SecretResolver

* build: pin published silo-plugin-sdk v0.6.0 (drop local replace)

* test(requests): guard at-rest cipher round-trip + empty-key auto-approval (code-review)

Max-effort code review of the #95 credential integration. Fixes the actionable
findings:
- TestEncryptAPIKeyRoundTripAndAAD: pins encryptAPIKey<->DecryptIfEncrypted
  inversion, the id-bound apiKeyAAD == secret.RowAAD(...) match (so #95's backfill
  rows decrypt), the blank-key "" sentinel, and row-bound AAD — the security-
  critical invariants had no automated guard (no DB harness for scanIntegration).
- TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured: pins that a keyless
  connection reads as unconfigured (request stays pending, never submitted), so
  integrationConfigured and resolveRouterConnections can't drift.
- Fix stale fulfillContext comment (referenced a resolved-API-key cache removed
  with SecretResolver).

Assessed-not-changed (documented): decrypt-error-fails-closed and failed-backfill
behaviors are origin/main #95 design we adopt; nil-cipher is unreachable in prod
and matches the codebase-wide no-guard pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build: drop stale machine-local SDK replace comment from go.mod

The replace directive was already removed when v0.6.0 was pinned (3410df7);
this leftover comment falsely claimed a local replace still existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): prettier-format schema-form utils to 100-col width

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: drop internal superpowers specs/plans from PR

These design specs and implementation plans are internal development
artifacts; keep them out of the upstream PR diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(metadata): exclude providers from content levels they don't declare

ResolveChain falls back to every enabled metadata provider when a library
+ content-level has no enabled chain entry. That fallback was media-type
blind: a provider declaring default_priority only for an unrelated level
(e.g. an audiobook provider declaring {"audiobook": N}) was kept in the
list (merely sorted last) and invoked for video content levels.

In production this made silo.audiobook-metadata hammer external audiobook
APIs with anime/movie/series titles every scheduled enrichment pass
(MatchWorker, 30s) for the season/episode levels that had no enabled chain
entry. Disabling the chain entries did not help because the fallback never
consults them; only disabling the installation removed it from the global
set.

Treat a non-empty default_priority map as the provider enumerating the
content levels it supports: in resolveEnabledProvidersByPriority, exclude
providers whose declared map omits the requested level instead of ranking
them last. Providers that declare no default_priority make no claim and
stay eligible everywhere (legacy behavior).

Fixes #105

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): isolate singleflight launch from leader ctx cancellation

The deduped ensureClient launch ran doEnsureClient under the leader caller's
ctx, so if that caller's request was canceled/timed out mid-launch the shared
plugin start was torn down and the error propagated to every waiter. Run the
launch under context.WithoutCancel so a single caller cannot cancel work the
other waiters depend on (values preserved for tracing/auth). (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): nil-guard request-router wiring

RequestRouterClient dereferenced a.Svc unconditionally and AttachRequestRouter
called SetRouterProvider even with nil deps, so a build without the plugin
service would panic instead of degrading. Guard both: the adapter returns a
controlled error and AttachRequestRouter no-ops, leaving fulfillment to fail
with the existing "no backend configured" path. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): correct value coercion + track capability sub-id in request form

- schemaForm: Boolean("false") was true; parse string booleans explicitly.
  array:num now coerces decimals ("1.5"), array:int stays integer-only.
- AdminRequests: track capability_id alongside installation_id (composite
  <Select> value) so a multi-capability installation resolves the exact
  backend; reset pluginConfig when the selected plugin changes so plugin A's
  keys never reach plugin B's options probe/save. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): address request-router review findings

* fix(requests): handle router review edge cases

* fix(web): resolve schema form build casing

* fix(requests): skip unconfigured 4k fulfillment targets

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-09 13:00:48 -04:00

2669 lines
89 KiB
Go

package requests
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
)
func TestCreateRequestQuotaExceeded(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalMaxRequests = 1
store.count = 1
service := newTestService(store)
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err == nil {
t.Fatal("expected quota error")
}
var quota QuotaError
if !errors.As(err, &quota) {
t.Fatalf("error = %v, want QuotaError", err)
}
if len(store.created) != 0 {
t.Fatalf("created requests = %d, want 0", len(store.created))
}
}
func TestNormalizeListFilterCapsLimit(t *testing.T) {
cases := []struct {
name string
in ListFilter
wantLim int
wantOff int
}{
{"zero defaults", ListFilter{}, defaultRequestListLimit, 0},
{"negative defaults", ListFilter{Limit: -10, Offset: -5}, defaultRequestListLimit, 0},
{"under cap preserved", ListFilter{Limit: 75, Offset: 10}, 75, 10},
{"at cap preserved", ListFilter{Limit: maxRequestListLimit, Offset: 0}, maxRequestListLimit, 0},
{"over cap clamped", ListFilter{Limit: 1_000_000}, maxRequestListLimit, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normalizeListFilter(tc.in)
if got.Limit != tc.wantLim {
t.Errorf("limit = %d, want %d", got.Limit, tc.wantLim)
}
if got.Offset != tc.wantOff {
t.Errorf("offset = %d, want %d", got.Offset, tc.wantOff)
}
})
}
}
func TestCreateRequestConcurrentSubmissionsRespectQuota(t *testing.T) {
const (
maxRequests = 5
goroutines = 20
)
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalMaxRequests = maxRequests
service := newTestService(store)
var (
wg sync.WaitGroup
successMu sync.Mutex
successes int
quotaFails int
)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(tmdbID int) {
defer wg.Done()
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: tmdbID,
Title: "Title",
})
successMu.Lock()
defer successMu.Unlock()
if err == nil {
successes++
return
}
var quota QuotaError
if errors.As(err, &quota) {
quotaFails++
}
}(1000 + i)
}
wg.Wait()
if successes != maxRequests {
t.Fatalf("successful creations = %d, want %d", successes, maxRequests)
}
if successes+quotaFails != goroutines {
t.Fatalf("non-quota errors: successes=%d quotaFails=%d total=%d", successes, quotaFails, goroutines)
}
if len(store.created) != maxRequests {
t.Fatalf("stored creations = %d, want %d", len(store.created), maxRequests)
}
}
func TestCreateRequestActiveDuplicateBlocks(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.count = 100
store.active[MediaTypeMovie][550] = &Request{
ID: "req-existing",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
}
service := newTestService(store)
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if !errors.Is(err, ErrAlreadyRequested) {
t.Fatalf("error = %v, want ErrAlreadyRequested", err)
}
if len(store.created) != 0 {
t.Fatalf("created requests = %d, want 0", len(store.created))
}
}
func TestCreateRequestAutoApprovalRequiresConfiguredIntegration(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
service := newTestService(store)
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Status != StatusPending {
t.Fatalf("status = %q, want pending", req.Status)
}
}
// TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured guards that a router
// connection that is enabled + bound but has no api key (empty after the repo's
// decrypt) reads as "not configured": auto-approval is declined and the request
// stays pending, rather than being auto-approved and then failing submission when
// resolveRouterConnections skips the keyless connection. This pins the empty-key
// check in integrationConfigured against the skip in resolveRouterConnections so
// the two can't drift at the public CreateRequest surface.
func TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
store.integrations = []Integration{autoApproveRouterInst("router-1", "")}
service := newTestService(store)
router := &fakeRouterProvider{}
service.SetRouterProvider(router)
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Status != StatusPending {
t.Fatalf("status = %q, want pending (empty-key connection is unconfigured)", req.Status)
}
if router.fulfillCalls != 0 {
t.Fatalf("fulfill calls = %d, want 0 (must not submit to a keyless connection)", router.fulfillCalls)
}
}
func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
// A plugin-driven router connection that sets only the generic
// Enabled/CapabilityID/InstallationID fields (no legacy Kind/IsDefault columns)
// must still satisfy the auto-approve gate.
store.integrations = []Integration{routerInst("router-1")}
service := newTestService(store)
service.SetRouterProvider(&fakeRouterProvider{})
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
// The configured router connection auto-approves and immediately submits, so the
// request lands in the fulfillment pipeline (one queued target).
if req.Status != StatusQueued {
t.Fatalf("status = %q, want queued (auto-approved and submitted)", req.Status)
}
}
func TestCreateRequestAutoApprovalRespectsSupportedMediaTypes(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
// A router connection that only serves series must NOT auto-approve a movie
// request; the gate falls back to manual approval (pending).
seriesOnly := routerInst("router-series")
seriesOnly.SupportedMediaTypes = []string{string(MediaTypeSeries)}
store.integrations = []Integration{seriesOnly}
service := newTestService(store)
service.SetRouterProvider(&fakeRouterProvider{})
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Status != StatusPending {
t.Fatalf("status = %q, want pending (no router connection supports movie)", req.Status)
}
}
func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
// The repo decrypts api_key_ref on read, so the connection carries the literal
// key here (no host-side secret resolution).
store.integrations = []Integration{autoApproveRouterInst("router-1", "radarr-key")}
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Status != StatusQueued || req.ExternalID != "ext-1080p" {
t.Fatalf("request = %+v, want queued with router external id", req)
}
if router.fulfillCalls != 1 {
t.Fatalf("fulfill calls = %d, want 1", router.fulfillCalls)
}
// The plaintext credential is resolved before dispatch and handed to the provider.
if len(router.gotConns) != 1 || router.gotConns[0].APIKey != "radarr-key" {
t.Fatalf("router connections = %+v, want resolved api key", router.gotConns)
}
}
func TestCreateRequestSubmissionFailureMarksFailed(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
store.integrations = []Integration{autoApproveRouterInst("router-1", "radarr-key")}
// A provider that creates no targets (e.g. no radarr instance) returns its own
// message; the host marks the request failed with it.
service := newTestService(store)
service.SetRouterProvider(&fakeRouterProvider{noTargets: true, fulfillMsg: "radarr unavailable"})
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Outcome != OutcomeFailed || req.LastError != "radarr unavailable" {
t.Fatalf("request = %+v, want failed outcome with provider message", req)
}
}
func TestCreateRequestEnrichesSeriesTVDBID(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
tmdbClient := &fakeTMDBClient{externalIDs: &tmdb.ExternalIDs{TVDBID: 12345}}
service := newTestServiceWithTMDB(store, tmdbClient)
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeSeries,
TMDBID: 1399,
Title: "Game of Thrones",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if len(store.created) != 1 {
t.Fatalf("created requests = %d, want 1", len(store.created))
}
if store.created[0].Input.TVDBID == nil || *store.created[0].Input.TVDBID != 12345 {
t.Fatalf("tvdb_id = %v, want 12345", store.created[0].Input.TVDBID)
}
}
func TestListMineAttachesTargets(t *testing.T) {
store := newFakeStore()
store.mine = []*Request{{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
RequestedByUserID: 1,
}}
store.targets = map[string][]Target{
"req-1": {{
ID: 10,
RequestID: "req-1",
Quality: Quality2160p,
Status: StatusQueued,
}},
}
got, err := newTestService(store).ListMine(context.Background(), testViewer(1), ListFilter{})
if err != nil {
t.Fatalf("ListMine returned error: %v", err)
}
if len(got) != 1 {
t.Fatalf("ListMine returned %d requests, want 1", len(got))
}
if len(got[0].Targets) != 1 || got[0].Targets[0].Quality != Quality2160p {
t.Fatalf("targets = %+v, want attached 2160p target", got[0].Targets)
}
}
func TestListMineAttachesLibraryContentID(t *testing.T) {
store := newFakeStore()
store.mine = []*Request{{
ID: "req-1",
Provider: "tmdb",
MediaType: MediaTypeMovie,
TMDBID: 42,
Title: "Test Movie",
Status: StatusCompleted,
Outcome: OutcomeActive,
RequestedByUserID: 1,
}}
presence := &fakePresence{available: map[MediaType]map[int]bool{
MediaTypeMovie: {42: true},
}}
got, err := NewService(store, &fakeTMDBClient{}, presence).ListMine(context.Background(), testViewer(1), ListFilter{})
if err != nil {
t.Fatalf("ListMine returned error: %v", err)
}
if len(got) != 1 {
t.Fatalf("ListMine returned %d requests, want 1", len(got))
}
if got[0].LibraryContentID != "movie-42" {
t.Fatalf("library content id = %q, want movie-42", got[0].LibraryContentID)
}
}
func TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
tmdbClient := &fakeTMDBClient{externalIDs: &tmdb.ExternalIDs{TVDBID: 420105, IMDbID: "tt18076310"}}
presence := &fakePresence{byTVDB: map[MediaType]map[int]int{
MediaTypeSeries: {420105: 201992},
}}
service := NewService(store, tmdbClient, presence)
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeSeries,
TMDBID: 201992,
Title: "The Rookie: Feds",
})
if !errors.Is(err, ErrAlreadyAvailable) {
t.Fatalf("err = %v, want ErrAlreadyAvailable", err)
}
if len(store.created) != 0 {
t.Fatalf("created requests = %d, want 0", len(store.created))
}
}
func TestCreateRequestNoActiveDuplicateCreatesRequest(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
service := newTestService(store)
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Status != StatusPending {
t.Fatalf("status = %q, want pending", req.Status)
}
if len(store.created) != 1 {
t.Fatalf("created requests = %d, want 1", len(store.created))
}
}
func TestCreateRequestClearsPriorFailedRequest(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.requests["req-prior-failed"] = &Request{
ID: "req-prior-failed",
MediaType: MediaTypeMovie,
TMDBID: 550,
Outcome: OutcomeFailed,
Status: StatusApproved,
LastError: "arr: decode response: json: cannot unmarshal object into Go value of type []radarr.movieResource",
}
store.requests["req-other-media-failed"] = &Request{
ID: "req-other-media-failed",
MediaType: MediaTypeMovie,
TMDBID: 999,
Outcome: OutcomeFailed,
}
service := newTestService(store)
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if _, ok := store.requests["req-prior-failed"]; ok {
t.Fatal("prior failed request was not cleared")
}
if _, ok := store.requests["req-other-media-failed"]; !ok {
t.Fatal("failed request for different media should not be cleared")
}
}
func TestSearchMarksSeriesAvailableByHydratedTVDBID(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
tmdbClient := &fakeTMDBClient{
page: &tmdb.MediaPage{
Page: 1,
Results: []tmdb.MediaResult{{
ID: 201992,
MediaType: "series",
Title: "The Rookie: Feds",
Year: 2022,
}},
},
externalIDsByID: map[int]*tmdb.ExternalIDs{
201992: {TVDBID: 420105, IMDbID: "tt18076310"},
},
}
presence := &fakePresence{byTVDB: map[MediaType]map[int]int{
MediaTypeSeries: {420105: 201992},
}}
service := NewService(store, tmdbClient, presence)
page, err := service.Search(context.Background(), testViewer(1), "rookie feds", MediaTypeSeries, 1)
if err != nil {
t.Fatalf("Search returned error: %v", err)
}
if got := page.Results[0].Availability; got != AvailabilityAvailable {
t.Fatalf("availability = %q, want available", got)
}
if got := page.Results[0].LibraryContentID; got != "series-201992" {
t.Fatalf("library content id = %q, want series-201992", got)
}
if page.Results[0].Request.Reason != "already_available" {
t.Fatalf("request reason = %q, want already_available", page.Results[0].Request.Reason)
}
if len(presence.got) != 1 || presence.got[0].TVDBID == nil || *presence.got[0].TVDBID != 420105 {
t.Fatalf("presence candidates = %+v, want hydrated tvdb id", presence.got)
}
}
func TestSearchWithNilPresenceDoesNotHydrateExternalIDs(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{Results: []tmdb.MediaResult{{
ID: 201992,
MediaType: "series",
Title: "The Rookie: Feds",
}}}}
service := NewService(store, tmdbClient, nil)
_, err := service.Search(context.Background(), testViewer(1), "rookie feds", MediaTypeSeries, 1)
if err != nil {
t.Fatalf("Search returned error: %v", err)
}
if len(tmdbClient.externalIDCalls) != 0 {
t.Fatalf("external ID calls = %v, want none", tmdbClient.externalIDCalls)
}
}
func TestSearchHydratesMultipleResultsBeforePresenceLookup(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
tmdbClient := &fakeTMDBClient{
page: &tmdb.MediaPage{Results: []tmdb.MediaResult{
{ID: 201992, MediaType: "series", Title: "The Rookie: Feds"},
{ID: 1399, MediaType: "series", Title: "Game of Thrones"},
}},
externalIDsByID: map[int]*tmdb.ExternalIDs{
201992: {TVDBID: 420105, IMDbID: "tt18076310"},
1399: {TVDBID: 121361, IMDbID: "tt0944947"},
},
}
presence := &fakePresence{}
service := NewService(store, tmdbClient, presence)
_, err := service.Search(context.Background(), testViewer(1), "series", MediaTypeSeries, 1)
if err != nil {
t.Fatalf("Search returned error: %v", err)
}
if len(presence.got) != 2 {
t.Fatalf("presence candidates = %d, want 2", len(presence.got))
}
got := map[int]int{}
for _, candidate := range presence.got {
if candidate.TVDBID != nil {
got[candidate.TMDBID] = *candidate.TVDBID
}
}
if got[201992] != 420105 || got[1399] != 121361 {
t.Fatalf("hydrated tvdb ids = %+v", got)
}
}
func TestSearchEnrichmentHidesOtherRequesterID(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.active[MediaTypeMovie][550] = &Request{
ID: "req-existing",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
RequestedByUserID: 2,
}
tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{
Page: 1,
TotalPages: 1,
TotalResults: 1,
Results: []tmdb.MediaResult{{
ID: 550,
MediaType: "movie",
Title: "Fight Club",
Year: 1999,
}},
}}
service := newTestServiceWithTMDB(store, tmdbClient)
result, err := service.Search(context.Background(), testViewer(1), "fight", MediaTypeMovie, 1)
if err != nil {
t.Fatalf("Search returned error: %v", err)
}
if len(result.Results) != 1 {
t.Fatalf("results = %d, want 1", len(result.Results))
}
state := result.Results[0].Request
if state.Status != StatusQueued || state.Requestable {
t.Fatalf("state = %+v, want queued non-requestable", state)
}
if state.RequestID != "" {
t.Fatalf("request id leaked as %q", state.RequestID)
}
}
func TestSearchEnrichmentShowsOwnRequestID(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.active[MediaTypeMovie][550] = &Request{
ID: "req-existing",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
RequestedByUserID: 2,
}
tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{
Page: 1,
TotalPages: 1,
TotalResults: 1,
Results: []tmdb.MediaResult{{
ID: 550,
MediaType: "movie",
Title: "Fight Club",
}},
}}
service := newTestServiceWithTMDB(store, tmdbClient)
result, err := service.Search(context.Background(), testViewer(2), "fight", MediaTypeMovie, 1)
if err != nil {
t.Fatalf("Search returned error: %v", err)
}
if got := result.Results[0].Request.RequestID; got != "req-existing" {
t.Fatalf("request id = %q, want req-existing", got)
}
}
func TestSearchWithoutMediaTypeSearchesMoviesAndSeries(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.active[MediaTypeSeries][1399] = &Request{
ID: "req-series",
MediaType: MediaTypeSeries,
TMDBID: 1399,
Status: StatusQueued,
Outcome: OutcomeActive,
RequestedByUserID: 1,
}
tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{
Page: 1,
TotalPages: 1,
TotalResults: 2,
Results: []tmdb.MediaResult{
{
ID: 550,
MediaType: "movie",
Title: "Fight Club",
},
{
ID: 1399,
MediaType: "series",
Title: "Fight Club: The Series",
},
},
}}
service := newTestServiceWithTMDB(store, tmdbClient)
result, err := service.Search(context.Background(), testViewer(1), "fight", "", 1)
if err != nil {
t.Fatalf("Search returned error: %v", err)
}
if tmdbClient.searchMediaType != "all" {
t.Fatalf("search media type = %q, want all", tmdbClient.searchMediaType)
}
if len(result.Results) != 2 {
t.Fatalf("results = %d, want 2", len(result.Results))
}
if result.Results[0].MediaType != MediaTypeMovie {
t.Fatalf("results[0].MediaType = %q, want movie", result.Results[0].MediaType)
}
if result.Results[1].MediaType != MediaTypeSeries {
t.Fatalf("results[1].MediaType = %q, want series", result.Results[1].MediaType)
}
if result.Results[1].Request.RequestID != "req-series" {
t.Fatalf("series request id = %q, want req-series", result.Results[1].Request.RequestID)
}
}
func TestDisabledRequestsBlockUserSurfaces(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = false
store.requests["req-1"] = &Request{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusPending,
Outcome: OutcomeActive,
RequestedByUserID: 1,
}
tmdbClient := &fakeTMDBClient{
page: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ID: 550, MediaType: "movie", Title: "Fight Club"}}},
detail: &tmdb.MediaDetail{ID: 550, MediaType: "movie", Title: "Fight Club"},
discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ID: 550, MediaType: "movie", Title: "Fight Club"}}},
}
service := newTestServiceWithTMDB(store, tmdbClient)
viewer := testViewer(1)
cases := []struct {
name string
call func() error
}{
{"search", func() error {
_, err := service.Search(context.Background(), viewer, "fight", MediaTypeMovie, 1)
return err
}},
{"discover all", func() error {
_, err := service.DiscoverAll(context.Background(), viewer)
return err
}},
{"discover section", func() error {
_, err := service.Discover(context.Background(), viewer, "popular_movies", 1)
return err
}},
{"detail", func() error {
_, err := service.GetDetail(context.Background(), viewer, MediaTypeMovie, 550)
return err
}},
{"create", func() error {
_, err := service.CreateRequest(context.Background(), viewer, CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
return err
}},
{"mine", func() error {
_, err := service.ListMine(context.Background(), viewer, ListFilter{})
return err
}},
{"get", func() error {
_, err := service.GetRequest(context.Background(), viewer, "req-1")
return err
}},
{"cancel", func() error {
_, err := service.Cancel(context.Background(), viewer, "req-1", "")
return err
}},
{"studios", func() error {
_, err := service.ListStudios(context.Background(), viewer)
return err
}},
{"networks", func() error {
_, err := service.ListNetworks(context.Background(), viewer)
return err
}},
{"genres", func() error {
_, err := service.ListGenres(context.Background(), viewer)
return err
}},
{"browse studio", func() error {
_, err := service.BrowseStudio(context.Background(), viewer, "marvel-studios", "popularity", 1)
return err
}},
{"browse network", func() error {
_, err := service.BrowseNetwork(context.Background(), viewer, "netflix", "popularity", 1)
return err
}},
{"browse genre", func() error {
_, err := service.BrowseGenre(context.Background(), viewer, "action", MediaTypeMovie, "popularity", 1)
return err
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := tc.call(); !errors.Is(err, ErrRequestsDisabled) {
t.Fatalf("err = %v, want ErrRequestsDisabled", err)
}
})
}
}
func TestReconcileRequestsCompletesFromCatalogPresence(t *testing.T) {
store := newFakeStore()
store.candidates = []*Request{{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
}}
service := NewService(store, &fakeTMDBClient{}, &fakePresence{available: map[MediaType]map[int]bool{
MediaTypeMovie: {550: true},
}})
result, err := service.ReconcileRequests(context.Background(), 100)
if err != nil {
t.Fatalf("ReconcileRequests returned error: %v", err)
}
if result.Completed != 1 || len(store.statusUpdates) != 1 || store.statusUpdates[0] != StatusCompleted {
t.Fatalf("result = %+v statusUpdates = %+v, want one completed update", result, store.statusUpdates)
}
}
func TestReconcileRequestsCompletesByStoredTVDBID(t *testing.T) {
store := newFakeStore()
tvdbID := 420105
store.candidates = []*Request{{
ID: "req-1",
MediaType: MediaTypeSeries,
TMDBID: 201992,
TVDBID: &tvdbID,
Status: StatusQueued,
Outcome: OutcomeActive,
}}
presence := &fakePresence{byTVDB: map[MediaType]map[int]int{
MediaTypeSeries: {420105: 201992},
}}
service := NewService(store, &fakeTMDBClient{}, presence)
result, err := service.ReconcileRequests(context.Background(), 100)
if err != nil {
t.Fatalf("ReconcileRequests returned error: %v", err)
}
if result.Completed != 1 {
t.Fatalf("completed = %d, want 1", result.Completed)
}
}
func TestReconcileRequestsMarksDownloadingFromProvider(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
store.candidates = []*Request{{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
}}
// Reconcile drives status per-target via the provider; seed a queued target.
store.requests["req-1"] = &Request{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusQueued, Outcome: OutcomeActive}
if _, err := store.CreateTarget(context.Background(), Target{
RequestID: "req-1", IntegrationID: "router-1",
Quality: Quality1080p, Status: StatusQueued, ExternalID: "123",
}); err != nil {
t.Fatalf("seed target: %v", err)
}
router := &fakeRouterProvider{statuses: []RouterTargetStatus{{
Quality: Quality1080p,
ConnectionID: "router-1",
Status: StatusDownloading,
ExternalStatus: "downloading",
}}}
service := newTestService(store)
service.SetRouterProvider(router)
result, err := service.ReconcileRequests(context.Background(), 100)
if err != nil {
t.Fatalf("ReconcileRequests returned error: %v", err)
}
if result.Downloading != 1 || len(store.statusUpdates) != 1 || store.statusUpdates[0] != StatusDownloading {
t.Fatalf("result = %+v statusUpdates = %+v, want one downloading update", result, store.statusUpdates)
}
if router.statusCalls != 1 {
t.Fatalf("provider status calls = %d, want 1", router.statusCalls)
}
}
func TestReconcileRequestsPreservesProviderFailureMessage(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
store.candidates = []*Request{{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
}}
store.requests["req-1"] = &Request{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusQueued, Outcome: OutcomeActive}
if _, err := store.CreateTarget(context.Background(), Target{
RequestID: "req-1", IntegrationID: "router-1",
Quality: Quality1080p, Status: StatusQueued, ExternalID: "123",
}); err != nil {
t.Fatalf("seed target: %v", err)
}
router := &fakeRouterProvider{statuses: []RouterTargetStatus{{
Quality: Quality1080p,
ConnectionID: "router-1",
Status: StatusFailed,
ExternalStatus: "failed",
Message: "indexer rejected the request",
}}}
service := newTestService(store)
service.SetRouterProvider(router)
result, err := service.ReconcileRequests(context.Background(), 100)
if err != nil {
t.Fatalf("ReconcileRequests returned error: %v", err)
}
if result.Failed != 1 {
t.Fatalf("result = %+v, want one failed target update", result)
}
targets, _ := store.ListTargets(context.Background(), "req-1")
if len(targets) != 1 || targets[0].LastError != "indexer rejected the request" {
t.Fatalf("targets = %+v, want provider failure message preserved", targets)
}
}
func TestReconcileRequestsResolvesGlobalInputsOncePerCycle(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")} // APIKeyRef "key-router-1"
// Three approved candidates that all share the same router connection. Each
// one drives submitApprovedRequest, which resolves the router connection.
// Without per-cycle caching this would fetch integrations/settings once per
// request.
for _, id := range []string{"req-1", "req-2", "req-3"} {
req := &Request{ID: id, MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusApproved, Outcome: OutcomeActive}
store.candidates = append(store.candidates, req)
store.requests[id] = req
}
service := newTestService(store)
service.SetRouterProvider(&fakeRouterProvider{})
result, err := service.ReconcileRequests(context.Background(), 100)
if err != nil {
t.Fatalf("ReconcileRequests returned error: %v", err)
}
if result.Checked != 3 || result.Submitted != 3 {
t.Fatalf("result = %+v, want 3 checked / 3 submitted", result)
}
// Integrations and settings are fetched once per cycle (the connection's key is
// already decrypted by the repo on read, so there is nothing to re-resolve).
if store.listIntegrationsCalls != 1 {
t.Fatalf("ListIntegrations calls = %d, want 1 per cycle", store.listIntegrationsCalls)
}
if store.getSettingsCalls != 1 {
t.Fatalf("GetSettings calls = %d, want 1 per cycle", store.getSettingsCalls)
}
}
func TestDeleteIntegrationRejectsLiveTargets(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{{
ID: "radarr-hd",
Enabled: true,
}}
store.targets = map[string][]Target{
"req-1": {{
ID: 10,
RequestID: "req-1",
IntegrationID: "radarr-hd",
Quality: Quality1080p,
Status: StatusDownloading,
}},
}
err := newTestService(store).DeleteIntegration(
context.Background(),
Viewer{UserID: 1, IsAdmin: true},
"radarr-hd",
)
if !errors.Is(err, ErrInvalidState) {
t.Fatalf("err = %v, want ErrInvalidState", err)
}
if len(store.integrations) != 1 {
t.Fatalf("integrations = %d, want delete blocked", len(store.integrations))
}
}
func TestCreateIntegrationRejectedByPluginValidate(t *testing.T) {
store := newFakeStore()
service := newTestService(store)
service.SetRouterProvider(&fakeRouterProvider{
validateFieldErrors: map[string]string{"root_folder": "root folder does not exist"},
validateFormError: "connection invalid",
})
install := 1
_, err := service.CreateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{
Name: "radarr",
CapabilityID: "arr",
BaseURL: "http://radarr.local",
InstallationID: &install,
})
if err == nil {
t.Fatal("expected validation error from plugin Validate")
}
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want *ValidationError", err)
}
if ve.FieldErrors["root_folder"] != "root folder does not exist" {
t.Fatalf("field errors = %+v, want root_folder error", ve.FieldErrors)
}
if ve.FormError != "connection invalid" {
t.Fatalf("form error = %q, want connection invalid", ve.FormError)
}
if len(store.integrations) != 0 {
t.Fatalf("integrations = %d, want 0 (rejected before persist)", len(store.integrations))
}
}
// TestValidateInstanceRequiresCapabilitySubID locks the capability_id contract:
// the column carries the capability SUB-ID ("arr"/"seerr"), matching the value
// the host passes to pluginhost.Client.RequestRouter -> requireCapability, which
// keys on (type, id). The capability TYPE ("request_router.v1") must NOT be
// accepted or defaulted in, since requireCapability("request_router.v1",
// "request_router.v1") never matches a plugin whose capability id is "arr".
func TestValidateInstanceRequiresCapabilitySubID(t *testing.T) {
install := 1
if err := validateInstance(&Integration{Name: "radarr", CapabilityID: "arr", InstallationID: &install}); err != nil {
t.Fatalf("validateInstance(sub-id \"arr\") = %v, want nil", err)
}
if err := validateInstance(&Integration{Name: "radarr", CapabilityID: "", InstallationID: &install}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("validateInstance(empty capability) = %v, want ErrInvalidInput", err)
}
}
// TestCreateIntegrationPassesCapabilitySubIDToPlugin guards that the sub-id the
// admin selected reaches the router provider verbatim (it used to be rewritten to
// the capability type, which the plugin runtime could never resolve).
func TestCreateIntegrationPassesCapabilitySubIDToPlugin(t *testing.T) {
store := newFakeStore()
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
install := 1
if _, err := service.CreateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{
Name: "radarr",
CapabilityID: "arr",
BaseURL: "http://radarr.local",
APIKeyRef: "key-radarr",
InstallationID: &install,
}); err != nil {
t.Fatalf("CreateIntegration err = %v, want nil", err)
}
if router.gotValidateCapability != "arr" {
t.Fatalf("plugin Validate capability = %q, want \"arr\"", router.gotValidateCapability)
}
}
// TestUpdateIntegrationRefusesStoredKeyReuseOnChangedBaseURL covers the security
// hardening: when the caller leaves api_key_ref blank ("keep saved key") but
// changes the base_url, the service must refuse rather than pair the stored,
// API-unreadable key with the new (potentially attacker-controlled) URL. The
// plugin Validate is never called and nothing is persisted.
func TestUpdateIntegrationRefusesStoredKeyReuseOnChangedBaseURL(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")} // BaseURL http://router-1.local, APIKeyRef key-router-1
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
install := 1
_, err := service.UpdateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{
ID: "router-1",
Name: "router-1",
CapabilityID: "arr",
BaseURL: "http://attacker.example", // changed from the stored base URL
APIKeyRef: "", // blank -> "keep saved key"
InstallationID: &install,
})
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want *ValidationError", err)
}
if ve.FieldErrors["api_key_ref"] == "" {
t.Fatalf("field errors = %+v, want api_key_ref message", ve.FieldErrors)
}
if router.validateCalls != 0 {
t.Fatalf("plugin Validate calls = %d, want 0 (refused before dispatch)", router.validateCalls)
}
// Stored row must be unchanged (not persisted with the new URL).
if got := store.integrations[0].BaseURL; got != "http://router-1.local" {
t.Fatalf("stored base_url = %q, want unchanged http://router-1.local", got)
}
}
// TestUpdateIntegrationKeepsKeyWhenBaseURLUnchanged confirms the normal
// edit-keeping-key flow still works: blank api_key_ref with an unchanged (or
// blank) base_url backfills the stored key, calls the plugin Validate, and
// persists.
func TestUpdateIntegrationKeepsKeyWhenBaseURLUnchanged(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
install := 1
updated, err := service.UpdateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{
ID: "router-1",
Name: "router-1-renamed",
CapabilityID: "arr",
BaseURL: "http://router-1.local", // unchanged
APIKeyRef: "", // blank -> keep saved key
InstallationID: &install,
})
if err != nil {
t.Fatalf("UpdateIntegration err = %v, want nil", err)
}
if router.validateCalls != 1 {
t.Fatalf("plugin Validate calls = %d, want 1", router.validateCalls)
}
if updated == nil || updated.Name != "router-1-renamed" {
t.Fatalf("updated = %+v, want persisted name router-1-renamed", updated)
}
}
func TestLoadIntegrationOptionsDoesNotBackfillStoredKeyForChangedBaseURL(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
if _, err := service.LoadIntegrationOptions(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{
ID: "router-1",
BaseURL: "http://attacker.example",
}); err != nil {
t.Fatalf("LoadIntegrationOptions: %v", err)
}
if router.gotOptionsConn.APIKey != "" {
t.Fatalf("probe API key = %q, want empty for changed base URL", router.gotOptionsConn.APIKey)
}
if router.gotOptionsConn.BaseURL != "http://attacker.example" {
t.Fatalf("probe base URL = %q, want submitted URL", router.gotOptionsConn.BaseURL)
}
}
func TestLoadIntegrationOptionsBackfillsStoredKeyForSameBaseURL(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
if _, err := service.LoadIntegrationOptions(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{
ID: "router-1",
BaseURL: "http://router-1.local",
}); err != nil {
t.Fatalf("LoadIntegrationOptions: %v", err)
}
if router.gotOptionsConn.APIKey != "key-router-1" {
t.Fatalf("probe API key = %q, want stored key for unchanged base URL", router.gotOptionsConn.APIKey)
}
}
func TestCancelOwnerCanWithdrawPendingRequest(t *testing.T) {
store := newFakeStore()
store.requests["req-mine"] = &Request{
ID: "req-mine",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusPending,
Outcome: OutcomeActive,
RequestedByUserID: 7,
}
service := newTestService(store)
req, err := service.Cancel(context.Background(), Viewer{UserID: 7, ProfileID: "profile-1"}, "req-mine", "no longer want")
if err != nil {
t.Fatalf("Cancel returned error: %v", err)
}
if req.Outcome != OutcomeCancelled {
t.Fatalf("Outcome = %q, want cancelled", req.Outcome)
}
}
func TestCancelNonOwnerForbidden(t *testing.T) {
store := newFakeStore()
store.requests["req-someone-else"] = &Request{
ID: "req-someone-else",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusPending,
Outcome: OutcomeActive,
RequestedByUserID: 7,
}
service := newTestService(store)
_, err := service.Cancel(context.Background(), Viewer{UserID: 8, ProfileID: "profile-2"}, "req-someone-else", "")
if !errors.Is(err, ErrForbidden) {
t.Fatalf("err = %v, want ErrForbidden", err)
}
}
func TestCancelAdminCanCancelAnyPending(t *testing.T) {
store := newFakeStore()
store.requests["req-other"] = &Request{
ID: "req-other",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusPending,
Outcome: OutcomeActive,
RequestedByUserID: 7,
}
service := newTestService(store)
_, err := service.Cancel(context.Background(), Viewer{UserID: 99, IsAdmin: true}, "req-other", "house cleaning")
if err != nil {
t.Fatalf("admin Cancel returned error: %v", err)
}
}
func TestCancelRejectsRequestsAlreadyInFulfillment(t *testing.T) {
cases := []struct {
name string
req Request
}{
{"approved", Request{Status: StatusApproved, Outcome: OutcomeActive}},
{"queued", Request{Status: StatusQueued, Outcome: OutcomeActive, IntegrationKind: "radarr", ExternalID: "42"}},
{"downloading", Request{Status: StatusDownloading, Outcome: OutcomeActive, IntegrationKind: "radarr", ExternalID: "42"}},
{"completed", Request{Status: StatusCompleted, Outcome: OutcomeActive}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
store := newFakeStore()
tc.req.ID = "req-x"
tc.req.RequestedByUserID = 7
store.requests["req-x"] = &tc.req
service := newTestService(store)
_, err := service.Cancel(context.Background(), Viewer{UserID: 7, ProfileID: "profile-1"}, "req-x", "")
if !errors.Is(err, ErrInvalidState) {
t.Fatalf("err = %v, want ErrInvalidState for %s", err, tc.name)
}
})
}
}
func TestDeclineRejectsApprovedRequests(t *testing.T) {
store := newFakeStore()
store.requests["req-approved"] = &Request{
ID: "req-approved",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusApproved,
Outcome: OutcomeActive,
}
service := newTestService(store)
_, err := service.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-approved", "changed mind")
if !errors.Is(err, ErrInvalidState) {
t.Fatalf("err = %v, want ErrInvalidState (approved is owned by the reconciler)", err)
}
}
func TestDeclineRejectsQueuedRequests(t *testing.T) {
store := newFakeStore()
store.requests["req-1"] = &Request{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
IntegrationKind: "radarr",
ExternalID: "42",
}
service := newTestService(store)
_, err := service.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1", "not needed")
if !errors.Is(err, ErrInvalidState) {
t.Fatalf("err = %v, want ErrInvalidState", err)
}
}
func TestRetryResubmitsFailedQueuedRequest(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
store.requests["req-1"] = &Request{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeFailed,
}
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
req, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1")
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if router.fulfillCalls != 1 {
t.Fatalf("fulfill calls = %d, want 1", router.fulfillCalls)
}
// Retry transitions the failed request back to approved and re-dispatches via
// the router; the request aggregate returns to queued with the new external id.
if req.Status != StatusQueued || req.ExternalID != "ext-1080p" {
t.Fatalf("request = %+v, want re-queued with router external id", req)
}
}
func newTestService(store *fakeStore) *Service {
return newTestServiceWithTMDB(store, &fakeTMDBClient{})
}
func newTestServiceWithTMDB(store *fakeStore, tmdbClient *fakeTMDBClient) *Service {
service := NewService(store, tmdbClient, &fakePresence{})
service.Now = func() time.Time { return time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) }
return service
}
func testViewer(userID int) Viewer {
return Viewer{UserID: userID, ProfileID: "profile-1"}
}
type fakeStore struct {
mu sync.Mutex
settings Settings
limit *UserLimit
count int
active map[MediaType]map[int]*Request
created []CreateRequestRecord
integrations []Integration
candidates []*Request
mine []*Request
statusUpdates []Status
requests map[string]*Request
targets map[string][]Target
targetSeq int64
listIntegrationsCalls int
getSettingsCalls int
}
func newFakeStore() *fakeStore {
return &fakeStore{
settings: Settings{
RequestsEnabled: true,
GlobalMaxRequests: 5,
GlobalWindowDays: 7,
},
active: map[MediaType]map[int]*Request{
MediaTypeMovie: {},
MediaTypeSeries: {},
},
requests: map[string]*Request{},
}
}
func (f *fakeStore) GetSettings(context.Context) (Settings, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.getSettingsCalls++
return f.settings, nil
}
func (f *fakeStore) UpdateSettings(_ context.Context, settings Settings) (Settings, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.settings = settings
return settings, nil
}
func (f *fakeStore) GetUserLimit(context.Context, int) (*UserLimit, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.limit, nil
}
func (f *fakeStore) UpsertUserLimit(_ context.Context, limit UserLimit) (*UserLimit, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.limit = &limit
return &limit, nil
}
func (f *fakeStore) CountUserRequestsSince(_ context.Context, userID int, since time.Time) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
used := f.count
for _, prior := range f.created {
if prior.Requester.UserID != userID {
continue
}
if prior.Now.Before(since) {
continue
}
used++
}
return used, nil
}
func (f *fakeStore) ListActiveByTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := map[int]*Request{}
for _, id := range ids {
if req := f.active[mediaType][id]; req != nil {
out[id] = req
}
}
return out, nil
}
func (f *fakeStore) DeleteFailedByTMDB(_ context.Context, mediaType MediaType, tmdbID int) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
deleted := 0
for id, req := range f.requests {
if req.MediaType == mediaType && req.TMDBID == tmdbID && req.Outcome == OutcomeFailed {
delete(f.requests, id)
deleted++
}
}
return deleted, nil
}
func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) (*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
if input.Quota != nil {
used := f.count
for _, prior := range f.created {
if prior.Requester.UserID != input.Quota.UserID {
continue
}
if prior.Now.Before(input.Quota.WindowStart) {
continue
}
used++
}
if used >= input.Quota.MaxRequests {
return nil, ErrQuotaExceeded
}
}
f.created = append(f.created, input)
req := &Request{
ID: input.ID,
Provider: "tmdb",
MediaType: input.Input.MediaType,
TMDBID: input.Input.TMDBID,
TVDBID: input.Input.TVDBID,
IMDbID: input.Input.IMDbID,
Title: input.Input.Title,
Status: input.Status,
Outcome: input.Outcome,
IsAnime: input.IsAnime,
RequestedByUserID: input.Requester.UserID,
RequestedByProfileID: input.Requester.ProfileID,
CreatedAt: input.Now,
UpdatedAt: input.Now,
}
f.requests[input.ID] = req
copy := *req
return &copy, nil
}
func (f *fakeStore) GetRequest(_ context.Context, id string) (*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
req := f.requests[strings.TrimSpace(id)]
if req == nil {
return nil, ErrNotFound
}
copy := *req
return &copy, nil
}
func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.candidates, nil
}
func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]*Request(nil), f.mine...), nil
}
func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) {
return nil, nil
}
func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Viewer) (*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.statusUpdates = append(f.statusUpdates, status)
req := f.requests[id]
if req == nil {
req = &Request{ID: id, Outcome: OutcomeActive}
f.requests[id] = req
}
req.Status = status
copy := *req
return &copy, nil
}
func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
req := f.requests[id]
if req == nil {
req = &Request{ID: id}
f.requests[id] = req
}
req.Outcome = outcome
req.LastError = message
copy := *req
return &copy, nil
}
func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.listIntegrationsCalls++
return f.integrations, nil
}
func (f *fakeStore) GetIntegration(_ context.Context, id string) (*Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.integrations {
if f.integrations[i].ID == id {
cp := f.integrations[i]
return &cp, nil
}
}
return nil, ErrNotFound
}
func (f *fakeStore) CreateIntegration(_ context.Context, in Integration) (*Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.integrations = append(f.integrations, in)
cp := in
return &cp, nil
}
func (f *fakeStore) UpdateIntegration(_ context.Context, in Integration) (*Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.integrations {
if f.integrations[i].ID == in.ID {
f.integrations[i] = in
cp := in
return &cp, nil
}
}
return nil, ErrNotFound
}
func (f *fakeStore) SaveIntegrationWithDefaults(_ context.Context, in Integration, isCreate bool) (*Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
if isCreate {
f.integrations = append(f.integrations, in)
cp := in
return &cp, nil
}
for i := range f.integrations {
if f.integrations[i].ID == in.ID {
f.integrations[i] = in
cp := in
return &cp, nil
}
}
return nil, ErrNotFound
}
func (f *fakeStore) DeleteIntegration(_ context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, targets := range f.targets {
for _, target := range targets {
if target.IntegrationID == id && (target.Status == StatusQueued || target.Status == StatusDownloading) {
return ErrInvalidState
}
}
}
for i := range f.integrations {
if f.integrations[i].ID == id {
f.integrations = append(f.integrations[:i], f.integrations[i+1:]...)
return nil
}
}
return ErrNotFound
}
func (f *fakeStore) ListTargets(_ context.Context, requestID string) ([]Target, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]Target(nil), f.targets[requestID]...), nil
}
func (f *fakeStore) CreateTarget(_ context.Context, t Target) (Target, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.targets == nil {
f.targets = map[string][]Target{}
}
f.targetSeq++
t.ID = f.targetSeq
f.targets[t.RequestID] = append(f.targets[t.RequestID], t)
return t, nil
}
func (f *fakeStore) DeleteTarget(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
for rid, ts := range f.targets {
for i := range ts {
if ts[i].ID == id {
f.targets[rid] = append(ts[:i], ts[i+1:]...)
return nil
}
}
}
return ErrNotFound
}
func (f *fakeStore) UpdateTargetStatus(_ context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, _ Viewer) (*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
var requestID string
for rid, ts := range f.targets {
for i := range ts {
if ts[i].ID == targetID {
if externalID != "" {
f.targets[rid][i].ExternalID = externalID
}
if externalStatus != "" {
f.targets[rid][i].ExternalStatus = externalStatus
}
f.targets[rid][i].Status = status
f.targets[rid][i].LastError = lastErr
requestID = rid
}
}
}
if requestID == "" {
return nil, ErrNotFound
}
f.statusUpdates = append(f.statusUpdates, status)
st, outcome := aggregateStatus(f.targets[requestID])
req := f.requests[requestID]
if req == nil {
req = &Request{ID: requestID}
f.requests[requestID] = req
}
req.Status = st
req.Outcome = outcome
// Surface the first target's external identity on the request snapshot so
// existing assertions on req.ExternalID/IntegrationKind keep working.
for _, t := range f.targets[requestID] {
if t.ExternalID != "" {
req.ExternalID = t.ExternalID
req.ExternalStatus = t.ExternalStatus
req.IntegrationKind = t.IntegrationKind
break
}
}
if outcome == OutcomeFailed {
req.LastError = lastErr
}
copy := *req
return &copy, nil
}
func TestListStudiosReturnsBundleWithDuotoneLogos(t *testing.T) {
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
studios, err := service.ListStudios(context.Background(), testViewer(1))
if err != nil {
t.Fatalf("ListStudios: %v", err)
}
if len(studios) != len(BundledStudios) {
t.Fatalf("len = %d, want %d", len(studios), len(BundledStudios))
}
for _, s := range studios {
if s.LogoURL == nil || *s.LogoURL == "" {
t.Errorf("studio %q missing logo URL", s.Slug)
continue
}
if !strings.Contains(*s.LogoURL, "filter(duotone,ffffff,bababa)") {
t.Errorf("studio %q logo URL missing duotone filter: %s", s.Slug, *s.LogoURL)
}
}
}
func TestListNetworksReturnsBundleWithDuotoneLogos(t *testing.T) {
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
networks, err := service.ListNetworks(context.Background(), testViewer(1))
if err != nil {
t.Fatalf("ListNetworks: %v", err)
}
if len(networks) != len(BundledNetworks) {
t.Fatalf("len = %d, want %d", len(networks), len(BundledNetworks))
}
for _, n := range networks {
if n.LogoURL == nil || *n.LogoURL == "" {
t.Errorf("network %q missing logo URL", n.Slug)
continue
}
if !strings.Contains(*n.LogoURL, "filter(duotone,ffffff,bababa)") {
t.Errorf("network %q logo URL missing duotone filter: %s", n.Slug, *n.LogoURL)
}
}
}
func TestListGenresReturnsBundleWithSeriesSupportFlag(t *testing.T) {
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
genres, err := service.ListGenres(context.Background(), testViewer(1))
if err != nil {
t.Fatalf("ListGenres: %v", err)
}
if len(genres) != len(BundledGenres) {
t.Fatalf("len = %d, want %d", len(genres), len(BundledGenres))
}
for _, g := range genres {
switch g.Slug {
case "action", "comedy", "drama", "sci-fi", "animation", "documentary":
if !g.SeriesSupported {
t.Errorf("%s should support series", g.Slug)
}
case "horror", "romance":
if g.SeriesSupported {
t.Errorf("%s should not support series", g.Slug)
}
}
if g.GradientFrom == "" || g.GradientTo == "" {
t.Errorf("%s missing gradient", g.Slug)
}
if g.LogoURL != nil {
t.Errorf("%s should not have a logo URL", g.Slug)
}
}
}
func TestBrowseStudioReturnsEnrichedMovies(t *testing.T) {
tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{
Page: 1,
TotalPages: 2,
TotalResults: 20,
Results: []tmdb.MediaResult{
{ID: 24428, MediaType: "movie", Title: "The Avengers", Year: 2012, Popularity: 100.5},
},
}}
service := newTestServiceWithTMDB(newFakeStore(), tmdbClient)
resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "popularity", 1)
if err != nil {
t.Fatalf("BrowseStudio: %v", err)
}
if resp.Kind != "studio" || resp.Slug != "marvel-studios" || resp.MediaType != MediaTypeMovie {
t.Errorf("resp = %+v", resp)
}
if resp.Page != 1 || resp.TotalPages != 2 {
t.Errorf("pagination = %d/%d", resp.Page, resp.TotalPages)
}
if len(resp.Results) != 1 || resp.Results[0].TMDBID != 24428 {
t.Errorf("results = %+v", resp.Results)
}
if resp.Results[0].Availability == "" {
t.Error("availability should be enriched")
}
}
func TestBrowseStudioUnknownSlugReturnsNotFound(t *testing.T) {
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
_, err := service.BrowseStudio(context.Background(), testViewer(1), "not-a-studio", "popularity", 1)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestBrowseStudioRejectsBadSort(t *testing.T) {
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
_, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "made-up-sort", 1)
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput", err)
}
}
func TestBrowseStudioDefaultsBlankSortToPopularity(t *testing.T) {
tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{}}}
service := newTestServiceWithTMDB(newFakeStore(), tmdbClient)
resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "", 1)
if err != nil {
t.Fatalf("BrowseStudio: %v", err)
}
if resp.Sort != "popularity" {
t.Errorf("sort = %q, want popularity (default)", resp.Sort)
}
}
func TestBrowseNetworkReturnsSeries(t *testing.T) {
tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{
Page: 1, TotalPages: 1, TotalResults: 1,
Results: []tmdb.MediaResult{
{ID: 1399, MediaType: "series", Title: "Game of Thrones", Year: 2011},
},
}}
service := newTestServiceWithTMDB(newFakeStore(), tmdbClient)
resp, err := service.BrowseNetwork(context.Background(), testViewer(1), "netflix", "popularity", 1)
if err != nil {
t.Fatalf("BrowseNetwork: %v", err)
}
if resp.MediaType != MediaTypeSeries {
t.Errorf("media_type = %q, want series", resp.MediaType)
}
}
func TestBrowseGenreRequiresMediaType(t *testing.T) {
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
_, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "", "popularity", 1)
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput", err)
}
}
func TestBrowseGenreSeriesRejectedWhenUnsupported(t *testing.T) {
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
_, err := service.BrowseGenre(context.Background(), testViewer(1), "horror", "series", "popularity", 1)
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput for horror+series", err)
}
}
func TestBrowseGenreMovieReturnsResults(t *testing.T) {
tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{
Results: []tmdb.MediaResult{{ID: 1, MediaType: "movie", Title: "Movie"}},
}}
service := newTestServiceWithTMDB(newFakeStore(), tmdbClient)
resp, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "movie", "popularity", 1)
if err != nil {
t.Fatalf("BrowseGenre: %v", err)
}
if resp.Kind != "genre" || resp.Slug != "action" || resp.MediaType != MediaTypeMovie {
t.Errorf("resp = %+v", resp)
}
}
type fakePresence struct {
mu sync.Mutex
available map[MediaType]map[int]bool
byTVDB map[MediaType]map[int]int
got []PresenceCandidate
}
func (f *fakePresence) Lookup(_ context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := map[int]PresenceMatch{}
f.got = append(f.got, candidates...)
for _, candidate := range candidates {
if f.available != nil && f.available[mediaType][candidate.TMDBID] {
out[candidate.TMDBID] = PresenceMatch{
Available: true,
ContentID: fakePresenceContentID(mediaType, candidate.TMDBID),
MatchedProvider: "tmdb",
}
continue
}
if candidate.TVDBID != nil && f.byTVDB != nil {
if tmdbID, ok := f.byTVDB[mediaType][*candidate.TVDBID]; ok && tmdbID == candidate.TMDBID {
out[candidate.TMDBID] = PresenceMatch{
Available: true,
ContentID: fakePresenceContentID(mediaType, candidate.TMDBID),
MatchedProvider: "tvdb",
}
}
}
}
return out, nil
}
func fakePresenceContentID(mediaType MediaType, tmdbID int) string {
return fmt.Sprintf("%s-%d", mediaType, tmdbID)
}
func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]bool, error) {
out := map[int]bool{}
for _, id := range ids {
matches, err := f.Lookup(context.Background(), mediaType, []PresenceCandidate{{TMDBID: id}})
if err != nil {
return nil, err
}
if matches[id].Available {
out[id] = true
}
}
return out, nil
}
type fakeTMDBClient struct {
mu sync.Mutex
page *tmdb.MediaPage
externalIDs *tmdb.ExternalIDs
externalIDsByID map[int]*tmdb.ExternalIDs
externalIDCalls []int
detail *tmdb.MediaDetail
discoverPage *tmdb.MediaPage
discoverErr error
searchMediaType string
}
func (f *fakeTMDBClient) SearchMedia(_ context.Context, mediaType, _ string, _ int) (*tmdb.MediaPage, error) {
f.searchMediaType = mediaType
return f.page, nil
}
func (f *fakeTMDBClient) DiscoverSection(context.Context, string, int) (*tmdb.MediaPage, error) {
return f.page, nil
}
func (f *fakeTMDBClient) DiscoverPage(context.Context, string, tmdb.DiscoverParams, int) (*tmdb.MediaPage, error) {
if f.discoverErr != nil {
return nil, f.discoverErr
}
if f.discoverPage != nil {
return f.discoverPage, nil
}
return &tmdb.MediaPage{Results: []tmdb.MediaResult{}}, nil
}
func (f *fakeTMDBClient) GetExternalIDs(_ context.Context, _ string, id int) (*tmdb.ExternalIDs, error) {
f.mu.Lock()
f.externalIDCalls = append(f.externalIDCalls, id)
f.mu.Unlock()
if f.externalIDsByID != nil {
return f.externalIDsByID[id], nil
}
return f.externalIDs, nil
}
func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.MediaDetail, error) {
return f.detail, nil
}
type fixedCeiling struct{ q string }
func (f fixedCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) {
return f.q, nil
}
// fakeRouterProvider is a canned RequestRouterProvider standing in for a
// request_router.v1 plugin. Fulfill emits one target per requested quality
// (unless noTargets is set), recording the qualities and connections it saw.
type fakeRouterProvider struct {
mu sync.Mutex
// Fulfill behavior.
noTargets bool
fulfillMsg string
fulfillErr error
targetsOverride []RouterTarget // when non-nil, Fulfill returns this verbatim
gotQualities []Quality
gotConns []ResolvedRouterConnection
gotInstallationID int
fulfillCalls int
gotRequesterEmail string
gotRequesterUsername string
// CheckStatus behavior.
statuses []RouterTargetStatus
statusErr error
statusCalls int
// ListConfigOptions behavior.
options map[string][]RouterOption
gotOptionsConn ResolvedRouterConnection
// Validate behavior (default empty = valid).
validateFieldErrors map[string]string
validateFormError string
validateErr error
validateCalls int
gotValidateCapability string
gotValidateSiblings []ResolvedRouterConnection
}
func (f *fakeRouterProvider) Fulfill(_ context.Context, installationID int, _ string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.gotRequesterEmail = req.RequesterEmail
f.gotRequesterUsername = req.RequesterUsername
f.fulfillCalls++
f.gotQualities = append(f.gotQualities, qualities...)
f.gotConns = conns
f.gotInstallationID = installationID
if f.fulfillErr != nil {
return nil, "", f.fulfillErr
}
if f.targetsOverride != nil {
return f.targetsOverride, f.fulfillMsg, nil
}
if f.noTargets {
return nil, f.fulfillMsg, nil
}
connID := ""
if len(conns) > 0 {
connID = conns[0].ID
}
out := make([]RouterTarget, 0, len(qualities))
for _, q := range qualities {
out = append(out, RouterTarget{
Quality: q,
ConnectionID: connID,
ExternalID: "ext-" + string(q),
ExternalStatus: "queued",
Status: StatusQueued,
})
}
return out, f.fulfillMsg, nil
}
func (f *fakeRouterProvider) CheckStatus(_ context.Context, _ int, _ string, _ Request, _ []RouterTargetRef, _ []ResolvedRouterConnection) ([]RouterTargetStatus, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.statusCalls++
return f.statuses, f.statusErr
}
func (f *fakeRouterProvider) ListConfigOptions(_ context.Context, _ int, _ string, conn ResolvedRouterConnection) (map[string][]RouterOption, error) {
f.mu.Lock()
f.gotOptionsConn = conn
f.mu.Unlock()
return f.options, nil
}
func (f *fakeRouterProvider) TestConnection(_ context.Context, _ int, _ string, _ ResolvedRouterConnection) (bool, string, error) {
return true, "", nil
}
func (f *fakeRouterProvider) Validate(_ context.Context, _ int, capabilityID string, _ ResolvedRouterConnection, siblings []ResolvedRouterConnection) (map[string]string, string, error) {
f.mu.Lock()
f.validateCalls++
f.gotValidateCapability = capabilityID
f.gotValidateSiblings = siblings
f.mu.Unlock()
return f.validateFieldErrors, f.validateFormError, f.validateErr
}
// routerInst builds an enabled request_router integration connection for tests.
func routerInst(id string) Integration {
return routerInstOn(id, 1)
}
// routerInstOn builds an enabled request_router connection bound to a specific
// installation id (for multi-installation isolation tests).
func routerInstOn(id string, installID int) Integration {
install := installID
return Integration{
ID: id,
Name: id,
Enabled: true,
BaseURL: "http://" + id + ".local",
APIKeyRef: "key-" + id,
CapabilityID: "arr",
InstallationID: &install,
}
}
// autoApproveRouterInst is a router connection that satisfies the auto-approval
// gate (integrationConfigured: an enabled request_router connection bound to an
// installation with a base URL and api key).
func autoApproveRouterInst(id, apiKeyRef string) Integration {
in := routerInst(id)
in.APIKeyRef = apiKeyRef
return in
}
func TestUpdateIntegrationPassesSiblingsToValidate(t *testing.T) {
store := newFakeStore()
inst := 1
a := routerInstOn("conn-a", inst)
b := routerInstOn("conn-b", inst)
b.PluginConfig = map[string]any{"service_kind": "radarr"}
store.integrations = []Integration{a, b}
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
if _, err := service.UpdateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{
ID: "conn-a",
Name: "conn-a",
CapabilityID: "arr",
BaseURL: "http://conn-a.local",
APIKeyRef: "key-conn-a",
InstallationID: &inst,
}); err != nil {
t.Fatalf("UpdateIntegration: %v", err)
}
if len(router.gotValidateSiblings) != 1 {
t.Fatalf("siblings = %d, want 1 (the other installation-1 connection)", len(router.gotValidateSiblings))
}
sib := router.gotValidateSiblings[0]
if sib.ID != "conn-b" {
t.Fatalf("sibling id = %q, want conn-b (self excluded)", sib.ID)
}
if sib.APIKey != "" || sib.BaseURL != "" {
t.Fatalf("sibling must carry no credentials, got APIKey=%q BaseURL=%q", sib.APIKey, sib.BaseURL)
}
if sib.Config["service_kind"] != "radarr" {
t.Fatalf("sibling config not passed: %+v", sib.Config)
}
}
func TestAllowedQualities(t *testing.T) {
svc := newTestService(newFakeStore())
t.Run("hd ceiling stays 1080p only", func(t *testing.T) {
svcHD := newTestService(newFakeStore())
svcHD.SetEntitlementResolver(fixedCeiling{q: "1080p"})
got := svcHD.allowedQualities(context.Background(), Request{}, Settings{})
if len(got) != 1 || got[0] != Quality1080p {
t.Fatalf("qualities = %v, want [1080p]", got)
}
})
t.Run("any/no-cap ceiling adds 2160p", func(t *testing.T) {
// A requester whose max playback quality is "Any" resolves to an empty
// (no-cap) ceiling. Empty means UNLIMITED, so 4K must be requested
// alongside 1080p — it must not be read as "below 4K".
svcAny := newTestService(newFakeStore())
svcAny.SetEntitlementResolver(fixedCeiling{q: ""})
got := svcAny.allowedQualities(context.Background(), Request{}, Settings{})
if len(got) != 2 || got[1] != Quality2160p {
t.Fatalf("qualities = %v, want [1080p 2160p]", got)
}
})
t.Run("force dual adds 2160p", func(t *testing.T) {
got := svc.allowedQualities(context.Background(), Request{}, Settings{ForceDualQuality: true})
if len(got) != 2 || got[1] != Quality2160p {
t.Fatalf("qualities = %v, want [1080p 2160p]", got)
}
})
t.Run("4k ceiling adds 2160p", func(t *testing.T) {
svc4k := newTestService(newFakeStore())
svc4k.SetEntitlementResolver(fixedCeiling{q: "2160p"})
got := svc4k.allowedQualities(context.Background(), Request{}, Settings{})
if len(got) != 2 || got[1] != Quality2160p {
t.Fatalf("qualities = %v, want [1080p 2160p]", got)
}
})
}
func TestSubmitApprovedFansOutDualQuality(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
router := &fakeRouterProvider{}
svc := NewService(store, &fakeTMDBClient{}, &fakePresence{})
svc.SetRouterProvider(router)
svc.SetEntitlementResolver(fixedCeiling{q: "2160p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
if len(router.gotQualities) != 2 {
t.Fatalf("expected 2 qualities (hd+uhd), got %d: %v", len(router.gotQualities), router.gotQualities)
}
targets, _ := store.ListTargets(context.Background(), "r1")
if len(targets) != 2 {
t.Fatalf("expected 2 persisted targets, got %d", len(targets))
}
}
func TestSubmitApprovedSkipsUnconfiguredOptional4KTarget(t *testing.T) {
store := newFakeStore()
hd := routerInst("router-1")
hd.PluginConfig = map[string]any{
"service_kind": "radarr",
"is_default": true,
"is_4k": false,
}
store.integrations = []Integration{hd}
router := &fakeRouterProvider{}
svc := newTestService(store)
svc.SetRouterProvider(router)
svc.SetEntitlementResolver(fixedCeiling{q: ""})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
if len(router.gotQualities) != 1 || router.gotQualities[0] != Quality1080p {
t.Fatalf("qualities = %v, want only [1080p] when no 4K default is configured", router.gotQualities)
}
targets, _ := store.ListTargets(context.Background(), "r1")
if len(targets) != 1 || targets[0].Quality != Quality1080p || targets[0].Status == StatusFailed {
t.Fatalf("targets = %+v, want one healthy 1080p target", targets)
}
}
func TestSubmitApprovedUsesConfiguredOptional4KDefault(t *testing.T) {
store := newFakeStore()
hd := routerInst("router-hd")
hd.PluginConfig = map[string]any{
"service_kind": "radarr",
"is_default": true,
"is_4k": false,
}
uhd := routerInst("router-uhd")
uhd.PluginConfig = map[string]any{
"service_kind": "radarr",
"is_4k": true,
"is_default_4k": true,
}
store.integrations = []Integration{hd, uhd}
router := &fakeRouterProvider{}
svc := newTestService(store)
svc.SetRouterProvider(router)
svc.SetEntitlementResolver(fixedCeiling{q: "2160p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
if len(router.gotQualities) != 2 || router.gotQualities[0] != Quality1080p || router.gotQualities[1] != Quality2160p {
t.Fatalf("qualities = %v, want [1080p 2160p]", router.gotQualities)
}
}
func TestSubmitApprovedNoRouterFails(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
svc := newTestService(store) // no router provider set
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit: %v", err)
}
if got.Outcome != OutcomeFailed {
t.Fatalf("outcome = %q, want failed (no router configured)", got.Outcome)
}
}
func TestSubmitApprovedNoConnectionsFails(t *testing.T) {
store := newFakeStore() // no integrations
svc := newTestService(store)
svc.SetRouterProvider(&fakeRouterProvider{})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit: %v", err)
}
if got.Outcome != OutcomeFailed {
t.Fatalf("outcome = %q, want failed (no enabled router connections)", got.Outcome)
}
}
func TestSubmitApprovedZeroTargetsUsesProviderMessage(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
svc := newTestService(store)
svc.SetRouterProvider(&fakeRouterProvider{noTargets: true, fulfillMsg: "no radarr instance configured for 1080p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit: %v", err)
}
if got.Outcome != OutcomeFailed || got.LastError != "no radarr instance configured for 1080p" {
t.Fatalf("request = %+v, want failed with provider message", got)
}
}
func TestSubmitApprovedIsIdempotentPerQuality(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
router := &fakeRouterProvider{}
svc := newTestService(store)
svc.SetRouterProvider(router)
svc.SetEntitlementResolver(fixedCeiling{q: "1080p"}) // HD ceiling -> only 1080p allowed
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
// Seed a healthy 1080p target so the re-run should not re-submit that quality.
if _, err := store.CreateTarget(context.Background(), Target{
RequestID: "r1", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusQueued, ExternalID: "ext-existing",
}); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
// HD ceiling only -> only 1080p is allowed, and it already has a healthy target,
// so Fulfill is never called.
if router.fulfillCalls != 0 {
t.Fatalf("fulfill calls = %d, want 0 (healthy 1080p target already exists)", router.fulfillCalls)
}
}
func TestSubmitApprovedRecordsDroppedQualityAsFailed(t *testing.T) {
store := newFakeStore()
store.settings.ForceDualQuality = true // want both 1080p and 2160p
store.integrations = []Integration{routerInst("router-1")}
// Plugin fulfills only 1080p, dropping the wanted 2160p.
router := &fakeRouterProvider{targetsOverride: []RouterTarget{{
Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-hd", ExternalStatus: "queued", Status: StatusQueued,
}}}
svc := newTestService(store)
svc.SetRouterProvider(router)
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
targets, _ := store.ListTargets(context.Background(), "r1")
if len(targets) != 2 {
t.Fatalf("targets = %d, want 2 (1080p queued + 2160p failed)", len(targets))
}
var failed2160 *Target
for i := range targets {
if targets[i].Quality == Quality2160p {
failed2160 = &targets[i]
}
}
if failed2160 == nil || failed2160.Status != StatusFailed {
t.Fatalf("2160p target = %+v, want a failed target", failed2160)
}
if failed2160.LastError != "fulfillment backend returned no target for this quality" {
t.Fatalf("2160p last error = %q, want the no-target message", failed2160.LastError)
}
// The failed 2160p target is not "healthy", so a re-run (Retry / reconcile)
// re-attempts only that quality. Provide a normal provider for the re-run.
retryRouter := &fakeRouterProvider{}
svc.SetRouterProvider(retryRouter)
cur := *store.requests["r1"]
cur.Status = StatusApproved
cur.Outcome = OutcomeActive
if _, err := svc.submitApprovedRequest(context.Background(), cur, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("retry submit: %v", err)
}
if len(retryRouter.gotQualities) != 1 || retryRouter.gotQualities[0] != Quality2160p {
t.Fatalf("retry qualities = %v, want only [2160p] (1080p is healthy)", retryRouter.gotQualities)
}
}
func TestSubmitApprovedContainsToSingleInstallation(t *testing.T) {
store := newFakeStore()
// Two enabled router connections on DIFFERENT installations. Only the first
// installation's connections may be sent to that plugin.
store.integrations = []Integration{
routerInstOn("router-a", 1),
routerInstOn("router-b", 2),
}
router := &fakeRouterProvider{}
svc := newTestService(store)
svc.SetRouterProvider(router)
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
if router.gotInstallationID != 1 {
t.Fatalf("installation id = %d, want 1 (first eligible)", router.gotInstallationID)
}
if len(router.gotConns) != 1 || router.gotConns[0].ID != "router-a" {
t.Fatalf("connections = %+v, want only installation 1's router-a", router.gotConns)
}
}
// TestSubmitApprovedContainsToChosenCapability guards that when one installation
// exposes connections for more than one request_router capability sub-id, the
// host hands the plugin only the connections for the FIRST chosen capability —
// never a connection belonging to a different capability of the same installation.
func TestSubmitApprovedContainsToChosenCapability(t *testing.T) {
store := newFakeStore()
arrConn := routerInstOn("arr-conn", 1) // CapabilityID "arr"
seerrConn := routerInstOn("seerr-conn", 1)
seerrConn.CapabilityID = "seerr"
store.integrations = []Integration{arrConn, seerrConn}
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
service.SetEntitlementResolver(fixedCeiling{q: "1080p"}) // single quality, keep it simple
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := service.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
if len(router.gotConns) != 1 {
t.Fatalf("fulfill conns = %d, want 1 (contained to the first chosen capability, not mixed across arr+seerr)", len(router.gotConns))
}
if router.gotConns[0].ID != "arr-conn" {
t.Fatalf("fulfilled conn = %q, want arr-conn (first chosen)", router.gotConns[0].ID)
}
}
func TestSubmitApprovedDedupesDuplicateQualityTargets(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
// Misbehaving plugin returns two targets for the same quality.
router := &fakeRouterProvider{targetsOverride: []RouterTarget{
{Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-1", Status: StatusQueued},
{Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-2", Status: StatusQueued},
}}
svc := newTestService(store)
svc.SetRouterProvider(router)
// Pin an HD ceiling: this test is about deduping a duplicate quality, not 4K
// entitlement, so keep it to a single requested quality (1080p).
svc.SetEntitlementResolver(fixedCeiling{q: "1080p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if got.Outcome == OutcomeFailed {
t.Fatalf("outcome = failed, want a clean queued aggregate")
}
targets, _ := store.ListTargets(context.Background(), "r1")
if len(targets) != 1 {
t.Fatalf("targets = %d, want 1 (duplicate quality deduped)", len(targets))
}
}
func TestSubmitApprovedSkipsMismatchedMediaType(t *testing.T) {
store := newFakeStore()
// Only a series-serving router connection exists; a movie request must not use it.
seriesOnly := routerInst("router-series")
seriesOnly.SupportedMediaTypes = []string{string(MediaTypeSeries)}
store.integrations = []Integration{seriesOnly}
router := &fakeRouterProvider{}
svc := newTestService(store)
svc.SetRouterProvider(router)
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit: %v", err)
}
if got.Outcome != OutcomeFailed || got.LastError != "no fulfillment backend configured" {
t.Fatalf("request = %+v, want failed with no-backend message", got)
}
if router.fulfillCalls != 0 {
t.Fatalf("fulfill calls = %d, want 0 (series connection filtered out for a movie)", router.fulfillCalls)
}
}
func TestSubmitApprovedSkipsBadConnectionUsesSibling(t *testing.T) {
store := newFakeStore()
// Two connections on the same installation: one has no api key (unconfigured),
// the other carries a literal key. The healthy sibling must still fulfill the
// request, and the no-key connection must never pin the installation.
bad := routerInstOn("router-bad", 1)
bad.APIKeyRef = ""
good := routerInstOn("router-good", 1)
good.APIKeyRef = "good-key"
store.integrations = []Integration{bad, good}
router := &fakeRouterProvider{}
svc := newTestService(store)
svc.SetRouterProvider(router)
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit must not abort on a single bad connection: %v", err)
}
if got.Outcome == OutcomeFailed {
t.Fatalf("outcome = failed, want submitted via the healthy sibling")
}
if router.fulfillCalls != 1 {
t.Fatalf("fulfill calls = %d, want 1", router.fulfillCalls)
}
if len(router.gotConns) != 1 || router.gotConns[0].ID != "router-good" || router.gotConns[0].APIKey != "good-key" {
t.Fatalf("router connections = %+v, want only the healthy router-good with resolved key", router.gotConns)
}
}
func TestSubmitApprovedSkipsConnectionWithEmptyKey(t *testing.T) {
store := newFakeStore()
noKey := routerInstOn("router-nokey", 1)
noKey.APIKeyRef = "" // resolves empty -> must be skipped (never send unauthenticated)
store.integrations = []Integration{noKey}
router := &fakeRouterProvider{}
svc := newTestService(store)
svc.SetRouterProvider(router)
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit: %v", err)
}
if got.Outcome != OutcomeFailed {
t.Fatalf("outcome = %q, want failed (no usable connection)", got.Outcome)
}
if router.fulfillCalls != 0 {
t.Fatalf("fulfill calls = %d, want 0 (empty-key connection skipped)", router.fulfillCalls)
}
}
func TestSubmitApprovedSkipsUnknownQuality(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
// Plugin returns a bogus quality alongside a valid one.
router := &fakeRouterProvider{targetsOverride: []RouterTarget{
{Quality: Quality("720p"), ConnectionID: "router-1", ExternalID: "ext-bad", Status: StatusQueued},
{Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-hd", Status: StatusQueued},
}}
svc := newTestService(store)
svc.SetRouterProvider(router)
// Pin an HD ceiling: this test is about skipping an unknown quality, not 4K
// entitlement, so keep it to a single requested quality (1080p).
svc.SetEntitlementResolver(fixedCeiling{q: "1080p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
targets, _ := store.ListTargets(context.Background(), "r1")
if len(targets) != 1 {
t.Fatalf("targets = %d, want 1 (720p skipped, 1080p persisted)", len(targets))
}
for _, tg := range targets {
if tg.Quality == Quality("720p") {
t.Fatalf("a 720p target was persisted: %+v", tg)
}
}
}
func TestSubmitApprovedSkipsUnknownConnectionTarget(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
router := &fakeRouterProvider{targetsOverride: []RouterTarget{
{Quality: Quality1080p, ConnectionID: "missing-router", ExternalID: "ext-hd", Status: StatusQueued},
}}
svc := newTestService(store)
svc.SetRouterProvider(router)
svc.SetEntitlementResolver(fixedCeiling{q: "1080p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit must not abort on an unknown plugin connection: %v", err)
}
if got.Outcome != OutcomeFailed {
t.Fatalf("outcome = %q, want failed missing-quality target", got.Outcome)
}
targets, _ := store.ListTargets(context.Background(), "r1")
if len(targets) != 1 || targets[0].IntegrationID != "" || targets[0].Status != StatusFailed {
t.Fatalf("targets = %+v, want one failed target without unknown integration id", targets)
}
}
func TestSubmitApprovedCoercesUnknownStatusToQueued(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
router := &fakeRouterProvider{targetsOverride: []RouterTarget{
{Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-hd", Status: Status("bogus")},
}}
svc := newTestService(store)
svc.SetRouterProvider(router)
// Pin an HD ceiling: this test is about status coercion, not 4K entitlement,
// so keep it to a single requested quality (1080p).
svc.SetEntitlementResolver(fixedCeiling{q: "1080p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
targets, _ := store.ListTargets(context.Background(), "r1")
if len(targets) != 1 || targets[0].Status != StatusQueued {
t.Fatalf("targets = %+v, want one StatusQueued target (unknown status coerced)", targets)
}
}
func TestSubmitApprovedSkipsTargetForHealthyQuality(t *testing.T) {
store := newFakeStore()
store.settings.ForceDualQuality = true // want 1080p + 2160p
store.integrations = []Integration{routerInst("router-1")}
// Seed a healthy 1080p target; the plugin (misbehaving) returns one anyway.
if _, err := store.CreateTarget(context.Background(), Target{
RequestID: "r1", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusQueued, ExternalID: "ext-existing",
}); err != nil {
t.Fatalf("seed: %v", err)
}
router := &fakeRouterProvider{targetsOverride: []RouterTarget{
{Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-dupe", Status: StatusQueued},
{Quality: Quality2160p, ConnectionID: "router-1", ExternalID: "ext-uhd", Status: StatusQueued},
}}
svc := newTestService(store)
svc.SetRouterProvider(router)
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit must not error on a duplicate of a healthy quality: %v", err)
}
targets, _ := store.ListTargets(context.Background(), "r1")
if len(targets) != 2 {
t.Fatalf("targets = %d, want 2 (existing 1080p kept + new 2160p; dup 1080p skipped)", len(targets))
}
count1080 := 0
for _, tg := range targets {
if tg.Quality == Quality1080p {
count1080++
}
}
if count1080 != 1 {
t.Fatalf("1080p targets = %d, want 1 (no duplicate persisted)", count1080)
}
}
func TestSubmitApprovedUnboundInstallationFailsWithGuidance(t *testing.T) {
store := newFakeStore()
// A router connection that exists but is not bound to a plugin installation
// (the migration leaves installation_id NULL for pre-existing rows).
unbound := routerInst("router-unbound")
unbound.InstallationID = nil
store.integrations = []Integration{unbound}
svc := newTestService(store)
svc.SetRouterProvider(&fakeRouterProvider{})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil)
if err != nil {
t.Fatalf("submit: %v", err)
}
if got.Outcome != OutcomeFailed ||
got.LastError != "request backend connection is not bound to a plugin installation; re-save it in admin" {
t.Fatalf("request = %+v, want failed with unbound-installation guidance", got)
}
}
type fakeRequesterIdentity struct {
email, username string
err error
gotUserID int
}
func (f *fakeRequesterIdentity) ResolveRequester(_ context.Context, userID int) (string, string, error) {
f.gotUserID = userID
return f.email, f.username, f.err
}
func TestSubmitApprovedPopulatesRequesterIdentity(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInstOn("router-1", 1)}
router := &fakeRouterProvider{}
service := newTestService(store)
service.SetRouterProvider(router)
service.SetRequesterIdentityResolver(&fakeRequesterIdentity{email: "u@example.com", username: "bob"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := service.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil {
t.Fatalf("submit: %v", err)
}
if router.gotRequesterEmail != "u@example.com" || router.gotRequesterUsername != "bob" {
t.Fatalf("descriptor identity = %q/%q, want u@example.com/bob", router.gotRequesterEmail, router.gotRequesterUsername)
}
}