Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
61 changes: 61 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# =============================================================================
# SonarCloud configuration
# https://docs.sonarsource.com/sonarcloud/advanced-setup/analysis-parameters/
#
# Project key and organization can be verified in SonarCloud under:
# Administration → General Settings
# =============================================================================

sonar.projectKey=nanotaboada_Dotnet.Samples.AspNetCore.WebApi
sonar.organization=nanotaboada

# Source encoding
sonar.sourceEncoding=UTF-8

# =============================================================================
# Sources and tests
# =============================================================================

sonar.sources=src/
sonar.tests=test/
sonar.test.inclusions=test/**/*.cs

# =============================================================================
# Global exclusions
# Keeps analysis focused on hand-written production code.
# =============================================================================

sonar.exclusions=\
**/obj/**,\
**/bin/**,\
**/Migrations/**

# =============================================================================
# Coverage exclusions
# Test files and generated/infrastructure code should not count against
# production code coverage metrics.
# =============================================================================

sonar.coverage.exclusions=\
test/**/*.cs,\
**/Migrations/**,\
**/Data/PlayerDbContext.cs

# =============================================================================
# Duplicate code (CPD) exclusions
# NOTE: // NOSONAR suppresses rule-based issues (bugs, code smells, security
# hotspots) but has no effect on CPD metrics. Files must be listed here to be
# excluded from duplicate code detection.
#
# test/**/*.cs — Fakes, Mocks, and Stubs are intentionally repetitive by
# design; similarity between arrange/act/assert blocks across tests is
# expected and harmless.
#
# **/Validators/PlayerRequestModelValidator.cs — the "Create" and "Update"
# rule sets share common rules by design; the duplication is intentional to
# keep each operation's validation self-contained and readable.
# =============================================================================

sonar.cpd.exclusions=\
test/**/*.cs,\
**/Validators/PlayerRequestModelValidator.cs
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
Loading
Loading