Files
minstrel/internal/library/duplicate_settle_test.go
T
bvandeusenandClaude Opus 5.5 7e6228c13f
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
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
docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:27:34 -04:00

149 lines
5.8 KiB
Go

package library
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// undecidedSlot is one recording filed as Void and as Reach, two songs
// MusicBrainz lists at lengths that cannot tell them apart, from a download
// Minstrel imported: the resolver leaves it pending for an admin.
func undecidedSlot(t *testing.T) (oneAlbumFixture, dbq.User) {
t.Helper()
f := newOneAlbumFixture(t, "acoustic", "Void", "Reach")
ctx := context.Background()
seedTrackList(t, f.pool, f.album, map[string]int{"Void": 220500, "Reach": 221000})
if _, err := f.pool.Exec(ctx,
`INSERT INTO grabs (info_hash, name, key, client, save_path, origin, import_state, destination)
VALUES ('abc123', 'Savant - Void [WEB]', 'k', 'qbit', '/dl', 'schedule', 'imported', $1)`,
filepath.Dir(f.aPath)); err != nil {
t.Fatal(err)
}
if res, err := ResolveDuplicates(ctx, f.pool, nil, "", true); err != nil || res.WrongFiles != 0 {
t.Fatalf("resolve = %+v, %v; want the group left for the operator", res, err)
}
name := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
u, err := dbq.New(f.pool).CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-token",
})
if err != nil {
t.Fatal(err)
}
return f, u
}
// emptiedTrack says whether a copy's track is marked missing and waiting for
// the album to be fetched again.
func emptiedTrack(t *testing.T, f oneAlbumFixture, id pgtype.UUID) bool {
t.Helper()
var emptied bool
if err := f.pool.QueryRow(context.Background(),
`SELECT missing_since IS NOT NULL AND emptied_at IS NOT NULL FROM tracks WHERE id = $1`, id).Scan(&emptied); err != nil {
t.Fatalf("track %s is gone: %v", syncpkg.FormatUUID(id), err)
}
return emptied
}
// settledByHand checks the group closed as the admin's and the audit row
// names them.
func settledByHand(t *testing.T, f oneAlbumFixture, actor dbq.User) {
t.Helper()
if status, _, _, auto := (resolveFixture{pool: f.pool, groupID: f.groupID}).group(t); status != "merged" || auto {
t.Errorf("group = (%s, auto=%v), want merged by the operator", status, auto)
}
var n int
if err := f.pool.QueryRow(context.Background(),
`SELECT count(*) FROM audit_log WHERE action = 'duplicate_wrong_file' AND actor_id = $1
AND metadata->>'group_id' = $2 AND NOT (metadata->>'automatic')::boolean`,
actor.ID, syncpkg.FormatUUID(f.groupID)).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("operator's wrong-file audit rows = %d, want 1", n)
}
}
// "Get it again": the admin cannot say which song the recording is, so
// every copy goes, the download they came from is set aside, and both slots
// wait for the album fetched again. A merge would have kept one title.
func TestSettleWrongSlot_GetItAgainRemovesEveryCopy_Integration(t *testing.T) {
f, op := undecidedSlot(t)
ctx := context.Background()
res, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, op.ID)
if err != nil {
t.Fatal(err)
}
if res.Kept != nil || len(res.Removed) != 2 || exists(f.aPath) || exists(f.bPath) {
t.Fatalf("result %+v, void exists %v, reach exists %v; want both copies removed",
res, exists(f.aPath), exists(f.bPath))
}
for _, id := range []pgtype.UUID{f.a.ID, f.b.ID} {
if !emptiedTrack(t, f, id) {
t.Errorf("track %s is not waiting to be fetched again", syncpkg.FormatUUID(id))
}
}
if !releaseBlocked(t, f.pool, "abc123") || len(res.Blocked) != 1 {
t.Errorf("blocked %v; want the download the files came from set aside", res.Blocked)
}
settledByHand(t, f, op)
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, op.ID); !errors.Is(err, ErrDuplicateGroupNotPending) {
t.Errorf("settling it twice: err = %v, want ErrDuplicateGroupNotPending", err)
}
}
// "This one is Reach": the copy the admin names stays, and only Void's slot
// is emptied and wanted again.
func TestSettleWrongSlot_KeepsTheCopyTheOperatorNames_Integration(t *testing.T) {
f, op := undecidedSlot(t)
res, err := SettleWrongSlot(context.Background(), f.pool, nil, f.groupID, f.b.ID, op.ID)
if err != nil {
t.Fatal(err)
}
if res.Kept == nil || res.Kept.TrackID != f.b.ID || len(res.Removed) != 1 || exists(f.aPath) || !exists(f.bPath) {
t.Fatalf("result %+v, void exists %v, reach exists %v; want only Void's copy removed",
res, exists(f.aPath), exists(f.bPath))
}
if !emptiedTrack(t, f, f.a.ID) {
t.Error("Void's track is not waiting to be fetched again")
}
if !releaseBlocked(t, f.pool, "abc123") {
t.Error("the download the files came from is not set aside")
}
settledByHand(t, f, op)
}
// Only a group of one recording in several slots is settled this way, and only
// with a copy from it.
func TestSettleWrongSlot_RefusesWhatItCannotSettle_Integration(t *testing.T) {
ctx := context.Background()
t.Run("two versions of a song", func(t *testing.T) {
f := newOneAlbumFixture(t, "acoustic", "Lovesick", "Lovesick (Instrumental)")
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, pgtype.UUID{}); !errors.Is(err, ErrNotWrongSlot) {
t.Errorf("err = %v, want ErrNotWrongSlot", err)
}
if !exists(f.aPath) || !exists(f.bPath) {
t.Error("a file was removed")
}
})
t.Run("a copy from another group", func(t *testing.T) {
f, op := undecidedSlot(t)
other := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, other, op.ID); !errors.Is(err, ErrSurvivorNotInGroup) {
t.Errorf("err = %v, want ErrSurvivorNotInGroup", err)
}
if !exists(f.aPath) || !exists(f.bPath) || releaseBlocked(t, f.pool, "abc123") {
t.Error("something changed")
}
})
}