527 lines
14 KiB
Go
527 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestExtractedDirName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{name: "plain", in: "demo.tar.gz", want: "demo"},
|
|
{name: "mixed case suffix", in: "Demo.TAR.GZ", want: "Demo"},
|
|
{name: "other name", in: "demo.zip", want: "demo.zip"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := extractedDirName(tt.in)
|
|
if got != tt.want {
|
|
t.Fatalf("extractedDirName(%q) = %q, want %q", tt.in, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsStableEnough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC)
|
|
cfg := config{
|
|
stableFor: 30 * time.Second,
|
|
stableScans: 3,
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
stableCount int
|
|
modTime time.Time
|
|
want bool
|
|
}{
|
|
{name: "not enough scans", stableCount: 2, modTime: now.Add(-1 * time.Minute), want: false},
|
|
{name: "not enough age", stableCount: 3, modTime: now.Add(-20 * time.Second), want: false},
|
|
{name: "enough scans and age", stableCount: 3, modTime: now.Add(-45 * time.Second), want: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := isStableEnough(cfg, tt.stableCount, fileSnapshot{modTime: tt.modTime}, now)
|
|
if got != tt.want {
|
|
t.Fatalf("isStableEnough(...) = %t, want %t", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseExtensions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := parseExtensions(".csv, txt;PDF \n tar.gz")
|
|
if err != nil {
|
|
t.Fatalf("parseExtensions() error = %v", err)
|
|
}
|
|
|
|
want := []string{".csv", ".txt", ".pdf", ".tar.gz"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("parseExtensions() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateGenericDuplicateNameSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
sourcePath := filepath.Join(dir, "source.csv")
|
|
targetPath := filepath.Join(dir, "target.csv")
|
|
|
|
if err := os.WriteFile(sourcePath, []byte("abcd"), 0o644); err != nil {
|
|
t.Fatalf("write source error = %v", err)
|
|
}
|
|
if err := os.WriteFile(targetPath, []byte("wxyz"), 0o644); err != nil {
|
|
t.Fatalf("write target error = %v", err)
|
|
}
|
|
|
|
decision, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameSize)
|
|
if err != nil {
|
|
t.Fatalf("evaluateGenericDuplicate() error = %v", err)
|
|
}
|
|
if !duplicate {
|
|
t.Fatalf("expected same-size file to be treated as duplicate")
|
|
}
|
|
if decision == "" {
|
|
t.Fatalf("expected non-empty decision")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateGenericDuplicateNameHash(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
sourcePath := filepath.Join(dir, "source.csv")
|
|
targetPath := filepath.Join(dir, "target.csv")
|
|
|
|
if err := os.WriteFile(sourcePath, []byte("same-content"), 0o644); err != nil {
|
|
t.Fatalf("write source error = %v", err)
|
|
}
|
|
if err := os.WriteFile(targetPath, []byte("same-content"), 0o644); err != nil {
|
|
t.Fatalf("write target error = %v", err)
|
|
}
|
|
|
|
_, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameHash)
|
|
if err != nil {
|
|
t.Fatalf("evaluateGenericDuplicate() error = %v", err)
|
|
}
|
|
if !duplicate {
|
|
t.Fatalf("expected same-hash file to be treated as duplicate")
|
|
}
|
|
|
|
if err := os.WriteFile(targetPath, []byte("DIFF-content"), 0o644); err != nil {
|
|
t.Fatalf("rewrite target error = %v", err)
|
|
}
|
|
|
|
_, duplicate, err = evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameHash)
|
|
if err != nil {
|
|
t.Fatalf("evaluateGenericDuplicate() error = %v", err)
|
|
}
|
|
if duplicate {
|
|
t.Fatalf("expected different-hash file not to be treated as duplicate")
|
|
}
|
|
}
|
|
|
|
func TestTransferFileMoveRemovesSource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
sourcePath := filepath.Join(dir, "source.csv")
|
|
targetPath := filepath.Join(dir, "target.csv")
|
|
want := []byte("move-me")
|
|
|
|
if err := os.WriteFile(sourcePath, want, 0o644); err != nil {
|
|
t.Fatalf("write source error = %v", err)
|
|
}
|
|
|
|
if err := transferFile(sourcePath, targetPath, "move"); err != nil {
|
|
t.Fatalf("transferFile() error = %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(sourcePath); !os.IsNotExist(err) {
|
|
t.Fatalf("expected source to be removed after move, stat err = %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(targetPath)
|
|
if err != nil {
|
|
t.Fatalf("read target error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("target content = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProcessGenericCandidateMoveDuplicateDeletesSourceAfterHashVerification(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootDir := t.TempDir()
|
|
sourceDir := filepath.Join(rootDir, "source")
|
|
targetDir := filepath.Join(rootDir, "target")
|
|
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir source error = %v", err)
|
|
}
|
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir target error = %v", err)
|
|
}
|
|
|
|
sourcePath := filepath.Join(sourceDir, "dup.csv")
|
|
targetPath := filepath.Join(targetDir, "dup.csv")
|
|
data := []byte("same-content")
|
|
|
|
if err := os.WriteFile(sourcePath, data, 0o644); err != nil {
|
|
t.Fatalf("write source error = %v", err)
|
|
}
|
|
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
|
|
t.Fatalf("write target error = %v", err)
|
|
}
|
|
|
|
cfg := config{
|
|
mode: "move",
|
|
generic: genericRule{
|
|
targetDir: targetDir,
|
|
dedupeMode: genericDedupeNameSize,
|
|
},
|
|
}
|
|
|
|
decision, handled, err := processGenericCandidate(cfg, sourcePath)
|
|
if err != nil {
|
|
t.Fatalf("processGenericCandidate() error = %v", err)
|
|
}
|
|
if !handled {
|
|
t.Fatalf("expected duplicate move to be treated as handled")
|
|
}
|
|
if decision == "" {
|
|
t.Fatalf("expected non-empty decision")
|
|
}
|
|
if _, err := os.Stat(sourcePath); !os.IsNotExist(err) {
|
|
t.Fatalf("expected source to be removed, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessGenericCandidateMoveDuplicateKeepsSourceWhenHashDiffers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootDir := t.TempDir()
|
|
sourceDir := filepath.Join(rootDir, "source")
|
|
targetDir := filepath.Join(rootDir, "target")
|
|
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir source error = %v", err)
|
|
}
|
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir target error = %v", err)
|
|
}
|
|
|
|
sourcePath := filepath.Join(sourceDir, "dup.csv")
|
|
targetPath := filepath.Join(targetDir, "dup.csv")
|
|
|
|
if err := os.WriteFile(sourcePath, []byte("abcd"), 0o644); err != nil {
|
|
t.Fatalf("write source error = %v", err)
|
|
}
|
|
if err := os.WriteFile(targetPath, []byte("wxyz"), 0o644); err != nil {
|
|
t.Fatalf("write target error = %v", err)
|
|
}
|
|
|
|
cfg := config{
|
|
mode: "move",
|
|
generic: genericRule{
|
|
targetDir: targetDir,
|
|
dedupeMode: genericDedupeNameSize,
|
|
},
|
|
}
|
|
|
|
_, handled, err := processGenericCandidate(cfg, sourcePath)
|
|
if err != nil {
|
|
t.Fatalf("processGenericCandidate() error = %v", err)
|
|
}
|
|
if handled {
|
|
t.Fatalf("expected hash mismatch not to be treated as handled duplicate")
|
|
}
|
|
if _, err := os.Stat(sourcePath); err != nil {
|
|
t.Fatalf("expected source to remain, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessArchiveCandidateMoveDuplicateZipDeletesSource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootDir := t.TempDir()
|
|
sourceDir := filepath.Join(rootDir, "source")
|
|
zipDir := filepath.Join(rootDir, "zip")
|
|
unzipDir := filepath.Join(rootDir, "unzip")
|
|
for _, dir := range []string{sourceDir, zipDir, unzipDir} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %q error = %v", dir, err)
|
|
}
|
|
}
|
|
|
|
sourcePath := filepath.Join(sourceDir, "demo.tar.gz")
|
|
targetPath := filepath.Join(zipDir, "demo.tar.gz")
|
|
data := []byte("archive-binary")
|
|
|
|
if err := os.WriteFile(sourcePath, data, 0o644); err != nil {
|
|
t.Fatalf("write source archive error = %v", err)
|
|
}
|
|
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
|
|
t.Fatalf("write target archive error = %v", err)
|
|
}
|
|
|
|
cfg := config{
|
|
mode: "move",
|
|
archive: archiveRule{
|
|
zipDir: zipDir,
|
|
unzipDir: unzipDir,
|
|
},
|
|
}
|
|
|
|
decision, handled, err := processArchiveCandidate(cfg, sourcePath)
|
|
if err != nil {
|
|
t.Fatalf("processArchiveCandidate() error = %v", err)
|
|
}
|
|
if !handled {
|
|
t.Fatalf("expected duplicate archive move to be treated as handled")
|
|
}
|
|
if decision == "" {
|
|
t.Fatalf("expected non-empty decision")
|
|
}
|
|
if _, err := os.Stat(sourcePath); !os.IsNotExist(err) {
|
|
t.Fatalf("expected source archive to be removed, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessArchiveCandidateMoveDuplicateUnzipDeletesSource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootDir := t.TempDir()
|
|
sourceDir := filepath.Join(rootDir, "source")
|
|
zipDir := filepath.Join(rootDir, "zip")
|
|
unzipDir := filepath.Join(rootDir, "unzip")
|
|
for _, dir := range []string{sourceDir, zipDir, unzipDir} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %q error = %v", dir, err)
|
|
}
|
|
}
|
|
|
|
sourcePath := filepath.Join(sourceDir, "demo.tar.gz")
|
|
unzipTarget := filepath.Join(unzipDir, "demo")
|
|
files := map[string]string{
|
|
"a.txt": "hello",
|
|
"nested/b.json": `{"ok":true}`,
|
|
"nested/c/data": "payload",
|
|
}
|
|
|
|
if err := writeTarGz(sourcePath, "demo", files); err != nil {
|
|
t.Fatalf("write tar.gz error = %v", err)
|
|
}
|
|
if err := writeExtractedDir(unzipTarget, files); err != nil {
|
|
t.Fatalf("write extracted dir error = %v", err)
|
|
}
|
|
|
|
cfg := config{
|
|
mode: "move",
|
|
archive: archiveRule{
|
|
zipDir: zipDir,
|
|
unzipDir: unzipDir,
|
|
},
|
|
}
|
|
|
|
decision, handled, err := processArchiveCandidate(cfg, sourcePath)
|
|
if err != nil {
|
|
t.Fatalf("processArchiveCandidate() error = %v", err)
|
|
}
|
|
if !handled {
|
|
t.Fatalf("expected matching unzip directory to be treated as handled duplicate")
|
|
}
|
|
if decision == "" {
|
|
t.Fatalf("expected non-empty decision")
|
|
}
|
|
if _, err := os.Stat(sourcePath); !os.IsNotExist(err) {
|
|
t.Fatalf("expected source archive to be removed, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessArchiveCandidateMoveDuplicateUnzipKeepsSourceWhenContentDiffers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootDir := t.TempDir()
|
|
sourceDir := filepath.Join(rootDir, "source")
|
|
zipDir := filepath.Join(rootDir, "zip")
|
|
unzipDir := filepath.Join(rootDir, "unzip")
|
|
for _, dir := range []string{sourceDir, zipDir, unzipDir} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %q error = %v", dir, err)
|
|
}
|
|
}
|
|
|
|
sourcePath := filepath.Join(sourceDir, "demo.tar.gz")
|
|
unzipTarget := filepath.Join(unzipDir, "demo")
|
|
if err := writeTarGz(sourcePath, "demo", map[string]string{"a.txt": "hello"}); err != nil {
|
|
t.Fatalf("write tar.gz error = %v", err)
|
|
}
|
|
if err := writeExtractedDir(unzipTarget, map[string]string{"a.txt": "DIFF"}); err != nil {
|
|
t.Fatalf("write extracted dir error = %v", err)
|
|
}
|
|
|
|
cfg := config{
|
|
mode: "move",
|
|
archive: archiveRule{
|
|
zipDir: zipDir,
|
|
unzipDir: unzipDir,
|
|
},
|
|
}
|
|
|
|
_, handled, err := processArchiveCandidate(cfg, sourcePath)
|
|
if err != nil {
|
|
t.Fatalf("processArchiveCandidate() error = %v", err)
|
|
}
|
|
if handled {
|
|
t.Fatalf("expected different unzip content not to be treated as handled duplicate")
|
|
}
|
|
if _, err := os.Stat(sourcePath); err != nil {
|
|
t.Fatalf("expected source archive to remain, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSaveAndLoadSettings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
settingsPath := filepath.Join(t.TempDir(), "settings.json")
|
|
want := formValues{
|
|
ArchiveEnabled: true,
|
|
ArchiveSourceDir: `\\server\share\incoming`,
|
|
ArchiveZipDir: `D:\zip`,
|
|
ArchiveUnzipDir: `D:\unzip`,
|
|
GenericEnabled: true,
|
|
GenericSourceDir: `\\server\share\others`,
|
|
GenericTargetDir: `D:\files`,
|
|
GenericExts: ".csv,.txt",
|
|
GenericDedupe: genericDedupeNameHash,
|
|
Interval: "15s",
|
|
StableFor: "1m",
|
|
StableScans: "3",
|
|
Mode: "move",
|
|
Recursive: true,
|
|
}
|
|
|
|
if err := saveSettings(settingsPath, want); err != nil {
|
|
t.Fatalf("saveSettings() error = %v", err)
|
|
}
|
|
|
|
got, err := loadSettings(settingsPath)
|
|
if err != nil {
|
|
t.Fatalf("loadSettings() error = %v", err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("loadSettings() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadLegacySettings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
settingsPath := filepath.Join(t.TempDir(), "legacy.json")
|
|
legacyJSON := `{
|
|
"source_dir": "\\\\server\\share\\incoming",
|
|
"zip_dir": "D:\\zip",
|
|
"unzip_dir": "D:\\unzip",
|
|
"interval": "15s",
|
|
"stable_for": "1m",
|
|
"stable_scans": "3",
|
|
"mode": "copy",
|
|
"recursive": true
|
|
}`
|
|
|
|
if err := os.WriteFile(settingsPath, []byte(legacyJSON), 0o644); err != nil {
|
|
t.Fatalf("write legacy settings error = %v", err)
|
|
}
|
|
|
|
got, err := loadSettings(settingsPath)
|
|
if err != nil {
|
|
t.Fatalf("loadSettings() error = %v", err)
|
|
}
|
|
|
|
if !got.ArchiveEnabled {
|
|
t.Fatalf("expected archive rule to be enabled for legacy settings")
|
|
}
|
|
if got.ArchiveSourceDir != `\\server\share\incoming` {
|
|
t.Fatalf("unexpected archive source = %q", got.ArchiveSourceDir)
|
|
}
|
|
if got.GenericEnabled {
|
|
t.Fatalf("did not expect generic rule to be enabled for legacy settings")
|
|
}
|
|
if got.GenericDedupe != genericDedupeNameSize {
|
|
t.Fatalf("expected legacy settings to default to name_size dedupe")
|
|
}
|
|
}
|
|
|
|
func writeTarGz(targetPath, rootName string, files map[string]string) error {
|
|
file, err := os.Create(targetPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
gzipWriter := gzip.NewWriter(file)
|
|
defer gzipWriter.Close()
|
|
|
|
tarWriter := tar.NewWriter(gzipWriter)
|
|
defer tarWriter.Close()
|
|
|
|
paths := make([]string, 0, len(files))
|
|
for name := range files {
|
|
paths = append(paths, name)
|
|
}
|
|
sort.Strings(paths)
|
|
|
|
for _, name := range paths {
|
|
body := []byte(files[name])
|
|
header := &tar.Header{
|
|
Name: filepath.ToSlash(filepath.Join(rootName, name)),
|
|
Mode: 0o644,
|
|
Size: int64(len(body)),
|
|
}
|
|
if err := tarWriter.WriteHeader(header); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tarWriter.Write(body); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func writeExtractedDir(root string, files map[string]string) error {
|
|
for name, body := range files {
|
|
targetPath := filepath.Join(root, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(targetPath, []byte(body), 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|