Skip to content

Commit bf7215c

Browse files
authored
Merge pull request #397 from nanotaboada/feat/player-validation-and-response-formatting
feat(validation): add DateOfBirth rules and fix Birth null mapping
2 parents 3625020 + 026d34a commit bf7215c

6 files changed

Lines changed: 281 additions & 4 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
logs/
22
bin/
33
obj/
4+
TestResults/

.vscode/mcp.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"servers": {
3+
// GitHub MCP Server - Interact with GitHub APIs (issues, PRs, repos, etc.)
4+
// https://github.com/github/github-mcp-server
5+
"github": {
6+
"type": "stdio",
7+
"command": "docker",
8+
"args": [
9+
"run",
10+
"-i",
11+
"--rm",
12+
"-e",
13+
"GITHUB_PERSONAL_ACCESS_TOKEN",
14+
"ghcr.io/github/github-mcp-server"
15+
],
16+
"env": {
17+
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
18+
}
19+
}
20+
},
21+
"inputs": [
22+
{
23+
"id": "github_token",
24+
"type": "promptString",
25+
"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.",
26+
"password": true
27+
}
28+
]
29+
}

src/Dotnet.Samples.AspNetCore.WebApi/Mappings/PlayerMappingProfile.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ public PlayerMappingProfile()
3737
)
3838
.ForMember(
3939
destination => destination.Birth,
40-
options => options.MapFrom(source => $"{source.DateOfBirth:MMMM d, yyyy}")
40+
options =>
41+
options.MapFrom(source =>
42+
source.DateOfBirth.HasValue
43+
? $"{source.DateOfBirth.Value:MMMM d, yyyy}"
44+
: null
45+
)
4146
)
4247
.ForMember(
4348
destination => destination.Dorsal,

src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ public PlayerRequestModelValidator(IPlayerRepository playerRepository)
3939
.WithMessage("AbbrPosition is required.")
4040
.Must(Position.IsValidAbbr)
4141
.WithMessage("AbbrPosition is invalid.");
42+
43+
When(
44+
player => player.DateOfBirth.HasValue,
45+
() =>
46+
{
47+
RuleFor(player => player.DateOfBirth)
48+
.Must(date => date!.Value.Date < DateTime.UtcNow.Date)
49+
.WithMessage("DateOfBirth must be a date in the past.")
50+
.Must(date =>
51+
date!.Value.Date >= new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc)
52+
)
53+
.WithMessage("DateOfBirth must be on or after January 1, 1900.");
54+
}
55+
);
4256
}
4357

