diff --git a/.gitignore b/.gitignore index 62835eb..6977992 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ logs/ bin/ obj/ +TestResults/ diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..5bce8b3 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,29 @@ +{ + "servers": { + // GitHub MCP Server - Interact with GitHub APIs (issues, PRs, repos, etc.) + // https://github.com/github/github-mcp-server + "github": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" + } + } + }, + "inputs": [ + { + "id": "github_token", + "type": "promptString", + "description": "GitHub Personal Access Token. Prefer fine-grained PATs with minimum permissions: Contents (read), Issues (read & write), Pull requests (read & write). If a classic PAT is unavoidable, minimum scope: repo.", + "password": true + } + ] +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Mappings/PlayerMappingProfile.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Mappings/PlayerMappingProfile.cs index e444719..1d7ee76 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Mappings/PlayerMappingProfile.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Mappings/PlayerMappingProfile.cs @@ -37,7 +37,12 @@ public PlayerMappingProfile() ) .ForMember( destination => destination.Birth, - options => options.MapFrom(source => $"{source.DateOfBirth:MMMM d, yyyy}") + options => + options.MapFrom(source => + source.DateOfBirth.HasValue + ? $"{source.DateOfBirth.Value:MMMM d, yyyy}" + : null + ) ) .ForMember( destination => destination.Dorsal, diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs index bd17682..8636d4e 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs @@ -39,6 +39,20 @@ public PlayerRequestModelValidator(IPlayerRepository playerRepository) .WithMessage("AbbrPosition is required.") .Must(Position.IsValidAbbr) .WithMessage("AbbrPosition is invalid."); + + When( + player => player.DateOfBirth.HasValue, + () => + { + RuleFor(player => player.DateOfBirth) + .Must(date => date!.Value.Date < DateTime.UtcNow.Date) + .WithMessage("DateOfBirth must be a date in the past.") + .Must(date => + date!.Value.Date >= new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ) + .WithMessage("DateOfBirth must be on or after January 1, 1900."); + } + ); } private async Task BeUniqueSquadNumber( diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerValidatorTests.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerValidatorTests.cs new file mode 100644 index 0000000..34c30ce --- /dev/null +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerValidatorTests.cs @@ -0,0 +1,225 @@ +using Dotnet.Samples.AspNetCore.WebApi.Models; +using Dotnet.Samples.AspNetCore.WebApi.Repositories; +using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities; +using Dotnet.Samples.AspNetCore.WebApi.Validators; +using FluentAssertions; +using Moq; + +namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Unit; + +public class PlayerValidatorTests +{ + private static PlayerRequestModelValidator CreateValidator( + Mock? repositoryMock = null + ) + { + var mock = repositoryMock ?? new Mock(); + return new PlayerRequestModelValidator(mock.Object); + } + + /* ------------------------------------------------------------------------- + * Valid request + * ---------------------------------------------------------------------- */ + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenRequestModelIsValid_ThenValidationShouldPass() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + var repositoryMock = new Mock(); + repositoryMock + .Setup(r => r.FindBySquadNumberAsync(request.SquadNumber)) + .ReturnsAsync(null as Player); + var validator = CreateValidator(repositoryMock); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + /* ------------------------------------------------------------------------- + * FirstName + * ---------------------------------------------------------------------- */ + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenFirstNameIsEmpty_ThenValidationShouldFail() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + request.FirstName = string.Empty; + var validator = CreateValidator(); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "FirstName"); + } + + /* ------------------------------------------------------------------------- + * LastName + * ---------------------------------------------------------------------- */ + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenLastNameIsEmpty_ThenValidationShouldFail() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + request.LastName = string.Empty; + var validator = CreateValidator(); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "LastName"); + } + + /* ------------------------------------------------------------------------- + * SquadNumber + * ---------------------------------------------------------------------- */ + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenSquadNumberIsNotGreaterThanZero_ThenValidationShouldFail() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + request.SquadNumber = 0; + var validator = CreateValidator(); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "SquadNumber"); + } + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenSquadNumberIsNotUnique_ThenValidationShouldFail() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + var existingPlayer = PlayerFakes.MakeNew(); + var repositoryMock = new Mock(); + repositoryMock + .Setup(r => r.FindBySquadNumberAsync(request.SquadNumber)) + .ReturnsAsync(existingPlayer); + var validator = CreateValidator(repositoryMock); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result + .Errors.Should() + .Contain(e => e.PropertyName == "SquadNumber" && e.ErrorMessage.Contains("unique")); + } + + /* ------------------------------------------------------------------------- + * AbbrPosition + * ---------------------------------------------------------------------- */ + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenAbbrPositionIsEmpty_ThenValidationShouldFail() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + request.AbbrPosition = string.Empty; + var validator = CreateValidator(); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "AbbrPosition"); + } + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenAbbrPositionIsInvalid_ThenValidationShouldFail() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + request.AbbrPosition = "INVALID"; + var validator = CreateValidator(); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "AbbrPosition"); + } + + /* ------------------------------------------------------------------------- + * DateOfBirth + * ---------------------------------------------------------------------- */ + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenDateOfBirthIsNull_ThenValidationShouldPass() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + request.DateOfBirth = null; + var repositoryMock = new Mock(); + repositoryMock + .Setup(r => r.FindBySquadNumberAsync(request.SquadNumber)) + .ReturnsAsync(null as Player); + var validator = CreateValidator(repositoryMock); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenDateOfBirthIsInTheFuture_ThenValidationShouldFail() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + request.DateOfBirth = DateTime.UtcNow.AddYears(1); + var validator = CreateValidator(); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "DateOfBirth"); + } + + [Fact] + [Trait("Category", "Unit")] + public async Task GivenValidateAsync_WhenDateOfBirthIsBeforeYear1900_ThenValidationShouldFail() + { + // Arrange + var request = PlayerFakes.MakeRequestModelForCreate(); + request.DateOfBirth = new DateTime(1899, 12, 31, 0, 0, 0, DateTimeKind.Utc); + var validator = CreateValidator(); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "DateOfBirth"); + } +} diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs index 7c118b3..0059ba1 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs @@ -14,6 +14,9 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities; /// public static class PlayerFakes { + private static string? FormatBirth(DateTime? dateOfBirth) => + dateOfBirth.HasValue ? $"{dateOfBirth.Value:MMMM d, yyyy}" : null; + /// /// Returns the starting 11 players with generated GUIDs for in-memory testing. /// Reuses production player data from PlayerData.MakeStarting11(). @@ -101,7 +104,7 @@ public static PlayerResponseModel MakeResponseModelForCreate() { FullName = $"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(), - Birth = $"{player.DateOfBirth:MMMM d, yyyy}", + Birth = FormatBirth(player.DateOfBirth), Dorsal = player.SquadNumber, Position = player.Position, Club = player.Team, @@ -157,7 +160,7 @@ public static PlayerResponseModel MakeResponseModelForRetrieve(int squadNumber) { FullName = $"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(), - Birth = $"{player.DateOfBirth:MMMM d, yyyy}", + Birth = FormatBirth(player.DateOfBirth), Dorsal = player.SquadNumber, Position = player.Position, Club = player.Team, @@ -181,7 +184,7 @@ .. PlayerData { FullName = $"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(), - Birth = $"{player.DateOfBirth:MMMM d, yyyy}", + Birth = FormatBirth(player.DateOfBirth), Dorsal = player.SquadNumber, Position = player.Position, Club = player.Team,