Files
streamrip-go/internal/provider/soundcloud/client_test.go
T
JorenandClaude Opus 5 b3b6a2eb1d refactor: delete dead code, dedupe helpers, add tests for untested packages
Dead code
  Removed, each verified to have no non-test caller: promptSearchSelection
  (77 lines, superseded by promptSearchSelectionMenu), AddPlaylistByTrackIDs,
  download.NewWithVerifySSL, qobuz.Client.GetTrackMetadata,
  tidal.Client.trackSupportsAtmos (test-only, and the comment above
  GetDownloadable already documented its removal from the live path) and its
  now-orphaned test, yandex.canonicalTrackRequestID (a no-op returning its
  trimmed input; the call site uses strings.TrimSpace directly) and the unused
  defaultEstimatedKbps constant.

Config keys that did nothing
  qobuz.download_booklets and misc.check_for_updates were declared, defaulted
  and documented in config.toml.example but read by no code. The whole
  [youtube] block went too: there is no YouTube provider. go-toml ignores
  unknown keys, verified, so existing user configs still load.

Duplicated helpers
  tidal.stringify and yandex.firstNonEmpty were byte-identical copies of
  jsonutil.StringFromAny and jsonutil.FirstNonEmpty. displayArtistNames
  existed three times, byte-identical, in internal/app, provider/yandex and
  provider/beatport; it now lives in jsonutil. The CLI's normalizeCodec
  delegates to config.NormalizeCodec so the flag and the config file are
  checked by the same rules.

Tests for packages that had none
  internal/jsonutil (0% -> 100%) and internal/netutil (0% -> 78%), the latter
  covering redactURL, which is a security control, and the zero-timeout
  contract that internal/download and the qobuz body copy depend on.
  internal/config rose to 82% with coverage of the new Validate(), the
  0600 permissions, CloneConfigData not sharing slices, and a concurrent
  UpdateTokens test that exercises the mutex under -race.

Test suite is ~85x faster
  Provider tests built clients with the default 60 requests/minute, so the
  rate limiter slept 1s per HTTP call: deezer took 31s, tidal 11s, qobuz 8s.
  Each provider test package now builds its config through a testConfigData
  helper that disables throttling. Whole suite: ~60s -> 0.7s, and 2.8s
  under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 17:05:55 +02:00

267 lines
9.1 KiB
Go

