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 the operator (#6184). 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 operator'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 operator 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 operator 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") } }) }