5c61c10b63
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>
321 lines
9.7 KiB
Go
321 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|