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 }