navidrome/plugins/host_scheduler_test.go
Deluan Quintão 30df004d4d
test(plugins): speed up integration tests (~45% improvement) (#5137)
* 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>
2026-03-02 16:18:30 -05:00

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
}