aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rwxr-xr-xtests/cases/test_backup.sh239
-rwxr-xr-xtests/cases/test_cron.sh138
-rwxr-xr-xtests/cases/test_dryrun.sh120
-rwxr-xr-xtests/cases/test_includes.sh161
-rwxr-xr-xtests/cases/test_validation.sh106
-rwxr-xr-xtests/lib/test_helpers.sh247
-rwxr-xr-xtests/test_rsyncshot.sh120
7 files changed, 1131 insertions, 0 deletions
diff --git a/tests/cases/test_backup.sh b/tests/cases/test_backup.sh
new file mode 100755
index 0000000..1ab4fbd
--- /dev/null
+++ b/tests/cases/test_backup.sh
@@ -0,0 +1,239 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Backup and Rotation Tests
+# ==============================================================================
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+# ------------------------------------------------------------------------------
+# Test: Creates backup directory structure
+# ------------------------------------------------------------------------------
+test_creates_backup_structure() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+ local exit_code=$?
+
+ # Check directory structure
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME" "should create hostname dir" || {
+ teardown_test_env
+ return 1
+ }
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest" "should create latest dir" || {
+ teardown_test_env
+ return 1
+ }
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should create MANUAL.0 snapshot" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Test: Copies files to backup
+# ------------------------------------------------------------------------------
+test_copies_files() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+
+ # Check files were copied
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/file1.txt" "should copy file1.txt" || {
+ teardown_test_env
+ return 1
+ }
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/etc/test.conf" "should copy test.conf" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Test: Snapshot is read-only
+# ------------------------------------------------------------------------------
+test_snapshot_readonly() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+
+ # Check snapshot directory has no write permission
+ # Note: We use stat to check actual permissions because -w always returns true for root
+ local perms
+ perms=$(stat -c '%A' "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" 2>/dev/null)
+ if [[ "$perms" == *w* ]]; then
+ echo "FAIL: Snapshot should be read-only (perms: $perms)"
+ teardown_test_env
+ return 1
+ fi
+
+ teardown_test_env
+ return 0
+}
+
+# ------------------------------------------------------------------------------
+# Test: Rotation works correctly
+# ------------------------------------------------------------------------------
+test_rotation() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ # Run backup twice
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null
+
+ # Should have MANUAL.0 and MANUAL.1
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should have MANUAL.0" || {
+ teardown_test_env
+ return 1
+ }
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.1" "should have MANUAL.1 after rotation" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Test: Deletes oldest snapshot beyond retention
+# ------------------------------------------------------------------------------
+test_retention_limit() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ # Run backup 4 times with retention of 3
+ for i in 1 2 3 4; do
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null
+ done
+
+ # Should have MANUAL.0, MANUAL.1, MANUAL.2 but NOT MANUAL.3
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should have MANUAL.0" || {
+ teardown_test_env
+ return 1
+ }
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.1" "should have MANUAL.1" || {
+ teardown_test_env
+ return 1
+ }
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.2" "should have MANUAL.2" || {
+ teardown_test_env
+ return 1
+ }
+ assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.3" "should NOT have MANUAL.3 (beyond retention)" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Test: Backup command works as alias
+# ------------------------------------------------------------------------------
+test_backup_command() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" backup 2>&1)
+ local exit_code=$?
+
+ # Should create MANUAL.0 (backup is alias for manual 1)
+ assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "backup should create MANUAL.0" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Test: Excludes files matching patterns
+# ------------------------------------------------------------------------------
+test_excludes_patterns() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+
+ # Create exclude file
+ cat > "$TEST_CONFIG_DIR/exclude.txt" << 'EOF'
+*.tmp
+*.log
+EOF
+
+ # Create files that should be excluded
+ echo "temp" > "$TEST_SOURCE_DIR/home/testuser/temp.tmp"
+ echo "log" > "$TEST_SOURCE_DIR/home/testuser/app.log"
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1)
+
+ # Excluded files should not exist in backup
+ assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/temp.tmp" "should exclude .tmp files" || {
+ teardown_test_env
+ return 1
+ }
+ assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/app.log" "should exclude .log files" || {
+ teardown_test_env
+ return 1
+ }
+ # Regular files should still exist
+ assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/file1.txt" "should include regular files" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_backup_tests() {
+ echo ""
+ echo "Running backup tests..."
+ echo "------------------------------------------------------------"
+
+ run_test "creates backup directory structure" test_creates_backup_structure
+ run_test "copies files to backup" test_copies_files
+ run_test "snapshot is read-only" test_snapshot_readonly
+ run_test "rotation works correctly" test_rotation
+ run_test "respects retention limit" test_retention_limit
+ run_test "backup command works as alias" test_backup_command
+ run_test "excludes files matching patterns" test_excludes_patterns
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_backup_tests
+ print_summary
+fi
diff --git a/tests/cases/test_cron.sh b/tests/cases/test_cron.sh
new file mode 100755
index 0000000..2fdb6f5
--- /dev/null
+++ b/tests/cases/test_cron.sh
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Cron Job Management Tests
+# ==============================================================================
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+# Save original crontab to restore later
+ORIGINAL_CRONTAB=""
+
+save_crontab() {
+ ORIGINAL_CRONTAB=$(crontab -l 2>/dev/null || true)
+}
+
+restore_crontab() {
+ if [ -n "$ORIGINAL_CRONTAB" ]; then
+ echo "$ORIGINAL_CRONTAB" | crontab -
+ else
+ crontab -r 2>/dev/null || true
+ fi
+}
+
+# ------------------------------------------------------------------------------
+# Test: Setup adds cron jobs
+# ------------------------------------------------------------------------------
+test_setup_adds_cron_jobs() {
+ setup_test_env
+ save_crontab
+
+ # Clear existing crontab
+ crontab -r 2>/dev/null || true
+
+ # Create minimal config
+ cat > "$TEST_CONFIG_DIR/config" << EOF
+REMOTE_HOST=""
+MOUNTDIR="$TEST_BACKUP_DIR"
+EOF
+ create_test_includes
+ create_test_excludes
+
+ # Run setup (will fail on some checks but should still add cron)
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true
+
+ # Check crontab contains rsyncshot entries
+ local crontab_content
+ crontab_content=$(crontab -l 2>/dev/null)
+
+ restore_crontab
+ teardown_test_env
+
+ assert_contains "$crontab_content" "rsyncshot" "crontab should contain rsyncshot" || return 1
+ assert_contains "$crontab_content" "hourly" "crontab should contain hourly job" || return 1
+ assert_contains "$crontab_content" "daily" "crontab should contain daily job" || return 1
+ assert_contains "$crontab_content" "weekly" "crontab should contain weekly job" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Repeated setup doesn't duplicate entries
+# ------------------------------------------------------------------------------
+test_no_duplicate_cron_entries() {
+ setup_test_env
+ save_crontab
+
+ # Clear existing crontab
+ crontab -r 2>/dev/null || true
+
+ cat > "$TEST_CONFIG_DIR/config" << EOF
+REMOTE_HOST=""
+MOUNTDIR="$TEST_BACKUP_DIR"
+EOF
+ create_test_includes
+ create_test_excludes
+
+ # Run setup twice
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true
+
+ # Count rsyncshot entries
+ local crontab_content hourly_count
+ crontab_content=$(crontab -l 2>/dev/null)
+ hourly_count=$(echo "$crontab_content" | grep -c "hourly" || echo 0)
+
+ restore_crontab
+ teardown_test_env
+
+ # Should only have 1 hourly entry, not 2
+ assert_equals "1" "$hourly_count" "should have exactly 1 hourly entry after repeated setup" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Setup preserves existing cron jobs
+# ------------------------------------------------------------------------------
+test_preserves_existing_cron() {
+ setup_test_env
+ save_crontab
+
+ # Add a custom cron job
+ (crontab -l 2>/dev/null || true; echo "0 5 * * * /custom/job.sh") | crontab -
+
+ cat > "$TEST_CONFIG_DIR/config" << EOF
+REMOTE_HOST=""
+MOUNTDIR="$TEST_BACKUP_DIR"
+EOF
+ create_test_includes
+ create_test_excludes
+
+ # Run setup
+ sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true
+
+ # Check custom job still exists
+ local crontab_content
+ crontab_content=$(crontab -l 2>/dev/null)
+
+ restore_crontab
+ teardown_test_env
+
+ assert_contains "$crontab_content" "/custom/job.sh" "should preserve existing cron jobs" || return 1
+ assert_contains "$crontab_content" "rsyncshot" "should also have rsyncshot jobs" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_cron_tests() {
+ echo ""
+ echo "Running cron tests..."
+ echo "------------------------------------------------------------"
+
+ run_test "setup adds cron jobs" test_setup_adds_cron_jobs
+ run_test "repeated setup doesn't duplicate entries" test_no_duplicate_cron_entries
+ run_test "setup preserves existing cron jobs" test_preserves_existing_cron
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_cron_tests
+ print_summary
+fi
diff --git a/tests/cases/test_dryrun.sh b/tests/cases/test_dryrun.sh
new file mode 100755
index 0000000..bff45e1
--- /dev/null
+++ b/tests/cases/test_dryrun.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Dry-Run Mode Tests
+# ==============================================================================
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+# ------------------------------------------------------------------------------
+# Test: Dry-run doesn't create backup directory
+# ------------------------------------------------------------------------------
+test_dryrun_no_directory_creation() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ # Remove backup dir to verify it's not created
+ rmdir "$TEST_BACKUP_DIR"
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+
+ # Backup directory should NOT be created in dry-run mode
+ assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME" "backup dir should not be created in dryrun" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Test: Dry-run shows what would be transferred
+# ------------------------------------------------------------------------------
+test_dryrun_shows_transfer_info() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ # Create the backup directory structure for dryrun to work
+ mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest"
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+
+ teardown_test_env
+
+ # Should show syncing messages
+ assert_contains "$output" "Syncing" "should show syncing info" || return 1
+ # Should show dry run message
+ assert_contains "$output" "Dry run complete" "should show dryrun complete message" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Dry-run shows command to run actual backup
+# ------------------------------------------------------------------------------
+test_dryrun_shows_actual_command() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest"
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+
+ teardown_test_env
+
+ # Should show how to run actual backup
+ assert_contains "$output" "sudo rsyncshot manual 1" "should show actual command" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Dry-run doesn't create snapshots
+# ------------------------------------------------------------------------------
+test_dryrun_no_snapshot_creation() {
+ setup_test_env
+
+ create_test_config
+ create_test_includes
+ create_test_excludes
+
+ mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest"
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+
+ # Should not create manual.0 snapshot
+ assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/manual.0" "snapshot should not be created in dryrun" || {
+ teardown_test_env
+ return 1
+ }
+
+ teardown_test_env
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_dryrun_tests() {
+ echo ""
+ echo "Running dry-run tests..."
+ echo "------------------------------------------------------------"
+
+ run_test "dry-run doesn't create backup directory" test_dryrun_no_directory_creation
+ run_test "dry-run shows transfer info" test_dryrun_shows_transfer_info
+ run_test "dry-run shows actual command" test_dryrun_shows_actual_command
+ run_test "dry-run doesn't create snapshots" test_dryrun_no_snapshot_creation
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_dryrun_tests
+ print_summary
+fi
diff --git a/tests/cases/test_includes.sh b/tests/cases/test_includes.sh
new file mode 100755
index 0000000..8f556e3
--- /dev/null
+++ b/tests/cases/test_includes.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Include File Parsing Tests
+# ==============================================================================
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+# ------------------------------------------------------------------------------
+# Test: Reads newline-separated paths
+# ------------------------------------------------------------------------------
+test_reads_newline_paths() {
+ setup_test_env
+
+ # Create config and include files
+ create_test_config
+ create_test_excludes
+
+ # Create include file with newline-separated paths
+ cat > "$TEST_CONFIG_DIR/include.txt" << EOF
+$TEST_SOURCE_DIR/home
+$TEST_SOURCE_DIR/etc
+EOF
+
+ # Run dryrun to test parsing
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+ local exit_code=$?
+
+ teardown_test_env
+
+ # Should process both directories
+ assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1
+ assert_contains "$output" "Syncing $TEST_SOURCE_DIR/etc" "should sync etc" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Skips comment lines
+# ------------------------------------------------------------------------------
+test_skips_comments() {
+ setup_test_env
+
+ create_test_config
+ create_test_excludes
+
+ # Create include file with comments
+ cat > "$TEST_CONFIG_DIR/include.txt" << EOF
+# This is a comment
+$TEST_SOURCE_DIR/home
+# Another comment
+$TEST_SOURCE_DIR/etc
+EOF
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+
+ teardown_test_env
+
+ # Should not try to sync comment lines
+ assert_not_contains "$output" "Syncing # This" "should skip comments" || return 1
+ assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Skips empty lines
+# ------------------------------------------------------------------------------
+test_skips_empty_lines() {
+ setup_test_env
+
+ create_test_config
+ create_test_excludes
+
+ # Create include file with empty lines
+ cat > "$TEST_CONFIG_DIR/include.txt" << EOF
+$TEST_SOURCE_DIR/home
+
+$TEST_SOURCE_DIR/etc
+
+EOF
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+
+ teardown_test_env
+
+ # Should process both directories without errors
+ assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1
+ assert_contains "$output" "Syncing $TEST_SOURCE_DIR/etc" "should sync etc" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Handles paths with spaces
+# ------------------------------------------------------------------------------
+test_handles_paths_with_spaces() {
+ setup_test_env
+
+ create_test_config
+ create_test_excludes
+
+ # Create a directory with spaces
+ mkdir -p "$TEST_SOURCE_DIR/path with spaces"
+ echo "test" > "$TEST_SOURCE_DIR/path with spaces/file.txt"
+
+ # Create include file with path containing spaces
+ cat > "$TEST_CONFIG_DIR/include.txt" << EOF
+$TEST_SOURCE_DIR/path with spaces
+EOF
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+
+ teardown_test_env
+
+ # Should handle the path with spaces
+ assert_contains "$output" "path with spaces" "should handle spaces in path" || return 1
+ assert_not_contains "$output" "not found" "should not report path not found" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Reports missing directory
+# ------------------------------------------------------------------------------
+test_reports_missing_directory() {
+ setup_test_env
+
+ create_test_config
+ create_test_excludes
+
+ # Create include file with non-existent path
+ cat > "$TEST_CONFIG_DIR/include.txt" << EOF
+/nonexistent/path/that/does/not/exist
+EOF
+
+ local output
+ output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1)
+ local exit_code=$?
+
+ teardown_test_env
+
+ assert_exit_code 1 "$exit_code" "should fail for missing directory" || return 1
+ assert_contains "$output" "not found" "should report directory not found" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_includes_tests() {
+ echo ""
+ echo "Running include file tests..."
+ echo "------------------------------------------------------------"
+
+ run_test "reads newline-separated paths" test_reads_newline_paths
+ run_test "skips comment lines" test_skips_comments
+ run_test "skips empty lines" test_skips_empty_lines
+ run_test "handles paths with spaces" test_handles_paths_with_spaces
+ run_test "reports missing directory" test_reports_missing_directory
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_includes_tests
+ print_summary
+fi
diff --git a/tests/cases/test_validation.sh b/tests/cases/test_validation.sh
new file mode 100755
index 0000000..03da676
--- /dev/null
+++ b/tests/cases/test_validation.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Input Validation Tests
+# ==============================================================================
+
+source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh"
+
+# ------------------------------------------------------------------------------
+# Test: Help works without root
+# ------------------------------------------------------------------------------
+test_help_without_root() {
+ local output
+ output=$("$SCRIPT_PATH" help 2>&1)
+ local exit_code=$?
+
+ assert_exit_code 0 "$exit_code" "help should exit with 0" || return 1
+ assert_contains "$output" "rsyncshot" "help should mention rsyncshot" || return 1
+ assert_contains "$output" "Usage" "help should show usage" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Rejects non-alphabetic snapshot type
+# ------------------------------------------------------------------------------
+test_rejects_numeric_snapshot_type() {
+ local output
+ output=$(sudo "$SCRIPT_PATH" "123" "5" 2>&1)
+ local exit_code=$?
+
+ assert_exit_code 1 "$exit_code" "should reject numeric snapshot type" || return 1
+ assert_contains "$output" "must be alphabetic" "should show alphabetic error" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Rejects mixed alphanumeric snapshot type
+# ------------------------------------------------------------------------------
+test_rejects_mixed_snapshot_type() {
+ local output
+ output=$(sudo "$SCRIPT_PATH" "hourly123" "5" 2>&1)
+ local exit_code=$?
+
+ assert_exit_code 1 "$exit_code" "should reject mixed snapshot type" || return 1
+ assert_contains "$output" "must be alphabetic" "should show alphabetic error" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Rejects non-numeric retention count
+# ------------------------------------------------------------------------------
+test_rejects_alpha_retention_count() {
+ local output
+ output=$(sudo "$SCRIPT_PATH" "manual" "abc" 2>&1)
+ local exit_code=$?
+
+ assert_exit_code 1 "$exit_code" "should reject alphabetic count" || return 1
+ assert_contains "$output" "must be a number" "should show number error" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Rejects mixed alphanumeric retention count
+# ------------------------------------------------------------------------------
+test_rejects_mixed_retention_count() {
+ local output
+ output=$(sudo "$SCRIPT_PATH" "manual" "5abc" 2>&1)
+ local exit_code=$?
+
+ assert_exit_code 1 "$exit_code" "should reject mixed count" || return 1
+ assert_contains "$output" "must be a number" "should show number error" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Test: Accepts valid alphabetic snapshot types
+# ------------------------------------------------------------------------------
+test_accepts_valid_snapshot_types() {
+ # We use dryrun to avoid actual backup, and expect it to fail on missing config
+ # but it should get past the validation stage
+ local output
+ output=$(sudo "$SCRIPT_PATH" dryrun "hourly" "24" 2>&1)
+
+ # Should not contain the validation error (might fail for other reasons like missing config)
+ assert_not_contains "$output" "must be alphabetic" "should accept valid type" || return 1
+}
+
+# ------------------------------------------------------------------------------
+# Run tests
+# ------------------------------------------------------------------------------
+run_validation_tests() {
+ echo ""
+ echo "Running validation tests..."
+ echo "------------------------------------------------------------"
+
+ setup_test_env
+
+ run_test "help works without root" test_help_without_root
+ run_test "rejects numeric snapshot type" test_rejects_numeric_snapshot_type
+ run_test "rejects mixed alphanumeric snapshot type" test_rejects_mixed_snapshot_type
+ run_test "rejects alphabetic retention count" test_rejects_alpha_retention_count
+ run_test "rejects mixed retention count" test_rejects_mixed_retention_count
+ run_test "accepts valid snapshot types" test_accepts_valid_snapshot_types
+
+ teardown_test_env
+}
+
+# Run if executed directly
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ run_validation_tests
+ print_summary
+fi
diff --git a/tests/lib/test_helpers.sh b/tests/lib/test_helpers.sh
new file mode 100755
index 0000000..726d788
--- /dev/null
+++ b/tests/lib/test_helpers.sh
@@ -0,0 +1,247 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# Test Helper Functions for rsyncshot
+# ==============================================================================
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[0;33m'
+NC='\033[0m' # No Color
+
+# Counters
+TESTS_RUN=0
+TESTS_PASSED=0
+TESTS_FAILED=0
+
+# Test environment paths
+TEST_DIR=""
+TEST_CONFIG_DIR=""
+TEST_BACKUP_DIR=""
+SCRIPT_PATH=""
+
+# ------------------------------------------------------------------------------
+# Setup/Teardown
+# ------------------------------------------------------------------------------
+
+setup_test_env() {
+ # Create temporary directories for testing
+ TEST_DIR=$(mktemp -d)
+ TEST_CONFIG_DIR="$TEST_DIR/etc/rsyncshot"
+ TEST_BACKUP_DIR="$TEST_DIR/backup"
+ TEST_SOURCE_DIR="$TEST_DIR/source"
+
+ mkdir -p "$TEST_CONFIG_DIR"
+ mkdir -p "$TEST_BACKUP_DIR"
+ mkdir -p "$TEST_SOURCE_DIR/home/testuser"
+ mkdir -p "$TEST_SOURCE_DIR/etc"
+
+ # Create some test files
+ echo "test file 1" > "$TEST_SOURCE_DIR/home/testuser/file1.txt"
+ echo "test file 2" > "$TEST_SOURCE_DIR/home/testuser/file2.txt"
+ echo "config data" > "$TEST_SOURCE_DIR/etc/test.conf"
+
+ # Find the script (relative to tests directory)
+ SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/rsyncshot"
+
+ if [ ! -f "$SCRIPT_PATH" ]; then
+ echo "ERROR: Cannot find rsyncshot script at $SCRIPT_PATH"
+ exit 1
+ fi
+}
+
+teardown_test_env() {
+ # Clean up temporary directories
+ if [ -n "$TEST_DIR" ] && [ -d "$TEST_DIR" ]; then
+ rm -rf "$TEST_DIR"
+ fi
+}
+
+# Run rsyncshot with test environment variables
+# Usage: run_rsyncshot [args...]
+run_rsyncshot() {
+ INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" "$@"
+}
+
+# Create a test config file
+create_test_config() {
+ local config_file="$TEST_CONFIG_DIR/config"
+ cat > "$config_file" << EOF
+REMOTE_HOST=""
+MOUNTDIR="$TEST_BACKUP_DIR"
+EOF
+ echo "$config_file"
+}
+
+# Create a test include file
+create_test_includes() {
+ local include_file="$TEST_CONFIG_DIR/include.txt"
+ cat > "$include_file" << EOF
+$TEST_SOURCE_DIR/home
+$TEST_SOURCE_DIR/etc
+EOF
+ echo "$include_file"
+}
+
+# Create a test exclude file
+create_test_excludes() {
+ local exclude_file="$TEST_CONFIG_DIR/exclude.txt"
+ cat > "$exclude_file" << EOF
+*.tmp
+*.log
+.cache
+EOF
+ echo "$exclude_file"
+}
+
+# ------------------------------------------------------------------------------
+# Assertions
+# ------------------------------------------------------------------------------
+
+assert_equals() {
+ local expected="$1"
+ local actual="$2"
+ local message="${3:-Values should be equal}"
+
+ if [ "$expected" = "$actual" ]; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ echo " Expected: $expected"
+ echo " Actual: $actual"
+ return 1
+ fi
+}
+
+assert_exit_code() {
+ local expected="$1"
+ local actual="$2"
+ local message="${3:-Exit code should be $expected}"
+
+ if [ "$expected" -eq "$actual" ]; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ echo " Expected exit code: $expected"
+ echo " Actual exit code: $actual"
+ return 1
+ fi
+}
+
+assert_contains() {
+ local haystack="$1"
+ local needle="$2"
+ local message="${3:-Output should contain '$needle'}"
+
+ if echo "$haystack" | grep -q "$needle"; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ echo " Looking for: $needle"
+ echo " In output: $haystack"
+ return 1
+ fi
+}
+
+assert_not_contains() {
+ local haystack="$1"
+ local needle="$2"
+ local message="${3:-Output should not contain '$needle'}"
+
+ if ! echo "$haystack" | grep -q "$needle"; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ echo " Should not contain: $needle"
+ echo " But found in: $haystack"
+ return 1
+ fi
+}
+
+assert_file_exists() {
+ local file="$1"
+ local message="${2:-File should exist: $file}"
+
+ if [ -f "$file" ]; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ return 1
+ fi
+}
+
+assert_dir_exists() {
+ local dir="$1"
+ local message="${2:-Directory should exist: $dir}"
+
+ if [ -d "$dir" ]; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ return 1
+ fi
+}
+
+assert_file_not_exists() {
+ local file="$1"
+ local message="${2:-File should not exist: $file}"
+
+ if [ ! -f "$file" ]; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ return 1
+ fi
+}
+
+assert_dir_not_exists() {
+ local dir="$1"
+ local message="${2:-Directory should not exist: $dir}"
+
+ if [ ! -d "$dir" ]; then
+ return 0
+ else
+ echo -e "${RED}FAIL${NC}: $message"
+ return 1
+ fi
+}
+
+# ------------------------------------------------------------------------------
+# Test Runner
+# ------------------------------------------------------------------------------
+
+run_test() {
+ local test_name="$1"
+ local test_func="$2"
+
+ TESTS_RUN=$((TESTS_RUN + 1))
+
+ # Run the test function and capture result
+ if $test_func; then
+ echo -e "${GREEN}PASS${NC}: $test_name"
+ TESTS_PASSED=$((TESTS_PASSED + 1))
+ return 0
+ else
+ TESTS_FAILED=$((TESTS_FAILED + 1))
+ return 1
+ fi
+}
+
+print_summary() {
+ echo ""
+ echo "============================================================"
+ echo "Test Summary"
+ echo "============================================================"
+ echo -e "Total: $TESTS_RUN"
+ echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}"
+ echo -e "Failed: ${RED}$TESTS_FAILED${NC}"
+ echo ""
+
+ if [ "$TESTS_FAILED" -eq 0 ]; then
+ echo -e "${GREEN}All tests passed!${NC}"
+ return 0
+ else
+ echo -e "${RED}Some tests failed.${NC}"
+ return 1
+ fi
+}
diff --git a/tests/test_rsyncshot.sh b/tests/test_rsyncshot.sh
new file mode 100755
index 0000000..15f2a99
--- /dev/null
+++ b/tests/test_rsyncshot.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# rsyncshot Test Suite
+# ==============================================================================
+#
+# Runs all automated tests for rsyncshot.
+#
+# Usage:
+# sudo ./tests/test_rsyncshot.sh # Run all tests
+# sudo ./tests/test_rsyncshot.sh -v # Verbose output
+# sudo ./tests/test_rsyncshot.sh --quick # Skip slow tests
+#
+# ==============================================================================
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+VERBOSE=false
+QUICK=false
+
+# Parse arguments
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -v|--verbose)
+ VERBOSE=true
+ shift
+ ;;
+ -q|--quick)
+ QUICK=true
+ shift
+ ;;
+ -h|--help)
+ echo "Usage: $0 [-v|--verbose] [-q|--quick]"
+ echo ""
+ echo "Options:"
+ echo " -v, --verbose Show detailed test output"
+ echo " -q, --quick Skip slow tests (backup/rotation)"
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1"
+ exit 1
+ ;;
+ esac
+done
+
+# Check for root
+if [ "$EUID" -ne 0 ]; then
+ echo "Tests must be run as root (sudo ./tests/test_rsyncshot.sh)"
+ exit 1
+fi
+
+# Source test helpers
+source "$SCRIPT_DIR/lib/test_helpers.sh"
+
+echo "============================================================"
+echo "rsyncshot Test Suite"
+echo "============================================================"
+echo ""
+echo "Script: $(cd "$SCRIPT_DIR/.." && pwd)/rsyncshot"
+echo "Date: $(date)"
+echo ""
+
+# Track overall results
+TOTAL_RUN=0
+TOTAL_PASSED=0
+TOTAL_FAILED=0
+
+# Run a test file and accumulate results
+run_test_file() {
+ local test_file="$1"
+ local test_name="$2"
+
+ # Reset counters before sourcing
+ TESTS_RUN=0
+ TESTS_PASSED=0
+ TESTS_FAILED=0
+
+ # Source and run the test file
+ source "$test_file"
+
+ # Call the run function
+ "run_${test_name}_tests"
+
+ # Accumulate totals
+ TOTAL_RUN=$((TOTAL_RUN + TESTS_RUN))
+ TOTAL_PASSED=$((TOTAL_PASSED + TESTS_PASSED))
+ TOTAL_FAILED=$((TOTAL_FAILED + TESTS_FAILED))
+}
+
+# Run test suites
+run_test_file "$SCRIPT_DIR/cases/test_validation.sh" "validation"
+run_test_file "$SCRIPT_DIR/cases/test_includes.sh" "includes"
+run_test_file "$SCRIPT_DIR/cases/test_dryrun.sh" "dryrun"
+
+if [ "$QUICK" = false ]; then
+ run_test_file "$SCRIPT_DIR/cases/test_backup.sh" "backup"
+ run_test_file "$SCRIPT_DIR/cases/test_cron.sh" "cron"
+else
+ echo ""
+ echo "Skipping slow tests (backup, cron) - use without --quick to run all"
+fi
+
+# Print final summary
+echo ""
+echo "============================================================"
+echo "Final Summary"
+echo "============================================================"
+echo -e "Total: $TOTAL_RUN"
+echo -e "Passed: ${GREEN}$TOTAL_PASSED${NC}"
+echo -e "Failed: ${RED}$TOTAL_FAILED${NC}"
+echo ""
+
+if [ "$TOTAL_FAILED" -eq 0 ]; then
+ echo -e "${GREEN}All tests passed!${NC}"
+ exit 0
+else
+ echo -e "${RED}Some tests failed.${NC}"
+ exit 1
+fi