//go:build integration package uploads import ( "context" "crypto/sha256" "encoding/json" "errors" "log/slog" "os" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" "pipu/api/internal/auth" "pipu/api/internal/library" "pipu/api/internal/musicbrainz" "pipu/api/internal/platform/db" ) type testReleaseCatalogue struct { release musicbrainz.Release searches int err error } func (c *testReleaseCatalogue) Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error) { c.searches++ return []musicbrainz.Release{c.release}, c.err } func (c *testReleaseCatalogue) Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error) { return musicbrainz.Cover{Data: []byte{0xff, 0xd8, 0xff, 1}, MIME: "image/jpeg", Exact: true}, nil } func TestReleaseJobsAndAtomicApplication(t *testing.T) { address := os.Getenv("TEST_DATABASE_URL") if address == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := db.Connect(ctx, address) require.NoError(t, err) defer pool.Close() require.NoError(t, db.Migrate(ctx, pool)) author := uuid.New() _, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','admin')`, author, "release-"+author.String()) require.NoError(t, err) source, err := library.NewStore(pool).SaveSource(ctx, library.Source{ID: uuid.New(), Name: "release-test", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{track:02} - {title}.{ext}")}) require.NoError(t, err) store := NewStore(pool) oldCover := "covers/original.jpg" sum1, sum2 := sha256.Sum256([]byte(uuid.NewString())), sha256.Sum256([]byte(uuid.NewString())) batch, reserved, err := store.CreateBatch(ctx, author, nil, []NewFile{{ClientID: "1", Name: "one.flac", Size: 10, SHA256: sum1[:]}, {ClientID: "2", Name: "two.flac", Size: 10, SHA256: sum2[:]}}, DefaultSettings, &oldCover) require.NoError(t, err) require.Len(t, reserved, 2) // Isolate from batches produced by other integration tests. _, err = pool.Exec(ctx, `UPDATE upload_release_jobs SET state='not_found' WHERE batch_id<>$1`, batch) require.NoError(t, err) defer func() { _, _ = pool.Exec(ctx, `DELETE FROM upload_batches WHERE id=$1`, batch) _ = store.ReconcileQuota(ctx) }() provider := &testReleaseCatalogue{release: musicbrainz.Release{ID: uuid.NewString(), Artist: "Artist", Title: "Album", Type: "Single", Date: "2024-01-01", Tracks: []musicbrainz.Track{ {Title: "Song 1", Artist: "Artist", Disc: 1, Position: 1, DurationMs: 180000}, {Title: "Song 2", Artist: "Artist", Disc: 1, Position: 2, DurationMs: 190000}, }}} svc := NewService(store, &previewCatalog{source: source}, previewRoot(t.TempDir()), nil, nil, nil, library.FPCalc{}, Config{StagingDir: t.TempDir()}, slog.New(slog.DiscardHandler)) svc.SetReleaseCatalogue(provider) review := auth.WithPrincipal(ctx, auth.Principal{UserID: author, Role: auth.RoleAdmin, ReviewsUploads: true}) for i, r := range reserved { require.NoError(t, store.ChangeStatus(ctx, r.FileID, []Status{StatusUploading}, StatusProcessing)) m := Meta{Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "SONG " + string(rune('1'+i))} n := i + 1 m.Track = &n raw, _ := json.Marshal(m) duration := 180000 + i*10000 require.NoError(t, store.Transition(ctx, r.FileID, []Status{StatusProcessing}, StatusPendingReview, func(tx pgx.Tx) error { _, e := tx.Exec(ctx, `UPDATE upload_files SET metadata=$2,duration_ms=$3 WHERE id=$1`, r.FileID, raw, duration) return e })) if i == 0 { _, err = store.ClaimRelease(ctx) require.ErrorIs(t, err, ErrNotFound, "lookup waits until the whole batch is processed") } } job, err := store.ClaimRelease(ctx) require.NoError(t, err) require.Equal(t, batch, job.ID) files, err := store.AllInBatch(ctx, batch) require.NoError(t, err) edited := *files[0].Meta year := 2025 edited.Year = &year require.NoError(t, store.UpdateMetadata(ctx, files[0].ID, edited, true, placement{})) require.NoError(t, store.SaveRelease(ctx, job, ReleaseReport{State: "ready", InputHash: "stale", Candidates: []ReleaseCandidate{}})) report, err := store.ReleaseReport(ctx, batch) require.NoError(t, err) require.Equal(t, "queued", report.State, "an old worker lease cannot overwrite an edit") require.True(t, svc.lookupNextRelease(ctx)) report, err = svc.GetReleaseReport(review, batch) require.NoError(t, err) require.Equal(t, "ready", report.State) require.True(t, report.Candidates[0].Complete) _, err = svc.SearchReleases(review, batch, musicbrainz.Query{Audio: true}) require.ErrorContains(t, err, "audio_disabled") identifier := &testAudioIdentifier{} svc.SetAudioIdentifier(identifier) provider.release.Tracks[0].RecordingID, provider.release.Tracks[1].RecordingID = uuid.NewString(), uuid.NewString() svc.encodedFingerprint = func(_ context.Context, filename string) (string, error) { if strings.Contains(filename, reserved[0].FileID.String()) { identifier.id = provider.release.Tracks[0].RecordingID } else { identifier.id = provider.release.Tracks[1].RecordingID } return "encoded", nil } _, err = svc.SearchReleases(review, batch, musicbrainz.Query{Audio: true}) require.NoError(t, err) require.True(t, svc.lookupNextRelease(ctx)) report, err = svc.GetReleaseReport(review, batch) require.NoError(t, err) require.True(t, report.AudioAvailable) require.Len(t, report.AudioResults, 2) require.Equal(t, "acoustid", report.Candidates[0].Matches[0].Method) require.True(t, report.Candidates[0].Complete) before, err := store.AllInBatch(ctx, batch) require.NoError(t, err) require.Equal(t, "SONG 1", before[0].Meta.Title) // Looking up the release never changes tags or cover. require.Equal(t, oldCover, *before[0].CoverPath) used, _, err := store.Usage(ctx, author) require.NoError(t, err) applied, err := svc.ApplyRelease(review, batch, provider.release.ID, true, false) require.NoError(t, err) require.Equal(t, provider.release.ID, *applied.SelectedRelease) persisted, err := store.ReleaseReport(ctx, batch) require.NoError(t, err) require.Len(t, persisted.AudioResults, 2, "metadata update trigger must not erase confirmed recognition") after, err := store.AllInBatch(ctx, batch) require.NoError(t, err) require.Equal(t, "Song 1", after[0].Meta.Title) require.Equal(t, oldCover, *after[0].CoverPath) require.Equal(t, StatusPendingReview, after[0].Status) require.Equal(t, provider.release.ID, after[0].Tags["musicbrainz_albumid"]) require.NotNil(t, after[0].TargetRelPath) require.Contains(t, *after[0].TargetRelPath, "01 - Song 1.flac") newUsed, _, err := store.Usage(ctx, author) require.NoError(t, err) require.Equal(t, used, newUsed) _, err = svc.ApplyRelease(review, batch, provider.release.ID, false, true) require.NoError(t, err) after, err = store.AllInBatch(ctx, batch) require.NoError(t, err) require.NotEqual(t, oldCover, *after[0].CoverPath) // A queued manual lookup makes the old candidate inapplicable immediately. _, err = svc.SearchReleases(review, batch, musicbrainz.Query{}) require.NoError(t, err) _, err = svc.ApplyRelease(review, batch, provider.release.ID, true, false) require.ErrorContains(t, err, "release_stale") provider.err = errors.New("provider offline") require.True(t, svc.lookupNextRelease(ctx)) report, err = store.ReleaseReport(ctx, batch) require.NoError(t, err) require.Equal(t, "error", report.State) _, err = store.ClaimRelease(ctx) require.ErrorIs(t, err, ErrNotFound, "failures back off instead of blocking upload processing") _, err = svc.GetReleaseReport(ctx, batch) require.Error(t, err, "review permission is mandatory") key := "integration:" + uuid.NewString() require.NoError(t, store.PutMetadataCache(ctx, key, []byte("cached"), time.Hour)) data, ok, err := store.GetMetadataCache(ctx, key) require.NoError(t, err) require.True(t, ok) require.Equal(t, "cached", string(data)) require.NoError(t, store.PutMetadataCache(ctx, key, data, -time.Second)) require.NoError(t, store.ExpireMetadataCache(ctx)) _, ok, err = store.GetMetadataCache(ctx, key) require.NoError(t, err) require.False(t, ok) }