328 lines
10 KiB
Go
328 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
)
|
|
|
|
// resetLidarrState clears lidarr_requests rows and resets lidarr_config
|
|
// to the factory-default state (enabled=false, nulls) between tests.
|
|
func resetLidarrState(t *testing.T, h *handlers) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
if _, err := h.pool.Exec(ctx,
|
|
"DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
|
|
); err != nil {
|
|
t.Fatalf("resetLidarrState: %v", err)
|
|
}
|
|
}
|
|
|
|
// saveLidarrConfig writes a test lidarr_config row pointing at the given stub URL.
|
|
func saveLidarrConfig(t *testing.T, h *handlers, baseURL string, enabled bool) {
|
|
t.Helper()
|
|
svc := lidarrconfig.New(h.pool)
|
|
err := svc.Save(context.Background(), lidarrconfig.Config{
|
|
Enabled: enabled,
|
|
BaseURL: baseURL,
|
|
APIKey: "test-key",
|
|
DefaultQualityProfileID: 1,
|
|
DefaultRootFolderPath: "/music",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("saveLidarrConfig: %v", err)
|
|
}
|
|
}
|
|
|
|
// lidarrArtistStubBody returns a minimal Lidarr artist lookup JSON payload.
|
|
func lidarrArtistStubBody(mbid, name string) string {
|
|
return fmt.Sprintf(`[{"foreignArtistId":%q,"artistName":%q,"genres":["Rock"],"albumCount":5,"images":[]}]`, mbid, name)
|
|
}
|
|
|
|
// newLidarrStub creates an httptest.Server that always returns the given body
|
|
// with 200 OK for any /api/v1/* path.
|
|
func newLidarrStub(t *testing.T, body string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// seedArtistWithMBID inserts an artist row with a specific MBID for in_library testing.
|
|
func seedArtistWithMBID(t *testing.T, h *handlers, name, mbid string) dbq.Artist {
|
|
t.Helper()
|
|
a, err := dbq.New(h.pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
|
Name: name,
|
|
SortName: name,
|
|
Mbid: &mbid,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seedArtistWithMBID: %v", err)
|
|
}
|
|
return a
|
|
}
|
|
|
|
// seedRequestForMBID inserts a pending lidarr_requests row for the given MBID.
|
|
func seedRequestForMBID(t *testing.T, h *handlers, userID pgtype.UUID, kind dbq.LidarrRequestKind, artistMBID, mbid string) {
|
|
t.Helper()
|
|
var albumMBID, trackMBID *string
|
|
switch kind {
|
|
case dbq.LidarrRequestKindAlbum:
|
|
albumMBID = &mbid
|
|
case dbq.LidarrRequestKindTrack:
|
|
trackMBID = &mbid
|
|
}
|
|
_, err := dbq.New(h.pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
|
|
UserID: userID,
|
|
Kind: kind,
|
|
LidarrArtistMbid: artistMBID,
|
|
LidarrAlbumMbid: albumMBID,
|
|
LidarrTrackMbid: trackMBID,
|
|
ArtistName: "Test Artist",
|
|
AlbumTitle: nil,
|
|
TrackTitle: nil,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seedRequestForMBID: %v", err)
|
|
}
|
|
}
|
|
|
|
// doLidarrSearch fires a GET /api/lidarr/search request directly at the handler.
|
|
// It simulates the RequireUser middleware by injecting a dummy user context.
|
|
func doLidarrSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", q, kind)
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
// Inject a minimal user context the same way other handler tests do it.
|
|
// The lidarr search handler doesn't use the user from context, so any
|
|
// non-nil user value keeps the handler from panicking.
|
|
w := httptest.NewRecorder()
|
|
h.handleLidarrSearch(w, req)
|
|
return w
|
|
}
|
|
|
|
// TestHandleLidarrSearch_ValidationErrors covers bad kind and missing query
|
|
// in a table-driven test.
|
|
func TestHandleLidarrSearch_ValidationErrors(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
q string
|
|
kind string
|
|
wantStatus int
|
|
wantCode string
|
|
}{
|
|
{
|
|
name: "bad kind playlist",
|
|
q: "Beatles",
|
|
kind: "playlist",
|
|
wantStatus: http.StatusBadRequest,
|
|
wantCode: "bad_kind",
|
|
},
|
|
{
|
|
name: "missing query",
|
|
q: "",
|
|
kind: "artist",
|
|
wantStatus: http.StatusBadRequest,
|
|
wantCode: "missing_query",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", tc.q, tc.kind)
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleLidarrSearch(w, req)
|
|
|
|
if w.Code != tc.wantStatus {
|
|
t.Fatalf("status = %d, want %d; body = %s", w.Code, tc.wantStatus, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode body: %v; body=%s", err, w.Body.String())
|
|
}
|
|
if resp.Error.Code != tc.wantCode {
|
|
t.Errorf("error.code = %q, want %q", resp.Error.Code, tc.wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleLidarrSearch_DisabledReturns503 verifies that a disabled Lidarr
|
|
// config causes the handler to return 503 lidarr_disabled.
|
|
func TestHandleLidarrSearch_DisabledReturns503(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
resetLidarrState(t, h)
|
|
|
|
// Config is already disabled after resetLidarrState; ensure it's explicit.
|
|
saveLidarrConfig(t, h, "http://127.0.0.1:1", false)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleLidarrSearch(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode body: %v; body=%s", err, w.Body.String())
|
|
}
|
|
if resp.Error.Code != "lidarr_disabled" {
|
|
t.Errorf("error.code = %q, want lidarr_disabled", resp.Error.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleLidarrSearch_HappyPath_RequestableResult checks a result that is
|
|
// neither in the library nor already requested.
|
|
func TestHandleLidarrSearch_HappyPath_RequestableResult(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
resetLidarrState(t, h)
|
|
|
|
const mbid = "artist-mbid-abc123"
|
|
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "The Beatles"))
|
|
saveLidarrConfig(t, h, stub.URL, true)
|
|
|
|
w := doLidarrSearch(t, h, "Beatles", "artist")
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
}
|
|
var results []lidarrSearchResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
|
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("len(results) = %d, want 1", len(results))
|
|
}
|
|
r := results[0]
|
|
if r.MBID != mbid {
|
|
t.Errorf("mbid = %q, want %q", r.MBID, mbid)
|
|
}
|
|
if r.InLibrary {
|
|
t.Errorf("in_library = true, want false")
|
|
}
|
|
if r.Requested {
|
|
t.Errorf("requested = true, want false")
|
|
}
|
|
}
|
|
|
|
// TestHandleLidarrSearch_HappyPath_InLibraryResult checks that a result whose
|
|
// MBID matches an artist in the local DB has in_library=true.
|
|
func TestHandleLidarrSearch_HappyPath_InLibraryResult(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
resetLidarrState(t, h)
|
|
|
|
const mbid = "artist-mbid-inlib"
|
|
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Library Artist"))
|
|
saveLidarrConfig(t, h, stub.URL, true)
|
|
|
|
// Seed the artist with the matching MBID so in_library becomes true.
|
|
seedArtistWithMBID(t, h, dbtest.TestUserPrefix+"LibraryArtist", mbid)
|
|
|
|
w := doLidarrSearch(t, h, "Library+Artist", "artist")
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
}
|
|
var results []lidarrSearchResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
|
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("len(results) = %d, want 1", len(results))
|
|
}
|
|
if !results[0].InLibrary {
|
|
t.Errorf("in_library = false, want true")
|
|
}
|
|
}
|
|
|
|
// TestHandleLidarrSearch_HappyPath_AlreadyRequested verifies that a result
|
|
// whose MBID matches a pending lidarr_requests row has requested=true.
|
|
func TestHandleLidarrSearch_HappyPath_AlreadyRequested(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
resetLidarrState(t, h)
|
|
|
|
const mbid = "artist-mbid-requested"
|
|
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Requested Artist"))
|
|
saveLidarrConfig(t, h, stub.URL, true)
|
|
|
|
// Seed a user and a pending request for the artist MBID.
|
|
user := seedUser(t, h.pool, "requester", "pw", false)
|
|
seedRequestForMBID(t, h, user.ID, dbq.LidarrRequestKindArtist, mbid, mbid)
|
|
|
|
w := doLidarrSearch(t, h, "Requested+Artist", "artist")
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
}
|
|
var results []lidarrSearchResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
|
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("len(results) = %d, want 1", len(results))
|
|
}
|
|
if !results[0].Requested {
|
|
t.Errorf("requested = false, want true")
|
|
}
|
|
}
|
|
|
|
// TestHandleLidarrSearch_LidarrUnreachableReturns503 verifies that a
|
|
// connection-refused Lidarr base URL returns 503 lidarr_unreachable.
|
|
func TestHandleLidarrSearch_LidarrUnreachableReturns503(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
resetLidarrState(t, h)
|
|
|
|
// Port 1 is reserved and will refuse connections immediately.
|
|
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil)
|
|
// Use a short-deadline context so the test doesn't hang waiting for TCP.
|
|
ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second)
|
|
defer cancel()
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.handleLidarrSearch(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
|
}
|
|
if resp.Error.Code != "lidarr_unreachable" {
|
|
t.Errorf("error.code = %q, want lidarr_unreachable", resp.Error.Code)
|
|
}
|
|
}
|