package lidarrrequests import ( "context" "errors" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) func newPool(t *testing.T) *pgxpool.Pool { t.Helper() if testing.Short() { t.Skip("skipping integration test in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) dbtest.ResetDB(t, pool) if _, err := pool.Exec(context.Background(), "DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1", ); err != nil { t.Fatalf("reset lidarr tables: %v", err) } return pool } func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { t.Helper() u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, }) if err != nil { t.Fatalf("seed user: %v", err) } return u.ID } func TestCreate_HappyPath_Artist(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) r, err := svc.Create(context.Background(), user, CreateParams{ Kind: "artist", LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", ArtistName: "Boards of Canada", }) if err != nil { t.Fatalf("Create: %v", err) } if r.Status != dbq.LidarrRequestStatusPending { t.Errorf("status = %v", r.Status) } } func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) _, err := svc.Create(context.Background(), user, CreateParams{ Kind: "track", LidarrArtistMBID: "a-mbid", ArtistName: "X", LidarrTrackMBID: "t-mbid", TrackTitle: "Y", // missing album fields }) if !errors.Is(err, ErrInvalidKindFields) { t.Fatalf("err = %v, want ErrInvalidKindFields", err) } } func TestApprove_NotConfigured(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) r, _ := svc.Create(context.Background(), user, CreateParams{ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", }) _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}) if !errors.Is(err, ErrLidarrDisabled) { t.Fatalf("err = %v, want ErrLidarrDisabled", err) } } func TestReject_TransitionsToRejected(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) r, _ := svc.Create(context.Background(), user, CreateParams{ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", }) rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality") if err != nil { t.Fatalf("Reject: %v", err) } if rejected.Status != dbq.LidarrRequestStatusRejected { t.Errorf("status = %v", rejected.Status) } } func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) r, _ := svc.Create(context.Background(), user, CreateParams{ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", }) _, _ = svc.Reject(context.Background(), r.ID, user, "first") _, err := svc.Reject(context.Background(), r.ID, user, "second") if !errors.Is(err, ErrNotPending) { t.Fatalf("err = %v, want ErrNotPending", err) } } func TestCancel_OwnPendingOnly(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) r, _ := svc.Create(context.Background(), user, CreateParams{ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", }) if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil { t.Fatalf("Cancel: %v", err) } // Second cancel hits "not pending" because we just rejected it. if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) { t.Errorf("err = %v, want ErrNotPending", err) } } func TestCreate_AlbumKindRequiresAlbumMBID(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) _, err := svc.Create(context.Background(), user, CreateParams{ Kind: "album", LidarrArtistMBID: "a-mbid", ArtistName: "X", // missing album_mbid + album_title }) if !errors.Is(err, ErrInvalidKindFields) { t.Fatalf("err = %v, want ErrInvalidKindFields", err) } } func TestCreate_UnknownKindRejected(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) _, err := svc.Create(context.Background(), user, CreateParams{ Kind: "playlist", LidarrArtistMBID: "a-mbid", ArtistName: "X", }) if !errors.Is(err, ErrInvalidKindFields) { t.Fatalf("err = %v, want ErrInvalidKindFields", err) } } func TestListPending_AndListByStatus(t *testing.T) { pool := newPool(t) user := seedUser(t, pool) svc := NewService(pool, lidarrconfig.New(pool), nil, nil) for _, name := range []string{"A", "B"} { if _, err := svc.Create(context.Background(), user, CreateParams{ Kind: "artist", LidarrArtistMBID: name + "-mbid", ArtistName: name, }); err != nil { t.Fatalf("Create(%s): %v", name, err) } } pending, err := svc.ListPending(context.Background(), 50) if err != nil { t.Fatalf("ListPending: %v", err) } if len(pending) != 2 { t.Errorf("ListPending len = %d, want 2", len(pending)) } rejected, err := svc.ListByStatus(context.Background(), "rejected", 50) if err != nil { t.Fatalf("ListByStatus(rejected): %v", err) } if len(rejected) != 0 { t.Errorf("ListByStatus(rejected) len = %d, want 0", len(rejected)) } } func TestListForUser_OnlyOwnRows(t *testing.T) { pool := newPool(t) alice := seedUser(t, pool) bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "x2", IsAdmin: false, }) if err != nil { t.Fatalf("seed bob: %v", err) } svc := NewService(pool, lidarrconfig.New(pool), nil, nil) if _, err := svc.Create(context.Background(), alice, CreateParams{ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "Alice's request", }); err != nil { t.Fatalf("Create(alice): %v", err) } if _, err := svc.Create(context.Background(), bob.ID, CreateParams{ Kind: "artist", LidarrArtistMBID: "b-mbid", ArtistName: "Bob's request", }); err != nil { t.Fatalf("Create(bob): %v", err) } rows, err := svc.ListForUser(context.Background(), alice, 50) if err != nil { t.Fatalf("ListForUser: %v", err) } if len(rows) != 1 || rows[0].ArtistName != "Alice's request" { t.Errorf("ListForUser = %+v, want only Alice's row", rows) } } // approveTestSetup wires Service against a stub Lidarr server. Returns the // Service, an admin user id, and the captured Lidarr request body for the // most recent call (handler updates it in-place). func approveTestSetup(t *testing.T) (*Service, pgtype.UUID, *pgxpool.Pool, *httptest.Server) { t.Helper() pool := newPool(t) stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // Approve resolves metadata/quality profiles before the add; // those list endpoints return JSON arrays, not the {"id":1} // object the add endpoints return. switch { case strings.Contains(r.URL.Path, "/metadataprofile"): _, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`)) case strings.Contains(r.URL.Path, "/qualityprofile"): _, _ = w.Write([]byte(`[{"id":7,"name":"Lossless"}]`)) default: _, _ = w.Write([]byte(`{"id":1}`)) } })) t.Cleanup(stub.Close) cfg := lidarrconfig.New(pool) if err := cfg.Save(context.Background(), lidarrconfig.Config{ Enabled: true, BaseURL: stub.URL, APIKey: "k", DefaultQualityProfileID: 7, DefaultRootFolderPath: "/music", }); err != nil { t.Fatalf("save config: %v", err) } clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } svc := NewService(pool, cfg, clientFn, nil) user := seedUser(t, pool) return svc, user, pool, stub } func TestApprove_HappyPath_ArtistKind(t *testing.T) { svc, user, _, _ := approveTestSetup(t) r, err := svc.Create(context.Background(), user, CreateParams{ Kind: "artist", LidarrArtistMBID: "ar-mbid", ArtistName: "Approvee", }) if err != nil { t.Fatalf("Create: %v", err) } approved, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}) if err != nil { t.Fatalf("Approve: %v", err) } if approved.Status != dbq.LidarrRequestStatusApproved { t.Errorf("status = %v, want approved", approved.Status) } // Default snapshot from config. if approved.QualityProfileID == nil || *approved.QualityProfileID != 7 { t.Errorf("quality_profile_id = %v, want 7", approved.QualityProfileID) } if approved.RootFolderPath == nil || *approved.RootFolderPath != "/music" { t.Errorf("root_folder_path = %v, want /music", approved.RootFolderPath) } } func TestApprove_HappyPath_AlbumKindWithOverride(t *testing.T) { svc, user, _, _ := approveTestSetup(t) r, err := svc.Create(context.Background(), user, CreateParams{ Kind: "album", LidarrArtistMBID: "ar-mbid", ArtistName: "X", LidarrAlbumMBID: "al-mbid", AlbumTitle: "Y", }) if err != nil { t.Fatalf("Create: %v", err) } approved, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{ QualityProfileID: 99, RootFolderPath: "/elsewhere", }) if err != nil { t.Fatalf("Approve: %v", err) } if approved.QualityProfileID == nil || *approved.QualityProfileID != 99 { t.Errorf("quality_profile_id = %v, want 99 (override)", approved.QualityProfileID) } if approved.RootFolderPath == nil || *approved.RootFolderPath != "/elsewhere" { t.Errorf("root_folder_path = %v, want /elsewhere", approved.RootFolderPath) } } func TestApprove_AlreadyApprovedReturnsErrNotPending(t *testing.T) { svc, user, _, _ := approveTestSetup(t) r, _ := svc.Create(context.Background(), user, CreateParams{ Kind: "artist", LidarrArtistMBID: "ar-mbid", ArtistName: "X", }) if _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}); err != nil { t.Fatalf("first approve: %v", err) } if _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}); !errors.Is(err, ErrNotPending) { t.Errorf("second approve err = %v, want ErrNotPending", err) } } func TestApprove_NotFound(t *testing.T) { svc, user, _, _ := approveTestSetup(t) var bogus pgtype.UUID bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} bogus.Valid = true if _, err := svc.Approve(context.Background(), bogus, user, ApproveOverrides{}); !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } }