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
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>
149 lines
5.8 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|