package soundcloud
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/config"
)
func TestGetTrackMetadataAndDownloadable(t *testing.T) {
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
if strings.Contains(joined, "--no-playlist") {
return []byte(`{"title":"Lean On","uploader":"Major Lazer","url":"https://cdn.example/audio.m4a","ext":"m4a","thumbnail":"https://img.example/cover.jpg"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
}
meta, err := c.GetMetadata(context.Background(), "https://soundcloud.com/a/b", "track")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if jsonutil.StringFromAny(meta["title"]) != "Lean On" {
t.Fatalf("title = %q, want Lean On", jsonutil.StringFromAny(meta["title"]))
}
if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/b" {
t.Fatalf("id = %q, want canonical soundcloud url", jsonutil.StringFromAny(meta["id"]))
}
d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if d.URL != "https://cdn.example/audio.m4a" || d.Extension != "m4a" {
t.Fatalf("unexpected downloadable: %+v", d)
}
if d.Audio.Container != "M4A" || d.Audio.Codec != "AAC" {
t.Fatalf("unexpected audio profile: %+v", d.Audio)
}
}
func TestGetPlaylistMetadata(t *testing.T) {
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
if strings.Contains(joined, "--skip-download") && !strings.Contains(joined, "--no-playlist") {
return []byte(`{"title":"Road Trip","entries":[{"webpage_url":"https://soundcloud.com/a/t1"},{"url":"https://soundcloud.com/a/t2"}]}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
}
meta, err := c.GetMetadata(context.Background(), "https://soundcloud.com/a/sets/road-trip", "playlist")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if jsonutil.StringFromAny(meta["name"]) != "Road Trip" {
t.Fatalf("name = %q, want Road Trip", jsonutil.StringFromAny(meta["name"]))
}
tracksMap, ok := meta["tracks"].(map[string]any)
if !ok {
t.Fatalf("tracks missing")
}
items := asAnySlice(tracksMap["items"])
if len(items) != 2 {
t.Fatalf("playlist items len = %d, want 2", len(items))
}
if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/sets/road-trip" {
t.Fatalf("playlist id not canonical: %q", jsonutil.StringFromAny(meta["id"]))
}
}
func TestSearchTrack(t *testing.T) {
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
if strings.Contains(joined, "scsearch2:lean on") {
return []byte(`{"entries":[{"title":"Lean On","uploader":"Major Lazer","webpage_url":"https://soundcloud.com/a/b"}]}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
}
pages, err := c.Search(context.Background(), "track", "lean on", 2)
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(pages) != 1 {
t.Fatalf("pages len = %d, want 1", len(pages))
}
items := asAnySlice(pages[0]["items"])
if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items))
}
item0, ok := items[0].(map[string]any)
if !ok {
t.Fatalf("expected first item map")
}
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/b" {
t.Fatalf("track search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
func TestSearchPlaylist(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/search/sets" {
_, _ = w.Write([]byte(`<html><body><a href="/a/sets/road-trip">x</a></body></html>`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.http = ts.Client()
origBase := soundcloudSearchBaseURL
soundcloudSearchBaseURL = ts.URL
defer func() { soundcloudSearchBaseURL = origBase }()
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
if strings.Contains(joined, "https://soundcloud.com/a/sets/road-trip") {
return []byte(`{"title":"Road Trip","uploader":"User","entries":[{"webpage_url":"https://soundcloud.com/a/t1"}]}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
}
pages, err := c.Search(context.Background(), "playlist", "road trip", 5)
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(pages) != 1 {
t.Fatalf("pages len = %d, want 1", len(pages))
}
items := asAnySlice(pages[0]["items"])
if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items))
}
item0, ok := items[0].(map[string]any)
if !ok {
t.Fatalf("expected first item map")
}
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/sets/road-trip" {
t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/search/sets" {
_, _ = w.Write([]byte(`<html><body><a href="/artist.name/sets/road.trip">x</a></body></html>`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.http = ts.Client()
origBase := soundcloudSearchBaseURL
soundcloudSearchBaseURL = ts.URL
defer func() { soundcloudSearchBaseURL = origBase }()
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
if strings.Contains(joined, "https://soundcloud.com/artist.name/sets/road.trip") {
return []byte(`{"title":"Road Trip","uploader":"User","entries":[{"webpage_url":"https://soundcloud.com/a/t1"}]}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
}
pages, err := c.Search(context.Background(), "playlist", "road trip", 5)
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(pages) != 1 {
t.Fatalf("pages len = %d, want 1", len(pages))
}
items := asAnySlice(pages[0]["items"])
if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items))
}
item0, ok := items[0].(map[string]any)
if !ok {
t.Fatalf("expected first item map")
}
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
func TestLoginShowsYtDlpHint(t *testing.T) {
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.bin = "definitely-not-a-real-yt-dlp-bin"
err := c.Login(context.Background())
if err == nil {
t.Fatalf("expected login error")
}
if !strings.Contains(strings.ToLower(err.Error()), "yt-dlp is required") {
t.Fatalf("expected yt-dlp hint in error, got: %v", err)
}
}
func TestTrackMetadataIncludesExplicitAndISRC(t *testing.T) {
meta := trackMetadataFromInfo("https://soundcloud.com/a/b", map[string]any{
"title": "T",
"uploader": "U",
"isrc": "US123",
"id": "9876",
"webpage_url": "https://soundcloud.com/a/b?si=abc",
"age_limit": float64(18),
"thumbnail": "https://img",
"upload_date": "20240101",
})
if jsonutil.StringFromAny(meta["isrc"]) != "US123" {
t.Fatalf("isrc = %q, want US123", jsonutil.StringFromAny(meta["isrc"]))
}
explicit, _ := meta["explicit"].(bool)
if !explicit {
t.Fatalf("expected explicit=true")
}
if jsonutil.StringFromAny(meta["source_track_id"]) != "9876" {
t.Fatalf("source_track_id = %q, want 9876", jsonutil.StringFromAny(meta["source_track_id"]))
}
if jsonutil.StringFromAny(jsonutil.NestedMap(meta, "album")["title"]) != "T" {
t.Fatalf("album title mismatch: %#v", jsonutil.NestedMap(meta, "album"))
}
}
func TestCanonicalSoundcloudURL(t *testing.T) {
got := canonicalSoundcloudURL(map[string]any{"webpage_url": "https://soundcloud.com/a/b/?si=x#frag"})
if got != "https://soundcloud.com/a/b" {
t.Fatalf("canonical url = %q, want %q", got, "https://soundcloud.com/a/b")
}
}
func TestCanonicalSoundcloudURLAcceptsSubdomain(t *testing.T) {
got := canonicalSoundcloudURL(map[string]any{"webpage_url": "https://m.soundcloud.com/a/b/?si=x#frag"})
if got != "https://soundcloud.com/a/b" {
t.Fatalf("canonical url = %q, want %q", got, "https://soundcloud.com/a/b")
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}