package qobuz import ( "context" "crypto/md5" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "testing" "streamrip-go/internal/config" ) func TestQualityMap(t *testing.T) { tests := []struct { in int want int }{ {1, 5}, {2, 6}, {3, 7}, {4, 27}, {0, 7}, {99, 7}, } for _, tt := range tests { got := qualityMap(tt.in) if got != tt.want { t.Fatalf("qualityMap(%d)=%d want %d", tt.in, got, tt.want) } } } func TestParseTrackMetadata(t *testing.T) { resp := map[string]any{ "id": "19512574", "title": "Dreams", "version": "Remastered", "track_number": float64(2), "media_number": float64(1), "parental_warning": false, "maximum_bit_depth": float64(24), "maximum_sampling_rate": float64(96), "performer": map[string]any{ "name": "Fleetwood Mac", }, "album": map[string]any{ "title": "Rumours", }, } m, err := ParseTrackMetadata(resp) if err != nil { t.Fatalf("ParseTrackMetadata() error = %v", err) } if m.ID != "19512574" || m.Title != "Dreams" || m.Album != "Rumours" || m.Artist != "Fleetwood Mac" { t.Fatalf("unexpected metadata: %+v", m) } if m.Quality != 3 { t.Fatalf("quality = %d, want 3", m.Quality) } } func TestGetPlaylistPagination(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") if offset == "" { offset = "0" } resp := map[string]any{} switch offset { case "0": resp = map[string]any{ "tracks_count": 1200, "tracks": map[string]any{"items": makeItems(0, 500)}, } case "500": resp = map[string]any{"tracks": map[string]any{"items": makeItems(500, 1000)}} case "1000": resp = map[string]any{"tracks": map[string]any{"items": makeItems(1000, 1200)}} default: w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"}) return } _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() c := newTestClient(t) c.loggedIn = true c.baseURL = ts.URL raw, err := c.GetMetadata(context.Background(), "playlist-id", "playlist") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } tracks, ok := mapValue(raw["tracks"]) if !ok { t.Fatalf("tracks missing") } items, ok := tracks["items"].([]any) if !ok { t.Fatalf("items missing") } if len(items) != 1200 { t.Fatalf("len(items) = %d, want 1200", len(items)) } } func TestGetLabelPagination(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") if offset == "" { offset = "0" } resp := map[string]any{} switch offset { case "0": resp = map[string]any{ "albums_count": 700, "albums": map[string]any{"items": makeItems(0, 500)}, } case "500": resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 700)}} default: w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"}) return } _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() c := newTestClient(t) c.loggedIn = true c.baseURL = ts.URL raw, err := c.GetMetadata(context.Background(), "label-id", "label") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } albums, ok := mapValue(raw["albums"]) if !ok { t.Fatalf("albums missing") } items, ok := albums["items"].([]any) if !ok { t.Fatalf("items missing") } if len(items) != 700 { t.Fatalf("len(items) = %d, want 700", len(items)) } } func TestGetArtistPagination(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") if offset == "" { offset = "0" } resp := map[string]any{} switch offset { case "0": resp = map[string]any{ "albums_count": 620, "albums": map[string]any{"items": makeItems(0, 500)}, } case "500": resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 620)}} default: w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"}) return } _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() c := newTestClient(t) c.loggedIn = true c.baseURL = ts.URL raw, err := c.GetMetadata(context.Background(), "artist-id", "artist") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } albums, ok := mapValue(raw["albums"]) if !ok { t.Fatalf("albums missing") } items, ok := albums["items"].([]any) if !ok { t.Fatalf("items missing") } if len(items) != 620 { t.Fatalf("len(items) = %d, want 620", len(items)) } } func newTestClient(t *testing.T) *Client { t.Helper() d := config.DefaultConfigData() d.Qobuz.AppID = "12345" cfg := &config.Config{File: d, Session: d} return New(cfg) } func makeItems(start, end int) []map[string]any { items := make([]map[string]any, 0, end-start) for i := start; i < end; i++ { items = append(items, map[string]any{"id": i}) } return items } func TestLoginRefreshesAppCredentialsWhenSecretInvalid(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/user/login": _ = json.NewEncoder(w).Encode(map[string]any{"user_auth_token": "uat-token"}) case "/track/getFileUrl": if r.Header.Get("X-App-Id") != "new-app" { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{"message": "bad app"}) return } tsValue := r.URL.Query().Get("request_ts") sig := r.URL.Query().Get("request_sig") if sig != qobuzSecretSig(tsValue, "good-secret") { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{"message": "bad secret"}) return } w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]any{"message": "ok secret"}) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() d := config.DefaultConfigData() d.Qobuz.EmailOrUserID = "user@example.com" d.Qobuz.PasswordOrToken = "hash" d.Qobuz.AppID = "old-app" d.Qobuz.Secrets = []string{"bad-secret"} cfg := &config.Config{File: d, Session: d} c := New(cfg) c.baseURL = ts.URL c.fetchCfg = func(context.Context) (string, []string, error) { return "new-app", []string{"good-secret"}, nil } if err := c.Login(context.Background()); err != nil { t.Fatalf("Login() error = %v", err) } if !c.loggedIn { t.Fatalf("expected logged-in client") } if c.secret != "good-secret" { t.Fatalf("secret = %q, want good-secret", c.secret) } if c.cfg.Session.Qobuz.AppID != "new-app" { t.Fatalf("session app id = %q", c.cfg.Session.Qobuz.AppID) } } func TestLoginRetriesAfterRefreshingAppCredentials(t *testing.T) { loginCalls := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/user/login": loginCalls++ if r.URL.Query().Get("app_id") != "new-app" { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]any{"message": "expired app id"}) return } _ = json.NewEncoder(w).Encode(map[string]any{"user_auth_token": "uat-token"}) case "/track/getFileUrl": tsValue := r.URL.Query().Get("request_ts") sig := r.URL.Query().Get("request_sig") if r.Header.Get("X-App-Id") == "new-app" && sig == qobuzSecretSig(tsValue, "good-secret") { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]any{"message": "ok secret"}) return } w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{"message": "bad secret"}) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() d := config.DefaultConfigData() d.Qobuz.EmailOrUserID = "user@example.com" d.Qobuz.PasswordOrToken = "hash" d.Qobuz.AppID = "old-app" d.Qobuz.Secrets = []string{"old-secret"} cfg := &config.Config{File: d, Session: d} c := New(cfg) c.baseURL = ts.URL c.fetchCfg = func(context.Context) (string, []string, error) { return "new-app", []string{"good-secret"}, nil } if err := c.Login(context.Background()); err != nil { t.Fatalf("Login() error = %v", err) } if loginCalls < 2 { t.Fatalf("expected login retry after refresh, calls=%d", loginCalls) } } func TestQobuzDownloadExtension(t *testing.T) { tests := []struct { name string resp map[string]any quality int url string want string }{ {name: "from url flac", resp: map[string]any{}, quality: 1, url: "https://cdn.example/a.flac?token=1", want: "flac"}, {name: "from url mp3", resp: map[string]any{}, quality: 4, url: "https://cdn.example/a.mp3", want: "mp3"}, {name: "from mime type", resp: map[string]any{"mime_type": "audio/flac"}, quality: 1, url: "https://cdn.example/stream", want: "flac"}, {name: "from format id", resp: map[string]any{"format_id": float64(5)}, quality: 4, url: "https://cdn.example/stream", want: "mp3"}, {name: "fallback quality", resp: map[string]any{}, quality: 3, url: "https://cdn.example/stream", want: "flac"}, } for _, tt := range tests { if got := qobuzDownloadExtension(tt.resp, tt.quality, tt.url); got != tt.want { t.Fatalf("%s: qobuzDownloadExtension()=%q want %q", tt.name, got, tt.want) } } } func TestGetDownloadableUsesReturnedURLExtension(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/track/getFileUrl" { w.WriteHeader(http.StatusNotFound) return } _ = json.NewEncoder(w).Encode(map[string]any{"url": "https://cdn.example/track.mp3?token=abc"}) })) defer ts.Close() c := newTestClient(t) c.loggedIn = true c.secret = "secret" c.baseURL = ts.URL d, err := c.GetDownloadable(context.Background(), "19512574", 4) if err != nil { t.Fatalf("GetDownloadable() error = %v", err) } if d.Extension != "mp3" { t.Fatalf("extension = %q, want mp3", d.Extension) } } func qobuzSecretSig(requestTS, secret string) string { raw := "trackgetFileUrlformat_id27intentstreamtrack_id19512574" + requestTS + secret hash := md5.Sum([]byte(raw)) return hex.EncodeToString(hash[:]) }