navidrome/plugins/playlist_provider_test.go
Deluan 04aa10f988 refactor(plugins): rename PlaylistGenerator to PlaylistProvider
Rename the capability from PlaylistGenerator to PlaylistProvider and the
internal orchestrator struct from playlistGeneratorOrchestrator to
playlistSyncer. The new names better describe what the capability does
(provides playlists) rather than how it works internally. All source
files, test plugin, PDK packages (Go/Rust), YAML schemas, and exported
WASM function names are updated accordingly.
2026-04-12 17:38:21 -04:00

189 lines
6.2 KiB
Go

//go:build !windows
package plugins
import (
"time"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/plugins/capabilities"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// findSyncer finds the playlistSyncer in a plugin's closers.
func findSyncer(m *Manager, pluginName string) *playlistSyncer {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.plugins[pluginName]
if !ok {
return nil
}
for _, c := range p.closers {
if syncer, ok := c.(*playlistSyncer); ok {
return syncer
}
}
return nil
}
var _ = Describe("PlaylistProvider", Ordered, func() {
var (
pgManager *Manager
mockPlsRepo *tests.MockPlaylistRepo
)
BeforeAll(func() {
pgManager, _ = createTestManagerWithPlugins(nil,
"test-playlist-provider"+PackageExtension,
)
mockPlsRepo = pgManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo)
})
Describe("capability detection", func() {
It("detects the PlaylistProvider capability", func() {
names := pgManager.PluginNames(string(CapabilityPlaylistProvider))
Expect(names).To(ContainElement("test-playlist-provider"))
})
})
Describe("syncer lifecycle", func() {
It("creates a syncer for the plugin", func() {
Expect(findSyncer(pgManager, "test-playlist-provider")).ToNot(BeNil())
})
It("discovers and syncs playlists from the plugin", func() {
// The syncer runs discoverAndSync in a goroutine on Start().
// Give it a moment to complete.
Eventually(func() int {
return mockPlsRepo.Len()
}).Should(BeNumerically(">=", 2))
})
It("creates playlists with correct fields", func() {
Eventually(func() bool {
return mockPlsRepo.FindByPluginPlaylistID("daily-mix-1") != nil
}).Should(BeTrue())
dailyMix1 := mockPlsRepo.FindByPluginPlaylistID("daily-mix-1")
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-provider"))
Expect(dailyMix1.PluginPlaylistID).To(Equal("daily-mix-1"))
Expect(dailyMix1.Public).To(BeFalse())
})
It("generates deterministic playlist IDs", func() {
expectedID := id.NewHash("test-playlist-provider", "daily-mix-1", "user-1")
Eventually(func() bool {
_, exists := mockPlsRepo.GetData(expectedID)
return exists
}).Should(BeTrue())
})
It("creates distinct IDs for different playlists", func() {
id1 := id.NewHash("test-playlist-provider", "daily-mix-1", "user-1")
id2 := id.NewHash("test-playlist-provider", "daily-mix-2", "user-1")
Expect(id1).ToNot(Equal(id2))
Eventually(func() bool {
_, exists1 := mockPlsRepo.GetData(id1)
_, exists2 := mockPlsRepo.GetData(id2)
return exists1 && exists2
}).Should(BeTrue())
})
})
Describe("GetAvailablePlaylists error handling", func() {
It("handles plugin errors gracefully", func() {
errManager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"test-playlist-provider": {"error": "service unavailable"},
}, "test-playlist-provider"+PackageExtension)
// Should still have the syncer (error is logged, not fatal)
Expect(findSyncer(errManager, "test-playlist-provider")).ToNot(BeNil())
// But no playlists created
errPlsRepo := errManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo)
// The syncer was started but GetAvailablePlaylists returned error,
// so no playlists should be created
Consistently(func() int {
return errPlsRepo.Len()
}, "500ms").Should(Equal(0))
})
})
Describe("GetPlaylist NotFound error", func() {
It("skips playlists when plugin returns NotFound", func() {
notFoundManager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"test-playlist-provider": {
"get_playlist_error": "playlist temporarily unavailable",
"get_playlist_error_type": string(capabilities.PlaylistProviderErrorNotFound),
},
}, "test-playlist-provider"+PackageExtension)
// Should still have the syncer
Expect(findSyncer(notFoundManager, "test-playlist-provider")).ToNot(BeNil())
// No playlists should be created (all returned NotFound)
notFoundPlsRepo := notFoundManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo)
Consistently(func() int {
return notFoundPlsRepo.Len()
}, "500ms").Should(Equal(0))
// No refresh timers should be scheduled for NotFound playlists
syncer := findSyncer(notFoundManager, "test-playlist-provider")
Eventually(func() int32 {
return syncer.refreshTimerCount.Load()
}).Should(Equal(int32(0)))
})
})
Describe("GetPlaylist transient error with RetryInterval", func() {
It("stores retryInterval and schedules retry on transient errors", func() {
retryManager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"test-playlist-provider": {
"get_playlist_error": "temporary failure",
"retry_interval": "60",
},
}, "test-playlist-provider"+PackageExtension)
syncer := findSyncer(retryManager, "test-playlist-provider")
Expect(syncer).ToNot(BeNil())
// retryInterval should be stored from the response
Eventually(func() time.Duration {
return time.Duration(syncer.retryInterval.Load())
}).Should(Equal(60 * time.Second))
// No playlists should be created (GetPlaylist failed)
retryPlsRepo := retryManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo)
Consistently(func() int {
return retryPlsRepo.Len()
}, "500ms").Should(Equal(0))
// Refresh timers should be scheduled for transient errors
Eventually(func() int32 {
return syncer.refreshTimerCount.Load()
}).Should(BeNumerically(">=", int32(1)))
})
})
Describe("stop", func() {
It("stops the syncer when the manager stops", func() {
stopManager, _ := createTestManagerWithPlugins(nil,
"test-playlist-provider"+PackageExtension,
)
Expect(findSyncer(stopManager, "test-playlist-provider")).ToNot(BeNil())
err := stopManager.Stop()
Expect(err).ToNot(HaveOccurred())
// After Stop(), the plugin is unloaded so findSyncer returns nil
Expect(findSyncer(stopManager, "test-playlist-provider")).To(BeNil())
})
})
})