From f357f0d4db4394249021c63faa52ab09411cbe1e Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:46:33 +0000 Subject: [PATCH 1/3] fix: Introduce `JobStatus` --- backup/internal/job.go | 12 ++++++++++-- backup/internal/rsync.go | 15 +++++++-------- backup/internal/test/job_test.go | 16 +++++++--------- backup/internal/types.go | 2 +- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/backup/internal/job.go b/backup/internal/job.go index e922c50..ed76511 100644 --- a/backup/internal/job.go +++ b/backup/internal/job.go @@ -16,9 +16,17 @@ type JobYAML struct { Exclusions []string `yaml:"exclusions,omitempty"` } -func (job Job) Apply(rsync JobCommand) string { +type JobStatus string + +const ( + Success JobStatus = "SUCCESS" + Failure JobStatus = "FAILURE" + Skipped JobStatus = "SKIPPED" +) + +func (job Job) Apply(rsync JobCommand) JobStatus { if !job.Enabled { - return "SKIPPED" + return Skipped } return rsync.Run(job) diff --git a/backup/internal/rsync.go b/backup/internal/rsync.go index a1cd11e..921b260 100644 --- a/backup/internal/rsync.go +++ b/backup/internal/rsync.go @@ -25,7 +25,7 @@ func NewSyncCommand(binPath string, logPath string) SyncCommand { } } -func (command SyncCommand) Run(job Job) string { +func (command SyncCommand) Run(job Job) JobStatus { logPath := fmt.Sprintf("%s/job-%s.log", command.BaseLogPath, job.Name) args := ArgumentsForJob(job, logPath, false) @@ -38,17 +38,17 @@ func (command SyncCommand) PrintArgs(job Job, args []string) { fmt.Printf("Command: %s %s\n", command.BinPath, strings.Join(args, " ")) } -func (command SyncCommand) RunWithArgs(job Job, args []string) string { +func (command SyncCommand) RunWithArgs(job Job, args []string) JobStatus { command.PrintArgs(job, args) out, err := command.Executor.Execute(command.BinPath, args...) fmt.Printf("Output:\n%s\n", string(out)) if err != nil { - return "FAILURE" + return Failure } - return "SUCCESS" + return Success } type SimulateCommand struct { @@ -65,9 +65,8 @@ func NewSimulateCommand(binPath string, logPath string) SimulateCommand { } } -func (command SimulateCommand) Run(job Job) string { +func (command SimulateCommand) Run(job Job) JobStatus { logPath := fmt.Sprintf("%s/job-%s.log", command.BaseLogPath, job.Name) - args := ArgumentsForJob(job, logPath, true) return command.RunWithArgs(job, args) @@ -86,13 +85,13 @@ func NewListCommand(binPath string) ListCommand { }, } } -func (command ListCommand) Run(job Job) string { +func (command ListCommand) Run(job Job) JobStatus { logPath := fmt.Sprintf("%s/job-%s.log", command.BaseLogPath, job.Name) args := ArgumentsForJob(job, logPath, false) command.PrintArgs(job, args) - return "SUCCESS" + return Success } func (command SyncCommand) GetVersionInfo() (string, string, error) { diff --git a/backup/internal/test/job_test.go b/backup/internal/test/job_test.go index 217d4f0..45c025b 100644 --- a/backup/internal/test/job_test.go +++ b/backup/internal/test/job_test.go @@ -7,8 +7,6 @@ import ( "github.com/stretchr/testify/assert" ) -const statusSuccess = "SUCCESS" - type Option func(*internal.Job) func NewJob(opts ...Option) *internal.Job { @@ -87,7 +85,7 @@ func TestApply(t *testing.T) { ) status := job.Apply(rsync) - assert.Equal(t, statusSuccess, status) + assert.Equal(t, internal.Success, status) } func TestApply_Disabled(t *testing.T) { command := newMockSyncCommand() @@ -100,7 +98,7 @@ func TestApply_Disabled(t *testing.T) { ) status := disabledJob.Apply(command) - assert.Equal(t, "SKIPPED", status) + assert.Equal(t, internal.Skipped, status) } func TestApply_Invalid(t *testing.T) { @@ -114,7 +112,7 @@ func TestApply_Invalid(t *testing.T) { ) status := invalidJob.Apply(rsync) - assert.Equal(t, "FAILURE", status) + assert.Equal(t, internal.Failure, status) } func TestJobSkippedEnabledTrue(t *testing.T) { @@ -127,7 +125,7 @@ func TestJobSkippedEnabledTrue(t *testing.T) { ) status := job.Apply(rsync) - assert.Equal(t, statusSuccess, status) + assert.Equal(t, internal.Success, status) } func TestJobSkippedEnabledFalse(t *testing.T) { @@ -141,7 +139,7 @@ func TestJobSkippedEnabledFalse(t *testing.T) { ) status := disabledJob.Apply(rsync) - assert.Equal(t, "SKIPPED", status) + assert.Equal(t, internal.Skipped, status) } func TestJobSkippedEnabledOmitted(t *testing.T) { @@ -154,7 +152,7 @@ func TestJobSkippedEnabledOmitted(t *testing.T) { ) status := job.Apply(rsync) - assert.Equal(t, statusSuccess, status) + assert.Equal(t, internal.Success, status) } func TestApplyWithMockedRsync(t *testing.T) { @@ -170,7 +168,7 @@ func TestApplyWithMockedRsync(t *testing.T) { ) status := job.Apply(rsync) - assert.Equal(t, statusSuccess, status) + assert.Equal(t, internal.Success, status) assert.NotEmpty(t, mockExecutor.CapturedCommands) cmd := mockExecutor.CapturedCommands[0] diff --git a/backup/internal/types.go b/backup/internal/types.go index 5d0e840..38fc7e3 100644 --- a/backup/internal/types.go +++ b/backup/internal/types.go @@ -27,6 +27,6 @@ type Job struct { } type JobCommand interface { - Run(job Job) string + Run(job Job) JobStatus GetVersionInfo() (string, string, error) } From fdc7d950650630fc7eba8d982929ce140be6c3a9 Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:56:00 +0000 Subject: [PATCH 2/3] chore: Cleanup codebase --- backup/internal/{job_runner.go => exec.go} | 9 +- backup/internal/job.go | 61 +++++++++- backup/internal/job_command.go | 14 +++ backup/internal/rsync.go | 79 ++----------- backup/internal/rsync_list.go | 27 +++++ backup/internal/rsync_simulate.go | 26 +++++ backup/internal/rsync_sync.go | 26 +++++ backup/internal/test/check_test.go | 42 +++---- backup/internal/test/config_test.go | 94 ++++++++-------- backup/internal/test/helper_test.go | 4 +- backup/internal/test/job_test.go | 105 +++++------------- ...ock_executor_test.go => mock_exec_test.go} | 6 +- backup/internal/test/rsync_test.go | 26 ++--- backup/internal/types.go | 5 - 14 files changed, 276 insertions(+), 248 deletions(-) rename backup/internal/{job_runner.go => exec.go} (65%) create mode 100644 backup/internal/job_command.go create mode 100644 backup/internal/rsync_list.go create mode 100644 backup/internal/rsync_simulate.go create mode 100644 backup/internal/rsync_sync.go rename backup/internal/test/{mock_executor_test.go => mock_exec_test.go} (86%) diff --git a/backup/internal/job_runner.go b/backup/internal/exec.go similarity index 65% rename from backup/internal/job_runner.go rename to backup/internal/exec.go index 33a9c53..e72c984 100644 --- a/backup/internal/job_runner.go +++ b/backup/internal/exec.go @@ -7,16 +7,15 @@ import ( "strings" ) -// JobRunner interface for executing commands. -type JobRunner interface { +type Exec interface { Execute(name string, args ...string) ([]byte, error) } -// RealSync implements JobRunner using actual os/exec. -type RealSync struct{} +// OsExec implements Exec using actual os/exec. +type OsExec struct{} // Execute runs the actual command. -func (r *RealSync) Execute(name string, args ...string) ([]byte, error) { +func (r *OsExec) Execute(name string, args ...string) ([]byte, error) { ctx := context.Background() cmd := exec.CommandContext(ctx, name, args...) diff --git a/backup/internal/job.go b/backup/internal/job.go index ed76511..c6b32e9 100644 --- a/backup/internal/job.go +++ b/backup/internal/job.go @@ -16,13 +16,62 @@ type JobYAML struct { Exclusions []string `yaml:"exclusions,omitempty"` } -type JobStatus string +type Option func(*Job) -const ( - Success JobStatus = "SUCCESS" - Failure JobStatus = "FAILURE" - Skipped JobStatus = "SKIPPED" -) +func NewJob(opts ...Option) Job { + // Default values + job := &Job{ + Name: "job", + Source: "", + Target: "", + Delete: true, + Enabled: true, + Exclusions: []string{}, + } + + // Apply all options (overrides defaults) + for _, opt := range opts { + opt(job) + } + + return *job +} + +func WithDelete(del bool) Option { + return func(p *Job) { + p.Delete = del + } +} + +func WithName(name string) Option { + return func(p *Job) { + p.Name = name + } +} + +func WithSource(source string) Option { + return func(p *Job) { + p.Source = source + } +} + +func WithTarget(target string) Option { + return func(p *Job) { + p.Target = target + } +} + +func WithEnabled(enabled bool) Option { + return func(p *Job) { + p.Enabled = enabled + } +} + +func WithExclusions(exclusions []string) Option { + return func(p *Job) { + p.Exclusions = exclusions + } +} func (job Job) Apply(rsync JobCommand) JobStatus { if !job.Enabled { diff --git a/backup/internal/job_command.go b/backup/internal/job_command.go new file mode 100644 index 0000000..f7c772f --- /dev/null +++ b/backup/internal/job_command.go @@ -0,0 +1,14 @@ +package internal + +type JobStatus string + +const ( + Success JobStatus = "SUCCESS" + Failure JobStatus = "FAILURE" + Skipped JobStatus = "SKIPPED" +) + +type JobCommand interface { + Run(job Job) JobStatus + GetVersionInfo() (string, string, error) +} diff --git a/backup/internal/rsync.go b/backup/internal/rsync.go index 921b260..4a6b104 100644 --- a/backup/internal/rsync.go +++ b/backup/internal/rsync.go @@ -10,38 +10,22 @@ import ( var ErrInvalidRsyncVersion = errors.New("invalid rsync version output") var ErrInvalidRsyncPath = errors.New("rsync path must be an absolute path") -type SyncCommand struct { +type SharedCommand struct { BinPath string BaseLogPath string - Executor JobRunner + Shell Exec } -func NewSyncCommand(binPath string, logPath string) SyncCommand { - return SyncCommand{ - BinPath: binPath, - BaseLogPath: logPath, - Executor: &RealSync{}, - } -} - -func (command SyncCommand) Run(job Job) JobStatus { - logPath := fmt.Sprintf("%s/job-%s.log", command.BaseLogPath, job.Name) - - args := ArgumentsForJob(job, logPath, false) - - return command.RunWithArgs(job, args) -} - -func (command SyncCommand) PrintArgs(job Job, args []string) { +func (c SharedCommand) PrintArgs(job Job, args []string) { fmt.Printf("Job: %s\n", job.Name) - fmt.Printf("Command: %s %s\n", command.BinPath, strings.Join(args, " ")) + fmt.Printf("Command: %s %s\n", c.BinPath, strings.Join(args, " ")) } -func (command SyncCommand) RunWithArgs(job Job, args []string) JobStatus { - command.PrintArgs(job, args) +func (c SharedCommand) RunWithArgs(job Job, args []string) JobStatus { + c.PrintArgs(job, args) - out, err := command.Executor.Execute(command.BinPath, args...) + out, err := c.Shell.Execute(c.BinPath, args...) fmt.Printf("Output:\n%s\n", string(out)) if err != nil { @@ -51,57 +35,14 @@ func (command SyncCommand) RunWithArgs(job Job, args []string) JobStatus { return Success } -type SimulateCommand struct { - SyncCommand -} - -func NewSimulateCommand(binPath string, logPath string) SimulateCommand { - return SimulateCommand{ - SyncCommand: SyncCommand{ - BinPath: binPath, - BaseLogPath: logPath, - Executor: &RealSync{}, - }, - } -} - -func (command SimulateCommand) Run(job Job) JobStatus { - logPath := fmt.Sprintf("%s/job-%s.log", command.BaseLogPath, job.Name) - args := ArgumentsForJob(job, logPath, true) - - return command.RunWithArgs(job, args) -} - -type ListCommand struct { - SyncCommand -} - -func NewListCommand(binPath string) ListCommand { - return ListCommand{ - SyncCommand: SyncCommand{ - BinPath: binPath, - BaseLogPath: "", - Executor: &RealSync{}, - }, - } -} -func (command ListCommand) Run(job Job) JobStatus { - logPath := fmt.Sprintf("%s/job-%s.log", command.BaseLogPath, job.Name) - - args := ArgumentsForJob(job, logPath, false) - command.PrintArgs(job, args) - - return Success -} - -func (command SyncCommand) GetVersionInfo() (string, string, error) { - rsyncPath := command.BinPath +func (c SharedCommand) GetVersionInfo() (string, string, error) { + rsyncPath := c.BinPath if !filepath.IsAbs(rsyncPath) { return "", "", fmt.Errorf("%w: \"%s\"", ErrInvalidRsyncPath, rsyncPath) } - output, err := command.Executor.Execute(command.BinPath, "--version") + output, err := c.Shell.Execute(c.BinPath, "--version") if err != nil { return "", "", fmt.Errorf("error fetching rsync version: %w", err) } diff --git a/backup/internal/rsync_list.go b/backup/internal/rsync_list.go new file mode 100644 index 0000000..b181af0 --- /dev/null +++ b/backup/internal/rsync_list.go @@ -0,0 +1,27 @@ +package internal + +import ( + "fmt" +) + +type ListCommand struct { + SharedCommand +} + +func NewListCommand(binPath string) ListCommand { + return ListCommand{ + SharedCommand: SharedCommand{ + BinPath: binPath, + BaseLogPath: "", + Shell: &OsExec{}, + }, + } +} +func (c ListCommand) Run(job Job) JobStatus { + logPath := fmt.Sprintf("%s/job-%s.log", c.BaseLogPath, job.Name) + args := ArgumentsForJob(job, logPath, false) + + c.PrintArgs(job, args) + + return Success +} diff --git a/backup/internal/rsync_simulate.go b/backup/internal/rsync_simulate.go new file mode 100644 index 0000000..8eaf951 --- /dev/null +++ b/backup/internal/rsync_simulate.go @@ -0,0 +1,26 @@ +package internal + +import ( + "fmt" +) + +type SimulateCommand struct { + SharedCommand +} + +func NewSimulateCommand(binPath string, logPath string) SimulateCommand { + return SimulateCommand{ + SharedCommand: SharedCommand{ + BinPath: binPath, + BaseLogPath: logPath, + Shell: &OsExec{}, + }, + } +} + +func (c SimulateCommand) Run(job Job) JobStatus { + logPath := fmt.Sprintf("%s/job-%s.log", c.BaseLogPath, job.Name) + args := ArgumentsForJob(job, logPath, true) + + return c.RunWithArgs(job, args) +} diff --git a/backup/internal/rsync_sync.go b/backup/internal/rsync_sync.go new file mode 100644 index 0000000..ea22044 --- /dev/null +++ b/backup/internal/rsync_sync.go @@ -0,0 +1,26 @@ +package internal + +import ( + "fmt" +) + +type SyncCommand struct { + SharedCommand +} + +func NewSyncCommand(binPath string, logPath string) SyncCommand { + return SyncCommand{ + SharedCommand: SharedCommand{ + BinPath: binPath, + BaseLogPath: logPath, + Shell: &OsExec{}, + }, + } +} + +func (c SyncCommand) Run(job Job) JobStatus { + logPath := fmt.Sprintf("%s/job-%s.log", c.BaseLogPath, job.Name) + args := ArgumentsForJob(job, logPath, false) + + return c.RunWithArgs(job, args) +} diff --git a/backup/internal/test/check_test.go b/backup/internal/test/check_test.go index ae88a36..3c4a139 100644 --- a/backup/internal/test/check_test.go +++ b/backup/internal/test/check_test.go @@ -7,14 +7,14 @@ import ( "sort" "testing" - "backup-rsync/backup/internal" + . "backup-rsync/backup/internal" "github.com/spf13/afero" "github.com/stretchr/testify/assert" ) func TestIsExcludedGlobally_PathGloballyExcluded(t *testing.T) { - sources := []internal.Path{ + sources := []Path{ { Path: "/home/data/", Exclusions: []string{"/projects/P1/", "/media/"}, @@ -32,13 +32,13 @@ func TestIsExcludedGlobally_PathGloballyExcluded(t *testing.T) { expectsError := true expectedLog := "Path '/home/data/projects/P1' is globally excluded by '/projects/P1/' in source '/home/data/'" - result := internal.IsExcludedGlobally(path, sources) + result := IsExcludedGlobally(path, sources) assert.Equal(t, expectsError, result) assert.Contains(t, logBuffer.String(), expectedLog) } func TestIsExcludedGlobally_PathNotExcluded(t *testing.T) { - sources := []internal.Path{ + sources := []Path{ { Path: "/home/data/", Exclusions: []string{"/projects/P1/", "/media/"}, @@ -55,12 +55,12 @@ func TestIsExcludedGlobally_PathNotExcluded(t *testing.T) { path := "/home/data/projects/Other" expectsError := false - result := internal.IsExcludedGlobally(path, sources) + result := IsExcludedGlobally(path, sources) assert.Equal(t, expectsError, result) } func TestIsExcludedGlobally_PathExcludedInAnotherSource(t *testing.T) { - sources := []internal.Path{ + sources := []Path{ { Path: "/home/data/", Exclusions: []string{"/projects/P1/", "/media/"}, @@ -78,7 +78,7 @@ func TestIsExcludedGlobally_PathExcludedInAnotherSource(t *testing.T) { expectsError := true expectedLog := "Path '/home/user/cache' is globally excluded by '/cache/' in source '/home/user/'" - result := internal.IsExcludedGlobally(path, sources) + result := IsExcludedGlobally(path, sources) assert.Equal(t, expectsError, result) assert.Contains(t, logBuffer.String(), expectedLog) } @@ -86,7 +86,7 @@ func TestIsExcludedGlobally_PathExcludedInAnotherSource(t *testing.T) { func runListUncoveredPathsTest( t *testing.T, fakeFS map[string][]string, - cfg internal.Config, + cfg Config, expectedUncoveredPaths []string, ) { t.Helper() @@ -103,7 +103,7 @@ func runListUncoveredPathsTest( } // Call the function - uncoveredPaths := internal.ListUncoveredPaths(fs, cfg) + uncoveredPaths := ListUncoveredPaths(fs, cfg) // Assertions sort.Strings(uncoveredPaths) @@ -120,12 +120,12 @@ func TestListUncoveredPathsVariationsAllCovered(t *testing.T) { "/var/log": {"app1", "app2"}, "/tmp": {"cache", "temp"}, }, - internal.Config{ - Sources: []internal.Path{ + Config{ + Sources: []Path{ {Path: "/var/log"}, {Path: "/tmp"}, }, - Jobs: []internal.Job{ + Jobs: []Job{ {Name: "Job1", Source: "/var/log"}, {Name: "Job2", Source: "/tmp"}, }, @@ -143,12 +143,12 @@ func TestListUncoveredPathsVariationsOneCoveredOneUncovered(t *testing.T) { "/home/user/cache": {}, "/home/user/npm": {}, }, - internal.Config{ - Sources: []internal.Path{ + Config{ + Sources: []Path{ {Path: "/home/data"}, {Path: "/home/user"}, }, - Jobs: []internal.Job{ + Jobs: []Job{ {Name: "Job1", Source: "/home/data"}, }, }, @@ -162,11 +162,11 @@ func TestListUncoveredPathsVariationsUncoveredExcluded(t *testing.T) { map[string][]string{ "/home/data": {"projects", "media"}, }, - internal.Config{ - Sources: []internal.Path{ + Config{ + Sources: []Path{ {Path: "/home/data", Exclusions: []string{"media"}}, }, - Jobs: []internal.Job{ + Jobs: []Job{ {Name: "Job1", Source: "/home/data/projects"}, }, }, @@ -183,11 +183,11 @@ func TestListUncoveredPathsVariationsSubfoldersCovered(t *testing.T) { "/home/data/family/me": {"a"}, "/home/data/family/you": {"a"}, }, - internal.Config{ - Sources: []internal.Path{ + Config{ + Sources: []Path{ {Path: "/home/data"}, }, - Jobs: []internal.Job{ + Jobs: []Job{ {Name: "JobMe", Source: "/home/data/family/me"}, {Name: "JobYou", Source: "/home/data/family/you"}, }, diff --git a/backup/internal/test/config_test.go b/backup/internal/test/config_test.go index 2d99175..a75457e 100644 --- a/backup/internal/test/config_test.go +++ b/backup/internal/test/config_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" - "backup-rsync/backup/internal" + . "backup-rsync/backup/internal" ) func TestLoadConfig1(t *testing.T) { @@ -24,7 +24,7 @@ jobs: ` reader := bytes.NewReader([]byte(yamlData)) - cfg, err := internal.LoadConfig(reader) + cfg, err := LoadConfig(reader) require.NoError(t, err) assert.Equal(t, "/mnt/backup1", cfg.Variables["target_base"]) @@ -51,10 +51,10 @@ jobs: reader := bytes.NewReader([]byte(yamlData)) - cfg, err := internal.LoadConfig(reader) + cfg, err := LoadConfig(reader) require.NoError(t, err) - expected := []internal.Job{ + expected := []Job{ { Name: "job1", Source: "/source1", @@ -82,7 +82,7 @@ name: "test_job" source: "/source" target: "/target" ` - expected := internal.Job{ + expected := Job{ Name: "test_job", Source: "/source", Target: "/target", @@ -90,7 +90,7 @@ target: "/target" Enabled: true, } - var job internal.Job + var job Job err := yaml.Unmarshal([]byte(yamlData), &job) require.NoError(t, err) @@ -105,7 +105,7 @@ target: "/target" delete: false enabled: false ` - expected := internal.Job{ + expected := Job{ Name: "test_job", Source: "/source", Target: "/target", @@ -113,7 +113,7 @@ enabled: false Enabled: false, } - var job internal.Job + var job Job err := yaml.Unmarshal([]byte(yamlData), &job) require.NoError(t, err) @@ -127,7 +127,7 @@ source: "/source" target: "/target" delete: false ` - expected := internal.Job{ + expected := Job{ Name: "test_job", Source: "/source", Target: "/target", @@ -135,7 +135,7 @@ delete: false Enabled: true, // default } - var job internal.Job + var job Job err := yaml.Unmarshal([]byte(yamlData), &job) require.NoError(t, err) @@ -149,49 +149,49 @@ func TestSubstituteVariables(t *testing.T) { input := "${target_base}/user/music/home" expected := "/mnt/backup1/user/music/home" - result := internal.SubstituteVariables(input, variables) + result := SubstituteVariables(input, variables) assert.Equal(t, expected, result, "SubstituteVariables result mismatch") } func TestValidateJobNames_ValidJobNames(t *testing.T) { - jobs := []internal.Job{ + jobs := []Job{ {Name: "job1"}, {Name: "job2"}, } - err := internal.ValidateJobNames(jobs) + err := ValidateJobNames(jobs) assert.NoError(t, err) } func TestValidateJobNames_DuplicateJobNames(t *testing.T) { - jobs := []internal.Job{ + jobs := []Job{ {Name: "job1"}, {Name: "job1"}, } - err := internal.ValidateJobNames(jobs) + err := ValidateJobNames(jobs) require.Error(t, err) assert.Contains(t, err.Error(), "duplicate job name: job1") } func TestValidateJobNames_InvalidCharactersInJobName(t *testing.T) { - jobs := []internal.Job{ + jobs := []Job{ {Name: "job 1"}, } - err := internal.ValidateJobNames(jobs) + err := ValidateJobNames(jobs) require.Error(t, err) assert.Contains(t, err.Error(), "invalid characters in job name: job 1") } func TestValidateJobNames_MixedErrors(t *testing.T) { - jobs := []internal.Job{ + jobs := []Job{ {Name: "job1"}, {Name: "job 1"}, {Name: "job1"}, } - err := internal.ValidateJobNames(jobs) + err := ValidateJobNames(jobs) require.Error(t, err) assert.Contains(t, err.Error(), "duplicate job name: job1") } @@ -199,15 +199,15 @@ func TestValidateJobNames_MixedErrors(t *testing.T) { func TestValidatePath_ValidSourcePath(t *testing.T) { test := struct { jobPath string - paths []internal.Path + paths []Path pathType string }{ jobPath: "/home/user/documents", - paths: []internal.Path{{Path: "/home/user"}}, + paths: []Path{{Path: "/home/user"}}, pathType: "source", } - err := internal.ValidatePath(test.jobPath, test.paths, test.pathType, "job1") + err := ValidatePath(test.jobPath, test.paths, test.pathType, "job1") assert.NoError(t, err) } @@ -215,15 +215,15 @@ func TestValidatePath_ValidSourcePath(t *testing.T) { func TestValidatePath_InvalidSourcePath(t *testing.T) { test := struct { jobPath string - paths []internal.Path + paths []Path pathType string }{ jobPath: "/invalid/source", - paths: []internal.Path{{Path: "/home/user"}}, + paths: []Path{{Path: "/home/user"}}, pathType: "source", } - err := internal.ValidatePath(test.jobPath, test.paths, test.pathType, "job1") + err := ValidatePath(test.jobPath, test.paths, test.pathType, "job1") require.Error(t, err) assert.EqualError(t, err, "invalid path for job 'job1': source /invalid/source") @@ -232,15 +232,15 @@ func TestValidatePath_InvalidSourcePath(t *testing.T) { func TestValidatePath_ValidTargetPath(t *testing.T) { test := struct { jobPath string - paths []internal.Path + paths []Path pathType string }{ jobPath: "/mnt/backup/documents", - paths: []internal.Path{{Path: "/mnt/backup"}}, + paths: []Path{{Path: "/mnt/backup"}}, pathType: "target", } - err := internal.ValidatePath(test.jobPath, test.paths, test.pathType, "job1") + err := ValidatePath(test.jobPath, test.paths, test.pathType, "job1") assert.NoError(t, err) } @@ -248,15 +248,15 @@ func TestValidatePath_ValidTargetPath(t *testing.T) { func TestValidatePath_InvalidTargetPath(t *testing.T) { test := struct { jobPath string - paths []internal.Path + paths []Path pathType string }{ jobPath: "/invalid/target", - paths: []internal.Path{{Path: "/mnt/backup"}}, + paths: []Path{{Path: "/mnt/backup"}}, pathType: "target", } - err := internal.ValidatePath(test.jobPath, test.paths, test.pathType, "job1") + err := ValidatePath(test.jobPath, test.paths, test.pathType, "job1") require.Error(t, err) assert.EqualError(t, err, "invalid path for job 'job1': target /invalid/target") @@ -265,25 +265,25 @@ func TestValidatePath_InvalidTargetPath(t *testing.T) { func TestValidatePaths_ValidPaths(t *testing.T) { test := struct { name string - cfg internal.Config + cfg Config expectsError bool }{ name: "Valid paths", - cfg: internal.Config{ - Sources: []internal.Path{ + cfg: Config{ + Sources: []Path{ {Path: "/home/user"}, }, - Targets: []internal.Path{ + Targets: []Path{ {Path: "/mnt/backup"}, }, - Jobs: []internal.Job{ + Jobs: []Job{ {Name: "job1", Source: "/home/user/documents", Target: "/mnt/backup/documents"}, }, }, } t.Run(test.name, func(t *testing.T) { - err := internal.ValidatePaths(test.cfg) + err := ValidatePaths(test.cfg) assert.NoError(t, err) }) } @@ -291,19 +291,19 @@ func TestValidatePaths_ValidPaths(t *testing.T) { func TestValidatePaths_InvalidPaths(t *testing.T) { test := struct { name string - cfg internal.Config + cfg Config expectsError bool errorMessage string }{ name: "Invalid paths", - cfg: internal.Config{ - Sources: []internal.Path{ + cfg: Config{ + Sources: []Path{ {Path: "/home/user"}, }, - Targets: []internal.Path{ + Targets: []Path{ {Path: "/mnt/backup"}, }, - Jobs: []internal.Job{ + Jobs: []Job{ {Name: "job1", Source: "/invalid/source", Target: "/invalid/target"}, }, }, @@ -313,18 +313,18 @@ func TestValidatePaths_InvalidPaths(t *testing.T) { } t.Run(test.name, func(t *testing.T) { - err := internal.ValidatePaths(test.cfg) + err := ValidatePaths(test.cfg) require.Error(t, err) assert.EqualError(t, err, test.errorMessage) }) } func TestConfigString_ValidConfig(t *testing.T) { - cfg := internal.Config{ - Sources: []internal.Path{}, - Targets: []internal.Path{}, + cfg := Config{ + Sources: []Path{}, + Targets: []Path{}, Variables: map[string]string{}, - Jobs: []internal.Job{}, + Jobs: []Job{}, } expectedOutput := "sources: []\ntargets: []\nvariables: {}\njobs: []\n" diff --git a/backup/internal/test/helper_test.go b/backup/internal/test/helper_test.go index 69ce92a..42c3f54 100644 --- a/backup/internal/test/helper_test.go +++ b/backup/internal/test/helper_test.go @@ -3,7 +3,7 @@ package internal_test import ( "testing" - "backup-rsync/backup/internal" + . "backup-rsync/backup/internal" "github.com/stretchr/testify/assert" ) @@ -20,7 +20,7 @@ func TestNormalizePath(t *testing.T) { } for _, test := range tests { - result := internal.NormalizePath(test.input) + result := NormalizePath(test.input) assert.Equal(t, test.expected, result) } } diff --git a/backup/internal/test/job_test.go b/backup/internal/test/job_test.go index 45c025b..51d9bd6 100644 --- a/backup/internal/test/job_test.go +++ b/backup/internal/test/job_test.go @@ -1,75 +1,26 @@ package internal_test import ( - "backup-rsync/backup/internal" + . "backup-rsync/backup/internal" "testing" "github.com/stretchr/testify/assert" ) -type Option func(*internal.Job) - -func NewJob(opts ...Option) *internal.Job { - // Default values - job := &internal.Job{ - Name: "job", - Source: "", - Target: "", - Delete: true, - Enabled: true, - Exclusions: []string{}, - } - - // Apply all options (overrides defaults) - for _, opt := range opts { - opt(job) - } - - return job -} - -func WithName(name string) Option { - return func(p *internal.Job) { - p.Name = name - } -} - -func WithSource(source string) Option { - return func(p *internal.Job) { - p.Source = source - } -} - -func WithTarget(target string) Option { - return func(p *internal.Job) { - p.Target = target - } -} - -func WithEnabled(enabled bool) Option { - return func(p *internal.Job) { - p.Enabled = enabled - } -} - -func WithExclusions(exclusions []string) Option { - return func(p *internal.Job) { - p.Exclusions = exclusions - } -} - -func newMockSyncCommand() internal.SyncCommand { - return internal.SyncCommand{ - BinPath: "/usr/bin/rsync", - Executor: &MockCommandExecutor{}, +func newMockSyncCommand() SyncCommand { + return SyncCommand{ + SharedCommand: SharedCommand{ + BinPath: "/usr/bin/rsync", + Shell: &MockExec{}, + }, } } -func newMockSimulateCommand() internal.SimulateCommand { - return internal.SimulateCommand{ - SyncCommand: internal.SyncCommand{ - BinPath: "/usr/bin/rsync", - Executor: &MockCommandExecutor{}, +func newMockSimulateCommand() SimulateCommand { + return SimulateCommand{ + SharedCommand: SharedCommand{ + BinPath: "/usr/bin/rsync", + Shell: &MockExec{}, }, } } @@ -77,7 +28,7 @@ func newMockSimulateCommand() internal.SimulateCommand { func TestApply(t *testing.T) { rsync := newMockSimulateCommand() - job := *NewJob( + job := NewJob( WithName("test_job"), WithSource("/home/test/"), WithTarget("/mnt/backup1/test/"), @@ -85,12 +36,12 @@ func TestApply(t *testing.T) { ) status := job.Apply(rsync) - assert.Equal(t, internal.Success, status) + assert.Equal(t, Success, status) } func TestApply_Disabled(t *testing.T) { command := newMockSyncCommand() - disabledJob := *NewJob( + disabledJob := NewJob( WithName("disabled_job"), WithSource("/home/disabled/"), WithTarget("/mnt/backup1/disabled/"), @@ -98,40 +49,40 @@ func TestApply_Disabled(t *testing.T) { ) status := disabledJob.Apply(command) - assert.Equal(t, internal.Skipped, status) + assert.Equal(t, Skipped, status) } func TestApply_Invalid(t *testing.T) { rsync := newMockSyncCommand() // Test case for failure (simulate by providing invalid source path) - invalidJob := *NewJob( + invalidJob := NewJob( WithName("invalid_job"), WithSource("/invalid/source/path"), WithTarget("/mnt/backup1/invalid/"), ) status := invalidJob.Apply(rsync) - assert.Equal(t, internal.Failure, status) + assert.Equal(t, Failure, status) } func TestJobSkippedEnabledTrue(t *testing.T) { rsync := newMockSyncCommand() - job := *NewJob( + job := NewJob( WithName("test_job"), WithSource("/home/test/"), WithTarget("/mnt/backup1/test/"), ) status := job.Apply(rsync) - assert.Equal(t, internal.Success, status) + assert.Equal(t, Success, status) } func TestJobSkippedEnabledFalse(t *testing.T) { rsync := newMockSyncCommand() - disabledJob := *NewJob( + disabledJob := NewJob( WithName("disabled_job"), WithSource("/home/disabled/"), WithTarget("/mnt/backup1/disabled/"), @@ -139,28 +90,28 @@ func TestJobSkippedEnabledFalse(t *testing.T) { ) status := disabledJob.Apply(rsync) - assert.Equal(t, internal.Skipped, status) + assert.Equal(t, Skipped, status) } func TestJobSkippedEnabledOmitted(t *testing.T) { rsync := newMockSyncCommand() - job := *NewJob( + job := NewJob( WithName("omitted_enabled_job"), WithSource("/home/omitted/"), WithTarget("/mnt/backup1/omitted/"), ) status := job.Apply(rsync) - assert.Equal(t, internal.Success, status) + assert.Equal(t, Success, status) } func TestApplyWithMockedRsync(t *testing.T) { - mockExecutor := &MockCommandExecutor{} + mockExecutor := &MockExec{} rsync := newMockSimulateCommand() - rsync.Executor = mockExecutor + rsync.Shell = mockExecutor - job := *NewJob( + job := NewJob( WithName("test_job"), WithSource("/home/test/"), WithTarget("/mnt/backup1/test/"), @@ -168,7 +119,7 @@ func TestApplyWithMockedRsync(t *testing.T) { ) status := job.Apply(rsync) - assert.Equal(t, internal.Success, status) + assert.Equal(t, Success, status) assert.NotEmpty(t, mockExecutor.CapturedCommands) cmd := mockExecutor.CapturedCommands[0] diff --git a/backup/internal/test/mock_executor_test.go b/backup/internal/test/mock_exec_test.go similarity index 86% rename from backup/internal/test/mock_executor_test.go rename to backup/internal/test/mock_exec_test.go index 54ee553..c620528 100644 --- a/backup/internal/test/mock_executor_test.go +++ b/backup/internal/test/mock_exec_test.go @@ -8,8 +8,8 @@ import ( // Static error for testing. var ErrExitStatus23 = errors.New("exit status 23") -// MockCommandExecutor implements JobRunner for testing. -type MockCommandExecutor struct { +// MockExec implements Exec for testing. +type MockExec struct { CapturedCommands []MockCommand Output string Error error @@ -22,7 +22,7 @@ type MockCommand struct { } // Execute captures the command and simulates execution. -func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) { +func (m *MockExec) Execute(name string, args ...string) ([]byte, error) { m.CapturedCommands = append(m.CapturedCommands, MockCommand{ Name: name, Args: append([]string{}, args...), // Make a copy of args diff --git a/backup/internal/test/rsync_test.go b/backup/internal/test/rsync_test.go index fcfbf9b..a40ba6c 100644 --- a/backup/internal/test/rsync_test.go +++ b/backup/internal/test/rsync_test.go @@ -1,7 +1,7 @@ package internal_test import ( - "backup-rsync/backup/internal" + . "backup-rsync/backup/internal" "errors" "strings" "testing" @@ -15,12 +15,12 @@ var errCommandNotFound = errors.New("command not found") const rsyncPath = "/usr/bin/rsync" func TestArgumentsForJob(t *testing.T) { - job := *NewJob( + job := NewJob( WithSource("/home/user/Music/"), WithTarget("/target/user/music/home"), WithExclusions([]string{"*.tmp", "node_modules/"}), ) - args := internal.ArgumentsForJob(job, "", true) + args := ArgumentsForJob(job, "", true) expectedArgs := []string{ "--dry-run", "-aiv", "--stats", "--delete", @@ -32,9 +32,9 @@ func TestArgumentsForJob(t *testing.T) { } func TestGetVersionInfo_Success(t *testing.T) { - rsync := internal.SyncCommand{ + rsync := SharedCommand{ BinPath: rsyncPath, - Executor: &MockCommandExecutor{ + Shell: &MockExec{ Output: "rsync version 3.2.3 protocol version 31\n", Error: nil, }, @@ -48,9 +48,9 @@ func TestGetVersionInfo_Success(t *testing.T) { } func TestGetVersionInfo_CommandError(t *testing.T) { - rsync := internal.SyncCommand{ + rsync := SharedCommand{ BinPath: rsyncPath, - Executor: &MockCommandExecutor{ + Shell: &MockExec{ Output: "", Error: errCommandNotFound, }, @@ -64,9 +64,9 @@ func TestGetVersionInfo_CommandError(t *testing.T) { } func TestGetVersionInfo_InvalidOutput(t *testing.T) { - rsync := internal.SyncCommand{ + rsync := SharedCommand{ BinPath: rsyncPath, - Executor: &MockCommandExecutor{ + Shell: &MockExec{ Output: "invalid output", Error: nil, }, @@ -80,9 +80,9 @@ func TestGetVersionInfo_InvalidOutput(t *testing.T) { } func TestGetVersionInfo_EmptyPath(t *testing.T) { - rsync := internal.SyncCommand{ + rsync := SharedCommand{ BinPath: "", - Executor: &MockCommandExecutor{ + Shell: &MockExec{ Output: "", Error: nil, }, @@ -97,9 +97,9 @@ func TestGetVersionInfo_EmptyPath(t *testing.T) { } func TestGetVersionInfo_IncompletePath(t *testing.T) { - rsync := internal.SyncCommand{ + rsync := SharedCommand{ BinPath: "bin/rsync", - Executor: &MockCommandExecutor{ + Shell: &MockExec{ Output: "", Error: nil, }, diff --git a/backup/internal/types.go b/backup/internal/types.go index 38fc7e3..89dfc62 100644 --- a/backup/internal/types.go +++ b/backup/internal/types.go @@ -25,8 +25,3 @@ type Job struct { Enabled bool `yaml:"enabled"` Exclusions []string `yaml:"exclusions,omitempty"` } - -type JobCommand interface { - Run(job Job) JobStatus - GetVersionInfo() (string, string, error) -} From 3f4877034b3b7b7899dbbf3f3f1cfc6df441833c Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:46:02 +0000 Subject: [PATCH 3/3] feat: Use mockery to avoid hand crafted mocks --- .golangci.yml | 1 + .mockery.yml | 17 +++ backup/internal/config.go | 8 + backup/internal/helper.go | 6 + backup/internal/job.go | 69 ++------- backup/internal/rsync.go | 4 +- backup/internal/test/job_test.go | 125 ++++------------ backup/internal/test/mock_exec_test.go | 124 +++++++++++----- backup/internal/test/mock_jobcommand_test.go | 148 +++++++++++++++++++ backup/internal/test/rsync_test.go | 61 ++++---- backup/internal/types.go | 27 ---- go.mod | 1 + go.sum | 2 + 13 files changed, 352 insertions(+), 241 deletions(-) create mode 100644 .mockery.yml create mode 100644 backup/internal/test/mock_jobcommand_test.go delete mode 100644 backup/internal/types.go diff --git a/.golangci.yml b/.golangci.yml index 155da2d..9e7e0f4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,6 +29,7 @@ linters: - github.com/spf13/cobra - github.com/spf13/afero - github.com/stretchr/testify/assert + - github.com/stretchr/testify/mock - github.com/stretchr/testify/require - gopkg.in/yaml.v3 - backup-rsync/backup/internal diff --git a/.mockery.yml b/.mockery.yml new file mode 100644 index 0000000..8a35222 --- /dev/null +++ b/.mockery.yml @@ -0,0 +1,17 @@ +all: false +dir: '{{.InterfaceDir}}/test' +filename: mock_{{.InterfaceName | lower}}_test.go +force-file-write: true +formatter: goimports +generate: true +include-auto-generated: false +log-level: info +structname: 'Mock{{.InterfaceName}}' +pkgname: 'internal_test' +recursive: false +template: testify +packages: + backup-rsync/backup/internal: + interfaces: + Exec: + JobCommand: diff --git a/backup/internal/config.go b/backup/internal/config.go index dc3a347..1e8effd 100644 --- a/backup/internal/config.go +++ b/backup/internal/config.go @@ -20,6 +20,14 @@ var ( ErrOverlappingPath = errors.New("overlapping path detected") ) +// Config represents the overall backup configuration. +type Config struct { + Sources []Path `yaml:"sources"` + Targets []Path `yaml:"targets"` + Variables map[string]string `yaml:"variables"` + Jobs []Job `yaml:"jobs"` +} + func (cfg Config) String() string { out, _ := yaml.Marshal(cfg) diff --git a/backup/internal/helper.go b/backup/internal/helper.go index 752e6f0..02f573f 100644 --- a/backup/internal/helper.go +++ b/backup/internal/helper.go @@ -8,6 +8,12 @@ import ( "time" ) +// Path represents a source or target path with optional exclusions. +type Path struct { + Path string `yaml:"path"` + Exclusions []string `yaml:"exclusions"` +} + func NormalizePath(path string) string { return strings.TrimSuffix(strings.ReplaceAll(path, "//", "/"), "/") } diff --git a/backup/internal/job.go b/backup/internal/job.go index c6b32e9..b185988 100644 --- a/backup/internal/job.go +++ b/backup/internal/job.go @@ -6,6 +6,18 @@ import ( "gopkg.in/yaml.v3" ) +// Job represents a backup job configuration for a source/target pair. +// +//nolint:recvcheck // UnmarshalYAML requires pointer receiver while Apply uses value receiver +type Job struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Target string `yaml:"target"` + Delete bool `yaml:"delete"` + Enabled bool `yaml:"enabled"` + Exclusions []string `yaml:"exclusions,omitempty"` +} + // JobYAML is a helper struct for proper YAML unmarshaling with defaults. type JobYAML struct { Name string `yaml:"name"` @@ -16,63 +28,6 @@ type JobYAML struct { Exclusions []string `yaml:"exclusions,omitempty"` } -type Option func(*Job) - -func NewJob(opts ...Option) Job { - // Default values - job := &Job{ - Name: "job", - Source: "", - Target: "", - Delete: true, - Enabled: true, - Exclusions: []string{}, - } - - // Apply all options (overrides defaults) - for _, opt := range opts { - opt(job) - } - - return *job -} - -func WithDelete(del bool) Option { - return func(p *Job) { - p.Delete = del - } -} - -func WithName(name string) Option { - return func(p *Job) { - p.Name = name - } -} - -func WithSource(source string) Option { - return func(p *Job) { - p.Source = source - } -} - -func WithTarget(target string) Option { - return func(p *Job) { - p.Target = target - } -} - -func WithEnabled(enabled bool) Option { - return func(p *Job) { - p.Enabled = enabled - } -} - -func WithExclusions(exclusions []string) Option { - return func(p *Job) { - p.Exclusions = exclusions - } -} - func (job Job) Apply(rsync JobCommand) JobStatus { if !job.Enabled { return Skipped diff --git a/backup/internal/rsync.go b/backup/internal/rsync.go index 4a6b104..f5b39aa 100644 --- a/backup/internal/rsync.go +++ b/backup/internal/rsync.go @@ -10,6 +10,8 @@ import ( var ErrInvalidRsyncVersion = errors.New("invalid rsync version output") var ErrInvalidRsyncPath = errors.New("rsync path must be an absolute path") +const RsyncVersionFlag = "--version" + type SharedCommand struct { BinPath string BaseLogPath string @@ -42,7 +44,7 @@ func (c SharedCommand) GetVersionInfo() (string, string, error) { return "", "", fmt.Errorf("%w: \"%s\"", ErrInvalidRsyncPath, rsyncPath) } - output, err := c.Shell.Execute(c.BinPath, "--version") + output, err := c.Shell.Execute(c.BinPath, RsyncVersionFlag) if err != nil { return "", "", fmt.Errorf("error fetching rsync version: %w", err) } diff --git a/backup/internal/test/job_test.go b/backup/internal/test/job_test.go index 51d9bd6..fe0b759 100644 --- a/backup/internal/test/job_test.go +++ b/backup/internal/test/job_test.go @@ -7,123 +7,52 @@ import ( "github.com/stretchr/testify/assert" ) -func newMockSyncCommand() SyncCommand { - return SyncCommand{ - SharedCommand: SharedCommand{ - BinPath: "/usr/bin/rsync", - Shell: &MockExec{}, - }, +func newJob() Job { + return Job{ + Name: "job", + Source: "", + Target: "", + Delete: true, + Enabled: true, + Exclusions: []string{}, } } -func newMockSimulateCommand() SimulateCommand { - return SimulateCommand{ - SharedCommand: SharedCommand{ - BinPath: "/usr/bin/rsync", - Shell: &MockExec{}, - }, - } -} - -func TestApply(t *testing.T) { - rsync := newMockSimulateCommand() +func TestApply_DisabledJob_ReturnsSkippedAndRunIsNotCalled(t *testing.T) { + mockJobCommand := NewMockJobCommand(t) - job := NewJob( - WithName("test_job"), - WithSource("/home/test/"), - WithTarget("/mnt/backup1/test/"), - WithExclusions([]string{"*.tmp"}), - ) + disabledJob := newJob() + disabledJob.Enabled = false - status := job.Apply(rsync) - assert.Equal(t, Success, status) -} -func TestApply_Disabled(t *testing.T) { - command := newMockSyncCommand() + // No expectations set - Run should NOT be called for disabled jobs - disabledJob := NewJob( - WithName("disabled_job"), - WithSource("/home/disabled/"), - WithTarget("/mnt/backup1/disabled/"), - WithEnabled(false), - ) + status := disabledJob.Apply(mockJobCommand) - status := disabledJob.Apply(command) assert.Equal(t, Skipped, status) } -func TestApply_Invalid(t *testing.T) { - rsync := newMockSyncCommand() - - // Test case for failure (simulate by providing invalid source path) - invalidJob := NewJob( - WithName("invalid_job"), - WithSource("/invalid/source/path"), - WithTarget("/mnt/backup1/invalid/"), - ) - - status := invalidJob.Apply(rsync) - assert.Equal(t, Failure, status) -} - -func TestJobSkippedEnabledTrue(t *testing.T) { - rsync := newMockSyncCommand() +func TestApply_JobFailing_RunIsCalledAndReturnsFailure(t *testing.T) { + mockJobCommand := NewMockJobCommand(t) - job := NewJob( - WithName("test_job"), - WithSource("/home/test/"), - WithTarget("/mnt/backup1/test/"), - ) + job := newJob() - status := job.Apply(rsync) - assert.Equal(t, Success, status) -} - -func TestJobSkippedEnabledFalse(t *testing.T) { - rsync := newMockSyncCommand() + // Set expectation that Run will be called and return Failure + mockJobCommand.EXPECT().Run(job).Return(Failure).Once() - disabledJob := NewJob( - WithName("disabled_job"), - WithSource("/home/disabled/"), - WithTarget("/mnt/backup1/disabled/"), - WithEnabled(false), - ) + status := job.Apply(mockJobCommand) - status := disabledJob.Apply(rsync) - assert.Equal(t, Skipped, status) + assert.Equal(t, Failure, status) } -func TestJobSkippedEnabledOmitted(t *testing.T) { - rsync := newMockSyncCommand() +func TestApply_JobSucceeds_RunIsCalledAndReturnsSuccess(t *testing.T) { + mockJobCommand := NewMockJobCommand(t) - job := NewJob( - WithName("omitted_enabled_job"), - WithSource("/home/omitted/"), - WithTarget("/mnt/backup1/omitted/"), - ) + job := newJob() - status := job.Apply(rsync) - assert.Equal(t, Success, status) -} - -func TestApplyWithMockedRsync(t *testing.T) { - mockExecutor := &MockExec{} - rsync := newMockSimulateCommand() - rsync.Shell = mockExecutor + // Set expectation that Run will be called and return Success + mockJobCommand.EXPECT().Run(job).Return(Success).Once() - job := NewJob( - WithName("test_job"), - WithSource("/home/test/"), - WithTarget("/mnt/backup1/test/"), - WithExclusions([]string{"*.tmp"}), - ) - status := job.Apply(rsync) + status := job.Apply(mockJobCommand) assert.Equal(t, Success, status) - assert.NotEmpty(t, mockExecutor.CapturedCommands) - - cmd := mockExecutor.CapturedCommands[0] - - assert.Equal(t, "/usr/bin/rsync", cmd.Name, "Command name mismatch") - assert.Contains(t, cmd.Args, "--dry-run", "Expected --dry-run flag in command arguments") } diff --git a/backup/internal/test/mock_exec_test.go b/backup/internal/test/mock_exec_test.go index c620528..6edbba5 100644 --- a/backup/internal/test/mock_exec_test.go +++ b/backup/internal/test/mock_exec_test.go @@ -1,55 +1,113 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + package internal_test import ( - "errors" - "strings" + mock "github.com/stretchr/testify/mock" ) -// Static error for testing. -var ErrExitStatus23 = errors.New("exit status 23") +// NewMockExec creates a new instance of MockExec. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockExec(t interface { + mock.TestingT + Cleanup(func()) +}) *MockExec { + mock := &MockExec{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) -// MockExec implements Exec for testing. + return mock +} + +// MockExec is an autogenerated mock type for the Exec type type MockExec struct { - CapturedCommands []MockCommand - Output string - Error error + mock.Mock } -// MockCommand represents a captured command execution. -type MockCommand struct { - Name string - Args []string +type MockExec_Expecter struct { + mock *mock.Mock } -// Execute captures the command and simulates execution. -func (m *MockExec) Execute(name string, args ...string) ([]byte, error) { - m.CapturedCommands = append(m.CapturedCommands, MockCommand{ - Name: name, - Args: append([]string{}, args...), // Make a copy of args - }) +func (_m *MockExec) EXPECT() *MockExec_Expecter { + return &MockExec_Expecter{mock: &_m.Mock} +} - // If Error is set, return it. - if m.Error != nil { - return nil, m.Error +// Execute provides a mock function for the type MockExec +func (_mock *MockExec) Execute(name string, args ...string) ([]byte, error) { + var tmpRet mock.Arguments + if len(args) > 0 { + tmpRet = _mock.Called(name, args) + } else { + tmpRet = _mock.Called(name) } + ret := tmpRet - // If Output is set, return it. - if m.Output != "" { - return []byte(m.Output), nil + if len(ret) == 0 { + panic("no return value specified for Execute") } - // Simulate specific scenarios for rsync. - if name == "/usr/bin/rsync" { - argsStr := strings.Join(args, " ") + var r0 []byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, ...string) ([]byte, error)); ok { + return returnFunc(name, args...) + } + if returnFunc, ok := ret.Get(0).(func(string, ...string) []byte); ok { + r0 = returnFunc(name, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(string, ...string) error); ok { + r1 = returnFunc(name, args...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} - if strings.Contains(argsStr, "/invalid/source/path") { - errMsg := "rsync: link_stat \"/invalid/source/path\" failed: No such file or directory" +// MockExec_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' +type MockExec_Execute_Call struct { + *mock.Call +} - return []byte(errMsg), ErrExitStatus23 +// Execute is a helper method to define mock.On call +// - name string +// - args ...string +func (_e *MockExec_Expecter) Execute(name interface{}, args ...interface{}) *MockExec_Execute_Call { + return &MockExec_Execute_Call{Call: _e.mock.On("Execute", + append([]interface{}{name}, args...)...)} +} + +func (_c *MockExec_Execute_Call) Run(run func(name string, args ...string)) *MockExec_Execute_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 []string + var variadicArgs []string + if len(args) > 1 { + variadicArgs = args[1].([]string) } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} - return []byte("mocked rsync success"), nil - } +func (_c *MockExec_Execute_Call) Return(bytes []byte, err error) *MockExec_Execute_Call { + _c.Call.Return(bytes, err) + return _c +} - return []byte("command not mocked"), nil +func (_c *MockExec_Execute_Call) RunAndReturn(run func(name string, args ...string) ([]byte, error)) *MockExec_Execute_Call { + _c.Call.Return(run) + return _c } diff --git a/backup/internal/test/mock_jobcommand_test.go b/backup/internal/test/mock_jobcommand_test.go new file mode 100644 index 0000000..5e911ab --- /dev/null +++ b/backup/internal/test/mock_jobcommand_test.go @@ -0,0 +1,148 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package internal_test + +import ( + "backup-rsync/backup/internal" + + mock "github.com/stretchr/testify/mock" +) + +// NewMockJobCommand creates a new instance of MockJobCommand. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockJobCommand(t interface { + mock.TestingT + Cleanup(func()) +}) *MockJobCommand { + mock := &MockJobCommand{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockJobCommand is an autogenerated mock type for the JobCommand type +type MockJobCommand struct { + mock.Mock +} + +type MockJobCommand_Expecter struct { + mock *mock.Mock +} + +func (_m *MockJobCommand) EXPECT() *MockJobCommand_Expecter { + return &MockJobCommand_Expecter{mock: &_m.Mock} +} + +// GetVersionInfo provides a mock function for the type MockJobCommand +func (_mock *MockJobCommand) GetVersionInfo() (string, string, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetVersionInfo") + } + + var r0 string + var r1 string + var r2 error + if returnFunc, ok := ret.Get(0).(func() (string, string, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func() string); ok { + r1 = returnFunc() + } else { + r1 = ret.Get(1).(string) + } + if returnFunc, ok := ret.Get(2).(func() error); ok { + r2 = returnFunc() + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockJobCommand_GetVersionInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetVersionInfo' +type MockJobCommand_GetVersionInfo_Call struct { + *mock.Call +} + +// GetVersionInfo is a helper method to define mock.On call +func (_e *MockJobCommand_Expecter) GetVersionInfo() *MockJobCommand_GetVersionInfo_Call { + return &MockJobCommand_GetVersionInfo_Call{Call: _e.mock.On("GetVersionInfo")} +} + +func (_c *MockJobCommand_GetVersionInfo_Call) Run(run func()) *MockJobCommand_GetVersionInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockJobCommand_GetVersionInfo_Call) Return(s string, s1 string, err error) *MockJobCommand_GetVersionInfo_Call { + _c.Call.Return(s, s1, err) + return _c +} + +func (_c *MockJobCommand_GetVersionInfo_Call) RunAndReturn(run func() (string, string, error)) *MockJobCommand_GetVersionInfo_Call { + _c.Call.Return(run) + return _c +} + +// Run provides a mock function for the type MockJobCommand +func (_mock *MockJobCommand) Run(job internal.Job) internal.JobStatus { + ret := _mock.Called(job) + + if len(ret) == 0 { + panic("no return value specified for Run") + } + + var r0 internal.JobStatus + if returnFunc, ok := ret.Get(0).(func(internal.Job) internal.JobStatus); ok { + r0 = returnFunc(job) + } else { + r0 = ret.Get(0).(internal.JobStatus) + } + return r0 +} + +// MockJobCommand_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' +type MockJobCommand_Run_Call struct { + *mock.Call +} + +// Run is a helper method to define mock.On call +// - job internal.Job +func (_e *MockJobCommand_Expecter) Run(job interface{}) *MockJobCommand_Run_Call { + return &MockJobCommand_Run_Call{Call: _e.mock.On("Run", job)} +} + +func (_c *MockJobCommand_Run_Call) Run(run func(job internal.Job)) *MockJobCommand_Run_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 internal.Job + if args[0] != nil { + arg0 = args[0].(internal.Job) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockJobCommand_Run_Call) Return(jobStatus internal.JobStatus) *MockJobCommand_Run_Call { + _c.Call.Return(jobStatus) + return _c +} + +func (_c *MockJobCommand_Run_Call) RunAndReturn(run func(job internal.Job) internal.JobStatus) *MockJobCommand_Run_Call { + _c.Call.Return(run) + return _c +} diff --git a/backup/internal/test/rsync_test.go b/backup/internal/test/rsync_test.go index a40ba6c..c06e0d8 100644 --- a/backup/internal/test/rsync_test.go +++ b/backup/internal/test/rsync_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -15,11 +16,12 @@ var errCommandNotFound = errors.New("command not found") const rsyncPath = "/usr/bin/rsync" func TestArgumentsForJob(t *testing.T) { - job := NewJob( - WithSource("/home/user/Music/"), - WithTarget("/target/user/music/home"), - WithExclusions([]string{"*.tmp", "node_modules/"}), - ) + job := Job{ + Delete: true, + Source: "/home/user/Music/", + Target: "/target/user/music/home", + Exclusions: []string{"*.tmp", "node_modules/"}, + } args := ArgumentsForJob(job, "", true) expectedArgs := []string{ @@ -32,14 +34,17 @@ func TestArgumentsForJob(t *testing.T) { } func TestGetVersionInfo_Success(t *testing.T) { + mockExec := NewMockExec(t) rsync := SharedCommand{ BinPath: rsyncPath, - Shell: &MockExec{ - Output: "rsync version 3.2.3 protocol version 31\n", - Error: nil, - }, + Shell: mockExec, } + // Set expectation for Execute call + mockExec.EXPECT().Execute(rsyncPath, mock.MatchedBy(func(args []string) bool { + return len(args) == 1 && args[0] == RsyncVersionFlag + })).Return([]byte("rsync version 3.2.3 protocol version 31\n"), nil).Once() + versionInfo, fullpath, err := rsync.GetVersionInfo() require.NoError(t, err) @@ -48,14 +53,17 @@ func TestGetVersionInfo_Success(t *testing.T) { } func TestGetVersionInfo_CommandError(t *testing.T) { + mockExec := NewMockExec(t) rsync := SharedCommand{ BinPath: rsyncPath, - Shell: &MockExec{ - Output: "", - Error: errCommandNotFound, - }, + Shell: mockExec, } + // Set expectation for Execute call to return error + mockExec.EXPECT().Execute(rsyncPath, mock.MatchedBy(func(args []string) bool { + return len(args) == 1 && args[0] == RsyncVersionFlag + })).Return(nil, errCommandNotFound).Once() + versionInfo, fullpath, err := rsync.GetVersionInfo() require.Error(t, err) @@ -64,14 +72,17 @@ func TestGetVersionInfo_CommandError(t *testing.T) { } func TestGetVersionInfo_InvalidOutput(t *testing.T) { + mockExec := NewMockExec(t) rsync := SharedCommand{ BinPath: rsyncPath, - Shell: &MockExec{ - Output: "invalid output", - Error: nil, - }, + Shell: mockExec, } + // Set expectation for Execute call to return invalid output + mockExec.EXPECT().Execute(rsyncPath, mock.MatchedBy(func(args []string) bool { + return len(args) == 1 && args[0] == RsyncVersionFlag + })).Return([]byte("invalid output"), nil).Once() + versionInfo, fullpath, err := rsync.GetVersionInfo() require.Error(t, err) @@ -80,14 +91,14 @@ func TestGetVersionInfo_InvalidOutput(t *testing.T) { } func TestGetVersionInfo_EmptyPath(t *testing.T) { + mockExec := NewMockExec(t) rsync := SharedCommand{ BinPath: "", - Shell: &MockExec{ - Output: "", - Error: nil, - }, + Shell: mockExec, } + // No expectations set - should fail before calling Execute due to path validation + versionInfo, fullpath, err := rsync.GetVersionInfo() require.Error(t, err) @@ -97,14 +108,14 @@ func TestGetVersionInfo_EmptyPath(t *testing.T) { } func TestGetVersionInfo_IncompletePath(t *testing.T) { + mockExec := NewMockExec(t) rsync := SharedCommand{ BinPath: "bin/rsync", - Shell: &MockExec{ - Output: "", - Error: nil, - }, + Shell: mockExec, } + // No expectations set - should fail before calling Execute due to path validation + versionInfo, fullpath, err := rsync.GetVersionInfo() require.Error(t, err) diff --git a/backup/internal/types.go b/backup/internal/types.go deleted file mode 100644 index 89dfc62..0000000 --- a/backup/internal/types.go +++ /dev/null @@ -1,27 +0,0 @@ -package internal - -// Path represents a source or target path with optional exclusions. -type Path struct { - Path string `yaml:"path"` - Exclusions []string `yaml:"exclusions"` -} - -// Config represents the overall backup configuration. -type Config struct { - Sources []Path `yaml:"sources"` - Targets []Path `yaml:"targets"` - Variables map[string]string `yaml:"variables"` - Jobs []Job `yaml:"jobs"` -} - -// Job represents a backup job configuration for a source/target pair. -// -//nolint:recvcheck // UnmarshalYAML requires pointer receiver while Apply uses value receiver -type Job struct { - Name string `yaml:"name"` - Source string `yaml:"source"` - Target string `yaml:"target"` - Delete bool `yaml:"delete"` - Enabled bool `yaml:"enabled"` - Exclusions []string `yaml:"exclusions,omitempty"` -} diff --git a/go.mod b/go.mod index 4a11214..1f51c78 100644 --- a/go.mod +++ b/go.mod @@ -14,5 +14,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 0bfa0c4..de53f1b 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=