feat(configmanager): add BackupService with tests

This commit is contained in:
Joseph Doherty
2026-01-19 17:40:22 -05:00
parent 54620ccb2e
commit 0e441898a6
3 changed files with 182 additions and 0 deletions
@@ -0,0 +1,94 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for managing configuration file backups.
/// </summary>
public class BackupService : IBackupService
{
private readonly IFileSystem _fileSystem;
private readonly ILogger<BackupService>? _logger;
private const string TimestampFormat = "yyyy-MM-dd_HHmmss";
public BackupService(IFileSystem fileSystem, ILogger<BackupService>? logger = null)
{
_fileSystem = fileSystem;
_logger = logger;
}
public async Task<string> CreateBackupAsync(string filePath, CancellationToken ct = default)
{
if (!_fileSystem.FileExists(filePath))
throw new FileNotFoundException("Source file not found", filePath);
var directory = _fileSystem.GetDirectoryName(filePath);
var baseName = _fileSystem.GetFileNameWithoutExtension(filePath);
var timestamp = DateTime.Now.ToString(TimestampFormat);
var backupPath = _fileSystem.Combine(directory, $"{baseName}.{timestamp}.bak");
await _fileSystem.CopyFileAsync(filePath, backupPath, ct);
_logger?.LogInformation("Created backup at {BackupPath}", backupPath);
return backupPath;
}
public async Task<IReadOnlyList<BackupInfo>> GetBackupsAsync(string filePath, CancellationToken ct = default)
{
var directory = _fileSystem.GetDirectoryName(filePath);
var baseName = _fileSystem.GetFileNameWithoutExtension(filePath);
var pattern = $"{baseName}.*.bak";
var files = await _fileSystem.GetFilesAsync(directory, pattern, ct);
var backups = new List<BackupInfo>();
foreach (var file in files)
{
if (TryParseTimestamp(file, baseName, out var timestamp))
{
backups.Add(new BackupInfo
{
Path = file,
Timestamp = timestamp,
Size = 0 // Would need file info for actual size
});
}
}
return backups.OrderByDescending(b => b.Timestamp).ToList();
}
public async Task RestoreBackupAsync(string backupPath, string targetPath, CancellationToken ct = default)
{
_logger?.LogInformation("Restoring backup from {BackupPath} to {TargetPath}", backupPath, targetPath);
await _fileSystem.CopyFileAsync(backupPath, targetPath, ct);
}
public async Task CleanupOldBackupsAsync(string filePath, int keepCount = 10, CancellationToken ct = default)
{
var backups = await GetBackupsAsync(filePath, ct);
var toDelete = backups.Skip(keepCount).ToList();
foreach (var backup in toDelete)
{
await _fileSystem.DeleteFileAsync(backup.Path, ct);
_logger?.LogInformation("Deleted old backup {BackupPath}", backup.Path);
}
}
private bool TryParseTimestamp(string filePath, string baseName, out DateTime timestamp)
{
timestamp = default;
var fileName = _fileSystem.GetFileNameWithoutExtension(filePath);
// Expected format: baseName.yyyy-MM-dd_HHmmss
var prefix = $"{baseName}.";
if (!fileName.StartsWith(prefix))
return false;
var timestampPart = fileName[prefix.Length..];
return DateTime.TryParseExact(timestampPart, TimestampFormat,
CultureInfo.InvariantCulture, DateTimeStyles.None, out timestamp);
}
}
@@ -0,0 +1,22 @@
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Represents backup file information.
/// </summary>
public class BackupInfo
{
public required string Path { get; init; }
public required DateTime Timestamp { get; init; }
public required long Size { get; init; }
}
/// <summary>
/// Service for managing configuration file backups.
/// </summary>
public interface IBackupService
{
Task<string> CreateBackupAsync(string filePath, CancellationToken ct = default);
Task<IReadOnlyList<BackupInfo>> GetBackupsAsync(string filePath, CancellationToken ct = default);
Task RestoreBackupAsync(string backupPath, string targetPath, CancellationToken ct = default);
Task CleanupOldBackupsAsync(string filePath, int keepCount = 10, CancellationToken ct = default);
}
@@ -0,0 +1,66 @@
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.Tests.Services;
public class BackupServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly BackupService _sut;
public BackupServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_sut = new BackupService(_fileSystem);
}
[Fact]
public async Task CreateBackupAsync_CreatesTimestampedBackup()
{
// Arrange
var sourcePath = "/config/appsettings.json";
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetDirectoryName(sourcePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(sourcePath).Returns("appsettings");
_fileSystem.Combine(Arg.Any<string[]>()).Returns(callInfo =>
{
var paths = callInfo.Arg<string[]>();
return string.Join("/", paths);
});
// Act
var backupPath = await _sut.CreateBackupAsync(sourcePath);
// Assert
backupPath.ShouldStartWith("/config/appsettings.");
backupPath.ShouldEndWith(".bak");
await _fileSystem.Received(1).CopyFileAsync(sourcePath, backupPath, Arg.Any<CancellationToken>());
}
[Fact]
public async Task CleanupOldBackupsAsync_KeepsOnlySpecifiedCount()
{
// Arrange
var filePath = "/config/appsettings.json";
_fileSystem.GetDirectoryName(filePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(filePath).Returns("appsettings");
var backups = Enumerable.Range(1, 15)
.Select(i => $"/config/appsettings.2026-01-{i:D2}_120000.bak")
.ToArray();
_fileSystem.GetFilesAsync("/config", "appsettings.*.bak", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(backups));
// Mock GetFileNameWithoutExtension for each backup file
foreach (var backup in backups)
{
var fileName = backup.Split('/').Last().Replace(".bak", "");
_fileSystem.GetFileNameWithoutExtension(backup).Returns(fileName);
}
// Act
await _sut.CleanupOldBackupsAsync(filePath, keepCount: 10);
// Assert - should delete 5 oldest backups
await _fileSystem.Received(5).DeleteFileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}