Files
minstrel/internal/library/song_link_test.go
T
bvandeusenandClaude Opus 5.5 8bd48b4302
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m56s
release / integration (push) Successful in 5m11s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: a merge keeps the song link when the removed copy names the song (M498)
MergeInheritSongLink took "linked" to mean song_key <> id, but the copy
whose id became the song's key has no song_id of its own. Removing that
copy dropped the survivor out of the song. It now asks whether another
copy shares the key. The test covers both copies as the one removed, so
it no longer passes or fails on which random id sorts first.

The release-change test counted audit rows other tests left behind
(ResetDB keeps audit_log); it now counts what its own pass adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:53:35 -04:00

221 lines
7.1 KiB
Go

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
}