No bytes for ten minutes usually means a peer has queued us, and the
stall timer could not tell position 2 from position 400: the first was
abandoned while it was about to start, the second was waited on for ten
minutes for nothing.
While a requested file is "Queued, Remotely", the grab asks slskd for
its place (GET .../downloads/{user}/{id}/position, which asks the peer)
once a minute. A place that improved counts as progress and restarts
the stall clock. A place beyond 50 twice running gives the peer up at
once, and the manager moves to the next copy; two readings because
slskd documents the figure as possibly inaccurate. Waiting in a queue
has an overall ceiling of an hour without a byte, since a queue moving
one place an hour would otherwise hold the grab all day. As with a
stall, files that already arrived still go forward.
Closes #275
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
1038 lines
24 KiB
Go
1038 lines
24 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// slskd tests run against an httptest server shaped like the real API.
|
|
// No daemon, no Soulseek account, no network.
|
|
|
|
// slskdStub is a fake slskd daemon.
|
|
type slskdStub struct {
|
|
server *httptest.Server
|
|
|
|
mu sync.Mutex
|
|
|
|
// responses is what a search returns.
|
|
responses []slskdResponse
|
|
|
|
// transfers is what the downloads endpoint reports, in order; the
|
|
// last entry repeats.
|
|
transfers [][]slskdTransfer
|
|
pollCount int
|
|
|
|
// before is what the downloads endpoint reports until something is
|
|
// enqueued: records slskd already held from earlier attempts.
|
|
before []slskdTransfer
|
|
|
|
// enqueued records what was requested for download.
|
|
enqueued []map[string]any
|
|
posted bool
|
|
|
|
// paths records the escaped path of every transfers call, and
|
|
// cancelled the escaped request URI of every DELETE.
|
|
paths []string
|
|
cancelled []string
|
|
|
|
// unauthorized makes every call return 401.
|
|
unauthorized bool
|
|
|
|
// searches records every search request body, and searchGets the
|
|
// request URI of every search GET.
|
|
searches []map[string]any
|
|
searchGets []string
|
|
|
|
// noResponsesEndpoint makes /searches/{id}/responses 404, as an
|
|
// older daemon would.
|
|
noResponsesEndpoint bool
|
|
|
|
// batches makes the daemon take batch downloads, as 0.26 does.
|
|
// Without it the batch endpoint answers 400, which is what an older
|
|
// daemon's per-user route does with a batch body. batchBodies
|
|
// records each batch, and delivered is written into its destination
|
|
// under downloads when it is enqueued, keyed by file base name.
|
|
batches bool
|
|
batchBodies []map[string]any
|
|
|
|
// positions is what the queue-position endpoint answers, in order;
|
|
// the last repeats. positionAsks counts the calls.
|
|
positions []int
|
|
positionAsks int
|
|
delivered map[string]string
|
|
downloads string
|
|
}
|
|
|
|
func newSlskdStub(t *testing.T) *slskdStub {
|
|
t.Helper()
|
|
|
|
s := &slskdStub{}
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/api/v0/application", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
writeJSON(t, w, map[string]any{"version": "0.21.0"})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v0/searches", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode search body: %v", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.searches = append(s.searches, body)
|
|
s.mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
})
|
|
|
|
mux.HandleFunc("/api/v0/searches/", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodDelete {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
responses := s.responses
|
|
noEndpoint := s.noResponsesEndpoint
|
|
s.searchGets = append(s.searchGets, r.URL.RequestURI())
|
|
s.mu.Unlock()
|
|
|
|
if strings.HasSuffix(r.URL.Path, "/responses") {
|
|
if noEndpoint {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
writeJSON(t, w, responses)
|
|
|
|
return
|
|
}
|
|
|
|
if r.URL.Query().Get("includeResponses") != "true" {
|
|
responses = nil
|
|
}
|
|
|
|
writeJSON(t, w, slskdSearch{
|
|
ID: "search-1",
|
|
IsComplete: true,
|
|
Responses: responses,
|
|
})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v0/transfers/downloads/", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.paths = append(s.paths, r.URL.EscapedPath())
|
|
s.mu.Unlock()
|
|
|
|
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/position") {
|
|
s.mu.Lock()
|
|
idx := min(s.positionAsks, len(s.positions)-1)
|
|
s.positionAsks++
|
|
place := 0
|
|
|
|
if idx >= 0 {
|
|
place = s.positions[idx]
|
|
}
|
|
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, place)
|
|
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/batches") {
|
|
s.enqueueBatch(t, w, r)
|
|
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
var body []map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode enqueue body: %v", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.enqueued = body
|
|
s.posted = true
|
|
|
|
for _, file := range body {
|
|
name, _ := file["filename"].(string)
|
|
norm := strings.ReplaceAll(name, `\`, "/")
|
|
s.write(t, path.Base(path.Dir(norm)), path.Base(norm))
|
|
}
|
|
|
|
s.mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
return
|
|
case http.MethodDelete:
|
|
s.mu.Lock()
|
|
s.cancelled = append(s.cancelled, r.URL.RequestURI())
|
|
s.mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
|
|
if !s.posted {
|
|
before := s.before
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, map[string]any{
|
|
"directories": []map[string]any{{"files": before}},
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
idx := s.pollCount
|
|
if idx >= len(s.transfers) {
|
|
idx = len(s.transfers) - 1
|
|
} else {
|
|
s.pollCount++
|
|
}
|
|
|
|
var batch []slskdTransfer
|
|
if idx >= 0 && len(s.transfers) > 0 {
|
|
batch = s.transfers[idx]
|
|
}
|
|
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, map[string]any{
|
|
"directories": []map[string]any{{"files": batch}},
|
|
})
|
|
})
|
|
|
|
s.server = httptest.NewServer(mux)
|
|
t.Cleanup(s.server.Close)
|
|
|
|
return s
|
|
}
|
|
|
|
// enqueueBatch answers the batch endpoint.
|
|
func (s *slskdStub) enqueueBatch(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
|
t.Helper()
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if !s.batches {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode batch body: %v", err)
|
|
}
|
|
|
|
s.batchBodies = append(s.batchBodies, body)
|
|
s.posted = true
|
|
|
|
files, _ := body["files"].([]any)
|
|
options, _ := body["options"].(map[string]any)
|
|
dest, _ := options["destination"].(string)
|
|
|
|
for _, f := range files {
|
|
file, _ := f.(map[string]any)
|
|
s.enqueued = append(s.enqueued, file)
|
|
|
|
name, _ := file["filename"].(string)
|
|
base := path.Base(strings.ReplaceAll(name, `\`, "/"))
|
|
|
|
s.write(t, filepath.FromSlash(dest), base)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
|
|
// deliver names the files that arrive once enqueued.
|
|
func (s *slskdStub) deliver(names ...string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.delivered == nil {
|
|
s.delivered = map[string]string{}
|
|
}
|
|
|
|
for _, n := range names {
|
|
s.delivered[n] = "audio"
|
|
}
|
|
}
|
|
|
|
// write puts a delivered file where slskd would: under dir in the
|
|
// downloads folder, renamed name_<ticks>.ext when the name is taken, as
|
|
// slskd's default Destination.Exists does. Callers hold s.mu.
|
|
func (s *slskdStub) write(t *testing.T, dir, base string) {
|
|
t.Helper()
|
|
|
|
content, ok := s.delivered[base]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
full := filepath.Join(s.downloads, dir)
|
|
if err := os.MkdirAll(full, 0o750); err != nil {
|
|
t.Errorf("mkdir: %v", err)
|
|
}
|
|
|
|
target := filepath.Join(full, base)
|
|
if _, err := os.Stat(target); err == nil {
|
|
ext := filepath.Ext(base)
|
|
target = filepath.Join(
|
|
full,
|
|
strings.TrimSuffix(base, ext)+"_"+strconv.FormatInt(time.Now().UnixNano(), 10)+ext,
|
|
)
|
|
}
|
|
|
|
if err := os.WriteFile(target, []byte(content), 0o600); err != nil {
|
|
t.Errorf("write: %v", err)
|
|
}
|
|
}
|
|
|
|
// reject enforces API-key auth like the real daemon.
|
|
func (s *slskdStub) reject(w http.ResponseWriter, r *http.Request) bool {
|
|
s.mu.Lock()
|
|
unauthorized := s.unauthorized
|
|
s.mu.Unlock()
|
|
|
|
if unauthorized || r.Header.Get("X-Api-Key") != "test-key" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
|
t.Helper()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
t.Errorf("encode response: %v", err)
|
|
}
|
|
}
|
|
|
|
// newStubSlskd builds the provider pointed at the stub, with a real
|
|
// temp directory standing in for slskd's downloads folder.
|
|
func newStubSlskd(t *testing.T, stub *slskdStub) (*slskd, string) {
|
|
t.Helper()
|
|
|
|
downloads := t.TempDir()
|
|
|
|
stub.mu.Lock()
|
|
stub.downloads = downloads
|
|
stub.mu.Unlock()
|
|
|
|
p, err := newSlskd(
|
|
Config{
|
|
ID: 1,
|
|
Kind: KindSlskd,
|
|
Name: "slskd",
|
|
Enabled: true,
|
|
Settings: map[string]string{
|
|
"url": stub.server.URL,
|
|
"downloadsPath": downloads,
|
|
},
|
|
},
|
|
func(string) (string, error) { return "test-key", nil },
|
|
slogDiscard(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newSlskd: %v", err)
|
|
}
|
|
|
|
s, ok := p.(*slskd)
|
|
if !ok {
|
|
t.Fatalf("provider is %T, want *slskd", p)
|
|
}
|
|
|
|
// Real intervals are tuned for Soulseek's pace; tests only care
|
|
// about the state machine, so run it at full speed.
|
|
s.searchPoll = time.Millisecond
|
|
s.searchWait = 200 * time.Millisecond
|
|
s.transferPoll = time.Millisecond
|
|
|
|
// Long enough that no existing test trips them by accident; the
|
|
// tests about stalls and absences set their own.
|
|
s.stallAfter = time.Minute
|
|
s.absentGrace = time.Minute
|
|
s.positionPoll = time.Millisecond
|
|
s.queueCeiling = time.Hour
|
|
|
|
return s, downloads
|
|
}
|
|
|
|
func TestSlskdCheck(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
if err := s.Check(context.Background()); err != nil {
|
|
t.Errorf("Check: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSlskdCheckRejectsBadKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.unauthorized = true
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
if err := s.Check(context.Background()); !errors.Is(err, ErrSlskdAuth) {
|
|
t.Errorf("error = %v, want ErrSlskdAuth", err)
|
|
}
|
|
}
|
|
|
|
// A downloads folder that is not readable from this machine is the
|
|
// classic slskd-on-a-NAS misconfiguration, and must surface at
|
|
// configuration time rather than after a long transfer.
|
|
func TestSlskdCheckRejectsUnreadableDownloadsPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
|
|
p, err := newSlskd(
|
|
Config{
|
|
ID: 1,
|
|
Settings: map[string]string{
|
|
"url": stub.server.URL,
|
|
"downloadsPath": "/definitely/not/a/real/path",
|
|
},
|
|
},
|
|
func(string) (string, error) { return "test-key", nil },
|
|
slogDiscard(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newSlskd: %v", err)
|
|
}
|
|
|
|
if err := p.Check(context.Background()); !errors.Is(
|
|
err, ErrSlskdDownloadsPath,
|
|
) {
|
|
t.Errorf("error = %v, want ErrSlskdDownloadsPath", err)
|
|
}
|
|
}
|
|
|
|
// Soulseek has no album concept, so candidates are built by grouping a
|
|
// peer's files into the folders they live in.
|
|
func TestSlskdGroupsResultsByPeerAndFolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.responses = []slskdResponse{
|
|
{
|
|
Username: "peer-one",
|
|
HasFreeUploadSlot: true,
|
|
QueueLength: 0,
|
|
UploadSpeed: 2_000_000,
|
|
Files: []slskdFile{
|
|
{Filename: `@@x\Music\OK Computer\01 Airbag.flac`, Size: 30_000_000},
|
|
{Filename: `@@x\Music\OK Computer\02 Paranoid Android.flac`, Size: 40_000_000},
|
|
{Filename: `@@x\Music\Kid A\01 Everything.flac`, Size: 30_000_000},
|
|
{Filename: `@@x\Music\Kid A\02 Kid A.flac`, Size: 30_000_000},
|
|
},
|
|
},
|
|
{
|
|
Username: "peer-two",
|
|
HasFreeUploadSlot: false,
|
|
QueueLength: 40,
|
|
Files: []slskdFile{
|
|
{
|
|
Filename: `\share\OK Computer [320]\01 - Airbag.mp3`,
|
|
Size: 8_000_000,
|
|
BitRate: 320,
|
|
},
|
|
{
|
|
Filename: `\share\OK Computer [320]\02 - Paranoid Android.mp3`,
|
|
Size: 9_000_000,
|
|
BitRate: 320,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
got, err := s.Search(context.Background(), Download{
|
|
Artist: "Radiohead",
|
|
Album: "OK Computer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
// Two folders from peer-one, one from peer-two.
|
|
if len(got) != 3 {
|
|
t.Fatalf("got %d candidates, want 3", len(got))
|
|
}
|
|
|
|
byOrigin := map[string]int{}
|
|
for _, c := range got {
|
|
byOrigin[c.Origin]++
|
|
}
|
|
|
|
if byOrigin["peer-one"] != 2 {
|
|
t.Errorf("peer-one folders = %d, want 2", byOrigin["peer-one"])
|
|
}
|
|
|
|
if byOrigin["peer-two"] != 1 {
|
|
t.Errorf("peer-two folders = %d, want 1", byOrigin["peer-two"])
|
|
}
|
|
}
|
|
|
|
// A busy peer behind a long queue is a worse bet than a free one, no
|
|
// matter how good the files look.
|
|
func TestSlskdPeerHealthReflectsAvailability(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
free := peerHealth(slskdResponse{
|
|
HasFreeUploadSlot: true,
|
|
QueueLength: 0,
|
|
UploadSpeed: 2_000_000,
|
|
})
|
|
|
|
busy := peerHealth(slskdResponse{
|
|
HasFreeUploadSlot: false,
|
|
QueueLength: 40,
|
|
})
|
|
|
|
if free <= busy {
|
|
t.Errorf("free peer health %f should exceed busy peer %f", free, busy)
|
|
}
|
|
|
|
if free > 1 || busy < 0 {
|
|
t.Errorf("health out of range: free=%f busy=%f", free, busy)
|
|
}
|
|
}
|
|
|
|
// Folders with almost nothing in them are Soulseek noise, not albums.
|
|
func TestSlskdSkipsTinyFolders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.responses = []slskdResponse{{
|
|
Username: "peer",
|
|
Files: []slskdFile{
|
|
{Filename: `\share\Random\one.mp3`, Size: 5_000_000},
|
|
},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
got, err := s.Search(context.Background(), Download{Query: "x"})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 0 {
|
|
t.Errorf("got %d candidates, want 0 for a single-file folder", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSlskdGrabCollectsFromDownloadsFolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{
|
|
{
|
|
{
|
|
Filename: `\share\OK Computer\01 Airbag.flac`,
|
|
State: "InProgress",
|
|
BytesTransferred: 100,
|
|
},
|
|
{
|
|
Filename: `\share\OK Computer\02 Paranoid Android.flac`,
|
|
State: "InProgress",
|
|
},
|
|
},
|
|
{
|
|
{
|
|
Filename: `\share\OK Computer\01 Airbag.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
{
|
|
Filename: `\share\OK Computer\02 Paranoid Android.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
},
|
|
}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
stub.deliver("01 Airbag.flac", "02 Paranoid Android.flac")
|
|
|
|
c := Candidate{
|
|
ID: "slskd:peer:OK Computer",
|
|
Protocol: ProtocolDirect,
|
|
Files: []CandidateFile{
|
|
{Path: `\share\OK Computer\01 Airbag.flac`, Size: 500, IsAudio: true},
|
|
{Path: `\share\OK Computer\02 Paranoid Android.flac`, Size: 500, IsAudio: true},
|
|
},
|
|
TotalSize: 1000,
|
|
Payload: map[string]string{"username": "peer"},
|
|
}
|
|
|
|
dst := t.TempDir()
|
|
|
|
got, err := s.Grab(context.Background(), c, dst, nil)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 2 {
|
|
t.Fatalf("collected %d files, want 2", len(got.Files))
|
|
}
|
|
|
|
for _, f := range got.Files {
|
|
if !strings.HasPrefix(f, dst) {
|
|
t.Errorf("file %s is not inside the staging dir %s", f, dst)
|
|
}
|
|
|
|
if _, err := os.Stat(f); err != nil {
|
|
t.Errorf("collected file missing: %v", err)
|
|
}
|
|
}
|
|
|
|
// The enqueue request named the files the candidate listed.
|
|
stub.mu.Lock()
|
|
enqueued := len(stub.enqueued)
|
|
stub.mu.Unlock()
|
|
|
|
if enqueued != 2 {
|
|
t.Errorf("enqueued %d files, want 2", enqueued)
|
|
}
|
|
}
|
|
|
|
// A peer that drops mid-folder is normal; partial results go forward
|
|
// and the importer's completeness check decides.
|
|
func TestSlskdGrabToleratesPartialFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{
|
|
Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
{Filename: `\s\Album\02 B.flac`, State: "Completed, Errored"},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
stub.deliver("01 A.flac")
|
|
|
|
c := Candidate{
|
|
Files: []CandidateFile{
|
|
{Path: `\s\Album\01 A.flac`, Size: 500, IsAudio: true},
|
|
{Path: `\s\Album\02 B.flac`, Size: 500, IsAudio: true},
|
|
},
|
|
Payload: map[string]string{"username": "peer"},
|
|
}
|
|
|
|
got, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Errorf("collected %d files, want the 1 that succeeded", len(got.Files))
|
|
}
|
|
}
|
|
|
|
func TestSlskdGrabFailsWhenEverythingFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{Filename: `\s\Album\01 A.flac`, State: "Completed, Errored"},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
c := Candidate{
|
|
Files: []CandidateFile{{Path: `\s\Album\01 A.flac`, IsAudio: true}},
|
|
Payload: map[string]string{"username": "peer"},
|
|
}
|
|
|
|
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
|
if !errors.Is(err, ErrSlskdTransferFailed) {
|
|
t.Errorf("error = %v, want ErrSlskdTransferFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestSlskdRequiresConfiguration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
settings map[string]string
|
|
secrets SecretLookup
|
|
}{
|
|
{
|
|
name: "no url",
|
|
settings: map[string]string{"downloadsPath": "/tmp"},
|
|
secrets: func(string) (string, error) { return "k", nil },
|
|
},
|
|
{
|
|
name: "no downloads path",
|
|
settings: map[string]string{"url": "http://localhost:5030"},
|
|
secrets: func(string) (string, error) { return "k", nil },
|
|
},
|
|
{
|
|
name: "no api key",
|
|
settings: map[string]string{
|
|
"url": "http://localhost:5030", "downloadsPath": "/tmp",
|
|
},
|
|
secrets: func(string) (string, error) {
|
|
return "", ErrSecretNotFound
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := newSlskd(
|
|
Config{Settings: tt.settings}, tt.secrets, slogDiscard(),
|
|
)
|
|
|
|
if !errors.Is(err, ErrNotConfigured) {
|
|
t.Errorf("error = %v, want ErrNotConfigured", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// slskdAlbum is a two-file candidate from peer, whose arrived files
|
|
// the stub writes where slskd would once they are enqueued.
|
|
func slskdAlbum(t *testing.T, stub *slskdStub, peer string, arrived ...string) Candidate {
|
|
t.Helper()
|
|
|
|
stub.deliver(arrived...)
|
|
|
|
return Candidate{
|
|
Files: []CandidateFile{
|
|
{Path: `\s\Album\01 A.flac`, Size: 500, IsAudio: true},
|
|
{Path: `\s\Album\02 B.flac`, Size: 500, IsAudio: true},
|
|
},
|
|
TotalSize: 1000,
|
|
Payload: map[string]string{"username": peer},
|
|
}
|
|
}
|
|
|
|
// cancelledURIs returns what the stub was asked to cancel.
|
|
func (s *slskdStub) cancelledURIs() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return append([]string(nil), s.cancelled...)
|
|
}
|
|
|
|
// A peer that queues us and never sends a byte is given up on, and the
|
|
// queued transfers are cancelled in slskd rather than left to start
|
|
// hours later for a request nobody is waiting on.
|
|
func TestSlskdGrabGivesUpOnAStalledPeer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "Queued, Remotely"},
|
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
s.stallAfter = 30 * time.Millisecond
|
|
|
|
_, err := s.Grab(
|
|
context.Background(), slskdAlbum(t, stub, "peer"), t.TempDir(), nil,
|
|
)
|
|
if !errors.Is(err, ErrSlskdTimeout) {
|
|
t.Fatalf("error = %v, want ErrSlskdTimeout", err)
|
|
}
|
|
|
|
got := stub.cancelledURIs()
|
|
if len(got) != 2 {
|
|
t.Fatalf("cancelled %v, want both queued transfers", got)
|
|
}
|
|
|
|
for _, uri := range got {
|
|
if !strings.HasSuffix(uri, "?remove=true") {
|
|
t.Errorf("cancel %s does not remove the record", uri)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A folder that stalls on its last track goes forward with what
|
|
// arrived, the same as one whose last track failed; the importer's
|
|
// completeness check decides whether that is enough.
|
|
func TestSlskdGrabKeepsWhatArrivedBeforeAStall(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{
|
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
s.stallAfter = 30 * time.Millisecond
|
|
|
|
got, err := s.Grab(
|
|
context.Background(),
|
|
slskdAlbum(t, stub, "peer", "01 A.flac"),
|
|
t.TempDir(), nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Errorf("collected %d files, want the 1 that arrived", len(got.Files))
|
|
}
|
|
|
|
if cancelled := stub.cancelledURIs(); len(cancelled) != 1 ||
|
|
!strings.Contains(cancelled[0], "/t2") {
|
|
t.Errorf("cancelled %v, want only the stalled t2", cancelled)
|
|
}
|
|
}
|
|
|
|
// Progress is what holds the stall timer off. A transfer that keeps
|
|
// moving bytes is never abandoned, however long it takes.
|
|
func TestSlskdGrabWaitsOnATransferThatIsMoving(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
|
|
for b := int64(1); b <= 100; b++ {
|
|
stub.transfers = append(stub.transfers, []slskdTransfer{
|
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "InProgress", BytesTransferred: b},
|
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
|
})
|
|
}
|
|
|
|
stub.transfers = append(stub.transfers, []slskdTransfer{
|
|
{
|
|
ID: "t1",
|
|
Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
{
|
|
ID: "t2",
|
|
Filename: `\s\Album\02 B.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
})
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
// A hundred polls take several times the stall window; each one
|
|
// moves a byte. The window is kept well above one poll so a
|
|
// descheduled test runner does not read as a stall.
|
|
s.transferPoll = 5 * time.Millisecond
|
|
s.stallAfter = 150 * time.Millisecond
|
|
|
|
got, err := s.Grab(
|
|
context.Background(),
|
|
slskdAlbum(t, stub, "peer", "01 A.flac", "02 B.flac"),
|
|
t.TempDir(), nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 2 {
|
|
t.Errorf("collected %d files, want 2", len(got.Files))
|
|
}
|
|
}
|
|
|
|
// A file slskd never lists was refused at enqueue and will never reach
|
|
// a terminal state. It counts as failed once the grace period is up,
|
|
// rather than being waited on until the six-hour ceiling.
|
|
func TestSlskdGrabCountsAnUnlistedFileAsFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{
|
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
s.absentGrace = 20 * time.Millisecond
|
|
|
|
got, err := s.Grab(
|
|
context.Background(),
|
|
slskdAlbum(t, stub, "peer", "01 A.flac"),
|
|
t.TempDir(), nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Errorf("collected %d files, want 1", len(got.Files))
|
|
}
|
|
}
|
|
|
|
// A finished record left by an earlier attempt at the same file is not
|
|
// this attempt's answer. Without the snapshot it would fail the grab on
|
|
// the first poll, before the new transfer had started.
|
|
func TestSlskdGrabIgnoresAnEarlierAttemptsRecord(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stale := slskdTransfer{
|
|
ID: "old", Filename: `\s\Album\01 A.flac`, State: "Completed, Errored",
|
|
}
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.before = []slskdTransfer{stale}
|
|
stub.transfers = [][]slskdTransfer{
|
|
{stale},
|
|
{
|
|
stale,
|
|
{
|
|
ID: "new", Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
},
|
|
}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
c := slskdAlbum(t, stub, "peer", "01 A.flac")
|
|
c.Files = c.Files[:1]
|
|
|
|
got, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Errorf("collected %d files, want 1", len(got.Files))
|
|
}
|
|
}
|
|
|
|
// Cancelling the download cancels the transfer in slskd too. The
|
|
// cleanup must not inherit the cancelled context, or it is never sent.
|
|
func TestSlskdGrabCancelsTransfersWhenTheCallerGivesUp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "InProgress", BytesTransferred: 10},
|
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := s.Grab(ctx, slskdAlbum(t, stub, "peer"), t.TempDir(), nil)
|
|
if !errors.Is(err, ErrSlskdTimeout) {
|
|
t.Fatalf("error = %v, want ErrSlskdTimeout", err)
|
|
}
|
|
|
|
if got := stub.cancelledURIs(); len(got) != 2 {
|
|
t.Errorf("cancelled %v, want both live transfers", got)
|
|
}
|
|
}
|
|
|
|
// Soulseek usernames carry spaces and punctuation; spliced raw into the
|
|
// path, a name with a slash addresses a different endpoint entirely.
|
|
func TestSlskdEscapesTheUsername(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{
|
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
{
|
|
ID: "t2", Filename: `\s\Album\02 B.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
if _, err := s.Grab(
|
|
context.Background(),
|
|
slskdAlbum(t, stub, "dj a/b", "01 A.flac", "02 B.flac"),
|
|
t.TempDir(), nil,
|
|
); err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
paths := append([]string(nil), stub.paths...)
|
|
stub.mu.Unlock()
|
|
|
|
for _, p := range paths {
|
|
// The batch endpoint carries the name in its body.
|
|
if p != "/api/v0/transfers/downloads/dj%20a%2Fb" &&
|
|
p != "/api/v0/transfers/downloads/batches" {
|
|
t.Errorf("transfers call went to %s", p)
|
|
}
|
|
}
|
|
}
|