Files

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)
}
}