navidrome/plugins/playlist_provider_test.go
Deluan 9ddbcbf6b4 feat: persist and expose plugin playlist ValidUntil in Subsonic API
Add a ValidUntil field to the Playlist model and persist it from the
plugin's GetPlaylistResponse during sync. This allows clients to know
when a plugin playlist's data will be refreshed. The value is exposed
in the OpenSubsonic playlist response alongside the existing
smart playlist ValidUntil calculation. The migration is consolidated
into a single multi-statement ExecContext call.
2026-04-12 17:38:21 -04:00

227 lines
7.8 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())
Expect(dailyMix1.ValidUntil).To(BeNil())
})
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("user permission validation", func() {
It("skips playlists for unauthorized users when AllUsers is false", func() {
// Create manager with restricted users — only "other-user" is allowed,
// but the plugin returns playlists for "admin" which resolves to "user-1"
restrictedManager, _ := createTestManagerWithPluginOverrides(nil,
map[string]pluginOverride{
"test-playlist-provider": {AllUsers: false, Users: `["other-user"]`},
},
"test-playlist-provider"+PackageExtension,
)
// No playlists should be created because "user-1" is not in allowed users
restrictedPlsRepo := restrictedManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo)
Consistently(func() int {
return restrictedPlsRepo.Len()
}, "500ms").Should(Equal(0))
})
It("creates playlists for authorized users when AllUsers is false", func() {
// Create manager with restricted users — "user-1" is allowed,
// and the plugin returns playlists for "admin" which resolves to "user-1"
allowedManager, _ := createTestManagerWithPluginOverrides(nil,
map[string]pluginOverride{
"test-playlist-provider": {AllUsers: false, Users: `["user-1"]`},
},
"test-playlist-provider"+PackageExtension,
)
// Playlists should be created because "user-1" is in allowed users
allowedPlsRepo := allowedManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo)
Eventually(func() int {
return allowedPlsRepo.Len()
}).Should(BeNumerically(">=", 2))
})
})
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())
})
})
})