package core_test import ( "context" "os" "path/filepath" "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("ImageUploadService", func() { var svc core.ImageUploadService var tmpDir string var artRepo *tests.MockArtworkRepo var queueRepo *tests.MockArtworkQueueRepo BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) artRepo = tests.CreateMockArtworkRepo() queueRepo = tests.CreateMockArtworkQueueRepo() ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} svc = core.NewImageUploadService(ds) }) Describe("SetImage", func() { It("creates directory and saves image file", func() { ctx := context.Background() reader := strings.NewReader("fake image data") filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg") Expect(err).ToNot(HaveOccurred()) Expect(filename).To(Equal("ar-1_pink_floyd.jpg")) absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg") data, err := os.ReadFile(absPath) Expect(err).ToNot(HaveOccurred()) Expect(string(data)).To(Equal("fake image data")) }) It("falls back to ID-only filename when name cleans to empty", func() { ctx := context.Background() reader := strings.NewReader("data") filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png") Expect(err).ToNot(HaveOccurred()) Expect(filename).To(Equal("pl-1.png")) }) It("removes old image when replacing", func() { ctx := context.Background() oldDir := filepath.Join(tmpDir, "artwork", "artist") Expect(os.MkdirAll(oldDir, 0755)).To(Succeed()) oldFile := filepath.Join(oldDir, "ar-1_old.png") Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed()) reader := strings.NewReader("new image") _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg") Expect(err).ToNot(HaveOccurred()) Expect(oldFile).ToNot(BeAnExistingFile()) newPath := filepath.Join(oldDir, "ar-1_new_name.jpg") Expect(newPath).To(BeAnExistingFile()) }) It("ignores missing old file without error", func() { ctx := context.Background() reader := strings.NewReader("data") _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") Expect(err).ToNot(HaveOccurred()) }) It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() { ctx := context.Background() Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", })).To(Succeed()) _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", strings.NewReader("img"), ".jpg") Expect(err).ToNot(HaveOccurred()) // SetImage only writes the file; the state row survives and nothing is queued until // the caller has persisted the new filename and called EnqueueArtwork. _, err = artRepo.GetItemArtwork("ar", "ar-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) }) }) Describe("EnqueueArtwork", func() { It("clears artwork state and enqueues a Bump", func() { ctx := context.Background() Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", })).To(Succeed()) svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1") _, err := artRepo.GetItemArtwork("ar", "ar-1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) queued, err := queueRepo.DequeueBatch(1000) Expect(err).ToNot(HaveOccurred()) Expect(queued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "ar"), HaveField("ItemID", "ar-1"), HaveField("Priority", model.ArtworkPriorityBump), ))) }) It("is a no-op for an unknown entity type", func() { svc.EnqueueArtwork(context.Background(), "unknown", "x-1") Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) }) }) Describe("RemoveImage", func() { It("removes the file at the given path", func() { ctx := context.Background() dir := filepath.Join(tmpDir, "artwork", "artist") Expect(os.MkdirAll(dir, 0755)).To(Succeed()) path := filepath.Join(dir, "ar-1_test.jpg") Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed()) err := svc.RemoveImage(ctx, path) Expect(err).ToNot(HaveOccurred()) Expect(path).ToNot(BeAnExistingFile()) }) It("succeeds when file does not exist", func() { ctx := context.Background() err := svc.RemoveImage(ctx, "/nonexistent/file.jpg") Expect(err).ToNot(HaveOccurred()) }) It("succeeds with empty path", func() { ctx := context.Background() err := svc.RemoveImage(ctx, "") Expect(err).ToNot(HaveOccurred()) }) }) }) var _ = Describe("MaxImageUploadSize", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) }) It("returns the configured size when valid", func() { conf.Server.MaxImageUploadSize = "20MB" Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000))) }) It("returns the default size when config is empty", func() { conf.Server.MaxImageUploadSize = "" Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) }) It("returns the default size when config is invalid", func() { conf.Server.MaxImageUploadSize = "not-a-size" Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) }) It("parses raw byte values", func() { conf.Server.MaxImageUploadSize = "52428800" Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800))) }) })