//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", "")) // Sharing grants read access through a separate opaque ID, never through the private owner endpoint. ownerCtx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner}) otherCtx := auth.WithPrincipal(ctx, auth.Principal{UserID: other}) _, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: id}) require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) _, err = st.sharePlaylist(ctx, other, id) require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) shareID, err := st.sharePlaylist(ctx, owner, id) require.NoError(t, err) require.NotEqual(t, id, shareID) sameID, err := st.sharePlaylist(ctx, owner, id) require.NoError(t, err) require.Equal(t, shareID, sameID) response, err := h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: shareID}) require.NoError(t, err) shared := gen.Playlist(response.(gen.GetSharedPlaylist200JSONResponse)) require.Equal(t, []uuid.UUID{first, second}, []uuid.UUID{shared.Tracks[0].Id, shared.Tracks[1].Id}) require.Nil(t, shared.ShareId, "the public DTO does not expose owner sharing controls") got, err = h.playlist(ctx, owner, id) require.NoError(t, err) require.Equal(t, shareID, *got.ShareId) require.Equal(t, 1, got.Revision, "sharing does not conflict with track editing") _, err = h.GetPlaylist(otherCtx, gen.GetPlaylistRequestObject{Id: id}) require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) _, err = h.RevokePlaylistShare(otherCtx, gen.RevokePlaylistShareRequestObject{Id: id}) require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) _, err = h.RevokePlaylistShare(ownerCtx, gen.RevokePlaylistShareRequestObject{Id: id}) require.NoError(t, err) _, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: shareID}) require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) freshID, err := st.sharePlaylist(ctx, owner, id) require.NoError(t, err) require.NotEqual(t, shareID, freshID, "revoked links never revive") 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) response, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID}) require.NoError(t, err) shared = gen.Playlist(response.(gen.GetSharedPlaylist200JSONResponse)) require.Equal(t, second, shared.Tracks[0].Id, "shared pages follow edits to the live playlist") _, err = pool.Exec(ctx, `UPDATE users SET is_blocked=true WHERE id=$1`, owner) require.NoError(t, err) _, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID}) require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) _, err = pool.Exec(ctx, `UPDATE users SET is_blocked=false WHERE id=$1`, owner) require.NoError(t, err) // 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) _, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID}) require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) 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) }