* docs(policy): add OPA policy engine design spec and implementation plan Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * build(deps): add OPA v1.18.2 SDK for the policy engine Pulls github.com/open-policy-agent/opa v1.18.2 (policy engine core for the upcoming internal/policy subsystem) and the transitive upgrades go mod tidy applied (otel 1.44, grpc 1.81.1, prometheus/common 0.67.5). Full build verified. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(policy): add OPA engine core, vendor scope policy, and parity suite New internal/policy package (dead code — nothing wires into request paths yet): prepared-query Engine with 25ms eval timeout and fail-closed decode, typed PDP.ResolveViewerScope, go:embed vendor bundle, capabilities lockdown for future admin-authored Rego, and vendor scope.rego reproducing access.Resolver.Resolve (library intersection, disabled-library handling, quality/rating ceilings) with a narrowing-only silo_custom.scope.override extension hook. Parity proven by 1368 dual-execution subtests against the real access.Resolver, including the nil-vs-empty AllowedLibraryIDs battery and quality/rating variation; rank tables are test-pinned to internal/access. Rego unit tests run via opa/v1/tester inside go test. Bench: ~106µs/op per scope decision incl. input marshaling. Also restores the OPA requirement to go.mod (the earlier deps commit ran go mod tidy before any import existed, so tidy dropped it). Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed, corrected (quality.allowed raw-file-rank divergence), and verified here. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(policy): add policy document store, foundation schema, and compile-check policy_foundation migration: policy_documents (one enabled doc per domain via partial unique index — two enabled docs would define override twice and conflict at eval), immutable policy_document_versions, single-row policy_generation counter, and the partitioned policy_decisions log table (daily range partitions, no FK, denial partial index). PolicyStore: transactional version numbering (FOR UPDATE), activation that verifies compiled_ok and bumps the generation in the same tx, enable/disable with typed ErrDomainAlreadyEnabled, and a delete guard for documents with an active version. CompileCheck sandboxes admin Rego: locked capabilities (no http.send/net.*/opa.runtime), enforced silo_custom.<domain> package path, vendor+stub layering, 2s budget, structured row/col errors. Engine gains NewEngineWithCustom / NewEngineFromStore with WARN-and-skip for invalid custom rows. DB-backed tests verified against a migrated Postgres (concurrent version numbering, atomic generation bumps, activation guards). Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and verified here (domain constants extracted). Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(policy): add policy System lifecycle with hot reload and cross-node invalidation policy.System owns one long-lived Engine and reloads it in place when policy documents change: EventPolicyChanged on the existing ChannelAdmin bus (new cache event constant) plus a 60s generation-poll fallback for Redis-less deployments, with a generation-consistent snapshot read. Vendor compile failure is startup-fatal; store/custom failures degrade to vendor-only and the poll loop heals them; runtime reload failures keep the last known-good engine. NotifyChanged gives the future admin handlers synchronous local reload + cross-node publish. Wiring: constructed in integrated/api modes only, PolicySystem field on api.Dependencies (unused by routes yet), policy.eval_timeout_ms setting (hot-reloaded via configWatcher.OnChange; default 25ms). Verified by a full server boot smoke and DB-backed convergence tests (event + poll paths, degraded boot, last-known-good). Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and verified here. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(policy): add async decision logging with sampling, retention, and query repo DecisionLogger batch-inserts each node's policy decisions straight to the partitioned policy_decisions table via a non-blocking buffered channel (drop-and-count on overflow — logging never adds latency to or fails a decision). Scope decisions sample 1-in-N (default 50, setting policy.decision_log_scope_sample_rate); denials and eval errors always log; input/result JSON samples only at policy.decision_log_verbosity= verbose. Cursor-paginated DecisionRepository backs the upcoming admin log viewer. Retention via partman (daily partitions) and a PolicyDecisionLogCleanupTask honoring policy.decision_log_retention_days (default 14). PDP emits entries per evaluation; the System owns the logger lifecycle and settings hot-reload. Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and verified here (removed an unused, unsynchronized PDP setter). Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(api): add admin policy management API and capability endpoint /api/v1/policy/capability (authenticated feature detection) plus the acting-admin /api/v1/admin/policy surface: vendor Rego viewer, document CRUD with the one-enabled-per-domain conflict mapped to 409, immutable version creation (compile-checked; failed versions persist as audit history with structured row/col errors and can never activate), activate/rollback with synchronous reload + cross-node invalidation via System.NotifyChanged, stateless validate, throwaway-bundle simulate (never touches the live engine, never logs decisions), and cursor-paginated decision-log queries. Routes mount only when the policy system is wired, keeping proxy/transcode modes untouched. Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and verified here (seeded the FK'd test user; replaced an unchecked fmt.Sscanf with strconv). Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): add /admin/policy workspace with Rego editor, simulate, and decision log New Policy admin page (System nav group): documents list with one-enabled-per-domain conflict handling, CodeMirror 6 Rego editor (hand-rolled StreamLanguage mode) with server compile issues rendered as inline lint diagnostics, explicit Save-version vs Activate flow with confirm, read-only vendor module viewer, simulate panel with seeded example inputs, version history with rollback, and a cursor-paginated decision-log browser. Capability-gated via /policy/capability. Adds the three decision-log settings to Log Retention. First code-editor dependency in web/ (@uiw/react-codemirror + @codemirror/*), decided in the design spec. Implementation drafted by Codex (GPT-5.5) via codex exec; verified here (lint, format:check, tsc --noEmit, vitest policy suites). Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(policy): make OPA authoritative for viewer scope resolution policy.ViewerResolver implements the ViewerResolver interface backed by PDP.ResolveViewerScope and replaces access.Resolver at all five construction sites: router viewer middleware, notifications scopes, the reconciler, jellycompat's scope filter, and the ABS resolver (which now accepts a pre-built resolver, preserving its PIN-at-login semantics). PIN/profile-token verification and disabled-library loading are extracted into shared exported helpers used by both implementations, so the legacy resolver stays compiled as the parity reference with identical behavior. The adapter lives in internal/policy (which already depends on internal/access transitively) — direct typed PDP calls, no new import cycle. Sites without a policy system (proxy modes, bare test routers) keep the legacy resolver until the cleanup phase. Verified: full test suite green (jellycompat TestBeginWebOperation* and one playback GPU test are pre-existing failures, confirmed identical on main), 1368-case parity suite, dedicated ViewerResolver parity/PIN/ nil-vs-empty/fail-closed tests, and a full server boot smoke. Implementation drafted by Codex (GPT-5.5) via codex exec; a first-pass reflection-based adapter was rejected and reworked into the typed in-policy adapter; reviewed line-by-line and verified here. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(policy): make OPA authoritative for acting-admin and permission gates vendor/permission.rego reproduces the acting-admin rule (admin role + primary-profile-or-none), HasEffectivePermission semantics for marker_edit, and the metadata-curation rule including the subtle admin-past-refused-bypass case that requires the explicitly ASSIGNED permission. Policy-backed middleware in policy_gates.go keeps all Go-side lookups (declared-profile primary check, item->library resolution, the 404-on-unknown-item path) and preserves the legacy status/body taxonomy exactly — proven by dual-execution middleware tests that run every scenario through both implementations and assert byte-equal responses. Permission decisions always log (allowed flag populated); simulate and the capability endpoint gain the permission domain automatically via the domain registry. Router swaps behind single constructor choice points with the legacy gates retained for policy-less wiring. Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and verified here. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(policy): make OPA authoritative for download and playback admission decisions vendor/action.rego decides download eligibility (downloads enabled + user allowed), download-transcode eligibility (transcode enabled + user allowed + artifacts available), and playback admission (stream/transcode counts vs limits, zero = unlimited), with a tightening-only silo_custom.action override that can also clamp a quality ceiling (never widen — merged via quality.min). Go keeps everything stateful: config loading, preset-ladder enumeration, and live session counting. Downloads consult an optional ActionDecider (nil = legacy logic) mapped back to the existing sentinel errors and capability response. Playback gains a minimal AdmissionDecider hook at the exact point of the legacy limit comparison: counts snapshot under the session mutex, PDP evaluated OUTSIDE the lock, then revalidated under lock before insert (retry on count drift) — no admission ever decided on stale counts and no eval under the mutex. Deny reasons map to the legacy ErrTooManyStreams / ErrTooManyTranscodes sentinels, pinned by tests. Parity: combination tables driven against the real PresetsFor / ensureTranscodeAllowed / SessionLimits math; full suite green (known pre-existing jellycompat flakes only). Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed (locking design verified line-by-line) and verified here. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): satisfy tsc -b strict return typing in the Rego stream tokenizer The production build (tsc -b) rejects assigning CodeMirror's string | void next() result to string | undefined; tsc --noEmit did not catch it. Restructured the string-literal loop. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(policy): clearer error when a decision is undefined for partial input Vendor policies index required input fields directly, so a hand-written simulate payload missing fields yields an undefined decision. Surface that as 'decision X is undefined for this input (missing required input fields?)' instead of 'empty result' — found while exercising the simulate API against a live server. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(web): set changeOrigin automatically when the API proxy target is remote Remote dev backends sit behind vhost-routing proxies that reject a localhost Host header; local targets keep the existing pass-through behavior. Enables pointing the Vite dev server at a hosted backend via VITE_API_PROXY_TARGET in web/.env.local. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(web): redesign the policy workspace around the decision pipeline The first-pass UI was structurally generic: a five-column document table squeezed beside the editor, three equal-weight action buttons with hidden preconditions, raw version IDs, and jargon copy — nothing taught the model. The page now teaches it: - A pipeline strip states the mental model up front: Silo decides the baseline -> your overrides narrow it -> every decision is logged. Tabs renamed to Overrides / Baseline / Decision Log (ids stay stable for bookmarked URLs). - The document table becomes one card per domain (Library visibility / Admin & permissions / Downloads & playback) with plain-language descriptions, example rules, status pills (Live vN / Draft / Disabled), inline creation, and the enable kill-switch in place. - Selecting an override drills into a full-width editor with a visible lifecycle rail (Draft -> Validated -> Saved -> Live) and one contextual primary action per step; the unedited live source shows no actions until edited. Version comments appear only at the save step. - Simulate is reframed as 'Test before going live' with a human verdict chip (Allowed / Denied — reason / ceiling summary) above the raw JSON; internal generation counters no longer surface. - History uses 'Make live' with plain go-live copy; authors read 'User N'; the baseline tab explains that upgrades never touch overrides. Hand-written redesign (no Codex); verified via vitest, tsc, eslint, prettier, and a production build. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(web): present the policy baseline as readable rules, not raw Rego The Baseline tab dumped five Rego modules into read-only editors. It now leads with what the rules actually do: one card per domain with plain-language statements of the shipped behavior and a note on what an override may change, plus content-rating and playback-quality tier ladders parsed live from the lib module sources (so the tiers shown are the ones the server enforces, not a hardcoded copy). The Rego source stays one click away behind a per-module accordion and remains the stated source of truth; unrecognized modules fall back to source-only. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(policy): add access-groups design addendum Groups with permission toggles become the everyday admin surface; the Rego editor is demoted behind policy.editor_enabled (default off). Restriction-only composition: group grants are an upper bound, per-user settings tighten further — same rule as the existing account/profile merge, one layer up. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(access): add access groups — group defaults with restriction-only composition New access_groups table + users.access_group_id (one group per user, NULL = today's behavior). Group grants are an upper bound composed with the user's own settings by strictest-wins rules — library intersection, MinQuality, AND'd booleans, strictest positive stream/transcode limits, permission-mask intersection, and a requests toggle gating CreateRequest. The merge happens in Go (access.ApplyGroupPolicy / EffectivePolicyForUser) before policy inputs are built, so vendor Rego, the parity suites, and the decision log are untouched; every enforcement surface (viewer scope in both resolvers, permission gates, downloads, playback admission, requests) consumes the effective policy and fails closed on provider errors. Changing a group's quality ceiling bumps its members' access_policy_revision, mirroring the per-user rule. Additive admin API: /admin/access-groups CRUD with member counts; PUT /admin/users/{id} + user DTOs gain access_group_id. Also demotes the Rego editor: policy.editor_enabled (default off, hot-reloaded) drives the capability endpoint's editor_available and 403-gates editor endpoints while the engine and decision logging keep running. Design: docs/superpowers/specs/2026-07-02-access-groups-design.md. Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed (composition core + fail-closed call-site audit) and verified here. DB-backed group-store tests pending local Postgres recovery. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): add Access Groups admin page and gate the policy editor New /admin/access-groups: a card grid summarizing each group (member count + key restrictions), drilling into an editor that reuses the same LibraryAccessSelector and quality presets as the user editor, with toggles for downloads/transcoded-downloads/requests, concurrent-stream and transcode limits, and a permissions mask (all-assignable by default, narrowable to specific permissions). Delete warns how many members fall back to the built-in defaults. Copy states the composition rule up front: a group grants the most a member can do; their own restrictions still apply on top. The user editor gains a Group picker and read-only row; the Policy nav entry is now hidden unless the capability reports the editor enabled. Plumbing (types, hooks, user-editor picker, nav gating) drafted by Codex (GPT-5.5); the Groups page hand-built. Verified: 25 tests across the touched suites, tsc, eslint, prettier, and a production build. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(access): seed a Default Group and auto-assign newly created users Adds access_groups.is_default with a partial unique index (one default at most — the profiles is_primary pattern) and seeds a permissive 'Default Group' whose ceiling is a no-op, so assignment never changes anyone's effective access until an admin edits it. The seed is guarded against pre-existing defaults and name collisions; the Down migration only removes the row if it is still untouched. Assignment happens at the single INSERT INTO users choke point (UserRepository.Create): when no explicit group is given, access_group_id is filled by a scalar subquery on the default flag — NULL when no default exists. Every creation path (setup, signup, invites, OAuth, admin create) is covered by construction. Setting a new default via the API atomically clears the previous one in the same transaction. Deleting or unsetting the default is legal: new users then start with no group, which is pre-feature behavior. Implementation drafted by Codex (GPT-5.5); migration guards and the choke-point subquery reviewed line-by-line here. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): surface the default access group Cards show a Default badge; the group editor gains a 'Default for new users' toggle (with copy noting existing users are never moved); the delete dialog warns when removing the default that new accounts will start with no group. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(access): ship the Default Group with house-rule ceilings Seed values per product decision: 5 concurrent streams, 5 transcodes, transcoded downloads off, and a permission mask of marker_edit only (metadata curation excluded). Plain downloads and requests stay on. The Down guard matches the new values so it still only removes an untouched seed row. Only newly created users are affected; existing users are never assigned. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(access): retire per-user defaults — the Default Group is the sole default policy Removes both legacy 'user defaults' mechanisms now that the seeded Default Group owns new-user policy: - users.max_streams / max_transcodes column defaults drop from 6/2 to 0 (= unrestricted at the user layer), so group ceilings apply to new signups/invites/OAuth users instead of fighting stale per-user numbers. Existing rows keep their stored values — nobody is silently uncapped on upgrade. - The dead defaults.max_playback_quality / defaults.max_profiles settings validation goes away with its only writer (the User Defaults dialog, removed on the web side). Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): replace the User Defaults dialog with group-governed creation The Users page's 'User Defaults' dialog (defaults.* server settings) duplicated what access groups now do properly, and its values were only ever form prefill — no backend path applied them. The button now links to Access Groups, and the create-user form seeds unrestricted user-layer values (0 streams/transcodes, any quality, downloads allowed) so the member's group governs; per-user fields remain for tightening individual users. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(access): migrate existing non-admin users into the Default Group Existing users join the seeded Default Group on upgrade so one policy source governs the whole instance. Their per-user limits still holding the retired 6/2 column defaults are normalized to 0 in the same statement so the group's ceilings actually apply; deliberately customized values are preserved. Admin accounts stay ungrouped — scope/action decisions are role-blind, so grouping an admin would cap the server owner on upgrade. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(access): keep admins out of the Default Group and treat group moves as policy changes New-user creation now mirrors the migration's admin exclusion: the default access group is only auto-assigned to non-admin roles, so a fresh server owner no longer inherits the starter group's transcode denial and stream caps. Changing a user's access group now bumps access_policy_revision (the group carries permissions, quality, and limits, exactly like the per-user fields that already bump it) and triggers admin session revocation when the group actually changes. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(policy): enforce marker_edit through the PDP on marker write routes The Rego permission policy owned marker_edit but no Go caller ever consulted it: PUT/DELETE /markers went through a handler-local check that short-circuited admins and read only the user's own permissions, so group permission masks and custom policy overrides were ignored. Marker writes are now gated by router middleware like the other permission surfaces: a PDP-backed RequireMarkerEdit that evaluates the group-merged effective permissions (plus the legacy variant for proxy/test wiring without a policy system). The handler-local check and its user loader are gone. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): assert device/quality policy facts and honor the quality ceiling The download_transcode action check hard-coded an empty device ID and never asserted the requested quality, and no caller consumed ActionDecision.QualityCeiling — custom download policies keyed on those inputs were silently ineffective. Resolve now threads the request's device ID and requested quality into the action input, and a returned quality ceiling downscales the prepared transcode target (the ceiling applies to what is served, matching the serve-time rule in serveDownloadBytes). FileQuality and the content-rating pair stay intentionally empty for downloads — documented on downloadActionInput: those ceilings are enforced against the served artifact by the scope-derived access filter, and asserting the source's quality would wrongly deny capped transcodes. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(access): align the default-group seed assertions with the migration The DB test still asserted the earlier no-op seed (transcode allowed, unlimited streams/transcodes, null permissions); the shipped migration seeds transcode denied, 5/5 limits, and marker_edit-only permissions, so the test failed on any database with the migration applied. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(policy): lock the Rego sandbox by builtin purity and bound compile work Exclude every nondeterministic builtin from the admin sandbox instead of denylisting names, so OPA upgrades cannot silently expose impure builtins while pure helpers like net.cidr_contains stay usable. Apply the same capabilities to the runtime engine, cap concurrent compile checks, and reject oversized sources before they reach the uncancelable compiler. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(policy): require literal booleans in vendor override and input checks Bare object.get truthiness treated any non-false value as satisfied, so a malformed override 'allowed' value could fail to tighten a base grant and hand-crafted simulate input could flip flag predicates. Compare against literal true so anything else denies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(policy): surface decision log cleanup failures to the task manager CleanupDecisionLogsOnce now returns the first error alongside the deleted count so a broken partition manager or DB outage marks the scheduled task failed instead of reporting 100% success while policy_decisions grows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): log admission decider errors before failing closed A policy-evaluation failure was silently mapped to the too-many-streams denial, making an engine outage indistinguishable from a real limit hit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(access): nil-guard the downloads user and restore the ABS legacy resolver effectiveDownloadUser dereferenced policy state before its nil-user check, and the ABS handler lost viewer-scoped filtering entirely when the policy system was unavailable because no legacy access.NewResolver fallback was wired like the other resolver paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): address admin policy review feedback - invalidate the version query by version_number, the key usePolicyVersion actually caches under - keep the goPrevious cursor-stack updater pure (Strict Mode double-invoke) - make version history rows keyboard-selectable like the document list - clamp download_transcode_allowed when downloads are disabled so groups cannot save a contradictory record Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): cap policy endpoint request bodies at 1 MiB The policy write endpoints (create document/version, set enabled, validate, simulate) decoded JSON bodies without a size limit, so an oversized payload buffered fully in memory before CompileCheck's 256 KiB source cap could reject it. Route all five through a shared decodePolicyRequest helper that wraps the body in http.MaxBytesReader and returns 413 with the repo's standard too_large error shape. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN * fix(access): forbid deleting or demoting the default access group Deleting the default group (or unsetting its is_default flag) left the server with no default: new non-admin users were then created ungrouped with max_streams/max_transcodes of 0 — unlimited — because the legacy per-user column defaults were retired in favor of the group's ceilings. The store now rejects both operations with ErrDefaultGroupRequired (mapped to 409); promoting another group remains the supported way to move the default, and atomically clears the previous one. The admin UI disables the delete button and the default toggle on the default group and explains the promote-another-group flow. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN * fix(web): keep unsaved policy drafts when a newer version activates elsewhere The editor state was keyed on the active version's id/sha, so a background refetch after another admin (or another tab) activated a version remounted the editor and silently discarded the dirty draft. PolicyEditorPanel now pins the seed it is editing against and only adopts an incoming seed when nothing can be lost: the editor is clean, the draft already equals the incoming source (the same-admin activate flow), or the selection moved to a different document. Otherwise the pinned editor stays mounted and an inline notice offers an explicit "Load live version" action. Part of #272 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN * fix(policy): fail reloads on invalid custom sources and surface degraded/apply state A stored custom source that stops compiling used to be silently skipped on reload: the bundle widened to vendor-only for that domain while the generation reported fully applied. Reload is now strict — a bad enabled source fails the reload and the last known-good engine keeps serving. Boot keeps its vendor fallback for availability, but skips are recorded on the engine and exposed (with store-outage reasons) through System.DegradedState and additive degraded fields on GET /policy/capability. Activate/SetEnabled re-run CompileCheck instead of trusting the stored compiled_ok flag. Mutation endpoints also no longer conflate persistence with live apply: activation/enable responses carry additive applied/failed_step/ loaded_generation fields and return 202 when the store change persisted but the local reload failed. Addresses review findings C1, C2, and the degraded-signal gap (6.1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(policy): type deny reasons across the contract and enforce profile_verified Deny handling used to branch on exact free-text reason strings in three Go consumers, and playback reported ANY unrecognized reason — including custom override free text and engine failures — as a stream-limit error. Decisions now carry a stable reason_code (custom overrides always get custom_denial); downloads, the metadata-curation gate, and playback admission switch on codes, with a new ErrPlaybackNotAllowed -> 403 playback_not_allowed mapping for non-limit denials. Rego tests pin every vendor code. The scope contract's tighten-only profile_verified output was also emitted but never consumed; a policy revocation now surfaces as ErrProfileUnverified (403 profile_unverified) instead of silently proceeding. Addresses review findings 6.2 and C4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(catalog): close the dual-library disabled-scope bypass in direct item authorization EnsureAccessible, EnsureAccessibleIDs, and FilterAccessibleContentIDs gated library access with allow/deny predicates over a single joined media_item_libraries row, so an item linked to BOTH a passing library and a disabled one satisfied the disabled check via the passing row — a direct-ID bypass of disabled-library scope on the detail, media-file, playback, and download paths. All library access predicates now share one helper (libraryAccessConditions) emitting independent EXISTS / NOT EXISTS subqueries, the semantics GetByIDsWithAccess already used, including the orphan-item membership guard for disabled-only scopes. SQL-shape tests pin every builder and a DB-gated regression test covers the dual-library item end to end. Addresses review finding C3 (plus the same shape in buildFilterAccessibleContentIDsSQL, which the review did not flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): serialize quota check and row creation under a per-user advisory lock The concurrent-download quota was check-then-insert with nothing serializing the pair: parallel creates could all observe free quota before any row existed, bypassing the cap and stacking artifact encode jobs. All four check->insert spans (ephemeral original, artifact-backed, series batch, managed batch) now run inside Repository.WithUserQuotaLock — a pg_advisory_xact_lock keyed by user, so the serialization holds across nodes. The artifact path keeps the limiter-before-Ensure ordering (a rejected request must not leave an encode job behind) by holding the lock across Ensure. Managed-entry replacement stays quota-exempt and lock-free. A DB-gated barrier test races 8 creates against a cap of 1. Addresses review finding C5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): assert served quality at create time for original and remux downloads Direct-original and remux downloads serve the source resolution unchanged, but create-time policy checks left file_quality empty — an over-ceiling source registered a row serveDownloadBytes could never satisfy. Resolve now runs a final download action check with FileQuality populated on those two paths (capped transcodes keep the ceiling-on-artifact behavior), a custom override ceiling below the served resolution denies, and quality_ceiling_exceeded maps to ErrQualityUnavailable. The ActionInput contract now documents exactly when file_quality and the rating facts are supplied so custom policy authors are not misled. Addresses review finding C6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(policy): guard activation against slow overrides and make eval timeouts observable A custom scope override that exceeds the 25ms eval budget compiled fine, activated fine, and then converted to 500s on every authenticated request — server-wide lockout authored in the admin editor. Activation and enable now run GuardEvalCost: the candidate source is evaluated on a throwaway engine against a canned representative input under the live budget, and a source that cannot complete is rejected 422 with ErrPolicySlowEval before it goes live. Runtime timeouts keep failing closed but now carry a distinct ErrPolicyEvalTimeout sentinel, an Error log, and a per-engine counter exposed as eval_timeouts on GET /policy/capability so intermittent near-budget policies are attributable. Addresses review finding C7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: gofmt remediation files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2726 lines
91 KiB
Go
2726 lines
91 KiB
Go
package requests
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
"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, "a) {
|
|
t.Fatalf("error = %v, want QuotaError", err)
|
|
}
|
|
if len(store.created) != 0 {
|
|
t.Fatalf("created requests = %d, want 0", len(store.created))
|
|
}
|
|
}
|
|
|
|
func TestCreateRequestGroupPolicyCanForbidRequests(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := newTestService(store)
|
|
service.SetGroupPolicyProvider(requestGroupProvider{group: &access.GroupPolicy{RequestsAllowed: false}})
|
|
|
|
_, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
|
|
MediaType: MediaTypeMovie,
|
|
TMDBID: 550,
|
|
Title: "Fight Club",
|
|
})
|
|
if !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("error = %v, want ErrForbidden", 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, "a) {
|
|
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
|
|
unnotified []string
|
|
notified []string
|
|
|
|
listIntegrationsCalls int
|
|
getSettingsCalls int
|
|
}
|
|
|
|
type requestGroupProvider struct {
|
|
group *access.GroupPolicy
|
|
err error
|
|
}
|
|
|
|
func (p requestGroupProvider) GetPolicyForUser(context.Context, int) (*access.GroupPolicy, error) {
|
|
return p.group, p.err
|
|
}
|
|
|
|
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 ©, 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 ©, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.candidates, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListFulfilledUnnotified(context.Context, int) ([]*Request, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]*Request, 0, len(f.unnotified))
|
|
for _, id := range f.unnotified {
|
|
if req := f.requests[id]; req != nil {
|
|
copy := *req
|
|
out = append(out, ©)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) MarkFulfilledNotified(_ context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
kept := f.unnotified[:0]
|
|
for _, pending := range f.unnotified {
|
|
if pending != id {
|
|
kept = append(kept, pending)
|
|
}
|
|
}
|
|
f.unnotified = kept
|
|
f.notified = append(f.notified, id)
|
|
return 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 ©, 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 ©, 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 ©, 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)
|
|
}
|
|
}
|