diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index dd4276f..989dab6 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -227,6 +227,22 @@ public async Task FileRequestHandler_UploadFileRequest_NoFileProfiles_RequestIsH result.Status.ShouldBe(ResultStatus.NotFound); } + [Fact] + public async Task FileRequestHandler_UploadFileRequest_ArchivedProfileIsRejected() + { + this.FileImportLogAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(TestData.GetEmptyFileImportLogAggregate())); + this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.NotFound($"No file profile found for File Profile Id {TestData.FileProfileId}")); + + Result result = await this.FileProcessorDomainService.UploadFile(TestData.UploadFileCommand, CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + result.Status.ShouldBe(ResultStatus.NotFound); + this.FileImportLogAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()) + .Called(Count.Never()); + } + [Fact] public async Task FileRequestHandler_UploadFileRequest_FileNotFound_RequestIsHandled() { @@ -713,6 +729,43 @@ public async Task ProcessTransactionForFileLine_UsesRecordedProfileVersionAfterA result.IsSuccess.ShouldBeTrue(); } + [Fact] + public async Task ProcessTransactionForFileLine_UsesRecordedVersionWhenParentProfileIsArchived() + { + this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.NotFound()); + this.FileProcessorManager.GetFileProfileVersion(Arg.Any(), + TestData.FileProfileVersionId, + Arg.Any()) + .ReturnsAsync(TestData.FileProfileSafaricom with + { + FileProfileId = TestData.FileProfileId, + FileProfileVersionId = TestData.FileProfileVersionId + }); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(TestData.GetFileAggregateWithLines())); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success()); + this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(TestData.ClientSaleTransactionResponse); + this.TransactionProcessorClient.GetMerchant(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(TestData.GetMerchantResponseWithOperator); + this.TransactionProcessorClient.GetMerchantContracts(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(TestData.GetMerchantContractsResponse() + .Where(contract => contract.OperatorName == TestData.SafaricomOperatorIdentifier) + .ToList())); + this.SecurityServiceClient.GetToken(Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(TestData.TokenResponse())); + this.FileFormatHandler.FileLineCanBeIgnored(Arg.Any()).Returns(false); + this.FileFormatHandler.ParseFileLine(Arg.Any()).Returns(TestData.TransactionMetadata); + + Result result = await this.FileProcessorDomainService.ProcessTransactionForFileLine( + TestData.ProcessTransactionForFileLineCommand, + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + } + [Theory] [InlineData("Safaricom")] [InlineData("Voucher")] diff --git a/FileProcessor.BusinessLogic.Tests/FileProfileManagerTests.cs b/FileProcessor.BusinessLogic.Tests/FileProfileManagerTests.cs new file mode 100644 index 0000000..5d95d03 --- /dev/null +++ b/FileProcessor.BusinessLogic.Tests/FileProfileManagerTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FileProcessor.BusinessLogic.Managers; +using FileProcessor.DataTransferObjects.Requests; +using FileProcessor.FileProfileAggregate; +using FileProcessor.Testing; +using Imposter.Abstractions; +using Shared.DomainDrivenDesign.EventSourcing; +using Shared.EventStore.Aggregate; +using Shouldly; +using SimpleResults; +using Xunit; +using FileProfileAggregateRoot = FileProcessor.FileProfileAggregate.FileProfileAggregate; +using FileProfileModel = FileProcessor.Models.FileProfile; + +namespace FileProcessor.BusinessLogic.Tests; + +public class FileProfileManagerTests +{ + [Fact] + public async Task GetFileProfileVersions_WhenProfileIsArchived_ReturnsHistoricalVersions() + { + FileProfileAggregateRoot aggregate = this.CreateArchivedAggregate(); + IAggregateRepositoryImposter repository = new(); + repository.GetLatestVersion(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(aggregate)); + FileProfileManager manager = new(repository.Instance()); + + Result> result = await manager.GetFileProfileVersions(TestData.FileProfileId, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(2); + result.Data[0].FileProfileVersion.ShouldBe(1); + result.Data[1].FileProfileVersion.ShouldBe(2); + } + + [Fact] + public async Task GetFileProfileVersion_WhenVersionDoesNotExist_ReturnsActionableNotFound() + { + FileProfileAggregateRoot aggregate = this.CreateArchivedAggregate(); + IAggregateRepositoryImposter repository = new(); + repository.GetLatestVersion(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(aggregate)); + FileProfileManager manager = new(repository.Instance()); + Guid missingVersionId = Guid.NewGuid(); + + Result result = await manager.GetFileProfileVersion(TestData.FileProfileId, + missingVersionId, + CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + result.Status.ShouldBe(ResultStatus.NotFound); + result.Message.ShouldContain(TestData.FileProfileId.ToString()); + result.Message.ShouldContain(missingVersionId.ToString()); + } + + private FileProfileAggregateRoot CreateArchivedAggregate() + { + FileProfileAggregateRoot aggregate = FileProfileAggregateRoot.Create(); + aggregate.CreateProfile(new CreateFileProfileRequest + { + FileProfileId = TestData.FileProfileId, + Name = TestData.SafaricomProfileName, + ListeningDirectory = TestData.SafaricomListeningDirectory, + RequestType = TestData.SafaricomRequestType, + OperatorName = TestData.SafaricomOperatorIdentifier, + LineTerminator = LineTerminatorType.LineFeed, + FileFormatHandler = TestData.SafaricomFileFormatHandler + }); + aggregate.UpdateProfile(TestData.FileProfileId, new UpdateFileProfileRequest + { + FileFormatHandler = "UpdatedHandler" + }); + aggregate.ArchiveProfile(TestData.FileProfileId); + return aggregate; + } +} diff --git a/FileProcessor.BusinessLogic.Tests/FileProfileRequestHandlerTests.cs b/FileProcessor.BusinessLogic.Tests/FileProfileRequestHandlerTests.cs new file mode 100644 index 0000000..4b7ca44 --- /dev/null +++ b/FileProcessor.BusinessLogic.Tests/FileProfileRequestHandlerTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FileProcessor.BusinessLogic.Managers; +using FileProcessor.BusinessLogic.RequestHandlers; +using FileProcessor.BusinessLogic.Requests; +using FileProcessor.Models; +using Imposter.Abstractions; +using Shouldly; +using SimpleResults; +using Xunit; +using FileProfileModel = FileProcessor.Models.FileProfile; + +namespace FileProcessor.BusinessLogic.Tests; + +public class FileProfileRequestHandlerTests +{ + [Fact] + public async Task GetFileProfileVersionsQuery_ForwardsToManager() + { + IFileProfileManagerImposter manager = new(); + List expected = new(); + manager.GetFileProfileVersions(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(expected)); + FileProfileRequestHandler handler = new(manager.Instance()); + FileProfileQueries.GetFileProfileVersionsQuery query = new(Guid.NewGuid()); + + Result> result = await handler.Handle(query, CancellationToken.None); + + result.Data.ShouldBeSameAs(expected); + manager.GetFileProfileVersions(query.FileProfileId, CancellationToken.None).Called(Count.Once()); + } + + [Fact] + public async Task GetFileProfileVersionQuery_ForwardsToManager() + { + IFileProfileManagerImposter manager = new(); + FileProfileModel expected = new(Guid.NewGuid(), "Profile", "/tmp/profile", "Request", "Operator", "\n", "Handler"); + manager.GetFileProfileVersion(Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(expected)); + FileProfileRequestHandler handler = new(manager.Instance()); + FileProfileQueries.GetFileProfileVersionQuery query = new(Guid.NewGuid(), Guid.NewGuid()); + + Result result = await handler.Handle(query, CancellationToken.None); + + result.Data.ShouldBeSameAs(expected); + manager.GetFileProfileVersion(query.FileProfileId, + query.FileProfileVersionId, + CancellationToken.None).Called(Count.Once()); + } +} diff --git a/FileProcessor.BusinessLogic/RequestHandlers/FileProfileRequestHandler.cs b/FileProcessor.BusinessLogic/RequestHandlers/FileProfileRequestHandler.cs index 5b1cc29..03b37a1 100644 --- a/FileProcessor.BusinessLogic/RequestHandlers/FileProfileRequestHandler.cs +++ b/FileProcessor.BusinessLogic/RequestHandlers/FileProfileRequestHandler.cs @@ -14,7 +14,9 @@ public class FileProfileRequestHandler : IRequestHandler>, IRequestHandler, IRequestHandler>>, - IRequestHandler> + IRequestHandler>, + IRequestHandler>>, + IRequestHandler> { private readonly IFileProfileManager FileProfileManager; @@ -47,4 +49,18 @@ public Task> Handle(FileProfileQueries.GetFileProfileQu { return this.FileProfileManager.GetFileProfile(request.FileProfileId, cancellationToken); } + + public Task>> Handle(FileProfileQueries.GetFileProfileVersionsQuery request, + CancellationToken cancellationToken) + { + return this.FileProfileManager.GetFileProfileVersions(request.FileProfileId, cancellationToken); + } + + public Task> Handle(FileProfileQueries.GetFileProfileVersionQuery request, + CancellationToken cancellationToken) + { + return this.FileProfileManager.GetFileProfileVersion(request.FileProfileId, + request.FileProfileVersionId, + cancellationToken); + } } diff --git a/FileProcessor.BusinessLogic/Requests/FileProfileQueries.cs b/FileProcessor.BusinessLogic/Requests/FileProfileQueries.cs index 0bdcd54..54f91f4 100644 --- a/FileProcessor.BusinessLogic/Requests/FileProfileQueries.cs +++ b/FileProcessor.BusinessLogic/Requests/FileProfileQueries.cs @@ -14,4 +14,9 @@ public record FileProfileQueries public record GetFileProfilesQuery : IRequest>>; public record GetFileProfileQuery(Guid FileProfileId) : IRequest>; + + public record GetFileProfileVersionsQuery(Guid FileProfileId) : IRequest>>; + + public record GetFileProfileVersionQuery(Guid FileProfileId, + Guid FileProfileVersionId) : IRequest>; } diff --git a/FileProcessor.Client/FileProcessorClient.cs b/FileProcessor.Client/FileProcessorClient.cs index e5539e4..fb9ef84 100644 --- a/FileProcessor.Client/FileProcessorClient.cs +++ b/FileProcessor.Client/FileProcessorClient.cs @@ -77,6 +77,23 @@ public async Task> GetFileProfile(String accessToken, return await this.SendRequest(HttpMethod.Get, requestUri, accessToken, null, cancellationToken); } + public async Task>> GetFileProfileVersions(String accessToken, + Guid fileProfileId, + CancellationToken cancellationToken) + { + String requestUri = this.BuildRequestUrl($"/api/file-profiles/{fileProfileId}/versions"); + return await this.SendRequest>(HttpMethod.Get, requestUri, accessToken, null, cancellationToken); + } + + public async Task> GetFileProfileVersion(String accessToken, + Guid fileProfileId, + Guid fileProfileVersionId, + CancellationToken cancellationToken) + { + String requestUri = this.BuildRequestUrl($"/api/file-profiles/{fileProfileId}/versions/{fileProfileVersionId}"); + return await this.SendRequest(HttpMethod.Get, requestUri, accessToken, null, cancellationToken); + } + public async Task> CreateFileProfile(String accessToken, CreateFileProfileRequest request, CancellationToken cancellationToken) @@ -296,7 +313,9 @@ private async Task> SendRequest(HttpMethod method, ? $"Request failed with status code {(Int32)response.StatusCode}" : responseContent; - return Result.Failure(message); + return response.StatusCode == System.Net.HttpStatusCode.NotFound + ? Result.NotFound(message) + : Result.Failure(message); } if (String.IsNullOrWhiteSpace(responseContent)) diff --git a/FileProcessor.Client/IFileProcessorClient.cs b/FileProcessor.Client/IFileProcessorClient.cs index 861c484..caa0d19 100644 --- a/FileProcessor.Client/IFileProcessorClient.cs +++ b/FileProcessor.Client/IFileProcessorClient.cs @@ -26,6 +26,15 @@ Task> GetFileProfile(String accessToken, Guid fileProfileId, CancellationToken cancellationToken); + Task>> GetFileProfileVersions(String accessToken, + Guid fileProfileId, + CancellationToken cancellationToken); + + Task> GetFileProfileVersion(String accessToken, + Guid fileProfileId, + Guid fileProfileVersionId, + CancellationToken cancellationToken); + Task> CreateFileProfile(String accessToken, DataTransferObjects.Requests.CreateFileProfileRequest request, CancellationToken cancellationToken); diff --git a/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs b/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs index ae3367f..9f3ab31 100644 --- a/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs +++ b/FileProcessor.FileProfileAggregate.Tests/FileProfileAggregateTests.cs @@ -144,6 +144,59 @@ public void FileProfileAggregate_Update_WithNoEffectiveChanges_DoesNotChangeAppl updateResult.IsSuccess.ShouldBeTrue(); } + [Fact] + public void FileProfileAggregate_Archive_RetainsHistoricalVersionsButHidesActiveProfile() + { + 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 originalVersion = aggregate.GetProfile(TestData.FileProfileId); + aggregate.UpdateProfile(TestData.FileProfileId, new UpdateFileProfileRequest + { + FileFormatHandler = "UpdatedHandler" + }); + + Result archiveResult = aggregate.ArchiveProfile(TestData.FileProfileId); + + archiveResult.IsSuccess.ShouldBeTrue(); + aggregate.GetProfile(TestData.FileProfileId).ShouldBeNull(); + List versions = aggregate.GetProfileVersions(TestData.FileProfileId); + versions.Count.ShouldBe(2); + versions[0].FileProfileVersion.ShouldBe(1); + versions[1].FileProfileVersion.ShouldBe(2); + aggregate.GetProfileVersion(TestData.FileProfileId, originalVersion.FileProfileVersionId) + .FileFormatHandler.ShouldBe(TestData.SafaricomFileFormatHandler); + } + + [Fact] + public void FileProfileAggregate_Archive_WhenAlreadyArchived_IsIdempotent() + { + FileProfileAggregateRoot aggregate = FileProfileAggregateRoot.Create(); + aggregate.CreateProfile(this.CreateProfile( + TestData.FileProfileId, + TestData.SafaricomProfileName, + TestData.SafaricomListeningDirectory, + TestData.SafaricomRequestType, + TestData.SafaricomOperatorIdentifier, + LineTerminatorType.LineFeed, + TestData.SafaricomFileFormatHandler)); + + Result firstArchive = aggregate.ArchiveProfile(TestData.FileProfileId); + Result secondArchive = aggregate.ArchiveProfile(TestData.FileProfileId); + + firstArchive.IsSuccess.ShouldBeTrue(); + secondArchive.IsSuccess.ShouldBeTrue(); + aggregate.ActiveProfileCount.ShouldBe(0); + aggregate.GetProfileVersions(TestData.FileProfileId).Count.ShouldBe(1); + } + [Fact] public void FileProfileAggregate_Update_ListeningDirectoryUpdatesDerivedDirectories() { diff --git a/FileProcessor.IntegrationTests/ClientProfileHistoryContractTests.cs b/FileProcessor.IntegrationTests/ClientProfileHistoryContractTests.cs new file mode 100644 index 0000000..d3943fc --- /dev/null +++ b/FileProcessor.IntegrationTests/ClientProfileHistoryContractTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FileProcessor.Client; +using FileProcessor.Models; +using System.Net; +using System.Net.Http; +using System.Text; +using NUnit.Framework; +using SimpleResults; + +namespace FileProcessor.IntegrationTests; + +public class ClientProfileHistoryContractTests +{ + [Test] + public void FileProcessorClient_ExposesHistoricalProfileQueries() + { + IFileProcessorClient client = null; + Func>>> getVersions = () => client.GetFileProfileVersions("token", + Guid.NewGuid(), + CancellationToken.None); + Func>> getVersion = () => client.GetFileProfileVersion("token", + Guid.NewGuid(), + Guid.NewGuid(), + CancellationToken.None); + + Assert.That((object)getVersions, Is.Not.Null); + Assert.That((object)getVersion, Is.Not.Null); + } + + [Test] + public async Task FileProcessorClient_GetFileProfileVersion_PreservesNotFoundStatus() + { + using HttpClient httpClient = new(new NotFoundHandler()); + FileProcessorClient client = new(_ => "https://fileprocessor", + httpClient, + _ => String.Empty, + (_, _) => null); + + Result result = await client.GetFileProfileVersion("token", + Guid.NewGuid(), + Guid.NewGuid(), + CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(result.Message, Does.Contain("historical version missing")); + } + + private sealed class NotFoundHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("historical version missing", Encoding.UTF8, "text/plain") + }); + } + } +} diff --git a/FileProcessor/Endpoints/FileProfileEndpoints.cs b/FileProcessor/Endpoints/FileProfileEndpoints.cs index b314f1d..3897295 100644 --- a/FileProcessor/Endpoints/FileProfileEndpoints.cs +++ b/FileProcessor/Endpoints/FileProfileEndpoints.cs @@ -22,6 +22,12 @@ public static void MapFileProfileEndpoints(this IEndpointRouteBuilder app) group.MapGet("/{fileProfileId:guid}", FileProfileHandlers.GetFileProfile) .WithName("GetFileProfile"); + group.MapGet("/{fileProfileId:guid}/versions", FileProfileHandlers.GetFileProfileVersions) + .WithName("GetFileProfileVersions"); + + group.MapGet("/{fileProfileId:guid}/versions/{fileProfileVersionId:guid}", FileProfileHandlers.GetFileProfileVersion) + .WithName("GetFileProfileVersion"); + group.MapPost("/", FileProfileHandlers.CreateFileProfile) .WithName("CreateFileProfile"); diff --git a/FileProcessor/Handlers/FileProfileHandlers.cs b/FileProcessor/Handlers/FileProfileHandlers.cs index a6b72c9..7ad9d5e 100644 --- a/FileProcessor/Handlers/FileProfileHandlers.cs +++ b/FileProcessor/Handlers/FileProfileHandlers.cs @@ -33,6 +33,27 @@ public static async Task GetFileProfile(IMediator mediator, return ResponseFactory.FromResult(result, x => x); } + public static async Task GetFileProfileVersions(IMediator mediator, + [FromRoute] Guid fileProfileId, + CancellationToken cancellationToken) + { + FileProfileQueries.GetFileProfileVersionsQuery query = new(fileProfileId); + Result> result = await mediator.Send(query, cancellationToken); + + return ResponseFactory.FromResult(result, x => x); + } + + public static async Task GetFileProfileVersion(IMediator mediator, + [FromRoute] Guid fileProfileId, + [FromRoute] Guid fileProfileVersionId, + CancellationToken cancellationToken) + { + FileProfileQueries.GetFileProfileVersionQuery query = new(fileProfileId, fileProfileVersionId); + Result result = await mediator.Send(query, cancellationToken); + + return ResponseFactory.FromResult(result, x => x); + } + public static async Task CreateFileProfile(IMediator mediator, [FromBody] CreateFileProfileRequest request, CancellationToken cancellationToken) diff --git a/docs/superpowers/specs/2026-10-16-historical-profile-lookup-archive-behavior-design.md b/docs/superpowers/specs/2026-10-16-historical-profile-lookup-archive-behavior-design.md new file mode 100644 index 0000000..aec3a69 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-historical-profile-lookup-archive-behavior-design.md @@ -0,0 +1,67 @@ +## Context + +Issue #842 completes the profile-version behavior needed by existing files. A profile can be archived after files have recorded a profile version. New uploads must stop using the archived profile, while historical versions must remain readable for processing, audit, and troubleshooting. + +The current aggregate already stores archive state separately from version state. `GetProfile` and `GetAllProfiles` exclude archived profiles, while `GetProfileVersion` and `GetProfileVersions` can read versions from an archived profile. The business manager already has methods for both historical lookups. The remaining work is to expose those queries consistently and lock the behavior down with tests. + +## Goals + +- Keep all historical versions readable after the parent profile is archived. +- Reject new uploads that request an archived profile. +- Allow existing files to continue processing through their recorded profile version. +- Return explicit not-found failures for unknown profiles or versions. +- Make repeated archival requests successful without appending duplicate archive events. +- Expose profile history through the HTTP API and client surface. + +## Non-goals + +- No new persistence projection or database schema changes; those belong to the separate projection work. +- No unarchive operation. +- No change to the immutable version model introduced by issues #839 and #840. +- No fallback from a missing recorded version to the current profile. + +## Design + +### Aggregate behavior + +Keep `IsArchived` on the profile state and leave its `Versions` collection intact. `ArchiveProfile` returns success when the profile is already archived and only appends `FileProfileArchivedEvent` on the first archival. Active profile reads continue to hide archived profiles. Historical version reads continue to search the retained version collection regardless of archive state. + +Add or strengthen aggregate tests covering: + +- Archive hides the active profile but retains every historical version. +- A second archive call succeeds and does not produce another archive event. +- A missing profile or missing version returns an explicit not-found result at the manager boundary. + +### Query/API surface + +Add query records, request-handler methods, endpoint mappings, and client methods for: + +- `GET /api/file-profiles/{fileProfileId}/versions` +- `GET /api/file-profiles/{fileProfileId}/versions/{fileProfileVersionId}` + +The list endpoint returns all versions ordered by version number, including when the parent profile is archived. The detail endpoint returns the requested historical version and reports a not-found result when the profile or version does not exist. Existing active-profile endpoints retain their current archived-profile behavior. + +### Upload and processing behavior + +Do not add a second archive check in the upload pipeline. Upload validation must continue to resolve the active profile through `GetFileProfile`; an archived profile therefore fails before a new file is accepted. + +Processing must continue to resolve `FileProfileVersionId` through the exact historical lookup path already used by the recorded-version work. The parent profile’s archive flag must not prevent that lookup. If the recorded version is unavailable, return the existing actionable error containing the file, profile, and version identifiers. + +Add business-logic tests for both sides of the boundary: + +- New upload against an archived profile is rejected and does not create an import-log entry. +- Existing file processing succeeds after profile archival when its recorded version remains available. + +## Error handling + +- Unknown profile on history lookup: `NotFound` identifying the profile ID. +- Unknown version on history lookup: `NotFound` identifying both profile and version IDs. +- Missing recorded version during processing: preserve the existing explicit diagnostic; never silently use the current active profile. +- Archive of an unknown profile: preserve the existing `NotFound` result. + +## Verification + +- Run focused aggregate, manager, handler/client, and business-logic tests while iterating. +- Run the complete solution build and test suite before completion. +- Run `git diff --check`. +- Report Docker-backed integration tests separately if the local Docker engine is unavailable.