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