diff --git a/server/internal/service/replication_service_test.go b/server/internal/service/replication_service_test.go new file mode 100644 index 0000000..a5c657d --- /dev/null +++ b/server/internal/service/replication_service_test.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "backupx/server/internal/backup" + "backupx/server/internal/config" + "backupx/server/internal/database" + "backupx/server/internal/logger" + "backupx/server/internal/model" + "backupx/server/internal/repository" + "backupx/server/internal/storage" + "backupx/server/internal/storage/codec" + storageRclone "backupx/server/internal/storage/rclone" +) + +type replicationTestHarness struct { + repl *ReplicationService + execution *BackupExecutionService + records repository.BackupRecordRepository + destDir string + srcDir string +} + +func newReplicationTestHarness(t *testing.T) *replicationTestHarness { + t.Helper() + baseDir := t.TempDir() + sourceData := filepath.Join(baseDir, "data") + srcStore := filepath.Join(baseDir, "src-store") + destStore := filepath.Join(baseDir, "dest-store") + if err := os.MkdirAll(sourceData, 0o755); err != nil { + t.Fatalf("mkdir data: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceData, "index.html"), []byte("hello-replicate"), 0o644); err != nil { + t.Fatalf("write data: %v", err) + } + log, err := logger.New(config.LogConfig{Level: "error"}) + if err != nil { + t.Fatalf("logger.New: %v", err) + } + db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log) + if err != nil { + t.Fatalf("database.Open: %v", err) + } + cipher := codec.NewConfigCipher("replicate-secret") + targets := repository.NewStorageTargetRepository(db) + tasks := repository.NewBackupTaskRepository(db) + records := repository.NewBackupRecordRepository(db) + replications := repository.NewReplicationRecordRepository(db) + nodes := repository.NewNodeRepository(db) + + mkTarget := func(name, basePath string) { + cfg, err := cipher.EncryptJSON(map[string]any{"basePath": basePath}) + if err != nil { + t.Fatalf("EncryptJSON: %v", err) + } + if err := targets.Create(context.Background(), &model.StorageTarget{Name: name, Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil { + t.Fatalf("create target %s: %v", name, err) + } + } + mkTarget("src", srcStore) // ID 1 + mkTarget("dest", destStore) // ID 2 + + task := &model.BackupTask{Name: "repl-test", Type: "file", Enabled: true, SourcePath: sourceData, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"} + if err := tasks.Create(context.Background(), task); err != nil { + t.Fatalf("create task: %v", err) + } + + logHub := backup.NewLogHub() + runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil)) + storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory()) + execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "") + repl := NewReplicationService(replications, records, targets, nodes, storageRegistry, cipher, baseDir, 2) + + return &replicationTestHarness{repl: repl, execution: execution, records: records, destDir: destStore, srcDir: srcStore} +} + +func countFiles(t *testing.T, dir string) int { + t.Helper() + n := 0 + _ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error { + if err == nil && info != nil && !info.IsDir() { + n++ + } + return nil + }) + return n +} + +// TestReplicationService_MirrorsToDestTarget 覆盖正常路径:把成功备份从源存储复制到目标存储, +// 目标出现对象、源保留(复制非移动),记录终态为 success。 +func TestReplicationService_MirrorsToDestTarget(t *testing.T) { + h := newReplicationTestHarness(t) + ctx := context.Background() + backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1) + if err != nil { + t.Fatalf("RunTaskByIDSync: %v", err) + } + if backupDetail.Status != "success" { + t.Fatalf("expected backup success, got %s", backupDetail.Status) + } + if countFiles(t, h.destDir) != 0 { + t.Fatalf("dest store should be empty before replication") + } + + done := make(chan struct{}) + h.repl.async = func(job func()) { + go func() { job(); close(done) }() + } + summary, err := h.repl.Start(ctx, backupDetail.ID, 2, "tester") + if err != nil { + t.Fatalf("replication Start: %v", err) + } + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("replication did not complete in time") + } + + final, err := h.repl.Get(ctx, summary.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if final.Status != model.ReplicationStatusSuccess { + t.Fatalf("expected replication success, got %s (err=%s)", final.Status, final.ErrorMessage) + } + if countFiles(t, h.destDir) == 0 { + t.Fatal("dest store should contain the replicated object") + } + if countFiles(t, h.srcDir) == 0 { + t.Fatal("source object must remain after replication (copy, not move)") + } +} + +// TestReplicationService_RejectsSameTarget 校验:目标与源相同时同步拒绝。 +func TestReplicationService_RejectsSameTarget(t *testing.T) { + h := newReplicationTestHarness(t) + ctx := context.Background() + backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1) + if err != nil { + t.Fatalf("RunTaskByIDSync: %v", err) + } + // 备份写到 target 1;以 target 1 作为复制目标应被拒绝。 + if _, err := h.repl.Start(ctx, backupDetail.ID, 1, "tester"); err == nil { + t.Fatal("expected error when dest target equals source") + } else if !strings.Contains(err.Error(), "目标存储无效或与源相同") { + t.Fatalf("unexpected error: %v", err) + } +}