From 93c3736f8e1eee5e588cd44c486b0fc96de91b1b Mon Sep 17 00:00:00 2001 From: FrostyApeOne Date: Mon, 6 Jul 2026 16:10:40 +0100 Subject: [PATCH 1/3] Fixed bugs and added a new request model and validations --- .../Controllers/TemplatesController.cs | 3 +- ...teCustomApplicationStatusCommandHandler.cs | 44 ++++----- ...CustomApplicationStatusCommandValidator.cs | 21 +++++ ...onStatusByApplicationStatusQueryHandler.cs | 9 +- ...mplateIdAndApplicationStatusQueryObject.cs | 19 ++++ .../DfE.ExternalApplications.Domain.csproj | 2 +- .../Generated/Client.g.cs | 12 ++- .../Generated/Contracts.g.cs | 2 +- .../Generated/swagger.json | 27 +++++- ...Dfe.ExternalApplications.Api.Client.csproj | 2 +- .../Controllers/TemplatesControllerTests.cs | 56 ++++++++--- ...tomApplicationStatusCommandHandlerTests.cs | 37 ++------ ...mApplicationStatusCommandValidatorTests.cs | 66 +++++++++++++ ...eIdAndApplicationStatusQueryObjectTests.cs | 93 +++++++++++++++++++ 14 files changed, 314 insertions(+), 79 deletions(-) create mode 100644 src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs create mode 100644 src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject.cs create mode 100644 src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs create mode 100644 src/Tests/DfE.ExternalApplications.Application.Tests/QueryObjects/Templates/GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObjectTests.cs diff --git a/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs b/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs index 0300569c..edc75be5 100644 --- a/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs +++ b/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs @@ -63,9 +63,10 @@ public async Task GetCustomApplicationStatusesAsync([FromRoute] G /// [HttpPost("{templateId}/custom-statuses")] [SwaggerResponse(201, "Custom status created/updated.", typeof(CustomApplicationStatusDto))] + [SwaggerResponse(400, "Invalid request data.", typeof(ExceptionResponse))] [Authorize(Policy = "CanWriteTemplate")] [Authorize(Roles = "Admin")] - public async Task CreateCustomApplicationStatusAsync([FromRoute] Guid templateId, [FromBody] CustomApplicationStatusDto request, CancellationToken cancellationToken) + public async Task CreateCustomApplicationStatusAsync([FromRoute] Guid templateId, [FromBody] CustomApplicationStatusRequest request, CancellationToken cancellationToken) { var command = new UpdateCustomApplicationStatusCommand(templateId, request.ApplicationStatus, request.Label); var result = await sender.Send(command, cancellationToken); diff --git a/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandHandler.cs b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandHandler.cs index e408b06a..c0096f18 100644 --- a/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandHandler.cs +++ b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandHandler.cs @@ -1,3 +1,4 @@ +using DfE.ExternalApplications.Application.Templates.QueryObjects; using DfE.ExternalApplications.Domain.Entities; using DfE.ExternalApplications.Domain.Interfaces; using DfE.ExternalApplications.Domain.Interfaces.Repositories; @@ -62,26 +63,18 @@ public async Task> Handle(UpdateCustomApplica var createdByUserId = dbUser.Id; // Check if custom status exists for this template and application status - var existing = await customApplicationStatusRepo.Query() - .FirstOrDefaultAsync(x => x.TemplateId == new TemplateId(request.TemplateId) && x.ApplicationStatus == request.ApplicationStatus, cancellationToken); + var existing = await new GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject( + request.TemplateId, + request.ApplicationStatus) + .Apply(customApplicationStatusRepo.Query()) + .FirstOrDefaultAsync(cancellationToken); if (existing is not null) { - // Update existing existing.UpdateLabel(request.Label); await unitOfWork.CommitAsync(cancellationToken); - var dto = new CustomApplicationStatusDto - { - CustomApplicationStatusId = existing.Id!.Value, - TemplateId = existing.TemplateId.Value, - ApplicationStatus = existing.ApplicationStatus, - Label = existing.Label, - CreatedOn = DateTime.UtcNow, - CreatedBy = createdByUserId.Value - }; - - return Result.Success(dto); + return Result.Success(MapToDto(existing)); } var entity = new CustomApplicationStatus( @@ -95,22 +88,23 @@ public async Task> Handle(UpdateCustomApplica await customApplicationStatusRepo.AddAsync(entity, cancellationToken); await unitOfWork.CommitAsync(cancellationToken); - var createdDto = new CustomApplicationStatusDto - { - CustomApplicationStatusId = entity.Id!.Value, - TemplateId = entity.TemplateId.Value, - ApplicationStatus = entity.ApplicationStatus, - Label = entity.Label, - CreatedOn = entity.CreatedOn, - CreatedBy = entity.CreatedBy.Value - }; - - return Result.Success(createdDto); + return Result.Success(MapToDto(entity)); } catch (Exception e) { return Result.Failure(e.ToString()); } } + + private static CustomApplicationStatusDto MapToDto(CustomApplicationStatus entity) => + new() + { + CustomApplicationStatusId = entity.Id!.Value, + TemplateId = entity.TemplateId.Value, + ApplicationStatus = entity.ApplicationStatus, + Label = entity.Label, + CreatedOn = entity.CreatedOn, + CreatedBy = entity.CreatedBy.Value + }; } } diff --git a/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs new file mode 100644 index 00000000..c8da17bb --- /dev/null +++ b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; + +namespace DfE.ExternalApplications.Application.Templates.Commands; + +/// +/// Validates create or update custom application status commands. +/// +public class UpdateCustomApplicationStatusCommandValidator : AbstractValidator +{ + public UpdateCustomApplicationStatusCommandValidator() + { + RuleFor(x => x.Label) + .NotEmpty() + .WithMessage("Label is required"); + + RuleFor(x => x.ApplicationStatus) + .IsInEnum() + .WithMessage($"ApplicationStatus must be a valid {nameof(ApplicationStatus)} value"); + } +} diff --git a/src/DfE.ExternalApplications.Application/Templates/Queries/GetCustomApplicationStatusByApplicationStatusQueryHandler.cs b/src/DfE.ExternalApplications.Application/Templates/Queries/GetCustomApplicationStatusByApplicationStatusQueryHandler.cs index c2fe904f..cc154af1 100644 --- a/src/DfE.ExternalApplications.Application/Templates/Queries/GetCustomApplicationStatusByApplicationStatusQueryHandler.cs +++ b/src/DfE.ExternalApplications.Application/Templates/Queries/GetCustomApplicationStatusByApplicationStatusQueryHandler.cs @@ -1,6 +1,6 @@ +using DfE.ExternalApplications.Application.Templates.QueryObjects; using DfE.ExternalApplications.Domain.Entities; using DfE.ExternalApplications.Domain.Interfaces.Repositories; -using DfE.ExternalApplications.Domain.ValueObjects; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using MediatR; @@ -19,8 +19,11 @@ public async Task> Handle(GetCustomApplicatio { try { - var entity = await customApplicationStatusRepo.Query() - .FirstOrDefaultAsync(x => x.TemplateId == new TemplateId(request.TemplateId) && x.ApplicationStatus == request.ApplicationStatus, cancellationToken); + var entity = await new GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject( + request.TemplateId, + request.ApplicationStatus) + .Apply(customApplicationStatusRepo.Query()) + .FirstOrDefaultAsync(cancellationToken); if (entity is null) return Result.NotFound("Custom application status not found"); diff --git a/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject.cs b/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject.cs new file mode 100644 index 00000000..7ddd95a3 --- /dev/null +++ b/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject.cs @@ -0,0 +1,19 @@ +using DfE.ExternalApplications.Application.Common.QueriesObjects; +using DfE.ExternalApplications.Domain.Entities; +using DfE.ExternalApplications.Domain.ValueObjects; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; + +namespace DfE.ExternalApplications.Application.Templates.QueryObjects; + +/// +/// Filters custom application statuses by template and application status. +/// +public sealed class GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject( + Guid templateId, + ApplicationStatus applicationStatus) : IQueryObject +{ + private readonly TemplateId _templateId = new(templateId); + + public IQueryable Apply(IQueryable query) => + query.Where(x => x.TemplateId == _templateId && x.ApplicationStatus == applicationStatus); +} diff --git a/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj b/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj index 425a1cb4..2936ec61 100644 --- a/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj +++ b/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/Client.g.cs b/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/Client.g.cs index 382d0be0..4ec8847a 100644 --- a/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/Client.g.cs +++ b/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/Client.g.cs @@ -4436,7 +4436,7 @@ public string BaseUrl /// /// Custom status created/updated. /// A server side error occurred. - public virtual async System.Threading.Tasks.Task CreateCustomApplicationStatusAsync(System.Guid templateId, CustomApplicationStatusDto request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task CreateCustomApplicationStatusAsync(System.Guid templateId, CustomApplicationStatusRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (templateId == null) throw new System.ArgumentNullException("templateId"); @@ -4497,6 +4497,16 @@ public string BaseUrl return objectResponse_.Object; } else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ExternalApplicationsException("Invalid request data.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); diff --git a/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/Contracts.g.cs b/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/Contracts.g.cs index b31b94b5..36ef86dd 100644 --- a/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/Contracts.g.cs +++ b/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/Contracts.g.cs @@ -306,7 +306,7 @@ public partial interface ITemplatesClient /// /// Custom status created/updated. /// A server side error occurred. - System.Threading.Tasks.Task CreateCustomApplicationStatusAsync(System.Guid templateId, CustomApplicationStatusDto request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task CreateCustomApplicationStatusAsync(System.Guid templateId, CustomApplicationStatusRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// diff --git a/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/swagger.json b/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/swagger.json index cdaa2c55..03d723e2 100644 --- a/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/swagger.json +++ b/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/swagger.json @@ -2657,7 +2657,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CustomApplicationStatusDto" + "$ref": "#/components/schemas/CustomApplicationStatusRequest" } } }, @@ -2674,6 +2674,16 @@ } } } + }, + "400": { + "description": "Invalid request data.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExceptionResponse" + } + } + } } } } @@ -3839,12 +3849,25 @@ "type": "string", "format": "date-time" }, - "CreatedBy": { + "createdBy": { "type": "string", "format": "guid" } } }, + "CustomApplicationStatusRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "applicationStatus": { + "$ref": "#/components/schemas/ApplicationStatus" + }, + "label": { + "type": "string", + "nullable": true + } + } + }, "CreateTemplateVersionRequest": { "type": "object", "additionalProperties": false, diff --git a/src/GovUK.Dfe.ExternalApplications.Api.Client/GovUK.Dfe.ExternalApplications.Api.Client.csproj b/src/GovUK.Dfe.ExternalApplications.Api.Client/GovUK.Dfe.ExternalApplications.Api.Client.csproj index 0610fb55..05840666 100644 --- a/src/GovUK.Dfe.ExternalApplications.Api.Client/GovUK.Dfe.ExternalApplications.Api.Client.csproj +++ b/src/GovUK.Dfe.ExternalApplications.Api.Client/GovUK.Dfe.ExternalApplications.Api.Client.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/Tests/DfE.ExternalApplications.Api.Tests.Integration/Controllers/TemplatesControllerTests.cs b/src/Tests/DfE.ExternalApplications.Api.Tests.Integration/Controllers/TemplatesControllerTests.cs index 78e1c0cd..ef2d1b0e 100644 --- a/src/Tests/DfE.ExternalApplications.Api.Tests.Integration/Controllers/TemplatesControllerTests.cs +++ b/src/Tests/DfE.ExternalApplications.Api.Tests.Integration/Controllers/TemplatesControllerTests.cs @@ -322,7 +322,7 @@ public async Task CreateCustomApplicationStatusAsync_ShouldCreateNewStatus_WhenN httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token"); - var request = new CustomApplicationStatusDto + var request = new CustomApplicationStatusRequest { ApplicationStatus = ApplicationStatus.Submitted, Label = "Custom Rejection Label" @@ -356,7 +356,7 @@ public async Task CreateCustomApplicationStatusAsync_ShouldUpdateExistingStatus_ new AuthenticationHeaderValue("Bearer", "test-token"); // Create first - var request1 = new CustomApplicationStatusDto + var request1 = new CustomApplicationStatusRequest { ApplicationStatus = ApplicationStatus.InProgress, Label = "First Label" @@ -366,7 +366,7 @@ public async Task CreateCustomApplicationStatusAsync_ShouldUpdateExistingStatus_ var firstId = firstResult.CustomApplicationStatusId; // Update with new label - var request2 = new CustomApplicationStatusDto + var request2 = new CustomApplicationStatusRequest { ApplicationStatus = ApplicationStatus.InProgress, Label = "Updated Label" @@ -380,11 +380,13 @@ public async Task CreateCustomApplicationStatusAsync_ShouldUpdateExistingStatus_ Assert.Equal(firstId, secondResult.CustomApplicationStatusId); // Same ID Assert.Equal(ApplicationStatus.InProgress, secondResult.ApplicationStatus); Assert.Equal("Updated Label", secondResult.Label); + Assert.Equal(firstResult.CreatedOn, secondResult.CreatedOn); + Assert.Equal(firstResult.CreatedBy, secondResult.CreatedBy); } [Theory] [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] - public async Task CreateCustomApplicationStatusAsync_ShouldAllowNullLabel( + public async Task CreateCustomApplicationStatusAsync_ShouldReturnBadRequest_WhenLabelIsEmpty( CustomWebApplicationDbContextFactory factory, ITemplatesClient templatesClient, HttpClient httpClient) @@ -399,19 +401,45 @@ public async Task CreateCustomApplicationStatusAsync_ShouldAllowNullLabel( httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token"); - var request = new CustomApplicationStatusDto + var request = new CustomApplicationStatusRequest { ApplicationStatus = ApplicationStatus.Submitted, - Label = null + Label = string.Empty }; - // Act - var result = await templatesClient.CreateCustomApplicationStatusAsync(Guid.Parse(EaContextSeeder.TemplateId), request); + // Act & Assert + var ex = await Assert.ThrowsAsync>( + () => templatesClient.CreateCustomApplicationStatusAsync(Guid.Parse(EaContextSeeder.TemplateId), request)); + Assert.Equal(400, ex.StatusCode); + } - // Assert - Assert.NotNull(result); - Assert.Equal(ApplicationStatus.Submitted, result.ApplicationStatus); - Assert.Null(result.Label); + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task CreateCustomApplicationStatusAsync_ShouldReturnBadRequest_WhenApplicationStatusIsInvalid( + CustomWebApplicationDbContextFactory factory, + ITemplatesClient templatesClient, + HttpClient httpClient) + { + // Arrange + factory.TestClaims = new List + { + new(ClaimTypes.Email, EaContextSeeder.BobEmail), + new(ClaimTypes.Role, "Admin") + }; + + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "test-token"); + + var request = new CustomApplicationStatusRequest + { + ApplicationStatus = (ApplicationStatus)999, + Label = "Test Label" + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync>( + () => templatesClient.CreateCustomApplicationStatusAsync(Guid.Parse(EaContextSeeder.TemplateId), request)); + Assert.Equal(400, ex.StatusCode); } [Theory] @@ -430,7 +458,7 @@ public async Task CreateCustomApplicationStatusAsync_ShouldReturnForbidden_WhenN httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token"); - var request = new CustomApplicationStatusDto + var request = new CustomApplicationStatusRequest { ApplicationStatus = ApplicationStatus.Submitted, Label = "Test Label" @@ -447,7 +475,7 @@ public async Task CreateCustomApplicationStatusAsync_ShouldReturnForbidden_WhenN public async Task CreateCustomApplicationStatusAsync_ShouldReturnForbidden_WhenNotAuthenticated( CustomWebApplicationDbContextFactory factory, ITemplatesClient templatesClient, - CustomApplicationStatusDto request) + CustomApplicationStatusRequest request) { // Act & Assert var ex = await Assert.ThrowsAsync( diff --git a/src/Tests/DfE.ExternalApplications.Application.Tests/CommandHandlers/Templates/UpdateCustomApplicationStatusCommandHandlerTests.cs b/src/Tests/DfE.ExternalApplications.Application.Tests/CommandHandlers/Templates/UpdateCustomApplicationStatusCommandHandlerTests.cs index 1f54d0ff..ed8ecc3f 100644 --- a/src/Tests/DfE.ExternalApplications.Application.Tests/CommandHandlers/Templates/UpdateCustomApplicationStatusCommandHandlerTests.cs +++ b/src/Tests/DfE.ExternalApplications.Application.Tests/CommandHandlers/Templates/UpdateCustomApplicationStatusCommandHandlerTests.cs @@ -61,13 +61,16 @@ public UpdateCustomApplicationStatusCommandHandlerTests() public async Task Handle_UpdatesLabel_WhenCustomStatusExists(string newLabel) { // Arrange + var originalCreatedOn = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc); + var originalCreatedBy = new UserId(Guid.NewGuid()); + var existingStatus = new CustomApplicationStatus( new CustomApplicationStatusId(Guid.NewGuid()), new TemplateId(_testTemplateId), ApplicationStatus.Submitted, "Old Label", - DateTime.UtcNow, - _testUser.Id + originalCreatedOn, + originalCreatedBy ); var statusQueryable = new List { existingStatus }.AsQueryable().BuildMock(); @@ -82,6 +85,8 @@ public async Task Handle_UpdatesLabel_WhenCustomStatusExists(string newLabel) Assert.True(result.IsSuccess, $"Expected success but got: {result.Error}"); Assert.Equal(newLabel, result.Value.Label); Assert.Equal(ApplicationStatus.Submitted, result.Value.ApplicationStatus); + Assert.Equal(originalCreatedOn, result.Value.CreatedOn); + Assert.Equal(originalCreatedBy.Value, result.Value.CreatedBy); await _unitOfWork.Received(1).CommitAsync(CancellationToken.None); await _customStatusRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } @@ -148,32 +153,4 @@ public async Task Handle_ReturnsForbid_WhenUserNotFoundInDatabase(string label) await _customStatusRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); await _unitOfWork.DidNotReceive().CommitAsync(CancellationToken.None); } - - [Theory] - [CustomAutoData] - public async Task Handle_AllowsNullLabel_WhenUpdating() - { - // Arrange - var existingStatus = new CustomApplicationStatus( - new CustomApplicationStatusId(Guid.NewGuid()), - new TemplateId(_testTemplateId), - ApplicationStatus.Submitted, - "Old Label", - DateTime.UtcNow, - _testUser.Id - ); - - var statusQueryable = new List { existingStatus }.AsQueryable().BuildMock(); - _customStatusRepo.Query().Returns(_ => statusQueryable); - - var command = new UpdateCustomApplicationStatusCommand(_testTemplateId, ApplicationStatus.Submitted, null); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - Assert.True(result.IsSuccess, $"Expected success but got: {result.Error}"); - Assert.Null(result.Value.Label); - await _unitOfWork.Received(1).CommitAsync(CancellationToken.None); - } } diff --git a/src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs b/src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs new file mode 100644 index 00000000..d453a466 --- /dev/null +++ b/src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs @@ -0,0 +1,66 @@ +using DfE.ExternalApplications.Application.Templates.Commands; +using FluentValidation.TestHelper; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; + +namespace DfE.ExternalApplications.Application.Tests.CommandValidators.Templates; + +public class UpdateCustomApplicationStatusCommandValidatorTests +{ + private readonly UpdateCustomApplicationStatusCommandValidator _validator = new(); + + [Fact] + public void Validate_ShouldSucceed_WhenAllPropertiesValid() + { + var command = new UpdateCustomApplicationStatusCommand( + Guid.NewGuid(), + ApplicationStatus.Submitted, + "Custom Label"); + + var result = _validator.TestValidate(command); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Validate_ShouldFail_WhenLabelIsEmpty(string? label) + { + var command = new UpdateCustomApplicationStatusCommand( + Guid.NewGuid(), + ApplicationStatus.Submitted, + label!); + + var result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(c => c.Label); + } + + [Fact] + public void Validate_ShouldFail_WhenApplicationStatusIsInvalidEnum() + { + var command = new UpdateCustomApplicationStatusCommand( + Guid.NewGuid(), + (ApplicationStatus)999, + "Custom Label"); + + var result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(c => c.ApplicationStatus); + } + + [Theory] + [InlineData(ApplicationStatus.InProgress)] + [InlineData(ApplicationStatus.Submitted)] + public void Validate_ShouldSucceed_WhenApplicationStatusIsValid(ApplicationStatus applicationStatus) + { + var command = new UpdateCustomApplicationStatusCommand( + Guid.NewGuid(), + applicationStatus, + "Custom Label"); + + var result = _validator.TestValidate(command); + + result.ShouldNotHaveValidationErrorFor(c => c.ApplicationStatus); + } +} diff --git a/src/Tests/DfE.ExternalApplications.Application.Tests/QueryObjects/Templates/GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObjectTests.cs b/src/Tests/DfE.ExternalApplications.Application.Tests/QueryObjects/Templates/GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObjectTests.cs new file mode 100644 index 00000000..d0453348 --- /dev/null +++ b/src/Tests/DfE.ExternalApplications.Application.Tests/QueryObjects/Templates/GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObjectTests.cs @@ -0,0 +1,93 @@ +using DfE.ExternalApplications.Application.Templates.QueryObjects; +using DfE.ExternalApplications.Domain.Entities; +using DfE.ExternalApplications.Domain.ValueObjects; +using FluentAssertions; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using MockQueryable; + +namespace DfE.ExternalApplications.Application.Tests.QueryObjects.Templates; + +public class GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObjectTests +{ + [Fact] + public void Apply_ShouldReturnMatchingCustomApplicationStatus() + { + // Arrange + var templateId = Guid.NewGuid(); + var userId = new UserId(Guid.NewGuid()); + + var matchingStatus = new CustomApplicationStatus( + new CustomApplicationStatusId(Guid.NewGuid()), + new TemplateId(templateId), + ApplicationStatus.Submitted, + "Submitted Label", + DateTime.UtcNow, + userId); + + var otherStatusForTemplate = new CustomApplicationStatus( + new CustomApplicationStatusId(Guid.NewGuid()), + new TemplateId(templateId), + ApplicationStatus.InProgress, + "In Progress Label", + DateTime.UtcNow, + userId); + + var statusForOtherTemplate = new CustomApplicationStatus( + new CustomApplicationStatusId(Guid.NewGuid()), + new TemplateId(Guid.NewGuid()), + ApplicationStatus.Submitted, + "Other Template Label", + DateTime.UtcNow, + userId); + + var statuses = new List + { + matchingStatus, + otherStatusForTemplate, + statusForOtherTemplate + }; + + var mockQuery = statuses.AsQueryable().BuildMock(); + var queryObject = new GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject( + templateId, + ApplicationStatus.Submitted); + + // Act + var result = queryObject.Apply(mockQuery).ToList(); + + // Assert + result.Should().HaveCount(1); + result.First().Id.Should().Be(matchingStatus.Id); + result.First().Label.Should().Be("Submitted Label"); + } + + [Fact] + public void Apply_ShouldReturnEmpty_WhenNoMatchingCustomApplicationStatusExists() + { + // Arrange + var templateId = Guid.NewGuid(); + var userId = new UserId(Guid.NewGuid()); + + var statuses = new List + { + new( + new CustomApplicationStatusId(Guid.NewGuid()), + new TemplateId(templateId), + ApplicationStatus.InProgress, + "In Progress Label", + DateTime.UtcNow, + userId) + }; + + var mockQuery = statuses.AsQueryable().BuildMock(); + var queryObject = new GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject( + templateId, + ApplicationStatus.Submitted); + + // Act + var result = queryObject.Apply(mockQuery).ToList(); + + // Assert + result.Should().BeEmpty(); + } +} From 788f081c15895ac529b8a9bbbe833c515aebf550 Mon Sep 17 00:00:00 2001 From: FrostyApeOne Date: Mon, 6 Jul 2026 16:12:03 +0100 Subject: [PATCH 2/3] version bump --- CHANGELOG.md | 4 ++++ .../DfE.ExternalApplications.Api.csproj | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e3eba0..21e9abed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,3 +124,7 @@ All notable changes to this service will be documented in this file. ## [1.5.0] - Public Beta ### Notes - Added support for Custom Application Status + +## [1.5.1] - Public Beta +### Notes +- Added a new request model for Custom Statuses and fixed model bugs \ No newline at end of file diff --git a/src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj b/src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj index 534f592b..1d58a5d9 100644 --- a/src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj +++ b/src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj @@ -5,8 +5,8 @@ $(NoWarn);1591 net10.0 f714ca7a-fc08-46ff-b0cc-373d6f04cf4c - 1.5.0 - 1.5.0 + 1.5.1 + 1.5.1 From e6a2445699e393552d2a623c3112170ce3dcd649 Mon Sep 17 00:00:00 2001 From: FrostyApeOne Date: Tue, 7 Jul 2026 14:13:55 +0100 Subject: [PATCH 3/3] Fixed various bugs --- .../Controllers/TemplatesController.cs | 4 ++ .../ExceptionHandlers/JsonExceptionHandler.cs | 37 ++++++++++++ src/DfE.ExternalApplications.Api/Program.cs | 14 +++++ ...teCustomApplicationStatusCommandHandler.cs | 6 +- ...CustomApplicationStatusCommandValidator.cs | 2 + .../DfE.ExternalApplications.Domain.csproj | 2 +- .../Generated/swagger.json | 6 +- ...Dfe.ExternalApplications.Api.Client.csproj | 2 +- .../Controllers/TemplatesControllerTests.cs | 60 +++++++++++++++++++ ...mApplicationStatusCommandValidatorTests.cs | 13 ++++ 10 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 src/DfE.ExternalApplications.Api/ExceptionHandlers/JsonExceptionHandler.cs diff --git a/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs b/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs index edc75be5..71f309f5 100644 --- a/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs +++ b/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs @@ -1,6 +1,7 @@ using Asp.Versioning; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using DfE.ExternalApplications.Application.Common.Exceptions; using DfE.ExternalApplications.Application.Templates.Commands; using DfE.ExternalApplications.Application.Templates.Queries; using MediatR; @@ -68,6 +69,9 @@ public async Task GetCustomApplicationStatusesAsync([FromRoute] G [Authorize(Roles = "Admin")] public async Task CreateCustomApplicationStatusAsync([FromRoute] Guid templateId, [FromBody] CustomApplicationStatusRequest request, CancellationToken cancellationToken) { + if (request is null) + throw new BadRequestException("Invalid request data."); + var command = new UpdateCustomApplicationStatusCommand(templateId, request.ApplicationStatus, request.Label); var result = await sender.Send(command, cancellationToken); diff --git a/src/DfE.ExternalApplications.Api/ExceptionHandlers/JsonExceptionHandler.cs b/src/DfE.ExternalApplications.Api/ExceptionHandlers/JsonExceptionHandler.cs new file mode 100644 index 00000000..87256d6d --- /dev/null +++ b/src/DfE.ExternalApplications.Api/ExceptionHandlers/JsonExceptionHandler.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Http.Interfaces; +using GovUK.Dfe.CoreLibs.Http.Models; +using Microsoft.AspNetCore.Http; + +namespace DfE.ExternalApplications.Api.ExceptionHandlers; + +/// +/// Handles invalid JSON request bodies, including unrecognised enum values. +/// +[ExcludeFromCodeCoverage] +public class JsonExceptionHandler : ICustomExceptionHandler +{ + public bool CanHandle(Type exceptionType) => + exceptionType == typeof(JsonException) || exceptionType == typeof(BadHttpRequestException); + + public int Priority => 15; + + public ExceptionResponse Handle(Exception exception, Dictionary? context = null) + { + var details = exception switch + { + JsonException jsonException => jsonException.Message, + BadHttpRequestException badHttpRequestException => badHttpRequestException.Message, + _ => exception.Message + }; + + return new ExceptionResponse + { + StatusCode = StatusCodes.Status400BadRequest, + Message = "Invalid request data.", + Details = details, + ExceptionType = exception.GetType().Name + }; + } +} diff --git a/src/DfE.ExternalApplications.Api/Program.cs b/src/DfE.ExternalApplications.Api/Program.cs index 0b637f22..cf9f90f4 100644 --- a/src/DfE.ExternalApplications.Api/Program.cs +++ b/src/DfE.ExternalApplications.Api/Program.cs @@ -112,6 +112,7 @@ public static async Task Main(string[] args) builder.Services.AddScoped(); builder.Services.AddCustomExceptionHandler(); + builder.Services.AddCustomExceptionHandler(); builder.Services.AddCustomExceptionHandler(); // Collect all frontend origins from all tenants for the default CORS policy @@ -269,6 +270,19 @@ public static async Task Main(string[] args) options.IncludeDetails = builder.Environment.IsDevelopment(); options.LogExceptions = true; options.DefaultErrorMessage = "Something went wrong"; + options.SharedPostProcessingAction = (exception, response) => + { + if (exception is DfE.ExternalApplications.Application.Common.Exceptions.ValidationException validationException) + { + response.Details = string.Join("; ", + validationException.Errors + .SelectMany(kvp => kvp.Value.Select(error => $"{kvp.Key}: {error}"))); + } + else if (exception is JsonException jsonException) + { + response.Details = jsonException.Message; + } + }; }); diff --git a/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandHandler.cs b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandHandler.cs index c0096f18..c4379fe6 100644 --- a/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandHandler.cs +++ b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandHandler.cs @@ -19,7 +19,7 @@ namespace DfE.ExternalApplications.Application.Templates.Commands /// public sealed record UpdateCustomApplicationStatusCommand( Guid TemplateId, - ApplicationStatus ApplicationStatus, + ApplicationStatus? ApplicationStatus, string? Label) : IRequest>; public sealed class UpdateCustomApplicationStatusCommandHandler( @@ -65,7 +65,7 @@ public async Task> Handle(UpdateCustomApplica // Check if custom status exists for this template and application status var existing = await new GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject( request.TemplateId, - request.ApplicationStatus) + request.ApplicationStatus!.Value) .Apply(customApplicationStatusRepo.Query()) .FirstOrDefaultAsync(cancellationToken); @@ -80,7 +80,7 @@ public async Task> Handle(UpdateCustomApplica var entity = new CustomApplicationStatus( new CustomApplicationStatusId(Guid.NewGuid()), new TemplateId(request.TemplateId), - request.ApplicationStatus, + request.ApplicationStatus!.Value, request.Label, DateTime.UtcNow, createdByUserId); diff --git a/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs index c8da17bb..7719d83e 100644 --- a/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs +++ b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs @@ -15,6 +15,8 @@ public UpdateCustomApplicationStatusCommandValidator() .WithMessage("Label is required"); RuleFor(x => x.ApplicationStatus) + .NotNull() + .WithMessage("ApplicationStatus is required") .IsInEnum() .WithMessage($"ApplicationStatus must be a valid {nameof(ApplicationStatus)} value"); } diff --git a/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj b/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj index 2936ec61..a8f20a6c 100644 --- a/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj +++ b/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/swagger.json b/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/swagger.json index 03d723e2..e874bb65 100644 --- a/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/swagger.json +++ b/src/GovUK.Dfe.ExternalApplications.Api.Client/Generated/swagger.json @@ -3858,13 +3858,17 @@ "CustomApplicationStatusRequest": { "type": "object", "additionalProperties": false, + "required": [ + "applicationStatus", + "label" + ], "properties": { "applicationStatus": { "$ref": "#/components/schemas/ApplicationStatus" }, "label": { "type": "string", - "nullable": true + "minLength": 1 } } }, diff --git a/src/GovUK.Dfe.ExternalApplications.Api.Client/GovUK.Dfe.ExternalApplications.Api.Client.csproj b/src/GovUK.Dfe.ExternalApplications.Api.Client/GovUK.Dfe.ExternalApplications.Api.Client.csproj index 05840666..a2423bfe 100644 --- a/src/GovUK.Dfe.ExternalApplications.Api.Client/GovUK.Dfe.ExternalApplications.Api.Client.csproj +++ b/src/GovUK.Dfe.ExternalApplications.Api.Client/GovUK.Dfe.ExternalApplications.Api.Client.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/Tests/DfE.ExternalApplications.Api.Tests.Integration/Controllers/TemplatesControllerTests.cs b/src/Tests/DfE.ExternalApplications.Api.Tests.Integration/Controllers/TemplatesControllerTests.cs index ef2d1b0e..809a06df 100644 --- a/src/Tests/DfE.ExternalApplications.Api.Tests.Integration/Controllers/TemplatesControllerTests.cs +++ b/src/Tests/DfE.ExternalApplications.Api.Tests.Integration/Controllers/TemplatesControllerTests.cs @@ -442,6 +442,66 @@ public async Task CreateCustomApplicationStatusAsync_ShouldReturnBadRequest_When Assert.Equal(400, ex.StatusCode); } + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task CreateCustomApplicationStatusAsync_ShouldReturnBadRequest_WhenApplicationStatusIsMissing( + CustomWebApplicationDbContextFactory factory, + ITemplatesClient templatesClient, + HttpClient httpClient) + { + // Arrange + factory.TestClaims = new List + { + new(ClaimTypes.Email, EaContextSeeder.BobEmail), + new(ClaimTypes.Role, "Admin") + }; + + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "test-token"); + + var request = new CustomApplicationStatusRequest + { + Label = "Test Label" + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync>( + () => templatesClient.CreateCustomApplicationStatusAsync(Guid.Parse(EaContextSeeder.TemplateId), request)); + Assert.Equal(400, ex.StatusCode); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task CreateCustomApplicationStatusAsync_ShouldReturnBadRequest_WhenApplicationStatusStringIsInvalid( + CustomWebApplicationDbContextFactory factory, + HttpClient httpClient) + { + // Arrange + factory.TestClaims = new List + { + new(ClaimTypes.Email, EaContextSeeder.BobEmail), + new(ClaimTypes.Role, "Admin") + }; + + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "test-token"); + + using var content = new StringContent( + """{"applicationStatus":"NotAValidStatus","label":"Test Label"}""", + System.Text.Encoding.UTF8, + "application/json"); + + // Act + var response = await httpClient.PostAsync( + $"v1/Templates/{EaContextSeeder.TemplateId}/custom-statuses", + content); + + // Assert + Assert.Equal(400, (int)response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid request data", body); + } + [Theory] [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] public async Task CreateCustomApplicationStatusAsync_ShouldReturnForbidden_WhenNotAdmin( diff --git a/src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs b/src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs index d453a466..fab80cf4 100644 --- a/src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs +++ b/src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs @@ -36,6 +36,19 @@ public void Validate_ShouldFail_WhenLabelIsEmpty(string? label) result.ShouldHaveValidationErrorFor(c => c.Label); } + [Fact] + public void Validate_ShouldFail_WhenApplicationStatusIsMissing() + { + var command = new UpdateCustomApplicationStatusCommand( + Guid.NewGuid(), + null, + "Custom Label"); + + var result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(c => c.ApplicationStatus); + } + [Fact] public void Validate_ShouldFail_WhenApplicationStatusIsInvalidEnum() {