test(plugins): add PlaylistGenerator integration tests with test WASM plugin

Add a test playlist generator plugin and integration tests for the
PlaylistGenerator capability. The test plugin exercises GetPlaylists and
GetPlaylist WASM functions, and the tests verify orchestration flow
including capability detection, playlist discovery/sync, deterministic
ID generation, error handling, and graceful stop.

Also initialize playlistGenerators map in the test helper to prevent nil
map panics when loading plugins with PlaylistGenerator capability.
This commit is contained in:
Deluan 2026-03-05 08:12:12 -05:00
parent dedaf8e64a
commit 188584e3fb
6 changed files with 250 additions and 4 deletions

View File

@ -0,0 +1,138 @@
//go:build !windows
package plugins
import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PlaylistGenerator", Ordered, func() {
var (
pgManager *Manager
mockPlsRepo *tests.MockPlaylistRepo
)
BeforeAll(func() {
pgManager, _ = createTestManagerWithPlugins(nil,
"test-playlist-generator"+PackageExtension,
)
// Pre-initialize the mock playlist repo to avoid a race with the
// discoverAndSync goroutine that is launched during Start().
mockDS := pgManager.ds.(*tests.MockDataStore)
if mockDS.MockedPlaylist == nil {
mockDS.MockedPlaylist = tests.CreateMockPlaylistRepo()
}
mockPlsRepo = mockDS.MockedPlaylist.(*tests.MockPlaylistRepo)
})
Describe("capability detection", func() {
It("detects the PlaylistGenerator capability", func() {
names := pgManager.PluginNames(string(CapabilityPlaylistGenerator))
Expect(names).To(ContainElement("test-playlist-generator"))
})
})
Describe("startPlaylistGenerators", func() {
It("creates an orchestrator for the plugin", func() {
Expect(pgManager.playlistGenerators).To(HaveKey("test-playlist-generator"))
})
It("discovers and syncs playlists from the plugin", func() {
// The orchestrator runs discoverAndSync in a goroutine on Start().
// Give it a moment to complete.
Eventually(func() int {
return len(mockPlsRepo.Data)
}).Should(BeNumerically(">=", 2))
})
It("creates playlists with correct fields", func() {
// Check that playlists have the correct plugin fields
Eventually(func() bool {
for _, pls := range mockPlsRepo.Data {
if pls.PluginID == "test-playlist-generator" && pls.PluginPlaylistID == "daily-mix-1" {
return true
}
}
return false
}).Should(BeTrue())
// Find the daily-mix-1 playlist and verify its fields
var dailyMix1 *model.Playlist
for _, pls := range mockPlsRepo.Data {
if pls.PluginPlaylistID == "daily-mix-1" {
dailyMix1 = pls
break
}
}
Expect(dailyMix1).ToNot(BeNil())
Expect(dailyMix1.Name).To(Equal("Daily Mix 1"))
Expect(dailyMix1.Comment).To(Equal("Your personalized daily mix"))
Expect(dailyMix1.ExternalImageURL).To(Equal("https://example.com/cover1.jpg"))
Expect(dailyMix1.OwnerID).To(Equal("user-1"))
Expect(dailyMix1.PluginID).To(Equal("test-playlist-generator"))
Expect(dailyMix1.PluginPlaylistID).To(Equal("daily-mix-1"))
Expect(dailyMix1.Public).To(BeFalse())
})
It("generates deterministic playlist IDs", func() {
expectedID := id.NewHash("test-playlist-generator", "daily-mix-1", "user-1")
Eventually(func() bool {
_, exists := mockPlsRepo.Data[expectedID]
return exists
}).Should(BeTrue())
})
It("creates distinct IDs for different playlists", func() {
id1 := id.NewHash("test-playlist-generator", "daily-mix-1", "user-1")
id2 := id.NewHash("test-playlist-generator", "daily-mix-2", "user-1")
Expect(id1).ToNot(Equal(id2))
Eventually(func() bool {
_, exists1 := mockPlsRepo.Data[id1]
_, exists2 := mockPlsRepo.Data[id2]
return exists1 && exists2
}).Should(BeTrue())
})
})
Describe("GetPlaylists error handling", func() {
It("handles plugin errors gracefully", func() {
errManager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"test-playlist-generator": {"error": "service unavailable"},
}, "test-playlist-generator"+PackageExtension)
// Should still have the orchestrator (error is logged, not fatal)
Expect(errManager.playlistGenerators).To(HaveKey("test-playlist-generator"))
// But no playlists created
errDS := errManager.ds.(*tests.MockDataStore)
if errDS.MockedPlaylist == nil {
errDS.MockedPlaylist = tests.CreateMockPlaylistRepo()
}
errPlsRepo := errDS.MockedPlaylist.(*tests.MockPlaylistRepo)
// The orchestrator was started but GetPlaylists returned error,
// so no playlists should be created
Consistently(func() int {
return len(errPlsRepo.Data)
}, "500ms").Should(Equal(0))
})
})
Describe("stop", func() {
It("stops the orchestrator when the manager stops", func() {
stopManager, _ := createTestManagerWithPlugins(nil,
"test-playlist-generator"+PackageExtension,
)
Expect(stopManager.playlistGenerators).To(HaveKey("test-playlist-generator"))
err := stopManager.Stop()
Expect(err).ToNot(HaveOccurred())
Expect(stopManager.playlistGenerators).To(BeEmpty())
})
})
})

