package lidarrrequests import ( "context" "io" "log/slog" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) // seedApprovedRequestDirect inserts a pending request and immediately // approves it via ApproveLidarrRequest, bypassing the Lidarr API call. func seedApprovedRequestDirect(t *testing.T, q *dbq.Queries, userID pgtype.UUID, p CreateParams) dbq.LidarrRequest { t.Helper() ctx := context.Background() row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{ UserID: userID, Kind: dbq.LidarrRequestKind(p.Kind), LidarrArtistMbid: p.LidarrArtistMBID, LidarrAlbumMbid: nilableStr(p.LidarrAlbumMBID), LidarrTrackMbid: nilableStr(p.LidarrTrackMBID), ArtistName: p.ArtistName, AlbumTitle: nilableStr(p.AlbumTitle), TrackTitle: nilableStr(p.TrackTitle), }) if err != nil { t.Fatalf("seedApprovedRequestDirect create: %v", err) } approved, err := q.ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{ ID: row.ID, QualityProfileID: nil, RootFolderPath: nil, DecidedBy: userID, }) if err != nil { t.Fatalf("seedApprovedRequestDirect approve: %v", err) } return approved } func nilableStr(s string) *string { if s == "" { return nil } return &s } func seedArtist(t *testing.T, q *dbq.Queries, name, mbid string) dbq.Artist { t.Helper() a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ Name: name, SortName: name, Mbid: &mbid, }) if err != nil { t.Fatalf("seedArtist: %v", err) } return a } func seedAlbum(t *testing.T, q *dbq.Queries, artistID pgtype.UUID, title, mbid string) dbq.Album { t.Helper() a, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: title, SortTitle: title, ArtistID: artistID, Mbid: &mbid, }) if err != nil { t.Fatalf("seedAlbum: %v", err) } return a } func seedTrack(t *testing.T, q *dbq.Queries, albumID, artistID pgtype.UUID, title, filePath string) dbq.Track { t.Helper() tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: title, AlbumID: albumID, ArtistID: artistID, DurationMs: 180000, FilePath: filePath, FileSize: 1024, FileFormat: "flac", }) if err != nil { t.Fatalf("seedTrack: %v", err) } return tr } func enableLidarrForPool(t *testing.T, pool *pgxpool.Pool) { t.Helper() if err := lidarrconfig.New(pool).Save(context.Background(), lidarrconfig.Config{ Enabled: true, BaseURL: "http://lidarr:8686", APIKey: "test", }); err != nil { t.Fatalf("enableLidarrForPool: %v", err) } } func newTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- // TestReconciler_MatchesArtistByMBID: an approved artist-kind request whose // MBID is in the artists table gets completed with matched_artist_id set. func TestReconciler_MatchesArtistByMBID(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) artistMBID := "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" artist := seedArtist(t, q, "Boards of Canada", artistMBID) req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Boards of Canada", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatalf("GetLidarrRequestByID: %v", err) } if got.Status != dbq.LidarrRequestStatusCompleted { t.Errorf("status = %v, want completed", got.Status) } if got.MatchedArtistID != artist.ID { t.Errorf("matched_artist_id = %v, want %v", got.MatchedArtistID, artist.ID) } } // TestReconciler_MatchesAlbumByMBID: an approved album-kind request whose // album MBID is in the albums table gets completed with matched_album_id set. func TestReconciler_MatchesAlbumByMBID(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) artistMBID := "a-artist-mbid-album-test" albumMBID := "b-album-mbid-album-test" artist := seedArtist(t, q, "Test Artist", artistMBID) album := seedAlbum(t, q, artist.ID, "Test Album", albumMBID) req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Test Artist", LidarrAlbumMBID: albumMBID, AlbumTitle: "Test Album", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatalf("GetLidarrRequestByID: %v", err) } if got.Status != dbq.LidarrRequestStatusCompleted { t.Errorf("status = %v, want completed", got.Status) } if got.MatchedAlbumID != album.ID { t.Errorf("matched_album_id = %v, want %v", got.MatchedAlbumID, album.ID) } } // TestReconciler_MatchesTrackViaAlbumMBID: an approved track-kind request // matches when the parent album appears in the library. matched_track_id is // also set to a track from that album. The track MBID on the request does // not need to match any tracks.mbid — matching is via the parent album. func TestReconciler_MatchesTrackViaAlbumMBID(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) artistMBID := "a-artist-mbid-track-test" albumMBID := "b-album-mbid-track-test" trackMBID := "c-track-mbid-does-not-exist-in-tracks-table" artist := seedArtist(t, q, "Track Test Artist", artistMBID) album := seedAlbum(t, q, artist.ID, "Track Test Album", albumMBID) track := seedTrack(t, q, album.ID, artist.ID, "Track One", "/music/track-test/01.flac") req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "track", LidarrArtistMBID: artistMBID, ArtistName: "Track Test Artist", LidarrAlbumMBID: albumMBID, AlbumTitle: "Track Test Album", LidarrTrackMBID: trackMBID, TrackTitle: "Track One", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatalf("GetLidarrRequestByID: %v", err) } if got.Status != dbq.LidarrRequestStatusCompleted { t.Errorf("status = %v, want completed", got.Status) } if got.MatchedAlbumID != album.ID { t.Errorf("matched_album_id = %v, want %v", got.MatchedAlbumID, album.ID) } if got.MatchedTrackID != track.ID { t.Errorf("matched_track_id = %v, want %v", got.MatchedTrackID, track.ID) } } // TestReconciler_NoMatchLeavesPending: an approved request whose MBID is not // in any library table is left unchanged after tickOnce. func TestReconciler_NoMatchLeavesPending(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "artist", LidarrArtistMBID: "nonexistent-mbid-xyz", ArtistName: "Ghost Artist", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatalf("GetLidarrRequestByID: %v", err) } if got.Status != dbq.LidarrRequestStatusApproved { t.Errorf("status = %v, want approved (unchanged)", got.Status) } if got.MatchedArtistID.Valid { t.Errorf("matched_artist_id should be NULL, got %v", got.MatchedArtistID) } } // TestReconciler_AlreadyCompletedRowNotReprocessed: a completed row is not // touched by the reconciler because ListApprovedLidarrRequestsForReconcile // filters on status='approved'. func TestReconciler_AlreadyCompletedRowNotReprocessed(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) artistMBID := "already-done-artist-mbid" artist := seedArtist(t, q, "Already Done Artist", artistMBID) // Seed approved, then complete it before running tickOnce. req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Already Done Artist", }) completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ ID: req.ID, MatchedArtistID: artist.ID, MatchedAlbumID: pgtype.UUID{}, MatchedTrackID: pgtype.UUID{}, }) if err != nil { t.Fatalf("CompleteLidarrRequest setup: %v", err) } rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatalf("GetLidarrRequestByID: %v", err) } if got.Status != dbq.LidarrRequestStatusCompleted { t.Errorf("status = %v, want completed (unchanged)", got.Status) } if !got.UpdatedAt.Time.Equal(completed.UpdatedAt.Time) { t.Errorf("updated_at moved: was %v, now %v — reconciler should not have touched completed row", completed.UpdatedAt.Time, got.UpdatedAt.Time) } } // TestReconciler_DisabledIsNoOp: when lidarr_config.enabled=false the // reconciler skips the entire tick, leaving approved requests untouched. func TestReconciler_DisabledIsNoOp(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() // lidarr_config is left disabled (newPool sets it to false). user := seedUser(t, pool) artistMBID := "disabled-noop-mbid" // Seed the matching artist so it WOULD be found if lidarr were enabled. seedArtist(t, q, "No-Op Artist", artistMBID) req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "No-Op Artist", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatalf("GetLidarrRequestByID: %v", err) } if got.Status != dbq.LidarrRequestStatusApproved { t.Errorf("status = %v, want approved — reconciler should no-op when disabled", got.Status) } } // TestReconciler_RunRunsAtLeastOneTick exercises Run's goroutine loop with a // short tick, verifies it picks up an approved row and transitions it to // completed before the test cancels the context. func TestReconciler_RunRunsAtLeastOneTick(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() pool := newPool(t) enableLidarrForPool(t, pool) q := dbq.New(pool) user := seedUser(t, pool) const mbid = "run-mbid" seedArtist(t, q, "Run Artist", mbid) req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "artist", LidarrArtistMBID: mbid, ArtistName: "Run Artist", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) rec.tick = 25 * time.Millisecond done := make(chan struct{}) go func() { rec.Run(ctx) close(done) }() // Poll the row up to ~2s for the reconciler to flip it. Beats sleeping // a fixed amount on slow CI. deadline := time.Now().Add(2 * time.Second) for { got, err := q.GetLidarrRequestByID(ctx, req.ID) if err == nil && got.Status == dbq.LidarrRequestStatusCompleted { break } if time.Now().After(deadline) { t.Fatalf("status not completed within 2s; last err=%v", err) } time.Sleep(10 * time.Millisecond) } cancel() select { case <-done: case <-time.After(time.Second): t.Fatal("Run did not exit within 1s of cancel") } } // TestReconciler_TrackKindNoTracksInAlbumYet: the parent album exists in the // library but no track rows have been ingested yet. Reconciler should leave // the row approved so a subsequent tick can pick it up once tracks land. func TestReconciler_TrackKindNoTracksInAlbumYet(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) artistMBID := "ar-mbid-tracks-pending" albumMBID := "al-mbid-tracks-pending" artist := seedArtist(t, q, "Pending Tracks Artist", artistMBID) _ = seedAlbum(t, q, artist.ID, "Album Without Tracks", albumMBID) // No tracks seeded. req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "track", LidarrArtistMBID: artistMBID, ArtistName: "Pending Tracks Artist", LidarrAlbumMBID: albumMBID, AlbumTitle: "Album Without Tracks", LidarrTrackMBID: "t-mbid-not-yet", TrackTitle: "Some Track", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatalf("GetLidarrRequestByID: %v", err) } if got.Status != dbq.LidarrRequestStatusApproved { t.Errorf("status = %v, want approved (album present, no tracks yet)", got.Status) } } // TestReconciler_AlbumKindNoMatchInAlbumsTable exercises the album branch's // no-rows return path when the request's album_mbid isn't in the library yet. func TestReconciler_AlbumKindNoMatchInAlbumsTable(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: "ar-no-match", ArtistName: "Nope Artist", LidarrAlbumMBID: "al-not-in-library", AlbumTitle: "Nope Album", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatalf("GetLidarrRequestByID: %v", err) } if got.Status != dbq.LidarrRequestStatusApproved { t.Errorf("status = %v, want approved (album not yet in library)", got.Status) } }