navidrome/server/subsonic/e2e/e2e_suite_test.go
Deluan Quintão 23548f40a0
fix(share): give visual feedback when downloading from a share (#5865)
* fix(share): give visual feedback when downloading a share

The share page handed the download URL to navidrome-music-player, which fell
through to downloadjs and buffered the whole ZIP into memory via XHR before
saving it. Nothing was handed to the browser until the last byte arrived, so a
large share produced a long silent window with no player feedback and no
browser download UI, inviting repeat clicks that each spawn another server-side
zip+transcode.

Use the player's customDownloader prop to trigger a synthetic anchor instead,
so the browser performs the download and reports its own progress. An anchor
rather than assigning window.location.href: the share page's service worker
registers a NavigationRoute over all navigations, which intercepts the streamed
archive and fails it into the offline fallback (observed as HTTP 503 in Chrome).

handleDownloads now loads the share before streaming so it can set
Content-Disposition and Content-Type. This also fixes error reporting: ZipShare
previously wrote to the ResponseWriter before checkShareError ran, locking the
status at 200, so expired, missing and non-downloadable shares all returned 200.
They now correctly return 410, 404 and 403.

* feat(share): acknowledge the download click in the player

The browser's download UI is the real progress indicator, but nothing in the
page itself reacted to the click, so the moment before the browser catches up
still read as unresponsive. Dim the download button and make it unclickable for
two seconds after a download starts, reusing the JSS function-value pattern the
existing single-track styling already uses.

A repeat download restarts the window instead of extending the original, and
the timer is cleared on unmount. This also blunts repeat clicking, where every
extra click costs another server-side zip and transcode.

Add SharePlayer tests covering the download mechanism and this state machine.
The dimming itself is verified in a browser rather than jsdom: JSS function
values are not evaluated there, so the rule is never emitted and a CSS
assertion would pass or fail for the wrong reason.

Signed-off-by: Deluan <deluan@navidrome.org>

* test(share): assert render counts in SharePlayer feedback tests

The two acknowledgement tests compared the props object across renders, which
React may reuse, so they passed without proving anything and then failed once
the surrounding assertions changed. Count renders instead, and let the pending
timer run out rather than advancing exactly to its deadline, which does not
cross it.

The repeat-download test now also asserts that no render happens at the
original deadline, proving the timer was replaced rather than merely that one
eventually fired.

* fix(share): count one visit per share download

The preflight share load added in this branch made every download record two
visits: handleDownloads called Share.Load, and ZipShare then loaded the share
again internally. Share.Load increments and persists VisitCount, so the counter
advanced twice per download and the repository work was duplicated.

Pass the already-loaded share into ZipShare instead of its id. handleDownloads
is its only production caller, and it now has the share in hand for the
Content-Disposition header anyway.

The archiver test asserts Load is not called, so the double-load cannot come
back unnoticed. Verified against a running server: the counter now advances by
one per download.

* test(share): derive feedback-window timings from the constant

The acknowledgement tests hardcoded clock advances tuned to a 2000ms window.
Raising DOWNLOAD_FEEDBACK_MS to 5000 left them advancing 1500ms and 1001ms,
which no longer reach the deadline they are meant to cross, so the repeat-
download test passed without proving the timer had been replaced.

Export the constant and derive the advances from it, and let the pending timer
run out in the unmount test rather than advancing a fixed amount. Changing the
duration can no longer silently strand a test short of its deadline.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-07-28 16:13:44 -04:00

440 lines
18 KiB
Go

package e2e
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"testing/fstest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/tests/harness"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSubsonicE2E(t *testing.T) {
tests.Init(t, false)
defer db.Close(t.Context())
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Subsonic API E2E Suite")
}
// Easy aliases for the storagetest package
type _t = map[string]any
var template = storagetest.Template
var track = storagetest.Track
var file = storagetest.File
// MusicBrainz ID constants for test data (valid UUID v4 values)
const (
mbidBeatlesArtist = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"
mbidAbbeyRoadAlbum = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
mbidAbbeyRoadRelGroup = "d4c3b2a1-f6e5-4b7a-9d8c-1f0e3a2b5c4d"
mbidComeTogether = "11111111-1111-4111-a111-111111111111" // mbz_release_track_id
mbidComeTogetherRec = "22222222-2222-4222-a222-222222222222" // mbz_recording_id
mbidSomething = "33333333-3333-4333-a333-333333333333" // mbz_release_track_id
mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id
)
// lyricFixture reads a public-domain lyric fixture (the same files the parser
// benchmarks use) so the e2e fixtures stay in sync with real-world content,
// including the word-level timing carried by the .elrc and .yaml variants.
func lyricFixture(name string) string {
// tests.Init chdirs to the project root, so reference fixtures from there.
data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name))
Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name)
return string(data)
}
// firstFixtureLine is the opening lyric line shared by every auld-lang-syne
// fixture; tests assert against it regardless of source format.
const firstFixtureLine = "Should auld acquaintance be forgot,"
// Shared test state
var (
ctx context.Context
ds *tests.MockDataStore
router *subsonic.Router
streamerSpy *harness.SpyStreamer
goldenDB *harness.DB
lib model.Library
// Admin user used for most tests
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
}
// Regular (non-admin) user for permission tests
regularUser = model.User{
ID: "regular-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
}
)
// buildTestFS creates the full test filesystem matching the plan
func buildTestFS() storagetest.FakeFS {
abbeyRoad := template(_t{
"albumartist": "The Beatles",
"artist": "The Beatles",
"album": "Abbey Road",
"year": 1969,
"genre": "Rock",
"musicbrainz_artistid": mbidBeatlesArtist,
"musicbrainz_albumartistid": mbidBeatlesArtist,
"musicbrainz_albumid": mbidAbbeyRoadAlbum,
"musicbrainz_releasegroupid": mbidAbbeyRoadRelGroup,
})
help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"})
ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"})
cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"})
// Template for diverse-format transcode test tracks
tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"}
// Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable
lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"})
return harness.CreateFS(fstest.MapFS{
// Rock / The Beatles / Abbey Road (with MBIDs)
// Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID),
// "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID).
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
_t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec, "bpm": 120})),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
_t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})),
// Rock / The Beatles / Help! (no MBIDs)
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
// Rock / Led Zeppelin / IV (no MBIDs)
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")),
// Jazz / Miles Davis / Kind of Blue (no MBIDs)
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
// Pop (standalone track, no MBIDs)
"Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")),
// CJK / シートベルツ / COWBOY BEBOP (Japanese artist, for CJK search tests)
"CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")),
// Diverse audio format tracks for transcode e2e tests
"Test/Transcode Formats/01 - TC FLAC Standard.flac": file(tcBase, _t{
"title": "TC FLAC Standard", "track": 1, "suffix": "flac",
"bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(240),
}),
"Test/Transcode Formats/02 - TC FLAC HiRes.flac": file(tcBase, _t{
"title": "TC FLAC HiRes", "track": 2, "suffix": "flac",
"bitrate": 3000, "samplerate": 96000, "bitdepth": 24, "channels": 2, "duration": int64(180),
}),
"Test/Transcode Formats/03 - TC ALAC Track.m4a": file(tcBase, _t{
"title": "TC ALAC Track", "track": 3, "suffix": "m4a",
"bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(200),
}),
"Test/Transcode Formats/04 - TC DSD Track.dsf": file(tcBase, _t{
"title": "TC DSD Track", "track": 4, "suffix": "dsf",
"bitrate": 5645, "samplerate": 2822400, "bitdepth": 1, "channels": 2, "duration": int64(300),
}),
"Test/Transcode Formats/05 - TC Opus Track.opus": file(tcBase, _t{
"title": "TC Opus Track", "track": 5, "suffix": "opus",
"bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(210),
}),
"Test/Transcode Formats/06 - TC MKA Opus.mka": file(tcBase, _t{
"title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus",
"bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220),
}),
"Test/Transcode Formats/07 - TC FLAC Multichannel.flac": file(tcBase, _t{
"title": "TC FLAC Multichannel", "track": 7, "suffix": "flac",
"bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180),
}),
// Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable).
// Content comes from tests/fixtures/lyrics (the same public-domain files the
// parser benchmarks use); the .elrc and .yaml variants carry word-level
// timing, which drives the v1 (line-level) vs v2 (enhanced/word-level) tests.
//
// Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time.
// "Enhanced LRC" embeds ELRC (word-level) content; the title is kept generic
// since ELRC is still valid LRC.
"Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC",
_t{"lyrics": lyricFixture("auld-lang-syne.elrc")})),
"Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain",
_t{"lyrics": lyricFixture("auld-lang-syne.txt")})),
"Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML",
_t{"lyrics": lyricFixture("auld-lang-syne.ttml")})),
// Sidecar — raw lyric text files read from the library FS at request time via fromExternalFile.
// The scanner skips non-audio extensions (.lrc, .srt, .yaml), so placing them as raw MapFile
// entries is safe: they are visible to the fake FS but invisible to the scanner.
"Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")),
"Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()},
"Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")),
"Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()},
"Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")),
"Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()},
// _empty folder (directory with no audio)
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
})
}
// createUser creates a user in the database with the given properties, assigns them to the test
// library, and returns the fully-loaded user (with Libraries populated).
func createUser(id, username, name string, isAdmin bool) model.User {
user := model.User{
ID: id,
UserName: username,
Name: name,
IsAdmin: isAdmin,
NewPassword: "password",
}
Expect(ds.User(ctx).Put(&user)).To(Succeed())
Expect(ds.User(ctx).SetUserLibraries(user.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := ds.User(ctx).FindByUsername(user.UserName)
Expect(err).ToNot(HaveOccurred())
user.Libraries = loadedUser.Libraries
return user
}
// doReq makes a full HTTP round-trip through the router and returns the parsed Subsonic response.
func doReq(endpoint string, params ...string) *responses.Subsonic {
return doReqWithUser(adminUser, endpoint, params...)
}
// doReqWithUser makes a full HTTP round-trip for the given user and returns the parsed Subsonic response.
func doReqWithUser(user model.User, endpoint string, params ...string) *responses.Subsonic {
w := httptest.NewRecorder()
r := buildReq(user, endpoint, params...)
router.ServeHTTP(w, r)
return parseJSONResponse(w)
}
// doRawReq returns the raw ResponseRecorder for endpoints that write binary data (stream, download, getCoverArt).
func doRawReq(endpoint string, params ...string) *httptest.ResponseRecorder {
return doRawReqWithUser(adminUser, endpoint, params...)
}
// doRawReqWithUser returns the raw ResponseRecorder for the given user.
func doRawReqWithUser(user model.User, endpoint string, params ...string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := buildReq(user, endpoint, params...)
router.ServeHTTP(w, r)
return w
}
// buildReq creates a GET request with Subsonic auth params (u, p, v, c, f=json).
func buildReq(user model.User, endpoint string, params ...string) *http.Request {
if len(params)%2 != 0 {
panic("buildReq: odd number of parameters")
}
q := url.Values{}
q.Add("u", user.UserName)
q.Add("p", "password")
q.Add("v", "1.16.1")
q.Add("c", "test-client")
q.Add("f", "json")
for i := 0; i < len(params); i += 2 {
q.Add(params[i], params[i+1])
}
return httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil)
}
// buildPostReq creates a POST request with a JSON body and Subsonic auth params in the query string.
func buildPostReq(user model.User, endpoint string, body string, params ...string) *http.Request {
getReq := buildReq(user, endpoint, params...)
r := httptest.NewRequest("POST", getReq.URL.RequestURI(), bytes.NewReader([]byte(body)))
r.Header.Set("Content-Type", "application/json")
return r
}
// doPostReq makes a POST round-trip as admin and returns the parsed Subsonic response.
func doPostReq(endpoint string, body string, params ...string) *responses.Subsonic {
w := httptest.NewRecorder()
r := buildPostReq(adminUser, endpoint, body, params...)
router.ServeHTTP(w, r)
return parseJSONResponse(w)
}
// doRawPostReq makes a POST round-trip as admin and returns the raw recorder.
func doRawPostReq(endpoint string, body string, params ...string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := buildPostReq(adminUser, endpoint, body, params...)
router.ServeHTTP(w, r)
return w
}
// parseJSONResponse parses the JSON response body into a Subsonic response struct.
func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic {
Expect(w.Code).To(Equal(http.StatusOK))
var wrapper responses.JsonWrapper
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
return &wrapper.Subsonic
}
// --- Noop stub implementations for Router dependencies ---
// noopArtwork implements artwork.Artwork
type noopArtwork struct{}
func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) {
return nil, time.Time{}, model.ErrNotFound
}
func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) {
return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil
}
// noopArchiver implements core.Archiver
type noopArchiver struct{}
func (n noopArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipShare(context.Context, *model.Share, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
// noopProvider implements external.Provider
type noopProvider struct{}
func (n noopProvider) UpdateAlbumInfo(_ context.Context, _ string) (*model.Album, error) {
return &model.Album{}, nil
}
func (n noopProvider) UpdateArtistInfo(_ context.Context, _ string, _ int, _ bool) (*model.Artist, error) {
return &model.Artist{}, nil
}
func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
// Compile-time interface checks
var (
_ artwork.Artwork = noopArtwork{}
_ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{}
)
var _ = BeforeSuite(func() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
conf.Server.MusicFolder = "fake:///music"
conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml"
conf.Server.DevExternalScanner = false
buildTestFS()
goldenDB = harness.SetupDB(ctx, &adminUser, &regularUser)
lib = goldenDB.Library
ctx = request.WithUser(GinkgoT().Context(), adminUser)
})
// Close the database before the suite's TempDir cleanup runs. Required on
// Windows where open SQLite handles hold file locks that block temp-dir
// removal; harmless on other OSes.
var _ = AfterSuite(func() {
db.Close(ctx)
})
// setupTestDB restores the database from the golden snapshot and creates the
// Subsonic Router. Call this from BeforeEach/BeforeAll in each test container.
func setupTestDB() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
DeferCleanup(configtest.SetupConfig())
DeferCleanup(func() {
// Wait for any background scan (e.g. from startScan endpoint) to finish
// before config cleanup runs, to avoid a data race on conf.Server.
Eventually(scanner.IsScanning).Should(BeFalse())
})
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false
conf.Server.DevEnableMediaFileProbe = false
// Restore DB to golden state (no scan needed)
goldenDB.Restore()
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds)
// Create the Subsonic Router with real DS, streamer spy, and real Decider
streamerSpy = &harness.SpyStreamer{}
decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{})
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
router = subsonic.New(
ds,
noopArtwork{},
streamerSpy,
noopArchiver{},
core.NewPlayers(ds),
noopProvider{},
s,
events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()),
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
lyrics.NewLyrics(ds, nil),
decider,
nil,
)
}