Files
cura-te-ipsum/tests/test_integration.py
Maks Snegov 52b4fef814 Add error recovery tests for backup failures
Adds 7 new tests in TestErrorRecovery class covering:
- Hardlink failure cleanup (incomplete backup removal)
- Rsync failure cleanup
- Detection of incomplete backups without marker files
- Lock handling after backup failures
- Permission error handling during cleanup

Tests verify that failed backups are properly cleaned up and don't interfere
with subsequent backup operations or lock management.
2026-02-04 22:21:02 -08:00

885 lines
28 KiB
Python

"""
Integration tests for the full backup workflow.
Tests the complete backup process from start to finish.
"""
import os
import shutil
import sys
import time
import pytest
from curateipsum import backup as bk
@pytest.fixture
def integration_dirs(tmp_path):
"""Setup integration test directories."""
backups_dir = tmp_path / "backups"
source_dir = tmp_path / "source"
backups_dir.mkdir()
source_dir.mkdir()
return backups_dir, source_dir
def test_initial_backup_creation(integration_dirs):
"""Test creating the first backup"""
backups_dir, source_dir = integration_dirs
# Create some files in source
(source_dir / "file1.txt").write_text("content1")
(source_dir / "file2.txt").write_text("content2")
# Run backup
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Verify backup was created
backups = os.listdir(str(backups_dir))
# Filter out lock files
backups = [b for b in backups if not b.startswith(".")]
assert len(backups) == 1
# Verify files exist in backup
backup_path = os.path.join(str(backups_dir), backups[0])
source_name = os.path.basename(str(source_dir))
backup_file1 = os.path.join(backup_path, source_name, "file1.txt")
backup_file2 = os.path.join(backup_path, source_name, "file2.txt")
assert os.path.exists(backup_file1)
assert os.path.exists(backup_file2)
# Verify backup marker exists
marker_files = [f for f in os.listdir(backup_path)
if f.startswith(".backup_finished")]
assert len(marker_files) == 1
def test_incremental_backup_with_hardlinks(integration_dirs):
"""Test that second backup uses hardlinks for unchanged files"""
backups_dir, source_dir = integration_dirs
# Create initial file
src_file = source_dir / "unchanged.txt"
src_file.write_text("unchanged content")
# First backup
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Wait a bit to ensure different timestamp
time.sleep(1.1)
# Second backup (no changes)
# Add a new file to trigger a new backup
(source_dir / "new.txt").write_text("new content")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Verify two backups exist
backups = sorted([b for b in os.listdir(str(backups_dir))
if not b.startswith(".")])
assert len(backups) == 2
# Verify unchanged file is hardlinked
source_name = os.path.basename(str(source_dir))
file1_path = os.path.join(str(backups_dir), backups[0],
source_name, "unchanged.txt")
file2_path = os.path.join(str(backups_dir), backups[1],
source_name, "unchanged.txt")
stat1 = os.stat(file1_path)
stat2 = os.stat(file2_path)
# Same inode means hardlinked
assert stat1.st_ino == stat2.st_ino
# Link count should be 2
assert stat1.st_nlink == 2
def test_backup_delta_directory(integration_dirs):
"""Test that delta directory contains changed files"""
backups_dir, source_dir = integration_dirs
# Create initial file
(source_dir / "file.txt").write_text("original")
# First backup
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
# Modify file
(source_dir / "file.txt").write_text("modified")
# Second backup
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Check delta directory in second backup
backups = sorted([b for b in os.listdir(str(backups_dir))
if not b.startswith(".")])
second_backup = backups[1]
delta_dir = os.path.join(str(backups_dir), second_backup, bk.DELTA_DIR)
# Delta directory should exist and contain the modified file
assert os.path.isdir(delta_dir)
source_name = os.path.basename(str(source_dir))
delta_file = os.path.join(delta_dir, source_name, "file.txt")
assert os.path.exists(delta_file)
def test_cleanup_retains_recent_backups(integration_dirs):
"""Test that cleanup doesn't remove recent backups"""
backups_dir, source_dir = integration_dirs
# Create multiple backups
for i in range(3):
(source_dir / f"file{i}.txt").write_text(f"content {i}")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
# Run cleanup with keep_all=10 (all should be kept)
bk.cleanup_old_backups(
backups_dir=str(backups_dir),
dry_run=False,
keep_all=10
)
# All backups should still exist
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 3
def test_dry_run_creates_no_backup(integration_dirs):
"""Test that dry run doesn't create actual backup"""
backups_dir, source_dir = integration_dirs
(source_dir / "file.txt").write_text("content")
# Dry run backup
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=True
)
# No backup should be created
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 0
def test_no_backup_if_no_changes(integration_dirs):
"""Test that no backup is created if nothing changed"""
backups_dir, source_dir = integration_dirs
# Create initial file
(source_dir / "file.txt").write_text("content")
# First backup
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
# Second backup with no changes
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Only one backup should exist
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 1
def test_lock_prevents_concurrent_backups(integration_dirs):
"""Test that lock file prevents concurrent backup runs"""
backups_dir, source_dir = integration_dirs
(source_dir / "file.txt").write_text("content")
# Manually create lock file
lock_acquired = bk.set_backups_lock(str(backups_dir))
assert lock_acquired
try:
# Try to run backup (should be blocked by lock)
# We can't actually test this without spawning a subprocess,
# but we can verify the lock exists
lock_path = os.path.join(str(backups_dir), bk.LOCK_FILE)
assert os.path.exists(lock_path)
finally:
bk.release_backups_lock(str(backups_dir))
# After releasing lock, backup should work
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 1
def test_multiple_source_directories(integration_dirs, tmp_path):
"""Test backing up multiple source directories"""
backups_dir, source_dir = integration_dirs
# Create second source
source2_dir = tmp_path / "source2"
source2_dir.mkdir()
# Create files in both sources
(source_dir / "file1.txt").write_text("source1")
(source2_dir / "file2.txt").write_text("source2")
# Backup both sources
bk.initiate_backup(
sources=[str(source_dir), str(source2_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Verify both sources are in backup
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 1
backup_path = os.path.join(str(backups_dir), backups[0])
source1_name = os.path.basename(str(source_dir))
source2_name = os.path.basename(str(source2_dir))
assert os.path.exists(os.path.join(backup_path, source1_name,
"file1.txt"))
assert os.path.exists(os.path.join(backup_path, source2_name,
"file2.txt"))
@pytest.fixture
def recovery_dirs(tmp_path):
"""Setup recovery test directories."""
backups_dir = tmp_path / "backups"
source_dir = tmp_path / "source"
restore_dir = tmp_path / "restore"
backups_dir.mkdir()
source_dir.mkdir()
return backups_dir, source_dir, restore_dir
def test_restore_from_backup(recovery_dirs):
"""Test restoring files from a backup"""
backups_dir, source_dir, restore_dir = recovery_dirs
# Create source files
src_file = source_dir / "important.txt"
src_file.write_text("important data")
# Create backup
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Simulate data loss - delete source
os.unlink(str(src_file))
assert not os.path.exists(str(src_file))
# Restore from backup
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
backup_path = os.path.join(str(backups_dir), backups[0])
source_name = os.path.basename(str(source_dir))
backed_up_file = os.path.join(backup_path, source_name, "important.txt")
# Verify file exists in backup
assert os.path.exists(backed_up_file)
# Restore file
restore_dir.mkdir()
import shutil
shutil.copy2(backed_up_file, str(restore_dir / "important.txt"))
# Verify restored content
assert (restore_dir / "important.txt").read_text() == "important data"
def test_find_file_version_in_old_backup(recovery_dirs):
"""Test finding an old version of a file"""
backups_dir, source_dir, _ = recovery_dirs
src_file = source_dir / "document.txt"
# Create version 1
src_file.write_text("version 1")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
# Create version 2
src_file.write_text("version 2")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Verify we can access both versions
backups = sorted([b for b in os.listdir(str(backups_dir))
if not b.startswith(".")])
source_name = os.path.basename(str(source_dir))
# First backup has version 1
backup1_file = os.path.join(str(backups_dir), backups[0],
source_name, "document.txt")
with open(backup1_file, "r") as f:
assert f.read() == "version 1"
# Second backup has version 2
backup2_file = os.path.join(str(backups_dir), backups[1],
source_name, "document.txt")
with open(backup2_file, "r") as f:
assert f.read() == "version 2"
# External tool availability checks
RSYNC_AVAILABLE = shutil.which("rsync") is not None
CP_PROGRAM = "gcp" if sys.platform == "darwin" else "cp"
CP_AVAILABLE = shutil.which(CP_PROGRAM) is not None
@pytest.mark.skipif(not RSYNC_AVAILABLE, reason="rsync not available")
def test_external_rsync_creates_backup(integration_dirs):
"""Test backup using external rsync binary"""
backups_dir, source_dir = integration_dirs
# Create initial file
(source_dir / "file1.txt").write_text("content1")
# Create first backup with Python rsync (to establish baseline)
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
# Add new file for second backup
(source_dir / "file2.txt").write_text("content2")
# Second backup with external rsync
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
external_rsync=True
)
# Verify two backups exist
backups = sorted([b for b in os.listdir(str(backups_dir))
if not b.startswith(".")])
assert len(backups) == 2
# Verify files exist in second backup
backup_path = os.path.join(str(backups_dir), backups[1])
source_name = os.path.basename(str(source_dir))
backup_file1 = os.path.join(backup_path, source_name, "file1.txt")
backup_file2 = os.path.join(backup_path, source_name, "file2.txt")
assert os.path.exists(backup_file1)
assert os.path.exists(backup_file2)
assert open(backup_file1).read() == "content1"
assert open(backup_file2).read() == "content2"
@pytest.mark.skipif(not CP_AVAILABLE, reason=f"{CP_PROGRAM} not available")
def test_external_hardlink_creates_backup(integration_dirs):
"""Test backup using external cp/gcp for hardlinking"""
backups_dir, source_dir = integration_dirs
# Create initial file
(source_dir / "unchanged.txt").write_text("unchanged content")
# First backup (creates baseline)
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
external_hardlink=True
)
time.sleep(1.1)
# Add new file for second backup
(source_dir / "new.txt").write_text("new content")
# Second backup with external hardlink
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
external_hardlink=True
)
# Verify two backups exist
backups = sorted([b for b in os.listdir(str(backups_dir))
if not b.startswith(".")])
assert len(backups) == 2
# Verify unchanged file is hardlinked (same inode)
source_name = os.path.basename(str(source_dir))
file1_path = os.path.join(str(backups_dir), backups[0],
source_name, "unchanged.txt")
file2_path = os.path.join(str(backups_dir), backups[1],
source_name, "unchanged.txt")
stat1 = os.stat(file1_path)
stat2 = os.stat(file2_path)
# Same inode means hardlinked
assert stat1.st_ino == stat2.st_ino
assert stat1.st_nlink == 2
@pytest.mark.skipif(not (RSYNC_AVAILABLE and CP_AVAILABLE),
reason=f"rsync or {CP_PROGRAM} not available")
def test_both_external_tools(integration_dirs):
"""Test backup using both external rsync and external hardlink"""
backups_dir, source_dir = integration_dirs
# Create initial files
(source_dir / "unchanged.txt").write_text("unchanged")
(source_dir / "modified.txt").write_text("original")
# First backup with Python tools (to establish baseline)
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
# Modify one file, leave other unchanged
(source_dir / "modified.txt").write_text("new content")
# Second backup with external tools
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
external_rsync=True,
external_hardlink=True
)
# Verify two backups exist
backups = sorted([b for b in os.listdir(str(backups_dir))
if not b.startswith(".")])
assert len(backups) == 2
source_name = os.path.basename(str(source_dir))
# Verify modified file has new content in second backup
backup2_modified = os.path.join(str(backups_dir), backups[1],
source_name, "modified.txt")
assert open(backup2_modified).read() == "new content"
# Verify unchanged file is hardlinked between backups
backup1_unchanged = os.path.join(str(backups_dir), backups[0],
source_name, "unchanged.txt")
backup2_unchanged = os.path.join(str(backups_dir), backups[1],
source_name, "unchanged.txt")
stat1 = os.stat(backup1_unchanged)
stat2 = os.stat(backup2_unchanged)
# External hardlink should preserve hardlinks for unchanged files
assert stat1.st_ino == stat2.st_ino
assert stat1.st_nlink == 2
class TestErrorRecovery:
"""Test error recovery and cleanup during backup failures"""
def test_hardlink_failure_removes_incomplete_backup(
self, integration_dirs, monkeypatch
):
"""Test that incomplete backup is removed when hardlink_dir fails"""
backups_dir, source_dir = integration_dirs
# Create initial backup
(source_dir / "file1.txt").write_text("content1")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Verify first backup exists
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 1
time.sleep(1.1)
# Add new file to trigger backup
(source_dir / "file2.txt").write_text("content2")
# Mock hardlink_dir to fail
from curateipsum import fs
original_hardlink_dir = fs.hardlink_dir
def failing_hardlink_dir(*args, **kwargs):
# Create partial directory to simulate partial failure
if "dst_dir" in kwargs:
dst = kwargs["dst_dir"]
else:
dst = args[1] if len(args) > 1 else None
if dst:
os.makedirs(dst, exist_ok=True)
# Create a partial file to test cleanup
with open(os.path.join(dst, "partial.txt"), "w") as f:
f.write("partial")
return False
monkeypatch.setattr(fs, "hardlink_dir", failing_hardlink_dir)
# Try to create second backup (should fail)
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Only original backup should exist (failed backup cleaned up)
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 1
# Verify the remaining backup is the original one
backup_path = os.path.join(str(backups_dir), backups[0])
source_name = os.path.basename(str(source_dir))
assert os.path.exists(os.path.join(backup_path, source_name,
"file1.txt"))
# file2.txt should not exist in original backup
assert not os.path.exists(os.path.join(backup_path, source_name,
"file2.txt"))
def test_rsync_failure_removes_incomplete_backup(
self, integration_dirs, monkeypatch
):
"""Test that incomplete backup is removed when rsync fails"""
backups_dir, source_dir = integration_dirs
# Create initial backup
(source_dir / "file1.txt").write_text("content1")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
# Add new file
(source_dir / "file2.txt").write_text("content2")
# Mock rsync to fail immediately without yielding
from curateipsum import fs
def failing_rsync(src, dst, dry_run=False):
# Fail immediately before any operations
raise fs.BackupCreationError("Simulated rsync failure")
# Make this a generator (unreachable but keeps signature)
yield # pragma: no cover
monkeypatch.setattr(fs, "rsync", failing_rsync)
# Try to create second backup (should fail and clean up)
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Only original backup should exist
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 1
def test_incomplete_backup_without_marker(self, integration_dirs):
"""Test that backups without marker are not counted as valid"""
backups_dir, source_dir = integration_dirs
# Create a complete backup
(source_dir / "file1.txt").write_text("content1")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Manually create incomplete backup directory (no marker)
incomplete_backup_name = "20250101_120000"
incomplete_path = os.path.join(str(backups_dir),
incomplete_backup_name)
os.makedirs(incomplete_path)
source_name = os.path.basename(str(source_dir))
os.makedirs(os.path.join(incomplete_path, source_name))
with open(os.path.join(incomplete_path, source_name,
"incomplete.txt"), "w") as f:
f.write("incomplete data")
# List all directory entries (including incomplete)
all_dirs = [d for d in os.listdir(str(backups_dir))
if not d.startswith(".")]
assert len(all_dirs) == 2
# But _iterate_backups should only find complete backup
valid_backups = list(bk._iterate_backups(str(backups_dir)))
assert len(valid_backups) == 1
# Verify get_latest_backup ignores incomplete backup
latest = bk._get_latest_backup(str(backups_dir))
assert latest is not None
assert latest.name != incomplete_backup_name
time.sleep(1.1)
# New backup should hardlink from the complete backup, not incomplete
(source_dir / "file2.txt").write_text("content2")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Should now have 3 directories (1 incomplete, 2 complete)
all_dirs = [d for d in os.listdir(str(backups_dir))
if not d.startswith(".")]
assert len(all_dirs) == 3
valid_backups = list(bk._iterate_backups(str(backups_dir)))
assert len(valid_backups) == 2
def test_backup_marker_only_not_valid(self, integration_dirs):
"""Test that directory with only marker file is not a valid backup"""
backups_dir, source_dir = integration_dirs
# Create directory with only marker file
marker_only_name = "20250101_120000"
marker_only_path = os.path.join(str(backups_dir), marker_only_name)
os.makedirs(marker_only_path)
marker_file = os.path.join(marker_only_path,
f"{bk.BACKUP_MARKER}_{marker_only_name}")
with open(marker_file, "w") as f:
pass # empty marker file
# Should not be recognized as valid backup
valid_backups = list(bk._iterate_backups(str(backups_dir)))
assert len(valid_backups) == 0
# get_latest_backup should return None
latest = bk._get_latest_backup(str(backups_dir))
assert latest is None
def test_lock_released_after_hardlink_failure(
self, integration_dirs, monkeypatch
):
"""Test that lock is properly released when backup fails"""
backups_dir, source_dir = integration_dirs
# Create initial backup
(source_dir / "file1.txt").write_text("content1")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
(source_dir / "file2.txt").write_text("content2")
# Mock hardlink_dir to fail
from curateipsum import fs
def failing_hardlink_dir(*args, **kwargs):
return False
monkeypatch.setattr(fs, "hardlink_dir", failing_hardlink_dir)
# Manually acquire lock
lock_acquired = bk.set_backups_lock(str(backups_dir))
assert lock_acquired
# Verify lock file exists
lock_path = os.path.join(str(backups_dir), bk.LOCK_FILE)
assert os.path.exists(lock_path)
try:
# Backup should fail due to hardlink failure
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
finally:
# Lock should still exist (we manually acquired it)
assert os.path.exists(lock_path)
# Release lock
bk.release_backups_lock(str(backups_dir))
# Lock should be removed
assert not os.path.exists(lock_path)
# Restore original function and verify backup can proceed
monkeypatch.undo()
# Now backup should succeed
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Should have 2 valid backups now
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 2
def test_lock_released_after_rsync_failure(
self, integration_dirs, monkeypatch
):
"""Test that lock is released after rsync failure"""
backups_dir, source_dir = integration_dirs
# Create initial backup
(source_dir / "file1.txt").write_text("content1")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
(source_dir / "file2.txt").write_text("content2")
# Mock rsync to fail
from curateipsum import fs
def failing_rsync(src, dst, dry_run=False):
raise fs.BackupCreationError("Simulated rsync failure")
monkeypatch.setattr(fs, "rsync", failing_rsync)
# Manually acquire lock
lock_acquired = bk.set_backups_lock(str(backups_dir))
assert lock_acquired
lock_path = os.path.join(str(backups_dir), bk.LOCK_FILE)
assert os.path.exists(lock_path)
try:
# Backup should fail
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
finally:
# Lock still exists (we manually acquired it)
assert os.path.exists(lock_path)
bk.release_backups_lock(str(backups_dir))
# Lock should be removed
assert not os.path.exists(lock_path)
def test_permission_error_during_cleanup(
self, integration_dirs, monkeypatch
):
"""Test handling of permission errors during failed backup cleanup"""
backups_dir, source_dir = integration_dirs
# Create initial backup
(source_dir / "file1.txt").write_text("content1")
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
time.sleep(1.1)
(source_dir / "file2.txt").write_text("content2")
# Track rmtree calls
rmtree_called = []
original_rmtree = shutil.rmtree
def tracking_rmtree(path, *args, **kwargs):
rmtree_called.append(path)
# Let it succeed (ignore_errors=True in code)
return original_rmtree(path, *args, **kwargs)
monkeypatch.setattr(shutil, "rmtree", tracking_rmtree)
# Mock rsync to fail
from curateipsum import fs
def failing_rsync(src, dst, dry_run=False):
raise fs.BackupCreationError("Simulated failure")
monkeypatch.setattr(fs, "rsync", failing_rsync)
# Run backup (will fail and attempt cleanup)
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False
)
# Verify cleanup was attempted (rmtree was called)
assert len(rmtree_called) > 0
# Verify failed backup was removed
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 1