release / web (push) Successful in 1m42s
release / go (push) Successful in 2m7s
release / govulncheck (push) Successful in 37s
release / integration (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m14s
release / Build signed APK (releases and dev) (push) Canceled after 4m42s
Lidarr's POST /api/v1/album validates `artist` as a nested resource (AlbumController: RuleFor(s => s.Artist).NotNull()), so the flat payload we sent was refused with "'Artist' must not be empty" every time. The album add has never worked against a real Lidarr; approved album and track requests sat in the reconciler retrying every 5 minutes. AddAlbum now does what Lidarr's own add-album UI does (getNewAlbum / getNewArtist): look the album up by MBID (album/lookup?term=lidarr:<mbid>), then POST that resource back with monitored + searchForNewAlbum. When Lidarr doesn't have the artist yet, the nested artist gets the request's quality/metadata profile and root folder, monitors this album only (monitor "none" + albumsToMonitor, which AlbumMonitoredService prefers) and no future releases. An artist Lidarr already has is left as it is. An MBID Lidarr's metadata doesn't know is ErrNotFound with no POST. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
882 lines
28 KiB
Go
882 lines
28 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["monitor"] != "all" {
|
|
t.Errorf("monitor = %v, want all (MonitorAll=true)", decoded["monitor"])
|
|
}
|
|
opts, ok := decoded["addOptions"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("addOptions missing or wrong type: %T", decoded["addOptions"])
|
|
}
|
|
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 decoded["monitor"] != "future" {
|
|
t.Errorf("monitor = %v, want future (MonitorAll=false)", decoded["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)
|
|
}
|
|
}
|