quintodrome/core/storage/local/local_test.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

456 lines
13 KiB
Go
Raw Normal View History

package local
import (
"io/fs"
"net/url"
"os"
"path/filepath"
"runtime"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
refactor(scanner): remove C++ taglib adapter (#5349) * refactor(build): remove CPP taglib adapter Remove the CGO-based TagLib adapter (adapters/taglib/) and all cross-taglib build infrastructure. The WASM-based go-taglib adapter (adapters/gotaglib/) is now the sole metadata extractor. - Delete adapters/taglib/ (CPP/CGO wrapper) - Delete .github/actions/download-taglib/ - Remove CROSS_TAGLIB_VERSION, CGO_CFLAGS_ALLOW, and all taglib-related references from Dockerfile, Makefile, CI pipeline, and devcontainer * fix(scanner): gracefully fallback to default extractor instead of crashing Replace log.Fatal with a graceful fallback when the configured scanner extractor is not found. Instead of terminating the process, the code now warns and falls back to the default taglib extractor using the existing consts.DefaultScannerExtractor constant. A fatal log is retained only for the case where the default extractor itself is not registered, which indicates a broken build. * test(scanner): cover default extractor fallback and suppress redundant warn Address review feedback on the extractor fallback in newLocalStorage: - Only log the "using default" warning when the configured extractor differs from the default, so a broken build (default extractor itself missing) logs only the fatal — not a misleading "falling back" warn followed immediately by the fatal. - Add a unit test that registers a mock under consts.DefaultScannerExtractor, sets the configured extractor to an unknown name, and asserts the local storage is constructed using the default extractor's constructor.
2026-04-12 17:52:29 -08:00
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model/metadata"
ci: run Go tests on Windows (#5380) * ci(windows): add skeleton go-windows job (compile-only smoke test) * ci(windows): fix comment to reference Task 7 not Task 6 * ci(windows): harden PATH visibility and set explicit bash shell * ci(windows): enable full go test suite and ndpgen check * test(gotaglib): skip Unix-only permission tests on Windows * test(lyrics): skip Windows-incompatible tests * test(utils): skip Windows-incompatible tests * test(mpv): skip Windows-incompatible playback tests Skip 3 subprocess-execution tests that rely on Unix-style mpv invocation; .bat output includes \r-terminated lines that break argument parsing (#TBD-mpv-windows). * test(storage): skip Windows-incompatible tests Skip relative-path test where filepath.Join uses backslash but the storage implementation returns a forward-slash URL path (#TBD-path-sep-storage). * test(storage/local): skip Windows-incompatible tests Skip 13 tests that fail because url.Parse("file://" + windowsPath) treats the drive letter colon as an invalid port; also skip the Windows drive-letter path test that exposes a backslash vs forward-slash normalisation bug (#TBD-path-sep-storage-local). * test(playlists): skip Windows-incompatible tests * test(model): skip Windows-incompatible tests * test(model/metadata): skip Windows-incompatible tests * test(core): skip Windows-incompatible tests AbsolutePath uses filepath.Join which produces OS-native path separators; skip the assertion test on Windows until the production code is fixed (#TBD-path-sep-core). * test(artwork): skip Windows-incompatible tests Artwork readers produce OS-native path separators on Windows while tests assert forward-slash paths; skip 11 affected tests pending a fix in production code (#TBD-path-sep-artwork). * test(persistence): skip Windows-incompatible tests Skip flaky timestamp comparison (#TBD-flake-persistence) and path-separator real-bugs (#TBD-path-sep-persistence) in FolderRepository.GetFolderUpdateInfo which uses filepath.Clean/os.PathSeparator converting stored forward-slash paths to backslashes on Windows. * test(scanner): skip Windows-incompatible tests Skip symlink tests (Unix-assumption), ndignore path-separator bugs (#TBD-path-sep-scanner) in processLibraryEvents/resolveFolderPath where filepath.Rel/filepath.Split return backslash paths incompatible with fs.FS forward-slash expectations, error message mismatch on Windows, and file format upgrade detection (#TBD-path-sep-scanner). * test(plugins): skip Windows-incompatible tests Add //go:build !windows tags to test files that reference the suite bootstrap (testManager, testdataDir, createTestManager) which is only compiled on non-Windows. Add a Windows-only suite stub that skips all specs via BeforeEach to prevent [build failed] on Windows CI. * test(server): skip Windows-incompatible tests Skip createUnixSocketFile tests that rely on Unix file permission bits (chmod/fchmod) which are not supported on Windows. * test(nativeapi): skip Windows-incompatible tests Skip the i18n JSON validation test that uses filepath.Join to build embedded-FS paths; filepath.Join produces backslashes on Windows which breaks fs.Open (embedded FS always uses forward slashes). * test(e2e): skip Windows-incompatible tests On Windows, SQLite holds file locks that prevent the Ginkgo TempDir DeferCleanup from deleting the DB file. Register an explicit db.Close DeferCleanup (LIFO before TempDir cleanup) on Windows so the file lock is released before the temp directory is removed. * test(windows): fix e2e AfterSuite and skip remaining scanner path test * test(scanner): skip another Windows path-sep test (#TBD-path-sep-scanner) * test(subsonic): skip timing-flaky test on Windows (#TBD-flake-time-resolution-subsonic) * test(scanner): skip 'detects file moved to different folder' on Windows * test(scanner): consolidate 'Library changes' Windows skips into BeforeEach * test(scanner): close DB before TempDir cleanup to fix Windows file lock * test(scanner): skip ScanFolders suite on Windows instead of closing shared DB * ci: retrigger for Windows soak run 2/3 * ci: retrigger for Windows soak run 3/3 * ci: retrigger for Windows soak run 3/3 (take 2) * test(scanner): skip Multi-Library suite on Windows (SQLite file lock) * ci(windows): promote go-windows to blocking status check * test(plugins): run platform-neutral specs on Windows, drop blanket Skip * test(windows): make tests cross-platform instead of skipping - subsonic: back-date submissionTime baseline by 1s so BeTemporally(">") passes under millisecond clock resolution - persistence: sleep briefly between Put calls so UpdatedAt is strictly after CreatedAt on low-resolution clocks - utils/files: close tempFile before os.Remove so the test works on Windows (where an open handle holds a file lock) - tests.TempFile: close the handle before returning; metadata tests no longer leak the open file into Ginkgo's TempDir cleanup Resolves Copilot review comments on #5380. * test(tests): add SkipOnWindows helper to reduce boilerplate Introduces tests.SkipOnWindows(reason) that wraps the 3-line runtime.GOOS guard pattern used in every Windows-skipped spec. * test(adapters): use tests.SkipOnWindows helper * test(core): use tests.SkipOnWindows helper * test(model): use tests.SkipOnWindows helper * test(persistence): use tests.SkipOnWindows helper * test(scanner): use tests.SkipOnWindows helper * test(server): use tests.SkipOnWindows helper * test(plugins): run pure-Go unit tests on Windows config_validation_test, manager_loader_test, and migrate_test have no WASM/exec dependencies and don't rely on the make-built test plugins from plugins_suite_test.go. Let them run on Windows too.
2026-04-19 09:16:47 -08:00
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LocalStorage", func() {
var tempDir string
var testExtractor *mockTestExtractor
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Create a temporary directory for testing
var err error
tempDir, err = os.MkdirTemp("", "navidrome-local-storage-test-")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
os.RemoveAll(tempDir)
})
// Create and register a test extractor
testExtractor = &mockTestExtractor{
results: make(map[string]metadata.Info),
}
RegisterExtractor("test", func(fs.FS, string) Extractor {
return testExtractor
})
conf.Server.Scanner.Extractor = "test"
})
Describe("newLocalStorage", func() {
ci: run Go tests on Windows (#5380) * ci(windows): add skeleton go-windows job (compile-only smoke test) * ci(windows): fix comment to reference Task 7 not Task 6 * ci(windows): harden PATH visibility and set explicit bash shell * ci(windows): enable full go test suite and ndpgen check * test(gotaglib): skip Unix-only permission tests on Windows * test(lyrics): skip Windows-incompatible tests * test(utils): skip Windows-incompatible tests * test(mpv): skip Windows-incompatible playback tests Skip 3 subprocess-execution tests that rely on Unix-style mpv invocation; .bat output includes \r-terminated lines that break argument parsing (#TBD-mpv-windows). * test(storage): skip Windows-incompatible tests Skip relative-path test where filepath.Join uses backslash but the storage implementation returns a forward-slash URL path (#TBD-path-sep-storage). * test(storage/local): skip Windows-incompatible tests Skip 13 tests that fail because url.Parse("file://" + windowsPath) treats the drive letter colon as an invalid port; also skip the Windows drive-letter path test that exposes a backslash vs forward-slash normalisation bug (#TBD-path-sep-storage-local). * test(playlists): skip Windows-incompatible tests * test(model): skip Windows-incompatible tests * test(model/metadata): skip Windows-incompatible tests * test(core): skip Windows-incompatible tests AbsolutePath uses filepath.Join which produces OS-native path separators; skip the assertion test on Windows until the production code is fixed (#TBD-path-sep-core). * test(artwork): skip Windows-incompatible tests Artwork readers produce OS-native path separators on Windows while tests assert forward-slash paths; skip 11 affected tests pending a fix in production code (#TBD-path-sep-artwork). * test(persistence): skip Windows-incompatible tests Skip flaky timestamp comparison (#TBD-flake-persistence) and path-separator real-bugs (#TBD-path-sep-persistence) in FolderRepository.GetFolderUpdateInfo which uses filepath.Clean/os.PathSeparator converting stored forward-slash paths to backslashes on Windows. * test(scanner): skip Windows-incompatible tests Skip symlink tests (Unix-assumption), ndignore path-separator bugs (#TBD-path-sep-scanner) in processLibraryEvents/resolveFolderPath where filepath.Rel/filepath.Split return backslash paths incompatible with fs.FS forward-slash expectations, error message mismatch on Windows, and file format upgrade detection (#TBD-path-sep-scanner). * test(plugins): skip Windows-incompatible tests Add //go:build !windows tags to test files that reference the suite bootstrap (testManager, testdataDir, createTestManager) which is only compiled on non-Windows. Add a Windows-only suite stub that skips all specs via BeforeEach to prevent [build failed] on Windows CI. * test(server): skip Windows-incompatible tests Skip createUnixSocketFile tests that rely on Unix file permission bits (chmod/fchmod) which are not supported on Windows. * test(nativeapi): skip Windows-incompatible tests Skip the i18n JSON validation test that uses filepath.Join to build embedded-FS paths; filepath.Join produces backslashes on Windows which breaks fs.Open (embedded FS always uses forward slashes). * test(e2e): skip Windows-incompatible tests On Windows, SQLite holds file locks that prevent the Ginkgo TempDir DeferCleanup from deleting the DB file. Register an explicit db.Close DeferCleanup (LIFO before TempDir cleanup) on Windows so the file lock is released before the temp directory is removed. * test(windows): fix e2e AfterSuite and skip remaining scanner path test * test(scanner): skip another Windows path-sep test (#TBD-path-sep-scanner) * test(subsonic): skip timing-flaky test on Windows (#TBD-flake-time-resolution-subsonic) * test(scanner): skip 'detects file moved to different folder' on Windows * test(scanner): consolidate 'Library changes' Windows skips into BeforeEach * test(scanner): close DB before TempDir cleanup to fix Windows file lock * test(scanner): skip ScanFolders suite on Windows instead of closing shared DB * ci: retrigger for Windows soak run 2/3 * ci: retrigger for Windows soak run 3/3 * ci: retrigger for Windows soak run 3/3 (take 2) * test(scanner): skip Multi-Library suite on Windows (SQLite file lock) * ci(windows): promote go-windows to blocking status check * test(plugins): run platform-neutral specs on Windows, drop blanket Skip * test(windows): make tests cross-platform instead of skipping - subsonic: back-date submissionTime baseline by 1s so BeTemporally(">") passes under millisecond clock resolution - persistence: sleep briefly between Put calls so UpdatedAt is strictly after CreatedAt on low-resolution clocks - utils/files: close tempFile before os.Remove so the test works on Windows (where an open handle holds a file lock) - tests.TempFile: close the handle before returning; metadata tests no longer leak the open file into Ginkgo's TempDir cleanup Resolves Copilot review comments on #5380. * test(tests): add SkipOnWindows helper to reduce boilerplate Introduces tests.SkipOnWindows(reason) that wraps the 3-line runtime.GOOS guard pattern used in every Windows-skipped spec. * test(adapters): use tests.SkipOnWindows helper * test(core): use tests.SkipOnWindows helper * test(model): use tests.SkipOnWindows helper * test(persistence): use tests.SkipOnWindows helper * test(scanner): use tests.SkipOnWindows helper * test(server): use tests.SkipOnWindows helper * test(plugins): run pure-Go unit tests on Windows config_validation_test, manager_loader_test, and migrate_test have no WASM/exec dependencies and don't rely on the make-built test plugins from plugins_suite_test.go. Let them run on Windows too.
2026-04-19 09:16:47 -08:00
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
})
Context("with valid path", func() {
It("should create a localStorage instance with correct path", func() {
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
localStorage := storage.(*localStorage)
Expect(localStorage.u.Scheme).To(Equal("file"))
// Check that the path is set correctly (could be resolved to real path on macOS)
Expect(localStorage.u.Path).To(ContainSubstring("navidrome-local-storage-test"))
Expect(localStorage.resolvedPath).To(ContainSubstring("navidrome-local-storage-test"))
Expect(localStorage.extractor).ToNot(BeNil())
})
It("should handle URL-decoded paths correctly", func() {
// Create a directory with spaces to test URL decoding
spacedDir := filepath.Join(tempDir, "test folder")
err := os.MkdirAll(spacedDir, 0755)
Expect(err).ToNot(HaveOccurred())
// Use proper URL construction instead of manual escaping
u := &url.URL{
Scheme: "file",
Path: spacedDir,
}
storage := newLocalStorage(*u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(localStorage.u.Path).To(Equal(spacedDir))
})
It("should resolve symlinks when possible", func() {
// Create a real directory and a symlink to it
realDir := filepath.Join(tempDir, "real")
linkDir := filepath.Join(tempDir, "link")
err := os.MkdirAll(realDir, 0755)
Expect(err).ToNot(HaveOccurred())
err = os.Symlink(realDir, linkDir)
Expect(err).ToNot(HaveOccurred())
u, err := url.Parse("file://" + linkDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(localStorage.u.Path).To(Equal(linkDir))
// Check that the resolved path contains the real directory name
Expect(localStorage.resolvedPath).To(ContainSubstring("real"))
})
It("should use u.Path as resolvedPath when symlink resolution fails", func() {
// Use a non-existent path to trigger symlink resolution failure
nonExistentPath := filepath.Join(tempDir, "non-existent")
u, err := url.Parse("file://" + nonExistentPath)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(localStorage.u.Path).To(Equal(nonExistentPath))
Expect(localStorage.resolvedPath).To(Equal(nonExistentPath))
})
})
Context("with Windows path", func() {
BeforeEach(func() {
if runtime.GOOS != "windows" {
Skip("Windows-specific test")
}
})
It("should handle Windows drive letters correctly", func() {
u, err := url.Parse("file://C:/music")
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(localStorage.u.Path).To(Equal("C:/music"))
})
})
refactor(scanner): remove C++ taglib adapter (#5349) * refactor(build): remove CPP taglib adapter Remove the CGO-based TagLib adapter (adapters/taglib/) and all cross-taglib build infrastructure. The WASM-based go-taglib adapter (adapters/gotaglib/) is now the sole metadata extractor. - Delete adapters/taglib/ (CPP/CGO wrapper) - Delete .github/actions/download-taglib/ - Remove CROSS_TAGLIB_VERSION, CGO_CFLAGS_ALLOW, and all taglib-related references from Dockerfile, Makefile, CI pipeline, and devcontainer * fix(scanner): gracefully fallback to default extractor instead of crashing Replace log.Fatal with a graceful fallback when the configured scanner extractor is not found. Instead of terminating the process, the code now warns and falls back to the default taglib extractor using the existing consts.DefaultScannerExtractor constant. A fatal log is retained only for the case where the default extractor itself is not registered, which indicates a broken build. * test(scanner): cover default extractor fallback and suppress redundant warn Address review feedback on the extractor fallback in newLocalStorage: - Only log the "using default" warning when the configured extractor differs from the default, so a broken build (default extractor itself missing) logs only the fatal — not a misleading "falling back" warn followed immediately by the fatal. - Add a unit test that registers a mock under consts.DefaultScannerExtractor, sets the configured extractor to an unknown name, and asserts the local storage is constructed using the default extractor's constructor.
2026-04-12 17:52:29 -08:00
Context("when the configured extractor is not registered", func() {
var defaultExtractor *mockTestExtractor
BeforeEach(func() {
defaultExtractor = &mockTestExtractor{results: make(map[string]metadata.Info)}
RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) Extractor {
return defaultExtractor
})
DeferCleanup(func() {
lock.Lock()
delete(extractors, consts.DefaultScannerExtractor)
lock.Unlock()
})
})
It("falls back to the default extractor instead of crashing", func() {
conf.Server.Scanner.Extractor = "nonexistent-extractor"
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
refactor(scanner): remove C++ taglib adapter (#5349) * refactor(build): remove CPP taglib adapter Remove the CGO-based TagLib adapter (adapters/taglib/) and all cross-taglib build infrastructure. The WASM-based go-taglib adapter (adapters/gotaglib/) is now the sole metadata extractor. - Delete adapters/taglib/ (CPP/CGO wrapper) - Delete .github/actions/download-taglib/ - Remove CROSS_TAGLIB_VERSION, CGO_CFLAGS_ALLOW, and all taglib-related references from Dockerfile, Makefile, CI pipeline, and devcontainer * fix(scanner): gracefully fallback to default extractor instead of crashing Replace log.Fatal with a graceful fallback when the configured scanner extractor is not found. Instead of terminating the process, the code now warns and falls back to the default taglib extractor using the existing consts.DefaultScannerExtractor constant. A fatal log is retained only for the case where the default extractor itself is not registered, which indicates a broken build. * test(scanner): cover default extractor fallback and suppress redundant warn Address review feedback on the extractor fallback in newLocalStorage: - Only log the "using default" warning when the configured extractor differs from the default, so a broken build (default extractor itself missing) logs only the fatal — not a misleading "falling back" warn followed immediately by the fatal. - Add a unit test that registers a mock under consts.DefaultScannerExtractor, sets the configured extractor to an unknown name, and asserts the local storage is constructed using the default extractor's constructor.
2026-04-12 17:52:29 -08:00
ls, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor))
})
})
})
Describe("localStorage.FS", func() {
ci: run Go tests on Windows (#5380) * ci(windows): add skeleton go-windows job (compile-only smoke test) * ci(windows): fix comment to reference Task 7 not Task 6 * ci(windows): harden PATH visibility and set explicit bash shell * ci(windows): enable full go test suite and ndpgen check * test(gotaglib): skip Unix-only permission tests on Windows * test(lyrics): skip Windows-incompatible tests * test(utils): skip Windows-incompatible tests * test(mpv): skip Windows-incompatible playback tests Skip 3 subprocess-execution tests that rely on Unix-style mpv invocation; .bat output includes \r-terminated lines that break argument parsing (#TBD-mpv-windows). * test(storage): skip Windows-incompatible tests Skip relative-path test where filepath.Join uses backslash but the storage implementation returns a forward-slash URL path (#TBD-path-sep-storage). * test(storage/local): skip Windows-incompatible tests Skip 13 tests that fail because url.Parse("file://" + windowsPath) treats the drive letter colon as an invalid port; also skip the Windows drive-letter path test that exposes a backslash vs forward-slash normalisation bug (#TBD-path-sep-storage-local). * test(playlists): skip Windows-incompatible tests * test(model): skip Windows-incompatible tests * test(model/metadata): skip Windows-incompatible tests * test(core): skip Windows-incompatible tests AbsolutePath uses filepath.Join which produces OS-native path separators; skip the assertion test on Windows until the production code is fixed (#TBD-path-sep-core). * test(artwork): skip Windows-incompatible tests Artwork readers produce OS-native path separators on Windows while tests assert forward-slash paths; skip 11 affected tests pending a fix in production code (#TBD-path-sep-artwork). * test(persistence): skip Windows-incompatible tests Skip flaky timestamp comparison (#TBD-flake-persistence) and path-separator real-bugs (#TBD-path-sep-persistence) in FolderRepository.GetFolderUpdateInfo which uses filepath.Clean/os.PathSeparator converting stored forward-slash paths to backslashes on Windows. * test(scanner): skip Windows-incompatible tests Skip symlink tests (Unix-assumption), ndignore path-separator bugs (#TBD-path-sep-scanner) in processLibraryEvents/resolveFolderPath where filepath.Rel/filepath.Split return backslash paths incompatible with fs.FS forward-slash expectations, error message mismatch on Windows, and file format upgrade detection (#TBD-path-sep-scanner). * test(plugins): skip Windows-incompatible tests Add //go:build !windows tags to test files that reference the suite bootstrap (testManager, testdataDir, createTestManager) which is only compiled on non-Windows. Add a Windows-only suite stub that skips all specs via BeforeEach to prevent [build failed] on Windows CI. * test(server): skip Windows-incompatible tests Skip createUnixSocketFile tests that rely on Unix file permission bits (chmod/fchmod) which are not supported on Windows. * test(nativeapi): skip Windows-incompatible tests Skip the i18n JSON validation test that uses filepath.Join to build embedded-FS paths; filepath.Join produces backslashes on Windows which breaks fs.Open (embedded FS always uses forward slashes). * test(e2e): skip Windows-incompatible tests On Windows, SQLite holds file locks that prevent the Ginkgo TempDir DeferCleanup from deleting the DB file. Register an explicit db.Close DeferCleanup (LIFO before TempDir cleanup) on Windows so the file lock is released before the temp directory is removed. * test(windows): fix e2e AfterSuite and skip remaining scanner path test * test(scanner): skip another Windows path-sep test (#TBD-path-sep-scanner) * test(subsonic): skip timing-flaky test on Windows (#TBD-flake-time-resolution-subsonic) * test(scanner): skip 'detects file moved to different folder' on Windows * test(scanner): consolidate 'Library changes' Windows skips into BeforeEach * test(scanner): close DB before TempDir cleanup to fix Windows file lock * test(scanner): skip ScanFolders suite on Windows instead of closing shared DB * ci: retrigger for Windows soak run 2/3 * ci: retrigger for Windows soak run 3/3 * ci: retrigger for Windows soak run 3/3 (take 2) * test(scanner): skip Multi-Library suite on Windows (SQLite file lock) * ci(windows): promote go-windows to blocking status check * test(plugins): run platform-neutral specs on Windows, drop blanket Skip * test(windows): make tests cross-platform instead of skipping - subsonic: back-date submissionTime baseline by 1s so BeTemporally(">") passes under millisecond clock resolution - persistence: sleep briefly between Put calls so UpdatedAt is strictly after CreatedAt on low-resolution clocks - utils/files: close tempFile before os.Remove so the test works on Windows (where an open handle holds a file lock) - tests.TempFile: close the handle before returning; metadata tests no longer leak the open file into Ginkgo's TempDir cleanup Resolves Copilot review comments on #5380. * test(tests): add SkipOnWindows helper to reduce boilerplate Introduces tests.SkipOnWindows(reason) that wraps the 3-line runtime.GOOS guard pattern used in every Windows-skipped spec. * test(adapters): use tests.SkipOnWindows helper * test(core): use tests.SkipOnWindows helper * test(model): use tests.SkipOnWindows helper * test(persistence): use tests.SkipOnWindows helper * test(scanner): use tests.SkipOnWindows helper * test(server): use tests.SkipOnWindows helper * test(plugins): run pure-Go unit tests on Windows config_validation_test, manager_loader_test, and migrate_test have no WASM/exec dependencies and don't rely on the make-built test plugins from plugins_suite_test.go. Let them run on Windows too.
2026-04-19 09:16:47 -08:00
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
})
Context("with existing directory", func() {
It("should return a localFS instance", func() {
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
Expect(musicFS).ToNot(BeNil())
_, ok := musicFS.(*localFS)
Expect(ok).To(BeTrue())
})
})
Context("with non-existent directory", func() {
It("should return an error", func() {
nonExistentPath := filepath.Join(tempDir, "non-existent")
u, err := url.Parse("file://" + nonExistentPath)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
_, err = storage.FS()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(nonExistentPath))
})
})
})
Describe("localFS.ReadTags", func() {
var testFile string
BeforeEach(func() {
ci: run Go tests on Windows (#5380) * ci(windows): add skeleton go-windows job (compile-only smoke test) * ci(windows): fix comment to reference Task 7 not Task 6 * ci(windows): harden PATH visibility and set explicit bash shell * ci(windows): enable full go test suite and ndpgen check * test(gotaglib): skip Unix-only permission tests on Windows * test(lyrics): skip Windows-incompatible tests * test(utils): skip Windows-incompatible tests * test(mpv): skip Windows-incompatible playback tests Skip 3 subprocess-execution tests that rely on Unix-style mpv invocation; .bat output includes \r-terminated lines that break argument parsing (#TBD-mpv-windows). * test(storage): skip Windows-incompatible tests Skip relative-path test where filepath.Join uses backslash but the storage implementation returns a forward-slash URL path (#TBD-path-sep-storage). * test(storage/local): skip Windows-incompatible tests Skip 13 tests that fail because url.Parse("file://" + windowsPath) treats the drive letter colon as an invalid port; also skip the Windows drive-letter path test that exposes a backslash vs forward-slash normalisation bug (#TBD-path-sep-storage-local). * test(playlists): skip Windows-incompatible tests * test(model): skip Windows-incompatible tests * test(model/metadata): skip Windows-incompatible tests * test(core): skip Windows-incompatible tests AbsolutePath uses filepath.Join which produces OS-native path separators; skip the assertion test on Windows until the production code is fixed (#TBD-path-sep-core). * test(artwork): skip Windows-incompatible tests Artwork readers produce OS-native path separators on Windows while tests assert forward-slash paths; skip 11 affected tests pending a fix in production code (#TBD-path-sep-artwork). * test(persistence): skip Windows-incompatible tests Skip flaky timestamp comparison (#TBD-flake-persistence) and path-separator real-bugs (#TBD-path-sep-persistence) in FolderRepository.GetFolderUpdateInfo which uses filepath.Clean/os.PathSeparator converting stored forward-slash paths to backslashes on Windows. * test(scanner): skip Windows-incompatible tests Skip symlink tests (Unix-assumption), ndignore path-separator bugs (#TBD-path-sep-scanner) in processLibraryEvents/resolveFolderPath where filepath.Rel/filepath.Split return backslash paths incompatible with fs.FS forward-slash expectations, error message mismatch on Windows, and file format upgrade detection (#TBD-path-sep-scanner). * test(plugins): skip Windows-incompatible tests Add //go:build !windows tags to test files that reference the suite bootstrap (testManager, testdataDir, createTestManager) which is only compiled on non-Windows. Add a Windows-only suite stub that skips all specs via BeforeEach to prevent [build failed] on Windows CI. * test(server): skip Windows-incompatible tests Skip createUnixSocketFile tests that rely on Unix file permission bits (chmod/fchmod) which are not supported on Windows. * test(nativeapi): skip Windows-incompatible tests Skip the i18n JSON validation test that uses filepath.Join to build embedded-FS paths; filepath.Join produces backslashes on Windows which breaks fs.Open (embedded FS always uses forward slashes). * test(e2e): skip Windows-incompatible tests On Windows, SQLite holds file locks that prevent the Ginkgo TempDir DeferCleanup from deleting the DB file. Register an explicit db.Close DeferCleanup (LIFO before TempDir cleanup) on Windows so the file lock is released before the temp directory is removed. * test(windows): fix e2e AfterSuite and skip remaining scanner path test * test(scanner): skip another Windows path-sep test (#TBD-path-sep-scanner) * test(subsonic): skip timing-flaky test on Windows (#TBD-flake-time-resolution-subsonic) * test(scanner): skip 'detects file moved to different folder' on Windows * test(scanner): consolidate 'Library changes' Windows skips into BeforeEach * test(scanner): close DB before TempDir cleanup to fix Windows file lock * test(scanner): skip ScanFolders suite on Windows instead of closing shared DB * ci: retrigger for Windows soak run 2/3 * ci: retrigger for Windows soak run 3/3 * ci: retrigger for Windows soak run 3/3 (take 2) * test(scanner): skip Multi-Library suite on Windows (SQLite file lock) * ci(windows): promote go-windows to blocking status check * test(plugins): run platform-neutral specs on Windows, drop blanket Skip * test(windows): make tests cross-platform instead of skipping - subsonic: back-date submissionTime baseline by 1s so BeTemporally(">") passes under millisecond clock resolution - persistence: sleep briefly between Put calls so UpdatedAt is strictly after CreatedAt on low-resolution clocks - utils/files: close tempFile before os.Remove so the test works on Windows (where an open handle holds a file lock) - tests.TempFile: close the handle before returning; metadata tests no longer leak the open file into Ginkgo's TempDir cleanup Resolves Copilot review comments on #5380. * test(tests): add SkipOnWindows helper to reduce boilerplate Introduces tests.SkipOnWindows(reason) that wraps the 3-line runtime.GOOS guard pattern used in every Windows-skipped spec. * test(adapters): use tests.SkipOnWindows helper * test(core): use tests.SkipOnWindows helper * test(model): use tests.SkipOnWindows helper * test(persistence): use tests.SkipOnWindows helper * test(scanner): use tests.SkipOnWindows helper * test(server): use tests.SkipOnWindows helper * test(plugins): run pure-Go unit tests on Windows config_validation_test, manager_loader_test, and migrate_test have no WASM/exec dependencies and don't rely on the make-built test plugins from plugins_suite_test.go. Let them run on Windows too.
2026-04-19 09:16:47 -08:00
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
// Create a test file
testFile = filepath.Join(tempDir, "test.mp3")
err := os.WriteFile(testFile, []byte("test data"), 0600)
Expect(err).ToNot(HaveOccurred())
// Reset extractor state
testExtractor.results = make(map[string]metadata.Info)
testExtractor.err = nil
})
Context("when extractor returns complete metadata", func() {
It("should return the metadata as-is", func() {
expectedInfo := metadata.Info{
Tags: map[string][]string{
"title": {"Test Song"},
"artist": {"Test Artist"},
},
AudioProperties: metadata.AudioProperties{
Duration: 180,
BitRate: 320,
},
FileInfo: &testFileInfo{name: "test.mp3"},
}
testExtractor.results["test.mp3"] = expectedInfo
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
results, err := musicFS.ReadTags("test.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveKey("test.mp3"))
Expect(results["test.mp3"]).To(Equal(expectedInfo))
})
})
Context("when extractor returns metadata without FileInfo", func() {
It("should populate FileInfo from filesystem", func() {
incompleteInfo := metadata.Info{
Tags: map[string][]string{
"title": {"Test Song"},
},
FileInfo: nil, // Missing FileInfo
}
testExtractor.results["test.mp3"] = incompleteInfo
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
results, err := musicFS.ReadTags("test.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveKey("test.mp3"))
result := results["test.mp3"]
Expect(result.FileInfo).ToNot(BeNil())
Expect(result.FileInfo.Name()).To(Equal("test.mp3"))
// Should be wrapped in localFileInfo
_, ok := result.FileInfo.(localFileInfo)
Expect(ok).To(BeTrue())
})
})
Context("when filesystem stat fails", func() {
It("should return an error", func() {
incompleteInfo := metadata.Info{
Tags: map[string][]string{"title": {"Test Song"}},
FileInfo: nil,
}
testExtractor.results["non-existent.mp3"] = incompleteInfo
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
_, err = musicFS.ReadTags("non-existent.mp3")
Expect(err).To(HaveOccurred())
})
})
Context("when extractor fails", func() {
It("should return the extractor error", func() {
testExtractor.err = &extractorError{message: "extractor failed"}
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
_, err = musicFS.ReadTags("test.mp3")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("extractor failed"))
})
})
Context("with multiple files", func() {
It("should process all files correctly", func() {
// Create another test file
testFile2 := filepath.Join(tempDir, "test2.mp3")
err := os.WriteFile(testFile2, []byte("test data 2"), 0600)
Expect(err).ToNot(HaveOccurred())
info1 := metadata.Info{
Tags: map[string][]string{"title": {"Song 1"}},
FileInfo: &testFileInfo{name: "test.mp3"},
}
info2 := metadata.Info{
Tags: map[string][]string{"title": {"Song 2"}},
FileInfo: nil, // This one needs FileInfo populated
}
testExtractor.results["test.mp3"] = info1
testExtractor.results["test2.mp3"] = info2
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
results, err := musicFS.ReadTags("test.mp3", "test2.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2))
Expect(results["test.mp3"].FileInfo).To(Equal(&testFileInfo{name: "test.mp3"}))
Expect(results["test2.mp3"].FileInfo).ToNot(BeNil())
Expect(results["test2.mp3"].FileInfo.Name()).To(Equal("test2.mp3"))
})
})
})
Describe("localFileInfo", func() {
var testFile string
var fileInfo fs.FileInfo
BeforeEach(func() {
testFile = filepath.Join(tempDir, "test.mp3")
err := os.WriteFile(testFile, []byte("test data"), 0600)
Expect(err).ToNot(HaveOccurred())
fileInfo, err = os.Stat(testFile)
Expect(err).ToNot(HaveOccurred())
})
Describe("BirthTime", func() {
It("should return birth time when available", func() {
lfi := localFileInfo{FileInfo: fileInfo}
birthTime := lfi.BirthTime()
// Birth time should be a valid time (not zero value)
Expect(birthTime).ToNot(BeZero())
// Should be around the current time (within last few minutes)
Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute))
})
})
It("should delegate all other FileInfo methods", func() {
lfi := localFileInfo{FileInfo: fileInfo}
Expect(lfi.Name()).To(Equal(fileInfo.Name()))
Expect(lfi.Size()).To(Equal(fileInfo.Size()))
Expect(lfi.Mode()).To(Equal(fileInfo.Mode()))
Expect(lfi.ModTime()).To(Equal(fileInfo.ModTime()))
Expect(lfi.IsDir()).To(Equal(fileInfo.IsDir()))
Expect(lfi.Sys()).To(Equal(fileInfo.Sys()))
})
})
Describe("Storage registration", func() {
It("should register localStorage for file scheme", func() {
ci: run Go tests on Windows (#5380) * ci(windows): add skeleton go-windows job (compile-only smoke test) * ci(windows): fix comment to reference Task 7 not Task 6 * ci(windows): harden PATH visibility and set explicit bash shell * ci(windows): enable full go test suite and ndpgen check * test(gotaglib): skip Unix-only permission tests on Windows * test(lyrics): skip Windows-incompatible tests * test(utils): skip Windows-incompatible tests * test(mpv): skip Windows-incompatible playback tests Skip 3 subprocess-execution tests that rely on Unix-style mpv invocation; .bat output includes \r-terminated lines that break argument parsing (#TBD-mpv-windows). * test(storage): skip Windows-incompatible tests Skip relative-path test where filepath.Join uses backslash but the storage implementation returns a forward-slash URL path (#TBD-path-sep-storage). * test(storage/local): skip Windows-incompatible tests Skip 13 tests that fail because url.Parse("file://" + windowsPath) treats the drive letter colon as an invalid port; also skip the Windows drive-letter path test that exposes a backslash vs forward-slash normalisation bug (#TBD-path-sep-storage-local). * test(playlists): skip Windows-incompatible tests * test(model): skip Windows-incompatible tests * test(model/metadata): skip Windows-incompatible tests * test(core): skip Windows-incompatible tests AbsolutePath uses filepath.Join which produces OS-native path separators; skip the assertion test on Windows until the production code is fixed (#TBD-path-sep-core). * test(artwork): skip Windows-incompatible tests Artwork readers produce OS-native path separators on Windows while tests assert forward-slash paths; skip 11 affected tests pending a fix in production code (#TBD-path-sep-artwork). * test(persistence): skip Windows-incompatible tests Skip flaky timestamp comparison (#TBD-flake-persistence) and path-separator real-bugs (#TBD-path-sep-persistence) in FolderRepository.GetFolderUpdateInfo which uses filepath.Clean/os.PathSeparator converting stored forward-slash paths to backslashes on Windows. * test(scanner): skip Windows-incompatible tests Skip symlink tests (Unix-assumption), ndignore path-separator bugs (#TBD-path-sep-scanner) in processLibraryEvents/resolveFolderPath where filepath.Rel/filepath.Split return backslash paths incompatible with fs.FS forward-slash expectations, error message mismatch on Windows, and file format upgrade detection (#TBD-path-sep-scanner). * test(plugins): skip Windows-incompatible tests Add //go:build !windows tags to test files that reference the suite bootstrap (testManager, testdataDir, createTestManager) which is only compiled on non-Windows. Add a Windows-only suite stub that skips all specs via BeforeEach to prevent [build failed] on Windows CI. * test(server): skip Windows-incompatible tests Skip createUnixSocketFile tests that rely on Unix file permission bits (chmod/fchmod) which are not supported on Windows. * test(nativeapi): skip Windows-incompatible tests Skip the i18n JSON validation test that uses filepath.Join to build embedded-FS paths; filepath.Join produces backslashes on Windows which breaks fs.Open (embedded FS always uses forward slashes). * test(e2e): skip Windows-incompatible tests On Windows, SQLite holds file locks that prevent the Ginkgo TempDir DeferCleanup from deleting the DB file. Register an explicit db.Close DeferCleanup (LIFO before TempDir cleanup) on Windows so the file lock is released before the temp directory is removed. * test(windows): fix e2e AfterSuite and skip remaining scanner path test * test(scanner): skip another Windows path-sep test (#TBD-path-sep-scanner) * test(subsonic): skip timing-flaky test on Windows (#TBD-flake-time-resolution-subsonic) * test(scanner): skip 'detects file moved to different folder' on Windows * test(scanner): consolidate 'Library changes' Windows skips into BeforeEach * test(scanner): close DB before TempDir cleanup to fix Windows file lock * test(scanner): skip ScanFolders suite on Windows instead of closing shared DB * ci: retrigger for Windows soak run 2/3 * ci: retrigger for Windows soak run 3/3 * ci: retrigger for Windows soak run 3/3 (take 2) * test(scanner): skip Multi-Library suite on Windows (SQLite file lock) * ci(windows): promote go-windows to blocking status check * test(plugins): run platform-neutral specs on Windows, drop blanket Skip * test(windows): make tests cross-platform instead of skipping - subsonic: back-date submissionTime baseline by 1s so BeTemporally(">") passes under millisecond clock resolution - persistence: sleep briefly between Put calls so UpdatedAt is strictly after CreatedAt on low-resolution clocks - utils/files: close tempFile before os.Remove so the test works on Windows (where an open handle holds a file lock) - tests.TempFile: close the handle before returning; metadata tests no longer leak the open file into Ginkgo's TempDir cleanup Resolves Copilot review comments on #5380. * test(tests): add SkipOnWindows helper to reduce boilerplate Introduces tests.SkipOnWindows(reason) that wraps the 3-line runtime.GOOS guard pattern used in every Windows-skipped spec. * test(adapters): use tests.SkipOnWindows helper * test(core): use tests.SkipOnWindows helper * test(model): use tests.SkipOnWindows helper * test(persistence): use tests.SkipOnWindows helper * test(scanner): use tests.SkipOnWindows helper * test(server): use tests.SkipOnWindows helper * test(plugins): run pure-Go unit tests on Windows config_validation_test, manager_loader_test, and migrate_test have no WASM/exec dependencies and don't rely on the make-built test plugins from plugins_suite_test.go. Let them run on Windows too.
2026-04-19 09:16:47 -08:00
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
// This tests the init() function indirectly
storage, err := storage.For("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
Expect(storage).To(BeAssignableToTypeOf(&localStorage{}))
})
})
})
// Test extractor for testing
type mockTestExtractor struct {
results map[string]metadata.Info
err error
}
func (m *mockTestExtractor) Parse(files ...string) (map[string]metadata.Info, error) {
if m.err != nil {
return nil, m.err
}
result := make(map[string]metadata.Info)
for _, file := range files {
if info, exists := m.results[file]; exists {
result[file] = info
}
}
return result, nil
}
func (m *mockTestExtractor) Version() string {
return "test-1.0"
}
type extractorError struct {
message string
}
func (e *extractorError) Error() string {
return e.message
}
// Test FileInfo that implements metadata.FileInfo
type testFileInfo struct {
name string
size int64
mode fs.FileMode
modTime time.Time
isDir bool
birthTime time.Time
}
func (t *testFileInfo) Name() string { return t.name }
func (t *testFileInfo) Size() int64 { return t.size }
func (t *testFileInfo) Mode() fs.FileMode { return t.mode }
func (t *testFileInfo) ModTime() time.Time { return t.modTime }
func (t *testFileInfo) IsDir() bool { return t.isDir }
func (t *testFileInfo) Sys() any { return nil }
func (t *testFileInfo) BirthTime() time.Time {
if t.birthTime.IsZero() {
return time.Now()
}
return t.birthTime
}