feat: add personal playlists and shareable track links
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3a097c15c8
commit
fa79394869
46 files changed
+2307
-292
No files matched your search
@@ -0,0 +1,100 @@
|
||||
//go:build integration
|
||||
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"pipu/api/internal/platform/db"
|
||||
)
|
||||
|
||||
func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := db.Connect(ctx, url)
|
||||
require.NoError(t, err)
|
||||
defer pool.Close()
|
||||
require.NoError(t, db.Migrate(ctx, pool))
|
||||
owner, other, artist, album, first, second, id := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','user'),($3,$4,'x','user')`, owner, owner.String(), other, other.String())
|
||||
require.NoError(t, err)
|
||||
defer pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1)`, []uuid.UUID{owner, other})
|
||||
_, err = pool.Exec(ctx, `INSERT INTO artists(id,name,sort_name,name_norm) VALUES($1,'Artist','artist',$2)`, artist, artist.String())
|
||||
require.NoError(t, err)
|
||||
defer pool.Exec(ctx, `DELETE FROM artists WHERE id=$1`, artist)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO albums(id,title,title_norm,album_artist_id) VALUES($1,'Album','album',$2)`, album, artist)
|
||||
require.NoError(t, err)
|
||||
defer pool.Exec(ctx, `DELETE FROM albums WHERE id=$1`, album)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO tracks(id,title,title_norm,album_id,duration_ms) VALUES($1,'First','first',$3,1000),($2,'Second','second',$3,2000)`, first, second, album)
|
||||
require.NoError(t, err)
|
||||
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second})
|
||||
st := NewStore(pool)
|
||||
h := &Handler{store: st, catalog: library.NewStore(pool), dto: func() library.DTO { return library.DTO{} }}
|
||||
require.NoError(t, st.savePlaylist(ctx, owner, id, " Mix ", "Description", []uuid.UUID{first, second}, 0, true))
|
||||
got, err := h.playlist(ctx, owner, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Mix", got.Name)
|
||||
require.Equal(t, int64(3000), got.DurationMs)
|
||||
require.Equal(t, first, got.Tracks[0].Id)
|
||||
_, err = h.playlist(ctx, other, id)
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
require.ErrorIs(t, st.savePlaylist(ctx, other, id, "stolen", "", nil, 1, false), apperr.NotFound("playlist.not_found", ""))
|
||||
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
require.NoError(t, st.savePlaylist(ctx, owner, id, "Mix", "", []uuid.UUID{second, first}, 1, false))
|
||||
require.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", ""))
|
||||
// A missing track rolls back both metadata and membership.
|
||||
require.Error(t, st.savePlaylist(ctx, owner, id, "broken", "", []uuid.UUID{uuid.New()}, 2, false))
|
||||
got, err = h.playlist(ctx, owner, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, got.Revision)
|
||||
require.Equal(t, "Mix", got.Name)
|
||||
require.Equal(t, second, got.Tracks[0].Id)
|
||||
// Exactly one concurrent write may consume a revision.
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results <- st.savePlaylist(ctx, owner, id, "Updated", "", []uuid.UUID{first}, 2, false)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
successes := 0
|
||||
for e := range results {
|
||||
if e == nil {
|
||||
successes++
|
||||
} else {
|
||||
require.ErrorIs(t, e, apperr.Conflict("playlist.conflict", ""))
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, successes)
|
||||
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id})
|
||||
require.NoError(t, err)
|
||||
var remaining int
|
||||
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining))
|
||||
require.Zero(t, remaining)
|
||||
// Deleting a playlist never deletes catalog tracks.
|
||||
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second}).Scan(&remaining))
|
||||
require.Equal(t, 2, remaining)
|
||||
rows, err := pool.Query(ctx, `SELECT id FROM playlists WHERE id=$1`, id)
|
||||
require.NoError(t, err)
|
||||
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, ids)
|
||||
}
|
||||
Reference in new issue
Block a user