ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
101 lines
4.3 KiB
Go
101 lines
4.3 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", ""))
|
|
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)
|
|
}
|