feat(configmanager): add BackupService with tests
This commit is contained in:
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user