package library import ( "context" "testing" "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/dbtest" ) // songLinkFixture is the single and the album it came from: one song on two // releases, in one pending acoustic group, and a user who liked the single. type songLinkFixture struct { single, albumCut dbq.Track groupID pgtype.UUID user dbq.User } func newSongLinkFixture(t *testing.T, pool *pgxpool.Pool) songLinkFixture { t.Helper() ctx := context.Background() q := dbq.New(pool) exec := func(sql string, args ...any) { t.Helper() if _, err := pool.Exec(ctx, sql, args...); err != nil { t.Fatalf("exec %q: %v", sql, err) } } artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Link Artist", SortName: "Link Artist"}) if err != nil { t.Fatal(err) } track := func(album, path string) dbq.Track { t.Helper() a, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: album, SortTitle: album, ArtistID: artist.ID}) if err != nil { t.Fatal(err) } tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: "Feel Good Inc.", AlbumID: a.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: path, FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatal(err) } return tr } f := songLinkFixture{ single: track("Feel Good Inc. (single)", "/music/Link Artist/Feel Good Inc/01 Feel Good Inc.mp3"), albumCut: track("Demon Days", "/music/Link Artist/Demon Days/06 Feel Good Inc.mp3"), } f.user, err = q.CreateUser(ctx, dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "song-link", PasswordHash: "x", ApiTokenHash: "song-link-token", }) if err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `INSERT INTO duplicate_groups (member_key, tier) VALUES ('song-link', 'acoustic') RETURNING id`, ).Scan(&f.groupID); err != nil { t.Fatal(err) } exec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.single.ID, f.albumCut.ID) exec(`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, f.user.ID, f.single.ID) return f } // A cross-release group becomes one song: its copies share a song key, the // like on the single reaches the album cut (and is logged for sync), the Liked // list shows the song once, a like or unlike on either copy reaches both, and // "not the same song" undoes the link. func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) f := newSongLinkFixture(t, pool) res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true) if err != nil { t.Fatal(err) } if res.Classes[ClassCrossRelease] != 1 || res.SongsLinked != 1 { t.Fatalf("classes %v, linked %d; want one cross-release group linked", res.Classes, res.SongsLinked) } keys := songKeys(t, q, f.single.ID, f.albumCut.ID) if keys[0] != keys[1] { t.Fatalf("song keys differ after linking: %v", keys) } liked, err := q.ListLikedTrackIDs(ctx, f.user.ID) if err != nil { t.Fatal(err) } if len(liked) != 2 { t.Errorf("liked track ids = %d, want both copies", len(liked)) } var logged int if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes WHERE entity_type = 'like_track' AND op = 'upsert'`, ).Scan(&logged); err != nil { t.Fatal(err) } if logged != 1 { t.Errorf("logged %d shared likes, want 1 (the album cut)", logged) } if n, err := q.CountLikedTracks(ctx, f.user.ID); err != nil || n != 1 { t.Errorf("CountLikedTracks = %d (%v), want 1 song", n, err) } rows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{UserID: f.user.ID, Limit: 10}) if err != nil { t.Fatal(err) } if len(rows) != 1 { t.Errorf("Liked list has %d rows, want the song once", len(rows)) } // A second pass changes nothing. if res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true); err != nil || res.SongsLinked != 0 { t.Errorf("second pass linked %d (%v), want 0", res.SongsLinked, err) } unliked, err := q.UnlikeTrack(ctx, dbq.UnlikeTrackParams{UserID: f.user.ID, TrackID: f.albumCut.ID}) if err != nil { t.Fatal(err) } if len(unliked) != 2 { t.Errorf("unlike removed %d likes, want both copies", len(unliked)) } added, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID}) if err != nil { t.Fatal(err) } if len(added) != 2 { t.Errorf("like added %d, want both copies", len(added)) } if again, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID}); err != nil || len(again) != 0 { t.Errorf("repeat like added %d (%v), want 0", len(again), err) } if err := q.UnlinkDuplicateGroupSongs(ctx, f.groupID); err != nil { t.Fatal(err) } keys = songKeys(t, q, f.single.ID, f.albumCut.ID) if keys[0] == keys[1] { t.Error("song keys still shared after unlinking") } } // Without the operator's permission the resolver links nothing. func TestCrossReleaseLinkNeedsAutoResolve_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) f := newSongLinkFixture(t, pool) if _, err := ResolveDuplicates(ctx, pool, nil, "", nil, false); err != nil { t.Fatal(err) } keys := songKeys(t, q, f.single.ID, f.albumCut.ID) if keys[0] == keys[1] { t.Error("linked with auto-resolve off") } } // A merge keeps the removed copy's link to the song on another release, // whichever copy's id names the song. That copy has no song_id of its own, // and before the fix its link was lost whenever the random ids made it the // key (the flaky failure in run 8891). func TestMergeInheritsSongLink_Integration(t *testing.T) { for _, removeAlbumCut := range []bool{true, false} { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) f := newSongLinkFixture(t, pool) if _, err := q.LinkTracksAsSong(ctx, []pgtype.UUID{f.single.ID, f.albumCut.ID}); err != nil { t.Fatal(err) } loser, kept := f.albumCut, f.single if !removeAlbumCut { loser, kept = f.single, f.albumCut } // A third, unlinked copy of the removed track stands in for a survivor. other, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: loser.Title, AlbumID: loser.AlbumID, ArtistID: loser.ArtistID, DurationMs: 1000, FilePath: loser.FilePath + ".copy.mp3", FileSize: 90, FileFormat: "mp3", }) if err != nil { t.Fatal(err) } if err := q.MergeInheritSongLink(ctx, dbq.MergeInheritSongLinkParams{SurvivorID: other.ID, LoserID: loser.ID}); err != nil { t.Fatal(err) } if _, err := q.DeleteTrack(ctx, loser.ID); err != nil { t.Fatal(err) } keys := songKeys(t, q, other.ID, kept.ID) if keys[0] != keys[1] { t.Errorf("removing the %s: survivor did not join the song", map[bool]string{true: "album cut", false: "single"}[removeAlbumCut]) } } } func songKeys(t *testing.T, q *dbq.Queries, ids ...pgtype.UUID) []pgtype.UUID { t.Helper() rows, err := q.ListTrackSongKeys(context.Background(), ids) if err != nil { t.Fatal(err) } byID := map[pgtype.UUID]pgtype.UUID{} for _, r := range rows { byID[r.ID] = r.SongKey } out := make([]pgtype.UUID, len(ids)) for i, id := range ids { out[i] = byID[id] } return out }