mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
* test(plugins): speed up integration tests with shared wazero cache Reduce plugin test suite runtime from ~22s to ~12s by: - Creating a shared wazero compilation cache directory in TestPlugins() and setting conf.Server.CacheFolder globally so all test Manager instances reuse compiled WASM binaries from disk cache - Moving 6 createTestManager* calls from inside It blocks to BeforeAll blocks in scrobbler_adapter_test.go and manager_call_test.go - Replacing time.Sleep(2s) in KVStore TTL test with Eventually polling - Reducing WebSocket callback sleeps from 100ms to 10ms Signed-off-by: Deluan <deluan@navidrome.org> * test(plugins): enhance websocket tests by storing server messages for verification Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
463 lines
14 KiB
Go
463 lines
14 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/scheduler"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("SchedulerService", Ordered, func() {
|
|
var (
|
|
manager *Manager
|
|
tmpDir string
|
|
mockSched *mockScheduler
|
|
mockTimers *mockTimerRegistry
|
|
testService *testableSchedulerService
|
|
origAfterFn func(time.Duration, func()) *time.Timer
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
var err error
|
|
tmpDir, err = os.MkdirTemp("", "scheduler-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Copy the test-scheduler plugin
|
|
srcPath := filepath.Join(testdataDir, "test-scheduler"+PackageExtension)
|
|
destPath := filepath.Join(tmpDir, "test-scheduler"+PackageExtension)
|
|
data, err := os.ReadFile(srcPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = os.WriteFile(destPath, data, 0600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Compute SHA256 for the plugin
|
|
hash := sha256.Sum256(data)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
// Setup config
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = tmpDir
|
|
conf.Server.Plugins.AutoReload = false
|
|
|
|
// Create mock scheduler and timer registry
|
|
mockSched = newMockScheduler()
|
|
mockTimers = newMockTimerRegistry()
|
|
|
|
// Replace timeAfterFunc with mock
|
|
origAfterFn = timeAfterFunc
|
|
timeAfterFunc = mockTimers.AfterFunc
|
|
|
|
// Setup mock DataStore with pre-enabled plugin
|
|
mockPluginRepo := tests.CreateMockPluginRepo()
|
|
mockPluginRepo.Permitted = true
|
|
mockPluginRepo.SetData(model.Plugins{{
|
|
ID: "test-scheduler",
|
|
Path: destPath,
|
|
SHA256: hashHex,
|
|
Enabled: true,
|
|
}})
|
|
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
|
|
|
// Create and start manager
|
|
manager = &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: dataStore,
|
|
subsonicRouter: http.NotFoundHandler(),
|
|
metrics: noopMetricsRecorder{},
|
|
}
|
|
err = manager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Get scheduler service from plugin's closers and wrap it for testing
|
|
service := findSchedulerService(manager, "test-scheduler")
|
|
Expect(service).ToNot(BeNil())
|
|
testService = &testableSchedulerService{schedulerServiceImpl: service}
|
|
testService.scheduler = mockSched
|
|
|
|
DeferCleanup(func() {
|
|
timeAfterFunc = origAfterFn
|
|
_ = manager.Stop()
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
})
|
|
|
|
BeforeEach(func() {
|
|
mockSched.Reset()
|
|
mockTimers.Reset()
|
|
testService.ClearSchedules()
|
|
})
|
|
|
|
Describe("Plugin Loading", func() {
|
|
It("should detect scheduler capability", func() {
|
|
names := manager.PluginNames(string(CapabilityScheduler))
|
|
Expect(names).To(ContainElement("test-scheduler"))
|
|
})
|
|
|
|
It("should register scheduler service for plugin", func() {
|
|
service := findSchedulerService(manager, "test-scheduler")
|
|
Expect(service).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("ScheduleOneTime", func() {
|
|
It("should schedule a one-time task", func() {
|
|
scheduleID, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "test-payload", "test-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(scheduleID).To(Equal("test-id"))
|
|
|
|
// Verify schedule was registered
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
Expect(mockTimers.GetTimerCount()).To(Equal(1))
|
|
})
|
|
|
|
It("should invoke plugin callback and auto-cleanup after firing", func() {
|
|
_, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "data", "cleanup-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
|
|
// Trigger fires the callback which calls the plugin's nd_scheduler_callback
|
|
// One-time schedules clean up after the callback completes
|
|
mockTimers.TriggerAll()
|
|
|
|
// One-time schedules should self-cleanup
|
|
Expect(testService.GetScheduleCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should reject duplicate schedule ID", func() {
|
|
_, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "data", "dup-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "data2", "dup-id")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("already exists"))
|
|
})
|
|
|
|
It("should auto-generate schedule ID when empty", func() {
|
|
scheduleID, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "data", "")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(scheduleID).ToNot(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("ScheduleRecurring", func() {
|
|
It("should schedule recurring tasks", func() {
|
|
scheduleID, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "recurring-data", "recurring-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(scheduleID).To(Equal("recurring-id"))
|
|
|
|
// Verify schedule was registered
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
entry := testService.GetSchedule("recurring-id")
|
|
Expect(entry).ToNot(BeNil())
|
|
Expect(entry.isRecurring).To(BeTrue())
|
|
})
|
|
|
|
It("should invoke plugin callback multiple times without self-canceling", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "persist-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Trigger multiple times - recurring schedules should persist
|
|
mockSched.TriggerAll()
|
|
mockSched.TriggerAll()
|
|
|
|
// Recurring schedules should persist
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Calling Host Functions", func() {
|
|
It("should allow plugin to schedule a one-time task from callback", func() {
|
|
// Schedule with magic payload that triggers plugin to call SchedulerScheduleOneTime
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "schedule-followup", "trigger-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
|
|
// Trigger - plugin callback will schedule a follow-up task
|
|
mockSched.TriggerAll()
|
|
|
|
// Verify the plugin created a new schedule via host function
|
|
Expect(testService.GetScheduleCount()).To(Equal(2)) // original + followup
|
|
|
|
// Verify the follow-up schedule was created with correct ID and properties
|
|
followup := testService.GetSchedule("followup-id")
|
|
Expect(followup).ToNot(BeNil())
|
|
Expect(followup.payload).To(Equal("followup-created"))
|
|
Expect(followup.isRecurring).To(BeFalse())
|
|
Expect(followup.timer).ToNot(BeNil()) // One-time tasks use timers
|
|
})
|
|
|
|
It("should allow plugin to schedule a recurring task from callback", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "schedule-recurring", "trigger-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
mockSched.TriggerAll()
|
|
|
|
// Verify the plugin created a recurring schedule
|
|
entry := testService.GetSchedule("recurring-from-plugin")
|
|
Expect(entry).ToNot(BeNil())
|
|
Expect(entry.isRecurring).To(BeTrue())
|
|
Expect(entry.payload).To(Equal("recurring-created"))
|
|
})
|
|
})
|
|
|
|
Describe("CancelSchedule", func() {
|
|
It("should cancel a recurring task", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "cancel-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
|
|
err = testService.CancelSchedule(GinkgoT().Context(), "cancel-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should cancel a one-time task", func() {
|
|
_, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "data", "cancel-onetime-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
Expect(mockTimers.GetTimerCount()).To(Equal(1))
|
|
|
|
err = testService.CancelSchedule(GinkgoT().Context(), "cancel-onetime-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should remove callback from scheduler for recurring tasks", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "cancel-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockSched.GetCallbackCount()).To(Equal(1))
|
|
|
|
err = testService.CancelSchedule(GinkgoT().Context(), "cancel-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockSched.GetCallbackCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should return error for non-existent schedule", func() {
|
|
err := testService.CancelSchedule(GinkgoT().Context(), "non-existent")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("not found"))
|
|
})
|
|
})
|
|
|
|
Describe("Scheduler Service Isolation", func() {
|
|
It("should share the same scheduler service across multiple plugin instances", func() {
|
|
// This test verifies that when we call plugin.instance() multiple times
|
|
// (creating multiple instances from the same compiled plugin), they all
|
|
// share the same scheduler service. This is the expected behavior since
|
|
// the scheduler service is registered once per plugin at compile time.
|
|
|
|
// Get the plugin
|
|
manager.mu.RLock()
|
|
plugin, ok := manager.plugins["test-scheduler"]
|
|
manager.mu.RUnlock()
|
|
Expect(ok).To(BeTrue())
|
|
|
|
// Schedule a task using the service directly
|
|
_, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "shared-data", "shared-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
|
|
// Create a plugin instance
|
|
instance, err := plugin.instance(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer instance.Close(GinkgoT().Context())
|
|
|
|
// The scheduler service is shared, so the schedule ID should clash
|
|
// if another instance tries to use the same ID
|
|
_, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "other-data", "shared-id")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("already exists"))
|
|
|
|
// But different IDs should work fine
|
|
_, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "instance2-data", "otherx-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(2))
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Unload", func() {
|
|
It("should cancel all schedules when plugin is unloaded", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 10s", "data1", "unload-1")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
_, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "data2", "unload-2")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(2))
|
|
Expect(mockSched.GetCallbackCount()).To(Equal(1)) // Only recurring task uses scheduler
|
|
Expect(mockTimers.GetTimerCount()).To(Equal(1)) // Only one-time task uses timer
|
|
|
|
err = manager.unloadPlugin("test-scheduler")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Expect(findSchedulerService(manager, "test-scheduler")).To(BeNil())
|
|
Expect(mockSched.GetCallbackCount()).To(Equal(0)) // Recurring task removed
|
|
})
|
|
})
|
|
})
|
|
|
|
// testableSchedulerService wraps schedulerServiceImpl with test helpers.
|
|
type testableSchedulerService struct {
|
|
*schedulerServiceImpl
|
|
}
|
|
|
|
func (t *testableSchedulerService) GetScheduleCount() int {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
return len(t.schedules)
|
|
}
|
|
|
|
func (t *testableSchedulerService) GetSchedule(id string) *scheduleEntry {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
return t.schedules[id]
|
|
}
|
|
|
|
func (t *testableSchedulerService) ClearSchedules() {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
t.schedules = make(map[string]*scheduleEntry)
|
|
}
|
|
|
|
// mockScheduler implements scheduler.Scheduler for testing without timing dependencies.
|
|
type mockScheduler struct {
|
|
mu sync.Mutex
|
|
callbacks map[int]func()
|
|
nextID int
|
|
}
|
|
|
|
func newMockScheduler() *mockScheduler {
|
|
return &mockScheduler{
|
|
callbacks: make(map[int]func()),
|
|
nextID: 1,
|
|
}
|
|
}
|
|
|
|
func (s *mockScheduler) Run(_ context.Context) {}
|
|
|
|
func (s *mockScheduler) Add(_ string, cmd func()) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
id := s.nextID
|
|
s.nextID++
|
|
s.callbacks[id] = cmd
|
|
return id, nil
|
|
}
|
|
|
|
func (s *mockScheduler) Remove(id int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.callbacks, id)
|
|
}
|
|
|
|
func (s *mockScheduler) TriggerAll() {
|
|
s.mu.Lock()
|
|
callbacks := make([]func(), 0, len(s.callbacks))
|
|
for _, cb := range s.callbacks {
|
|
callbacks = append(callbacks, cb)
|
|
}
|
|
s.mu.Unlock()
|
|
for _, cb := range callbacks {
|
|
cb()
|
|
}
|
|
}
|
|
|
|
func (s *mockScheduler) GetCallbackCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.callbacks)
|
|
}
|
|
|
|
func (s *mockScheduler) Reset() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.callbacks = make(map[int]func())
|
|
s.nextID = 1
|
|
}
|
|
|
|
var _ scheduler.Scheduler = (*mockScheduler)(nil)
|
|
|
|
// mockTimerRegistry tracks mock timers created during tests.
|
|
type mockTimerRegistry struct {
|
|
mu sync.Mutex
|
|
callbacks []func()
|
|
timers []*time.Timer
|
|
}
|
|
|
|
func newMockTimerRegistry() *mockTimerRegistry {
|
|
return &mockTimerRegistry{
|
|
callbacks: make([]func(), 0),
|
|
timers: make([]*time.Timer, 0),
|
|
}
|
|
}
|
|
|
|
// AfterFunc creates a timer that we control for testing.
|
|
func (r *mockTimerRegistry) AfterFunc(_ time.Duration, f func()) *time.Timer {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// Store callback for TriggerAll
|
|
r.callbacks = append(r.callbacks, f)
|
|
|
|
// Create a real timer that won't fire (very long duration, immediately stopped)
|
|
t := time.NewTimer(time.Hour * 24 * 365)
|
|
t.Stop()
|
|
r.timers = append(r.timers, t)
|
|
|
|
return t
|
|
}
|
|
|
|
// TriggerAll fires all pending timer callbacks.
|
|
func (r *mockTimerRegistry) TriggerAll() {
|
|
r.mu.Lock()
|
|
callbacks := make([]func(), len(r.callbacks))
|
|
copy(callbacks, r.callbacks)
|
|
r.mu.Unlock()
|
|
|
|
for _, cb := range callbacks {
|
|
cb()
|
|
}
|
|
}
|
|
|
|
func (r *mockTimerRegistry) GetTimerCount() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.callbacks)
|
|
}
|
|
|
|
func (r *mockTimerRegistry) Reset() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.callbacks = make([]func(), 0)
|
|
r.timers = make([]*time.Timer, 0)
|
|
}
|
|
|
|
// findSchedulerService finds the scheduler service from a plugin's closers.
|
|
func findSchedulerService(m *Manager, pluginName string) *schedulerServiceImpl {
|
|
m.mu.RLock()
|
|
instance, ok := m.plugins[pluginName]
|
|
m.mu.RUnlock()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
for _, closer := range instance.closers {
|
|
if svc, ok := closer.(*schedulerServiceImpl); ok {
|
|
return svc
|
|
}
|
|
}
|
|
return nil
|
|
}
|