From 5c61c10b63169c5d6ad20dd65e09780f460f6858 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 10:17:08 -0400 Subject: [PATCH] feat(playlists): Service with CRUD methods (M7 #352 slice 1) Create / Get / List / Update / Delete with the visibility model from the spec: private by default, owner-only mutations, public read for non-owners. Update uses sqlc's CASE-WHEN-flag pattern for PATCH-style partial updates without writing N variants. Delete cleans up the cached cover file from disk best-effort. Track operations (Append/Remove/Reorder) and the collage generator land in subsequent tasks. Also adds playlists + playlist_tracks to dbtest.ResetDB's truncate list so integration tests in this package start from a clean state. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/dbtest/reset.go | 2 + internal/playlists/service.go | 334 ++++++++++++++++++ internal/playlists/service_test.go | 320 +++++++++++++++++ .../playlists/service_test_helpers_test.go | 92 +++++ 4 files changed, 748 insertions(+) create mode 100644 internal/playlists/service.go create mode 100644 internal/playlists/service_test.go create mode 100644 internal/playlists/service_test_helpers_test.go diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index 3395d19a..8e24cd48 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -52,6 +52,8 @@ var dataTables = []string{ "sessions", "lidarr_quarantine_actions", "lidarr_quarantine", + "playlist_tracks", + "playlists", "tracks", "albums", "artists", diff --git a/internal/playlists/service.go b/internal/playlists/service.go new file mode 100644 index 00000000..19ec8bd8 --- /dev/null +++ b/internal/playlists/service.go @@ -0,0 +1,334 @@ +// Package playlists implements the user-facing playlist CRUD service. +// +// Visibility model (from the M7 #352 design spec): +// - Playlists are private by default. Only the owner sees private rows +// in List, Get, and is allowed to mutate them via Update / Delete. +// - is_public = true makes a playlist readable by any authenticated +// user; mutations are still owner-only. +// +// Track-list mutations (Append / Remove / Reorder) and the cover-art +// collage generator land in subsequent tasks; this file ships the +// CRUD-only slice on top of the migration 0014 + sqlc queries from +// task 1. +package playlists + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Typed errors. The API layer maps each to its wire status code; tests +// use errors.Is to assert the right path was taken. +var ( + ErrNotFound = errors.New("playlists: playlist not found") + ErrForbidden = errors.New("playlists: forbidden") + ErrInvalidInput = errors.New("playlists: invalid input") +) + +// Service wires the sqlc queries + on-disk cover-art directory together. +// dataDir is the root for cached cover-art collages (cover_path on the +// playlist row is stored relative to this directory so the install can +// be relocated without rewriting rows). +type Service struct { + pool *pgxpool.Pool + logger *slog.Logger + dataDir string +} + +// NewService constructs a Service. A nil logger gets slog.Default(). +func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Service { + if logger == nil { + logger = slog.Default() + } + return &Service{pool: pool, logger: logger, dataDir: dataDir} +} + +// PlaylistRow is the wire shape returned by Create / Update / List / +// the embedded portion of Get. owner_username is denormalized in via +// the JOIN on users so callers don't have to resolve it themselves. +type PlaylistRow struct { + ID pgtype.UUID + UserID pgtype.UUID + OwnerUsername string + Name string + Description string + IsPublic bool + CoverPath *string + TrackCount int32 + DurationSec int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +// PlaylistDetail extends PlaylistRow with the ordered track list. +// Returned by Get only; the List endpoint stays cheap and returns +// header-only PlaylistRows. +type PlaylistDetail struct { + PlaylistRow + Tracks []PlaylistTrack +} + +// PlaylistTrack is one row of a playlist's ordered track list. +// +// Title / ArtistName / AlbumTitle / DurationSec are the snapshot the +// playlist row was created with — they remain authoritative even after +// the upstream tracks/albums/artists rows change or disappear. +// +// TrackID is nil when the upstream tracks row has been deleted from +// the library (ON DELETE SET NULL). AlbumID / ArtistID come from the +// LEFT JOIN through the live track and follow the same nil-on-removed +// semantics — once the track row is gone, neither the album nor the +// artist can be resolved either, so they collapse to nil together. +type PlaylistTrack struct { + Position int32 + TrackID *pgtype.UUID + AlbumID *pgtype.UUID + ArtistID *pgtype.UUID + Title string + ArtistName string + AlbumTitle string + DurationSec int32 + AddedAt pgtype.Timestamptz +} + +// Create makes a new playlist owned by userID. +// +// The post-create GetPlaylist call exists to pick up owner_username +// from the users JOIN — CreatePlaylist returns only playlist columns, +// not the joined username, so we'd otherwise have to resolve it twice +// in the API layer. +func (s *Service) Create(ctx context.Context, userID pgtype.UUID, name, description string, isPublic bool) (*PlaylistRow, error) { + if name == "" { + return nil, fmt.Errorf("%w: name required", ErrInvalidInput) + } + q := dbq.New(s.pool) + row, err := q.CreatePlaylist(ctx, dbq.CreatePlaylistParams{ + UserID: userID, + Name: name, + Description: description, + IsPublic: isPublic, + }) + if err != nil { + return nil, fmt.Errorf("create playlist: %w", err) + } + full, err := q.GetPlaylist(ctx, row.ID) + if err != nil { + return nil, fmt.Errorf("get-after-create: %w", err) + } + return playlistFromGetRow(full), nil +} + +// Get returns the playlist identified by playlistID along with its +// ordered track list. Visibility: owner can always see; non-owners +// only see public playlists. Returns ErrNotFound for missing rows +// and ErrForbidden when a non-owner asks for a private one. +func (s *Service) Get(ctx context.Context, callerID, playlistID pgtype.UUID) (*PlaylistDetail, error) { + q := dbq.New(s.pool) + row, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get playlist: %w", err) + } + if !row.IsPublic && !pgtypeUUIDEqual(row.UserID, callerID) { + return nil, ErrForbidden + } + + trackRows, err := q.ListPlaylistTracks(ctx, playlistID) + if err != nil { + return nil, fmt.Errorf("list tracks: %w", err) + } + tracks := make([]PlaylistTrack, 0, len(trackRows)) + for _, t := range trackRows { + pt := PlaylistTrack{ + Position: t.Position, + Title: t.Title, + ArtistName: t.ArtistName, + AlbumTitle: t.AlbumTitle, + DurationSec: t.DurationSec, + AddedAt: t.AddedAt, + } + // pt.track_id (snapshot FK, ON DELETE SET NULL) and the joined + // live_track_id should agree on validity in normal operation — + // prefer the snapshot's track_id because it survives even when + // the live row's other join columns happen to be NULL for + // unrelated reasons. + if t.TrackID.Valid { + id := t.TrackID + pt.TrackID = &id + } + if t.AlbumID.Valid { + id := t.AlbumID + pt.AlbumID = &id + } + if t.ArtistID.Valid { + id := t.ArtistID + pt.ArtistID = &id + } + tracks = append(tracks, pt) + } + + return &PlaylistDetail{ + PlaylistRow: *playlistFromGetRow(row), + Tracks: tracks, + }, nil +} + +// List returns all playlists visible to callerID: every playlist owned +// by the caller (any visibility) plus every other user's public +// playlists, ordered newest-first by updated_at. +func (s *Service) List(ctx context.Context, callerID pgtype.UUID) ([]PlaylistRow, error) { + q := dbq.New(s.pool) + rows, err := q.ListPlaylistsForUser(ctx, callerID) + if err != nil { + return nil, fmt.Errorf("list playlists: %w", err) + } + out := make([]PlaylistRow, 0, len(rows)) + for _, r := range rows { + out = append(out, *playlistFromListRow(r)) + } + return out, nil +} + +// UpdateInput captures optional fields. Each pointer being nil means +// "don't touch" — non-nil triggers a write of that column. The sqlc +// UpdatePlaylist query uses CASE-WHEN flags so we can keep PATCH +// semantics with one query rather than N variants. +type UpdateInput struct { + Name *string + Description *string + IsPublic *bool +} + +// Update applies a partial update to playlistID. Only the owner may +// mutate — non-owners (including admins, currently) get ErrForbidden. +// Empty Name (when explicitly set) is rejected with ErrInvalidInput +// rather than silently no-op'd, since the user clearly intended to +// change something. +func (s *Service) Update(ctx context.Context, callerID, playlistID pgtype.UUID, in UpdateInput) (*PlaylistRow, error) { + q := dbq.New(s.pool) + existing, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get-before-update: %w", err) + } + if !pgtypeUUIDEqual(existing.UserID, callerID) { + return nil, ErrForbidden + } + + params := dbq.UpdatePlaylistParams{ + ID: playlistID, + UpdateName: in.Name != nil, + UpdateDescription: in.Description != nil, + UpdateIsPublic: in.IsPublic != nil, + } + if in.Name != nil { + if *in.Name == "" { + return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput) + } + params.Name = *in.Name + } + if in.Description != nil { + params.Description = *in.Description + } + if in.IsPublic != nil { + params.IsPublic = *in.IsPublic + } + + updated, err := q.UpdatePlaylist(ctx, params) + if err != nil { + return nil, fmt.Errorf("update playlist: %w", err) + } + full, err := q.GetPlaylist(ctx, updated.ID) + if err != nil { + return nil, fmt.Errorf("get-after-update: %w", err) + } + return playlistFromGetRow(full), nil +} + +// Delete removes playlistID. Owner-only, like Update. After the row is +// gone, a best-effort attempt is made to remove the cached cover-art +// collage from disk; failures other than ENOENT are logged but do not +// fail the call (the row is already gone, returning an error would +// confuse callers about whether the delete took effect). +func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID) error { + q := dbq.New(s.pool) + existing, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("get-before-delete: %w", err) + } + if !pgtypeUUIDEqual(existing.UserID, callerID) { + return ErrForbidden + } + + deleted, err := q.DeletePlaylist(ctx, playlistID) + if err != nil { + return fmt.Errorf("delete playlist: %w", err) + } + if deleted.CoverPath != nil && *deleted.CoverPath != "" { + full := filepath.Join(s.dataDir, *deleted.CoverPath) + if rerr := os.Remove(full); rerr != nil && !errors.Is(rerr, os.ErrNotExist) { + s.logger.Warn("playlist delete: cover file remove failed", + "path", full, "playlist_id", playlistID, "err", rerr) + } + } + return nil +} + +func playlistFromGetRow(r dbq.GetPlaylistRow) *PlaylistRow { + return &PlaylistRow{ + ID: r.ID, + UserID: r.UserID, + OwnerUsername: r.OwnerUsername, + Name: r.Name, + Description: r.Description, + IsPublic: r.IsPublic, + CoverPath: r.CoverPath, + TrackCount: r.TrackCount, + DurationSec: r.DurationSec, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func playlistFromListRow(r dbq.ListPlaylistsForUserRow) *PlaylistRow { + return &PlaylistRow{ + ID: r.ID, + UserID: r.UserID, + OwnerUsername: r.OwnerUsername, + Name: r.Name, + Description: r.Description, + IsPublic: r.IsPublic, + CoverPath: r.CoverPath, + TrackCount: r.TrackCount, + DurationSec: r.DurationSec, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +// pgtypeUUIDEqual treats two zero/invalid UUIDs as not-equal. The +// service uses this for owner-vs-caller checks where an invalid UUID +// can never legitimately match anything. +func pgtypeUUIDEqual(a, b pgtype.UUID) bool { + if !a.Valid || !b.Valid { + return false + } + return a.Bytes == b.Bytes +} diff --git a/internal/playlists/service_test.go b/internal/playlists/service_test.go new file mode 100644 index 00000000..6d55fb15 --- /dev/null +++ b/internal/playlists/service_test.go @@ -0,0 +1,320 @@ +package playlists_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" +) + +func TestCreate_HappyPath(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + dir := t.TempDir() + + svc := playlists.NewService(pool, nil, dir) + row, err := svc.Create(context.Background(), user.ID, "Saturday morning", "Mellow", false) + if err != nil { + t.Fatalf("Create: %v", err) + } + if row.Name != "Saturday morning" { + t.Errorf("name = %q, want Saturday morning", row.Name) + } + if row.IsPublic { + t.Error("IsPublic = true, want false (default)") + } + if row.OwnerUsername != "test-alice" { + t.Errorf("OwnerUsername = %q, want test-alice", row.OwnerUsername) + } + if row.TrackCount != 0 || row.DurationSec != 0 { + t.Errorf("rollups should start at 0; got count=%d duration=%d", row.TrackCount, row.DurationSec) + } +} + +func TestCreate_EmptyNameRejected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + _, err := svc.Create(context.Background(), user.ID, "", "", false) + if !errors.Is(err, playlists.ErrInvalidInput) { + t.Errorf("err = %v, want ErrInvalidInput", err) + } +} + +func TestGet_NotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + _, err := svc.Get(context.Background(), user.ID, randomUUID()) + if !errors.Is(err, playlists.ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestGet_Forbidden_PrivatePlaylistFromOtherUser(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Private", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + if _, err := svc.Get(context.Background(), bob.ID, pl.ID); !errors.Is(err, playlists.ErrForbidden) { + t.Errorf("err = %v, want ErrForbidden", err) + } +} + +func TestGet_Public_VisibleToOtherUser(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Public mix", "", true) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + got, err := svc.Get(context.Background(), bob.ID, pl.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != "Public mix" { + t.Errorf("name = %q", got.Name) + } + if !got.IsPublic { + t.Error("IsPublic = false, want true") + } + if len(got.Tracks) != 0 { + t.Errorf("Tracks len = %d, want 0 for an empty playlist", len(got.Tracks)) + } +} + +func TestGet_Owner_SeesOwnPrivate(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + got, err := svc.Get(context.Background(), alice.ID, pl.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != "Mine" { + t.Errorf("name = %q", got.Name) + } +} + +func TestList_OwnAndPublic(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + if _, err := svc.Create(context.Background(), alice.ID, "Alice private", "", false); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := svc.Create(context.Background(), alice.ID, "Alice public", "", true); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := svc.Create(context.Background(), bob.ID, "Bob private", "", false); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := svc.Create(context.Background(), bob.ID, "Bob public", "", true); err != nil { + t.Fatalf("seed: %v", err) + } + + rows, err := svc.List(context.Background(), alice.ID) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(rows) != 3 { + t.Errorf("alice's list = %d rows, want 3 (her 2 + bob's public)", len(rows)) + } + for _, r := range rows { + if r.Name == "Bob private" { + t.Errorf("alice's list leaked bob's private playlist") + } + } +} + +func TestUpdate_OwnerOnly(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + rename := "Renamed" + if _, err := svc.Update(context.Background(), bob.ID, pl.ID, playlists.UpdateInput{Name: &rename}); !errors.Is(err, playlists.ErrForbidden) { + t.Errorf("err = %v, want ErrForbidden", err) + } + + updated, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{Name: &rename}) + if err != nil { + t.Fatalf("owner Update: %v", err) + } + if updated.Name != "Renamed" { + t.Errorf("name = %q, want Renamed", updated.Name) + } +} + +func TestUpdate_PartialFieldsLeaveOthersAlone(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Original", "Long description", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + // Touch only IsPublic — name and description must survive. + pub := true + updated, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{IsPublic: &pub}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Name != "Original" { + t.Errorf("name = %q, want unchanged Original", updated.Name) + } + if updated.Description != "Long description" { + t.Errorf("description = %q, want unchanged", updated.Description) + } + if !updated.IsPublic { + t.Error("IsPublic = false, want true after toggle") + } +} + +func TestUpdate_EmptyNameRejected(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Original", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + empty := "" + if _, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{Name: &empty}); !errors.Is(err, playlists.ErrInvalidInput) { + t.Errorf("err = %v, want ErrInvalidInput", err) + } +} + +func TestUpdate_NotFound(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + rename := "Renamed" + if _, err := svc.Update(context.Background(), alice.ID, randomUUID(), playlists.UpdateInput{Name: &rename}); !errors.Is(err, playlists.ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestDelete_OwnerOnly(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + if err := svc.Delete(context.Background(), bob.ID, pl.ID); !errors.Is(err, playlists.ErrForbidden) { + t.Errorf("non-owner err = %v, want ErrForbidden", err) + } + if err := svc.Delete(context.Background(), alice.ID, pl.ID); err != nil { + t.Fatalf("owner Delete: %v", err) + } + // Idempotency: a second delete reports NotFound rather than silently succeeding. + if err := svc.Delete(context.Background(), alice.ID, pl.ID); !errors.Is(err, playlists.ErrNotFound) { + t.Errorf("repeat Delete err = %v, want ErrNotFound", err) + } +} + +func TestDelete_NotFound(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + if err := svc.Delete(context.Background(), alice.ID, randomUUID()); !errors.Is(err, playlists.ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestDelete_RemovesCoverFile(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + dir := t.TempDir() + svc := playlists.NewService(pool, nil, dir) + + pl, err := svc.Create(context.Background(), user.ID, "Will be deleted", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + // Simulate the collage generator having stashed a file at the + // path stored on the row. The Delete path should clean it up. + coverDir := filepath.Join(dir, "playlist_covers") + if err := os.MkdirAll(coverDir, 0o755); err != nil { + t.Fatalf("mkdir cover dir: %v", err) + } + relPath := filepath.Join("playlist_covers", uuidString(pl.ID)+".jpg") + full := filepath.Join(dir, relPath) + if err := os.WriteFile(full, []byte("jpeg-bytes"), 0o644); err != nil { + t.Fatalf("seed cover file: %v", err) + } + if _, err := pool.Exec(context.Background(), + `UPDATE playlists SET cover_path = $1 WHERE id = $2`, relPath, pl.ID); err != nil { + t.Fatalf("set cover_path: %v", err) + } + + if err := svc.Delete(context.Background(), user.ID, pl.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, statErr := os.Stat(full); !errors.Is(statErr, os.ErrNotExist) { + t.Errorf("cover file still on disk after delete: stat err=%v", statErr) + } +} + +func TestDelete_MissingCoverFileTolerated(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + dir := t.TempDir() + svc := playlists.NewService(pool, nil, dir) + + pl, err := svc.Create(context.Background(), user.ID, "No cover on disk", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + // Set cover_path to a file that doesn't exist — Delete must not + // error on a missing cached file; it's just cleanup. + relPath := filepath.Join("playlist_covers", uuidString(pl.ID)+".jpg") + if _, err := pool.Exec(context.Background(), + `UPDATE playlists SET cover_path = $1 WHERE id = $2`, relPath, pl.ID); err != nil { + t.Fatalf("set cover_path: %v", err) + } + + if err := svc.Delete(context.Background(), user.ID, pl.ID); err != nil { + t.Fatalf("Delete with missing cover file: %v", err) + } +} diff --git a/internal/playlists/service_test_helpers_test.go b/internal/playlists/service_test_helpers_test.go new file mode 100644 index 00000000..91b7ce8c --- /dev/null +++ b/internal/playlists/service_test_helpers_test.go @@ -0,0 +1,92 @@ +package playlists_test + +import ( + "context" + "crypto/rand" + "io" + "log/slog" + "os" + "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" +) + +// newPool migrates a fresh schema against MINSTREL_TEST_DATABASE_URL +// and returns a connection pool with all data tables wiped (test +// users only — see internal/dbtest/reset.go for the why). Skips when +// the env var isn't set or -short is passed. +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 +} + +// seedUser inserts a `test-`-prefixed user (so dbtest.ResetDB can +// clean it without touching the operator's admin row) and returns +// the persisted user record. +func seedUser(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", + ApiToken: name + "-token", + IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user %s: %v", name, err) + } + return u +} + +// randomUUID returns a fresh valid pgtype.UUID. Used in tests that +// need an id guaranteed-not-to-exist in the database. +func randomUUID() pgtype.UUID { + var u pgtype.UUID + if _, err := rand.Read(u.Bytes[:]); err != nil { + panic(err) + } + u.Valid = true + return u +} + +// uuidString renders a pgtype.UUID as the canonical 8-4-4-4-12 form +// without depending on a third-party UUID package — used in tests +// that build a filename from a playlist id. +func uuidString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + const hex = "0123456789abcdef" + out := make([]byte, 36) + j := 0 + for i, x := range u.Bytes { + if i == 4 || i == 6 || i == 8 || i == 10 { + out[j] = '-' + j++ + } + out[j] = hex[x>>4] + out[j+1] = hex[x&0x0f] + j += 2 + } + return string(out) +}