esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

syncer_test.go (13376B)


      1 package syncer
      2 
      3 import (
      4 	"strings"
      5 	"testing"
      6 
      7 	"github.com/louloulibs/esync/internal/config"
      8 )
      9 
     10 // ---------------------------------------------------------------------------
     11 // Helper: build a minimal Config for testing
     12 // ---------------------------------------------------------------------------
     13 func minimalConfig(local, remote string) *config.Config {
     14 	return &config.Config{
     15 		Sync: config.SyncSection{
     16 			Local:  local,
     17 			Remote: remote,
     18 		},
     19 		Settings: config.Settings{
     20 			Rsync: config.RsyncSettings{
     21 				Archive:  true,
     22 				Compress: true,
     23 				Progress: true,
     24 			},
     25 		},
     26 	}
     27 }
     28 
     29 // ---------------------------------------------------------------------------
     30 // 1. TestBuildCommand_Local — verify rsync flags for local sync
     31 // ---------------------------------------------------------------------------
     32 func TestBuildCommand_Local(t *testing.T) {
     33 	cfg := minimalConfig("/home/user/src", "/data/dest")
     34 
     35 	s := New(cfg)
     36 	cmd := s.BuildCommand()
     37 
     38 	// Should start with rsync (possibly absolute path)
     39 	if !strings.HasSuffix(cmd[0], "rsync") {
     40 		t.Errorf("cmd[0] = %q, want rsync binary", cmd[0])
     41 	}
     42 
     43 	// Must contain base flags
     44 	for _, flag := range []string{"--recursive", "--times", "--progress", "--info=progress2", "--copy-unsafe-links"} {
     45 		if !containsArg(cmd, flag) {
     46 			t.Errorf("missing flag %q in %v", flag, cmd)
     47 		}
     48 	}
     49 
     50 	// Archive and compress are true by default
     51 	if !containsArg(cmd, "--archive") {
     52 		t.Error("missing --archive flag")
     53 	}
     54 	if !containsArg(cmd, "--compress") {
     55 		t.Error("missing --compress flag")
     56 	}
     57 
     58 	// Source must end with /
     59 	source := cmd[len(cmd)-2]
     60 	if !strings.HasSuffix(source, "/") {
     61 		t.Errorf("source = %q, must end with /", source)
     62 	}
     63 	if source != "/home/user/src/" {
     64 		t.Errorf("source = %q, want %q", source, "/home/user/src/")
     65 	}
     66 
     67 	// Destination is last argument
     68 	dest := cmd[len(cmd)-1]
     69 	if dest != "/data/dest" {
     70 		t.Errorf("destination = %q, want %q", dest, "/data/dest")
     71 	}
     72 
     73 	// No -e flag for local sync without SSH config
     74 	if containsArgPrefix(cmd, "-e") {
     75 		t.Error("should not have -e flag for local sync")
     76 	}
     77 }
     78 
     79 // ---------------------------------------------------------------------------
     80 // 2. TestBuildCommand_Remote — verify remote destination format
     81 // ---------------------------------------------------------------------------
     82 func TestBuildCommand_Remote(t *testing.T) {
     83 	cfg := minimalConfig("/home/user/src", "user@server:/data/dest")
     84 
     85 	s := New(cfg)
     86 	cmd := s.BuildCommand()
     87 
     88 	// Destination should be the raw remote string
     89 	dest := cmd[len(cmd)-1]
     90 	if dest != "user@server:/data/dest" {
     91 		t.Errorf("destination = %q, want %q", dest, "user@server:/data/dest")
     92 	}
     93 }
     94 
     95 // ---------------------------------------------------------------------------
     96 // 3. TestBuildCommand_SSHConfig — verify -e flag with SSH options
     97 // ---------------------------------------------------------------------------
     98 func TestBuildCommand_SSHConfig(t *testing.T) {
     99 	cfg := minimalConfig("/home/user/src", "/data/dest")
    100 	cfg.Sync.SSH = &config.SSHConfig{
    101 		Host:         "myserver.com",
    102 		User:         "deploy",
    103 		Port:         2222,
    104 		IdentityFile: "~/.ssh/id_ed25519",
    105 	}
    106 
    107 	s := New(cfg)
    108 	cmd := s.BuildCommand()
    109 
    110 	// Should contain -e flag
    111 	eIdx := indexOfArg(cmd, "-e")
    112 	if eIdx < 0 {
    113 		t.Fatal("missing -e flag")
    114 	}
    115 
    116 	// The SSH command string follows -e
    117 	sshCmd := cmd[eIdx+1]
    118 	if !strings.Contains(sshCmd, "ssh") {
    119 		t.Errorf("SSH command should start with ssh, got %q", sshCmd)
    120 	}
    121 	if !strings.Contains(sshCmd, "-p 2222") {
    122 		t.Errorf("SSH command missing port, got %q", sshCmd)
    123 	}
    124 	if !strings.Contains(sshCmd, "-i ~/.ssh/id_ed25519") {
    125 		t.Errorf("SSH command missing identity file, got %q", sshCmd)
    126 	}
    127 	// ControlMaster options
    128 	if !strings.Contains(sshCmd, "-o ControlMaster=auto") {
    129 		t.Errorf("SSH command missing ControlMaster, got %q", sshCmd)
    130 	}
    131 	if !strings.Contains(sshCmd, "-o ControlPath=/tmp/esync-ssh-%r@%h:%p") {
    132 		t.Errorf("SSH command missing ControlPath, got %q", sshCmd)
    133 	}
    134 	if !strings.Contains(sshCmd, "-o ControlPersist=600") {
    135 		t.Errorf("SSH command missing ControlPersist, got %q", sshCmd)
    136 	}
    137 
    138 	// Destination should be user@host:/path when SSH is configured
    139 	dest := cmd[len(cmd)-1]
    140 	if dest != "deploy@myserver.com:/data/dest" {
    141 		t.Errorf("destination = %q, want %q", dest, "deploy@myserver.com:/data/dest")
    142 	}
    143 }
    144 
    145 // ---------------------------------------------------------------------------
    146 // 4. TestBuildCommand_ExcludePatterns — verify --exclude for combined patterns
    147 // ---------------------------------------------------------------------------
    148 func TestBuildCommand_ExcludePatterns(t *testing.T) {
    149 	cfg := minimalConfig("/src", "/dst")
    150 	cfg.Settings.Ignore = []string{".git", "node_modules"}
    151 	cfg.Settings.Rsync.Ignore = []string{"**/*.tmp", "*.log"}
    152 
    153 	s := New(cfg)
    154 	cmd := s.BuildCommand()
    155 
    156 	// Should have --exclude for each pattern
    157 	// **/*.tmp should be stripped to *.tmp
    158 	expectedExcludes := []string{".git", "node_modules", "*.tmp", "*.log"}
    159 	for _, pattern := range expectedExcludes {
    160 		expected := "--exclude=" + pattern
    161 		if !containsArg(cmd, expected) {
    162 			t.Errorf("missing %q in %v", expected, cmd)
    163 		}
    164 	}
    165 }
    166 
    167 // ---------------------------------------------------------------------------
    168 // 5. TestBuildCommand_ExtraArgs — verify passthrough of extra_args
    169 // ---------------------------------------------------------------------------
    170 func TestBuildCommand_ExtraArgs(t *testing.T) {
    171 	cfg := minimalConfig("/src", "/dst")
    172 	cfg.Settings.Rsync.ExtraArgs = []string{"--delete", "--verbose"}
    173 
    174 	s := New(cfg)
    175 	cmd := s.BuildCommand()
    176 
    177 	if !containsArg(cmd, "--delete") {
    178 		t.Errorf("missing --delete in %v", cmd)
    179 	}
    180 	if !containsArg(cmd, "--verbose") {
    181 		t.Errorf("missing --verbose in %v", cmd)
    182 	}
    183 }
    184 
    185 // ---------------------------------------------------------------------------
    186 // 6. TestBuildCommand_DryRun — verify --dry-run flag
    187 // ---------------------------------------------------------------------------
    188 func TestBuildCommand_DryRun(t *testing.T) {
    189 	cfg := minimalConfig("/src", "/dst")
    190 
    191 	s := New(cfg)
    192 	s.DryRun = true
    193 	cmd := s.BuildCommand()
    194 
    195 	if !containsArg(cmd, "--dry-run") {
    196 		t.Errorf("missing --dry-run in %v", cmd)
    197 	}
    198 }
    199 
    200 // ---------------------------------------------------------------------------
    201 // 7. TestBuildCommand_Backup — verify --backup and --backup-dir flags
    202 // ---------------------------------------------------------------------------
    203 func TestBuildCommand_Backup(t *testing.T) {
    204 	cfg := minimalConfig("/src", "/dst")
    205 	cfg.Settings.Rsync.Backup = true
    206 	cfg.Settings.Rsync.BackupDir = ".my_backup"
    207 
    208 	s := New(cfg)
    209 	cmd := s.BuildCommand()
    210 
    211 	if !containsArg(cmd, "--backup") {
    212 		t.Errorf("missing --backup in %v", cmd)
    213 	}
    214 	if !containsArg(cmd, "--backup-dir=.my_backup") {
    215 		t.Errorf("missing --backup-dir=.my_backup in %v", cmd)
    216 	}
    217 }
    218 
    219 // ---------------------------------------------------------------------------
    220 // Additional tests for helper functions
    221 // ---------------------------------------------------------------------------
    222 
    223 func TestExtractFiles(t *testing.T) {
    224 	output := `sending incremental file list
    225 src/main.go
    226 src/utils.go
    227 config.toml
    228 
    229 sent 1,234 bytes  received 56 bytes  2,580.00 bytes/sec
    230 total size is 5,678  speedup is 4.40
    231 `
    232 	s := New(minimalConfig("/src", "/dst"))
    233 	files := s.extractFiles(output)
    234 
    235 	// Should extract the file lines (not the header or stats)
    236 	if len(files) != 3 {
    237 		t.Fatalf("extractFiles returned %d files, want 3: %v", len(files), files)
    238 	}
    239 	expected := []string{"src/main.go", "src/utils.go", "config.toml"}
    240 	for i, f := range files {
    241 		if f.Name != expected[i] {
    242 			t.Errorf("files[%d].Name = %q, want %q", i, f.Name, expected[i])
    243 		}
    244 	}
    245 }
    246 
    247 func TestExtractStats(t *testing.T) {
    248 	output := `sending incremental file list
    249 src/main.go
    250 
    251 Number of files: 10
    252 Number of regular files transferred: 3
    253 Total file size: 99,999 bytes
    254 Total transferred file size: 5,678 bytes
    255 sent 1,234 bytes  received 56 bytes  2,580.00 bytes/sec
    256 total size is 5,678  speedup is 4.40
    257 `
    258 	s := New(minimalConfig("/src", "/dst"))
    259 	count, bytes := s.extractStats(output)
    260 
    261 	if count != 3 {
    262 		t.Errorf("extractStats count = %d, want 3", count)
    263 	}
    264 	if bytes != 5678 {
    265 		t.Errorf("extractStats bytes = %d, want 5678", bytes)
    266 	}
    267 }
    268 
    269 func TestBuildSSHCommand_NoSSH(t *testing.T) {
    270 	cfg := minimalConfig("/src", "/dst")
    271 	s := New(cfg)
    272 	sshCmd := s.buildSSHCommand()
    273 	if sshCmd != "" {
    274 		t.Errorf("buildSSHCommand() = %q, want empty string for no SSH config", sshCmd)
    275 	}
    276 }
    277 
    278 func TestBuildDestination_Local(t *testing.T) {
    279 	cfg := minimalConfig("/src", "/dst")
    280 	s := New(cfg)
    281 	dest := s.buildDestination()
    282 	if dest != "/dst" {
    283 		t.Errorf("buildDestination() = %q, want %q", dest, "/dst")
    284 	}
    285 }
    286 
    287 func TestBuildDestination_SSHWithUser(t *testing.T) {
    288 	cfg := minimalConfig("/src", "/remote/path")
    289 	cfg.Sync.SSH = &config.SSHConfig{
    290 		Host: "myserver.com",
    291 		User: "deploy",
    292 	}
    293 	s := New(cfg)
    294 	dest := s.buildDestination()
    295 	if dest != "deploy@myserver.com:/remote/path" {
    296 		t.Errorf("buildDestination() = %q, want %q", dest, "deploy@myserver.com:/remote/path")
    297 	}
    298 }
    299 
    300 func TestBuildDestination_SSHWithoutUser(t *testing.T) {
    301 	cfg := minimalConfig("/src", "/remote/path")
    302 	cfg.Sync.SSH = &config.SSHConfig{
    303 		Host: "myserver.com",
    304 	}
    305 	s := New(cfg)
    306 	dest := s.buildDestination()
    307 	if dest != "myserver.com:/remote/path" {
    308 		t.Errorf("buildDestination() = %q, want %q", dest, "myserver.com:/remote/path")
    309 	}
    310 }
    311 
    312 // ---------------------------------------------------------------------------
    313 // 8. TestBuildCommand_IncludePatterns — verify include/exclude filter rules
    314 // ---------------------------------------------------------------------------
    315 func TestBuildCommand_IncludePatterns(t *testing.T) {
    316 	cfg := minimalConfig("/src", "/dst")
    317 	cfg.Settings.Include = []string{"src", "docs/api"}
    318 	cfg.Settings.Ignore = []string{".git"}
    319 
    320 	s := New(cfg)
    321 	cmd := s.BuildCommand()
    322 
    323 	// Should have include rules: file match, dir match, subtree for each prefix
    324 	// Plus ancestor dirs for nested paths
    325 	for _, expected := range []string{
    326 		"--include=src",    // file match
    327 		"--include=src/",   // dir match
    328 		"--include=src/**", // subtree
    329 		"--include=docs/",     // ancestor dir
    330 		"--include=docs/api",  // file match
    331 		"--include=docs/api/", // dir match
    332 		"--include=docs/api/**", // subtree
    333 	} {
    334 		if !containsArg(cmd, expected) {
    335 			t.Errorf("missing %s in %v", expected, cmd)
    336 		}
    337 	}
    338 	if !containsArg(cmd, "--exclude=.git") {
    339 		t.Errorf("missing --exclude=.git in %v", cmd)
    340 	}
    341 	if !containsArg(cmd, "--exclude=*") {
    342 		t.Errorf("missing --exclude=* catch-all in %v", cmd)
    343 	}
    344 
    345 	// Verify ordering: all --include before --exclude=*
    346 	lastInclude := -1
    347 	catchAllExclude := -1
    348 	for i, a := range cmd {
    349 		if strings.HasPrefix(a, "--include=") {
    350 			lastInclude = i
    351 		}
    352 		if a == "--exclude=*" {
    353 			catchAllExclude = i
    354 		}
    355 	}
    356 	if lastInclude >= catchAllExclude {
    357 		t.Errorf("--include rules must come before --exclude=* catch-all")
    358 	}
    359 
    360 	// Verify named excludes come before catch-all
    361 	gitExclude := -1
    362 	for i, a := range cmd {
    363 		if a == "--exclude=.git" {
    364 			gitExclude = i
    365 		}
    366 	}
    367 	if gitExclude >= catchAllExclude {
    368 		t.Errorf("--exclude=.git must come before --exclude=* catch-all")
    369 	}
    370 }
    371 
    372 // ---------------------------------------------------------------------------
    373 // 9. TestBuildCommand_NoIncludeMeansNoFilterRules — no include = no catch-all
    374 // ---------------------------------------------------------------------------
    375 func TestBuildCommand_NoIncludeMeansNoFilterRules(t *testing.T) {
    376 	cfg := minimalConfig("/src", "/dst")
    377 	cfg.Settings.Ignore = []string{".git"}
    378 
    379 	s := New(cfg)
    380 	cmd := s.BuildCommand()
    381 
    382 	// Should NOT have --include or --exclude=* catch-all
    383 	for _, a := range cmd {
    384 		if strings.HasPrefix(a, "--include=") {
    385 			t.Errorf("unexpected --include in %v", cmd)
    386 		}
    387 	}
    388 	if containsArg(cmd, "--exclude=*") {
    389 		t.Errorf("unexpected --exclude=* catch-all in %v", cmd)
    390 	}
    391 	// Regular excludes still present
    392 	if !containsArg(cmd, "--exclude=.git") {
    393 		t.Errorf("missing --exclude=.git in %v", cmd)
    394 	}
    395 }
    396 
    397 // ---------------------------------------------------------------------------
    398 // 10. TestBuildCommand_IncludeFiles — individual files in include list
    399 // ---------------------------------------------------------------------------
    400 func TestBuildCommand_IncludeFiles(t *testing.T) {
    401 	cfg := minimalConfig("/src", "/dst")
    402 	cfg.Settings.Include = []string{"readme.md", "Snakefile"}
    403 
    404 	s := New(cfg)
    405 	cmd := s.BuildCommand()
    406 
    407 	// Individual files get a bare --include (no trailing /)
    408 	if !containsArg(cmd, "--include=readme.md") {
    409 		t.Errorf("missing --include=readme.md in %v", cmd)
    410 	}
    411 	if !containsArg(cmd, "--include=Snakefile") {
    412 		t.Errorf("missing --include=Snakefile in %v", cmd)
    413 	}
    414 	// Catch-all must be present
    415 	if !containsArg(cmd, "--exclude=*") {
    416 		t.Errorf("missing --exclude=* catch-all in %v", cmd)
    417 	}
    418 }
    419 
    420 // ---------------------------------------------------------------------------
    421 // Test helpers
    422 // ---------------------------------------------------------------------------
    423 
    424 func containsArg(args []string, target string) bool {
    425 	for _, a := range args {
    426 		if a == target {
    427 			return true
    428 		}
    429 	}
    430 	return false
    431 }
    432 
    433 func containsArgPrefix(args []string, prefix string) bool {
    434 	for _, a := range args {
    435 		if strings.HasPrefix(a, prefix) {
    436 			return true
    437 		}
    438 	}
    439 	return false
    440 }
    441 
    442 func indexOfArg(args []string, target string) int {
    443 	for i, a := range args {
    444 		if a == target {
    445 			return i
    446 		}
    447 	}
    448 	return -1
    449 }