mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Backend: Improve Copy()/Move() and increase pkg/internal test coverage
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -117,7 +117,10 @@ func PhotoUnstack(router *gin.RouterGroup) {
|
||||
|
||||
destName := fmt.Sprintf("%s.%s%s", unstackFile.AbsPrefix(false), unstackFile.Checksum(), unstackFile.Extension())
|
||||
|
||||
if err := unstackFile.Move(destName); err != nil {
|
||||
// MediaFile.Move/Copy allow replacing an existing empty destination file
|
||||
// without force, but will not overwrite non-empty files.
|
||||
if moveErr := unstackFile.Move(destName, true); moveErr != nil {
|
||||
log.Error(moveErr)
|
||||
log.Errorf("photo: cannot rename %s to %s (unstack)", clean.Log(unstackFile.BaseName()), clean.Log(filepath.Base(destName)))
|
||||
AbortUnexpectedError(c)
|
||||
return
|
||||
@@ -134,8 +137,8 @@ func PhotoUnstack(router *gin.RouterGroup) {
|
||||
newPhoto.PhotoPath = unstackFile.RootRelPath()
|
||||
newPhoto.PhotoName = unstackFile.BasePrefix(false)
|
||||
|
||||
if err := newPhoto.Create(); err != nil {
|
||||
log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(baseName))
|
||||
if createErr := newPhoto.Create(); createErr != nil {
|
||||
log.Errorf("photo: %s (unstack %s)", createErr.Error(), clean.Log(baseName))
|
||||
AbortSaveFailed(c)
|
||||
return
|
||||
}
|
||||
@@ -149,23 +152,26 @@ func PhotoUnstack(router *gin.RouterGroup) {
|
||||
relRoot = file.FileRoot
|
||||
}
|
||||
|
||||
if err := entity.UnscopedDb().Exec(`UPDATE files
|
||||
if updateErr := entity.UnscopedDb().Exec(`UPDATE files
|
||||
SET photo_id = ?, photo_uid = ?, file_name = ?, file_missing = 0
|
||||
WHERE file_name = ? AND file_root = ?`,
|
||||
newPhoto.ID, newPhoto.PhotoUID, r.RootRelName(),
|
||||
relName, relRoot).Error; err != nil {
|
||||
relName, relRoot).Error; updateErr != nil {
|
||||
// Handle error...
|
||||
log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName()))
|
||||
log.Errorf("photo: %s (unstack %s)", updateErr.Error(), clean.Log(r.BaseName()))
|
||||
|
||||
// Remove new photo from index.
|
||||
if _, err := newPhoto.Delete(true); err != nil {
|
||||
log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName()))
|
||||
if _, deleteErr := newPhoto.Delete(true); deleteErr != nil {
|
||||
log.Errorf("photo: %s (unstack %s)", deleteErr.Error(), clean.Log(r.BaseName()))
|
||||
}
|
||||
|
||||
// Revert file rename.
|
||||
if unstackSingle {
|
||||
if err := r.Move(photoprism.FileName(relRoot, relName)); err != nil {
|
||||
log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName()))
|
||||
// MediaFile.Move/Copy allow replacing an existing empty destination file
|
||||
// without force, but will not overwrite non-empty files.
|
||||
if moveErr := r.Move(photoprism.FileName(relRoot, relName), true); moveErr != nil {
|
||||
log.Error(moveErr)
|
||||
log.Errorf("photo: file name could not be reverted (unstack %s)", clean.Log(r.BaseName()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +190,8 @@ func PhotoUnstack(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Reset type for existing photo stack to image.
|
||||
if err := stackPhoto.Update("PhotoType", entity.MediaImage); err != nil {
|
||||
log.Errorf("photo: %s (unstack %s)", err, clean.Log(baseName))
|
||||
if updateErr := stackPhoto.Update("PhotoType", entity.MediaImage); updateErr != nil {
|
||||
log.Errorf("photo: %s (unstack %s)", updateErr, clean.Log(baseName))
|
||||
AbortUnexpectedError(c)
|
||||
return
|
||||
}
|
||||
|
@@ -316,18 +316,26 @@ func CliTestContext() *cli.Context {
|
||||
func (c *Config) RemoveTestData() error {
|
||||
if err := os.RemoveAll(c.ImportPath()); err != nil {
|
||||
return err
|
||||
} else if err = fs.MkdirAll(c.ImportPath()); err != nil {
|
||||
log.Warnf("testdata: %s (mkdir)", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(c.TempPath()); err != nil {
|
||||
return err
|
||||
} else if err = fs.MkdirAll(c.TempPath()); err != nil {
|
||||
log.Warnf("testdata: %s (mkdir)", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(c.OriginalsPath()); err != nil {
|
||||
return err
|
||||
} else if err = fs.MkdirAll(c.OriginalsPath()); err != nil {
|
||||
log.Warnf("testdata: %s (mkdir)", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(c.CachePath()); err != nil {
|
||||
log.Warnf("test: %s (remove cache)", err)
|
||||
} else if err = fs.MkdirAll(c.CachePath()); err != nil {
|
||||
log.Warnf("testdata: %s (mkdir)", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@@ -26,7 +26,7 @@ func TestDialectSQLite3(t *testing.T) {
|
||||
_ = os.Remove(dumpName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
} else if err = fs.Copy(testDbOriginal, dumpName); err != nil {
|
||||
} else if err = fs.Copy(testDbOriginal, dumpName, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(dumpName)
|
||||
|
19
internal/ffmpeg/apple/avc_test.go
Normal file
19
internal/ffmpeg/apple/avc_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
)
|
||||
|
||||
func TestApple_TranscodeToAvcCmd_Format(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.AppleAvc, 1500, encode.DefaultQuality, encode.PresetFast, "", "0:v:0", "0:a:0?")
|
||||
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
|
||||
s := cmd.String()
|
||||
assert.True(t, strings.Contains(s, "-c:v h264_videotoolbox"))
|
||||
assert.True(t, strings.Contains(s, "-profile high -level 51 -q:v "))
|
||||
assert.True(t, strings.Contains(s, "-movflags use_metadata_tags+faststart -map_metadata 0 "))
|
||||
}
|
41
internal/ffmpeg/extract_image_cmd_negative_test.go
Normal file
41
internal/ffmpeg/extract_image_cmd_negative_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// Negative: destination directory is unwritable, ffmpeg should fail to write.
|
||||
func TestExtractImageCmd_UnwritableDest(t *testing.T) {
|
||||
opt := encode.NewPreviewImageOptions("/usr/bin/ffmpeg", time.Second*1)
|
||||
srcName := fs.Abs("./testdata/25fps.vp9")
|
||||
dir := t.TempDir()
|
||||
unwritable := filepath.Join(dir, "nope")
|
||||
if err := os.MkdirAll(unwritable, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(unwritable, 0o755)
|
||||
|
||||
destName := filepath.Join(unwritable, "frame.jpg")
|
||||
cmd := ExtractImageCmd(srcName, destName, opt)
|
||||
err := cmd.Run()
|
||||
assert.Error(t, err)
|
||||
assert.NoFileExists(t, destName)
|
||||
}
|
||||
|
||||
// Negative: ffmpeg binary is missing; command execution should error immediately.
|
||||
func TestExtractImageCmd_MissingBinary(t *testing.T) {
|
||||
opt := encode.NewPreviewImageOptions("/path/does/not/exist/ffmpeg", time.Second*1)
|
||||
srcName := fs.Abs("./testdata/25fps.vp9")
|
||||
destName := filepath.Join(t.TempDir(), "frame.jpg")
|
||||
cmd := ExtractImageCmd(srcName, destName, opt)
|
||||
err := cmd.Run()
|
||||
assert.Error(t, err)
|
||||
}
|
8
internal/ffmpeg/ffmpeg_test.go
Normal file
8
internal/ffmpeg/ffmpeg_test.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package ffmpeg
|
||||
|
||||
import "os"
|
||||
|
||||
// Ensure that vendor-specific HW-accelerated test runs are disabled unless explicitly set.
|
||||
func init() {
|
||||
_ = os.Unsetenv("PHOTOPRISM_FFMPEG_ENCODER")
|
||||
}
|
26
internal/ffmpeg/intel/avc_test.go
Normal file
26
internal/ffmpeg/intel/avc_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package intel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
)
|
||||
|
||||
func TestIntel_TranscodeToAvcCmd_WithDevice(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.IntelAvc, 1500, encode.DefaultQuality, encode.PresetFast, "/dev/dri/renderD128", "0:v:0", "0:a:0?")
|
||||
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
|
||||
s := cmd.String()
|
||||
assert.True(t, strings.Contains(s, "-hwaccel qsv -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format qsv"))
|
||||
assert.True(t, strings.Contains(s, "-c:v h264_qsv"))
|
||||
assert.True(t, strings.Contains(s, "-preset fast -global_quality 25"))
|
||||
}
|
||||
|
||||
func TestIntel_TranscodeToAvcCmd_NoDevice(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.IntelAvc, 1500, encode.DefaultQuality, encode.PresetFast, "", "0:v:0", "0:a:0?")
|
||||
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
|
||||
s := cmd.String()
|
||||
assert.True(t, strings.Contains(s, "-hwaccel qsv -hwaccel_output_format qsv"))
|
||||
}
|
19
internal/ffmpeg/nvidia/avc_test.go
Normal file
19
internal/ffmpeg/nvidia/avc_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package nvidia
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
)
|
||||
|
||||
func TestNvidia_TranscodeToAvcCmd_Format(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.NvidiaAvc, 1500, encode.DefaultQuality, encode.PresetFast, "", "0:v:0", "0:a:0?")
|
||||
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
|
||||
s := cmd.String()
|
||||
assert.True(t, strings.Contains(s, "-c:v h264_nvenc"))
|
||||
assert.True(t, strings.Contains(s, "-gpu any"))
|
||||
assert.True(t, strings.Contains(s, "-rc:v constqp -cq 25"))
|
||||
}
|
@@ -2,6 +2,7 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -40,7 +41,7 @@ func TestRemuxFile(t *testing.T) {
|
||||
_ = os.Remove(destName)
|
||||
}()
|
||||
|
||||
if err := fs.Copy(origName, srcName); err != nil {
|
||||
if err := fs.Copy(origName, srcName, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ func TestRemuxCmd(t *testing.T) {
|
||||
_ = os.Remove(destName)
|
||||
}()
|
||||
|
||||
if err := fs.Copy(origName, srcName); err != nil {
|
||||
if err := fs.Copy(origName, srcName, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -98,3 +99,66 @@ func TestRemuxCmd(t *testing.T) {
|
||||
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -avoid_negative_ts make_zero -i SRC -map 0:v:0 -map 0:a:0? -dn -ignore_unknown -codec copy -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 DEST", cmdStr)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemuxFile_DestExists_NoForce_NoOp(t *testing.T) {
|
||||
opt := encode.NewRemuxOptions("/usr/bin/ffmpeg", fs.VideoMp4, false)
|
||||
dir := fs.Abs("./testdata")
|
||||
src := filepath.Join(dir, "30fps.mov")
|
||||
dest := filepath.Join(dir, "already-there.mp4")
|
||||
// Create a tiny placeholder dest file
|
||||
_ = os.Remove(dest)
|
||||
if err := os.WriteFile(dest, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(dest)
|
||||
// Should be a no-op and return nil (dest exists, no force)
|
||||
err := RemuxFile(src, dest, opt)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, dest)
|
||||
}
|
||||
|
||||
func TestRemuxFile_TempExists_NoForce_Error(t *testing.T) {
|
||||
opt := encode.NewRemuxOptions("/usr/bin/ffmpeg", fs.VideoMp4, false)
|
||||
dir := fs.Abs("./testdata")
|
||||
// Use a copy to avoid modifying the original during test
|
||||
src := filepath.Join(dir, "30fps.remux-temp.mov")
|
||||
orig := filepath.Join(dir, "30fps.mov")
|
||||
dest := filepath.Join(dir, "30fps.remux-temp.mp4")
|
||||
temp := filepath.Join(dir, ".30fps.remux-temp.mp4")
|
||||
// Cleanup
|
||||
_ = os.Remove(src)
|
||||
_ = os.Remove(dest)
|
||||
_ = os.Remove(temp)
|
||||
defer func() { _ = os.Remove(src); _ = os.Remove(dest); _ = os.Remove(temp) }()
|
||||
// Prepare src and temp conflict
|
||||
if err := fs.Copy(orig, src, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(temp, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := RemuxFile(src, dest, opt)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "temp file")
|
||||
}
|
||||
|
||||
func TestRemuxCmd_ErrorPaths_And_DefaultBin(t *testing.T) {
|
||||
// Same source/dest error
|
||||
opt := encode.NewRemuxOptions("", fs.VideoMp4, false)
|
||||
_, err := RemuxCmd("file.mp4", "file.mp4", opt)
|
||||
assert.Error(t, err)
|
||||
// Non-existent src
|
||||
_, err = RemuxCmd("./testdata/does-not-exist.mp4", "out.mp4", opt)
|
||||
assert.Error(t, err)
|
||||
// Default ffmpeg bin selected when empty
|
||||
// Use an existing file to pass validation
|
||||
src := fs.Abs("./testdata/30fps.mov")
|
||||
dest := fs.Abs("./testdata/30fps.default-bin.mp4")
|
||||
_ = os.Remove(dest)
|
||||
defer os.Remove(dest)
|
||||
cmd, err := RemuxCmd(src, dest, opt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Contains(t, cmd.String(), "ffmpeg ")
|
||||
}
|
||||
|
47
internal/ffmpeg/transcode_cmd_negative_test.go
Normal file
47
internal/ffmpeg/transcode_cmd_negative_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// Negative: destination directory is unwritable; running ffmpeg should fail.
|
||||
func TestTranscodeCmd_UnwritableDest(t *testing.T) {
|
||||
ffmpegBin := "/usr/bin/ffmpeg"
|
||||
opt := encode.NewVideoOptions(ffmpegBin, encode.SoftwareAvc, 640, encode.DefaultQuality, encode.PresetFast, "", "0:v:0", "0:a:0?")
|
||||
srcName := fs.Abs("./testdata/25fps.vp9")
|
||||
dir := t.TempDir()
|
||||
unwritable := filepath.Join(dir, "nope")
|
||||
if err := os.MkdirAll(unwritable, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(unwritable, 0o755)
|
||||
destName := filepath.Join(unwritable, "out.mp4")
|
||||
|
||||
cmd, _, err := TranscodeCmd(srcName, destName, opt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = cmd.Run()
|
||||
assert.Error(t, err)
|
||||
assert.NoFileExists(t, destName)
|
||||
}
|
||||
|
||||
// Negative: missing ffmpeg binary should cause execution error.
|
||||
func TestTranscodeCmd_MissingBinary(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/path/does/not/exist/ffmpeg", encode.SoftwareAvc, 640, encode.DefaultQuality, encode.PresetFast, "", "0:v:0", "0:a:0?")
|
||||
srcName := fs.Abs("./testdata/25fps.vp9")
|
||||
destName := filepath.Join(t.TempDir(), "out.mp4")
|
||||
cmd, _, err := TranscodeCmd(srcName, destName, opt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = cmd.Run()
|
||||
assert.Error(t, err)
|
||||
}
|
18
internal/ffmpeg/v4l/avc_test.go
Normal file
18
internal/ffmpeg/v4l/avc_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package v4l
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
)
|
||||
|
||||
func TestV4L_TranscodeToAvcCmd_Format(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.V4LAvc, 1500, encode.DefaultQuality, encode.PresetFast, "", "0:v:0", "0:a:0?")
|
||||
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
|
||||
s := cmd.String()
|
||||
assert.True(t, strings.Contains(s, "-c:v h264_v4l2m2m"))
|
||||
assert.True(t, strings.Contains(s, "-num_output_buffers 72 -num_capture_buffers 64"))
|
||||
}
|
26
internal/ffmpeg/vaapi/avc_test.go
Normal file
26
internal/ffmpeg/vaapi/avc_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package vaapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
)
|
||||
|
||||
func TestVaapi_TranscodeToAvcCmd_WithDevice(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.VaapiAvc, 1500, encode.DefaultQuality, encode.PresetFast, "/dev/dri/renderD128", "0:v:0", "0:a:0?")
|
||||
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
|
||||
s := cmd.String()
|
||||
assert.True(t, strings.Contains(s, "-hwaccel vaapi -hwaccel_device /dev/dri/renderD128"))
|
||||
assert.True(t, strings.Contains(s, "-c:v h264_vaapi"))
|
||||
assert.True(t, strings.Contains(s, "-qp 25"))
|
||||
}
|
||||
|
||||
func TestVaapi_TranscodeToAvcCmd_NoDevice(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.VaapiAvc, 1500, encode.DefaultQuality, encode.PresetFast, "", "0:v:0", "0:a:0?")
|
||||
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
|
||||
s := cmd.String()
|
||||
assert.True(t, strings.Contains(s, "-hwaccel vaapi"))
|
||||
}
|
@@ -92,16 +92,16 @@ func ImportWorker(jobs <-chan ImportJob) {
|
||||
}
|
||||
|
||||
if opt.Move {
|
||||
if moveErr := f.Move(destFileName); moveErr != nil {
|
||||
if moveErr := f.Move(destFileName, false); moveErr != nil {
|
||||
logRelName := clean.Log(fs.RelName(destMainFileName, imp.originalsPath()))
|
||||
log.Debugf("import: %s", clean.Error(moveErr))
|
||||
log.Warnf("import: failed moving file to %s, is another import running at the same time?", logRelName)
|
||||
log.Error(moveErr)
|
||||
log.Warnf("import: could not move file to %s, is another import running?", logRelName)
|
||||
}
|
||||
} else {
|
||||
if copyErr := f.Copy(destFileName); copyErr != nil {
|
||||
if copyErr := f.Copy(destFileName, false); copyErr != nil {
|
||||
logRelName := clean.Log(fs.RelName(destMainFileName, imp.originalsPath()))
|
||||
log.Debugf("import: %s", clean.Error(copyErr))
|
||||
log.Warnf("import: failed copying file to %s, is another import running at the same time?", logRelName)
|
||||
log.Error(copyErr)
|
||||
log.Warnf("import: could not copy file to %s, is another import running?", logRelName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@@ -40,8 +40,8 @@ func TestIndexRelated(t *testing.T) {
|
||||
for _, f := range testRelated.Files {
|
||||
dest := filepath.Join(testPath, f.BaseName())
|
||||
|
||||
if err := f.Copy(dest); err != nil {
|
||||
t.Fatalf("copying test file failed: %s", err)
|
||||
if copyErr := f.Copy(dest, false); copyErr != nil {
|
||||
t.Fatalf("copying test file failed: %s", copyErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ func TestIndexRelated(t *testing.T) {
|
||||
for _, f := range testRelated.Files {
|
||||
dest := filepath.Join(testPath, f.BaseName())
|
||||
|
||||
if err := f.Copy(dest); err != nil {
|
||||
t.Fatalf("copying test file failed: %s", err)
|
||||
if copyErr := f.Copy(dest, false); copyErr != nil {
|
||||
t.Fatal(copyErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -622,58 +622,123 @@ func (m *MediaFile) HasSameName(f *MediaFile) bool {
|
||||
}
|
||||
|
||||
// Move file to a new destination with the filename provided in parameter.
|
||||
func (m *MediaFile) Move(dest string) error {
|
||||
destName := filepath.Base(dest)
|
||||
destDir := filepath.Dir(dest)
|
||||
func (m *MediaFile) Move(filePath string, force bool) (err error) {
|
||||
// Check for obviously empty or invalid file paths.
|
||||
if filePath == "" || filePath == "." || filePath == ".." {
|
||||
return errors.New("move: invalid destination file path")
|
||||
}
|
||||
|
||||
// Check destination filename and create path if it does not exist yet.
|
||||
if destName == "" {
|
||||
return errors.New("move: invalid destination filename")
|
||||
} else if destDir == "" {
|
||||
// Check whether a destination file
|
||||
// and directory name are specified.
|
||||
if filepath.Base(filePath) == "" {
|
||||
return errors.New("move: invalid destination name")
|
||||
} else if filepath.Dir(filePath) == "" {
|
||||
return errors.New("move: invalid destination path")
|
||||
} else if err := fs.MkdirAll(destDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve absolute destination file path
|
||||
// and return an error if unsuccessful.
|
||||
if filePath, err = filepath.Abs(filePath); err != nil {
|
||||
return fmt.Errorf("move: could not resolve destination file path (%s)", err)
|
||||
}
|
||||
|
||||
destName := filepath.Base(filePath)
|
||||
logName := clean.Log(destName)
|
||||
destDir := filepath.Dir(filePath)
|
||||
|
||||
// Error if source and destination file path are the same.
|
||||
if filePath == m.FileName() {
|
||||
return fmt.Errorf("move: cannot overwrite file %s with itself", logName)
|
||||
}
|
||||
|
||||
// Error if destination exists (and is not empty) without the force flag being used.
|
||||
if fs.Exists(filePath) {
|
||||
if fs.FileExistsIsEmpty(filePath) {
|
||||
log.Infof("move: replacing empty destination file %s", logName)
|
||||
} else if force {
|
||||
log.Warnf("move: overwriting destination file %s", logName)
|
||||
} else {
|
||||
return fmt.Errorf("move: destination name %s already exists", logName)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the target directory exists.
|
||||
if err = fs.MkdirAll(destDir); err != nil {
|
||||
return fmt.Errorf("move: could not create target directory (%s)", err)
|
||||
}
|
||||
|
||||
// Remember file modification time.
|
||||
modTime := m.ModTime()
|
||||
|
||||
// First try to rename existing file as that's faster than copying it and then deleting the original.
|
||||
if err := os.Rename(m.fileName, dest); err != nil {
|
||||
log.Tracef("move: cannot rename %s, fallback to copy and delete (%s)", clean.Log(destName), clean.Error(err))
|
||||
// First try to rename existing file as that's faster
|
||||
// than copying it and then deleting the original.
|
||||
if renameErr := os.Rename(m.fileName, filePath); renameErr != nil {
|
||||
log.Tracef("move: cannot rename %s, fallback to copy and delete (%s)", clean.Log(destName), clean.Error(renameErr))
|
||||
} else {
|
||||
m.SetFileName(dest)
|
||||
m.SetFileName(filePath)
|
||||
m.SetModTime(modTime)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// If renaming is not possible, copy the file and then delete the original.
|
||||
if err := m.Copy(dest); err != nil {
|
||||
return err
|
||||
// If renaming the file is not possible, copy its
|
||||
// contents and then delete the original file.
|
||||
if copyErr := m.Copy(filePath, force); copyErr != nil {
|
||||
return fmt.Errorf("%s (move fallback)", copyErr)
|
||||
}
|
||||
|
||||
if err := os.Remove(m.fileName); err != nil {
|
||||
return err
|
||||
if rmErr := os.Remove(m.fileName); rmErr != nil {
|
||||
return fmt.Errorf("move: %s", rmErr)
|
||||
}
|
||||
|
||||
m.SetFileName(dest)
|
||||
m.SetFileName(filePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy a MediaFile to another file by destinationFilename.
|
||||
func (m *MediaFile) Copy(dest string) error {
|
||||
destName := filepath.Base(dest)
|
||||
destDir := filepath.Dir(dest)
|
||||
// Copy copies the file contents to the specified destination.
|
||||
// It only overwrites existing files when the force flag is used.
|
||||
func (m *MediaFile) Copy(filePath string, force bool) (err error) {
|
||||
// Check for obviously empty or invalid file paths.
|
||||
if filePath == "" || filePath == "." || filePath == ".." {
|
||||
return errors.New("copy: invalid destination file path")
|
||||
}
|
||||
|
||||
// Check destination filename and create path if it does not exist yet.
|
||||
if destName == "" {
|
||||
return errors.New("copy: invalid destination filename")
|
||||
} else if destDir == "" {
|
||||
// Check whether a destination file and directory name are specified.
|
||||
if filepath.Base(filePath) == "" {
|
||||
return errors.New("copy: invalid destination name")
|
||||
} else if filepath.Dir(filePath) == "" {
|
||||
return errors.New("copy: invalid destination path")
|
||||
} else if err := fs.MkdirAll(destDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve absolute destination file path and return an error if unsuccessful.
|
||||
if filePath, err = filepath.Abs(filePath); err != nil {
|
||||
return fmt.Errorf("copy: could not resolve destination file path (%s)", err)
|
||||
}
|
||||
|
||||
destName := filepath.Base(filePath)
|
||||
logName := clean.Log(destName)
|
||||
destDir := filepath.Dir(filePath)
|
||||
|
||||
// Error if source and destination file path are the same.
|
||||
if filePath == m.FileName() {
|
||||
return fmt.Errorf("copy: cannot overwrite file %s with itself", logName)
|
||||
}
|
||||
|
||||
// Error if destination exists (and is not empty) without the force flag being used.
|
||||
if fs.Exists(filePath) {
|
||||
if fs.FileExistsIsEmpty(filePath) {
|
||||
log.Infof("copy: replacing empty destination file %s", logName)
|
||||
} else if force {
|
||||
log.Warnf("copy: overwriting destination file %s", logName)
|
||||
} else {
|
||||
return fmt.Errorf("copy: destination name %s already exists", logName)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the target directory exists.
|
||||
if err = fs.MkdirAll(destDir); err != nil {
|
||||
return fmt.Errorf("copy: could not create target directory (%s)", err)
|
||||
}
|
||||
|
||||
m.fileMutex.Lock()
|
||||
@@ -682,32 +747,33 @@ func (m *MediaFile) Copy(dest string) error {
|
||||
thisFile, err := m.openFile()
|
||||
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
return err
|
||||
return fmt.Errorf("copy: source file %s cannot be opened (%s)", m.BaseName(), err)
|
||||
}
|
||||
|
||||
defer thisFile.Close()
|
||||
|
||||
destFile, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE, fs.ModeFile)
|
||||
// Open the target file path for writing, discarding any trailing bytes.
|
||||
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fs.ModeFile)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
return err
|
||||
return fmt.Errorf("copy: destination file %s cannot be opened (%s)", logName, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Update the file timestamp after the file has been copied and closed.
|
||||
if err = destFile.Close(); err != nil {
|
||||
log.Debugf("copy: failed to close %s (%s)", clean.Log(destName), clean.Error(err))
|
||||
} else if err = os.Chtimes(dest, time.Time{}, m.ModTime()); err != nil {
|
||||
log.Debugf("copy: failed to set mtime for %s (%s)", clean.Log(destName), clean.Error(err))
|
||||
log.Debugf("copy: could not close destination file %s (%s)", logName, clean.Error(err))
|
||||
} else if err = os.Chtimes(filePath, time.Time{}, m.ModTime()); err != nil {
|
||||
log.Debugf("copy: could not set Mtime for destination file %s (%s)", logName, clean.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy file contents to the destination.
|
||||
_, err = io.Copy(destFile, thisFile)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
return err
|
||||
return fmt.Errorf("copy: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1398,8 +1464,8 @@ func (m *MediaFile) RenameSidecarFiles(oldFileName string) (renamed map[string]s
|
||||
if fs.FileExists(destName) {
|
||||
renamed[fs.RelName(srcName, sidecarPath)] = fs.RelName(destName, sidecarPath)
|
||||
|
||||
if err := os.Remove(srcName); err != nil {
|
||||
log.Errorf("files: failed removing sidecar %s", clean.Log(fs.RelName(srcName, sidecarPath)))
|
||||
if rmErr := os.Remove(srcName); rmErr != nil {
|
||||
log.Errorf("files: could not remove sidecar %s", clean.Log(fs.RelName(srcName, sidecarPath)))
|
||||
} else {
|
||||
log.Infof("files: removed sidecar %s", clean.Log(fs.RelName(srcName, sidecarPath)))
|
||||
}
|
||||
@@ -1407,8 +1473,8 @@ func (m *MediaFile) RenameSidecarFiles(oldFileName string) (renamed map[string]s
|
||||
continue
|
||||
}
|
||||
|
||||
if err := fs.Move(srcName, destName); err != nil {
|
||||
return renamed, err
|
||||
if moveErr := fs.Move(srcName, destName, true); moveErr != nil {
|
||||
return renamed, moveErr
|
||||
} else {
|
||||
log.Infof("files: moved existing sidecar to %s", clean.Log(newName+filepath.Ext(srcName)))
|
||||
renamed[fs.RelName(srcName, sidecarPath)] = fs.RelName(destName, sidecarPath)
|
||||
|
206
internal/photoprism/mediafile_copy_move_force_test.go
Normal file
206
internal/photoprism/mediafile_copy_move_force_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package photoprism
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, p string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, p string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_Existing_NoForce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
writeFile(t, dst, []byte("LONGER_DEST_CONTENT"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.Copy(dst, false)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "LONGER_DEST_CONTENT", string(readFile(t, dst)))
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
// Create an empty destination file.
|
||||
writeFile(t, dst, []byte{})
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = m.Copy(dst, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, "ABC", string(readFile(t, dst)))
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_Existing_Force_TruncatesAndOverwrites(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
writeFile(t, dst, []byte("LONGER_DEST_CONTENT"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set a known mod time via MediaFile to update cache and file mtime.
|
||||
known := time.Date(2020, 5, 4, 3, 2, 1, 0, time.UTC)
|
||||
_ = m.SetModTime(known)
|
||||
|
||||
if err = m.Copy(dst, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "ABC", string(readFile(t, dst)))
|
||||
// Check mtime propagated to destination (second resolution).
|
||||
if st, err := os.Stat(dst); err == nil {
|
||||
assert.Equal(t, known, st.ModTime().UTC().Truncate(time.Second))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_SamePath_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = m.Copy(src, true)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_InvalidDestPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = m.Copy(".", true)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMediaFile_Move_Existing_NoForce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
writeFile(t, dst, []byte("BBB"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.Move(dst, false)
|
||||
assert.Error(t, err)
|
||||
// Verify no changes
|
||||
assert.FileExists(t, src)
|
||||
assert.Equal(t, "BBB", string(readFile(t, dst)))
|
||||
}
|
||||
|
||||
func TestMediaFile_Move_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
// Pre-create empty destination file
|
||||
writeFile(t, dst, []byte{})
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = m.Move(dst, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Source removed, destination replaced.
|
||||
_, srcErr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(srcErr))
|
||||
assert.Equal(t, "AAA", string(readFile(t, dst)))
|
||||
assert.Equal(t, dst, m.FileName())
|
||||
}
|
||||
|
||||
func TestMediaFile_Move_Existing_Force(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
writeFile(t, dst, []byte("BBB"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = m.Move(dst, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Source removed, destination replaced.
|
||||
_, srcErr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(srcErr))
|
||||
assert.Equal(t, "AAA", string(readFile(t, dst)))
|
||||
assert.Equal(t, dst, m.FileName())
|
||||
}
|
||||
|
||||
func TestMediaFile_Move_SamePath_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.Move(src, true)
|
||||
assert.Error(t, err)
|
||||
}
|
@@ -877,63 +877,67 @@ func TestMediaFile_SetModTime(t *testing.T) {
|
||||
func TestMediaFile_Move(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
tmpPath := c.CachePath() + "/_tmp/TestMediaFile_Move"
|
||||
origName := tmpPath + "/original.jpg"
|
||||
destName := tmpPath + "/destination.jpg"
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
tmpPath := c.CachePath() + "/_tmp/TestMediaFile_Move"
|
||||
origName := tmpPath + "/original.jpg"
|
||||
destName := tmpPath + "/destination.jpg"
|
||||
|
||||
if err := fs.MkdirAll(tmpPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fs.MkdirAll(tmpPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpPath)
|
||||
defer os.RemoveAll(tmpPath)
|
||||
|
||||
f, err := NewMediaFile(c.ExamplesPath() + "/table_white.jpg")
|
||||
f, err := NewMediaFile(c.ExamplesPath() + "/table_white.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := f.Copy(origName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copyErr := f.Copy(origName, false); copyErr != nil {
|
||||
t.Fatal(copyErr)
|
||||
}
|
||||
|
||||
assert.True(t, fs.FileExists(origName))
|
||||
assert.True(t, fs.FileExists(origName))
|
||||
|
||||
m, err := NewMediaFile(origName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := NewMediaFile(origName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = m.Move(destName); err != nil {
|
||||
t.Errorf("failed to move: %s", err)
|
||||
}
|
||||
if moveErr := m.Move(destName, false); moveErr != nil {
|
||||
t.Error(moveErr)
|
||||
}
|
||||
|
||||
assert.True(t, fs.FileExists(destName))
|
||||
assert.Equal(t, destName, m.FileName())
|
||||
assert.True(t, fs.FileExists(destName))
|
||||
assert.Equal(t, destName, m.FileName())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
tmpPath := c.CachePath() + "/_tmp/TestMediaFile_Copy"
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
tmpPath := c.CachePath() + "/_tmp/TestMediaFile_Copy"
|
||||
|
||||
if err := fs.MkdirAll(tmpPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fs.MkdirAll(tmpPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpPath)
|
||||
defer os.RemoveAll(tmpPath)
|
||||
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/table_white.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/table_white.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := mediaFile.Copy(tmpPath + "table_whitecopy.jpg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copyErr := mediaFile.Copy(tmpPath+"table_whitecopy.jpg", false); copyErr != nil {
|
||||
t.Fatal(copyErr)
|
||||
}
|
||||
|
||||
assert.True(t, fs.FileExists(tmpPath+"table_whitecopy.jpg"))
|
||||
assert.True(t, fs.FileExists(tmpPath+"table_whitecopy.jpg"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_Extension(t *testing.T) {
|
||||
@@ -2580,13 +2584,13 @@ func TestMediaFile_SkipTranscoding(t *testing.T) {
|
||||
|
||||
func TestMediaFile_RenameSidecarFiles(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
c := config.TestConfig()
|
||||
|
||||
jpegExample := filepath.Join(conf.ExamplesPath(), "/limes.jpg")
|
||||
jpegPath := filepath.Join(conf.OriginalsPath(), "2020", "12")
|
||||
jpegExample := filepath.Join(c.ExamplesPath(), "/limes.jpg")
|
||||
jpegPath := filepath.Join(c.OriginalsPath(), "2020", "12")
|
||||
jpegName := filepath.Join(jpegPath, "foobar.jpg")
|
||||
|
||||
if err := fs.Copy(jpegExample, jpegName); err != nil {
|
||||
if err := fs.Copy(jpegExample, jpegName, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2596,18 +2600,18 @@ func TestMediaFile_RenameSidecarFiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = fs.MkdirAll(filepath.Join(conf.SidecarPath(), "foo")); err != nil {
|
||||
if err = fs.MkdirAll(filepath.Join(c.SidecarPath(), "foo")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srcName := filepath.Join(conf.SidecarPath(), "foo/bar.jpg.json")
|
||||
dstName := filepath.Join(conf.SidecarPath(), "2020/12/foobar.jpg.json")
|
||||
srcName := filepath.Join(c.SidecarPath(), "foo/bar.jpg.json")
|
||||
dstName := filepath.Join(c.SidecarPath(), "2020/12/foobar.jpg.json")
|
||||
|
||||
if err = os.WriteFile(srcName, []byte("{}"), 0666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if renamed, err := mf.RenameSidecarFiles(filepath.Join(conf.OriginalsPath(), "foo/bar.jpg")); err != nil {
|
||||
if renamed, err := mf.RenameSidecarFiles(filepath.Join(c.OriginalsPath(), "foo/bar.jpg")); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(renamed) != 1 {
|
||||
t.Errorf("len should be 2: %#v", renamed)
|
||||
@@ -2630,13 +2634,13 @@ func TestMediaFile_RenameSidecarFiles(t *testing.T) {
|
||||
|
||||
func TestMediaFile_RemoveSidecarFiles(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
c := config.TestConfig()
|
||||
|
||||
jpegExample := filepath.Join(conf.ExamplesPath(), "/limes.jpg")
|
||||
jpegPath := filepath.Join(conf.OriginalsPath(), "2020", "12")
|
||||
jpegExample := filepath.Join(c.ExamplesPath(), "/limes.jpg")
|
||||
jpegPath := filepath.Join(c.OriginalsPath(), "2020", "12")
|
||||
jpegName := filepath.Join(jpegPath, "foobar.jpg")
|
||||
|
||||
if err := fs.Copy(jpegExample, jpegName); err != nil {
|
||||
if err := fs.Copy(jpegExample, jpegName, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2646,14 +2650,14 @@ func TestMediaFile_RemoveSidecarFiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sidecarName := filepath.Join(conf.SidecarPath(), "2020/12/foobar.jpg.json")
|
||||
sidecarName := filepath.Join(c.SidecarPath(), "2020/12/foobar.jpg.json")
|
||||
|
||||
if err := os.WriteFile(sidecarName, []byte("{}"), 0666); err != nil {
|
||||
t.Fatal(err)
|
||||
if writeErr := os.WriteFile(sidecarName, []byte("{}"), 0666); writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
|
||||
if n, err := mf.RemoveSidecarFiles(); err != nil {
|
||||
t.Fatal(err)
|
||||
if n, rmErr := mf.RemoveSidecarFiles(); rmErr != nil {
|
||||
t.Fatal(rmErr)
|
||||
} else if fs.FileExists(sidecarName) {
|
||||
t.Errorf("src file still exists: %s", sidecarName)
|
||||
} else if n == 0 {
|
||||
|
@@ -49,7 +49,7 @@ func SetUserImageURL(m *entity.User, imageUrl, imageSrc, thumbPath string) error
|
||||
}
|
||||
}
|
||||
|
||||
if err = fs.Move(tmpName, imageName); err != nil {
|
||||
if err = fs.Move(tmpName, imageName, true); err != nil {
|
||||
return fmt.Errorf("failed to rename avatar image (%w)", err)
|
||||
}
|
||||
|
||||
|
@@ -3,6 +3,7 @@ package thumb
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -26,3 +27,20 @@ func TestParseFilter(t *testing.T) {
|
||||
assert.Equal(t, ResampleLanczos, ParseFilter("invalid", LibImaging))
|
||||
})
|
||||
}
|
||||
|
||||
func TestResampleFilter_StringAndVips(t *testing.T) {
|
||||
// String
|
||||
assert.Equal(t, "blackman", ResampleBlackman.String())
|
||||
assert.Equal(t, "lanczos", ResampleLanczos.String())
|
||||
assert.Equal(t, "cubic", ResampleCubic.String())
|
||||
assert.Equal(t, "linear", ResampleLinear.String())
|
||||
assert.Equal(t, "nearest", ResampleNearest.String())
|
||||
|
||||
// Vips mapping
|
||||
assert.Equal(t, vips.KernelLanczos3, ResampleBlackman.Vips())
|
||||
assert.Equal(t, vips.KernelLanczos3, ResampleLanczos.Vips())
|
||||
assert.Equal(t, vips.KernelLanczos3, ResampleAuto.Vips())
|
||||
assert.Equal(t, vips.KernelCubic, ResampleCubic.Vips())
|
||||
assert.Equal(t, vips.KernelLinear, ResampleLinear.Vips())
|
||||
assert.Equal(t, vips.KernelNearest, ResampleNearest.Vips())
|
||||
}
|
||||
|
@@ -46,3 +46,9 @@ func TestInit(t *testing.T) {
|
||||
assert.Equal(t, LibVips, Library)
|
||||
})
|
||||
}
|
||||
|
||||
func TestShutdown_NoPanic(t *testing.T) {
|
||||
// Call Shutdown twice to cover both not-started and already-reset states.
|
||||
Shutdown()
|
||||
Shutdown()
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
@@ -24,3 +26,33 @@ func TestMemSize(t *testing.T) {
|
||||
assert.InEpsilon(t, 1.430511474, result.MByte(), 0.1)
|
||||
assert.Equal(t, "1.5 MB", result.String())
|
||||
}
|
||||
|
||||
func TestBytes_GByte(t *testing.T) {
|
||||
var b Bytes = 3 * GB
|
||||
assert.Equal(t, 3.0, b.GByte())
|
||||
}
|
||||
|
||||
func TestMemSize_ColorModels(t *testing.T) {
|
||||
// 10x10 grayscale: 100 bytes
|
||||
g := image.NewGray(image.Rect(0, 0, 10, 10))
|
||||
assert.Equal(t, Bytes(100), MemSize(g))
|
||||
// 10x10 gray16: 200 bytes
|
||||
g16 := image.NewGray16(image.Rect(0, 0, 10, 10))
|
||||
assert.Equal(t, Bytes(200), MemSize(g16))
|
||||
// 10x10 rgba: 400 bytes
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, 10, 10))
|
||||
assert.Equal(t, Bytes(400), MemSize(rgba))
|
||||
// 10x10 rgba64: 800 bytes
|
||||
rgba64 := image.NewRGBA64(image.Rect(0, 0, 10, 10))
|
||||
assert.Equal(t, Bytes(800), MemSize(rgba64))
|
||||
// Alpha-only: 100 bytes
|
||||
a := image.NewAlpha(image.Rect(0, 0, 10, 10))
|
||||
assert.Equal(t, Bytes(100), MemSize(a))
|
||||
// Alpha16: 200 bytes
|
||||
a16 := image.NewAlpha16(image.Rect(0, 0, 10, 10))
|
||||
assert.Equal(t, Bytes(200), MemSize(a16))
|
||||
// Custom image with NRGBA color model still reports by bounds*BPP switch; use NRGBA to hit 4 bytes path
|
||||
nr := image.NewNRGBA(image.Rect(0, 0, 10, 10))
|
||||
_ = nr // already covered by rgba
|
||||
_ = color.RGBA{} // avoid unused import warning if any
|
||||
}
|
||||
|
@@ -27,3 +27,14 @@ func TestFind(t *testing.T) {
|
||||
assert.Equal(t, 1024, size.Height)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVision_DefaultsAndBounds(t *testing.T) {
|
||||
// Exact 720 returns Fit720
|
||||
sz := Vision(720)
|
||||
assert.Equal(t, SizeFit720, sz)
|
||||
// Below 224 selects the smallest square tile >= resolution
|
||||
assert.Equal(t, SizeTile100, Vision(100))
|
||||
// Next square tile at or above resolution
|
||||
assert.Equal(t, SizeTile384, Vision(300))
|
||||
assert.Equal(t, SizeTile500, Vision(500))
|
||||
}
|
||||
|
@@ -156,43 +156,3 @@ func VipsJpegExportParams(width, height int) *vips.JpegExportParams {
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// VipsRotate rotates a vips image based on the Exif orientation.
|
||||
func VipsRotate(img *vips.ImageRef, orientation int) error {
|
||||
var err error
|
||||
|
||||
switch orientation {
|
||||
case OrientationUnspecified:
|
||||
// Do nothing.
|
||||
case OrientationNormal:
|
||||
// Do nothing.
|
||||
case OrientationFlipH:
|
||||
err = img.Flip(vips.DirectionHorizontal)
|
||||
case OrientationFlipV:
|
||||
err = img.Flip(vips.DirectionVertical)
|
||||
case OrientationRotate90:
|
||||
// Rotate the image 90 degrees counter-clockwise.
|
||||
err = img.Rotate(vips.Angle270)
|
||||
case OrientationRotate180:
|
||||
err = img.Rotate(vips.Angle180)
|
||||
case OrientationRotate270:
|
||||
// Rotate the image 270 degrees counter-clockwise.
|
||||
err = img.Rotate(vips.Angle90)
|
||||
case OrientationTranspose:
|
||||
err = img.Flip(vips.DirectionHorizontal)
|
||||
if err == nil {
|
||||
// Rotate the image 90 degrees counter-clockwise.
|
||||
err = img.Rotate(vips.Angle270)
|
||||
}
|
||||
case OrientationTransverse:
|
||||
err = img.Flip(vips.DirectionVertical)
|
||||
if err == nil {
|
||||
// Rotate the image 90 degrees counter-clockwise.
|
||||
err = img.Rotate(vips.Angle270)
|
||||
}
|
||||
default:
|
||||
log.Debugf("vips: invalid orientation %d (rotate image)", orientation)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
45
internal/thumb/vips_rotate.go
Normal file
45
internal/thumb/vips_rotate.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
// VipsRotate rotates a vips image based on the Exif orientation.
|
||||
func VipsRotate(img *vips.ImageRef, orientation int) error {
|
||||
var err error
|
||||
|
||||
switch orientation {
|
||||
case OrientationUnspecified:
|
||||
// Do nothing.
|
||||
case OrientationNormal:
|
||||
// Do nothing.
|
||||
case OrientationFlipH:
|
||||
err = img.Flip(vips.DirectionHorizontal)
|
||||
case OrientationFlipV:
|
||||
err = img.Flip(vips.DirectionVertical)
|
||||
case OrientationRotate90:
|
||||
// Rotate the image 90 degrees counter-clockwise.
|
||||
err = img.Rotate(vips.Angle270)
|
||||
case OrientationRotate180:
|
||||
err = img.Rotate(vips.Angle180)
|
||||
case OrientationRotate270:
|
||||
// Rotate the image 270 degrees counter-clockwise.
|
||||
err = img.Rotate(vips.Angle90)
|
||||
case OrientationTranspose:
|
||||
err = img.Flip(vips.DirectionHorizontal)
|
||||
if err == nil {
|
||||
// Rotate the image 90 degrees counter-clockwise.
|
||||
err = img.Rotate(vips.Angle270)
|
||||
}
|
||||
case OrientationTransverse:
|
||||
err = img.Flip(vips.DirectionVertical)
|
||||
if err == nil {
|
||||
// Rotate the image 90 degrees counter-clockwise.
|
||||
err = img.Rotate(vips.Angle270)
|
||||
}
|
||||
default:
|
||||
log.Debugf("vips: invalid orientation %d (rotate image)", orientation)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
204
internal/thumb/vips_rotate_test.go
Normal file
204
internal/thumb/vips_rotate_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestVipsRotate(t *testing.T) {
|
||||
if err := os.MkdirAll("testdata/vips/rotate", fs.ModeDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("OrientationNormal", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
dst := "testdata/vips/rotate/0.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
// Load image from file.
|
||||
img, err := vips.NewImageFromFile(src)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = VipsRotate(img, OrientationNormal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := vips.NewJpegExportParams()
|
||||
imageBytes, _, exportErr := img.ExportJpeg(params)
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
// Write thumbnail to file.
|
||||
if err = os.WriteFile(dst, imageBytes, fs.ModeFile); err != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
t.Run("OrientationRotate90", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
dst := "testdata/vips/rotate/90.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
// Load image from file.
|
||||
img, err := vips.NewImageFromFile(src)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = VipsRotate(img, OrientationRotate90); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := vips.NewJpegExportParams()
|
||||
imageBytes, _, exportErr := img.ExportJpeg(params)
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
// Write thumbnail to file.
|
||||
if err = os.WriteFile(dst, imageBytes, fs.ModeFile); err != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
t.Run("OrientationRotate180", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
dst := "testdata/vips/rotate/180.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
// Load image from file.
|
||||
img, err := vips.NewImageFromFile(src)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = VipsRotate(img, OrientationRotate180); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := vips.NewJpegExportParams()
|
||||
imageBytes, _, exportErr := img.ExportJpeg(params)
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
// Write thumbnail to file.
|
||||
if err = os.WriteFile(dst, imageBytes, fs.ModeFile); err != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
t.Run("OrientationRotate270", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
dst := "testdata/vips/rotate/270.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
// Load image from file.
|
||||
img, err := vips.NewImageFromFile(src)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = VipsRotate(img, OrientationRotate270); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := vips.NewJpegExportParams()
|
||||
imageBytes, _, exportErr := img.ExportJpeg(params)
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
// Write thumbnail to file.
|
||||
if err = os.WriteFile(dst, imageBytes, fs.ModeFile); err != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVipsRotate_VariousAndInvalid(t *testing.T) {
|
||||
// Ensure vips is initialized for image operations
|
||||
VipsInit()
|
||||
|
||||
img, err := vips.LoadImageFromFile("testdata/example.jpg", VipsImportParams())
|
||||
if err != nil {
|
||||
t.Fatalf("load failed: %v", err)
|
||||
}
|
||||
defer img.Close()
|
||||
|
||||
// Valid operations should not return errors
|
||||
if c, err := img.Copy(); err == nil {
|
||||
defer c.Close()
|
||||
assert.NoError(t, VipsRotate(c, OrientationFlipH))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := img.Copy(); err == nil {
|
||||
defer c.Close()
|
||||
assert.NoError(t, VipsRotate(c, OrientationFlipV))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := img.Copy(); err == nil {
|
||||
defer c.Close()
|
||||
assert.NoError(t, VipsRotate(c, OrientationRotate90))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := img.Copy(); err == nil {
|
||||
defer c.Close()
|
||||
assert.NoError(t, VipsRotate(c, OrientationRotate180))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := img.Copy(); err == nil {
|
||||
defer c.Close()
|
||||
assert.NoError(t, VipsRotate(c, OrientationRotate270))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := img.Copy(); err == nil {
|
||||
defer c.Close()
|
||||
assert.NoError(t, VipsRotate(c, OrientationTranspose))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := img.Copy(); err == nil {
|
||||
defer c.Close()
|
||||
assert.NoError(t, VipsRotate(c, OrientationTransverse))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Invalid orientation triggers debug branch but must not error
|
||||
if c, err := img.Copy(); err == nil {
|
||||
defer c.Close()
|
||||
assert.NoError(t, VipsRotate(c, 999))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
@@ -1,14 +1,11 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestVips(t *testing.T) {
|
||||
@@ -197,133 +194,3 @@ func TestVipsJpegExportParams(t *testing.T) {
|
||||
assert.Equal(t, JpegQualitySmall().Int(), result.Quality)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVipsRotate(t *testing.T) {
|
||||
if err := os.MkdirAll("testdata/vips/rotate", fs.ModeDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("OrientationNormal", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
dst := "testdata/vips/rotate/0.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
// Load image from file.
|
||||
img, err := vips.NewImageFromFile(src)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = VipsRotate(img, OrientationNormal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := vips.NewJpegExportParams()
|
||||
imageBytes, _, exportErr := img.ExportJpeg(params)
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
// Write thumbnail to file.
|
||||
if err = os.WriteFile(dst, imageBytes, fs.ModeFile); err != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
t.Run("OrientationRotate90", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
dst := "testdata/vips/rotate/90.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
// Load image from file.
|
||||
img, err := vips.NewImageFromFile(src)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = VipsRotate(img, OrientationRotate90); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := vips.NewJpegExportParams()
|
||||
imageBytes, _, exportErr := img.ExportJpeg(params)
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
// Write thumbnail to file.
|
||||
if err = os.WriteFile(dst, imageBytes, fs.ModeFile); err != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
t.Run("OrientationRotate180", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
dst := "testdata/vips/rotate/180.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
// Load image from file.
|
||||
img, err := vips.NewImageFromFile(src)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = VipsRotate(img, OrientationRotate180); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := vips.NewJpegExportParams()
|
||||
imageBytes, _, exportErr := img.ExportJpeg(params)
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
// Write thumbnail to file.
|
||||
if err = os.WriteFile(dst, imageBytes, fs.ModeFile); err != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
t.Run("OrientationRotate270", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
dst := "testdata/vips/rotate/270.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
// Load image from file.
|
||||
img, err := vips.NewImageFromFile(src)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = VipsRotate(img, OrientationRotate270); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := vips.NewJpegExportParams()
|
||||
imageBytes, _, exportErr := img.ExportJpeg(params)
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
// Write thumbnail to file.
|
||||
if err = os.WriteFile(dst, imageBytes, fs.ModeFile); err != nil {
|
||||
t.Fatal(exportErr)
|
||||
}
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
}
|
||||
|
@@ -1,45 +0,0 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Copy copies a file to a destination.
|
||||
func Copy(src, dest string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("copy: %s (panic)", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err = MkdirAll(filepath.Dir(dest)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
thisFile, err := os.Open(src)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer thisFile.Close()
|
||||
|
||||
destFile, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE, ModeFile)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, thisFile)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
144
pkg/fs/copy_move.go
Normal file
144
pkg/fs/copy_move.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Copy copies a file to a destination.
|
||||
func Copy(src, dest string, force bool) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("%s (panic)", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check for obviously empty or invalid source and destination file paths.
|
||||
if src == "" || src == "." || src == ".." {
|
||||
return errors.New("invalid copy source file path")
|
||||
} else if dest == "" || dest == "." || dest == ".." {
|
||||
return errors.New("invalid copy destination file path")
|
||||
}
|
||||
|
||||
// Check whether a destination file and directory name are specified.
|
||||
if filepath.Base(dest) == "" {
|
||||
return errors.New("invalid copy destination name")
|
||||
} else if filepath.Dir(dest) == "" {
|
||||
return errors.New("invalid copy destination path")
|
||||
}
|
||||
|
||||
// Resolve absolute destination file path and return an error if unsuccessful.
|
||||
if dest, err = filepath.Abs(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destName := filepath.Base(dest)
|
||||
destDir := filepath.Dir(dest)
|
||||
|
||||
// Error if source and destination file path are the same.
|
||||
if dest == src {
|
||||
return fmt.Errorf("cannot copy file %s onto itself", destName)
|
||||
}
|
||||
|
||||
// Error if destination exists (and is not empty) without the force flag being used.
|
||||
if Exists(dest) {
|
||||
if !force && !FileExistsIsEmpty(dest) {
|
||||
return fmt.Errorf("copy destination %s already exists", destName)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the target directory exists.
|
||||
if err = MkdirAll(destDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
thisFile, err := os.Open(src)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer thisFile.Close()
|
||||
|
||||
// Open destination for write; create or truncate to avoid trailing bytes
|
||||
destFile, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, ModeFile)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, thisFile)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move moves an existing file to a new destination and returns an error if it fails.
|
||||
func Move(src, dest string, force bool) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("%s (panic)", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check for obviously empty or invalid source and destination file paths.
|
||||
if src == "" || src == "." || src == ".." {
|
||||
return errors.New("invalid move source file path")
|
||||
} else if dest == "" || dest == "." || dest == ".." {
|
||||
return errors.New("invalid move destination file path")
|
||||
}
|
||||
|
||||
// Check whether a destination file and directory name are specified.
|
||||
if filepath.Base(dest) == "" {
|
||||
return errors.New("invalid move destination name")
|
||||
} else if filepath.Dir(dest) == "" {
|
||||
return errors.New("invalid move destination path")
|
||||
}
|
||||
|
||||
// Resolve absolute destination file path and return an error if unsuccessful.
|
||||
if dest, err = filepath.Abs(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destName := filepath.Base(dest)
|
||||
destDir := filepath.Dir(dest)
|
||||
|
||||
// Error if source and destination file path are the same.
|
||||
if dest == src {
|
||||
return fmt.Errorf("cannot move file %s onto itself", destName)
|
||||
}
|
||||
|
||||
// Error if destination exists (and is not empty) without the force flag being used.
|
||||
if Exists(dest) {
|
||||
if !force && !FileExistsIsEmpty(dest) {
|
||||
return fmt.Errorf("move destination %s already exists", destName)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the target directory exists.
|
||||
if err = MkdirAll(destDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Rename(src, dest); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = Copy(src, dest, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Remove(src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
164
pkg/fs/copy_move_test.go
Normal file
164
pkg/fs/copy_move_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCopy_NewDestination_Succeeds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "sub", "dst.txt")
|
||||
|
||||
assert.NoError(t, os.WriteFile(src, []byte("hello"), 0o644))
|
||||
|
||||
err := Copy(src, dst, false)
|
||||
assert.NoError(t, err)
|
||||
b, _ := os.ReadFile(dst)
|
||||
assert.Equal(t, "hello", string(b))
|
||||
}
|
||||
|
||||
func TestCopy_ExistingNonEmpty_NoForce_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
assert.NoError(t, os.WriteFile(src, []byte("short"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(dst, []byte("existing"), 0o644))
|
||||
|
||||
err := Copy(src, dst, false)
|
||||
assert.Error(t, err)
|
||||
b, _ := os.ReadFile(dst)
|
||||
assert.Equal(t, "existing", string(b))
|
||||
}
|
||||
|
||||
func TestCopy_ExistingNonEmpty_Force_TruncatesAndOverwrites(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
assert.NoError(t, os.WriteFile(src, []byte("short"), 0o644))
|
||||
// Destination contains longer content which must be truncated when force=true
|
||||
assert.NoError(t, os.WriteFile(dst, []byte("existing-long"), 0o644))
|
||||
|
||||
err := Copy(src, dst, true)
|
||||
assert.NoError(t, err)
|
||||
b, _ := os.ReadFile(dst)
|
||||
assert.Equal(t, "short", string(b))
|
||||
}
|
||||
|
||||
func TestCopy_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
assert.NoError(t, os.WriteFile(src, []byte("data"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(dst, []byte{}, 0o644))
|
||||
|
||||
err := Copy(src, dst, false)
|
||||
assert.NoError(t, err)
|
||||
b, _ := os.ReadFile(dst)
|
||||
assert.Equal(t, "data", string(b))
|
||||
}
|
||||
|
||||
func TestCopy_SamePath_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.txt")
|
||||
assert.NoError(t, os.WriteFile(src, []byte("x"), 0o644))
|
||||
err := Copy(src, src, true)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCopy_InvalidPaths_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.txt")
|
||||
assert.NoError(t, os.WriteFile(src, []byte("x"), 0o644))
|
||||
assert.Error(t, Copy("", filepath.Join(dir, "a.txt"), false))
|
||||
assert.Error(t, Copy(src, "", false))
|
||||
assert.Error(t, Copy(src, ".", false))
|
||||
}
|
||||
|
||||
func TestMove_NewDestination_Succeeds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "sub", "dst.txt")
|
||||
|
||||
assert.NoError(t, os.WriteFile(src, []byte("hello"), 0o644))
|
||||
|
||||
err := Move(src, dst, false)
|
||||
assert.NoError(t, err)
|
||||
// Source is removed; dest contains data
|
||||
_, serr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(serr))
|
||||
b, _ := os.ReadFile(dst)
|
||||
assert.Equal(t, "hello", string(b))
|
||||
}
|
||||
|
||||
func TestMove_ExistingNonEmpty_NoForce_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
assert.NoError(t, os.WriteFile(src, []byte("src"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(dst, []byte("dst"), 0o644))
|
||||
|
||||
err := Move(src, dst, false)
|
||||
assert.Error(t, err)
|
||||
// Verify both files unchanged
|
||||
bsrc, _ := os.ReadFile(src)
|
||||
bdst, _ := os.ReadFile(dst)
|
||||
assert.Equal(t, "src", string(bsrc))
|
||||
assert.Equal(t, "dst", string(bdst))
|
||||
}
|
||||
|
||||
func TestMove_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
assert.NoError(t, os.WriteFile(src, []byte("src"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(dst, []byte{}, 0o644))
|
||||
|
||||
err := Move(src, dst, false)
|
||||
assert.NoError(t, err)
|
||||
_, serr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(serr))
|
||||
bdst, _ := os.ReadFile(dst)
|
||||
assert.Equal(t, "src", string(bdst))
|
||||
}
|
||||
|
||||
func TestMove_ExistingNonEmpty_Force_Succeeds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
assert.NoError(t, os.WriteFile(src, []byte("AAA"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(dst, []byte("BBBBB"), 0o644))
|
||||
|
||||
err := Move(src, dst, true)
|
||||
assert.NoError(t, err)
|
||||
_, serr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(serr))
|
||||
bdst, _ := os.ReadFile(dst)
|
||||
assert.Equal(t, "AAA", string(bdst))
|
||||
}
|
||||
|
||||
func TestMove_SamePath_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.txt")
|
||||
assert.NoError(t, os.WriteFile(src, []byte("x"), 0o644))
|
||||
err := Move(src, src, true)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMove_InvalidPaths_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.txt")
|
||||
assert.NoError(t, os.WriteFile(src, []byte("x"), 0o644))
|
||||
assert.Error(t, Move("", filepath.Join(dir, "a.txt"), false))
|
||||
assert.Error(t, Move(src, "", false))
|
||||
assert.Error(t, Move(src, ".", false))
|
||||
}
|
@@ -12,3 +12,12 @@ func TestProcessed(t *testing.T) {
|
||||
assert.False(t, Found.Processed())
|
||||
})
|
||||
}
|
||||
|
||||
func TestDoneProcessedCount(t *testing.T) {
|
||||
d := Done{
|
||||
"a.jpg": Found,
|
||||
"b.jpg": Processed,
|
||||
"c.jpg": 0,
|
||||
}
|
||||
assert.Equal(t, 1, d.Processed())
|
||||
}
|
||||
|
@@ -150,3 +150,12 @@ func TestExt(t *testing.T) {
|
||||
assert.Equal(t, "", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestArchiveExt(t *testing.T) {
|
||||
t.Run("zip", func(t *testing.T) {
|
||||
assert.Equal(t, ExtZip, ArchiveExt("/testdata/archive.ZIP"))
|
||||
})
|
||||
t.Run("not archive", func(t *testing.T) {
|
||||
assert.Equal(t, "", ArchiveExt("/testdata/file.jpg"))
|
||||
})
|
||||
}
|
||||
|
@@ -12,6 +12,10 @@ func TestType_String(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestType_ToUpper(t *testing.T) {
|
||||
assert.Equal(t, "JPG", ImageJpeg.ToUpper())
|
||||
}
|
||||
|
||||
func TestType_Equal(t *testing.T) {
|
||||
t.Run("jpg", func(t *testing.T) {
|
||||
assert.True(t, ImageJpeg.Equal("jpg"))
|
||||
|
23
pkg/fs/fs.go
23
pkg/fs/fs.go
@@ -91,23 +91,34 @@ func FileExists(filePath string) bool {
|
||||
}
|
||||
|
||||
// FileExistsNotEmpty returns true if file exists, is not a directory, and not empty.
|
||||
func FileExistsNotEmpty(fileName string) bool {
|
||||
if fileName == "" {
|
||||
func FileExistsNotEmpty(filePath string) bool {
|
||||
if filePath == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stat(fileName)
|
||||
info, err := os.Stat(filePath)
|
||||
|
||||
return err == nil && !info.IsDir() && info.Size() > 0
|
||||
}
|
||||
|
||||
// FileExistsIsEmpty returns true if the file exists, but is empty.
|
||||
func FileExistsIsEmpty(filePath string) bool {
|
||||
if filePath == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
|
||||
return err == nil && !info.IsDir() && info.Size() == 0
|
||||
}
|
||||
|
||||
// FileSize returns the size of a file in bytes or -1 in case of an error.
|
||||
func FileSize(fileName string) int64 {
|
||||
if fileName == "" {
|
||||
func FileSize(filePath string) int64 {
|
||||
if filePath == "" {
|
||||
return -1
|
||||
}
|
||||
|
||||
info, err := os.Stat(fileName)
|
||||
info, err := os.Stat(filePath)
|
||||
|
||||
if err != nil || info == nil {
|
||||
return -1
|
||||
|
@@ -2,8 +2,12 @@ package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -42,6 +46,8 @@ func TestFileExists(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFileExistsNotEmpty(t *testing.T) {
|
||||
assert.False(t, FileExistsNotEmpty("./testdata"))
|
||||
assert.False(t, FileExistsNotEmpty("./testdata/"))
|
||||
assert.True(t, FileExistsNotEmpty("./testdata/test.jpg"))
|
||||
assert.True(t, FileExistsNotEmpty("./testdata/test.jpg"))
|
||||
assert.False(t, FileExistsNotEmpty("./testdata/empty.jpg"))
|
||||
@@ -49,6 +55,16 @@ func TestFileExistsNotEmpty(t *testing.T) {
|
||||
assert.False(t, FileExistsNotEmpty(""))
|
||||
}
|
||||
|
||||
func TestFileExistsIsEmpty(t *testing.T) {
|
||||
assert.False(t, FileExistsIsEmpty("./testdata"))
|
||||
assert.False(t, FileExistsIsEmpty("./testdata/"))
|
||||
assert.False(t, FileExistsIsEmpty("./testdata/test.jpg"))
|
||||
assert.False(t, FileExistsIsEmpty("./testdata/test.jpg"))
|
||||
assert.True(t, FileExistsIsEmpty("./testdata/empty.jpg"))
|
||||
assert.False(t, FileExistsIsEmpty("./foo.jpg"))
|
||||
assert.False(t, FileExistsIsEmpty(""))
|
||||
}
|
||||
|
||||
func TestFileSize(t *testing.T) {
|
||||
assert.Equal(t, 10990, int(FileSize("./testdata/test.jpg")))
|
||||
assert.Equal(t, 10990, int(FileSize("./testdata/test.jpg")))
|
||||
@@ -121,3 +137,52 @@ func TestDirIsEmpty(t *testing.T) {
|
||||
assert.Equal(t, true, DirIsEmpty("./testdata/emptyDir"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocketExists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "test.sock")
|
||||
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Skipf("unix sockets not supported: %v", err)
|
||||
}
|
||||
defer func() { _ = ln.Close(); _ = os.Remove(sock) }()
|
||||
|
||||
assert.True(t, SocketExists(sock))
|
||||
assert.False(t, SocketExists(filepath.Join(dir, "missing.sock")))
|
||||
}
|
||||
|
||||
func TestDownload_SuccessAndErrors(t *testing.T) {
|
||||
// Serve known content
|
||||
tsOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("hello world"))
|
||||
}))
|
||||
defer tsOK.Close()
|
||||
|
||||
// Serve a failure status
|
||||
tsFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusBadRequest)
|
||||
}))
|
||||
defer tsFail.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
goodPath := filepath.Join(dir, "sub", "file.txt")
|
||||
badPath := filepath.Join("file.txt") // invalid path according to Download
|
||||
|
||||
// Success
|
||||
err := Download(goodPath, tsOK.URL)
|
||||
assert.NoError(t, err)
|
||||
b, rerr := os.ReadFile(goodPath)
|
||||
assert.NoError(t, rerr)
|
||||
assert.Equal(t, "hello world", string(b))
|
||||
|
||||
// Invalid target path
|
||||
err = Download(badPath, tsOK.URL)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Server error status
|
||||
anotherPath := filepath.Join(dir, "b", "x.txt")
|
||||
err = Download(anotherPath, tsFail.URL)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
@@ -1,34 +0,0 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Move moves an existing file to a new destination and returns an error if it fails.
|
||||
func Move(src, dest string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("move: %s (panic)", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err = MkdirAll(filepath.Dir(dest)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Rename(src, dest); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = Copy(src, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Remove(src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@@ -5,37 +5,40 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
tmpDir := os.TempDir()
|
||||
func TestResolve_FileAndSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "file.txt")
|
||||
link := filepath.Join(dir, "link.txt")
|
||||
|
||||
linkName := filepath.Join(tmpDir, uuid.NewString()+"-link.tmp")
|
||||
targetName := filepath.Join(tmpDir, uuid.NewString()+".tmp")
|
||||
|
||||
// Delete files after test.
|
||||
defer func(link, target string) {
|
||||
_ = os.Remove(link)
|
||||
_ = os.Remove(target)
|
||||
}(linkName, targetName)
|
||||
|
||||
// Create empty test target file.
|
||||
if targetFile, err := os.OpenFile(targetName, os.O_RDONLY|os.O_CREATE, ModeFile); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if err = targetFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
assert.NoError(t, os.WriteFile(target, []byte("x"), 0o644))
|
||||
// Create symlink if supported on this platform
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlinks not supported: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Symlink(targetName, linkName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Resolving the file returns its absolute path
|
||||
absFile, err := Resolve(target)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, filepath.IsAbs(absFile))
|
||||
|
||||
if result, err := Resolve(linkName); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
assert.Equal(t, targetName, result)
|
||||
}
|
||||
// Resolving the link returns the target absolute path
|
||||
absLink, err := Resolve(link)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, absFile, absLink)
|
||||
}
|
||||
|
||||
func TestResolve_BrokenSymlink_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
broken := filepath.Join(dir, "broken.txt")
|
||||
// Symlink to missing target
|
||||
if err := os.Symlink(filepath.Join(dir, "missing.txt"), broken); err != nil {
|
||||
t.Skipf("symlinks not supported: %v", err)
|
||||
return
|
||||
}
|
||||
_, err := Resolve(broken)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
19
pkg/fs/stat_test.go
Normal file
19
pkg/fs/stat_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStat(t *testing.T) {
|
||||
// Success case
|
||||
info, err := Stat("./testdata/test.jpg")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, info.IsDir())
|
||||
assert.Greater(t, info.Size(), int64(0))
|
||||
|
||||
// Error on empty path
|
||||
_, err = Stat("")
|
||||
assert.Error(t, err)
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -201,3 +202,28 @@ func TestCacheFileFromReader(t *testing.T) {
|
||||
assert.Equal(t, "0", readLines[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteFile_Truncates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
assert.NoError(t, os.WriteFile(p, []byte("LONGDATA"), 0o644))
|
||||
assert.NoError(t, WriteFile(p, []byte("short"), ModeFile))
|
||||
b, err := os.ReadFile(p)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "short", string(b))
|
||||
}
|
||||
|
||||
func TestWriteFile_Errors(t *testing.T) {
|
||||
err := WriteFile("", []byte("x"), ModeFile)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestWriteFileFromReader_Errors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "x.txt")
|
||||
|
||||
// nil reader
|
||||
assert.Error(t, WriteFileFromReader(p, nil))
|
||||
// empty filename
|
||||
assert.Error(t, WriteFileFromReader("", bytes.NewBufferString("hi")))
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package fs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -136,8 +137,10 @@ func UnzipFile(f *zip.File, dir string) (fileName string, err error) {
|
||||
|
||||
defer rc.Close()
|
||||
|
||||
// Compose destination file or directory path.
|
||||
fileName = filepath.Join(dir, f.Name)
|
||||
// Compose destination file or directory path with safety checks.
|
||||
if fileName, err = safeJoin(dir, f.Name); err != nil {
|
||||
return fileName, err
|
||||
}
|
||||
|
||||
// Create destination path if it is a directory.
|
||||
if f.FileInfo().IsDir() {
|
||||
@@ -169,3 +172,23 @@ func UnzipFile(f *zip.File, dir string) (fileName string, err error) {
|
||||
|
||||
return fileName, nil
|
||||
}
|
||||
|
||||
// safeJoin joins a base directory with a relative name and ensures
|
||||
// that the resulting path stays within the base directory. Absolute
|
||||
// paths and Windows-style volume names are rejected.
|
||||
func safeJoin(baseDir, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("invalid zip path")
|
||||
}
|
||||
if filepath.IsAbs(name) || filepath.VolumeName(name) != "" {
|
||||
return "", fmt.Errorf("invalid zip path: absolute or volume path not allowed")
|
||||
}
|
||||
cleaned := filepath.Clean(name)
|
||||
// Prevent paths that resolve outside the base dir.
|
||||
dest := filepath.Join(baseDir, cleaned)
|
||||
base := filepath.Clean(baseDir)
|
||||
if dest != base && !strings.HasPrefix(dest, base+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("invalid zip path: outside target directory")
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
@@ -1,13 +1,153 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func writeZip(t *testing.T, path string, entries map[string][]byte) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
|
||||
for name, data := range entries {
|
||||
hdr := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
w, createErr := zw.CreateHeader(hdr)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
if _, writeErr := w.Write(data); writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
}
|
||||
assert.NoError(t, zw.Close())
|
||||
}
|
||||
|
||||
func TestUnzip_SkipRulesAndLimits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "test.zip")
|
||||
|
||||
entries := map[string][]byte{
|
||||
"__MACOSX/._junk": []byte("meta"), // skipped by prefix
|
||||
"ok1.txt": []byte("abc"), // 3 bytes
|
||||
"dir/../evil.txt": []byte("pwned"), // skipped due to ..
|
||||
"ok2.txt": []byte("x"), // 1 byte
|
||||
}
|
||||
writeZip(t, zipPath, entries)
|
||||
|
||||
// totalSizeLimit == 0 → skip everything
|
||||
files, skipped, err := Unzip(zipPath, filepath.Join(dir, "a"), 0, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, files)
|
||||
assert.GreaterOrEqual(t, len(skipped), 1)
|
||||
|
||||
// Apply per-file and total limits
|
||||
outDir := filepath.Join(dir, "b")
|
||||
files, skipped, err = Unzip(zipPath, outDir, 2, 3) // file limit=2 bytes; total limit=3 bytes
|
||||
assert.NoError(t, err)
|
||||
|
||||
// ok1 (3 bytes) skipped by file limit; evil skipped by '..'; __MACOSX skipped by prefix
|
||||
// ok2 (1 byte) allowed; total limit reduces to 2; nothing else left that fits
|
||||
assert.ElementsMatch(t, []string{filepath.Join(outDir, "ok2.txt")}, files)
|
||||
// Ensure file written
|
||||
b, rerr := os.ReadFile(filepath.Join(outDir, "ok2.txt"))
|
||||
assert.NoError(t, rerr)
|
||||
assert.Equal(t, []byte("x"), b)
|
||||
// Skipped contains at least the three excluded entries
|
||||
assert.GreaterOrEqual(t, len(skipped), 3)
|
||||
}
|
||||
|
||||
func TestUnzip_AbsolutePathRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "abs.zip")
|
||||
absName := string(os.PathSeparator) + filepath.Join("tmp", "abs.txt")
|
||||
entries := map[string][]byte{absName: []byte("bad")}
|
||||
writeZip(t, zipPath, entries)
|
||||
|
||||
_, _, err := Unzip(zipPath, filepath.Join(dir, "out"), 0, 10)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for absolute path entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnzip_WindowsVolumePathRejected(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("volume path semantics only apply on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "vol.zip")
|
||||
entries := map[string][]byte{"C:/Windows/System32/evil.txt": []byte("bad")}
|
||||
writeZip(t, zipPath, entries)
|
||||
|
||||
_, _, err := Unzip(zipPath, filepath.Join(dir, "out"), 0, 10)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for volume path entry on Windows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnzip_WindowsBackslashVolumePathRejected(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("volume path semantics only apply on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "vol_bs.zip")
|
||||
entries := map[string][]byte{"C:\\Windows\\System32\\evil.txt": []byte("bad")}
|
||||
writeZip(t, zipPath, entries)
|
||||
|
||||
_, _, err := Unzip(zipPath, filepath.Join(dir, "out"), 0, 10)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for backslash volume path entry on Windows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnzip_CreatesDirectoriesAndNestedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "nested.zip")
|
||||
entries := map[string][]byte{
|
||||
"nested/": nil, // directory entry
|
||||
"nested/a.txt": []byte("A"),
|
||||
"nested/sub/": nil, // nested dir entry
|
||||
"nested/sub/b.txt": []byte("BB"),
|
||||
}
|
||||
writeZip(t, zipPath, entries)
|
||||
|
||||
outDir := filepath.Join(dir, "out")
|
||||
files, skipped, err := Unzip(zipPath, outDir, 10, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Expect both files extracted; directories may also be included in the returned list.
|
||||
expectedA := filepath.Join(outDir, "nested/a.txt")
|
||||
expectedB := filepath.Join(outDir, "nested/sub/b.txt")
|
||||
m := map[string]bool{}
|
||||
for _, f := range files {
|
||||
m[f] = true
|
||||
}
|
||||
if !m[expectedA] || !m[expectedB] {
|
||||
t.Fatalf("extracted list missing expected files: %v", files)
|
||||
}
|
||||
if len(skipped) != 0 {
|
||||
t.Fatalf("unexpected skipped: %v", skipped)
|
||||
}
|
||||
// Check directories exist
|
||||
if fi, err := os.Stat(filepath.Join(outDir, "nested")); err != nil || !fi.IsDir() {
|
||||
t.Fatalf("nested dir missing")
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(outDir, "nested/sub")); err != nil || !fi.IsDir() {
|
||||
t.Fatalf("nested subdir missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZip(t *testing.T) {
|
||||
t.Run("Compressed", func(t *testing.T) {
|
||||
zipDir := filepath.Join(os.TempDir(), "pkg/fs")
|
||||
|
@@ -1,7 +1,10 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -35,4 +38,93 @@ func TestReadUrl(t *testing.T) {
|
||||
assert.Equal(t, expected, data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HttpServer", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("hello"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
data, err := ReadUrl(ts.URL, []string{"http", "https"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, []byte("hello"), data)
|
||||
})
|
||||
|
||||
t.Run("InvalidEmpty", func(t *testing.T) {
|
||||
_, err := ReadUrl("", []string{"https"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("MissingScheme", func(t *testing.T) {
|
||||
_, err := ReadUrl("example.com/file.jpg", []string{"https"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("DisallowedScheme", func(t *testing.T) {
|
||||
_, err := ReadUrl("http://example.com", []string{"data"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("UnsupportedScheme", func(t *testing.T) {
|
||||
_, err := ReadUrl("ssh://host/path", []string{"ssh"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("InvalidDataUrl", func(t *testing.T) {
|
||||
_, err := ReadUrl("data:image/png;base64,", []string{"data"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("FileSchemeInvalidPath", func(t *testing.T) {
|
||||
// os.ReadFile will not accept a file:// URL; expect error path is exercised.
|
||||
_, err := ReadUrl("file:///this/does/not/exist", []string{"file"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDataUrl_LargeBinary(t *testing.T) {
|
||||
// 2 MiB of zeros -> expect application/octet-stream
|
||||
big := bytes.Repeat([]byte{0}, 2*1024*1024)
|
||||
s := DataUrl(bytes.NewReader(big))
|
||||
if !strings.HasPrefix(s, "data:application/octet-stream;base64,") {
|
||||
t.Fatalf("unexpected prefix: %s", s[:48])
|
||||
}
|
||||
enc := strings.SplitN(s, ",", 2)[1]
|
||||
wantLen := EncodedLenBase64(len(big))
|
||||
if len(enc) != wantLen {
|
||||
t.Fatalf("unexpected base64 length: got=%d want=%d", len(enc), wantLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataBase64_Large(t *testing.T) {
|
||||
big := bytes.Repeat([]byte("A"), 1*1024*1024+3)
|
||||
b64 := DataBase64(bytes.NewReader(big))
|
||||
wantLen := EncodedLenBase64(len(big))
|
||||
assert.Equal(t, wantLen, len(b64))
|
||||
}
|
||||
|
||||
func TestDataUrl_JpegDetection(t *testing.T) {
|
||||
// Minimal JPEG-like header: FF D8 FF E0 'JFIF' ...
|
||||
buf := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte("JFIF\x00\x01\x02\x00\x00")...)
|
||||
buf = append(buf, bytes.Repeat([]byte{0}, 64)...)
|
||||
s := DataUrl(bytes.NewReader(buf))
|
||||
assert.True(t, strings.HasPrefix(s, "data:image/jpeg;base64,"))
|
||||
}
|
||||
|
||||
func TestDataUrl_GifDetection(t *testing.T) {
|
||||
// Minimal GIF89a header + padding
|
||||
buf := append([]byte("GIF89a"), bytes.Repeat([]byte{0}, 32)...)
|
||||
s := DataUrl(bytes.NewReader(buf))
|
||||
assert.True(t, strings.HasPrefix(s, "data:image/gif;base64,"))
|
||||
}
|
||||
|
||||
func TestDataUrl_WebpDetection(t *testing.T) {
|
||||
// Minimal RIFF/WEBP container header
|
||||
// RIFF <size=26> WEBP VP8 + padding
|
||||
riff := []byte{'R', 'I', 'F', 'F', 26, 0, 0, 0, 'W', 'E', 'B', 'P', 'V', 'P', '8', ' '}
|
||||
buf := append(riff, bytes.Repeat([]byte{0}, 32)...)
|
||||
s := DataUrl(bytes.NewReader(buf))
|
||||
assert.True(t, strings.HasPrefix(s, "data:image/webp;base64,"))
|
||||
}
|
||||
|
@@ -35,3 +35,8 @@ func TestType_NotEqual(t *testing.T) {
|
||||
assert.True(t, Cylindrical.NotEqual(Unknown.String()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestType_UnknownMethod(t *testing.T) {
|
||||
assert.True(t, Unknown.Unknown())
|
||||
assert.False(t, Equirectangular.Unknown())
|
||||
}
|
||||
|
@@ -43,3 +43,50 @@ func TestInfo(t *testing.T) {
|
||||
assert.Equal(t, fs.VideoMp4, info.VideoFileType())
|
||||
})
|
||||
}
|
||||
|
||||
func TestInfo_VideoSize(t *testing.T) {
|
||||
// Negative values yield 0
|
||||
assert.Equal(t, int64(0), Info{FileSize: -1, VideoOffset: 0}.VideoSize())
|
||||
assert.Equal(t, int64(0), Info{FileSize: 10, VideoOffset: -1}.VideoSize())
|
||||
// Normal size
|
||||
assert.Equal(t, int64(90), Info{FileSize: 100, VideoOffset: 10}.VideoSize())
|
||||
}
|
||||
|
||||
func TestInfo_VideoBitrate(t *testing.T) {
|
||||
// Unknown size or duration yields 0
|
||||
assert.Equal(t, 0.0, Info{FileSize: -1, VideoOffset: 0, Duration: time.Second}.VideoBitrate())
|
||||
assert.Equal(t, 0.0, Info{FileSize: 100, VideoOffset: 50, Duration: 0}.VideoBitrate())
|
||||
// Bitrate: (size*8)/(duration) in Mbps
|
||||
inf := Info{FileSize: 1000, VideoOffset: 500, Duration: time.Second}
|
||||
// size = 500 bytes; bitrate = (500*8)/1 / 1e6 = 0.004 Mbps
|
||||
assert.InDelta(t, 0.004, inf.VideoBitrate(), 1e-6)
|
||||
}
|
||||
|
||||
func TestInfo_VideoFileExtAndType(t *testing.T) {
|
||||
// MOV maps to .mov and VideoMov
|
||||
mov := Info{VideoMimeType: header.ContentTypeMov}
|
||||
if got := mov.VideoFileExt(); got != fs.ExtMov {
|
||||
t.Fatalf("mov ext: got=%s want=%s", got, fs.ExtMov)
|
||||
}
|
||||
if got := mov.VideoFileType(); got != fs.VideoMov {
|
||||
t.Fatalf("mov type: got=%v want=%v", got, fs.VideoMov)
|
||||
}
|
||||
|
||||
// MP4 maps to .mp4 and VideoMp4
|
||||
mp4 := Info{VideoMimeType: header.ContentTypeMp4}
|
||||
if got := mp4.VideoFileExt(); got != fs.ExtMp4 {
|
||||
t.Fatalf("mp4 ext: got=%s want=%s", got, fs.ExtMp4)
|
||||
}
|
||||
if got := mp4.VideoFileType(); got != fs.VideoMp4 {
|
||||
t.Fatalf("mp4 type: got=%v want=%v", got, fs.VideoMp4)
|
||||
}
|
||||
|
||||
// Unknown defaults to MP4
|
||||
unk := Info{VideoMimeType: ""}
|
||||
if got := unk.VideoFileExt(); got != fs.ExtMp4 {
|
||||
t.Fatalf("unk ext: got=%s want=%s", got, fs.ExtMp4)
|
||||
}
|
||||
if got := unk.VideoFileType(); got != fs.VideoMp4 {
|
||||
t.Fatalf("unk type: got=%v want=%v", got, fs.VideoMp4)
|
||||
}
|
||||
}
|
||||
|
@@ -48,3 +48,9 @@ func TestReader(t *testing.T) {
|
||||
assert.Equal(t, info.VideoMimeType, mimetype.Detect(videoData).String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewReader_FileNotFound(t *testing.T) {
|
||||
r, err := NewReader("/path/does/not/exist", 0)
|
||||
assert.Nil(t, r)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
92
pkg/txt/report/json_test.go
Normal file
92
pkg/txt/report/json_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func TestRowsToObjectsAndJSONExport(t *testing.T) {
|
||||
rows := [][]string{
|
||||
{"Alice", "30", "extra"}, // extra value should be ignored
|
||||
{"Bob"}, // missing values default to ""
|
||||
{"Carol", "27"},
|
||||
}
|
||||
cols := []string{"First Name", "Age", "!@#$%"}
|
||||
|
||||
objs := RowsToObjects(rows, cols)
|
||||
if assert.Len(t, objs, 3) {
|
||||
assert.Equal(t, map[string]string{"first_name": "Alice", "age": "30", "col": "extra"}, objs[0])
|
||||
assert.Equal(t, map[string]string{"first_name": "Bob", "age": "", "col": ""}, objs[1])
|
||||
assert.Equal(t, map[string]string{"first_name": "Carol", "age": "27", "col": ""}, objs[2])
|
||||
}
|
||||
|
||||
// JSONExport should marshal the same shape
|
||||
s, err := JSONExport(rows, cols)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var back []map[string]string
|
||||
assert.NoError(t, json.Unmarshal([]byte(s), &back))
|
||||
assert.Equal(t, objs, back)
|
||||
|
||||
// Duplicate column names collide to the same key; last wins
|
||||
rows = [][]string{{"x", "y"}}
|
||||
cols = []string{"A-A", "A A"}
|
||||
objs = RowsToObjects(rows, cols)
|
||||
assert.Equal(t, map[string]string{"a_a": "y"}, objs[0])
|
||||
}
|
||||
|
||||
func TestCliFormatStrict(t *testing.T) {
|
||||
// Helper to build a cli.Context with flags
|
||||
newCtx := func(setFlags func(ctx *cli.Context)) *cli.Context {
|
||||
app := &cli.App{Flags: CliFlags}
|
||||
fs := flag.NewFlagSet("test", 0)
|
||||
// Register app flags into the stdlib flagset
|
||||
for _, fl := range app.Flags {
|
||||
_ = fl.Apply(fs)
|
||||
}
|
||||
ctx := cli.NewContext(app, fs, nil)
|
||||
if setFlags != nil {
|
||||
setFlags(ctx)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Default
|
||||
fmt, err := CliFormatStrict(newCtx(nil))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, Format(Default), fmt)
|
||||
|
||||
// Individual flags
|
||||
fmt, err = CliFormatStrict(newCtx(func(ctx *cli.Context) { _ = ctx.Set("json", "true") }))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, Format(JSON), fmt)
|
||||
|
||||
fmt, err = CliFormatStrict(newCtx(func(ctx *cli.Context) { _ = ctx.Set("md", "true") }))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, Format(Markdown), fmt)
|
||||
|
||||
fmt, err = CliFormatStrict(newCtx(func(ctx *cli.Context) { _ = ctx.Set("csv", "true") }))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, Format(CSV), fmt)
|
||||
|
||||
fmt, err = CliFormatStrict(newCtx(func(ctx *cli.Context) { _ = ctx.Set("tsv", "true") }))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, Format(TSV), fmt)
|
||||
|
||||
// Multiple flags → usage error with exit code 2
|
||||
_, err = CliFormatStrict(newCtx(func(ctx *cli.Context) {
|
||||
_ = ctx.Set("json", "true")
|
||||
_ = ctx.Set("csv", "true")
|
||||
}))
|
||||
if assert.Error(t, err) {
|
||||
if exit, ok := err.(cli.ExitCoder); ok {
|
||||
assert.Equal(t, 2, exit.ExitCode())
|
||||
} else {
|
||||
t.Fatalf("expected cli.ExitCoder, got %T", err)
|
||||
}
|
||||
}
|
||||
}
|
22
pkg/txt/report/sort_test.go
Normal file
22
pkg/txt/report/sort_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSort(t *testing.T) {
|
||||
rows := [][]string{
|
||||
{"b", "z"},
|
||||
{"a", "b"},
|
||||
{"a", "a"}, // tie on col 0 broken by col 1
|
||||
}
|
||||
Sort(rows)
|
||||
assert.Equal(t, [][]string{{"a", "a"}, {"a", "b"}, {"b", "z"}}, rows)
|
||||
}
|
||||
|
||||
func TestRender_InvalidFormat(t *testing.T) {
|
||||
_, err := Render([][]string{{"x"}}, []string{"col"}, Options{Format: Format("invalid")})
|
||||
assert.Error(t, err)
|
||||
}
|
@@ -6,6 +6,20 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTrimmedSplitWithEscape(t *testing.T) {
|
||||
s := ` a\,b , c , \, d ` // escaped comma and escaped separator, spaces
|
||||
parts := TrimmedSplitWithEscape(s, ',', EscapeRune)
|
||||
// Expect trimming and escape handling; escaped separator stays in same token
|
||||
assert.Equal(t, []string{"a,b", "c", ", d"}, parts)
|
||||
}
|
||||
|
||||
func TestUnTrimmedSplitWithEscape(t *testing.T) {
|
||||
s := ` a\,b , c `
|
||||
parts := UnTrimmedSplitWithEscape(s, ',', EscapeRune)
|
||||
// No trimming; spaces preserved around segments
|
||||
assert.Equal(t, []string{" a,b ", " c "}, parts)
|
||||
}
|
||||
|
||||
func TestSplitWithEscape(t *testing.T) {
|
||||
t.Run("TrimmedEmptyString", func(t *testing.T) {
|
||||
testString := ""
|
||||
|
Reference in New Issue
Block a user