4458
private async Task<bool> BeUniqueSquadNumber(
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
using Dotnet.Samples.AspNetCore.WebApi.Models;
2+
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
3+
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
4+
using Dotnet.Samples.AspNetCore.WebApi.Validators;
5+
using FluentAssertions;
6+
using Moq;
7+
8+
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Unit;
9+
10+
public class PlayerValidatorTests
11+
{
12+
private static PlayerRequestModelValidator CreateValidator(
13+
Mock<IPlayerRepository>? repositoryMock = null
14+
)
15+
{
16+
var mock = repositoryMock ?? new Mock<IPlayerRepository>();
17+
return new PlayerRequestModelValidator(mock.Object);
18+
}
19+
20+
/* -------------------------------------------------------------------------
21+
* Valid request
22+
* ---------------------------------------------------------------------- */
23+
24+
[Fact]
25+
[Trait("Category", "Unit")]
26+
public async Task GivenValidateAsync_WhenRequestModelIsValid_ThenValidationShouldPass()
27+
{
28+
// Arrange
29+
var request = PlayerFakes.MakeRequestModelForCreate();
30+
var repositoryMock = new Mock<IPlayerRepository>();
31+
repositoryMock
32+
.Setup(r => r.FindBySquadNumberAsync(request.SquadNumber))
33+
.ReturnsAsync(null as Player);
34+
var validator = CreateValidator(repositoryMock);
35+
36+
// Act
37+
var result = await validator.ValidateAsync(request);
38+
39+
// Assert
40+
result.IsValid.Should().BeTrue();
41+
result.Errors.Should().BeEmpty();
42+
}
43+
44+
/* -------------------------------------------------------------------------
45+
* FirstName
46+
* ---------------------------------------------------------------------- */
47+
48+
[Fact]
49+
[Trait("Category", "Unit")]
50+
public async Task GivenValidateAsync_WhenFirstNameIsEmpty_ThenValidationShouldFail()
51+
{
52+
// Arrange
53+
var request = PlayerFakes.MakeRequestModelForCreate();
54+
request.FirstName = string.Empty;
55+
var validator = CreateValidator();
56+
57+
// Act
58+
var result = await validator.ValidateAsync(request);
59+
60+
// Assert
61+
result.IsValid.Should().BeFalse();
62+
result.Errors.Should().Contain(e => e.PropertyName == "FirstName");
63+
}
64+
65+
/* -------------------------------------------------------------------------
66+
* LastName
67+
* ---------------------------------------------------------------------- */
68+
69+
[Fact]
70+
[Trait("Category", "Unit")]
71+
public async Task GivenValidateAsync_WhenLastNameIsEmpty_ThenValidationShouldFail()
72+
{
73+
// Arrange
74+
var request = PlayerFakes.MakeRequestModelForCreate();
75+
request.LastName = string.Empty;
76+
var validator = CreateValidator();
77+
78+
// Act
79+
var result = await validator.ValidateAsync(request);
80+
81+
// Assert
82+
result.IsValid.Should().BeFalse();
83+
result.Errors.Should().Contain(e => e.PropertyName == "LastName");
84+
}
85+
86+
/* -------------------------------------------------------------------------
87+
* SquadNumber
88+
* ---------------------------------------------------------------------- */
89+
90+
[Fact]
91+
[Trait("Category", "Unit")]
92+
public async Task GivenValidateAsync_WhenSquadNumberIsNotGreaterThanZero_ThenValidationShouldFail()
93+
{
94+
// Arrange
95+
var request = PlayerFakes.MakeRequestModelForCreate();
96+
request.SquadNumber = 0;
97+
var validator = CreateValidator();
98+
99+
// Act
100+
var result = await validator.ValidateAsync(request);
101+
102+
// Assert
103+
result.IsValid.Should().BeFalse();
104+
result.Errors.Should().Contain(e => e.PropertyName == "SquadNumber");
105+
}
106+
107+
[Fact]
108+
[Trait("Category", "Unit")]
109+
public async Task GivenValidateAsync_WhenSquadNumberIsNotUnique_ThenValidationShouldFail()
110+
{
111+
// Arrange
112+
var request = PlayerFakes.MakeRequestModelForCreate();
113+
var existingPlayer = PlayerFakes.MakeNew();
114+
var repositoryMock = new Mock<IPlayerRepository>();
115+
repositoryMock
116+
.Setup(r => r.FindBySquadNumberAsync(request.SquadNumber))
117+
.ReturnsAsync(existingPlayer);
118+
var validator = CreateValidator(repositoryMock);
119+
120+
// Act
121+
var result = await validator.ValidateAsync(request);
122+
123+
// Assert
124+
result.IsValid.Should().BeFalse();
125+
result
126+
.Errors.Should()
127+
.Contain(e => e.PropertyName == "SquadNumber" && e.ErrorMessage.Contains("unique"));
128+
}
129+
130+
/* -------------------------------------------------------------------------
131+
* AbbrPosition
132+
* ---------------------------------------------------------------------- */
133+
134+
[Fact]
135+
[Trait("Category", "Unit")]
136+
public async Task GivenValidateAsync_WhenAbbrPositionIsEmpty_ThenValidationShouldFail()
137+
{
138+
// Arrange
139+
var request = PlayerFakes.MakeRequestModelForCreate();
140+
request.AbbrPosition = string.Empty;
141+
var validator = CreateValidator();
142+
143+
// Act
144+
var result = await validator.ValidateAsync(request);
145+
146+
// Assert
147+
result.IsValid.Should().BeFalse();
148+
result.Errors.Should().Contain(e => e.PropertyName == "AbbrPosition");
149+
}
150+
151+
[Fact]
152+
[Trait("Category", "Unit")]
153+
public async Task GivenValidateAsync_WhenAbbrPositionIsInvalid_ThenValidationShouldFail()
154+
{
155+
// Arrange
156+
var request = PlayerFakes.MakeRequestModelForCreate();
157+
request.AbbrPosition = "INVALID";
158+
var validator = CreateValidator();
159+
160+
// Act
161+
var result = await validator.ValidateAsync(request);
162+
163+
// Assert
164+
result.IsValid.Should().BeFalse();
165+
result.Errors.Should().Contain(e => e.PropertyName == "AbbrPosition");
166+
}
167+
168+
/* -------------------------------------------------------------------------
169+
* DateOfBirth
170+
* ---------------------------------------------------------------------- */
171+
172+
[Fact]
173+
[Trait("Category", "Unit")]
174+
public async Task GivenValidateAsync_WhenDateOfBirthIsNull_ThenValidationShouldPass()
175+
{
176+
// Arrange
177+
var request = PlayerFakes.MakeRequestModelForCreate();
178+
request.DateOfBirth = null;
179+
var repositoryMock = new Mock<IPlayerRepository>();
180+
repositoryMock
181+
.Setup(r => r.FindBySquadNumberAsync(request.SquadNumber))
182+
.ReturnsAsync(null as Player);
183+
var validator = CreateValidator(repositoryMock);
184+
185+
// Act
186+
var result = await validator.ValidateAsync(request);
187+
188+
// Assert
189+
result.IsValid.Should().BeTrue();
190+
}
191+
192+
[Fact]
193+
[Trait("Category", "Unit")]
194+
public async Task GivenValidateAsync_WhenDateOfBirthIsInTheFuture_ThenValidationShouldFail()
195+
{
196+
// Arrange
197+
var request = PlayerFakes.MakeRequestModelForCreate();
198+
request.DateOfBirth = DateTime.UtcNow.AddYears(1);
199+
var validator = CreateValidator();
200+
201+
// Act
202+
var result = await validator.ValidateAsync(request);
203+
204+
// Assert
205+
result.IsValid.Should().BeFalse();
206+
result.Errors.Should().Contain(e => e.PropertyName == "DateOfBirth");
207+
}
208+
209+
[Fact]
210+
[Trait("Category", "Unit")]
211+
public async Task GivenValidateAsync_WhenDateOfBirthIsBeforeYear1900_ThenValidationShouldFail()
212+
{
213+
// Arrange
214+
var request = PlayerFakes.MakeRequestModelForCreate();
215+
request.DateOfBirth = new DateTime(1899, 12, 31, 0, 0, 0, DateTimeKind.Utc);
216+
var validator = CreateValidator();
217+
218+
// Act
219+
var result = await validator.ValidateAsync(request);
220+
221+
// Assert
222+
result.IsValid.Should().BeFalse();
223+
result.Errors.Should().Contain(e => e.PropertyName == "DateOfBirth");
224+
}
225+
}

test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
1414
/// </summary>
1515
public static class PlayerFakes
1616
{
17+
private static string? FormatBirth(DateTime? dateOfBirth) =>
18+
dateOfBirth.HasValue ? $"{dateOfBirth.Value:MMMM d, yyyy}" : null;
19+
1720
/// <summary>
1821
/// Returns the starting 11 players with generated GUIDs for in-memory testing.
1922
/// Reuses production player data from PlayerData.MakeStarting11().
@@ -101,7 +104,7 @@ public static PlayerResponseModel MakeResponseModelForCreate()
101104
{
102105
FullName =
103106
$"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(),
104-
Birth = $"{player.DateOfBirth:MMMM d, yyyy}",
107+
Birth = FormatBirth(player.DateOfBirth),
105108
Dorsal = player.SquadNumber,
106109
Position = player.Position,
107110
Club = player.Team,
@@ -157,7 +160,7 @@ public static PlayerResponseModel MakeResponseModelForRetrieve(int squadNumber)
157160
{
158161
FullName =
159162
$"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(),
160-
Birth = $"{player.DateOfBirth:MMMM d, yyyy}",
163+
Birth = FormatBirth(player.DateOfBirth),
161164
Dorsal = player.SquadNumber,
162165
Position = player.Position,
163166
Club = player.Team,
@@ -181,7 +184,7 @@ .. PlayerData
181184
{
182185
FullName =
183186
$"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(),
184-
Birth = $"{player.DateOfBirth:MMMM d, yyyy}",
187+
Birth = FormatBirth(player.DateOfBirth),
185188
Dorsal = player.SquadNumber,
186189
Position = player.Position,
187190
Club = player.Team,

0 commit comments

Comments
 (0)