navidrome/plugins/manager_test.go
Deluan f66c888e09 test: move purgeCacheBySize unit tests
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00

335 lines
9.9 KiB
Go

package plugins
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Manager", Ordered, func() {
var ctx context.Context
// Ensure plugin is loaded at the start (might have been unloaded by previous tests)
BeforeAll(func() {
ctx = GinkgoT().Context()
if _, ok := testManager.plugins["test-metadata-agent"]; !ok {
err := testManager.LoadPlugin("test-metadata-agent")
Expect(err).ToNot(HaveOccurred())
}
})
// Ensure plugin is restored after all tests in this block
AfterAll(func() {
if _, ok := testManager.plugins["test-metadata-agent"]; !ok {
_ = testManager.LoadPlugin("test-metadata-agent")
}
})
Describe("LoadPlugin", func() {
It("auto-loads plugins from folder on Start", func() {
// Plugin is already loaded by testManager.Start() via discoverPlugins
names := testManager.PluginNames(string(CapabilityMetadataAgent))
Expect(names).To(ContainElement("test-metadata-agent"))
})
It("returns error when plugin file does not exist", func() {
err := testManager.LoadPlugin("nonexistent")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("plugin file not found"))
})
It("returns error when plugin is already loaded", func() {
// Plugin was loaded on Start, try to load again
err := testManager.LoadPlugin("test-metadata-agent")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("already loaded"))
})
It("returns error when plugins folder is not configured", func() {
originalFolder := conf.Server.Plugins.Folder
originalDataFolder := conf.Server.DataFolder
conf.Server.Plugins.Folder = ""
conf.Server.DataFolder = ""
defer func() {
conf.Server.Plugins.Folder = originalFolder
conf.Server.DataFolder = originalDataFolder
}()
err := testManager.LoadPlugin("test")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no plugins folder configured"))
})
})
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("can reload after unload", func() {
// Reload the plugin we just unloaded
err := testManager.LoadPlugin("test-metadata-agent")
Expect(err).ToNot(HaveOccurred())
names := testManager.PluginNames(string(CapabilityMetadataAgent))
Expect(names).To(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("ReloadPlugin", func() {
It("unloads and reloads a plugin", func() {
err := testManager.ReloadPlugin("test-metadata-agent")
Expect(err).ToNot(HaveOccurred())
names := testManager.PluginNames(string(CapabilityMetadataAgent))
Expect(names).To(ContainElement("test-metadata-agent"))
})
It("returns error when plugin not found", func() {
err := testManager.ReloadPlugin("nonexistent")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to unload"))
})
})
Describe("GetPluginInfo", func() {
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() {
// Plugin is already loaded
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())
})
})
})