mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-05 16:15:43 +00:00
341 lines
12 KiB
Go
341 lines
12 KiB
Go
package download
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"reflect"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
|
||
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
||
"github.com/GopeedLab/gopeed/pkg/base"
|
||
)
|
||
|
||
// State tests initialize config without starting network clients or cron loops.
|
||
func protocolConfigDownloader(t *testing.T, storage Storage, managers ...fetcher.FetcherManager) *Downloader {
|
||
t.Helper()
|
||
if storage == nil {
|
||
storage = NewMemStorage()
|
||
}
|
||
d := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir(), FetchManagers: managers})
|
||
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{}
|
||
if err := storage.Setup([]string{bucketConfig}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { d.cancelBackground(); _ = storage.Close() })
|
||
if err := d.initProtocolConfigs(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return d
|
||
}
|
||
func protocolObject(t *testing.T, d *Downloader, name string) map[string]any {
|
||
t.Helper()
|
||
cfg, err := d.GetConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
raw, err := json.Marshal(cfg.ProtocolConfig[name])
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var result map[string]any
|
||
if err := json.Unmarshal(raw, &result); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return result
|
||
}
|
||
func configureBT(t *testing.T, d *Downloader, urls ...string) {
|
||
t.Helper()
|
||
cfg, _ := d.GetConfig()
|
||
cfg.ProtocolConfig["bt"] = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": urls, "customTrackers": []string{"udp://custom.example:80/announce"}}
|
||
if err := d.PutConfig(cfg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
func seedBTState(t *testing.T, d *Downloader) {
|
||
t.Helper()
|
||
if err := d.updateProtocolState("bt", func(_, _ json.RawMessage) (any, error) {
|
||
return map[string]any{
|
||
"trackerSources": map[string][]string{"https://example.com/list": {"udp://cached.example:80/announce"}},
|
||
"trackerSubscriptionStatuses": map[string]any{"https://example.com/list": map[string]any{"success": true, "trackerCount": 1, "lastFetchTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC)}},
|
||
"lastTrackerUpdateTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC),
|
||
}, nil
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestFullConfigValidatesProtocolsAndPreservesExplicitValues(t *testing.T) {
|
||
d := protocolConfigDownloader(t, nil, &httpProtocol.FetcherManager{}, &btProtocol.FetcherManager{})
|
||
configureBT(t, d)
|
||
cfg, _ := d.GetConfig()
|
||
cfg.ProtocolConfig["http"] = map[string]any{"connections": 0, "useServerCtime": false}
|
||
if err := d.PutConfig(cfg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := protocolObject(t, d, "http")
|
||
if got["connections"] != float64(0) || got["useServerCtime"] != false {
|
||
t.Fatal(got)
|
||
}
|
||
for _, invalid := range []any{map[string]any{"connections": "bad"}, map[string]any{"useServerCtime": "bad"}} {
|
||
cfg, _ := d.GetConfig()
|
||
cfg.ProtocolConfig["http"] = invalid
|
||
if err := d.PutConfig(cfg); err == nil {
|
||
t.Fatalf("invalid protocol config accepted: %v", invalid)
|
||
}
|
||
}
|
||
cfg, _ = d.GetConfig()
|
||
cfg.DownloadDir = "must-not-publish"
|
||
cfg.ProtocolConfig["bt"] = map[string]any{"listenPort": -1}
|
||
if err := d.PutConfig(cfg); err == nil {
|
||
t.Fatal("invalid BT config accepted")
|
||
}
|
||
saved, _ := d.GetConfig()
|
||
if saved.DownloadDir == "must-not-publish" {
|
||
t.Fatal("invalid full PUT partially published")
|
||
}
|
||
cfg, _ = d.GetConfig()
|
||
cfg.ProtocolConfig["unknown"] = map[string]any{"state": map[string]any{"injected": true}}
|
||
if err := d.PutConfig(cfg); err == nil {
|
||
t.Fatal("unregistered protocol accepted")
|
||
}
|
||
// Returned configs are detached snapshots.
|
||
snapshot, _ := d.GetConfig()
|
||
snapshot.ProtocolConfig["http"] = map[string]any{"connections": 999}
|
||
if !reflect.DeepEqual(protocolObject(t, d, "http"), got) {
|
||
t.Fatal("snapshot changed live config")
|
||
}
|
||
}
|
||
|
||
func TestProtocolConfigIgnoresUnknownFields(t *testing.T) {
|
||
for _, mode := range []string{"stored", "put"} {
|
||
t.Run(mode, func(t *testing.T) {
|
||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||
cfg, _ := d.GetConfig()
|
||
cfg.ProtocolConfig["bt"] = map[string]any{
|
||
"listenPort": 6881,
|
||
"autoUpdateTrackers": false,
|
||
"trackers": []string{"udp://obsolete.example:80/announce"},
|
||
"state": map[string]any{"obsolete": true},
|
||
}
|
||
if mode == "stored" {
|
||
if err := d.storage.Put(bucketConfig, "config", cfg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var restored base.DownloaderStoreConfig
|
||
if exists, err := d.storage.Get(bucketConfig, "config", &restored); err != nil || !exists {
|
||
t.Fatalf("load stored config: %v %v", exists, err)
|
||
}
|
||
d.cfg.DownloaderStoreConfig = &restored
|
||
if err := d.initProtocolConfigs(); err != nil {
|
||
t.Fatalf("load config containing removed fields: %v", err)
|
||
}
|
||
} else if err := d.PutConfig(cfg); err != nil {
|
||
t.Fatalf("save config containing unknown fields: %v", err)
|
||
}
|
||
actual := protocolObject(t, d, "bt")
|
||
if actual["listenPort"] != float64(6881) || actual["autoUpdateTrackers"] != false {
|
||
t.Fatalf("known fields changed: %v", actual)
|
||
}
|
||
if _, exists := actual["trackers"]; exists {
|
||
t.Fatal("removed field retained")
|
||
}
|
||
if len(actual["customTrackers"].([]any)) != 0 {
|
||
t.Fatal("removed trackers migrated into custom trackers")
|
||
}
|
||
if _, exists := actual["state"].(map[string]any)["obsolete"]; exists {
|
||
t.Fatal("unknown state field retained")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestBTStateSurvivesOmittedNullForgedAndStaleFullConfig(t *testing.T) {
|
||
for _, mode := range []string{"omitted", "null", "forged", "wrong-type", "stale", "omitted-protocol", "omitted-timestamp", "case-variant", "unicode-variant"} {
|
||
t.Run(mode, func(t *testing.T) {
|
||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||
configureBT(t, d, "https://example.com/list")
|
||
stale, _ := d.GetConfig()
|
||
seedBTState(t, d)
|
||
expected := protocolObject(t, d, "bt")["state"]
|
||
cfg, _ := d.GetConfig()
|
||
bt := protocolObject(t, d, "bt")
|
||
switch mode {
|
||
case "omitted":
|
||
delete(bt, "state")
|
||
case "null":
|
||
bt["state"] = nil
|
||
case "forged":
|
||
bt["state"] = map[string]any{"lastTrackerUpdateTime": "2000-01-01T00:00:00Z", "trackerSources": map[string]any{}, "unknown": true}
|
||
case "wrong-type":
|
||
bt["state"] = "ignore this before decoding"
|
||
case "stale":
|
||
cfg = stale
|
||
bt = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": []string{"https://example.com/list"}, "customTrackers": []string{"udp://custom.example:80/announce"}, "state": map[string]any{}}
|
||
case "omitted-protocol":
|
||
delete(cfg.ProtocolConfig, "bt")
|
||
case "case-variant":
|
||
bt["STATE"] = "ignore this alias"
|
||
case "unicode-variant":
|
||
bt["ſtate"] = "ignore Unicode case folding"
|
||
case "omitted-timestamp":
|
||
bt["state"] = map[string]any{"trackerSources": map[string]any{}}
|
||
}
|
||
if mode != "omitted-protocol" {
|
||
bt["listenPort"] = 6881
|
||
cfg.ProtocolConfig["bt"] = bt
|
||
}
|
||
if err := d.PutConfig(cfg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
actual := protocolObject(t, d, "bt")
|
||
if !reflect.DeepEqual(actual["state"], expected) {
|
||
t.Fatalf("backend state replaced: %v", actual)
|
||
}
|
||
if _, exists := actual["trackers"]; exists {
|
||
t.Fatal("derived tracker list persisted")
|
||
}
|
||
if mode != "omitted-protocol" && actual["listenPort"] != float64(6881) {
|
||
t.Fatal("writable config did not change")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestBTConfigRemovalPrunesOnlyRemovedState(t *testing.T) {
|
||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||
configureBT(t, d, "https://example.com/list")
|
||
seedBTState(t, d)
|
||
configureBT(t, d, "https://new.example/list")
|
||
state := protocolObject(t, d, "bt")["state"].(map[string]any)
|
||
if len(state["trackerSources"].(map[string]any)) != 0 || len(state["trackerSubscriptionStatuses"].(map[string]any)) != 0 {
|
||
t.Fatal("removed subscription retained state")
|
||
}
|
||
}
|
||
|
||
type counterState struct {
|
||
Count int `json:"count"`
|
||
Other int `json:"other"`
|
||
}
|
||
type counterConfig struct {
|
||
fetcher.ConfigState[counterState]
|
||
Writable int `json:"writable"`
|
||
}
|
||
type counterManager struct{ httpProtocol.FetcherManager }
|
||
|
||
func (*counterManager) Name() string { return "counter" }
|
||
func (*counterManager) DefaultConfig() any { return &counterConfig{Writable: 7} }
|
||
func counterHost(d *Downloader) fetcher.ProtocolHost {
|
||
return fetcher.ProtocolHost{UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
|
||
return d.updateProtocolState("counter", change)
|
||
}}
|
||
}
|
||
|
||
func TestProtocolUpdateStateCommitsOnlyStateAndUsesLatestConfig(t *testing.T) {
|
||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||
host := counterHost(d)
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 20; i++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
if err := fetcher.UpdateState[counterConfig, counterState](host, func(cfg *counterConfig, state *counterState) error { cfg.Writable = 999; state.Count++; return nil }); err != nil {
|
||
t.Error(err)
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
actual := protocolObject(t, d, "counter")
|
||
if actual["writable"] != float64(7) || actual["state"].(map[string]any)["count"] != float64(20) {
|
||
t.Fatal(actual)
|
||
}
|
||
// Rejecting a callback must not publish its partial mutation.
|
||
err := fetcher.UpdateState[counterConfig, counterState](host, func(_ *counterConfig, state *counterState) error { state.Count = 1000; return errors.New("reject") })
|
||
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), actual) {
|
||
t.Fatal("failed callback published state")
|
||
}
|
||
if err := d.updateProtocolState("counter", func(_, _ json.RawMessage) (any, error) { return map[string]any{"count": "bad"}, nil }); err == nil {
|
||
t.Fatal("invalid state accepted")
|
||
}
|
||
}
|
||
|
||
func TestProtocolStateAndFullPutDoNotLoseConcurrentUpdates(t *testing.T) {
|
||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||
snapshot, _ := d.GetConfig()
|
||
snapshot.ProtocolConfig["counter"] = map[string]any{"writable": 21}
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 20; i++ {
|
||
wg.Add(2)
|
||
go func() {
|
||
defer wg.Done()
|
||
if err := d.PutConfig(snapshot); err != nil {
|
||
t.Error(err)
|
||
}
|
||
}()
|
||
go func() {
|
||
defer wg.Done()
|
||
if err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(cfg *counterConfig, state *counterState) error { state.Count++; return nil }); err != nil {
|
||
t.Error(err)
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
actual := protocolObject(t, d, "counter")
|
||
if actual["writable"] != float64(21) || actual["state"].(map[string]any)["count"] != float64(20) {
|
||
t.Fatal(actual)
|
||
}
|
||
}
|
||
|
||
func TestBTSubscriptionStatusesSurviveStorageRestart(t *testing.T) {
|
||
dir := t.TempDir()
|
||
storage := NewBoltStorage(dir)
|
||
d := protocolConfigDownloader(t, storage, &btProtocol.FetcherManager{})
|
||
configureBT(t, d, "https://example.com/list")
|
||
seedBTState(t, d)
|
||
expected := protocolObject(t, d, "bt")["state"]
|
||
if err := storage.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reopened := NewBoltStorage(dir)
|
||
if err := reopened.Setup([]string{bucketConfig}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer reopened.Close()
|
||
var persisted base.DownloaderStoreConfig
|
||
if exists, err := reopened.Get(bucketConfig, "config", &persisted); err != nil || !exists {
|
||
t.Fatalf("reload persisted config: %v %v", exists, err)
|
||
}
|
||
raw, _ := json.Marshal(persisted.ProtocolConfig["bt"])
|
||
var cfg map[string]any
|
||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !reflect.DeepEqual(cfg["state"], expected) {
|
||
t.Fatalf("state lost after reopening storage: %+v", cfg)
|
||
}
|
||
}
|
||
|
||
type rejectedConfigStorage struct{ Storage }
|
||
|
||
func (*rejectedConfigStorage) Put(string, string, any) error {
|
||
return errors.New("storage unavailable")
|
||
}
|
||
|
||
func TestProtocolStateAndPutStorageFailureDoNotPublish(t *testing.T) {
|
||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||
before := protocolObject(t, d, "counter")
|
||
d.storage = &rejectedConfigStorage{Storage: d.storage}
|
||
err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(_ *counterConfig, state *counterState) error { state.Count = 99; return nil })
|
||
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
|
||
t.Fatal("failed state persistence published a change")
|
||
}
|
||
cfg, _ := d.GetConfig()
|
||
cfg.ProtocolConfig["counter"] = map[string]any{"writable": 99}
|
||
if err := d.PutConfig(cfg); err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
|
||
t.Fatal("failed config persistence published a change")
|
||
}
|
||
}
|