From 0e441898a66d0df7cd3cd7cbe799fe1c45622c98 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Jan 2026 17:40:22 -0500 Subject: [PATCH] feat(configmanager): add BackupService with tests --- .../Services/BackupService.cs | 94 +++++++++++++++++++ .../Services/IBackupService.cs | 22 +++++ .../Services/BackupServiceTests.cs | 66 +++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Services/BackupService.cs create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Services/IBackupService.cs create mode 100644 NEW/tests/JdeScoping.ConfigManager.Tests/Services/BackupServiceTests.cs diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/BackupService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/BackupService.cs new file mode 100644 index 0000000..264431e --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/BackupService.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace JdeScoping.ConfigManager.Services; + +/// +/// Service for managing configuration file backups. +/// +public class BackupService : IBackupService +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger? _logger; + private const string TimestampFormat = "yyyy-MM-dd_HHmmss"; + + public BackupService(IFileSystem fileSystem, ILogger? logger = null) + { + _fileSystem = fileSystem; + _logger = logger; + } + + public async Task 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> 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(); + + 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); + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IBackupService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IBackupService.cs new file mode 100644 index 0000000..f93cb82 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IBackupService.cs @@ -0,0 +1,22 @@ +namespace JdeScoping.ConfigManager.Services; + +/// +/// Represents backup file information. +/// +public class BackupInfo +{ + public required string Path { get; init; } + public required DateTime Timestamp { get; init; } + public required long Size { get; init; } +} + +/// +/// Service for managing configuration file backups. +/// +public interface IBackupService +{ + Task CreateBackupAsync(string filePath, CancellationToken ct = default); + Task> 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); +} diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/Services/BackupServiceTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/BackupServiceTests.cs new file mode 100644 index 0000000..cd76e50 --- /dev/null +++ b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/BackupServiceTests.cs @@ -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(); + _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()).Returns(callInfo => + { + var paths = callInfo.Arg(); + 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()); + } + + [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()) + .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(), Arg.Any()); + } +}