From 5fdb2211041c1f31ea66f5a68ddf4db1460cc054 Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:25:40 +0000 Subject: [PATCH 1/4] fix: Format md files --- AGENTS.md | 1 + MOCKERY_INTEGRATION.md | 17 +++++++++-------- TESTING_GUIDE.md | 19 ++++++++++--------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2db1fcf..f545c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ make report-coverage # Generate HTML coverage report ## CI Pipeline CI runs on every push/PR to `main` (`.github/workflows/go.yml`): + 1. Sanity check (format + clean + mod tidy) 2. Lint (golangci-lint) 3. Build diff --git a/MOCKERY_INTEGRATION.md b/MOCKERY_INTEGRATION.md index 2ef7cd9..c396aee 100644 --- a/MOCKERY_INTEGRATION.md +++ b/MOCKERY_INTEGRATION.md @@ -14,15 +14,15 @@ The project uses `.mockery.yml` to control mock generation: ```yaml all: false -dir: '{{.InterfaceDir}}/test' +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' +structname: "Mock{{.InterfaceName}}" +pkgname: "internal_test" recursive: false template: testify packages: @@ -33,6 +33,7 @@ packages: ``` Key points: + - **Output directory**: `/test/` (alongside other test files) - **Filename**: `mock__test.go` - **Struct naming**: `Mock` (e.g., `MockExec`, `MockJobCommand`) @@ -41,10 +42,10 @@ Key points: ## Generated Mocks -| Mock | Source Interface | File | -|---|---|---| -| `MockExec` | `Exec` | `backup/internal/test/mock_exec_test.go` | -| `MockJobCommand` | `JobCommand` | `backup/internal/test/mock_jobcommand_test.go` | +| Mock | Source Interface | File | +| ---------------- | ---------------- | ---------------------------------------------- | +| `MockExec` | `Exec` | `backup/internal/test/mock_exec_test.go` | +| `MockJobCommand` | `JobCommand` | `backup/internal/test/mock_jobcommand_test.go` | ## Usage Examples @@ -111,4 +112,4 @@ When interfaces change, regenerate with: mockery ``` -This updates all mocks according to `.mockery.yml`. Generated files are committed to the repository. \ No newline at end of file +This updates all mocks according to `.mockery.yml`. Generated files are committed to the repository. diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1758263..53ace07 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -30,14 +30,14 @@ backup/ ## Dependency Injection Points -| Dependency | Interface/Type | Real | Test | -|---|---|---|---| -| Command execution | `internal.Exec` | `OsExec` | `MockExec` or `stubExec` | -| Job runner | `internal.JobCommand` | `ListCommand`, `SyncCommand`, `SimulateCommand` | `MockJobCommand` | -| Filesystem | `afero.Fs` | `afero.NewOsFs()` | `afero.NewMemMapFs()` | -| Output | `io.Writer` | `os.Stdout` / `cmd.OutOrStdout()` | `bytes.Buffer` | -| Logging | `*log.Logger` | File-backed logger | `log.New(&buf, "", 0)` | -| Time | `time.Time` | `time.Now()` | Fixed `time.Date(...)` | +| Dependency | Interface/Type | Real | Test | +| ----------------- | --------------------- | ----------------------------------------------- | ------------------------ | +| Command execution | `internal.Exec` | `OsExec` | `MockExec` or `stubExec` | +| Job runner | `internal.JobCommand` | `ListCommand`, `SyncCommand`, `SimulateCommand` | `MockJobCommand` | +| Filesystem | `afero.Fs` | `afero.NewOsFs()` | `afero.NewMemMapFs()` | +| Output | `io.Writer` | `os.Stdout` / `cmd.OutOrStdout()` | `bytes.Buffer` | +| Logging | `*log.Logger` | File-backed logger | `log.New(&buf, "", 0)` | +| Time | `time.Time` | `time.Now()` | Fixed `time.Date(...)` | ## Command-Level Tests (cmd/test/) @@ -83,6 +83,7 @@ func TestRun_ValidConfig(t *testing.T) { ``` Three builder levels available: + - `BuildRootCommand()` — production defaults (real OS filesystem, real exec) - `BuildRootCommandWithFs(fs)` — custom filesystem, real exec - `BuildRootCommandWithDeps(fs, shell)` — full control for testing @@ -158,4 +159,4 @@ make report-coverage # Generate HTML coverage report 3. **Use `require` for errors, `assert` for values** — `require` stops the test on failure 4. **Table-driven tests** for multiple input/output scenarios 5. **Scope mocks to individual tests** — each test creates its own mock instance -6. **Defer cleanup** — `CreateMainLogger` returns a cleanup function; always `defer` it \ No newline at end of file +6. **Defer cleanup** — `CreateMainLogger` returns a cleanup function; always `defer` it From b4afadf890934605c74ac4e2f24dfee618dcce34 Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:31:34 +0000 Subject: [PATCH 2/4] feat: Integration tests --- Makefile | 5 +- backup/cmd/test/integration_test.go | 735 ++++++++++++++++++++++++++++ 2 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 backup/cmd/test/integration_test.go diff --git a/Makefile b/Makefile index b4b88c2..0d1e07d 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ BUILD_CMD = CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -tags=prod PACKAGE = ./backup/main.go COVERAGE_THRESHOLD = 98 -.PHONY: build clean test lint tidy checksums release sanity-check check-mod-tidy lint-config-check lint-fix format check-clean check-coverage +.PHONY: build clean test test-integration lint tidy checksums release sanity-check check-mod-tidy lint-config-check lint-fix format check-clean check-coverage format: go fmt ./... @@ -36,6 +36,9 @@ sanity-check: format check-clean check-mod-tidy test: go test -race ./... -v +test-integration: + go test -race -tags=integration ./... -v + tidy: gofmt -s -w . go mod tidy diff --git a/backup/cmd/test/integration_test.go b/backup/cmd/test/integration_test.go new file mode 100644 index 0000000..76c073f --- /dev/null +++ b/backup/cmd/test/integration_test.go @@ -0,0 +1,735 @@ +//go:build integration + +package cmd_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "backup-rsync/backup/cmd" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- helpers --- + +// setupDirs creates source and target temp directories. +// Returns (sourceDir, targetDir). +func setupDirs(t *testing.T) (string, string) { + t.Helper() + + base := t.TempDir() + src := filepath.Join(base, "source") + dst := filepath.Join(base, "target") + + require.NoError(t, os.MkdirAll(src, 0750)) + require.NoError(t, os.MkdirAll(dst, 0750)) + + return src, dst +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0750)) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) +} + +func fileExists(t *testing.T, path string) bool { + t.Helper() + + _, err := os.Stat(path) + + return err == nil +} + +func readFileContent(t *testing.T, path string) string { + t.Helper() + + data, err := os.ReadFile(path) + require.NoError(t, err) + + return string(data) +} + +func writeIntegrationConfig(t *testing.T, yaml string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + + require.NoError(t, os.WriteFile(path, []byte(yaml), 0600)) + + return path +} + +func executeIntegrationCommand(t *testing.T, args ...string) (string, error) { + t.Helper() + + rootCmd := cmd.BuildRootCommand() + + var stdout bytes.Buffer + + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetArgs(args) + + err := rootCmd.Execute() + + return stdout.String(), err +} + +// --- run: basic sync from source to target --- + +func TestIntegration_Run_BasicSync(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "hello.txt"), "hello world") + writeFile(t, filepath.Join(src, "subdir", "nested.txt"), "nested content") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "basic" + source: "`+src+`/" + target: "`+dst+`/" + delete: false +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Job: basic") + assert.Contains(t, stdout, "Status [basic]: SUCCESS") + + assert.Equal(t, "hello world", readFileContent(t, filepath.Join(dst, "hello.txt"))) + assert.Equal(t, "nested content", readFileContent(t, filepath.Join(dst, "subdir", "nested.txt"))) +} + +// --- run: idempotent second sync produces no changes --- + +func TestIntegration_Run_IdempotentSync(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "data.txt"), "same content") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "idem" + source: "`+src+`/" + target: "`+dst+`/" +`) + + // First sync + _, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + require.NoError(t, err) + + // Second sync - should still succeed, nothing new to transfer + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [idem]: SUCCESS") + assert.Equal(t, "same content", readFileContent(t, filepath.Join(dst, "data.txt"))) +} + +// --- run: delete mode removes extra files from target --- + +func TestIntegration_Run_DeleteRemovesExtraFiles(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "keep.txt"), "keep me") + writeFile(t, filepath.Join(dst, "keep.txt"), "keep me") + writeFile(t, filepath.Join(dst, "stale.txt"), "should be removed") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "cleanup" + source: "`+src+`/" + target: "`+dst+`/" + delete: true +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [cleanup]: SUCCESS") + + assert.True(t, fileExists(t, filepath.Join(dst, "keep.txt"))) + assert.False(t, fileExists(t, filepath.Join(dst, "stale.txt")), "stale.txt should have been deleted") +} + +// --- run: no-delete mode preserves extra files in target --- + +func TestIntegration_Run_NoDeletePreservesExtraFiles(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "a.txt"), "a") + writeFile(t, filepath.Join(dst, "extra.txt"), "should remain") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "nodelete" + source: "`+src+`/" + target: "`+dst+`/" + delete: false +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [nodelete]: SUCCESS") + + assert.True(t, fileExists(t, filepath.Join(dst, "a.txt"))) + assert.True(t, fileExists(t, filepath.Join(dst, "extra.txt")), "extra.txt should be preserved") +} + +// --- run: exclusions prevent syncing excluded paths --- + +func TestIntegration_Run_Exclusions(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "docs", "readme.txt"), "documentation") + writeFile(t, filepath.Join(src, "cache", "tmp.dat"), "temporary data") + writeFile(t, filepath.Join(src, "logs", "app.log"), "log data") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "filtered" + source: "`+src+`/" + target: "`+dst+`/" + exclusions: + - "cache" + - "logs" +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [filtered]: SUCCESS") + + assert.True(t, fileExists(t, filepath.Join(dst, "docs", "readme.txt"))) + assert.False(t, fileExists(t, filepath.Join(dst, "cache", "tmp.dat")), "cache should be excluded") + assert.False(t, fileExists(t, filepath.Join(dst, "logs", "app.log")), "logs should be excluded") +} + +// --- run: disabled job is skipped --- + +func TestIntegration_Run_DisabledJobSkipped(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "file.txt"), "content") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "disabled-job" + source: "`+src+`/" + target: "`+dst+`/" + enabled: false +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [disabled-job]: SKIPPED") + assert.False(t, fileExists(t, filepath.Join(dst, "file.txt")), "disabled job should not sync files") +} + +// --- run: multiple jobs with mixed outcomes --- + +func TestIntegration_Run_MultipleJobs(t *testing.T) { + base := t.TempDir() + + srcA := filepath.Join(base, "srcA") + dstA := filepath.Join(base, "dstA") + srcB := filepath.Join(base, "srcB") + dstB := filepath.Join(base, "dstB") + + require.NoError(t, os.MkdirAll(srcA, 0750)) + require.NoError(t, os.MkdirAll(dstA, 0750)) + require.NoError(t, os.MkdirAll(srcB, 0750)) + require.NoError(t, os.MkdirAll(dstB, 0750)) + + writeFile(t, filepath.Join(srcA, "a.txt"), "alpha") + writeFile(t, filepath.Join(srcB, "b.txt"), "bravo") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+base+`" +targets: + - path: "`+base+`" +jobs: + - name: "jobA" + source: "`+srcA+`/" + target: "`+dstA+`/" + - name: "jobB" + source: "`+srcB+`/" + target: "`+dstB+`/" +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [jobA]: SUCCESS") + assert.Contains(t, stdout, "Status [jobB]: SUCCESS") + assert.Contains(t, stdout, "Summary: 2 succeeded, 0 failed, 0 skipped") + + assert.Equal(t, "alpha", readFileContent(t, filepath.Join(dstA, "a.txt"))) + assert.Equal(t, "bravo", readFileContent(t, filepath.Join(dstB, "b.txt"))) +} + +// --- run: partial changes — only modified files are synced --- + +func TestIntegration_Run_PartialChanges(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "unchanged.txt"), "same") + writeFile(t, filepath.Join(src, "modified.txt"), "original") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "partial" + source: "`+src+`/" + target: "`+dst+`/" +`) + + // Initial sync + _, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + require.NoError(t, err) + + assert.Equal(t, "original", readFileContent(t, filepath.Join(dst, "modified.txt"))) + + // Modify source file + writeFile(t, filepath.Join(src, "modified.txt"), "updated") + + // Second sync - should pick up the change + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [partial]: SUCCESS") + + assert.Equal(t, "updated", readFileContent(t, filepath.Join(dst, "modified.txt"))) + assert.Equal(t, "same", readFileContent(t, filepath.Join(dst, "unchanged.txt"))) +} + +// --- simulate: dry-run does NOT modify target --- + +func TestIntegration_Simulate_NoChanges(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "new.txt"), "should not appear in target") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "dryrun" + source: "`+src+`/" + target: "`+dst+`/" +`) + + stdout, err := executeIntegrationCommand(t, "simulate", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Job: dryrun") + assert.Contains(t, stdout, "Status [dryrun]: SUCCESS") + assert.Contains(t, stdout, "--dry-run") + + assert.False(t, fileExists(t, filepath.Join(dst, "new.txt")), + "simulate should not create files in target") +} + +// --- simulate: shows what would be transferred --- + +func TestIntegration_Simulate_ShowsChanges(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "report.txt"), "quarterly report") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "preview" + source: "`+src+`/" + target: "`+dst+`/" +`) + + stdout, err := executeIntegrationCommand(t, "simulate", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Job: preview") + assert.Contains(t, stdout, "--dry-run") + assert.Contains(t, stdout, "Status [preview]: SUCCESS") +} + +// --- simulate then run: simulate doesn't interfere with subsequent run --- + +func TestIntegration_SimulateThenRun(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "data.txt"), "important data") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "workflow" + source: "`+src+`/" + target: "`+dst+`/" +`) + + // Simulate first + _, err := executeIntegrationCommand(t, "simulate", "--config", cfgPath) + require.NoError(t, err) + + assert.False(t, fileExists(t, filepath.Join(dst, "data.txt")), "simulate should not modify target") + + // Now actually run + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [workflow]: SUCCESS") + assert.Equal(t, "important data", readFileContent(t, filepath.Join(dst, "data.txt"))) +} + +// --- list: lists commands without executing rsync --- + +func TestIntegration_List_ShowsCommands(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "x.txt"), "x") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "listjob" + source: "`+src+`/" + target: "`+dst+`/" + exclusions: + - "temp" +`) + + stdout, err := executeIntegrationCommand(t, "list", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Job: listjob") + assert.Contains(t, stdout, "--exclude=temp") + assert.Contains(t, stdout, src+"/") + assert.Contains(t, stdout, dst+"/") + assert.Contains(t, stdout, "Status [listjob]: SUCCESS") + + // list should not actually sync files + assert.False(t, fileExists(t, filepath.Join(dst, "x.txt")), "list should not sync files") +} + +// --- run: variable substitution works end-to-end --- + +func TestIntegration_Run_VariableSubstitution(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "v.txt"), "vars work") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +variables: + src_dir: "`+src+`" + dst_dir: "`+dst+`" +jobs: + - name: "var-job" + source: "${src_dir}/" + target: "${dst_dir}/" +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [var-job]: SUCCESS") + assert.Equal(t, "vars work", readFileContent(t, filepath.Join(dst, "v.txt"))) +} + +// --- run: mixed enabled/disabled/multiple results with summary --- + +func TestIntegration_Run_MixedJobsSummary(t *testing.T) { + base := t.TempDir() + + srcOK := filepath.Join(base, "srcOK") + dstOK := filepath.Join(base, "dstOK") + srcSkip := filepath.Join(base, "srcSkip") + dstSkip := filepath.Join(base, "dstSkip") + + require.NoError(t, os.MkdirAll(srcOK, 0750)) + require.NoError(t, os.MkdirAll(dstOK, 0750)) + require.NoError(t, os.MkdirAll(srcSkip, 0750)) + require.NoError(t, os.MkdirAll(dstSkip, 0750)) + + writeFile(t, filepath.Join(srcOK, "ok.txt"), "ok") + writeFile(t, filepath.Join(srcSkip, "skip.txt"), "skip") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+base+`" +targets: + - path: "`+base+`" +jobs: + - name: "active" + source: "`+srcOK+`/" + target: "`+dstOK+`/" + enabled: true + - name: "inactive" + source: "`+srcSkip+`/" + target: "`+dstSkip+`/" + enabled: false +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [active]: SUCCESS") + assert.Contains(t, stdout, "Status [inactive]: SKIPPED") + assert.Contains(t, stdout, "Summary: 1 succeeded, 0 failed, 1 skipped") + + assert.True(t, fileExists(t, filepath.Join(dstOK, "ok.txt"))) + assert.False(t, fileExists(t, filepath.Join(dstSkip, "skip.txt"))) +} + +// --- run: empty source directory syncs nothing --- + +func TestIntegration_Run_EmptySource(t *testing.T) { + src, dst := setupDirs(t) + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "empty" + source: "`+src+`/" + target: "`+dst+`/" +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [empty]: SUCCESS") +} + +// --- run: deep directory hierarchy is synced correctly --- + +func TestIntegration_Run_DeepHierarchy(t *testing.T) { + src, dst := setupDirs(t) + + writeFile(t, filepath.Join(src, "a", "b", "c", "d", "deep.txt"), "deep file") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "deep" + source: "`+src+`/" + target: "`+dst+`/" +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Status [deep]: SUCCESS") + assert.Equal(t, "deep file", readFileContent(t, filepath.Join(dst, "a", "b", "c", "d", "deep.txt"))) +} + +// --- check-coverage: fully covered config reports no uncovered paths --- + +func TestIntegration_CheckCoverage_FullCoverage(t *testing.T) { + src := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(src, "docs"), 0750)) + require.NoError(t, os.MkdirAll(filepath.Join(src, "photos"), 0750)) + + dst := t.TempDir() + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "docs" + source: "`+filepath.Join(src, "docs")+`/" + target: "`+filepath.Join(dst, "docs")+`/" + - name: "photos" + source: "`+filepath.Join(src, "photos")+`/" + target: "`+filepath.Join(dst, "photos")+`/" +`) + + stdout, err := executeIntegrationCommand(t, "check-coverage", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Uncovered paths:") + // Both subdirectories are covered, so no uncovered paths should appear after the header + lines := splitNonEmpty(stdout) + assert.Equal(t, 1, len(lines), "only the header line expected; got: %v", lines) +} + +// --- check-coverage: incomplete coverage reports uncovered paths --- + +func TestIntegration_CheckCoverage_IncompleteCoverage(t *testing.T) { + src := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(src, "docs"), 0750)) + require.NoError(t, os.MkdirAll(filepath.Join(src, "music"), 0750)) + require.NoError(t, os.MkdirAll(filepath.Join(src, "videos"), 0750)) + + dst := t.TempDir() + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "docs-only" + source: "`+filepath.Join(src, "docs")+`/" + target: "`+filepath.Join(dst, "docs")+`/" +`) + + stdout, err := executeIntegrationCommand(t, "check-coverage", "--config", cfgPath) + + require.NoError(t, err) + // The source root itself should be reported as uncovered (since music and videos aren't covered) + assert.Contains(t, stdout, src) +} + +// --- config show: end-to-end with variable resolution --- + +func TestIntegration_ConfigShow(t *testing.T) { + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "/data" +targets: + - path: "/backup" +variables: + base: "/backup" +jobs: + - name: "resolved" + source: "/data/files/" + target: "${base}/files/" +`) + + stdout, err := executeIntegrationCommand(t, "config", "show", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "/backup/files/") + assert.Contains(t, stdout, "resolved") +} + +// --- config validate: valid config passes --- + +func TestIntegration_ConfigValidate_Valid(t *testing.T) { + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "/data" +targets: + - path: "/backup" +jobs: + - name: "valid" + source: "/data/stuff/" + target: "/backup/stuff/" +`) + + stdout, err := executeIntegrationCommand(t, "config", "validate", "--config", cfgPath) + + require.NoError(t, err) + assert.Contains(t, stdout, "Configuration is valid.") +} + +// --- config validate: overlapping sources are rejected --- + +func TestIntegration_ConfigValidate_OverlappingSources(t *testing.T) { + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "/data" +targets: + - path: "/backup" +jobs: + - name: "parent" + source: "/data/user/" + target: "/backup/user/" + - name: "child" + source: "/data/user/docs/" + target: "/backup/docs/" +`) + + _, err := executeIntegrationCommand(t, "config", "validate", "--config", cfgPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "validating config") +} + +// --- version: real rsync version output --- + +func TestIntegration_Version(t *testing.T) { + stdout, err := executeIntegrationCommand(t, "version") + + require.NoError(t, err) + assert.Contains(t, stdout, "Rsync Binary Path: /usr/bin/rsync") + assert.Contains(t, stdout, "Version Info:") + assert.Contains(t, stdout, "rsync") +} + +// splitNonEmpty splits a string by newlines and returns non-empty trimmed lines. +func splitNonEmpty(s string) []string { + var result []string + + for _, line := range bytes.Split([]byte(s), []byte("\n")) { + trimmed := bytes.TrimSpace(line) + if len(trimmed) > 0 { + result = append(result, string(trimmed)) + } + } + + return result +} From 5da13278e7a3fe2dffd4b5ff09c72b4f39dfb505 Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:37:38 +0000 Subject: [PATCH 3/4] ci: Run integration tests --- .github/workflows/go.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 33b3794..6b7f570 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -41,5 +41,8 @@ jobs: - name: Test run: make test + - name: Integration test + run: make test-integration + - name: Check coverage run: make check-coverage From c5b2f392e376a386db2fc2d3a37fb9959c0ff0bc Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:41:28 +0000 Subject: [PATCH 4/4] docs: Add integration test hints --- AGENTS.md | 23 ++++++++------- TESTING_GUIDE.md | 75 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f545c70..ecc78a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,15 +47,16 @@ backup/ ## Build and Test ```sh -make build # Build to dist/backup -make test # go test -race ./... -v -make lint # golangci-lint run ./... -make lint-fix # Auto-fix lint issues -make format # go fmt ./... -make tidy # gofmt -s + go mod tidy -make sanity-check # format + clean + tidy -make check-coverage # Fail if coverage < 90% -make report-coverage # Generate HTML coverage report +make build # Build to dist/backup +make test # go test -race ./... -v +make test-integration # go test -race -tags=integration ./... -v +make lint # golangci-lint run ./... +make lint-fix # Auto-fix lint issues +make format # go fmt ./... +make tidy # gofmt -s + go mod tidy +make sanity-check # format + clean + tidy +make check-coverage # Fail if coverage < 98% +make report-coverage # Generate HTML coverage report ``` ## Testing Conventions @@ -71,6 +72,7 @@ make report-coverage # Generate HTML coverage report - Prefer table-driven tests for multiple input scenarios - Use `afero.NewMemMapFs()` in tests — never hit the real filesystem - Use `bytes.Buffer` or `io.Discard` for output capture in tests +- Integration tests use `//go:build integration` tag and run real rsync on temp directories - CI enforces coverage threshold via `make check-coverage` ## CI Pipeline @@ -81,7 +83,8 @@ CI runs on every push/PR to `main` (`.github/workflows/go.yml`): 2. Lint (golangci-lint) 3. Build 4. Test (with `-race` flag) -5. Coverage threshold enforcement (90%) +5. Integration test (with real rsync, `-tags=integration`) +6. Coverage threshold enforcement (98%) ## Conventions diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 53ace07..74b549a 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -16,8 +16,9 @@ All tests use dependency injection — no global state mutation. Key patterns: ``` backup/ cmd/test/ - commands_test.go # CLI integration tests (all commands) - root_test.go # Root command help output + commands_test.go # CLI command tests (all commands, stubbed exec) + integration_test.go # Integration tests with real rsync (build tag: integration) + root_test.go # Root command help output internal/test/ check_test.go # CoverageChecker tests (afero-based) config_test.go # Config loading, validation, Apply @@ -144,12 +145,76 @@ func TestCreateMainLogger_DeterministicLogPath(t *testing.T) { } ``` +## Integration Tests + +Integration tests live in `cmd/test/integration_test.go` behind the `//go:build integration` tag. They exercise the full CLI with **real rsync** against temp directories — no mocks or stubs. + +### Build Tag + +```go +//go:build integration +``` + +Tests are excluded from `make test` and `make check-coverage`. Run them separately: + +```sh +make test-integration # go test -race -tags=integration ./... -v +``` + +### Design Principles + +- **Real rsync** — uses `/usr/bin/rsync` via `BuildRootCommand()` (production defaults) +- **Real filesystem** — creates temp directories via `t.TempDir()`, cleaned up automatically +- **Reproducible** — each test sets up its own isolated source/target directory pair +- **No mocks** — validates actual rsync behavior (file transfer, deletion, exclusions) + +### Scenarios Covered + +| Category | Tests | What's Verified | +| -------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| **run — basic** | `BasicSync`, `IdempotentSync`, `PartialChanges`, `EmptySource`, `DeepHierarchy` | Files are synced correctly; re-sync is idempotent; only modified files transfer | +| **run — delete** | `DeleteRemovesExtraFiles`, `NoDeletePreservesExtraFiles` | `--delete` flag removes stale files; omitting it preserves them | +| **run — exclusions** | `Exclusions` | `--exclude` patterns prevent syncing of matching paths | +| **run — jobs** | `DisabledJobSkipped`, `MultipleJobs`, `MixedJobsSummary`, `VariableSubstitution` | Multi-job orchestration, enabled/disabled, `${var}` resolution | +| **simulate** | `NoChanges`, `ShowsChanges`, `SimulateThenRun` | Dry-run produces no side effects; subsequent run works normally | +| **list** | `ShowsCommands` | Prints rsync commands without executing them | +| **check-coverage** | `FullCoverage`, `IncompleteCoverage` | Coverage checker on real directory trees | +| **config** | `ConfigShow`, `ConfigValidate_Valid`, `ConfigValidate_OverlappingSources` | End-to-end config parsing and validation | +| **version** | `Version` | Real rsync version output | + +### Example + +```go +func TestIntegration_Run_BasicSync(t *testing.T) { + src, dst := setupDirs(t) + writeFile(t, filepath.Join(src, "hello.txt"), "hello world") + + cfgPath := writeIntegrationConfig(t, ` +sources: + - path: "`+src+`" +targets: + - path: "`+dst+`" +jobs: + - name: "basic" + source: "`+src+`/" + target: "`+dst+`/" + delete: false +`) + + stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) + require.NoError(t, err) + assert.Contains(t, stdout, "Status [basic]: SUCCESS") + assert.Equal(t, "hello world", readFileContent(t, filepath.Join(dst, "hello.txt"))) +} +``` + ## Running Tests ```sh -make test # go test -race ./... -v -make check-coverage # Fail if coverage < 90% -make report-coverage # Generate HTML coverage report +make test # go test -race ./... -v (unit tests only) +make test-integration # go test -race -tags=integration ./... -v (includes integration) +make check-coverage # Fail if coverage < threshold (unit tests only) +make report-coverage # Generate HTML coverage report ``` ## Key Principles