Checked against slskd 0.26.0's source rather than a live daemon, which #267 never had. slskd reads a search's searchTimeout in seconds, counted from the last response. We sent milliseconds, telling it a search may idle for five hours, so a search never completed on its own. It now sends seconds, with slskd's floor of 5. slskd writes a finished file to <downloads>/<remote leaf folder>/, and when a name is taken it writes name_<ticks>.ext beside it. collect found files by name there, so a file left by an earlier failed attempt, or by the user's own download, was collected in place of this grab's. A user who changed slskd's destination setting got nothing collected at all. slskd 0.26 takes a batch download with an explicit destination. Each grab now enqueues batches into yellowjacket/<uuid>/ (one per disc, since a batch's files land flat), collects from exactly there, and removes the folder afterwards, including after a failure. An older daemon answers the batch route with 400, which is remembered, and the per-user enqueue is used. There, collect skips files that were already present, unchanged, before the enqueue, and takes the renamed copy slskd wrote instead. The comparison is against a snapshot, not a clock, because slskd may run on another machine. The per-folder lock from #272 is kept only while batches are not known to work. The test stub now writes files when they are enqueued, as slskd does, including the rename, so tests no longer stage files before a grab. An opt-in TestSlskdLive runs against a real daemon when YJ_SLSKD_URL, YJ_SLSKD_API_KEY and YJ_SLSKD_DOWNLOADS are set. Closes #274 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
1014 lines
24 KiB
Go
1014 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
|
|
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.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
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|