release / govulncheck (push) Successful in 29s
release / web (push) Successful in 1m58s
release / go (push) Successful in 2m14s
release / integration (push) Successful in 5m17s
release / android (push) Successful in 6m21s
release / Build signed APK (releases and dev) (push) Successful in 6m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m1s
release / Verify release artifacts (tag releases only) (push) Skipped
ArtistResource has no top-level `monitor`. Lidarr reads the choice from AddOptions (AddArtistOptions, a MonitoringOptions), so the "all"/"future" we sent there was dropped on deserialisation. AddOptions.Monitor stayed Unknown, and AlbumMonitoredService.SetAlbumMonitoredStatus returns early on Unknown. The request's monitoring was never applied. Send monitor and monitored inside addOptions with searchForMissingAlbums, the shape Lidarr's getNewArtist.js posts, and set monitorNewItems "all" explicitly: both choices mean new releases are watched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
893 lines
29 KiB
Go
893 lines
29 KiB
Go
package lidarr
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// newTestClient creates an httptest.Server and a Client wired to it.
|
|
func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) {
|
|
srv := httptest.NewServer(handler)
|
|
return &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()}, srv
|
|
}
|
|
|
|
// mustReadFixture reads a file from testdata/ or fatals the test.
|
|
func mustReadFixture(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
b, err := os.ReadFile("testdata/" + name)
|
|
if err != nil {
|
|
t.Fatalf("read fixture %q: %v", name, err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// --- LookupArtist ---
|
|
|
|
func TestLookupArtist_HappyPath(t *testing.T) {
|
|
body := mustReadFixture(t, "lookup_artist.json")
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-Api-Key"); got != "key123" {
|
|
t.Errorf("X-Api-Key = %q, want key123", got)
|
|
}
|
|
if r.URL.Path != "/api/v1/artist/lookup" {
|
|
t.Errorf("path = %q, want /api/v1/artist/lookup", r.URL.Path)
|
|
}
|
|
if got := r.URL.Query().Get("term"); got != "boards" {
|
|
t.Errorf("term = %q, want boards", got)
|
|
}
|
|
_, _ = w.Write(body)
|
|
})
|
|
defer srv.Close()
|
|
|
|
got, err := c.LookupArtist(context.Background(), "boards")
|
|
if err != nil {
|
|
t.Fatalf("LookupArtist: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
|
|
// First result — rich data.
|
|
if got[0].MBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
|
|
t.Errorf("MBID = %q", got[0].MBID)
|
|
}
|
|
if got[0].Name != "Boards of Canada" {
|
|
t.Errorf("Name = %q", got[0].Name)
|
|
}
|
|
if got[0].Secondary != "Electronic · 18 albums" {
|
|
t.Errorf("Secondary = %q, want %q", got[0].Secondary, "Electronic · 18 albums")
|
|
}
|
|
if got[0].ImageURL != "https://example.invalid/boc.jpg" {
|
|
t.Errorf("ImageURL = %q", got[0].ImageURL)
|
|
}
|
|
|
|
// Second result — empty genres and zero album count → empty secondary.
|
|
if got[1].Secondary != "" {
|
|
t.Errorf("expected empty secondary for no genres + 0 albums; got %q", got[1].Secondary)
|
|
}
|
|
if got[1].ImageURL != "" {
|
|
t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL)
|
|
}
|
|
}
|
|
|
|
func TestLookupArtist_AuthFailed(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
})
|
|
defer srv.Close()
|
|
_, err := c.LookupArtist(context.Background(), "boards")
|
|
if !errors.Is(err, ErrAuthFailed) {
|
|
t.Fatalf("err = %v, want ErrAuthFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestLookupArtist_Forbidden(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
})
|
|
defer srv.Close()
|
|
_, err := c.LookupArtist(context.Background(), "boards")
|
|
if !errors.Is(err, ErrAuthFailed) {
|
|
t.Fatalf("err = %v, want ErrAuthFailed (403 must map to auth)", err)
|
|
}
|
|
}
|
|
|
|
func TestLookupArtist_ServerError(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
defer srv.Close()
|
|
_, err := c.LookupArtist(context.Background(), "boards")
|
|
if !errors.Is(err, ErrServerError) {
|
|
t.Fatalf("err = %v, want ErrServerError", err)
|
|
}
|
|
}
|
|
|
|
func TestLookupArtist_Unreachable(t *testing.T) {
|
|
c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}}
|
|
_, err := c.LookupArtist(context.Background(), "boards")
|
|
if !errors.Is(err, ErrUnreachable) {
|
|
t.Fatalf("err = %v, want ErrUnreachable", err)
|
|
}
|
|
}
|
|
|
|
func TestLookupArtist_BadJSON(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`not json`))
|
|
})
|
|
defer srv.Close()
|
|
_, err := c.LookupArtist(context.Background(), "boards")
|
|
if !errors.Is(err, ErrInvalidPayload) {
|
|
t.Fatalf("err = %v, want ErrInvalidPayload", err)
|
|
}
|
|
}
|
|
|
|
// --- LookupAlbum ---
|
|
|
|
func TestLookupAlbum_HappyPath(t *testing.T) {
|
|
body := mustReadFixture(t, "lookup_album.json")
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/album/lookup" {
|
|
t.Errorf("path = %q, want /api/v1/album/lookup", r.URL.Path)
|
|
}
|
|
if got := r.URL.Query().Get("term"); got != "geogaddi" {
|
|
t.Errorf("term = %q, want geogaddi", got)
|
|
}
|
|
_, _ = w.Write(body)
|
|
})
|
|
defer srv.Close()
|
|
|
|
got, err := c.LookupAlbum(context.Background(), "geogaddi")
|
|
if err != nil {
|
|
t.Fatalf("LookupAlbum: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
|
|
// First result: year from releaseDate, trackCount in secondary.
|
|
if got[0].MBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
|
|
t.Errorf("MBID = %q", got[0].MBID)
|
|
}
|
|
if got[0].ArtistMBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
|
|
t.Errorf("ArtistMBID = %q, want 069b64b6-7884-4f6a-94cc-e4c1d6c87a01", got[0].ArtistMBID)
|
|
}
|
|
if got[0].AlbumMBID != "" {
|
|
t.Errorf("AlbumMBID = %q, want empty on album result", got[0].AlbumMBID)
|
|
}
|
|
if got[0].Name != "Music Has the Right to Children" {
|
|
t.Errorf("Name = %q", got[0].Name)
|
|
}
|
|
want0 := "Boards of Canada · 1998 · 18 tracks"
|
|
if got[0].Secondary != want0 {
|
|
t.Errorf("Secondary = %q, want %q", got[0].Secondary, want0)
|
|
}
|
|
// Poster image from second image entry.
|
|
if got[0].ImageURL != "https://example.invalid/mhtrtc-poster.jpg" {
|
|
t.Errorf("ImageURL = %q", got[0].ImageURL)
|
|
}
|
|
|
|
// Second result.
|
|
want1 := "Boards of Canada · 2002 · 23 tracks"
|
|
if got[1].Secondary != want1 {
|
|
t.Errorf("Secondary = %q, want %q", got[1].Secondary, want1)
|
|
}
|
|
if got[1].ImageURL != "" {
|
|
t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL)
|
|
}
|
|
}
|
|
|
|
func TestLookupAlbum_BadJSON(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{}`))
|
|
})
|
|
defer srv.Close()
|
|
_, err := c.LookupAlbum(context.Background(), "x")
|
|
if !errors.Is(err, ErrInvalidPayload) {
|
|
t.Fatalf("err = %v, want ErrInvalidPayload (object, not array)", err)
|
|
}
|
|
}
|
|
|
|
// --- LookupTrack ---
|
|
|
|
func TestLookupTrack_HappyPath(t *testing.T) {
|
|
body := mustReadFixture(t, "lookup_track.json")
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/track/lookup" {
|
|
t.Errorf("path = %q, want /api/v1/track/lookup", r.URL.Path)
|
|
}
|
|
_, _ = w.Write(body)
|
|
})
|
|
defer srv.Close()
|
|
|
|
got, err := c.LookupTrack(context.Background(), "roygbiv")
|
|
if err != nil {
|
|
t.Fatalf("LookupTrack: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
if got[0].Name != "Roygbiv" {
|
|
t.Errorf("Name = %q", got[0].Name)
|
|
}
|
|
want := "Music Has the Right to Children · Boards of Canada"
|
|
if got[0].Secondary != want {
|
|
t.Errorf("Secondary = %q, want %q", got[0].Secondary, want)
|
|
}
|
|
if got[0].ArtistMBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
|
|
t.Errorf("ArtistMBID = %q, want 069b64b6-7884-4f6a-94cc-e4c1d6c87a01", got[0].ArtistMBID)
|
|
}
|
|
if got[0].AlbumMBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
|
|
t.Errorf("AlbumMBID = %q, want a1b2c3d4-e5f6-7890-abcd-ef1234567890", got[0].AlbumMBID)
|
|
}
|
|
if got[0].ImageURL != "" {
|
|
t.Errorf("tracks carry no images; ImageURL = %q", got[0].ImageURL)
|
|
}
|
|
}
|
|
|
|
func TestLookupTrack_NoArtistName(t *testing.T) {
|
|
// Verify secondary degrades gracefully when artistName is absent.
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`[{"foreignTrackId":"abc","title":"X","albumTitle":"AlbumY","artistName":""}]`))
|
|
})
|
|
defer srv.Close()
|
|
got, err := c.LookupTrack(context.Background(), "x")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if got[0].Secondary != "AlbumY" {
|
|
t.Errorf("Secondary = %q, want %q", got[0].Secondary, "AlbumY")
|
|
}
|
|
}
|
|
|
|
// --- AddArtist ---
|
|
|
|
func TestAddArtist_PostsCorrectBody(t *testing.T) {
|
|
var decoded map[string]any
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("method = %q, want POST", r.Method)
|
|
}
|
|
if r.URL.Path != "/api/v1/artist" {
|
|
t.Errorf("path = %q, want /api/v1/artist", r.URL.Path)
|
|
}
|
|
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil {
|
|
t.Errorf("decode body: %v", err)
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
})
|
|
defer srv.Close()
|
|
|
|
err := c.AddArtist(context.Background(), AddArtistParams{
|
|
ForeignArtistID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
|
|
QualityProfileID: 2,
|
|
RootFolderPath: "/music",
|
|
MonitorAll: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddArtist: %v", err)
|
|
}
|
|
|
|
if decoded["foreignArtistId"] != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
|
|
t.Errorf("foreignArtistId = %v", decoded["foreignArtistId"])
|
|
}
|
|
if decoded["qualityProfileId"] != float64(2) {
|
|
t.Errorf("qualityProfileId = %v", decoded["qualityProfileId"])
|
|
}
|
|
if decoded["rootFolderPath"] != "/music" {
|
|
t.Errorf("rootFolderPath = %v", decoded["rootFolderPath"])
|
|
}
|
|
if decoded["monitored"] != true {
|
|
t.Errorf("monitored = %v, want true", decoded["monitored"])
|
|
}
|
|
if decoded["monitorNewItems"] != "all" {
|
|
t.Errorf("monitorNewItems = %v, want all", decoded["monitorNewItems"])
|
|
}
|
|
// #5239: ArtistResource has no top-level monitor; Lidarr reads the
|
|
// choice from addOptions and silently drops it anywhere else.
|
|
if _, set := decoded["monitor"]; set {
|
|
t.Errorf("monitor sent at top level (%v), where Lidarr ignores it", decoded["monitor"])
|
|
}
|
|
opts, ok := decoded["addOptions"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("addOptions missing or wrong type: %T", decoded["addOptions"])
|
|
}
|
|
if opts["monitor"] != "all" {
|
|
t.Errorf("addOptions.monitor = %v, want all (MonitorAll=true)", opts["monitor"])
|
|
}
|
|
if opts["monitored"] != true {
|
|
t.Errorf("addOptions.monitored = %v, want true", opts["monitored"])
|
|
}
|
|
if opts["searchForMissingAlbums"] != true {
|
|
t.Errorf("addOptions.searchForMissingAlbums = %v, want true", opts["searchForMissingAlbums"])
|
|
}
|
|
}
|
|
|
|
func TestAddArtist_MonitorFuture(t *testing.T) {
|
|
var decoded map[string]any
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&decoded)
|
|
w.WriteHeader(http.StatusCreated)
|
|
})
|
|
defer srv.Close()
|
|
|
|
_ = c.AddArtist(context.Background(), AddArtistParams{MonitorAll: false})
|
|
if opts, _ := decoded["addOptions"].(map[string]any); opts["monitor"] != "future" {
|
|
t.Errorf("addOptions.monitor = %v, want future (MonitorAll=false)", opts["monitor"])
|
|
}
|
|
}
|
|
|
|
func TestAddArtist_ServerError(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
defer srv.Close()
|
|
err := c.AddArtist(context.Background(), AddArtistParams{})
|
|
if !errors.Is(err, ErrServerError) {
|
|
t.Fatalf("err = %v, want ErrServerError", err)
|
|
}
|
|
}
|
|
|
|
// --- AddAlbum ---
|
|
|
|
const (
|
|
addAlbumMBID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
addArtistMBID = "069b64b6-7884-4f6a-94cc-e4c1d6c87a01"
|
|
)
|
|
|
|
// albumLookupStub answers Lidarr's album lookup with lookupBody and records
|
|
// the body of the album POST that follows it.
|
|
func albumLookupStub(t *testing.T, lookupBody string, postStatus int, postBody string) (*Client, *httptest.Server, *map[string]any, *int) {
|
|
t.Helper()
|
|
var posted map[string]any
|
|
posts := 0
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/album/lookup":
|
|
if got := r.URL.Query().Get("term"); got != "lidarr:"+addAlbumMBID {
|
|
t.Errorf("lookup term = %q, want lidarr:%s", got, addAlbumMBID)
|
|
}
|
|
_, _ = w.Write([]byte(lookupBody))
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/album":
|
|
posts++
|
|
_ = json.NewDecoder(r.Body).Decode(&posted)
|
|
w.WriteHeader(postStatus)
|
|
_, _ = w.Write([]byte(postBody))
|
|
default:
|
|
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
})
|
|
return c, srv, &posted, &posts
|
|
}
|
|
|
|
var addAlbumParams = AddAlbumParams{
|
|
ForeignAlbumID: addAlbumMBID,
|
|
ForeignArtistID: addArtistMBID,
|
|
ArtistName: "Boards of Canada",
|
|
QualityProfileID: 1,
|
|
MetadataProfileID: 3,
|
|
RootFolderPath: "/music",
|
|
}
|
|
|
|
// #5234: Lidarr refuses a flat album add with "'Artist' must not be empty".
|
|
// The add has to be the looked-up album resource with its artist nested.
|
|
func TestAddAlbum_NewArtist_PostsTheLookedUpAlbumWithTheArtistNested(t *testing.T) {
|
|
lookup := `[
|
|
{"foreignAlbumId": "someone-else", "title": "Other"},
|
|
{"foreignAlbumId": "` + addAlbumMBID + `", "title": "Music Has the Right to Children",
|
|
"releases": [{"foreignReleaseId": "r1", "monitored": true}],
|
|
"artist": {"foreignArtistId": "` + addArtistMBID + `", "artistName": "Boards of Canada"}}
|
|
]`
|
|
c, srv, posted, _ := albumLookupStub(t, lookup, http.StatusCreated, `{"id":9}`)
|
|
defer srv.Close()
|
|
|
|
if err := c.AddAlbum(context.Background(), addAlbumParams); err != nil {
|
|
t.Fatalf("AddAlbum: %v", err)
|
|
}
|
|
body := *posted
|
|
if body["foreignAlbumId"] != addAlbumMBID || body["title"] != "Music Has the Right to Children" {
|
|
t.Errorf("posted album = %v / %v, want the looked-up album", body["foreignAlbumId"], body["title"])
|
|
}
|
|
if rel, _ := body["releases"].([]any); len(rel) != 1 {
|
|
t.Errorf("releases = %v, want the lookup's releases passed through", body["releases"])
|
|
}
|
|
if body["monitored"] != true {
|
|
t.Errorf("monitored = %v, want true", body["monitored"])
|
|
}
|
|
if opts, _ := body["addOptions"].(map[string]any); opts["searchForNewAlbum"] != true {
|
|
t.Errorf("addOptions = %v, want searchForNewAlbum true", body["addOptions"])
|
|
}
|
|
|
|
artist, ok := body["artist"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("artist = %T, want a nested object", body["artist"])
|
|
}
|
|
for k, want := range map[string]any{
|
|
"foreignArtistId": addArtistMBID,
|
|
"qualityProfileId": float64(1),
|
|
"metadataProfileId": float64(3),
|
|
"rootFolderPath": "/music",
|
|
"monitored": true,
|
|
"monitorNewItems": "none",
|
|
} {
|
|
if artist[k] != want {
|
|
t.Errorf("artist.%s = %v, want %v", k, artist[k], want)
|
|
}
|
|
}
|
|
// A new artist monitors this album only: albumsToMonitor wins over the
|
|
// monitor type in Lidarr's AlbumMonitoredService.
|
|
aOpts, _ := artist["addOptions"].(map[string]any)
|
|
if aOpts["monitor"] != "none" || aOpts["searchForMissingAlbums"] != false {
|
|
t.Errorf("artist.addOptions = %v, want monitor none, no catalogue search", aOpts)
|
|
}
|
|
if only, _ := aOpts["albumsToMonitor"].([]any); len(only) != 1 || only[0] != addAlbumMBID {
|
|
t.Errorf("artist.addOptions.albumsToMonitor = %v, want [%s]", aOpts["albumsToMonitor"], addAlbumMBID)
|
|
}
|
|
}
|
|
|
|
// An artist Lidarr already has keeps its own profiles and monitoring.
|
|
func TestAddAlbum_ExistingArtist_IsLeftAsLidarrHasIt(t *testing.T) {
|
|
lookup := `[{"foreignAlbumId": "` + addAlbumMBID + `",
|
|
"artist": {"id": 42, "foreignArtistId": "` + addArtistMBID + `", "qualityProfileId": 5,
|
|
"metadataProfileId": 2, "rootFolderPath": "/lib", "monitorNewItems": "all"}}]`
|
|
c, srv, posted, _ := albumLookupStub(t, lookup, http.StatusCreated, `{"id":9}`)
|
|
defer srv.Close()
|
|
|
|
if err := c.AddAlbum(context.Background(), addAlbumParams); err != nil {
|
|
t.Fatalf("AddAlbum: %v", err)
|
|
}
|
|
artist, _ := (*posted)["artist"].(map[string]any)
|
|
if artist["qualityProfileId"] != float64(5) || artist["rootFolderPath"] != "/lib" || artist["monitorNewItems"] != "all" {
|
|
t.Errorf("existing artist was rewritten: %v", artist)
|
|
}
|
|
if _, set := artist["addOptions"]; set {
|
|
t.Errorf("existing artist got addOptions %v", artist["addOptions"])
|
|
}
|
|
}
|
|
|
|
func TestAddAlbum_NotInLidarrsMetadata_IsNotFoundAndPostsNothing(t *testing.T) {
|
|
c, srv, _, posts := albumLookupStub(t, `[{"foreignAlbumId": "someone-else"}]`, http.StatusCreated, "")
|
|
defer srv.Close()
|
|
|
|
err := c.AddAlbum(context.Background(), addAlbumParams)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
|
}
|
|
if *posts != 0 {
|
|
t.Errorf("posted %d times, want 0", *posts)
|
|
}
|
|
}
|
|
|
|
func TestAddAlbum_AlreadyAdded_IsErrAlreadyExists(t *testing.T) {
|
|
lookup := `[{"foreignAlbumId": "` + addAlbumMBID + `", "artist": {"id": 42}}]`
|
|
c, srv, _, _ := albumLookupStub(t, lookup, http.StatusBadRequest,
|
|
`[{"propertyName":"ForeignAlbumId","errorMessage":"This album has already been added."}]`)
|
|
defer srv.Close()
|
|
|
|
if err := c.AddAlbum(context.Background(), addAlbumParams); !errors.Is(err, ErrAlreadyExists) {
|
|
t.Fatalf("err = %v, want ErrAlreadyExists", err)
|
|
}
|
|
}
|
|
|
|
func TestAddAlbum_AuthFailed(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
})
|
|
defer srv.Close()
|
|
err := c.AddAlbum(context.Background(), addAlbumParams)
|
|
if !errors.Is(err, ErrAuthFailed) {
|
|
t.Fatalf("err = %v, want ErrAuthFailed", err)
|
|
}
|
|
}
|
|
|
|
// --- ListQualityProfiles ---
|
|
|
|
func TestListQualityProfiles_HappyPath(t *testing.T) {
|
|
body := mustReadFixture(t, "quality_profiles.json")
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/qualityprofile" {
|
|
t.Errorf("path = %q, want /api/v1/qualityprofile", r.URL.Path)
|
|
}
|
|
_, _ = w.Write(body)
|
|
})
|
|
defer srv.Close()
|
|
|
|
got, err := c.ListQualityProfiles(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListQualityProfiles: %v", err)
|
|
}
|
|
if len(got) != 3 {
|
|
t.Fatalf("len = %d, want 3", len(got))
|
|
}
|
|
if got[0].ID != 1 || got[0].Name != "Any" {
|
|
t.Errorf("first profile = %+v, want {ID:1 Name:Any}", got[0])
|
|
}
|
|
if got[1].ID != 2 || got[1].Name != "Lossless" {
|
|
t.Errorf("second profile = %+v", got[1])
|
|
}
|
|
}
|
|
|
|
func TestListQualityProfiles_BadJSON(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`not json`))
|
|
})
|
|
defer srv.Close()
|
|
_, err := c.ListQualityProfiles(context.Background())
|
|
if !errors.Is(err, ErrInvalidPayload) {
|
|
t.Fatalf("err = %v, want ErrInvalidPayload", err)
|
|
}
|
|
}
|
|
|
|
// --- LookupArtist (additional) ---
|
|
|
|
func TestLookupArtist_LookupFailed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()}
|
|
_, err := c.LookupArtist(context.Background(), "boards")
|
|
if !errors.Is(err, ErrLookupFailed) {
|
|
t.Fatalf("err = %v, want ErrLookupFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestGet_BaseURLWithTrailingSlash(t *testing.T) {
|
|
body, _ := os.ReadFile("testdata/lookup_artist.json")
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify path is /api/v1/artist/lookup, NOT //api/v1/artist/lookup
|
|
if r.URL.Path != "/api/v1/artist/lookup" {
|
|
t.Errorf("got path %q; trailing-slash BaseURL produced double-slash", r.URL.Path)
|
|
}
|
|
_, _ = w.Write(body)
|
|
}))
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL + "/", APIKey: "k", HTTP: srv.Client()}
|
|
_, err := c.LookupArtist(context.Background(), "boards")
|
|
if err != nil {
|
|
t.Fatalf("LookupArtist: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- AddArtist (additional) ---
|
|
|
|
func TestAddArtist_PostFailed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
}))
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()}
|
|
err := c.AddArtist(context.Background(), AddArtistParams{ForeignArtistID: "x", QualityProfileID: 1, RootFolderPath: "/m"})
|
|
if !errors.Is(err, ErrLookupFailed) {
|
|
t.Fatalf("err = %v, want ErrLookupFailed", err)
|
|
}
|
|
}
|
|
|
|
// --- ListRootFolders ---
|
|
|
|
func TestListRootFolders_HappyPath(t *testing.T) {
|
|
body := mustReadFixture(t, "root_folders.json")
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/rootfolder" {
|
|
t.Errorf("path = %q, want /api/v1/rootfolder", r.URL.Path)
|
|
}
|
|
_, _ = w.Write(body)
|
|
})
|
|
defer srv.Close()
|
|
|
|
got, err := c.ListRootFolders(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListRootFolders: %v", err)
|
|
}
|
|
if len(got) != 3 {
|
|
t.Fatalf("len = %d, want 3", len(got))
|
|
}
|
|
if got[0].Path != "/music" {
|
|
t.Errorf("Path = %q", got[0].Path)
|
|
}
|
|
if !got[0].Accessible {
|
|
t.Errorf("Accessible = false for /music, want true")
|
|
}
|
|
if got[0].FreeSpace != 107374182400 {
|
|
t.Errorf("FreeSpace = %d", got[0].FreeSpace)
|
|
}
|
|
if got[2].Accessible {
|
|
t.Errorf("expected /music-offline to be inaccessible")
|
|
}
|
|
}
|
|
|
|
func TestListRootFolders_NullBody(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`null`))
|
|
})
|
|
defer srv.Close()
|
|
// null decodes to a nil slice, no error — but len check should yield 0.
|
|
got, err := c.ListRootFolders(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error on null body: %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty slice from null body, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestListRootFolders_BadJSON(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("{not valid json"))
|
|
}))
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()}
|
|
_, err := c.ListRootFolders(context.Background())
|
|
if !errors.Is(err, ErrInvalidPayload) {
|
|
t.Fatalf("err = %v, want ErrInvalidPayload", err)
|
|
}
|
|
}
|
|
|
|
// --- Ping ---
|
|
|
|
func TestPing_ReturnsVersion(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/system/status" {
|
|
t.Errorf("path = %q, want /api/v1/system/status", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"version":"2.4.3.3795","buildTime":"2024-01-15T00:00:00Z"}`))
|
|
})
|
|
defer srv.Close()
|
|
|
|
res, err := c.Ping(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Ping: %v", err)
|
|
}
|
|
if res.Version != "2.4.3.3795" {
|
|
t.Errorf("Version = %q, want 2.4.3.3795", res.Version)
|
|
}
|
|
}
|
|
|
|
func TestPing_AuthFailed(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
})
|
|
defer srv.Close()
|
|
_, err := c.Ping(context.Background())
|
|
if !errors.Is(err, ErrAuthFailed) {
|
|
t.Fatalf("err = %v, want ErrAuthFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestPing_Unreachable(t *testing.T) {
|
|
c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}}
|
|
_, err := c.Ping(context.Background())
|
|
if !errors.Is(err, ErrUnreachable) {
|
|
t.Fatalf("err = %v, want ErrUnreachable", err)
|
|
}
|
|
}
|
|
|
|
func TestPing_BadJSON(t *testing.T) {
|
|
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{bad`))
|
|
})
|
|
defer srv.Close()
|
|
_, err := c.Ping(context.Background())
|
|
if !errors.Is(err, ErrInvalidPayload) {
|
|
t.Fatalf("err = %v, want ErrInvalidPayload", err)
|
|
}
|
|
}
|
|
|
|
// --- pickPosterImage ---
|
|
|
|
func TestPickPosterImage_PreferRemoteURL(t *testing.T) {
|
|
imgs := []lidarrImage{
|
|
{CoverType: "poster", RemoteURL: "https://remote.example/img.jpg", URL: "https://local.example/img.jpg"},
|
|
}
|
|
got := pickPosterImage(imgs)
|
|
if got != "https://remote.example/img.jpg" {
|
|
t.Errorf("got %q, want remote URL", got)
|
|
}
|
|
}
|
|
|
|
func TestPickPosterImage_FallsBackToLocalURL(t *testing.T) {
|
|
imgs := []lidarrImage{
|
|
{CoverType: "poster", RemoteURL: "", URL: "https://local.example/img.jpg"},
|
|
}
|
|
got := pickPosterImage(imgs)
|
|
if got != "https://local.example/img.jpg" {
|
|
t.Errorf("got %q, want local URL", got)
|
|
}
|
|
}
|
|
|
|
func TestPickPosterImage_SkipsNonPoster(t *testing.T) {
|
|
imgs := []lidarrImage{
|
|
{CoverType: "banner", RemoteURL: "https://banner.example/img.jpg"},
|
|
{CoverType: "cover", RemoteURL: "https://cover.example/img.jpg"},
|
|
}
|
|
got := pickPosterImage(imgs)
|
|
if got != "" {
|
|
t.Errorf("got %q, want empty (no poster)", got)
|
|
}
|
|
}
|
|
|
|
// --- UnmonitorTrack ---
|
|
|
|
// unmonitorMux is the three-endpoint stub UnmonitorTrack walks. The flags it
|
|
// captures are inspected by the individual tests.
|
|
type unmonitorMux struct {
|
|
t *testing.T
|
|
wantAlbumMbid string
|
|
albumStatus int // override status for album lookup; 0 → 200 with body
|
|
tracksStatus int // override status for tracks list; 0 → 200 with body
|
|
putStatus int // override status for PUT /track/monitor; 0 → 200
|
|
albumBody []byte // when albumStatus is 0
|
|
tracksBody []byte // when tracksStatus is 0
|
|
putBody map[string]any
|
|
putHit bool
|
|
}
|
|
|
|
func (m *unmonitorMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/album":
|
|
if got := r.URL.Query().Get("foreignAlbumId"); got != m.wantAlbumMbid {
|
|
m.t.Errorf("album lookup foreignAlbumId = %q, want %q", got, m.wantAlbumMbid)
|
|
}
|
|
if m.albumStatus != 0 {
|
|
w.WriteHeader(m.albumStatus)
|
|
return
|
|
}
|
|
_, _ = w.Write(m.albumBody)
|
|
case "/api/v1/track":
|
|
if got := r.URL.Query().Get("albumId"); got != "42" {
|
|
m.t.Errorf("track list albumId = %q, want 42", got)
|
|
}
|
|
if m.tracksStatus != 0 {
|
|
w.WriteHeader(m.tracksStatus)
|
|
return
|
|
}
|
|
_, _ = w.Write(m.tracksBody)
|
|
case "/api/v1/track/monitor":
|
|
if r.Method != http.MethodPut {
|
|
m.t.Errorf("monitor method = %q, want PUT", r.Method)
|
|
}
|
|
m.putHit = true
|
|
if m.putStatus != 0 {
|
|
w.WriteHeader(m.putStatus)
|
|
return
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&m.putBody)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
default:
|
|
m.t.Errorf("unexpected path: %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestUnmonitorTrack_HappyPath(t *testing.T) {
|
|
mux := &unmonitorMux{
|
|
t: t,
|
|
wantAlbumMbid: "album-mbid",
|
|
albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
|
|
tracksBody: []byte(`[{"id":1001,"foreignTrackId":"other-mbid"},{"id":1002,"foreignTrackId":"track-mbid"}]`),
|
|
}
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
|
|
|
if err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid"); err != nil {
|
|
t.Fatalf("UnmonitorTrack: %v", err)
|
|
}
|
|
if !mux.putHit {
|
|
t.Fatal("PUT /api/v1/track/monitor was not called")
|
|
}
|
|
if mux.putBody["monitored"] != false {
|
|
t.Errorf("PUT body monitored = %v, want false", mux.putBody["monitored"])
|
|
}
|
|
ids, ok := mux.putBody["trackIds"].([]any)
|
|
if !ok || len(ids) != 1 {
|
|
t.Fatalf("PUT body trackIds = %v, want [1002]", mux.putBody["trackIds"])
|
|
}
|
|
if ids[0] != float64(1002) {
|
|
t.Errorf("PUT trackIds[0] = %v, want 1002 (the matching foreignTrackId)", ids[0])
|
|
}
|
|
}
|
|
|
|
func TestUnmonitorTrack_EmptyMbid(t *testing.T) {
|
|
c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "k", HTTP: &http.Client{}}
|
|
if err := c.UnmonitorTrack(context.Background(), "", "album-mbid"); err == nil {
|
|
t.Error("UnmonitorTrack with empty trackMbid: want error, got nil")
|
|
}
|
|
if err := c.UnmonitorTrack(context.Background(), "track-mbid", ""); err == nil {
|
|
t.Error("UnmonitorTrack with empty albumMbid: want error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestUnmonitorTrack_AlbumNotInLidarr(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/album" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`[]`))
|
|
}))
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
|
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestUnmonitorTrack_TrackNotInAlbum(t *testing.T) {
|
|
mux := &unmonitorMux{
|
|
t: t,
|
|
wantAlbumMbid: "album-mbid",
|
|
albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
|
|
// No track with foreignTrackId="track-mbid"
|
|
tracksBody: []byte(`[{"id":1001,"foreignTrackId":"other-mbid"}]`),
|
|
}
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
|
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
|
}
|
|
if mux.putHit {
|
|
t.Error("PUT was called even though track wasn't found")
|
|
}
|
|
}
|
|
|
|
func TestUnmonitorTrack_ServerError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
|
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
|
if !errors.Is(err, ErrServerError) {
|
|
t.Fatalf("err = %v, want ErrServerError", err)
|
|
}
|
|
}
|
|
|
|
func TestUnmonitorTrack_AuthFailed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
|
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
|
if !errors.Is(err, ErrAuthFailed) {
|
|
t.Fatalf("err = %v, want ErrAuthFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestUnmonitorTrack_Unreachable(t *testing.T) {
|
|
c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "k", HTTP: &http.Client{}}
|
|
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
|
if !errors.Is(err, ErrUnreachable) {
|
|
t.Fatalf("err = %v, want ErrUnreachable", err)
|
|
}
|
|
}
|
|
|
|
func TestUnmonitorTrack_PutServerError(t *testing.T) {
|
|
mux := &unmonitorMux{
|
|
t: t,
|
|
wantAlbumMbid: "album-mbid",
|
|
albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
|
|
tracksBody: []byte(`[{"id":1002,"foreignTrackId":"track-mbid"}]`),
|
|
putStatus: http.StatusInternalServerError,
|
|
}
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
|
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
|
if !errors.Is(err, ErrServerError) {
|
|
t.Fatalf("err = %v, want ErrServerError on PUT 5xx", err)
|
|
}
|
|
}
|