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") } } // The #3918 proof. 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) } }