🏡 index : ironmagma/quintodrome.git

//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 = conf.NewDir(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
}