unarr/internal/engine/resolve_test.go
Deivid Soto c7ee0c0a28 feat(downloads): ordered preferred_methods list honored for web tasks
The agent ignored its config.toml method preference for web-driven downloads
(only the local `unarr download` command read it), and resolveMethod tried
torrent first in auto mode — so a 'debrid only' user still got torrent tasks.

- config: preferred_methods (ordered list, e.g. ["debrid","usenet"]) with
  MethodOrder() resolution; back-compat with the singular preferred_method.
  Methods absent from the list are disabled (debrid-only never torrents).
- resolveMethod/tryFallback honor the config order (gating, no fallback to a
  method outside the list) over the per-task preference.
- report preferred_methods on register so the web honors it (resolves debrid,
  gates the P2P stream fallback).
- enable the usenet downloader when usenet is listed (it was never enabled).
- config_menu: ordered presets (debrid-only, debrid→torrent, debrid→usenet…).

Tests: resolveMethod gating + fallback within/outside the list.
2026-06-14 12:51:32 +02:00

216 lines
7.4 KiB
Go

package engine
import (
"context"
"fmt"
"testing"
)
// mockDownloader implements Downloader for testing.
type mockDownloader struct {
method DownloadMethod
available bool
err error
}
func (m *mockDownloader) Method() DownloadMethod { return m.method }
func (m *mockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return m.available, m.err
}
func (m *mockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
return &Result{Method: m.method, FileName: "test.mkv", FilePath: "/tmp/test.mkv"}, nil
}
func (m *mockDownloader) Pause(_ string) error { return nil }
func (m *mockDownloader) Cancel(_ string) error { return nil }
func (m *mockDownloader) Shutdown(_ context.Context) error { return nil }
func TestResolveMethodAuto(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "auto"}
method, err := resolveMethod(context.Background(), task, downloaders, nil)
if err != nil {
t.Fatal(err)
}
// Torrent is first in auto order
if method != MethodTorrent {
t.Errorf("method = %q, want torrent (first in auto order)", method)
}
}
func TestResolveMethodSpecific(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "debrid"}
method, err := resolveMethod(context.Background(), task, downloaders, nil)
if err != nil {
t.Fatal(err)
}
if method != MethodDebrid {
t.Errorf("method = %q, want debrid", method)
}
}
func TestResolveMethodSkipsTried(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "auto",
TriedMethods: []DownloadMethod{MethodTorrent},
}
method, err := resolveMethod(context.Background(), task, downloaders, nil)
if err != nil {
t.Fatal(err)
}
if method != MethodDebrid {
t.Errorf("method = %q, want debrid (torrent already tried)", method)
}
}
func TestResolveMethodNoneAvailable(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: false},
}
task := &Task{PreferredMethod: "auto"}
_, err := resolveMethod(context.Background(), task, downloaders, nil)
if err == nil {
t.Error("expected error when no method available")
}
}
func TestResolveMethodAvailabilityError(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: false, err: fmt.Errorf("network error")},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{ID: "test-resolve-err", PreferredMethod: "auto"}
method, err := resolveMethod(context.Background(), task, downloaders, nil)
if err != nil {
t.Fatal(err)
}
// Should fallback to debrid when torrent has error
if method != MethodDebrid {
t.Errorf("method = %q, want debrid (torrent errored)", method)
}
}
func TestTryFallbackAutoMode(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "auto",
ResolvedMethod: MethodTorrent,
}
if !tryFallback(task, downloaders, nil) {
t.Error("should have fallback available")
}
if len(task.TriedMethods) != 1 || task.TriedMethods[0] != MethodTorrent {
t.Error("torrent should be in tried methods")
}
}
func TestTryFallbackSpecificMode(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "torrent",
ResolvedMethod: MethodTorrent,
}
if tryFallback(task, downloaders, nil) {
t.Error("should not fallback in specific mode")
}
}
// ── config.toml preferred_methods gating ──────────────────────────────────
// Config list wins over the per-task preference: a "debrid only" agent picks
// debrid even when the task says auto AND torrent is available (the old bug:
// torrent-first auto stole every download from debrid-only users).
func TestResolveMethodConfigListWins(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "auto"} // web said auto
method, err := resolveMethod(context.Background(), task, downloaders, []string{"debrid"})
if err != nil {
t.Fatal(err)
}
if method != MethodDebrid {
t.Errorf("method = %q, want debrid (config list debrid-only wins)", method)
}
}
// A method not in the config list is NEVER used, even if it's the only one
// available — failing closed is the whole point of "debrid only".
func TestResolveMethodConfigListGatesOutTorrent(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: false},
}
task := &Task{PreferredMethod: "auto"}
_, err := resolveMethod(context.Background(), task, downloaders, []string{"debrid"})
if err == nil {
t.Error("expected error: torrent is available but not in the debrid-only list")
}
}
// Ordered list honours order: debrid first, then usenet.
func TestResolveMethodConfigListOrder(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: false},
MethodUsenet: &mockDownloader{method: MethodUsenet, available: true},
}
task := &Task{PreferredMethod: "auto"}
method, err := resolveMethod(context.Background(), task, downloaders, []string{"debrid", "usenet"})
if err != nil {
t.Fatal(err)
}
if method != MethodUsenet {
t.Errorf("method = %q, want usenet (debrid unavailable, next in list)", method)
}
}
// A multi-method config list allows fallback within the list...
func TestTryFallbackConfigListMulti(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "auto", ResolvedMethod: MethodDebrid}
if !tryFallback(task, downloaders, []string{"debrid", "torrent"}) {
t.Error("should fall back to torrent (in the list)")
}
}
// ...but a single-method config list has no fallback (debrid-only never torrents).
func TestTryFallbackConfigListSingle(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "auto", ResolvedMethod: MethodDebrid}
if tryFallback(task, downloaders, []string{"debrid"}) {
t.Error("debrid-only must not fall back to torrent")
}
}