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/Controllers/TemplatesController.cs b/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs index 0300569c..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; @@ -63,10 +64,14 @@ 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) { + 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/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 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 e408b06a..c4379fe6 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; @@ -18,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( @@ -62,32 +63,24 @@ 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!.Value) + .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( new CustomApplicationStatusId(Guid.NewGuid()), new TemplateId(request.TemplateId), - request.ApplicationStatus, + request.ApplicationStatus!.Value, request.Label, DateTime.UtcNow, createdByUserId); @@ -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..7719d83e --- /dev/null +++ b/src/DfE.ExternalApplications.Application/Templates/Commands/UpdateCustomApplicationStatusCommandValidator.cs @@ -0,0 +1,23 @@ +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) + .NotNull() + .WithMessage("ApplicationStatus is required") + .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..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/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..e874bb65 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,29 @@ "type": "string", "format": "date-time" }, - "CreatedBy": { + "createdBy": { "type": "string", "format": "guid" } } }, + "CustomApplicationStatusRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "applicationStatus", + "label" + ], + "properties": { + "applicationStatus": { + "$ref": "#/components/schemas/ApplicationStatus" + }, + "label": { + "type": "string", + "minLength": 1 + } + } + }, "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..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 78e1c0cd..809a06df 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,105 @@ 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 & 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_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] + [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 result = await templatesClient.CreateCustomApplicationStatusAsync(Guid.Parse(EaContextSeeder.TemplateId), request); + var response = await httpClient.PostAsync( + $"v1/Templates/{EaContextSeeder.TemplateId}/custom-statuses", + content); // Assert - Assert.NotNull(result); - Assert.Equal(ApplicationStatus.Submitted, result.ApplicationStatus); - Assert.Null(result.Label); + Assert.Equal(400, (int)response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid request data", body); } [Theory] @@ -430,7 +518,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 +535,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..fab80cf4 --- /dev/null +++ b/src/Tests/DfE.ExternalApplications.Application.Tests/CommandValidators/Templates/UpdateCustomApplicationStatusCommandValidatorTests.cs @@ -0,0 +1,79 @@ +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_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() + { + 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(); + } +}