package downloader import ( "context" "errors" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" ) func TestPathMap(t *testing.T) { m := PathMap{Local: "/downloads", Remote: "/data/torrents/"} for in, want := range map[string]string{ "/downloads": "/data/torrents", "/downloads/abc/x.mp3": "/data/torrents/abc/x.mp3", "/downloads2/abc": "/downloads2/abc", "/music/a": "/music/a", "": "", } { if got := m.ToRemote(in); got != want { t.Errorf("ToRemote(%q) = %q, want %q", in, got, want) } } if got := m.ToLocal("/data/torrents/abc"); got != "/downloads/abc" { t.Errorf("ToLocal = %q", got) } if (PathMap{Local: "/downloads", Remote: "/downloads/"}).Active() { t.Error("the same folder under one name is not a mapping") } } // fakeQbit is a qBittorrent WebUI that wants a login, and that expires the // session once to make the client log in again. func fakeQbit(t *testing.T, expireOnce bool) (*httptest.Server, *atomic.Int32) { t.Helper() var logins atomic.Int32 var expired atomic.Bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Referer") == "" { w.WriteHeader(http.StatusForbidden) return } switch r.URL.Path { case "/api/v2/auth/login": _ = r.ParseForm() if r.Form.Get("password") != "pw" { _, _ = w.Write([]byte("Fails.")) return } logins.Add(1) http.SetCookie(w, &http.Cookie{Name: "SID", Value: "s", Path: "/"}) _, _ = w.Write([]byte("Ok.")) case "/api/v2/torrents/info": if _, err := r.Cookie("SID"); err != nil { w.WriteHeader(http.StatusForbidden) return } if expireOnce && !expired.Swap(true) { w.WriteHeader(http.StatusForbidden) return } if r.URL.Query().Get("category") != "minstrel" { t.Errorf("category = %q", r.URL.Query().Get("category")) } _, _ = w.Write([]byte(`[{"hash": "ABC", "name": "x", "save_path": "/data/torrents/abc", "progress": 1}]`)) case "/api/v2/torrents/add": body, _ := io.ReadAll(r.Body) if !strings.Contains(string(body), "/data/torrents/new") { t.Errorf("add body has no translated save path: %s", body) } _, _ = w.Write([]byte("Ok.")) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(srv.Close) return srv, &logins } func TestQBittorrent_LogsInAndLogsInAgainWhenTheSessionExpires(t *testing.T) { srv, logins := fakeQbit(t, true) d := Mapped(&Client{Config: Config{BaseURL: srv.URL, Username: "u", Password: "pw", Category: "minstrel"}}, PathMap{Local: "/downloads", Remote: "/data/torrents"}) ts, err := d.Torrents(context.Background()) if err != nil { t.Fatalf("torrents: %v", err) } if logins.Load() != 2 { t.Errorf("logins = %d, want 2 (the second after the 403)", logins.Load()) } if len(ts) != 1 || ts[0].Hash != "abc" || ts[0].SavePath != "/downloads/abc" || !ts[0].Done() { t.Errorf("torrents = %+v, want one, hash lowercased, path in Minstrel's terms", ts) } if err := d.AddTorrent(context.Background(), Add{Magnet: "magnet:?xt=urn:btih:abc", SavePath: "/downloads/new"}); err != nil { t.Errorf("add: %v", err) } } func TestQBittorrent_AWrongPasswordIsAuth(t *testing.T) { srv, _ := fakeQbit(t, false) c := &Client{Config: Config{BaseURL: srv.URL, Password: "nope"}} if _, err := c.Torrents(context.Background()); !errors.Is(err, ErrAuth) { t.Fatalf("err = %v, want ErrAuth", err) } } func TestExchange_MapsDownloadsAndRefusesWhileRestoring(t *testing.T) { var restoring, sentKey atomic.Bool restoring.Store(true) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "" { sentKey.Store(true) } pending := "0" if restoring.Load() { pending = "3" } _, _ = io.WriteString(w, `{"enabled": true, "running": false, "held_reason": "disk full", "restore_pending": `+pending+`, "downloads": [ {"infohash": "ABC", "name": "Album", "save_path": "/dl", "state": "seeding", "progress": 1, "length": 10, "bytes": 10, "category": "minstrel"}, {"infohash": "DEF", "name": "Other", "save_path": "/dl", "state": "fast", "category": "someone-else"}, {"infohash": "123", "name": "Broken", "save_path": "/dl", "failed": true, "fail_reason": "tracker gone", "category": "minstrel"} ]}`) })) defer srv.Close() c := &Exchange{Config: Config{BaseURL: srv.URL, Category: "minstrel"}} if _, err := c.Torrents(context.Background()); !errors.Is(err, ErrRestoring) { t.Fatalf("while restoring: err = %v, want ErrRestoring", err) } if sentKey.Load() { t.Error("an Authorization header was sent with no key set") } restoring.Store(false) ts, err := c.Torrents(context.Background()) if err != nil { t.Fatalf("torrents: %v", err) } if len(ts) != 2 { t.Fatalf("torrents = %+v, want the two in Minstrel's category", ts) } if ts[0].Hash != "abc" || ts[0].State != "uploading" || ts[0].ContentPath != "/dl/Album" || !strings.Contains(ts[0].EngineDown, "disk full") { t.Errorf("first = %+v", ts[0]) } if !ts[1].Failed() || ts[1].FailReason != "tracker gone" { t.Errorf("second = %+v, want failed with Exchange's reason", ts[1]) } } func TestFor_RefusesAnUnknownProtocol(t *testing.T) { if _, err := For("transmission", Config{}); err == nil { t.Fatal("want an error for a client Minstrel cannot drive") } }