test-web / test (push) Successful in 2m4s
test-go / test (push) Successful in 2m23s
test-go / integration (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 6m29s
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token held each user's apiKey in plaintext and was looked up by equality, so a leaked row or backup handed out working keys. Migration 0063 replaces it with api_token_hash (sha256, hex), computed in place from the existing keys so every Subsonic client keeps working. The key can no longer be read back: GET /api/me/api-token is gone, and POST returns the new key once. Settings shows it right after Regenerate with a copy button and a "won't be shown again" note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
11 KiB
Go
282 lines
11 KiB
Go
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", ApiTokenHash: 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")
|
|
}
|
|
}
|