Files
MeBox/internal/service/download_multi_client_test.go
T
2026-08-10 21:04:04 +08:00

418 lines
15 KiB
Go

package service
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sort"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestAddDownloadUsesDefaultTransmissionClient(t *testing.T) {
var mu sync.Mutex
var added map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("X-Transmission-Session-Id", "session-test")
w.WriteHeader(http.StatusConflict)
return
}
var req transmissionRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode transmission request: %v", err)
return
}
switch req.Method {
case "torrent-get":
_ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: map[string]interface{}{"torrents": []interface{}{}}})
case "torrent-add":
mu.Lock()
added = req.Arguments
mu.Unlock()
_ = json.NewEncoder(w).Encode(transmissionRPCResponse{
Result: "success",
Arguments: map[string]interface{}{
"torrent-added": map[string]interface{}{"hashString": "transmission-hash", "name": "Movie 2026"},
},
})
default:
t.Errorf("unexpected transmission method %q", req.Method)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{})
repos := repository.New(db)
client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
t.Fatal(err)
}
manager := NewDownloadManager(zap.NewNop(), repos, nil)
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.SetDownloadManager(manager)
task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Movie+2026", "/downloads/movies", DownloadTaskMeta{Title: "Movie 2026"})
if err != nil {
t.Fatal(err)
}
if task.Source != "transmission" || task.DownloadClientID != client.ID || task.ExternalID != "transmission-hash" {
t.Fatalf("task downloader identity = %#v", task)
}
mu.Lock()
defer mu.Unlock()
if added["filename"] != "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Movie+2026" {
t.Fatalf("transmission filename = %#v", added["filename"])
}
if added["download-dir"] != "/downloads/movies" {
t.Fatalf("transmission download-dir = %#v", added["download-dir"])
}
}
func TestAddDownloadSendsFetchedTorrentBytesToTransmission(t *testing.T) {
torrentData := []byte("d4:infod4:name7:fixtureee")
torrentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/x-bittorrent")
w.Header().Set("Content-Disposition", `attachment; filename="fixture.torrent"`)
_, _ = w.Write(torrentData)
}))
defer torrentServer.Close()
var metainfo string
transmission := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("X-Transmission-Session-Id", "session-test")
w.WriteHeader(http.StatusConflict)
return
}
var req transmissionRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode transmission request: %v", err)
return
}
arguments := map[string]interface{}{}
switch req.Method {
case "torrent-get":
arguments["torrents"] = []interface{}{}
case "torrent-add":
metainfo, _ = req.Arguments["metainfo"].(string)
arguments["torrent-added"] = map[string]interface{}{"hashString": "torrent-file-hash"}
}
_ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments})
}))
defer transmission.Close()
db := newServiceTestDB(t, &model.Site{}, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{})
repos := repository.New(db)
if err := repos.Site.Create(t.Context(), &model.Site{Name: "Fixture", Type: "custom_rss", URL: torrentServer.URL, AuthType: "cookie", Enabled: true}); err != nil {
t.Fatal(err)
}
client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: transmission.URL, IsDefault: true, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
t.Fatal(err)
}
manager := NewDownloadManager(zap.NewNop(), repos, nil)
site := NewSiteService(zap.NewNop(), repos, "")
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil, site)
svc.SetDownloadManager(manager)
task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", torrentServer.URL+"/fixture.torrent", "/downloads", DownloadTaskMeta{})
if err != nil {
t.Fatal(err)
}
if metainfo != base64.StdEncoding.EncodeToString(torrentData) {
t.Fatalf("metainfo = %q", metainfo)
}
if task.ExternalID != "torrent-file-hash" || task.Title != "fixture" {
t.Fatalf("task = %#v", task)
}
}
func TestAddDownloadSendsPublicTorrentURLBytesToAria2(t *testing.T) {
torrentData := []byte("d4:infod4:name7:fixtureee")
torrentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/x-bittorrent")
_, _ = w.Write(torrentData)
}))
defer torrentServer.Close()
var addMethod string
aria2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req aria2Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode aria2 request: %v", err)
return
}
result := interface{}(map[string]interface{}{"version": "1.37"})
switch req.Method {
case "aria2.tellActive", "aria2.tellWaiting", "aria2.tellStopped":
result = []interface{}{}
case "aria2.addTorrent", "aria2.addUri":
addMethod = req.Method
result = "aria2-torrent-gid"
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "result": result})
}))
defer aria2.Close()
db := newServiceTestDB(t, &model.Site{}, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{})
repos := repository.New(db)
client := &model.DownloadClient{Name: "aria2", Type: "aria2", Host: aria2.URL, IsDefault: true, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
t.Fatal(err)
}
site := NewSiteService(zap.NewNop(), repos, "")
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil, site)
svc.SetDownloadManager(NewDownloadManager(zap.NewNop(), repos, nil))
task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", torrentServer.URL+"/public.torrent", "/downloads", DownloadTaskMeta{Title: "Public Torrent"})
if err != nil {
t.Fatal(err)
}
if addMethod != "aria2.addTorrent" || task.ExternalID != "aria2-torrent-gid" {
t.Fatalf("add method = %q task = %#v", addMethod, task)
}
}
func TestReloadConfigHotSwapsUpdatedTransmissionClient(t *testing.T) {
newServer := func(addCalls *int32, hash string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("X-Transmission-Session-Id", "session-test")
w.WriteHeader(http.StatusConflict)
return
}
var req transmissionRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode transmission request: %v", err)
return
}
arguments := map[string]interface{}{}
switch req.Method {
case "torrent-get":
arguments["torrents"] = []interface{}{}
case "torrent-add":
atomic.AddInt32(addCalls, 1)
arguments["torrent-added"] = map[string]interface{}{"hashString": hash}
}
_ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments})
}))
}
var firstCalls, secondCalls int32
first := newServer(&firstCalls, "first-hash")
defer first.Close()
second := newServer(&secondCalls, "second-hash")
defer second.Close()
db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{})
repos := repository.New(db)
client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: first.URL, IsDefault: true, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
t.Fatal(err)
}
manager := NewDownloadManager(zap.NewNop(), repos, nil)
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.SetDownloadManager(manager)
if _, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=First+Movie", "/downloads", DownloadTaskMeta{Title: "First Movie"}); err != nil {
t.Fatal(err)
}
client.Host = second.URL
if err := repos.DownloadClient.Update(t.Context(), client); err != nil {
t.Fatal(err)
}
task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Second+Movie", "/downloads", DownloadTaskMeta{Title: "Second Movie"})
if err != nil {
t.Fatal(err)
}
if atomic.LoadInt32(&firstCalls) != 1 || atomic.LoadInt32(&secondCalls) != 1 || task.ExternalID != "second-hash" {
t.Fatalf("hot reload calls = %d/%d task = %#v", firstCalls, secondCalls, task)
}
}
func TestDownloadManagerPersistsOldestEnabledClientAsDefault(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("X-Transmission-Session-Id", "session-test")
w.WriteHeader(http.StatusConflict)
return
}
http.NotFound(w, r)
}))
defer server.Close()
db := newServiceTestDB(t, &model.DownloadClient{})
repos := repository.New(db)
first := &model.DownloadClient{
Base: model.Base{CreatedAt: time.Now().Add(-time.Hour)},
Name: "First Transmission",
Type: "transmission",
Host: server.URL,
Enabled: true,
IsDefault: false,
}
second := &model.DownloadClient{
Base: model.Base{CreatedAt: time.Now()},
Name: "Second Transmission",
Type: "transmission",
Host: server.URL,
Enabled: true,
IsDefault: false,
}
if err := repos.DownloadClient.Create(t.Context(), first); err != nil {
t.Fatal(err)
}
if err := repos.DownloadClient.Create(t.Context(), second); err != nil {
t.Fatal(err)
}
manager := NewDownloadManager(zap.NewNop(), repos, nil)
if err := manager.LoadAll(t.Context()); err != nil {
t.Fatal(err)
}
selected, _, err := manager.GetDefault(t.Context())
if err != nil {
t.Fatal(err)
}
if selected.ID != first.ID {
t.Fatalf("default client = %#v", selected)
}
refreshed, err := repos.DownloadClient.FindByID(t.Context(), first.ID)
if err != nil {
t.Fatal(err)
}
if refreshed == nil || !refreshed.IsDefault {
t.Fatalf("persisted default = %#v", refreshed)
}
}
func TestRelocateRejectsNonQBitClient(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("X-Transmission-Session-Id", "session-test")
w.WriteHeader(http.StatusConflict)
return
}
t.Errorf("unexpected Transmission request during unsupported relocation")
}))
defer server.Close()
db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{})
repos := repository.New(db)
client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
t.Fatal(err)
}
manager := NewDownloadManager(zap.NewNop(), repos, nil)
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.SetDownloadManager(manager)
if err := svc.ReloadConfig(t.Context()); err != nil {
t.Fatal(err)
}
err := svc.RelocateTorrent(t.Context(), "transmission-hash", "/new/location", client.ID)
if !errors.Is(err, ErrDownloadOperationUnsupported) {
t.Fatalf("err = %v", err)
}
}
func TestListAggregatesEnabledClientsWithNormalizedProgress(t *testing.T) {
transmission := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("X-Transmission-Session-Id", "session-test")
w.WriteHeader(http.StatusConflict)
return
}
var req transmissionRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode transmission request: %v", err)
return
}
arguments := map[string]interface{}{}
if req.Method == "torrent-get" {
arguments["torrents"] = []map[string]interface{}{{
"hashString": "transmission-hash",
"name": "Transmission Movie",
"totalSize": 1000,
"percentDone": 0.5,
"rateDownload": 100,
"rateUpload": 10,
"status": 4,
"downloadDir": "/downloads/transmission",
"addedDate": 100,
"doneDate": 0,
}}
}
_ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments})
}))
defer transmission.Close()
aria2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req aria2Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode aria2 request: %v", err)
return
}
result := interface{}(map[string]interface{}{"version": "1.37"})
switch req.Method {
case "aria2.tellActive":
result = []map[string]interface{}{{
"gid": "aria2-gid",
"bittorrent": map[string]interface{}{"info": map[string]interface{}{"name": "Aria Movie"}, "infoHash": "aria-info-hash"},
"totalLength": "2000",
"completedLength": "500",
"downloadSpeed": "200",
"uploadSpeed": "20",
"status": "active",
"dir": "/downloads/aria2",
}}
case "aria2.tellWaiting", "aria2.tellStopped":
result = []interface{}{}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": req.ID,
"result": result,
})
}))
defer aria2.Close()
db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{})
repos := repository.New(db)
transmissionClient := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: transmission.URL, IsDefault: true, Enabled: true}
aria2Client := &model.DownloadClient{Name: "aria2", Type: "aria2", Host: aria2.URL, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), transmissionClient); err != nil {
t.Fatal(err)
}
if err := repos.DownloadClient.Create(t.Context(), aria2Client); err != nil {
t.Fatal(err)
}
manager := NewDownloadManager(zap.NewNop(), repos, nil)
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.SetDownloadManager(manager)
if err := svc.ReloadConfig(t.Context()); err != nil {
t.Fatal(err)
}
_, live, err := svc.List(t.Context())
if err != nil {
t.Fatal(err)
}
if len(live) != 2 {
t.Fatalf("live torrents = %#v", live)
}
sort.Slice(live, func(i, j int) bool { return live[i].Source < live[j].Source })
if live[0].Source != "aria2" || live[0].ClientID != aria2Client.ID || live[0].Progress != 0.25 || live[0].ContentPath != "/downloads/aria2/Aria Movie" {
t.Fatalf("aria2 live torrent = %#v", live[0])
}
if live[1].Source != "transmission" || live[1].ClientID != transmissionClient.ID || live[1].Progress != 0.5 || live[1].ContentPath != "/downloads/transmission/Transmission Movie" {
t.Fatalf("transmission live torrent = %#v", live[1])
}
}