From f1cc9c0b5f8183b3f7cbb705488a4d02ee7fa7f4 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 16 Oct 2026 16:55:47 +0100 Subject: [PATCH 1/2] Implement immutable file profile versions --- FIleProcessor.Models/FileProfile.cs | 18 +- .../Managers/FileProfileManager.cs | 28 +++ .../Managers/IFileProfileManager.cs | 4 + .../FileProfileDomainEvents.cs | 15 +- .../FileProfileAggregateTests.cs | 60 +++++++ .../FileProfileAggregate.cs | 160 +++++++++++++++--- 6 files changed, 258 insertions(+), 27 deletions(-) diff --git a/FIleProcessor.Models/FileProfile.cs b/FIleProcessor.Models/FileProfile.cs index f75194e..2559782 100644 --- a/FIleProcessor.Models/FileProfile.cs +++ b/FIleProcessor.Models/FileProfile.cs @@ -16,6 +16,10 @@ public record FileProfile /// public Guid FileProfileId { get; init; } + public Guid FileProfileVersionId { get; init; } + + public Int32 FileProfileVersion { get; init; } + /// /// Gets or sets the name. /// @@ -85,9 +89,19 @@ public record FileProfile /// Type of the request. /// Name of the operator. /// The line terminator. - public FileProfile(Guid fileProfileId, String name, String listeningDirectory, String requestType, String operatorName, String lineTerminator, String fileFormatHandler) + public FileProfile(Guid fileProfileId, + String name, + String listeningDirectory, + String requestType, + String operatorName, + String lineTerminator, + String fileFormatHandler, + Guid fileProfileVersionId = default, + Int32 fileProfileVersion = 0) { this.FileProfileId = fileProfileId; + this.FileProfileVersionId = fileProfileVersionId; + this.FileProfileVersion = fileProfileVersion; this.Name = name; this.ListeningDirectory = listeningDirectory; this.ProcessedDirectory = $"{listeningDirectory}/processed"; @@ -98,4 +112,4 @@ public FileProfile(Guid fileProfileId, String name, String listeningDirectory, S this.FileFormatHandler = fileFormatHandler; } } -} \ No newline at end of file +} diff --git a/FileProcessor.BusinessLogic/Managers/FileProfileManager.cs b/FileProcessor.BusinessLogic/Managers/FileProfileManager.cs index 9ffc7cf..518f50f 100644 --- a/FileProcessor.BusinessLogic/Managers/FileProfileManager.cs +++ b/FileProcessor.BusinessLogic/Managers/FileProfileManager.cs @@ -52,6 +52,34 @@ public async Task> GetFileProfile(Guid fileProfileId, C return Result.Success(fileProfile); } + public async Task>> GetFileProfileVersions(Guid fileProfileId, CancellationToken cancellationToken) + { + Result aggregateResult = await this.LoadAggregate(cancellationToken); + if (aggregateResult.IsFailed) + return ResultHelpers.CreateFailure(aggregateResult); + + List versions = aggregateResult.Data.GetProfileVersions(fileProfileId); + if (versions.Count == 0) + return Result.NotFound($"No file profile found for File Profile Id {fileProfileId}"); + + return Result.Success(versions); + } + + public async Task> GetFileProfileVersion(Guid fileProfileId, + Guid fileProfileVersionId, + CancellationToken cancellationToken) + { + Result aggregateResult = await this.LoadAggregate(cancellationToken); + if (aggregateResult.IsFailed) + return ResultHelpers.CreateFailure(aggregateResult); + + FileProfileModel version = aggregateResult.Data.GetProfileVersion(fileProfileId, fileProfileVersionId); + if (version == null) + return Result.NotFound($"No file profile version found for File Profile Id {fileProfileId} and Version Id {fileProfileVersionId}"); + + return Result.Success(version); + } + public async Task> CreateFileProfile(CreateFileProfileRequest request, CancellationToken cancellationToken) { Result aggregateResult = await this.LoadAggregate(cancellationToken); diff --git a/FileProcessor.BusinessLogic/Managers/IFileProfileManager.cs b/FileProcessor.BusinessLogic/Managers/IFileProfileManager.cs index a5ff4ce..55e1c17 100644 --- a/FileProcessor.BusinessLogic/Managers/IFileProfileManager.cs +++ b/FileProcessor.BusinessLogic/Managers/IFileProfileManager.cs @@ -14,6 +14,10 @@ public interface IFileProfileManager Task> GetFileProfile(Guid fileProfileId, CancellationToken cancellationToken); + Task>> GetFileProfileVersions(Guid fileProfileId, CancellationToken cancellationToken); + + Task> GetFileProfileVersion(Guid fileProfileId, Guid fileProfileVersionId, CancellationToken cancellationToken); + Task> CreateFileProfile(CreateFileProfileRequest request, CancellationToken cancellationToken); Task> UpdateFileProfile(Guid fileProfileId, UpdateFileProfileRequest request, CancellationToken cancellationToken); diff --git a/FileProcessor.FileProfile.DomainEvents/FileProfileDomainEvents.cs b/FileProcessor.FileProfile.DomainEvents/FileProfileDomainEvents.cs index 917e6c7..b7da151 100644 --- a/FileProcessor.FileProfile.DomainEvents/FileProfileDomainEvents.cs +++ b/FileProcessor.FileProfile.DomainEvents/FileProfileDomainEvents.cs @@ -10,7 +10,20 @@ public record FileProfileCreatedEvent(Guid FileProfileCollectionId, String RequestType, String OperatorName, String LineTerminator, - String FileFormatHandler) : DomainEvent(FileProfileCollectionId, Guid.NewGuid()); + String FileFormatHandler, + Guid FileProfileVersionId = default, + Int32 FileProfileVersion = 1) : DomainEvent(FileProfileCollectionId, Guid.NewGuid()); + +public record FileProfileVersionCreatedEvent(Guid FileProfileCollectionId, + Guid FileProfileId, + Guid FileProfileVersionId, + Int32 FileProfileVersion, + String Name, + String ListeningDirectory, + String RequestType, + String OperatorName, + String LineTerminator, + String FileFormatHandler) : DomainEvent(FileProfileCollectionId, Guid.NewGuid()); public record FileProfileNameUpdatedEvent(Guid FileProfileCollectionId, Guid FileProfileId, String Name) : DomainEvent(FileProfileCollectionId, Guid.NewGuid()); diff --git a/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs b/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs index 49b8c14..64652e1 100644 --- a/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs +++ b/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs @@ -171,6 +171,66 @@ public void FileProfileAggregate_Update_ListeningDirectoryUpdatesDerivedDirector updatedProfile.FailedDirectory.ShouldBe("/home/txnproc/bulkfiles/new-safaricom/failed"); } + [Fact] + public void FileProfileAggregate_Create_CreatesInitialImmutableVersion() + { + FileProfileAggregateRoot aggregate = FileProfileAggregateRoot.Create(); + + Result createResult = aggregate.CreateProfile(this.CreateProfile( + TestData.FileProfileId, + TestData.SafaricomProfileName, + TestData.SafaricomListeningDirectory, + TestData.SafaricomRequestType, + TestData.SafaricomOperatorIdentifier, + LineTerminatorType.LineFeed, + TestData.SafaricomFileFormatHandler)); + + FileProcessor.Models.FileProfile profile = aggregate.GetProfile(TestData.FileProfileId); + List versions = aggregate.GetProfileVersions(TestData.FileProfileId); + + createResult.IsSuccess.ShouldBeTrue(); + profile.FileProfileVersionId.ShouldNotBe(Guid.Empty); + profile.FileProfileVersion.ShouldBe(1); + versions.Count.ShouldBe(1); + versions[0].FileProfileVersionId.ShouldBe(profile.FileProfileVersionId); + } + + [Fact] + public void FileProfileAggregate_Update_CreatesNewVersionAndPreservesPreviousVersion() + { + FileProfileAggregateRoot aggregate = FileProfileAggregateRoot.Create(); + aggregate.CreateProfile(this.CreateProfile( + TestData.FileProfileId, + TestData.SafaricomProfileName, + TestData.SafaricomListeningDirectory, + TestData.SafaricomRequestType, + TestData.SafaricomOperatorIdentifier, + LineTerminatorType.LineFeed, + TestData.SafaricomFileFormatHandler)); + + FileProcessor.Models.FileProfile originalProfile = aggregate.GetProfile(TestData.FileProfileId); + + Result updateResult = aggregate.UpdateProfile(TestData.FileProfileId, new UpdateFileProfileRequest + { + FileFormatHandler = "UpdatedHandler" + }); + + FileProcessor.Models.FileProfile updatedProfile = aggregate.GetProfile(TestData.FileProfileId); + FileProcessor.Models.FileProfile historicalProfile = aggregate.GetProfileVersion( + TestData.FileProfileId, + originalProfile.FileProfileVersionId); + List versions = aggregate.GetProfileVersions(TestData.FileProfileId); + + updateResult.IsSuccess.ShouldBeTrue(); + updatedProfile.FileProfileVersionId.ShouldNotBe(originalProfile.FileProfileVersionId); + updatedProfile.FileProfileVersion.ShouldBe(2); + updatedProfile.FileFormatHandler.ShouldBe("UpdatedHandler"); + historicalProfile.FileProfileVersionId.ShouldBe(originalProfile.FileProfileVersionId); + historicalProfile.FileProfileVersion.ShouldBe(1); + historicalProfile.FileFormatHandler.ShouldBe(TestData.SafaricomFileFormatHandler); + versions.Count.ShouldBe(2); + } + [Fact] public void FileProfileAggregate_Create_WithDuplicateName_IsRejected() { diff --git a/FileProcessor.FileProfileAggregate/FileProfileAggregate.cs b/FileProcessor.FileProfileAggregate/FileProfileAggregate.cs index 4605978..a15254e 100644 --- a/FileProcessor.FileProfileAggregate/FileProfileAggregate.cs +++ b/FileProcessor.FileProfileAggregate/FileProfileAggregate.cs @@ -30,10 +30,52 @@ public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileCre OperatorName = domainEvent.OperatorName, LineTerminator = domainEvent.LineTerminator, FileFormatHandler = domainEvent.FileFormatHandler, - IsArchived = false + IsArchived = false, + CurrentVersionId = domainEvent.FileProfileVersionId == Guid.Empty ? domainEvent.FileProfileId : domainEvent.FileProfileVersionId, + CurrentVersion = domainEvent.FileProfileVersion, + Versions = new List + { + new() + { + FileProfileVersionId = domainEvent.FileProfileVersionId == Guid.Empty ? domainEvent.FileProfileId : domainEvent.FileProfileVersionId, + FileProfileVersion = domainEvent.FileProfileVersion, + Name = domainEvent.Name, + ListeningDirectory = domainEvent.ListeningDirectory, + RequestType = domainEvent.RequestType, + OperatorName = domainEvent.OperatorName, + LineTerminator = domainEvent.LineTerminator, + FileFormatHandler = domainEvent.FileFormatHandler + } + } }; } + public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileVersionCreatedEvent domainEvent) + { + FileProfileState fileProfile = aggregate.GetRequiredProfileState(domainEvent.FileProfileId); + FileProfileVersionState version = new() + { + FileProfileVersionId = domainEvent.FileProfileVersionId, + FileProfileVersion = domainEvent.FileProfileVersion, + Name = domainEvent.Name, + ListeningDirectory = domainEvent.ListeningDirectory, + RequestType = domainEvent.RequestType, + OperatorName = domainEvent.OperatorName, + LineTerminator = domainEvent.LineTerminator, + FileFormatHandler = domainEvent.FileFormatHandler + }; + + fileProfile.Versions.Add(version); + fileProfile.CurrentVersionId = version.FileProfileVersionId; + fileProfile.CurrentVersion = version.FileProfileVersion; + fileProfile.Name = version.Name; + fileProfile.ListeningDirectory = version.ListeningDirectory; + fileProfile.RequestType = version.RequestType; + fileProfile.OperatorName = version.OperatorName; + fileProfile.LineTerminator = version.LineTerminator; + fileProfile.FileFormatHandler = version.FileFormatHandler; + } + public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileNameUpdatedEvent domainEvent) { FileProfileState fileProfile = aggregate.GetRequiredProfileState(domainEvent.FileProfileId); @@ -105,7 +147,9 @@ public static Result CreateProfile(this FileProfileAggregate aggregate, CreateFi request.RequestType.Trim(), request.OperatorName.Trim(), lineTerminatorResult.Data, - request.FileFormatHandler.Trim()); + request.FileFormatHandler.Trim(), + Guid.NewGuid(), + 1); aggregate.ApplyAndAppend(fileProfileCreatedEvent); return Result.Success(); @@ -124,37 +168,44 @@ public static Result UpdateProfile(this FileProfileAggregate aggregate, Guid fil return validationResult; } - if (IsDomainEventRequired(profile.Name, request.Name)) { - aggregate.ApplyAndAppend(new FileProfileNameUpdatedEvent(aggregate.AggregateId, fileProfileId, request.Name)); - } - - if (IsDomainEventRequired(profile.ListeningDirectory, request.ListeningDirectory)) { - aggregate.ApplyAndAppend(new FileProfileListeningDirectoryUpdatedEvent(aggregate.AggregateId, fileProfileId, request.ListeningDirectory)); - } - - if (IsDomainEventRequired(profile.RequestType, request.RequestType)) { - aggregate.ApplyAndAppend(new FileProfileRequestTypeUpdatedEvent(aggregate.AggregateId, fileProfileId, request.RequestType)); - } - - if (IsDomainEventRequired(profile.OperatorName, request.OperatorName)) { - aggregate.ApplyAndAppend(new FileProfileOperatorNameUpdatedEvent(aggregate.AggregateId, fileProfileId, request.OperatorName)); - } - + String lineTerminator = profile.LineTerminator; if (request.LineTerminator.HasValue) { Result lineTerminatorResult = request.LineTerminator.Value.ToLineTerminator(); if (lineTerminatorResult.IsFailed) { return ResultHelpers.CreateFailure(lineTerminatorResult); } - if (IsDomainEventRequired(profile.LineTerminator, lineTerminatorResult.Data)) { - aggregate.ApplyAndAppend(new FileProfileLineTerminatorUpdatedEvent(aggregate.AggregateId, fileProfileId, lineTerminatorResult.Data)); - } + lineTerminator = lineTerminatorResult.Data; } - if (IsDomainEventRequired(profile.FileFormatHandler, request.FileFormatHandler)) { - aggregate.ApplyAndAppend(new FileProfileFileFormatHandlerUpdatedEvent(aggregate.AggregateId, fileProfileId, request.FileFormatHandler)); + String name = request.Name == null ? profile.Name : request.Name.Trim(); + String listeningDirectory = request.ListeningDirectory == null ? profile.ListeningDirectory : request.ListeningDirectory.Trim(); + String requestType = request.RequestType == null ? profile.RequestType : request.RequestType.Trim(); + String operatorName = request.OperatorName == null ? profile.OperatorName : request.OperatorName.Trim(); + String fileFormatHandler = request.FileFormatHandler == null ? profile.FileFormatHandler : request.FileFormatHandler.Trim(); + + if (IsDomainEventRequired(profile.Name, name) == false && + IsDomainEventRequired(profile.ListeningDirectory, listeningDirectory) == false && + IsDomainEventRequired(profile.RequestType, requestType) == false && + IsDomainEventRequired(profile.OperatorName, operatorName) == false && + IsDomainEventRequired(profile.LineTerminator, lineTerminator) == false && + IsDomainEventRequired(profile.FileFormatHandler, fileFormatHandler) == false) + { + return Result.Success(); } + Int32 nextVersion = profile.Versions.Count == 0 ? 1 : profile.Versions.Max(version => version.FileProfileVersion) + 1; + aggregate.ApplyAndAppend(new FileProfileVersionCreatedEvent(aggregate.AggregateId, + fileProfileId, + Guid.NewGuid(), + nextVersion, + name, + listeningDirectory, + requestType, + operatorName, + lineTerminator, + fileFormatHandler)); + return Result.Success(); } @@ -232,6 +283,27 @@ public static FileProfileModel GetProfile(this FileProfileAggregate aggregate, G return profile.ToModel(); } + public static List GetProfileVersions(this FileProfileAggregate aggregate, Guid fileProfileId) + { + if (aggregate.FileProfiles.TryGetValue(fileProfileId, out FileProfileState profile) == false) + return new List(); + + return profile.Versions.OrderBy(version => version.FileProfileVersion) + .Select(version => version.ToModel(fileProfileId)) + .ToList(); + } + + public static FileProfileModel GetProfileVersion(this FileProfileAggregate aggregate, + Guid fileProfileId, + Guid fileProfileVersionId) + { + if (aggregate.FileProfiles.TryGetValue(fileProfileId, out FileProfileState profile) == false) + return null; + + FileProfileVersionState version = profile.Versions.SingleOrDefault(candidate => candidate.FileProfileVersionId == fileProfileVersionId); + return version?.ToModel(fileProfileId); + } + private static Result ValidateCreateRequest(this FileProfileAggregate aggregate, CreateFileProfileRequest request) { if (request == null) @@ -439,7 +511,22 @@ private static FileProfileModel ToModel(this FileProfileState fileProfile) fileProfile.RequestType, fileProfile.OperatorName, fileProfile.LineTerminator, - fileProfile.FileFormatHandler); + fileProfile.FileFormatHandler, + fileProfile.CurrentVersionId, + fileProfile.CurrentVersion); + } + + private static FileProfileModel ToModel(this FileProfileVersionState version, Guid fileProfileId) + { + return new FileProfileModel(fileProfileId, + version.Name, + version.ListeningDirectory, + version.RequestType, + version.OperatorName, + version.LineTerminator, + version.FileFormatHandler, + version.FileProfileVersionId, + version.FileProfileVersion); } } @@ -509,4 +596,29 @@ internal sealed class FileProfileState public string FileFormatHandler { get; set; } public bool IsArchived { get; set; } + + public Guid CurrentVersionId { get; set; } + + public int CurrentVersion { get; set; } + + public List Versions { get; set; } = new(); +} + +internal sealed class FileProfileVersionState +{ + public Guid FileProfileVersionId { get; set; } + + public int FileProfileVersion { get; set; } + + public string Name { get; set; } + + public string ListeningDirectory { get; set; } + + public string RequestType { get; set; } + + public string OperatorName { get; set; } + + public string LineTerminator { get; set; } + + public string FileFormatHandler { get; set; } } From cdeaf10d0a4c39752dcdccea028d7a35fdf1aa12 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 16 Oct 2026 16:58:08 +0100 Subject: [PATCH 2/2] Preserve legacy profile version snapshots --- .../FileProfileAggregateTests.cs | 29 +++++++++++++++++++ .../FileProfileAggregate.cs | 17 +++++++++++ 2 files changed, 46 insertions(+) diff --git a/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs b/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs index 64652e1..ae3367f 100644 --- a/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs +++ b/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs @@ -231,6 +231,35 @@ public void FileProfileAggregate_Update_CreatesNewVersionAndPreservesPreviousVer versions.Count.ShouldBe(2); } + [Fact] + public void FileProfileAggregate_ReplayingLegacyUpdateEvents_UpdatesInitialVersionSnapshot() + { + FileProfileAggregateRoot aggregate = FileProfileAggregateRoot.Create(); + Guid versionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + aggregate.PlayEvent(new FileProcessor.FileProfile.DomainEvents.FileProfileCreatedEvent( + FileProfileAggregateRoot.FileProfileCollectionId, + TestData.FileProfileId, + TestData.SafaricomProfileName, + TestData.SafaricomListeningDirectory, + TestData.SafaricomRequestType, + TestData.SafaricomOperatorIdentifier, + "\n", + TestData.SafaricomFileFormatHandler, + versionId, + 1)); + aggregate.PlayEvent(new FileProcessor.FileProfile.DomainEvents.FileProfileFileFormatHandlerUpdatedEvent( + FileProfileAggregateRoot.FileProfileCollectionId, + TestData.FileProfileId, + "LegacyUpdatedHandler")); + + FileProcessor.Models.FileProfile profile = aggregate.GetProfile(TestData.FileProfileId); + FileProcessor.Models.FileProfile version = aggregate.GetProfileVersion(TestData.FileProfileId, versionId); + + profile.FileFormatHandler.ShouldBe("LegacyUpdatedHandler"); + version.FileFormatHandler.ShouldBe("LegacyUpdatedHandler"); + } + [Fact] public void FileProfileAggregate_Create_WithDuplicateName_IsRejected() { diff --git a/FileProcessor.FileProfileAggregate/FileProfileAggregate.cs b/FileProcessor.FileProfileAggregate/FileProfileAggregate.cs index a15254e..8c3d0ad 100644 --- a/FileProcessor.FileProfileAggregate/FileProfileAggregate.cs +++ b/FileProcessor.FileProfileAggregate/FileProfileAggregate.cs @@ -80,36 +80,42 @@ public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileNam { FileProfileState fileProfile = aggregate.GetRequiredProfileState(domainEvent.FileProfileId); fileProfile.Name = domainEvent.Name; + fileProfile.UpdateCurrentVersionSnapshot(); } public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileListeningDirectoryUpdatedEvent domainEvent) { FileProfileState fileProfile = aggregate.GetRequiredProfileState(domainEvent.FileProfileId); fileProfile.ListeningDirectory = domainEvent.ListeningDirectory; + fileProfile.UpdateCurrentVersionSnapshot(); } public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileRequestTypeUpdatedEvent domainEvent) { FileProfileState fileProfile = aggregate.GetRequiredProfileState(domainEvent.FileProfileId); fileProfile.RequestType = domainEvent.RequestType; + fileProfile.UpdateCurrentVersionSnapshot(); } public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileOperatorNameUpdatedEvent domainEvent) { FileProfileState fileProfile = aggregate.GetRequiredProfileState(domainEvent.FileProfileId); fileProfile.OperatorName = domainEvent.OperatorName; + fileProfile.UpdateCurrentVersionSnapshot(); } public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileLineTerminatorUpdatedEvent domainEvent) { FileProfileState fileProfile = aggregate.GetRequiredProfileState(domainEvent.FileProfileId); fileProfile.LineTerminator = domainEvent.LineTerminator; + fileProfile.UpdateCurrentVersionSnapshot(); } public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileFileFormatHandlerUpdatedEvent domainEvent) { FileProfileState fileProfile = aggregate.GetRequiredProfileState(domainEvent.FileProfileId); fileProfile.FileFormatHandler = domainEvent.FileFormatHandler; + fileProfile.UpdateCurrentVersionSnapshot(); } public static void PlayEvent(this FileProfileAggregate aggregate, FileProfileArchivedEvent domainEvent) @@ -528,6 +534,17 @@ private static FileProfileModel ToModel(this FileProfileVersionState version, Gu version.FileProfileVersionId, version.FileProfileVersion); } + + private static void UpdateCurrentVersionSnapshot(this FileProfileState fileProfile) + { + FileProfileVersionState currentVersion = fileProfile.Versions.Single(version => version.FileProfileVersionId == fileProfile.CurrentVersionId); + currentVersion.Name = fileProfile.Name; + currentVersion.ListeningDirectory = fileProfile.ListeningDirectory; + currentVersion.RequestType = fileProfile.RequestType; + currentVersion.OperatorName = fileProfile.OperatorName; + currentVersion.LineTerminator = fileProfile.LineTerminator; + currentVersion.FileFormatHandler = fileProfile.FileFormatHandler; + } } public static class LineTerminatorTypeExtensions