View File

@ -137,10 +137,11 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s
// Create and start manager
manager := &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
metrics: metrics,
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
plugins: make(map[string]*plugin),
playlistGenerators: make(map[string]*playlistGeneratorOrchestrator),
ds: dataStore,
metrics: metrics,
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())

View File

@ -0,0 +1,16 @@
module test-playlist-generator
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,71 @@
// Test playlist generator plugin for Navidrome plugin system integration tests.
package main
import (
"fmt"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
pg "github.com/navidrome/navidrome/plugins/pdk/go/playlistgenerator"
)
func init() {
pg.Register(&testPlaylistGenerator{})
}
type testPlaylistGenerator struct{}
func (t *testPlaylistGenerator) GetPlaylists(_ pg.GetPlaylistsRequest) (pg.GetPlaylistsResponse, error) {
// Check for configured error
errMsg, hasErr := pdk.GetConfig("error")
if hasErr && errMsg != "" {
return pg.GetPlaylistsResponse{}, fmt.Errorf("%s", errMsg)
}
// Get the owner user ID from config (defaults to "user-1")
ownerID := "user-1"
if id, ok := pdk.GetConfig("owner_id"); ok && id != "" {
ownerID = id
}
return pg.GetPlaylistsResponse{
Playlists: []pg.PlaylistInfo{
{ID: "daily-mix-1", OwnerUserID: ownerID},
{ID: "daily-mix-2", OwnerUserID: ownerID},
},
RefreshInterval: 0, // No re-discovery in tests
}, nil
}
func (t *testPlaylistGenerator) GetPlaylist(req pg.GetPlaylistRequest) (pg.GetPlaylistResponse, error) {
// Check for configured error
errMsg, hasErr := pdk.GetConfig("get_playlist_error")
if hasErr && errMsg != "" {
return pg.GetPlaylistResponse{}, fmt.Errorf("%s", errMsg)
}
switch req.ID {
case "daily-mix-1":
return pg.GetPlaylistResponse{
Name: "Daily Mix 1",
Description: "Your personalized daily mix",
CoverArtURL: "https://example.com/cover1.jpg",
Tracks: []pg.SongRef{
{Name: "Song A", Artist: "Artist One"},
{Name: "Song B", Artist: "Artist Two"},
},
ValidUntil: 0, // Static, no refresh
}, nil
case "daily-mix-2":
return pg.GetPlaylistResponse{
Name: "Daily Mix 2",
Tracks: []pg.SongRef{
{Name: "Song C", Artist: "Artist Three"},
},
ValidUntil: 0,
}, nil
default:
return pg.GetPlaylistResponse{}, fmt.Errorf("unknown playlist: %s", req.ID)
}
}
func main() {}

View File

@ -0,0 +1,6 @@
{
"name": "Test Playlist Generator",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test playlist generator plugin for integration testing"
}