feat(playlists): share playlists with revocable read-only links
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

This commit is contained in:
mrleo1nid committed 2026-10-22 14:57:14 +03:00
1 parent bfcf6e48b2
commit 0a59059738
23 files changed
+1164 -319

No files matched your search

@@ -54,6 +54,40 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
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.
@@ -63,6 +97,16 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
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
@@ -86,6 +130,8 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
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)