Files
gopeed/pkg/download/protocol_state_test.go
T

341 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}