navidrome/plugins/playlist_generator_test.go
Deluan 9c516cf601 refactor(plugins): serialize PlaylistGenerator plugin calls via work queue
Replace the timer-per-playlist + WaitGroup.Go model with a single worker
goroutine processing work items from a buffered channel. This eliminates
race conditions from parallel plugin calls (rate limiting risk),
unsynchronized map/field access across goroutines, and overlapping
discovery and refresh timers. The worker owns all mutable state
(refreshTimers, discoveryTimer) exclusively, while retryInterval and
refreshTimerCount use atomics for safe test observability. Also adds
retry-on-failure for GetAvailablePlaylists (5 min delay) and makes
MockPlaylistRepo thread-safe with sync.RWMutex.
2026-04-12 17:32:18 -04:00

172 lines
6 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"
)
var _ = Describe("PlaylistGenerator", Ordered, func() {
var (
pgManager *Manager
mockPlsRepo *tests.MockPlaylistRepo
)
BeforeAll(func() {
pgManager, _ = createTestManagerWithPlugins(nil,
"test-playlist-generator"+PackageExtension,
)
mockPlsRepo = pgManager.ds.(*tests.MockDataStore).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 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-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.GetData(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.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-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
errPlsRepo := errManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo)
// The orchestrator 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-generator": {
"get_playlist_error": "playlist temporarily unavailable",
"get_playlist_error_type": string(capabilities.PlaylistGeneratorErrorNotFound),
},
}, "test-playlist-generator"+PackageExtension)
// Should still have the orchestrator
Expect(notFoundManager.playlistGenerators).To(HaveKey("test-playlist-generator"))
// 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
orch := notFoundManager.playlistGenerators["test-playlist-generator"]
Eventually(func() int32 {
return orch.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-generator": {
"get_playlist_error": "temporary failure",
"retry_interval": "60",
},
}, "test-playlist-generator"+PackageExtension)
Expect(retryManager.playlistGenerators).To(HaveKey("test-playlist-generator"))
orch := retryManager.playlistGenerators["test-playlist-generator"]
// retryInterval should be stored from the response
Eventually(func() time.Duration {
return time.Duration(orch.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 orch.refreshTimerCount.Load()
}).Should(BeNumerically(">=", int32(1)))
})
})
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())
})
})
})