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