test(e2e): add NewTestStream function and implement spyStreamer for testing

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-03-08 23:51:30 -04:00
parent a1a55141f6
commit a46cd491fb
3 changed files with 174 additions and 19 deletions

View File

@ -6,6 +6,7 @@ import (
"io"
"mime"
"os"
"strings"
"sync"
"time"
@ -142,6 +143,17 @@ func (s *Stream) EstimatedContentLength() int {
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
}
// NewTestStream creates a Stream for testing purposes.
func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream {
return &Stream{
ctx: context.Background(),
mf: mf,
format: format,
bitRate: bitRate,
ReadCloser: io.NopCloser(strings.NewReader("")),
}
}
var (
onceTranscodingCache sync.Once
instanceTranscodingCache TranscodingCache

View File

@ -3,6 +3,7 @@ package e2e
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@ -19,6 +20,7 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
@ -70,6 +72,7 @@ var (
ctx context.Context
ds *tests.MockDataStore
router *subsonic.Router
spy *spyStreamer
lib model.Library
// Snapshot paths for fast DB restore
@ -225,36 +228,50 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil
}
// noopStreamer implements transcode.MediaStreamer
type noopStreamer struct{}
// spyStreamer captures the StreamRequest passed to DoStream for test assertions,
// then returns a minimal fake Stream so the handler completes without error.
type spyStreamer struct {
LastRequest transcode.StreamRequest
LastMediaFile *model.MediaFile
}
func (n noopStreamer) NewStream(context.Context, transcode.StreamRequest) (*transcode.Stream, error) {
func (s *spyStreamer) NewStream(ctx context.Context, req transcode.StreamRequest) (*transcode.Stream, error) {
return nil, model.ErrNotFound
}
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, transcode.StreamRequest) (*transcode.Stream, error) {
return nil, model.ErrNotFound
func (s *spyStreamer) DoStream(_ context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) {
s.LastRequest = req
s.LastMediaFile = mf
format := req.Format
if format == "" || format == "raw" {
format = mf.Suffix
}
return transcode.NewTestStream(mf, format, req.BitRate), nil
}
// noopDecider implements transcode.Decider
type noopDecider struct{}
// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods.
type noopFFmpeg struct{}
func (n noopDecider) MakeDecision(context.Context, *model.MediaFile, *transcode.ClientInfo, transcode.DecisionOptions) (*transcode.Decision, error) {
return nil, nil
func (n noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) {
return nil, errors.New("noop ffmpeg: transcode not supported")
}
func (n noopDecider) ResolveRequest(context.Context, *model.MediaFile, string, int, int) transcode.StreamRequest {
return transcode.StreamRequest{Format: "raw"}
func (n noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) {
return nil, errors.New("noop ffmpeg: extract image not supported")
}
func (n noopDecider) CreateTranscodeParams(*transcode.Decision) (string, error) {
func (n noopFFmpeg) Probe(context.Context, []string) (string, error) {
return "", nil
}
func (n noopDecider) ResolveRequestFromToken(context.Context, string, string, int) (transcode.StreamRequest, *model.MediaFile, error) {
return transcode.StreamRequest{}, nil, nil
func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) {
return nil, errors.New("noop ffmpeg: probe not supported")
}
func (n noopFFmpeg) CmdPath() (string, error) { return "", nil }
func (n noopFFmpeg) IsAvailable() bool { return false }
func (n noopFFmpeg) Version() string { return "noop" }
// noopArchiver implements core.Archiver
type noopArchiver struct{}
@ -319,11 +336,11 @@ func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error {
// Compile-time interface checks
var (
_ artwork.Artwork = noopArtwork{}
_ transcode.MediaStreamer = noopStreamer{}
_ transcode.MediaStreamer = &spyStreamer{}
_ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{}
_ scrobbler.PlayTracker = noopPlayTracker{}
_ transcode.Decider = noopDecider{}
_ ffmpeg.FFmpeg = noopFFmpeg{}
)
var _ = BeforeSuite(func() {
@ -401,13 +418,15 @@ func setupTestDB() {
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds)
// Create the Subsonic Router with real DS + noop stubs
// Create the Subsonic Router with real DS, spy streamer, and real Decider
spy = &spyStreamer{}
decider := transcode.NewDecider(ds, noopFFmpeg{})
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
router = subsonic.New(
ds,
noopArtwork{},
noopStreamer{},
spy,
noopArchiver{},
core.NewPlayers(ds),
noopProvider{},
@ -419,7 +438,7 @@ func setupTestDB() {
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
lyrics.NewLyrics(nil),
noopDecider{},
decider,
)
}

View File

@ -3,6 +3,9 @@ package e2e
import (
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -14,21 +17,142 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() {
})
Describe("Stream", func() {
var trackID string
BeforeAll(func() {
// All test tracks are mp3 at 320kbps
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
trackID = songs[0].ID
})
It("returns error when id parameter is missing", func() {
resp := doReq("stream")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("streams raw when no format or bitrate specified", func() {
w := doRawReq("stream", "id", trackID)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("raw"))
})
It("streams raw when format=raw", func() {
w := doRawReq("stream", "id", trackID, "format", "raw")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("raw"))
})
It("transcodes to different format with bitrate", func() {
w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("opus"))
Expect(spy.LastRequest.BitRate).To(Equal(128))
})
It("downsamples when only maxBitRate is specified (lower than source)", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
w := doRawReq("stream", "id", trackID, "maxBitRate", "128")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("opus"))
Expect(spy.LastRequest.BitRate).To(Equal(128))
})
It("streams raw when maxBitRate is higher than source", func() {
w := doRawReq("stream", "id", trackID, "maxBitRate", "999")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("raw"))
})
It("streams raw when format matches source and no bitrate reduction", func() {
w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "320")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("raw"))
})
It("transcodes when same format but lower bitrate", func() {
w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "128")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("mp3"))
Expect(spy.LastRequest.BitRate).To(Equal(128))
})
It("falls back to raw for unknown format", func() {
w := doRawReq("stream", "id", trackID, "format", "xyz")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("raw"))
})
It("passes timeOffset through", func() {
w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128", "timeOffset", "30")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("opus"))
Expect(spy.LastRequest.Offset).To(Equal(30))
})
})
Describe("Download", func() {
var trackID string
BeforeAll(func() {
// All test tracks are mp3 at 320kbps
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
trackID = songs[0].ID
})
It("returns error when id parameter is missing", func() {
resp := doReq("download")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("downloads raw when no format specified and AutoTranscodeDownload is false", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableDownloads = true
conf.Server.AutoTranscodeDownload = false
w := doRawReq("download", "id", trackID)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("raw"))
})
It("downloads with explicit format and bitrate", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableDownloads = true
w := doRawReq("download", "id", trackID, "format", "opus", "bitrate", "128")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(spy.LastRequest.Format).To(Equal("opus"))
Expect(spy.LastRequest.BitRate).To(Equal(128))
})
It("returns error when downloads are disabled", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableDownloads = false
resp := doReq("download", "id", trackID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
})
})
Describe("GetCoverArt", func() {