package library import ( "context" "errors" "io/fs" "os" "path/filepath" "syscall" "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/dbtest" ) // mergeFixture is a library with one duplicate pair carrying history on both // copies, and a neighbour track for similarity edges. type mergeFixture struct { pool *pgxpool.Pool keep, remove, other dbq.Track keepPath, removePath string groupID pgtype.UUID alice, bob dbq.User playlistID pgtype.UUID removePlaylistPos int32 aliceEarlierLikeOnRem time.Time } func newMergeFixture(t *testing.T) mergeFixture { t.Helper() pool := newPool(t) ctx := context.Background() q := dbq.New(pool) dir := t.TempDir() f := mergeFixture{pool: pool} f.keepPath = filepath.Join(dir, "keep.flac") f.removePath = filepath.Join(dir, "remove.mp3") for _, p := range []string{f.keepPath, f.removePath} { if err := os.WriteFile(p, []byte("audio"), 0o644); err != nil { t.Fatalf("write %s: %v", p, err) } } var album dbq.Album var artist dbq.Artist f.keep, album, artist = seedTrack(t, pool, f.keepPath) upsert := func(title, path string) dbq.Track { t.Helper() tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: title, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 215000, FilePath: path, FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatalf("track %s: %v", title, err) } return tr } f.remove = upsert("WWW (copy)", f.removePath) f.other = upsert("Neighbour", filepath.Join(dir, "other.mp3")) mustExec := func(sql string, args ...any) { t.Helper() if _, err := pool.Exec(ctx, sql, args...); err != nil { t.Fatalf("exec %q: %v", sql, err) } } // Only the copy being removed carries a recording MBID. mustExec(`UPDATE tracks SET mbid = 'rec-www' WHERE id = $1`, f.remove.ID) user := func(name string) dbq.User { t.Helper() u, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-merge-token", }) if err != nil { t.Fatalf("user %s: %v", name, err) } return u } f.alice, f.bob = user("merge-alice"), user("merge-bob") // Alice liked both copies, the removed one first; Bob liked only the removed one. f.aliceEarlierLikeOnRem = time.Now().Add(-72 * time.Hour).UTC().Truncate(time.Microsecond) mustExec(`INSERT INTO general_likes (user_id, track_id, liked_at) VALUES ($1, $2, $3), ($1, $4, now()), ($5, $2, now())`, f.alice.ID, f.remove.ID, f.aliceEarlierLikeOnRem, f.keep.ID, f.bob.ID) now := pgtype.Timestamptz{Time: time.Now(), Valid: true} session, err := q.InsertPlaySession(ctx, dbq.InsertPlaySessionParams{UserID: f.alice.ID, StartedAt: now}) if err != nil { t.Fatalf("session: %v", err) } for _, track := range []dbq.Track{f.remove, f.remove, f.keep} { if _, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{ UserID: f.alice.ID, TrackID: track.ID, SessionID: session.ID, StartedAt: now, }); err != nil { t.Fatalf("play event: %v", err) } } if _, err := q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{ UserID: f.alice.ID, TrackID: f.remove.ID, SessionID: session.ID, SkippedAt: now, PositionMs: 1000, }); err != nil { t.Fatalf("skip event: %v", err) } pl, err := q.CreatePlaylist(ctx, dbq.CreatePlaylistParams{UserID: f.alice.ID, Name: "merge-mix"}) if err != nil { t.Fatalf("playlist: %v", err) } f.playlistID = pl.ID entry, err := q.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{PlaylistID: pl.ID, TrackID: f.remove.ID}) if err != nil { t.Fatalf("playlist entry: %v", err) } f.removePlaylistPos = entry.Position mustExec(`INSERT INTO track_tags (track_id, tag, weight) VALUES ($1, 'j-pop', 1), ($1, 'house', 0.5), ($2, 'house', 0.9)`, f.remove.ID, f.keep.ID) mustExec(`INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $3, 0.8, 'listenbrainz'), ($2, $3, 0.7, 'listenbrainz'), ($1, $2, 0.99, 'listenbrainz'), ($3, $1, 0.6, 'musicbrainz_tag')`, f.remove.ID, f.keep.ID, f.other.ID) if err := pool.QueryRow(ctx, `INSERT INTO duplicate_groups (member_key, tier) VALUES ('merge-fixture', 'exact') RETURNING id`, ).Scan(&f.groupID); err != nil { t.Fatalf("group: %v", err) } mustExec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.keep.ID, f.remove.ID) return f } func (f mergeFixture) count(t *testing.T, sql string, args ...any) int { t.Helper() var n int if err := f.pool.QueryRow(context.Background(), sql, args...).Scan(&n); err != nil { t.Fatalf("count %q: %v", sql, err) } return n } // The #3911 proof: after a merge, every piece of history the removed copy held // is on the copy kept, nothing is doubled, and the removed copy — row and file — // is gone. func TestMergeDuplicateGroup_Integration(t *testing.T) { f := newMergeFixture(t) ctx := context.Background() res, err := MergeDuplicateGroup(ctx, f.pool, nil, "", f.groupID, f.keep.ID) if err != nil { t.Fatalf("merge: %v", err) } if len(res.Removed) != 1 || res.Removed[0].FilePath != f.removePath || res.Survivor.TrackID != f.keep.ID { t.Fatalf("result = %+v, want the removed copy reported and the kept one as survivor", res) } if res.PlayEvents != 2 || res.SkipEvents != 1 || res.Likes != 2 || res.PlaylistEntries != 1 { t.Errorf("moved = plays %d skips %d likes %d playlist %d, want 2, 1, 2, 1", res.PlayEvents, res.SkipEvents, res.Likes, res.PlaylistEntries) } if _, err := os.Stat(f.removePath); !errors.Is(err, os.ErrNotExist) { t.Errorf("removed copy's file still on disk: %v", err) } if _, err := os.Stat(f.keepPath); err != nil { t.Errorf("kept copy's file is gone: %v", err) } if n := f.count(t, `SELECT count(*) FROM tracks WHERE id = $1`, f.remove.ID); n != 0 { t.Errorf("removed copy's row still exists") } // Likes: one per user, Alice's dated to her earlier like. if n := f.count(t, `SELECT count(*) FROM general_likes WHERE track_id = $1`, f.keep.ID); n != 2 { t.Errorf("likes on the kept copy = %d, want 2 (Alice once, Bob)", n) } var aliceLiked time.Time if err := f.pool.QueryRow(ctx, `SELECT liked_at FROM general_likes WHERE user_id = $1 AND track_id = $2`, f.alice.ID, f.keep.ID).Scan(&aliceLiked); err != nil { t.Fatalf("alice's like: %v", err) } if !aliceLiked.Equal(f.aliceEarlierLikeOnRem) { t.Errorf("alice's like dated %v, want her earlier like %v", aliceLiked, f.aliceEarlierLikeOnRem) } // Plays and skips move exactly: none lost, none invented. if n := f.count(t, `SELECT count(*) FROM play_events WHERE track_id = $1`, f.keep.ID); n != 3 { t.Errorf("plays on the kept copy = %d, want 3", n) } if n := f.count(t, `SELECT count(*) FROM skip_events WHERE track_id = $1`, f.keep.ID); n != 1 { t.Errorf("skips on the kept copy = %d, want 1", n) } // The playlist entry stays where it was and now plays the kept copy. if n := f.count(t, `SELECT count(*) FROM playlist_tracks WHERE playlist_id = $1 AND position = $2 AND track_id = $3`, f.playlistID, f.removePlaylistPos, f.keep.ID); n != 1 { t.Errorf("playlist entry at position %d does not point at the kept copy", f.removePlaylistPos) } // Tags are a union; the kept copy's own weight wins where both had the tag. if n := f.count(t, `SELECT count(*) FROM track_tags WHERE track_id = $1`, f.keep.ID); n != 2 { t.Errorf("tags on the kept copy = %d, want 2 (house, j-pop)", n) } if n := f.count(t, `SELECT count(*) FROM track_tags WHERE track_id = $1 AND tag = 'house' AND weight = 0.9`, f.keep.ID); n != 1 { t.Errorf("the kept copy's own house weight was overwritten") } // Similarity: rewritten onto the kept copy, no duplicate edge, no self-edge, // nothing left pointing at the removed copy. if n := f.count(t, `SELECT count(*) FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`, f.keep.ID, f.other.ID); n != 1 { t.Errorf("listenbrainz edge keep→other = %d rows, want exactly 1", n) } if n := f.count(t, `SELECT count(*) FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'musicbrainz_tag'`, f.other.ID, f.keep.ID); n != 1 { t.Errorf("musicbrainz_tag edge other→keep was not carried over") } if n := f.count(t, `SELECT count(*) FROM track_similarity WHERE track_a_id = track_b_id`); n != 0 { t.Errorf("a self-edge was written") } // The removed copy's recording MBID is inherited; the group is closed. if n := f.count(t, `SELECT count(*) FROM tracks WHERE id = $1 AND mbid = 'rec-www'`, f.keep.ID); n != 1 { t.Errorf("the kept copy did not inherit the recording MBID") } if n := f.count(t, `SELECT count(*) FROM duplicate_groups WHERE id = $1 AND status = 'merged' AND resolved_at IS NOT NULL`, f.groupID); n != 1 { t.Errorf("group was not marked merged") } // A second merge of the same group is refused rather than repeated. if _, err := MergeDuplicateGroup(ctx, f.pool, nil, "", f.groupID, f.keep.ID); !errors.Is(err, ErrDuplicateGroupNotPending) { t.Errorf("second merge err = %v, want ErrDuplicateGroupNotPending", err) } } // When the removed copy's file cannot go, nothing may change: its likes, plays // and row stay exactly where they were, and the group stays pending. func TestMergeDuplicateGroup_UnremovableFileChangesNothing(t *testing.T) { f := newMergeFixture(t) stubRemoveFile(t, func(name string) error { return &fs.PathError{Op: "remove", Path: name, Err: syscall.EROFS} }) _, err := MergeDuplicateGroup(context.Background(), f.pool, nil, "", f.groupID, f.keep.ID) var fre *FileRemoveError if !errors.As(err, &fre) || !fre.NotWritable() { t.Fatalf("err = %v, want a not-writable *FileRemoveError", err) } if n := f.count(t, `SELECT count(*) FROM tracks WHERE id = $1`, f.remove.ID); n != 1 { t.Errorf("the copy's row was deleted although its file was not") } if n := f.count(t, `SELECT count(*) FROM general_likes WHERE track_id = $1`, f.remove.ID); n != 2 { t.Errorf("likes on the copy = %d, want both still there", n) } if n := f.count(t, `SELECT count(*) FROM play_events WHERE track_id = $1`, f.remove.ID); n != 2 { t.Errorf("plays on the copy = %d, want both still there", n) } if n := f.count(t, `SELECT count(*) FROM duplicate_groups WHERE id = $1 AND status = 'pending'`, f.groupID); n != 1 { t.Errorf("group left pending = false, want it still pending") } } func TestMergeDuplicateGroup_SurvivorMustBeAMember(t *testing.T) { f := newMergeFixture(t) var stranger pgtype.UUID stranger.Bytes[15], stranger.Valid = 0xEE, true _, err := MergeDuplicateGroup(context.Background(), f.pool, nil, "", f.groupID, stranger) if !errors.Is(err, ErrSurvivorNotInGroup) { t.Fatalf("err = %v, want ErrSurvivorNotInGroup", err) } if _, err := os.Stat(f.removePath); err != nil { t.Errorf("a refused merge removed a file: %v", err) } if n := f.count(t, `SELECT count(*) FROM duplicate_groups WHERE id = $1 AND status = 'pending'`, f.groupID); n != 1 { t.Errorf("a refused merge changed the group") } }