Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
name: Bug report
about: Report a bug or unexpected behavior
title: "[BUG]"
labels: bug, .NET
assignees: ''

---

## Description

A clear and concise description of what the bug is.

## Steps to Reproduce

1. Step 1
2. Step 2
3. Step 3

## Expected Behavior

What you expected to happen.

## Actual Behavior

What actually happened.

## Environment

- **.NET SDK version:** (output of `dotnet --version`)
- **ASP.NET Core version:** (from `*.csproj`)
- **OS:** (e.g., macOS 15.0, Ubuntu 24.04, Windows 11)

## Additional Context

Add any other context about the problem here (logs, screenshots, etc.).

## Possible Solution

(Optional) Suggest a fix or workaround if you have one.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ IValidator<PlayerRequestModel> validator
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
{
var validation = await validator.ValidateAsync(player);
// Use the "Create" rule set, which includes BeUniqueSquadNumber.
var validation = await validator.ValidateAsync(
player,
options => options.IncludeRuleSets("Create")
);

if (!validation.IsValid)
{
Expand Down Expand Up @@ -199,7 +203,13 @@ public async Task<IResult> PutAsync(
[FromBody] PlayerRequestModel player
)
{
var validation = await validator.ValidateAsync(player);
// Use the "Update" rule set, which omits BeUniqueSquadNumber.
// The player being updated already exists in the database, so a
// uniqueness check on its own squad number would always fail.
var validation = await validator.ValidateAsync(
player,
options => options.IncludeRuleSets("Update")
);
if (!validation.IsValid)
{
var errors = validation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,24 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Validators;
/// PlayerRequestModel.
/// </summary>
/// <remarks>
/// This class is part of the FluentValidation library, which provides a fluent
/// interface for building validation rules.
/// Rules are organized into CRUD-named rule sets to make their intent explicit.
/// This prevents <c>BeUniqueSquadNumber</c> from running on PUT requests, where
/// the player's squad number already exists in the database by definition.
///
/// <list type="bullet">
/// <item><description>
/// <c>"Create"</c> — used by <c>POST /players</c>; includes all rules plus
/// the uniqueness check for <c>SquadNumber</c>.
/// </description></item>
/// <item><description>
/// <c>"Update"</c> — used by <c>PUT /players/squadNumber/{n}</c>; same
/// rules, but <c>BeUniqueSquadNumber</c> is intentionally omitted.
/// </description></item>
/// </list>
///
/// Controllers pass <c>opts.IncludeRuleSets("Create")</c> or
/// <c>opts.IncludeRuleSets("Update")</c> so that only the appropriate rule
/// set runs for each operation.
/// </remarks>
public class PlayerRequestModelValidator : AbstractValidator<PlayerRequestModel>
{
Expand All @@ -26,35 +42,88 @@ public PlayerRequestModelValidator(
_playerRepository = playerRepository;
var clock = timeProvider ?? TimeProvider.System;

RuleFor(player => player.FirstName).NotEmpty().WithMessage("FirstName is required.");
// "Create" rule set — POST /players
// Includes BeUniqueSquadNumber to prevent duplicate squad numbers on insert.
RuleSet(
"Create",
() =>
{
RuleFor(player => player.FirstName)
.NotEmpty()
.WithMessage("FirstName is required.");

RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required.");

RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required.");
RuleFor(player => player.SquadNumber)
.NotEmpty()
.WithMessage("SquadNumber is required.")
.GreaterThan(0)
.WithMessage("SquadNumber must be greater than 0.")
.MustAsync(BeUniqueSquadNumber)
.WithMessage("SquadNumber must be unique.");

RuleFor(player => player.SquadNumber)
.NotEmpty()
.WithMessage("SquadNumber is required.")
.GreaterThan(0)
.WithMessage("SquadNumber must be greater than 0.")
.MustAsync(BeUniqueSquadNumber)
.WithMessage("SquadNumber must be unique.");
RuleFor(player => player.AbbrPosition)
.NotEmpty()
.WithMessage("AbbrPosition is required.")
.Must(Position.IsValidAbbr)
.WithMessage("AbbrPosition is invalid.");

RuleFor(player => player.AbbrPosition)
.NotEmpty()
.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 < clock.GetUtcNow().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.");
}
);
}
);

When(
player => player.DateOfBirth.HasValue,
// "Update" rule set — PUT /players/squadNumber/{n}
// BeUniqueSquadNumber is intentionally omitted: on PUT the player being
// updated already exists in the database, so the check would always fail.
RuleSet(
"Update",
() =>
{
RuleFor(player => player.DateOfBirth)
.Must(date => date!.Value.Date < clock.GetUtcNow().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.");
RuleFor(player => player.FirstName)
.NotEmpty()
.WithMessage("FirstName is required.");

RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required.");

RuleFor(player => player.SquadNumber)
.NotEmpty()
.WithMessage("SquadNumber is required.")
.GreaterThan(0)
.WithMessage("SquadNumber must be greater than 0.");

RuleFor(player => player.AbbrPosition)
.NotEmpty()
.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 < clock.GetUtcNow().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.");
}
);
}
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
Expand Down Expand Up @@ -31,12 +32,17 @@ public async Task Post_Players_ValidationError_Returns400BadRequest()
var request = PlayerFakes.MakeRequestModelForCreate();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
validator
.Setup(validator => validator.ValidateAsync(request, It.IsAny<CancellationToken>()))
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(
new ValidationResult(
new List<ValidationFailure>
{
new("SquadNumber", "SquadNumber must be greater than 0.")
new("SquadNumber", "SquadNumber must be greater than 0."),
}
)
);
Expand All @@ -52,7 +58,7 @@ public async Task Post_Players_ValidationError_Returns400BadRequest()
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
Expand All @@ -75,7 +81,7 @@ public async Task Post_Players_Existing_Returns409Conflict()
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
Expand All @@ -92,7 +98,7 @@ public async Task Post_Players_Existing_Returns409Conflict()
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
Expand All @@ -116,7 +122,7 @@ public async Task Post_Players_NonExisting_Returns201Created()
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
Expand All @@ -136,7 +142,7 @@ public async Task Post_Players_NonExisting_Returns201Created()
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
Expand Down Expand Up @@ -303,12 +309,17 @@ public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest()
var controller = new PlayerController(service.Object, logger.Object, validator.Object);

validator
.Setup(validator => validator.ValidateAsync(request, It.IsAny<CancellationToken>()))
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(
new ValidationResult(
new List<ValidationFailure>
{
new("SquadNumber", "SquadNumber must be greater than 0.")
new("SquadNumber", "SquadNumber must be greater than 0."),
}
)
);
Expand All @@ -322,7 +333,7 @@ public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest()
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
Expand All @@ -344,7 +355,7 @@ public async Task Put_PlayerBySquadNumber_NonExisting_Returns404NotFound()
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
Expand All @@ -364,7 +375,7 @@ public async Task Put_PlayerBySquadNumber_NonExisting_Returns404NotFound()
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
Expand All @@ -389,7 +400,7 @@ public async Task Put_PlayerBySquadNumber_SquadNumberMismatch_Returns400BadReque
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
Expand Down Expand Up @@ -425,7 +436,7 @@ public async Task Put_PlayerBySquadNumber_Existing_Returns204NoContent()
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
Expand Down
Loading
Loading