//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: 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) }