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>
230 lines
7.1 KiB
Go
230 lines
7.1 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
|
)
|
|
|
|
func newPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in -short mode")
|
|
}
|
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
}
|
|
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
pool, err := pgxpool.New(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
dbtest.ResetDB(t, pool)
|
|
return pool
|
|
}
|
|
|
|
func seedTrack(t *testing.T, pool *pgxpool.Pool, filePath string) (dbq.Track, dbq.Album, dbq.Artist) {
|
|
t.Helper()
|
|
q := dbq.New(pool)
|
|
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
|
Name: "Delete Test Artist", SortName: "Delete Test Artist",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("artist: %v", err)
|
|
}
|
|
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
|
Title: "Delete Test Album", SortTitle: "Delete Test Album",
|
|
ArtistID: artist.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("album: %v", err)
|
|
}
|
|
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
Title: "Delete Test Track", AlbumID: album.ID, ArtistID: artist.ID,
|
|
DurationMs: 1000, FilePath: filePath, FileSize: 100, FileFormat: "mp3",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("track: %v", err)
|
|
}
|
|
return track, album, artist
|
|
}
|
|
|
|
// stubRemoveFile makes file removal fail (or succeed) on demand for one test.
|
|
// See removeFile for why this is a seam rather than a chmod.
|
|
func stubRemoveFile(t *testing.T, fn func(string) error) {
|
|
t.Helper()
|
|
orig := removeFile
|
|
removeFile = fn
|
|
t.Cleanup(func() { removeFile = orig })
|
|
}
|
|
|
|
func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := dbq.New(pool)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "track.mp3")
|
|
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
track, album, artist := seedTrack(t, pool, path)
|
|
// A sibling keeps the album non-empty, so this case pins that the tidy-up
|
|
// only removes an album the delete actually emptied.
|
|
if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
Title: "Sibling", AlbumID: album.ID, ArtistID: artist.ID,
|
|
DurationMs: 1000, FilePath: filepath.Join(dir, "sibling.mp3"), FileSize: 100, FileFormat: "mp3",
|
|
}); err != nil {
|
|
t.Fatalf("sibling: %v", err)
|
|
}
|
|
|
|
got, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
|
if err != nil {
|
|
t.Fatalf("DeleteTrackFile: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("file still exists: %v", err)
|
|
}
|
|
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
|
t.Errorf("track row still exists")
|
|
}
|
|
if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil {
|
|
t.Errorf("album with a remaining track vanished: %v", err)
|
|
}
|
|
if got.AlbumID != nil || got.ArtistID != nil {
|
|
t.Errorf("reported tidy-up %+v for an album that still has a track", got)
|
|
}
|
|
}
|
|
|
|
func TestDeleteTrackFile_EmptiedAlbumAndArtistGoToo(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := dbq.New(pool)
|
|
|
|
path := filepath.Join(t.TempDir(), "lone.mp3")
|
|
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
track, album, artist := seedTrack(t, pool, path)
|
|
|
|
got, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
|
if err != nil {
|
|
t.Fatalf("DeleteTrackFile: %v", err)
|
|
}
|
|
if got.AlbumID == nil || *got.AlbumID != album.ID {
|
|
t.Errorf("AlbumID = %v, want %v", got.AlbumID, album.ID)
|
|
}
|
|
if got.ArtistID == nil || *got.ArtistID != artist.ID {
|
|
t.Errorf("ArtistID = %v, want %v", got.ArtistID, artist.ID)
|
|
}
|
|
if _, err := q.GetAlbumByID(context.Background(), album.ID); err == nil {
|
|
t.Errorf("emptied album row still exists")
|
|
}
|
|
if _, err := q.GetArtistByID(context.Background(), artist.ID); err == nil {
|
|
t.Errorf("emptied artist row still exists")
|
|
}
|
|
}
|
|
|
|
// A file that cannot be removed must leave EVERYTHING in place:
|
|
// the row is what carries likes, plays and playlist memberships, and the file
|
|
// surviving means the next scan would re-import it as a stranger.
|
|
func TestDeleteTrackFile_UnremovableFileDeletesNothing(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
errno syscall.Errno
|
|
notWritable bool
|
|
}{
|
|
{"read-only mount", syscall.EROFS, true},
|
|
{"permission denied", syscall.EACCES, true},
|
|
{"operation not permitted", syscall.EPERM, true},
|
|
{"i/o error", syscall.EIO, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := dbq.New(pool)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "track.mp3")
|
|
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
track, album, _ := seedTrack(t, pool, path)
|
|
stubRemoveFile(t, func(name string) error {
|
|
return &fs.PathError{Op: "remove", Path: name, Err: tc.errno}
|
|
})
|
|
|
|
_, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
|
|
|
var fre *FileRemoveError
|
|
if !errors.As(err, &fre) {
|
|
t.Fatalf("err = %v, want a *FileRemoveError", err)
|
|
}
|
|
if fre.NotWritable() != tc.notWritable {
|
|
t.Errorf("NotWritable = %v, want %v", fre.NotWritable(), tc.notWritable)
|
|
}
|
|
if fre.Dir() != dir {
|
|
t.Errorf("Dir = %q, want the parent directory %q", fre.Dir(), dir)
|
|
}
|
|
if fre.Reason() != tc.errno.Error() {
|
|
t.Errorf("Reason = %q, want %q", fre.Reason(), tc.errno.Error())
|
|
}
|
|
if fre.UID != os.Getuid() || fre.GID != os.Getgid() {
|
|
t.Errorf("identity = %d:%d, want this process's %d:%d", fre.UID, fre.GID, os.Getuid(), os.Getgid())
|
|
}
|
|
if _, err := q.GetTrackByID(context.Background(), track.ID); err != nil {
|
|
t.Errorf("track row was deleted although its file was not: %v", err)
|
|
}
|
|
if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil {
|
|
t.Errorf("album row was deleted although the track's file was not: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Errorf("file gone although removal was refused: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := dbq.New(pool)
|
|
|
|
track, _, _ := seedTrack(t, pool, "/no/such/file/anywhere.mp3")
|
|
|
|
if _, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID); err != nil {
|
|
t.Fatalf("DeleteTrackFile with missing file: %v", err)
|
|
}
|
|
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
|
t.Errorf("track row still exists")
|
|
}
|
|
}
|
|
|
|
func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) {
|
|
pool := newPool(t)
|
|
|
|
var bogus pgtype.UUID
|
|
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
|
bogus.Valid = true
|
|
|
|
_, err := DeleteTrackFile(context.Background(), pool, nil, "", bogus)
|
|
if !errors.Is(err, ErrTrackNotFound) {
|
|
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
|
}
|
|
}
|