release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
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
RemoveTrack took the admin's id and dropped it, so a merge left an audit trail and the more destructive removal did not. It now reads the track's title, artist, album and file path before the delete and writes them, with whether the album and artist went too, under the new track_remove action. The audit action groups lose their plan-era U1/U2/U3 headings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
9.2 KiB
Go
297 lines
9.2 KiB
Go
package tracks
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"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"
|
|
)
|
|
|
|
// --- test seed helpers (inlined; mirrors quarantine patterns) -----------
|
|
|
|
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 seedAdmin(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
|
t.Helper()
|
|
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
|
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
|
ApiTokenHash: name + "-token", IsAdmin: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seed admin %s: %v", name, err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
// seedArtistAlbumTrack creates an artist + album + track triple. mbid is
|
|
// set on both the album and the track when non-empty (the album mbid is
|
|
// derived from the track mbid suffix); empty leaves both nil so the
|
|
// track exercises the local-only path. filePath is the on-disk path
|
|
// stored on the track row. Names are unique per call so multiple seeds
|
|
// in one test don't collide on UNIQUE constraints.
|
|
func seedArtistAlbumTrack(t *testing.T, pool *pgxpool.Pool, prefix, mbid, filePath string) (dbq.Artist, dbq.Album, dbq.Track) {
|
|
t.Helper()
|
|
q := dbq.New(pool)
|
|
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
|
Name: prefix + " Artist", SortName: prefix + " Artist",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("artist: %v", err)
|
|
}
|
|
var albumMBIDPtr *string
|
|
if mbid != "" {
|
|
s := mbid + "-album"
|
|
albumMBIDPtr = &s
|
|
}
|
|
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
|
Title: prefix + " Album", SortTitle: prefix + " Album",
|
|
ArtistID: artist.ID, Mbid: albumMBIDPtr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("album: %v", err)
|
|
}
|
|
var trackMBIDPtr *string
|
|
if mbid != "" {
|
|
s := mbid
|
|
trackMBIDPtr = &s
|
|
}
|
|
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
Title: prefix + " Track", AlbumID: album.ID, ArtistID: artist.ID,
|
|
DurationMs: 1000, FilePath: filePath, FileSize: 1, FileFormat: "mp3",
|
|
Mbid: trackMBIDPtr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("track: %v", err)
|
|
}
|
|
return artist, album, track
|
|
}
|
|
|
|
// --- tests --------------------------------------------------------------
|
|
|
|
func TestRemoveTrack_NotFound(t *testing.T) {
|
|
pool := newPool(t)
|
|
admin := seedAdmin(t, pool, "alice")
|
|
|
|
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
|
|
|
|
svc := NewService(pool, nil, "")
|
|
_, _, err := svc.RemoveTrack(context.Background(), bogus, admin.ID)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveTrack_LocalFile_HappyPath(t *testing.T) {
|
|
pool := newPool(t)
|
|
admin := seedAdmin(t, pool, "alice")
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "track.mp3")
|
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, track := seedArtistAlbumTrack(t, pool, "Solo", "", path)
|
|
|
|
svc := NewService(pool, nil, "")
|
|
_, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
|
|
if err != nil {
|
|
t.Fatalf("RemoveTrack: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("file still present: %v", err)
|
|
}
|
|
q := dbq.New(pool)
|
|
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
|
t.Errorf("track row still exists")
|
|
}
|
|
}
|
|
|
|
func TestRemoveTrack_WritesAuditRow(t *testing.T) {
|
|
pool := newPool(t)
|
|
admin := seedAdmin(t, pool, "alice")
|
|
if _, err := pool.Exec(context.Background(), `DELETE FROM audit_log`); err != nil {
|
|
t.Fatalf("reset audit_log: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "track.mp3")
|
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, track := seedArtistAlbumTrack(t, pool, "Audited", "", path)
|
|
|
|
svc := NewService(pool, nil, "")
|
|
if _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID); err != nil {
|
|
t.Fatalf("RemoveTrack: %v", err)
|
|
}
|
|
|
|
var actor pgtype.UUID
|
|
var title, artist, album, filePath string
|
|
var albumDeleted bool
|
|
err := pool.QueryRow(context.Background(), `
|
|
SELECT actor_id, metadata->>'title', metadata->>'artist', metadata->>'album',
|
|
metadata->>'file_path', (metadata->>'album_deleted')::boolean
|
|
FROM audit_log WHERE action = 'track_remove'`).
|
|
Scan(&actor, &title, &artist, &album, &filePath, &albumDeleted)
|
|
if err != nil {
|
|
t.Fatalf("read audit row: %v", err)
|
|
}
|
|
if actor != admin.ID {
|
|
t.Errorf("actor = %v, want the admin %v", actor, admin.ID)
|
|
}
|
|
if title != "Audited Track" || artist != "Audited Artist" || album != "Audited Album" || filePath != path {
|
|
t.Errorf("metadata = %q / %q / %q / %q", title, artist, album, filePath)
|
|
}
|
|
if !albumDeleted {
|
|
t.Errorf("album_deleted = false, want true: the track was its album's only one")
|
|
}
|
|
}
|
|
|
|
func TestRemoveTrack_FileAlreadyMissing(t *testing.T) {
|
|
pool := newPool(t)
|
|
admin := seedAdmin(t, pool, "alice")
|
|
|
|
// Path doesn't exist on disk; service must tolerate and clean up DB.
|
|
missing := filepath.Join(t.TempDir(), "does-not-exist.mp3")
|
|
_, _, track := seedArtistAlbumTrack(t, pool, "Ghost", "", missing)
|
|
|
|
svc := NewService(pool, nil, "")
|
|
_, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
|
|
if err != nil {
|
|
t.Fatalf("RemoveTrack: %v", err)
|
|
}
|
|
q := dbq.New(pool)
|
|
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
|
t.Errorf("track row still exists")
|
|
}
|
|
}
|
|
|
|
func TestRemoveTrack_Cascade_AlbumEmpty(t *testing.T) {
|
|
pool := newPool(t)
|
|
admin := seedAdmin(t, pool, "alice")
|
|
|
|
q := dbq.New(pool)
|
|
// Two-album artist: removing the sole track of album A leaves A
|
|
// orphaned, but the artist still has album B (so the artist cascade
|
|
// must NOT fire).
|
|
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
|
Name: "Cascade Artist", SortName: "Cascade Artist",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("artist: %v", err)
|
|
}
|
|
albumA, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
|
Title: "Album A", SortTitle: "Album A", ArtistID: artist.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("albumA: %v", err)
|
|
}
|
|
albumB, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
|
Title: "Album B", SortTitle: "Album B", ArtistID: artist.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("albumB: %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
pathA := filepath.Join(dir, "a.mp3")
|
|
pathB := filepath.Join(dir, "b.mp3")
|
|
_ = os.WriteFile(pathA, []byte("x"), 0o644)
|
|
_ = os.WriteFile(pathB, []byte("x"), 0o644)
|
|
trackA, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
Title: "TA", AlbumID: albumA.ID, ArtistID: artist.ID,
|
|
DurationMs: 1000, FilePath: pathA, FileSize: 1, FileFormat: "mp3",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("trackA: %v", err)
|
|
}
|
|
if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
Title: "TB", AlbumID: albumB.ID, ArtistID: artist.ID,
|
|
DurationMs: 1000, FilePath: pathB, FileSize: 1, FileFormat: "mp3",
|
|
}); err != nil {
|
|
t.Fatalf("trackB: %v", err)
|
|
}
|
|
|
|
svc := NewService(pool, nil, "")
|
|
delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), trackA.ID, admin.ID)
|
|
if err != nil {
|
|
t.Fatalf("RemoveTrack: %v", err)
|
|
}
|
|
if delAlbum == nil || *delAlbum != albumA.ID {
|
|
t.Errorf("deletedAlbumID = %v, want %v", delAlbum, albumA.ID)
|
|
}
|
|
if delArtist != nil {
|
|
t.Errorf("deletedArtistID = %v, want nil (artist still has album B)", delArtist)
|
|
}
|
|
if _, err := q.GetAlbumByID(context.Background(), albumA.ID); err == nil {
|
|
t.Errorf("album A still exists after cascade")
|
|
}
|
|
if _, err := q.GetAlbumByID(context.Background(), albumB.ID); err != nil {
|
|
t.Errorf("album B unexpectedly removed: %v", err)
|
|
}
|
|
if _, err := q.GetArtistByID(context.Background(), artist.ID); err != nil {
|
|
t.Errorf("artist unexpectedly removed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveTrack_Cascade_ArtistEmpty(t *testing.T) {
|
|
pool := newPool(t)
|
|
admin := seedAdmin(t, pool, "alice")
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "lone.mp3")
|
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
artist, album, track := seedArtistAlbumTrack(t, pool, "Lone", "", path)
|
|
|
|
svc := NewService(pool, nil, "")
|
|
delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
|
|
if err != nil {
|
|
t.Fatalf("RemoveTrack: %v", err)
|
|
}
|
|
if delAlbum == nil || *delAlbum != album.ID {
|
|
t.Errorf("deletedAlbumID = %v, want %v", delAlbum, album.ID)
|
|
}
|
|
if delArtist == nil || *delArtist != artist.ID {
|
|
t.Errorf("deletedArtistID = %v, want %v", delArtist, artist.ID)
|
|
}
|
|
q := dbq.New(pool)
|
|
if _, err := q.GetAlbumByID(context.Background(), album.ID); err == nil {
|
|
t.Errorf("album still exists after cascade")
|
|
}
|
|
if _, err := q.GetArtistByID(context.Background(), artist.ID); err == nil {
|
|
t.Errorf("artist still exists after cascade")
|
|
}
|
|
}
|