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) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,8 @@ var dataTables = []string{
|
||||
"sessions",
|
||||
"lidarr_quarantine_actions",
|
||||
"lidarr_quarantine",
|
||||
"playlist_tracks",
|
||||
"playlists",
|
||||
"tracks",
|
||||
"albums",
|
||||
"artists",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user