Files
pipu-music/services/api/internal/playback/playlists_integration_test.go
T
mrleo1nid 0a59059738
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
feat(playlists): share playlists with revocable read-only links
2026-10-22 14:57:14 +03:00

147 lines
7.1 KiB
Go

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