package plugins import ( "context" "fmt" "os" "path/filepath" "sync" "time" "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Manager", Ordered, func() { var ctx context.Context BeforeAll(func() { ctx = GinkgoT().Context() }) Describe("Plugin Loading", func() { It("loads enabled plugins from DB on Start", func() { // Plugin is already loaded by testManager.Start() via loadEnabledPlugins names := testManager.PluginNames(string(CapabilityMetadataAgent)) Expect(names).To(ContainElement("test-metadata-agent")) }) }) Describe("UnloadPlugin", func() { It("removes a loaded plugin", func() { // Plugin is already loaded from Start err := testManager.UnloadPlugin("test-metadata-agent") Expect(err).ToNot(HaveOccurred()) names := testManager.PluginNames(string(CapabilityMetadataAgent)) Expect(names).ToNot(ContainElement("test-metadata-agent")) }) It("returns error when plugin not found", func() { err := testManager.UnloadPlugin("nonexistent") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not found")) }) }) Describe("EnablePlugin", func() { It("enables and loads a disabled plugin", func() { // First disable the plugin (which also unloads it) err := testManager.DisablePlugin(ctx, "test-metadata-agent") Expect(err).ToNot(HaveOccurred()) Expect(testManager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) // Enable it err = testManager.EnablePlugin(ctx, "test-metadata-agent") Expect(err).ToNot(HaveOccurred()) names := testManager.PluginNames(string(CapabilityMetadataAgent)) Expect(names).To(ContainElement("test-metadata-agent")) }) }) Describe("DisablePlugin", func() { It("disables and unloads an enabled plugin", func() { // Ensure the plugin is loaded first _ = testManager.EnablePlugin(ctx, "test-metadata-agent") err := testManager.DisablePlugin(ctx, "test-metadata-agent") Expect(err).ToNot(HaveOccurred()) names := testManager.PluginNames(string(CapabilityMetadataAgent)) Expect(names).ToNot(ContainElement("test-metadata-agent")) }) }) Describe("GetPluginInfo", func() { BeforeEach(func() { // Ensure plugin is loaded for this test _ = testManager.EnablePlugin(ctx, "test-metadata-agent") }) It("returns information about all loaded plugins", func() { info := testManager.GetPluginInfo() Expect(info).To(HaveKey("test-metadata-agent")) Expect(info["test-metadata-agent"].Name).To(Equal("Test Plugin")) Expect(info["test-metadata-agent"].Version).To(Equal("1.0.0")) }) }) It("can call the plugin concurrently", func() { // Ensure plugin is loaded _ = testManager.EnablePlugin(ctx, "test-metadata-agent") const concurrency = 30 errs := make(chan error, concurrency) bios := make(chan string, concurrency) g := sync.WaitGroup{} g.Add(concurrency) for i := range concurrency { go func(i int) { defer g.Done() a, ok := testManager.LoadMediaAgent("test-metadata-agent") Expect(ok).To(BeTrue()) agent := a.(agents.ArtistBiographyRetriever) bio, err := agent.GetArtistBiography(ctx, fmt.Sprintf("artist-%d", i), fmt.Sprintf("Artist %d", i), "") if err != nil { errs <- err return } bios <- bio }(i) } g.Wait() // Collect results for range concurrency { select { case err := <-errs: Expect(err).ToNot(HaveOccurred()) case bio := <-bios: Expect(bio).To(ContainSubstring("Biography for Artist")) } } }) }) var _ = Describe("purgeCacheBySize", func() { var ( tmpDir string ctx context.Context ) BeforeEach(func() { var err error ctx = GinkgoT().Context() tmpDir, err = os.MkdirTemp("", "cache-purge-test-*") Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { os.RemoveAll(tmpDir) }) createFileWithSize := func(path string, sizeBytes int64, modTime time.Time) { dir := filepath.Dir(path) err := os.MkdirAll(dir, 0755) Expect(err).ToNot(HaveOccurred()) f, err := os.Create(path) Expect(err).ToNot(HaveOccurred()) defer f.Close() // Write random data to reach desired size if sizeBytes > 0 { err = f.Truncate(sizeBytes) Expect(err).ToNot(HaveOccurred()) } // Set modification time err = os.Chtimes(path, modTime, modTime) Expect(err).ToNot(HaveOccurred()) } getDirSize := func(dir string) uint64 { var total uint64 err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } info, err := d.Info() if err != nil { return nil } total += uint64(info.Size()) return nil }) Expect(err).ToNot(HaveOccurred()) return total } Context("when maxSize is invalid or zero", func() { It("should not remove any files with invalid size", func() { cacheDir := filepath.Join(tmpDir, "cache") createFileWithSize(filepath.Join(cacheDir, "file1.bin"), 1000, time.Now()) createFileWithSize(filepath.Join(cacheDir, "file2.bin"), 1000, time.Now()) purgeCacheBySize(ctx, cacheDir, "invalid") Expect(getDirSize(cacheDir)).To(Equal(uint64(2000))) }) It("should not remove any files when maxSize is 0", func() { cacheDir := filepath.Join(tmpDir, "cache") createFileWithSize(filepath.Join(cacheDir, "file1.bin"), 1000, time.Now()) createFileWithSize(filepath.Join(cacheDir, "file2.bin"), 1000, time.Now()) purgeCacheBySize(ctx, cacheDir, "0") Expect(getDirSize(cacheDir)).To(Equal(uint64(2000))) }) }) Context("when cache directory doesn't exist", func() { It("should not error", func() { nonExistentDir := filepath.Join(tmpDir, "nonexistent") Expect(func() { purgeCacheBySize(ctx, nonExistentDir, "100MB") }).ToNot(Panic()) }) }) Context("when total size is under limit", func() { It("should not remove any files", func() { cacheDir := filepath.Join(tmpDir, "cache") createFileWithSize(filepath.Join(cacheDir, "file1.bin"), 1000, time.Now()) createFileWithSize(filepath.Join(cacheDir, "file2.bin"), 1000, time.Now()) purgeCacheBySize(ctx, cacheDir, "10KB") Expect(getDirSize(cacheDir)).To(Equal(uint64(2000))) }) }) Context("when total size exceeds limit", func() { It("should remove oldest files first", func() { cacheDir := filepath.Join(tmpDir, "cache") now := time.Now() // Create files with different ages (1MB each) oldestFile := filepath.Join(cacheDir, "old.bin") middleFile := filepath.Join(cacheDir, "middle.bin") newestFile := filepath.Join(cacheDir, "new.bin") createFileWithSize(oldestFile, 1*1024*1024, now.Add(-3*time.Hour)) createFileWithSize(middleFile, 1*1024*1024, now.Add(-2*time.Hour)) createFileWithSize(newestFile, 1*1024*1024, now.Add(-1*time.Hour)) // Set limit to 2MiB - should remove oldest file purgeCacheBySize(ctx, cacheDir, "2MiB") // Oldest should be removed _, err := os.Stat(oldestFile) Expect(os.IsNotExist(err)).To(BeTrue(), "oldest file should be removed") // Others should remain _, err = os.Stat(middleFile) Expect(err).ToNot(HaveOccurred(), "middle file should remain") _, err = os.Stat(newestFile) Expect(err).ToNot(HaveOccurred(), "newest file should remain") }) It("should remove multiple files to get under limit", func() { cacheDir := filepath.Join(tmpDir, "cache") now := time.Now() // Create 5 files, 1MiB each (total 5MiB) for i := 0; i < 5; i++ { path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin")) createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour)) } // Set limit to 2.5MiB - should remove oldest 3 files (leaving 2MiB) purgeCacheBySize(ctx, cacheDir, "2.5MiB") finalSize := getDirSize(cacheDir) limit, _ := humanize.ParseBytes("2.5MiB") Expect(finalSize).To(BeNumerically("<=", limit)) }) It("should remove empty parent directories after removing files", func() { cacheDir := filepath.Join(tmpDir, "cache") now := time.Now() // Create files in subdirectories oldFile := filepath.Join(cacheDir, "subdir1", "old.bin") newFile := filepath.Join(cacheDir, "subdir2", "new.bin") createFileWithSize(oldFile, 2*1024*1024, now.Add(-2*time.Hour)) createFileWithSize(newFile, 2*1024*1024, now.Add(-1*time.Hour)) // Set limit to 2MiB - should remove old file and its parent dir purgeCacheBySize(ctx, cacheDir, "2MiB") // Old file and its parent dir should be removed _, err := os.Stat(oldFile) Expect(os.IsNotExist(err)).To(BeTrue()) _, err = os.Stat(filepath.Join(cacheDir, "subdir1")) Expect(os.IsNotExist(err)).To(BeTrue(), "empty parent directory should be removed") // New file and its parent dir should remain _, err = os.Stat(newFile) Expect(err).ToNot(HaveOccurred()) _, err = os.Stat(filepath.Join(cacheDir, "subdir2")) Expect(err).ToNot(HaveOccurred()) }) }) })