From 1d1dcf6bbe3ddd5747d96aa6dd07c3901d4ca06d Mon Sep 17 00:00:00 2001 From: Nano Taboada <87288+nanotaboada@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:37:23 -0300 Subject: [PATCH 1/3] refactor!: use GUID internally and expose SquadNumber as public identifier --- .codacy.yml | 1 + codecov.yml | 1 + scripts/run-migrations-and-copy-database.sh | 63 ++++++ .../Controllers/PlayerController.cs | 57 +++--- .../Data/IPlayerRepository.cs | 6 +- .../Data/IRepository.cs | 4 +- .../Data/PlayerData.cs | 180 +++++++++++++++--- .../Data/PlayerDbContext.cs | 21 ++ .../Data/PlayerRepository.cs | 7 +- .../Data/Repository.cs | 4 +- .../Data/players-sqlite3.db | Bin 12288 -> 28672 bytes .../Dotnet.Samples.AspNetCore.WebApi.csproj | 2 +- ... 20250414191223_InitialCreate.Designer.cs} | 15 +- ...ate.cs => 20250414191223_InitialCreate.cs} | 24 ++- .../20250414195445_SeedStarting11.Designer.cs | 69 +++++++ .../20250414195445_SeedStarting11.cs | 62 ++++++ .../PlayerDbContextModelSnapshot.cs | 13 +- .../Models/Player.cs | 2 +- .../Models/PlayerRequestModel.cs | 2 - .../Models/PlayerResponseModel.cs | 2 - .../Program.cs | 5 +- .../Services/IPlayerService.cs | 6 +- .../Services/PlayerService.cs | 18 +- .../Utilities/ApplicationBuilderExtensions.cs | 55 +++--- .../Utilities/DbContextUtils.cs | 35 +--- .../Validators/PlayerRequestModelValidator.cs | 17 +- .../Unit/PlayerControllerTests.cs | 92 ++++----- .../Unit/PlayerServiceTests.cs | 54 +++--- .../Utilities/PlayerFakes.cs | 60 +++--- 29 files changed, 627 insertions(+), 250 deletions(-) create mode 100755 scripts/run-migrations-and-copy-database.sh rename src/Dotnet.Samples.AspNetCore.WebApi/Migrations/{20240515182115_InitialCreate.Designer.cs => 20250414191223_InitialCreate.Designer.cs} (84%) rename src/Dotnet.Samples.AspNetCore.WebApi/Migrations/{20240515182115_InitialCreate.cs => 20250414191223_InitialCreate.cs} (77%) create mode 100644 src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.Designer.cs create mode 100644 src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.cs diff --git a/.codacy.yml b/.codacy.yml index 036a1e4..38b1635 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -30,3 +30,4 @@ exclude_paths: - '**/Utilities/**' # Helper extensions or static classes - '**/Validators/**' # FluentValidation validators - 'test/**/*' # Entire test suite (unit + integration) + - 'scripts/**/*' # Helper shell scripts diff --git a/codecov.yml b/codecov.yml index 2144c82..f289b69 100644 --- a/codecov.yml +++ b/codecov.yml @@ -50,6 +50,7 @@ ignore: - '**/*.md' - .*\/test\/.* + - .*\/scripts\/.* - .*\/Program\.cs - '**/LICENSE' - '**/README.md' diff --git a/scripts/run-migrations-and-copy-database.sh b/scripts/run-migrations-and-copy-database.sh new file mode 100755 index 0000000..f86a26d --- /dev/null +++ b/scripts/run-migrations-and-copy-database.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +DATA_FILE="players-sqlite3.db" + +PROJECT_ROOT_PATH="src/Dotnet.Samples.AspNetCore.WebApi" +PROJECT_BASE_PATH="$PROJECT_ROOT_PATH/bin/Debug/net8.0" + +SOURCE_FILE_PATH="$PROJECT_BASE_PATH/Data/$DATA_FILE" +TARGET_FILE_PATH="$PROJECT_ROOT_PATH/Data/$DATA_FILE" + +log() { + local emoji=$1 + local level=$2 + local message=$3 + local timestamp + timestamp=$(date +"%Y-%m-%d %H:%M:%S") + echo "$emoji [$timestamp] [$level] $message" +} + +# Check if the EF Core CLI tool is installed +if ! command -v dotnet ef &> /dev/null; then + log "❌" "ERROR" "'dotnet ef' not found. Install it with 'dotnet tool install --global dotnet-ef'" + exit 1 +fi + +# Ensure clean placeholder database file exists +log "✅" "INFO" "Resetting placeholder database at '$TARGET_FILE_PATH'" +rm -f "$TARGET_FILE_PATH" +touch "$TARGET_FILE_PATH" + +# Run the database migration +log "✅" "INFO" "Running EF Core database migration for project at '$PROJECT_ROOT_PATH'..." +dotnet ef database update --project "$PROJECT_ROOT_PATH" +if [ $? -ne 0 ]; then + log "❌" "ERROR" "Migration failed. See error above." + exit 1 +fi + +# Check and copy database +if [ -f "$SOURCE_FILE_PATH" ]; then + log "✅" "INFO" "Found database at '$SOURCE_FILE_PATH'" + log "✅" "INFO" "Copying to '$TARGET_FILE_PATH'..." + cp -f "$SOURCE_FILE_PATH" "$TARGET_FILE_PATH" + + if [ $? -eq 0 ]; then + log "✅" "INFO" "Database successfully copied to '$TARGET_FILE_PATH'" + else + log "❌" "ERROR" "Failed to copy the database file." + exit 1 + fi +else + log "⚠️" "WARNING" "Database file not found at '$SOURCE_FILE_PATH'." + log "⚠️" "WARNING" "Make sure the migration actually generated the file." + exit 1 +fi + +# Confirm destination file exists +if [ -f "$TARGET_FILE_PATH" ]; then + log "✅" "INFO" "Done. The database is now available at '$TARGET_FILE_PATH'" +else + log "⚠️" "WARNING" "Something went wrong. The destination file was not found." + exit 1 +fi diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs index 5c731b6..0b730df 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs @@ -2,6 +2,7 @@ using Dotnet.Samples.AspNetCore.WebApi.Models; using Dotnet.Samples.AspNetCore.WebApi.Services; using FluentValidation; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Dotnet.Samples.AspNetCore.WebApi.Controllers; @@ -45,11 +46,11 @@ public async Task PostAsync([FromBody] PlayerRequestModel player) return TypedResults.BadRequest(errors); } - if (await playerService.RetrieveByIdAsync(player.Id) != null) + if (await playerService.RetrieveBySquadNumberAsync(player.SquadNumber) != null) { logger.LogWarning( - "POST /players failed: Player with ID {Id} already exists", - player.Id + "POST /players failed: Player with Squad Number {SquadNumber} already exists", + player.SquadNumber ); return TypedResults.Conflict(); } @@ -58,8 +59,8 @@ public async Task PostAsync([FromBody] PlayerRequestModel player) logger.LogInformation("POST /players created: {@Player}", result); return TypedResults.CreatedAtRoute( - routeName: "GetById", - routeValues: new { id = result.Id }, + routeName: "GetBySquadNumber", + routeValues: new { squadNumber = result.Dorsal }, value: result ); } @@ -98,10 +99,12 @@ public async Task GetAsync() /// The ID of the Player /// OK /// Not Found - [HttpGet("{id:long}", Name = "GetById")] + [Authorize(Roles = "Admin")] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpGet("{id:Guid}", Name = "GetById")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetByIdAsync([FromRoute] long id) + public async Task GetByIdAsync([FromRoute] Guid id) { var player = await playerService.RetrieveByIdAsync(id); if (player != null) @@ -149,19 +152,23 @@ public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) * ---------------------------------------------------------------------- */ /// - /// Updates (entirely) a Player by its ID + /// Updates (entirely) a Player by its Squad Number /// - /// The ID of the Player + /// /// The PlayerRequestModel + /// The Squad Number of the Player /// No Content /// Bad Request /// Not Found - [HttpPut("{id}")] + [HttpPut("{squadNumber:int}")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task PutAsync([FromRoute] long id, [FromBody] PlayerRequestModel player) + public async Task PutAsync( + [FromRoute] int squadNumber, + [FromBody] PlayerRequestModel player + ) { var validation = await validator.ValidateAsync(player); if (!validation.IsValid) @@ -170,16 +177,20 @@ public async Task PutAsync([FromRoute] long id, [FromBody] PlayerReques .Errors.Select(error => new { error.PropertyName, error.ErrorMessage }) .ToArray(); - logger.LogWarning("PUT /players/{Id} validation failed: {@Errors}", id, errors); + logger.LogWarning( + "PUT /players/{squadNumber} validation failed: {@Errors}", + squadNumber, + errors + ); return TypedResults.BadRequest(errors); } - if (await playerService.RetrieveByIdAsync(id) == null) + if (await playerService.RetrieveBySquadNumberAsync(squadNumber) == null) { - logger.LogWarning("PUT /players/{Id} not found", id); + logger.LogWarning("PUT /players/{SquadNumber} not found", squadNumber); return TypedResults.NotFound(); } await playerService.UpdateAsync(player); - logger.LogInformation("PUT /players/{Id} updated: {@Player}", id, player); + logger.LogInformation("PUT /players/{SquadNumber} updated: {@Player}", squadNumber, player); return TypedResults.NoContent(); } @@ -188,25 +199,25 @@ public async Task PutAsync([FromRoute] long id, [FromBody] PlayerReques * ---------------------------------------------------------------------- */ /// - /// Deletes a Player by its ID + /// Deletes a Player by its Squad Number /// - /// The ID of the Player + /// The Squad Number of the Player /// No Content /// Not Found - [HttpDelete("{id:long}")] + [HttpDelete("{squadNumber:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteAsync([FromRoute] long id) + public async Task DeleteAsync([FromRoute] int squadNumber) { - if (await playerService.RetrieveByIdAsync(id) == null) + if (await playerService.RetrieveBySquadNumberAsync(squadNumber) == null) { - logger.LogWarning("DELETE /players/{Id} not found", id); + logger.LogWarning("DELETE /players/{SquadNumber} not found", squadNumber); return TypedResults.NotFound(); } else { - await playerService.DeleteAsync(id); - logger.LogInformation("DELETE /players/{Id} deleted", id); + await playerService.DeleteAsync(squadNumber); + logger.LogInformation("DELETE /players/{SquadNumber} deleted", squadNumber); return TypedResults.NoContent(); } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Data/IPlayerRepository.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Data/IPlayerRepository.cs index cb90fca..29a4131 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Data/IPlayerRepository.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Data/IPlayerRepository.cs @@ -14,8 +14,8 @@ public interface IPlayerRepository : IRepository /// /// The Squad Number of the Player to retrieve. /// - /// A ValueTask representing the asynchronous operation, containing the Player if found, - /// or null if no Player with the specified Squad Number exists. + /// A Task representing the asynchronous operation,containing the Player + /// if found, or null if no Player with the specified Squad Number exists. /// - ValueTask FindBySquadNumberAsync(int squadNumber); + Task FindBySquadNumberAsync(int squadNumber); } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Data/IRepository.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Data/IRepository.cs index 612c885..9924b37 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Data/IRepository.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Data/IRepository.cs @@ -29,7 +29,7 @@ public interface IRepository /// A ValueTask representing the asynchronous operation, containing the entity if found, /// or null if no entity with the specified ID exists. /// - ValueTask FindByIdAsync(long id); + ValueTask FindByIdAsync(Guid id); /// /// Updates an existing entity in the repository. @@ -43,5 +43,5 @@ public interface IRepository /// /// The unique identifier of the entity to remove. /// A Task representing the asynchronous operation. - Task RemoveAsync(long id); + Task RemoveAsync(Guid id); } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerData.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerData.cs index b1ac927..e386b37 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerData.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerData.cs @@ -12,7 +12,6 @@ public static List MakeStarting11() [ new() { - Id = 1, FirstName = "Damián", MiddleName = "Emiliano", LastName = "Martínez", @@ -26,7 +25,6 @@ public static List MakeStarting11() }, new() { - Id = 2, FirstName = "Nahuel", LastName = "Molina", DateOfBirth = new DateTime(1998, 4, 5, 0, 0, 0, DateTimeKind.Utc), @@ -39,7 +37,6 @@ public static List MakeStarting11() }, new() { - Id = 3, FirstName = "Cristian", MiddleName = "Gabriel", LastName = "Romero", @@ -53,7 +50,6 @@ public static List MakeStarting11() }, new() { - Id = 4, FirstName = "Nicolás", MiddleName = "Hernán Gonzalo", LastName = "Otamendi", @@ -67,7 +63,6 @@ public static List MakeStarting11() }, new() { - Id = 5, FirstName = "Nicolás", MiddleName = "Alejandro", LastName = "Tagliafico", @@ -81,7 +76,6 @@ public static List MakeStarting11() }, new() { - Id = 6, FirstName = "Ángel", MiddleName = "Fabián", LastName = "Di María", @@ -95,7 +89,6 @@ public static List MakeStarting11() }, new() { - Id = 7, FirstName = "Rodrigo", MiddleName = "Javier", LastName = "de Paul", @@ -109,7 +102,6 @@ public static List MakeStarting11() }, new() { - Id = 8, FirstName = "Enzo", MiddleName = "Jeremías", LastName = "Fernández", @@ -123,7 +115,6 @@ public static List MakeStarting11() }, new() { - Id = 9, FirstName = "Alexis", LastName = "Mac Allister", DateOfBirth = new DateTime(1998, 12, 23, 0, 0, 0, DateTimeKind.Utc), @@ -136,7 +127,6 @@ public static List MakeStarting11() }, new() { - Id = 10, FirstName = "Lionel", MiddleName = "Andrés", LastName = "Messi", @@ -150,7 +140,164 @@ public static List MakeStarting11() }, new() { - Id = 11, + FirstName = "Julián", + LastName = "Álvarez", + DateOfBirth = new DateTime(2000, 1, 30, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 9, + Position = Position.CentreForward.Text, + AbbrPosition = Position.CentreForward.Abbr, + Team = "Manchester City", + League = "Premier League", + Starting11 = true, + } + ]; + } + + public static List MakeStarting11WithId() + { + return + [ + new() + { + Id = Guid.Parse("f91b9671-cfd1-48d7-afb9-34e93568c9ee"), + FirstName = "Damián", + MiddleName = "Emiliano", + LastName = "Martínez", + DateOfBirth = new DateTime(1992, 9, 1, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 23, + Position = Position.Goalkeeper.Text, + AbbrPosition = Position.Goalkeeper.Abbr, + Team = "Aston Villa FC", + League = "Premier League", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("51ec988a-0d8b-42d9-84e4-5e17c93d8d50"), + FirstName = "Nahuel", + LastName = "Molina", + DateOfBirth = new DateTime(1998, 4, 5, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 26, + Position = Position.RightBack.Text, + AbbrPosition = Position.RightBack.Abbr, + Team = "Altético Madrid", + League = "La Liga", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("0969be24-086c-4c51-9c29-0280683b8133"), + FirstName = "Cristian", + MiddleName = "Gabriel", + LastName = "Romero", + DateOfBirth = new DateTime(1998, 4, 26, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 13, + Position = Position.CentreBack.Text, + AbbrPosition = Position.CentreBack.Abbr, + Team = "Tottenham Hotspur", + League = "Premier League", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("ac532709-4682-49db-acc2-395f61f405ab"), + FirstName = "Nicolás", + MiddleName = "Hernán Gonzalo", + LastName = "Otamendi", + DateOfBirth = new DateTime(1988, 2, 11, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 19, + Position = Position.CentreBack.Text, + AbbrPosition = Position.CentreBack.Abbr, + Team = "SL Benfica", + League = "Liga Portugal", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("dc052ee4-c69d-49da-a256-b8ec727f4d59"), + FirstName = "Nicolás", + MiddleName = "Alejandro", + LastName = "Tagliafico", + DateOfBirth = new DateTime(1992, 8, 30, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 3, + Position = Position.LeftBack.Text, + AbbrPosition = Position.LeftBack.Abbr, + Team = "Olympique Lyon", + League = "Ligue 1", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("6def9bb7-23c2-42b5-b37b-2e9b6fec31cd"), + FirstName = "Ángel", + MiddleName = "Fabián", + LastName = "Di María", + DateOfBirth = new DateTime(1988, 2, 13, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 11, + Position = Position.RightWinger.Text, + AbbrPosition = Position.RightWinger.Abbr, + Team = "SL Benfica", + League = "Liga Portugal", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("8ca911d9-ab32-4366-b2b1-cad5eb6f4bcc"), + FirstName = "Rodrigo", + MiddleName = "Javier", + LastName = "de Paul", + DateOfBirth = new DateTime(1994, 5, 23, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 7, + Position = Position.CentralMidfield.Text, + AbbrPosition = Position.CentralMidfield.Abbr, + Team = "Altético Madrid", + League = "La Liga", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("198c4774-9607-4e76-8475-ec2528af69d2"), + FirstName = "Enzo", + MiddleName = "Jeremías", + LastName = "Fernández", + DateOfBirth = new DateTime(2001, 1, 16, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 24, + Position = Position.CentralMidfield.Text, + AbbrPosition = Position.CentralMidfield.Abbr, + Team = "Chelsea FC", + League = "Premier League", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("06971ada-1b3d-4d4a-88f5-e2f35311b5aa"), + FirstName = "Alexis", + LastName = "Mac Allister", + DateOfBirth = new DateTime(1998, 12, 23, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 20, + Position = Position.CentralMidfield.Text, + AbbrPosition = Position.CentralMidfield.Abbr, + Team = "Liverpool FC", + League = "Premier League", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("df6f6bab-5efd-4518-80bb-09ef54435636"), + FirstName = "Lionel", + MiddleName = "Andrés", + LastName = "Messi", + DateOfBirth = new DateTime(1987, 6, 23, 0, 0, 0, DateTimeKind.Utc), + SquadNumber = 10, + Position = Position.RightWinger.Text, + AbbrPosition = Position.RightWinger.Abbr, + Team = "Inter Miami CF", + League = "Major League Soccer", + Starting11 = true, + }, + new() + { + Id = Guid.Parse("27cf4e29-67d5-4c3b-9cf8-7d3fa3942fcb"), FirstName = "Julián", LastName = "Álvarez", DateOfBirth = new DateTime(2000, 1, 30, 0, 0, 0, DateTimeKind.Utc), @@ -176,7 +323,6 @@ public static List CreateFromDeserializedJson() var json = """ [{ - "id": 1, "firstName": "Damián", "middleName": "Emiliano", "lastName": "Martínez", @@ -189,7 +335,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 2, "firstName": "Nahuel", "middleName": null, "lastName": "Molina", @@ -202,7 +347,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 3, "firstName": "Cristian", "middleName": "Gabriel", "lastName": "Romero", @@ -215,7 +359,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 4, "firstName": "Nicolás", "middleName": "Hernán Gonzalo", "lastName": "Otamendi", @@ -228,7 +371,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 5, "firstName": "Nicolás", "middleName": "Alejandro", "lastName": "Tagliafico", @@ -241,7 +383,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 6, "firstName": "Ángel", "middleName": "Fabián", "lastName": "Di María", @@ -254,7 +395,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 7, "firstName": "Rodrigo", "middleName": "Javier", "lastName": "de Paul", @@ -267,7 +407,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 8, "firstName": "Enzo", "middleName": "Jeremías", "lastName": "Fernández", @@ -280,7 +419,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 9, "firstName": "Alexis", "middleName": null, "lastName": "Mac Allister", @@ -293,7 +431,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 10, "firstName": "Lionel", "middleName": "Andrés", "lastName": "Messi", @@ -306,7 +443,6 @@ public static List CreateFromDeserializedJson() "starting11": true }, { - "id": 11, "firstName": "Julián", "middleName": null, "lastName": "Álvarez", diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs index 404818c..8752ba1 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs @@ -14,4 +14,25 @@ public class PlayerDbContext(DbContextOptions options) : DbCont /// corresponds to a table in the database, allowing CRUD operations and LINQ queries. /// public DbSet Players => Set(); + + /// + /// Configures the model for the Player entity. + /// This method is called by the runtime to configure the model for the context. + /// + /// The model builder. + /// + /// This method is used to configure the model and relationships using the Fluent API. + /// It is called when the model for a derived context is being created. + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(player => player.Id); + entity.Property(player => player.Id).ValueGeneratedOnAdd(); + entity.HasIndex(player => player.SquadNumber).IsUnique(); + }); + } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerRepository.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerRepository.cs index 0c577da..f80a724 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerRepository.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerRepository.cs @@ -7,6 +7,11 @@ public sealed class PlayerRepository(PlayerDbContext dbContext) : Repository(dbContext), IPlayerRepository { - public async ValueTask FindBySquadNumberAsync(int squadNumber) => + public async Task FindBySquadNumberAsync(int squadNumber) => await _dbSet.FirstOrDefaultAsync(p => p.SquadNumber == squadNumber); + + public async Task SquadNumberExistsAsync(int squadNumber) + { + return await dbContext.Players.AnyAsync(p => p.SquadNumber == squadNumber); + } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Data/Repository.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Data/Repository.cs index e77f7f9..330f273 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Data/Repository.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Data/Repository.cs @@ -15,7 +15,7 @@ public async Task AddAsync(T entity) public async Task> GetAllAsync() => await _dbSet.AsNoTracking().ToListAsync(); - public async ValueTask FindByIdAsync(long id) => await _dbSet.FindAsync(id); + public async ValueTask FindByIdAsync(Guid id) => await _dbSet.FindAsync(id); public async Task UpdateAsync(T entity) { @@ -23,7 +23,7 @@ public async Task UpdateAsync(T entity) await dbContext.SaveChangesAsync(); } - public async Task RemoveAsync(long id) + public async Task RemoveAsync(Guid id) { var entity = await _dbSet.FindAsync(id); if (entity != null) diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Data/players-sqlite3.db b/src/Dotnet.Samples.AspNetCore.WebApi/Data/players-sqlite3.db index 5ec30082aca630f2c3b5ad659a2bf698cf492a75..c3208ada7b0e48185b3811e24e3188f76d9dff3a 100644 GIT binary patch literal 28672 zcmeI4&u`qu6~{@fr1kDfVuYkn9KkT9)JPWYxcvRYD1!2*EPGc{q*dY^0)f(wCB|IR z=5pQ0C=hIl_S}CV#~gwldg`S)2tDQ0Q;HTvi?o-VS`_H1m%fo!zr2#YhaOzy4ZvA0 z`DW(L=Y1am&XT+FZX+(J_#n$WQ6Z|_D;&>rKNdxf<0fD$!**Q8VTWzUg+JbT&gTSo z>*?cZLE|QCf8m55&CJexJJp*02EJfH0!RP}AOR$R1dsp{KmthMe@&n}S)Q}!`Tq4d zZPSmJ?{BRo(LT+)TN{siQG2D=*{1p6P}y4#oK_%it}MU18Hmd(esEu0ytv7txVj=P z4jL|sZ!ezdZn6He+IV?xexCn%r--%_Ixri2Obmu;Ic_63+ecM|vbfw{6kEalmbkJC z+h(I7daElNt#xO4r3Kdajwi4vuB|UOo%IjId%*`kIBFZldAC@JI`p^;Xg)aAjN9#m zUVN?*UDnEv3cC8hjq_q}xXZIs23oCPDOf-8dNAx-){ToeONU!K+uQl&XInJt47X{} zXt#I4ZFXD7pmKTQ{sn*HFOMn!prUGhximTVp?MjG3rE?KFFefdfv>Pc?K^yVPL}yk$pxqy+0MiB&DoK$=Rsly zKxq0j6P}gTv&k?#!1=rwbk&MGv|B`-N5`5PP?@gQr`bea;W*(>YzGSxKmter2_OL^ zfCP{L5m3JVfI0!RP}AOR$R1dsp{Kmter2_OL^@UjrNF;-fdWB2uvCTp^-Ynr~bL23K+ zF%bKfbW8g_Ukc8c$%>+GEvN7}N|bmxg(q7Mt*S7qG2yqI@K>m?AOR$R1dsp{Kmter z2_OL^fCP{L5R4|F2bBoUkv9&-`@yU(=7L{yCM@ z{$AUvehr7QAOR$R1dsp{Kmter2_S(N66kZasm*!rn#$K_Z`Lb{>4jRL*u*e>ooJrw z659(cV)|<6z=Nnt=(%@$Nqq2GdeBdvL^*w`NRmV(nW&O@_>?L<2<+KyS{?SX){fX+-=GFN*mA^4tS1W#Kgof+5L=QrrXu50> zOLAQz*+B@upQ*Z`8Vz`Bm?loz&JRBCHfgsT%eG|_$sme);ze~m-rXz2_u*}8ny;~pwzEU?{#XV4;BKfqnY~f3NQQ07j_(lJRlyHmbBJYyIti3e z)m2$`b;ofM`f=QCMmwUDB=Ej9&1KuRh^(CTyHx zd_8cDP;)(RJ!|LjZgw|%0ZlH%K-{N zw1}h-`BWRV^V-;Gh%QYZ#5)liS6s{TqPH6*pG-_`USqMiK3ku!_@1OIL7)*2s1wZw zX*h}wAgsVM6*JU)-Clu7BnO{$K^PxJAkVB7?Iv*qJ!gPIA(C}APf88?pdfCv^RVHr zCi|U7@#7v98~a%bZor=0FOP!MqLJ*g-^l6l2tME!WP1`I_j#$Vis(5vft;`S4y#z&nq6D7IUuB;B5R$_-Y&TFK zwwB?+bwZcnIsumrNwFluQe8_{RWFB(h@*5V+Rj1t>sg29i~@|W5#>~s)91!-Wko^L zy{IGJ$%^iyUVc75x??Oy!HoqbOI;2;n2?jqstM&Fl()c;TfU4Uhm5Kmter2_OL^fCP{L z5fH0+AayftRtPSX#)pw~44 zO+XXS1T+CnKoigeGyzRO6Zqc~INKkam|t0my$U3*GSXuBgox;88MH1v-pH17=}Hb& z(ity@4lk`7qC1mjlKM$=r&!75bLFq$&pH*6EaHG9%}Vaa3M!UhJn=lW7Es@3LEY4M0k%ER~Q<7!pxUB4^|4&Dhdf*b9A z7F}V9g`{D_b(3bMRPu7^B24d<&q_UrXL-0M60uk0GR!`%VTj58Y*GURfIEeB`5Ahg zdv-_Fk~L|m-h686Ph<9v+3d_;Ge}@W7!NSjO7jRf>l1LJPvQ(bz2@+6v_PTvoeKKw=B<8nbt8>kIwnP%iy8WnI2r5U5@Xzu3bhV7=uxv`s_^p#3gmS21=U3}^Jks8Y5`SVZuMPIUr2 zTu3M#J7Jc&FKC1B;$y(-6SRlh4ANP*oo`3dnHUvGOV)WKX^o=-hCsfDkw+W&a9kO) zq>Neq%Iv%Z&TJc3Rkb{%uvI`-V9ti^q-^(!Ia8O-@ecMqlp$fA*02hU_PCJk24;t! z#EnHoYiZ6(m_;}N>%5M@n&%h_fLCyXQ4DW!NG4@F8-uF6sDUw$)TN!lwHIEd#7=@1 zJ!%u=o$wHJ;K^;?1ckd$>nkdUfx@Gn6)4ZcQ_Oft!bgqJSC!UFCN=EK=lyzCI@!!M zxzxu%DY2lGzOl@#fSRH>%;Tznnkn;u2$coIU$;|Ne4ieQU*S@cu!#foh|8$e7Q+=* zxuMD!be5I#Mcix?R^W_=;6*RIbAR~wUv|CdQYvSz@tVY-o)*FD - + PreserveNewest true diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20240515182115_InitialCreate.Designer.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414191223_InitialCreate.Designer.cs similarity index 84% rename from src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20240515182115_InitialCreate.Designer.cs rename to src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414191223_InitialCreate.Designer.cs index 865575a..6567cb1 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20240515182115_InitialCreate.Designer.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414191223_InitialCreate.Designer.cs @@ -11,34 +11,31 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Migrations { [DbContext(typeof(PlayerDbContext))] - [Migration("20240515182115_InitialCreate")] + [Migration("20250414191223_InitialCreate")] partial class InitialCreate { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("TEXT"); b.Property("AbbrPosition") - .IsRequired() .HasColumnType("TEXT"); b.Property("DateOfBirth") .HasColumnType("TEXT"); b.Property("FirstName") - .IsRequired() .HasColumnType("TEXT"); b.Property("LastName") - .IsRequired() .HasColumnType("TEXT"); b.Property("League") @@ -48,7 +45,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("Position") - .IsRequired() .HasColumnType("TEXT"); b.Property("SquadNumber") @@ -62,6 +58,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("SquadNumber") + .IsUnique(); + b.ToTable("Players"); }); #pragma warning restore 612, 618 diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20240515182115_InitialCreate.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414191223_InitialCreate.cs similarity index 77% rename from src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20240515182115_InitialCreate.cs rename to src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414191223_InitialCreate.cs index 72f346b..33cb777 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20240515182115_InitialCreate.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414191223_InitialCreate.cs @@ -15,16 +15,14 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "Players", columns: table => new { - Id = table - .Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - FirstName = table.Column(type: "TEXT", nullable: false), + Id = table.Column(type: "TEXT", nullable: false), + FirstName = table.Column(type: "TEXT", nullable: true), MiddleName = table.Column(type: "TEXT", nullable: true), - LastName = table.Column(type: "TEXT", nullable: false), + LastName = table.Column(type: "TEXT", nullable: true), DateOfBirth = table.Column(type: "TEXT", nullable: true), SquadNumber = table.Column(type: "INTEGER", nullable: false), - Position = table.Column(type: "TEXT", nullable: false), - AbbrPosition = table.Column(type: "TEXT", nullable: false), + Position = table.Column(type: "TEXT", nullable: true), + AbbrPosition = table.Column(type: "TEXT", nullable: true), Team = table.Column(type: "TEXT", nullable: true), League = table.Column(type: "TEXT", nullable: true), Starting11 = table.Column(type: "INTEGER", nullable: false) @@ -32,14 +30,20 @@ protected override void Up(MigrationBuilder migrationBuilder) constraints: table => { table.PrimaryKey("PK_Players", x => x.Id); - } - ); + }); + + migrationBuilder.CreateIndex( + name: "IX_Players_SquadNumber", + table: "Players", + column: "SquadNumber", + unique: true); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable(name: "Players"); + migrationBuilder.DropTable( + name: "Players"); } } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.Designer.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.Designer.cs new file mode 100644 index 0000000..4afc29e --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Dotnet.Samples.AspNetCore.WebApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations +{ + [DbContext(typeof(PlayerDbContext))] + [Migration("20250414195445_SeedStarting11")] + partial class SeedStarting11 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AbbrPosition") + .HasColumnType("TEXT"); + + b.Property("DateOfBirth") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("League") + .HasColumnType("TEXT"); + + b.Property("MiddleName") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("TEXT"); + + b.Property("SquadNumber") + .HasColumnType("INTEGER"); + + b.Property("Starting11") + .HasColumnType("INTEGER"); + + b.Property("Team") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SquadNumber") + .IsUnique(); + + b.ToTable("Players"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.cs new file mode 100644 index 0000000..aa4c9ff --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.cs @@ -0,0 +1,62 @@ +using Dotnet.Samples.AspNetCore.WebApi.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Dotnet.Samples.AspNetCore.WebApi.Migrations +{ + /// + public partial class SeedStarting11 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + var starting11 = PlayerData.MakeStarting11WithId(); + + foreach (var player in starting11) + { + migrationBuilder.InsertData( + table: "Players", + columns: + [ + "Id", + "FirstName", + "MiddleName", + "LastName", + "DateOfBirth", + "SquadNumber", + "Position", + "AbbrPosition", + "Team", + "League", + "Starting11" + ], + values: new object[] + { + player.Id, + player.FirstName, + player.MiddleName, + player.LastName, + player.DateOfBirth, + player.SquadNumber, + player.Position, + player.AbbrPosition, + player.Team, + player.League, + player.Starting11 + } + ); + } + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + var starting11 = PlayerData.MakeStarting11WithId(); + foreach (var player in starting11) + { + migrationBuilder.DeleteData(table: "Players", keyColumn: "Id", keyValue: player.Id); + } + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs index 347b8e0..79ae23e 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs @@ -15,27 +15,24 @@ partial class PlayerDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("TEXT"); b.Property("AbbrPosition") - .IsRequired() .HasColumnType("TEXT"); b.Property("DateOfBirth") .HasColumnType("TEXT"); b.Property("FirstName") - .IsRequired() .HasColumnType("TEXT"); b.Property("LastName") - .IsRequired() .HasColumnType("TEXT"); b.Property("League") @@ -45,7 +42,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("Position") - .IsRequired() .HasColumnType("TEXT"); b.Property("SquadNumber") @@ -59,6 +55,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("SquadNumber") + .IsUnique(); + b.ToTable("Players"); }); #pragma warning restore 612, 618 diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Models/Player.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Models/Player.cs index 1dfef0c..2cb144c 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Models/Player.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Models/Player.cs @@ -10,7 +10,7 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Models; /// public class Player { - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); public string? FirstName { get; set; } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerRequestModel.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerRequestModel.cs index 8f9697c..3735482 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerRequestModel.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerRequestModel.cs @@ -12,8 +12,6 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Models; /// public class PlayerRequestModel { - public long Id { get; set; } - public string? FirstName { get; set; } public string? MiddleName { get; set; } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerResponseModel.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerResponseModel.cs index 6fd67bf..a285228 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerResponseModel.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerResponseModel.cs @@ -10,8 +10,6 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Models; /// public class PlayerResponseModel { - public long Id { get; set; } - public string? FullName { get; set; } public string? Birth { get; set; } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs index 9d36e20..331a918 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -30,6 +30,7 @@ * Services * -------------------------------------------------------------------------- */ builder.Services.AddControllers(); + builder.Services.AddDbContextPool(options => { var dataSource = Path.Combine(AppContext.BaseDirectory, "Data", "players-sqlite3.db"); @@ -38,8 +39,6 @@ { options.EnableSensitiveDataLogging(); options.LogTo(Log.Logger.Information, LogLevel.Information); - // https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-9.0/whatsnew#improved-data-seeding - options.UseAsyncSeeding(DbContextUtils.SeedAsync); } }); @@ -88,4 +87,6 @@ // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#endpoints app.MapControllers(); +await app.InitData(); + await app.RunAsync(); diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Services/IPlayerService.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Services/IPlayerService.cs index 45f0e8e..b5d39a2 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Services/IPlayerService.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Services/IPlayerService.cs @@ -30,7 +30,7 @@ public interface IPlayerService /// A Task representing the asynchronous operation, containing the Player if found, /// or null if not. /// - public Task RetrieveByIdAsync(long id); + public Task RetrieveByIdAsync(Guid id); /// /// Retrieves a Player from the repository by its Squad Number. @@ -52,8 +52,8 @@ public interface IPlayerService /// /// Removes an existing Player from the repository. /// - /// The ID of the Player to delete. + /// The Squad Number of the Player to delete. /// A Task representing the asynchronous operation. - public Task DeleteAsync(long id); + public Task DeleteAsync(int squadNumber); } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Services/PlayerService.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Services/PlayerService.cs index c8ef34c..ea446e8 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Services/PlayerService.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Services/PlayerService.cs @@ -90,7 +90,7 @@ public async Task> RetrieveAsync() } } - public async Task RetrieveByIdAsync(long id) + public async Task RetrieveByIdAsync(Guid id) { var player = await playerRepository.FindByIdAsync(id); return player is not null ? mapper.Map(player) : null; @@ -108,7 +108,10 @@ public async Task> RetrieveAsync() public async Task UpdateAsync(PlayerRequestModel playerRequestModel) { - if (await playerRepository.FindByIdAsync(playerRequestModel.Id) is Player player) + if ( + await playerRepository.FindBySquadNumberAsync(playerRequestModel.SquadNumber) + is Player player + ) { mapper.Map(playerRequestModel, player); await playerRepository.UpdateAsync(player); @@ -125,12 +128,15 @@ public async Task UpdateAsync(PlayerRequestModel playerRequestModel) * Delete * ---------------------------------------------------------------------- */ - public async Task DeleteAsync(long id) + public async Task DeleteAsync(int squadNumber) { - if (await playerRepository.FindByIdAsync(id) is not null) + if (await playerRepository.FindBySquadNumberAsync(squadNumber) is Player player) { - await playerRepository.RemoveAsync(id); - logger.LogInformation("Player with Id {Id} removed from Repository", id); + await playerRepository.RemoveAsync(player.Id); + logger.LogInformation( + "Player with Id {SquadNumber} removed from Repository", + squadNumber + ); memoryCache.Remove(CacheKey_RetrieveAsync); logger.LogInformation( "Removed objects from Cache with Key: {Key}", diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/ApplicationBuilderExtensions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/ApplicationBuilderExtensions.cs index c9e7783..7572beb 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/ApplicationBuilderExtensions.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/ApplicationBuilderExtensions.cs @@ -1,38 +1,45 @@ using Dotnet.Samples.AspNetCore.WebApi.Data; using Microsoft.EntityFrameworkCore; -namespace Dotnet.Samples.AspNetCore.WebApi.Utilities; - -public static class ApplicationBuilderExtensions +namespace Dotnet.Samples.AspNetCore.WebApi.Utilities { /// - /// Async extension method to populate the database with initial data + /// Provides extension methods for to + /// simplify database seeding operations. /// - public static async Task SeedDbContextAsync(this IApplicationBuilder app) + public static class ApplicationBuilderExtensions { - using var scope = app.ApplicationServices.CreateScope(); - var services = scope.ServiceProvider; - var logger = services.GetRequiredService>(); - var dbContext = services.GetRequiredService(); - - try + /// + /// Ensures the SQLite database file exists and contains tables. + /// If the database is newly created, it seeds it with initial data. + /// Does not apply or validate migrations. + /// + /// The instance. + public static async Task InitData(this IApplicationBuilder app) { - await dbContext.Database.EnsureCreatedAsync(); + using var scope = app.ApplicationServices.CreateScope(); + var services = scope.ServiceProvider; + var logger = services.GetRequiredService>(); + var dbContext = services.GetRequiredService(); + try + { + await dbContext.Database.MigrateAsync(); + logger.LogInformation("Database successfully migrated."); - if (!await dbContext.Players.AnyAsync()) + if (!await dbContext.Players.AnyAsync()) + { + DbContextUtils.Seed(dbContext); + logger.LogInformation("DbContext successfully seeded."); + } + } + catch (Exception exception) { - await dbContext.Players.AddRangeAsync(PlayerData.MakeStarting11()); - await dbContext.SaveChangesAsync(); - logger.LogInformation("Successfully seeded database with initial data."); + logger.LogError(exception, "An error occurred while initializing the database."); + throw new InvalidOperationException( + "Failed to initialize the database.", + exception + ); } } - catch (Exception exception) - { - logger.LogError(exception, "An error occurred while seeding the database"); - throw new InvalidOperationException( - "An error occurred while seeding the database", - exception - ); - } } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/DbContextUtils.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/DbContextUtils.cs index 438050d..259001e 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/DbContextUtils.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/DbContextUtils.cs @@ -1,42 +1,15 @@ using Dotnet.Samples.AspNetCore.WebApi.Data; -using Microsoft.EntityFrameworkCore; namespace Dotnet.Samples.AspNetCore.WebApi.Utilities; public static class DbContextUtils { - /// - /// Seeds the database with initial data if empty. - /// - /// - /// This method checks if the database is empty and seeds it with initial data. - /// It is designed to be used with UseAsyncSeeding. - /// - /// The database context to seed. - /// Unused parameter, required by the UseAsyncSeeding API. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous seeding operation. - public static Task SeedAsync(DbContext context, bool _, CancellationToken cancellationToken) + public static void Seed(PlayerDbContext context) { - if (context is not PlayerDbContext playerDbContext) + if (!context.Players.Any()) { - throw new ArgumentException( - $"Expected context of type {nameof(PlayerDbContext)}, but got {context.GetType().Name}", - nameof(context) - ); - } - return SeedPlayersAsync(playerDbContext, cancellationToken); - } - - private static async Task SeedPlayersAsync( - PlayerDbContext dbContext, - CancellationToken cancellationToken - ) - { - if (!await dbContext.Players.AnyAsync(cancellationToken)) - { - await dbContext.Players.AddRangeAsync(PlayerData.MakeStarting11(), cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + context.Players.AddRange(PlayerData.MakeStarting11()); + context.SaveChanges(); } } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs index d7192b0..b107df4 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs @@ -1,3 +1,4 @@ +using Dotnet.Samples.AspNetCore.WebApi.Data; using Dotnet.Samples.AspNetCore.WebApi.Enums; using Dotnet.Samples.AspNetCore.WebApi.Models; using FluentValidation; @@ -15,8 +16,12 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Validators; /// public class PlayerRequestModelValidator : AbstractValidator { - public PlayerRequestModelValidator() + private readonly IPlayerRepository _playerRepository; + + public PlayerRequestModelValidator(IPlayerRepository playerRepository) { + _playerRepository = playerRepository; + RuleFor(player => player.FirstName).NotEmpty().WithMessage("FirstName is required."); RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required."); @@ -25,7 +30,10 @@ public PlayerRequestModelValidator() .NotEmpty() .WithMessage("SquadNumber is required.") .GreaterThan(0) - .WithMessage("SquadNumber must be greater than 0."); + .WithMessage("SquadNumber must be greater than 0.") + .MustAsync(BeUniqueSquadNumber) + .WithMessage("SquadNumber must be unique."); + ; RuleFor(player => player.AbbrPosition) .NotEmpty() @@ -33,4 +41,9 @@ public PlayerRequestModelValidator() .Must(Position.IsValidAbbr) .WithMessage("AbbrPosition is invalid."); } + + private async Task BeUniqueSquadNumber( + int squadNumber, + CancellationToken cancellationToken + ) => (await _playerRepository.FindBySquadNumberAsync(squadNumber)) is null; } diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs index 530d688..847f85e 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs @@ -46,7 +46,7 @@ public async Task GivenPostAsync_WhenValidatorReturnsErrors_ThenResponseStatusCo var result = await controller.PostAsync(request); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Never); + service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Never); service.Verify(service => service.CreateAsync(It.IsAny()), Times.Never); validator.Verify( validator => @@ -65,14 +65,14 @@ public async Task GivenPostAsync_WhenValidatorReturnsErrors_ThenResponseStatusCo [Fact] [Trait("Category", "Unit")] - public async Task GivenPostAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_ThenResponseStatusCodeShouldBe409Conflict() + public async Task GivenPostAsync_WhenServiceRetrieveBySquadNumberAsyncReturnsPlayer_ThenResponseStatusCodeShouldBe409Conflict() { // Arrange var request = PlayerFakes.MakeRequestModelForCreate(); var response = PlayerFakes.MakeResponseModelForCreate(); var (service, logger, validator) = PlayerMocks.InitControllerMocks(); service - .Setup(service => service.RetrieveByIdAsync(It.IsAny())) + .Setup(service => service.RetrieveBySquadNumberAsync(request.SquadNumber)) .ReturnsAsync(response); validator .Setup(validator => @@ -89,7 +89,7 @@ public async Task GivenPostAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_ThenR var result = await controller.PostAsync(request); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); + service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); service.Verify(service => service.CreateAsync(It.IsAny()), Times.Never); validator.Verify( validator => @@ -108,15 +108,14 @@ public async Task GivenPostAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_ThenR [Fact] [Trait("Category", "Unit")] - public async Task GivenPostAsync_WhenServiceRetrieveByIdAsyncReturnsNull_ThenResponseStatusCodeShouldBe201Created() + public async Task GivenPostAsync_WhenServiceRetrieveBySquadNumberAsyncReturnsNull_ThenResponseStatusCodeShouldBe201Created() { // Arrange - var id = 999; var request = PlayerFakes.MakeRequestModelForCreate(); var response = PlayerFakes.MakeResponseModelForCreate(); var (service, logger, validator) = PlayerMocks.InitControllerMocks(); service - .Setup(service => service.RetrieveByIdAsync(id)) + .Setup(service => service.RetrieveBySquadNumberAsync(request.SquadNumber)) .ReturnsAsync(null as PlayerResponseModel); service.Setup(service => service.CreateAsync(request)).ReturnsAsync(response); validator @@ -137,7 +136,7 @@ public async Task GivenPostAsync_WhenServiceRetrieveByIdAsyncReturnsNull_ThenRes var result = await controller.PostAsync(request); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); + service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); service.Verify(service => service.CreateAsync(It.IsAny()), Times.Once); validator.Verify( validator => @@ -213,7 +212,7 @@ public async Task GivenGetAsync_WhenServiceRetrieveAsyncReturnsEmptyList_ThenRes public async Task GivenGetByIdAsync_WhenServiceRetrieveByIdAsyncReturnsNull_ThenResponseStatusCodeShouldBe404NotFound() { // Arrange - var id = 999; + var id = Guid.NewGuid(); var (service, logger, validator) = PlayerMocks.InitControllerMocks(); service .Setup(service => service.RetrieveByIdAsync(id)) @@ -225,7 +224,7 @@ public async Task GivenGetByIdAsync_WhenServiceRetrieveByIdAsyncReturnsNull_Then var result = await controller.GetByIdAsync(id); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); + service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); if (result is NotFound httpResult) { httpResult.Should().NotBeNull().And.BeOfType(); @@ -238,8 +237,8 @@ public async Task GivenGetByIdAsync_WhenServiceRetrieveByIdAsyncReturnsNull_Then public async Task GivenGetByIdAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_ThenResponseStatusCodeShouldBe200Ok() { // Arrange - var id = 1; - var response = PlayerFakes.MakeResponseModelForRetrieve(id); + var id = Guid.NewGuid(); + var response = PlayerFakes.MakeResponseModelForRetrieve(10); var (service, logger, validator) = PlayerMocks.InitControllerMocks(); service.Setup(service => service.RetrieveByIdAsync(id)).ReturnsAsync(response); @@ -249,7 +248,7 @@ public async Task GivenGetByIdAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_Th var result = await controller.GetByIdAsync(id); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); + service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); if (result is Ok httpResult) { httpResult.Should().NotBeNull().And.BeOfType>(); @@ -290,7 +289,7 @@ public async Task GivenGetBySquadNumberAsync_WhenServiceRetrieveBySquadNumberAsy { // Arrange var squadNumber = 10; - var response = PlayerFakes.MakeResponseModelForRetrieve(1); + var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber); var (service, logger, validator) = PlayerMocks.InitControllerMocks(); service .Setup(service => service.RetrieveBySquadNumberAsync(squadNumber)) @@ -321,9 +320,9 @@ public async Task GivenGetBySquadNumberAsync_WhenServiceRetrieveBySquadNumberAsy public async Task GivenPutAsync_WhenValidatorReturnsErrors_ThenResponseStatusCodeShouldBe400BadRequest() { // Arrange - var id = 1; - var request = PlayerFakes.MakeRequestModelForUpdate(id); - request.SquadNumber = -99; // Invalid Squad Number + var squadNumber = 20; + var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber); + request.SquadNumber = -999; // Invalid Squad Number var (service, logger, validator) = PlayerMocks.InitControllerMocks(); var controller = new PlayerController(service.Object, logger.Object, validator.Object); @@ -340,10 +339,10 @@ public async Task GivenPutAsync_WhenValidatorReturnsErrors_ThenResponseStatusCod ); // Act - var result = await controller.PutAsync(id, request); + var result = await controller.PutAsync(squadNumber, request); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Never); + service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Never); service.Verify(service => service.UpdateAsync(It.IsAny()), Times.Never); validator.Verify( validator => @@ -362,13 +361,13 @@ public async Task GivenPutAsync_WhenValidatorReturnsErrors_ThenResponseStatusCod [Fact] [Trait("Category", "Unit")] - public async Task GivenPutAsync_WhenServiceRetrieveByIdAsyncReturnsNull_ThenResponseStatusCodeShouldBe404NotFound() + public async Task GivenPutAsync_WhenServiceRetrieveBySquadNumberAsyncReturnsNull_ThenResponseStatusCodeShouldBe404NotFound() { // Arrange - var id = 999; + var squadNumber = 999; var (service, logger, validator) = PlayerMocks.InitControllerMocks(); service - .Setup(service => service.RetrieveByIdAsync(id)) + .Setup(service => service.RetrieveBySquadNumberAsync(squadNumber)) .ReturnsAsync(null as PlayerResponseModel); validator .Setup(validator => @@ -382,10 +381,10 @@ public async Task GivenPutAsync_WhenServiceRetrieveByIdAsyncReturnsNull_ThenResp var controller = new PlayerController(service.Object, logger.Object, validator.Object); // Act - var result = await controller.PutAsync(id, It.IsAny()); + var result = await controller.PutAsync(squadNumber, It.IsAny()); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); + service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); service.Verify(service => service.UpdateAsync(It.IsAny()), Times.Never); validator.Verify( validator => @@ -404,16 +403,18 @@ public async Task GivenPutAsync_WhenServiceRetrieveByIdAsyncReturnsNull_ThenResp [Fact] [Trait("Category", "Unit")] - public async Task GivenPutAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_ThenResponseStatusCodeShouldBe204NoContent() + public async Task GivenPutAsync_WhenServiceRetrieveBySquadNumberAsyncReturnsPlayer_ThenResponseStatusCodeShouldBe204NoContent() { // Arrange - var id = 1; - var request = PlayerFakes.MakeRequestModelForUpdate(id); - var response = PlayerFakes.MakeResponseModelForUpdate(id); + var squadNumber = 23; + var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber); + var response = PlayerFakes.MakeResponseModelForUpdate(squadNumber); request.FirstName = "Emiliano"; request.MiddleName = string.Empty; var (service, logger, validator) = PlayerMocks.InitControllerMocks(); - service.Setup(service => service.RetrieveByIdAsync(id)).ReturnsAsync(response); + service + .Setup(service => service.RetrieveBySquadNumberAsync(squadNumber)) + .ReturnsAsync(response); service.Setup(service => service.UpdateAsync(request)); validator .Setup(validator => @@ -427,10 +428,10 @@ public async Task GivenPutAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_ThenRe var controller = new PlayerController(service.Object, logger.Object, validator.Object); // Act - var result = await controller.PutAsync(id, request); + var result = await controller.PutAsync(squadNumber, request); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); + service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); service.Verify(service => service.UpdateAsync(It.IsAny()), Times.Once); if (result is NoContent httpResult) { @@ -445,23 +446,23 @@ public async Task GivenPutAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_ThenRe [Fact] [Trait("Category", "Unit")] - public async Task GivenDeleteAsync_WhenServiceRetrieveByIdAsyncReturnsNull_ThenResponseStatusCodeShouldBe404NotFound() + public async Task GivenDeleteAsync_WhenServiceRetrieveBySquadNumberAsyncReturnsNull_ThenResponseStatusCodeShouldBe404NotFound() { // Arrange - var id = 999; + var squadNumber = 999; var (service, logger, validator) = PlayerMocks.InitControllerMocks(); service - .Setup(service => service.RetrieveByIdAsync(id)) + .Setup(service => service.RetrieveBySquadNumberAsync(squadNumber)) .ReturnsAsync(null as PlayerResponseModel); var controller = new PlayerController(service.Object, logger.Object, validator.Object); // Act - var result = await controller.DeleteAsync(id); + var result = await controller.DeleteAsync(squadNumber); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); - service.Verify(service => service.DeleteAsync(It.IsAny()), Times.Never); + service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); + service.Verify(service => service.DeleteAsync(It.IsAny()), Times.Never); if (result is NotFound httpResult) { httpResult.Should().NotBeNull().And.BeOfType(); @@ -471,24 +472,25 @@ public async Task GivenDeleteAsync_WhenServiceRetrieveByIdAsyncReturnsNull_ThenR [Fact] [Trait("Category", "Unit")] - public async Task GivenDeleteAsync_WhenServiceRetrieveByIdAsyncReturnsPlayer_ThenResponseStatusCodeShouldBe204NoContent() + public async Task GivenDeleteAsync_WhenServiceRetrieveBySquadNumberAsyncReturnsPlayer_ThenResponseStatusCodeShouldBe204NoContent() { // Arrange - var id = 2; + var squadNumber = 26; + var response = PlayerFakes.MakeResponseModelForUpdate(squadNumber); var (service, logger, validator) = PlayerMocks.InitControllerMocks(); service - .Setup(service => service.RetrieveByIdAsync(id)) - .ReturnsAsync(PlayerFakes.MakeResponseModelForUpdate(id)); - service.Setup(service => service.DeleteAsync(id)); + .Setup(service => service.RetrieveBySquadNumberAsync(squadNumber)) + .ReturnsAsync(response); + service.Setup(service => service.DeleteAsync(squadNumber)); var controller = new PlayerController(service.Object, logger.Object, validator.Object); // Act - var result = await controller.DeleteAsync(id); + var result = await controller.DeleteAsync(squadNumber); // Assert - service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); - service.Verify(service => service.DeleteAsync(It.IsAny()), Times.Once); + service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); + service.Verify(service => service.DeleteAsync(It.IsAny()), Times.Once); if (result is NoContent httpResult) { httpResult.Should().NotBeNull().And.BeOfType(); diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerServiceTests.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerServiceTests.cs index b31ec9e..b851ca7 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerServiceTests.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerServiceTests.cs @@ -128,7 +128,7 @@ public async Task GivenRetrieveAsync_WhenExecutedForTheSecondTime_ThenSecondExec public async Task GivenRetrieveByIdAsync_WhenRepositoryFindByIdAsyncReturnsNull_TheResultShouldBeNull() { // Arrange - var id = 999; + var id = Guid.NewGuid(); var (repository, logger, memoryCache, mapper) = PlayerMocks.InitServiceMocks(); repository.Setup(repository => repository.FindByIdAsync(id)).ReturnsAsync(null as Player); @@ -143,7 +143,7 @@ public async Task GivenRetrieveByIdAsync_WhenRepositoryFindByIdAsyncReturnsNull_ var result = await service.RetrieveByIdAsync(id); // Assert - repository.Verify(repository => repository.FindByIdAsync(It.IsAny()), Times.Once); + repository.Verify(repository => repository.FindByIdAsync(It.IsAny()), Times.Once); mapper.Verify(mapper => mapper.Map(It.IsAny()), Times.Never); result.Should().BeNull(); } @@ -153,9 +153,10 @@ public async Task GivenRetrieveByIdAsync_WhenRepositoryFindByIdAsyncReturnsNull_ public async Task GivenRetrieveByIdAsync_WhenRepositoryFindByIdAsyncReturnsPlayer_TheResultShouldBePlayer() { // Arrange - var id = 1; - var player = PlayerFakes.MakeFromStarting11ById(id); - var response = PlayerFakes.MakeResponseModelForRetrieve(id); + var id = Guid.NewGuid(); + var squadNumber = 10; + var player = PlayerFakes.MakeFromStarting11(squadNumber); + var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber); var (repository, logger, memoryCache, mapper) = PlayerMocks.InitServiceMocks(); repository.Setup(repository => repository.FindByIdAsync(id)).ReturnsAsync(player); mapper.Setup(mapper => mapper.Map(player)).Returns(response); @@ -171,7 +172,7 @@ public async Task GivenRetrieveByIdAsync_WhenRepositoryFindByIdAsyncReturnsPlaye var result = await service.RetrieveByIdAsync(id); // Assert - repository.Verify(repository => repository.FindByIdAsync(It.IsAny()), Times.Once); + repository.Verify(repository => repository.FindByIdAsync(It.IsAny()), Times.Once); mapper.Verify(mapper => mapper.Map(It.IsAny()), Times.Once); result.Should().BeOfType(); result.Should().BeEquivalentTo(response); @@ -212,10 +213,9 @@ public async Task GivenRetrieveBySquadNumberAsync_WhenRepositoryFindBySquadNumbe public async Task GivenRetrieveBySquadNumberAsync_WhenRepositoryFindBySquadNumberAsyncReturnsPlayer_ThenResultShouldBePlayer() { // Arrange - var id = 1; - var player = PlayerFakes.MakeFromStarting11ById(id); - var squadNumber = player.SquadNumber; - var response = PlayerFakes.MakeResponseModelForRetrieve(id); + var squadNumber = 10; + var player = PlayerFakes.MakeFromStarting11(squadNumber); + var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber); var (repository, logger, memoryCache, mapper) = PlayerMocks.InitServiceMocks(); repository .Setup(repository => repository.FindBySquadNumberAsync(squadNumber)) @@ -248,14 +248,16 @@ public async Task GivenRetrieveBySquadNumberAsync_WhenRepositoryFindBySquadNumbe [Fact] [Trait("Category", "Unit")] - public async Task GivenUpdateAsync_WhenRepositoryFindByIdAsyncReturnsPlayer_ThenRepositoryUpdateAsyncAndCacheRemove() + public async Task GivenUpdateAsync_WhenRepositoryFindBySquadNumberAsyncReturnsPlayer_ThenRepositoryUpdateAsyncAndCacheRemove() { // Arrange - var id = 1; - var player = PlayerFakes.MakeFromStarting11ById(id); - var request = PlayerFakes.MakeRequestModelForUpdate(id); + var squadNumber = 23; + var player = PlayerFakes.MakeFromStarting11(squadNumber); + var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber); var (repository, logger, memoryCache, mapper) = PlayerMocks.InitServiceMocks(); - repository.Setup(repository => repository.FindByIdAsync(id)).ReturnsAsync(player); + repository + .Setup(repository => repository.FindBySquadNumberAsync(squadNumber)) + .ReturnsAsync(player); var service = new PlayerService( repository.Object, @@ -268,7 +270,10 @@ public async Task GivenUpdateAsync_WhenRepositoryFindByIdAsyncReturnsPlayer_Then await service.UpdateAsync(request); // Assert - repository.Verify(repository => repository.FindByIdAsync(It.IsAny()), Times.Once); + repository.Verify( + repository => repository.FindBySquadNumberAsync(It.IsAny()), + Times.Once + ); repository.Verify(repository => repository.UpdateAsync(It.IsAny()), Times.Once); memoryCache.Verify(cache => cache.Remove(It.IsAny()), Times.Once); mapper.Verify( @@ -283,14 +288,14 @@ public async Task GivenUpdateAsync_WhenRepositoryFindByIdAsyncReturnsPlayer_Then [Fact] [Trait("Category", "Unit")] - public async Task GivenDeleteAsync_WhenRepositoryFindByIdAsyncReturnsPlayer_ThenRepositoryDeleteAsyncAndCacheRemove() + public async Task GivenDeleteAsync_WhenRepositoryFindBySquadNumberAsyncReturnsPlayer_ThenRepositoryDeleteAsyncAndCacheRemove() { // Arrange - var id = 2; - var player = PlayerFakes.MakeFromStarting11ById(id); + var squadNumber = 26; + var player = PlayerFakes.MakeFromStarting11(squadNumber); var (repository, logger, memoryCache, mapper) = PlayerMocks.InitServiceMocks(); repository - .Setup(repository => repository.FindByIdAsync(It.IsAny())) + .Setup(repository => repository.FindBySquadNumberAsync(squadNumber)) .ReturnsAsync(player); var service = new PlayerService( @@ -301,11 +306,14 @@ public async Task GivenDeleteAsync_WhenRepositoryFindByIdAsyncReturnsPlayer_Then ); // Act - await service.DeleteAsync(It.IsAny()); + await service.DeleteAsync(squadNumber); // Assert - repository.Verify(repository => repository.FindByIdAsync(It.IsAny()), Times.Once); - repository.Verify(repository => repository.RemoveAsync(It.IsAny()), Times.Once); + repository.Verify( + repository => repository.FindBySquadNumberAsync(It.IsAny()), + Times.Once + ); + repository.Verify(repository => repository.RemoveAsync(It.IsAny()), Times.Once); memoryCache.Verify(cache => cache.Remove(It.IsAny()), Times.Once); } diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs index b856a5e..3f98710 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs @@ -18,7 +18,7 @@ public static List MakeStarting11() [ new() { - Id = 1, + Id = Guid.NewGuid(), FirstName = "Damián", MiddleName = "Emiliano", LastName = "Martínez", @@ -32,7 +32,7 @@ public static List MakeStarting11() }, new() { - Id = 2, + Id = Guid.NewGuid(), FirstName = "Nahuel", LastName = "Molina", DateOfBirth = new DateTime(1998, 4, 5, 0, 0, 0, DateTimeKind.Utc), @@ -45,7 +45,7 @@ public static List MakeStarting11() }, new() { - Id = 3, + Id = Guid.NewGuid(), FirstName = "Cristian", MiddleName = "Gabriel", LastName = "Romero", @@ -59,7 +59,7 @@ public static List MakeStarting11() }, new() { - Id = 4, + Id = Guid.NewGuid(), FirstName = "Nicolás", MiddleName = "Hernán Gonzalo", LastName = "Otamendi", @@ -73,7 +73,7 @@ public static List MakeStarting11() }, new() { - Id = 5, + Id = Guid.NewGuid(), FirstName = "Nicolás", MiddleName = "Alejandro", LastName = "Tagliafico", @@ -87,7 +87,7 @@ public static List MakeStarting11() }, new() { - Id = 6, + Id = Guid.NewGuid(), FirstName = "Ángel", MiddleName = "Fabián", LastName = "Di María", @@ -101,7 +101,7 @@ public static List MakeStarting11() }, new() { - Id = 7, + Id = Guid.NewGuid(), FirstName = "Rodrigo", MiddleName = "Javier", LastName = "de Paul", @@ -115,7 +115,7 @@ public static List MakeStarting11() }, new() { - Id = 8, + Id = Guid.NewGuid(), FirstName = "Enzo", MiddleName = "Jeremías", LastName = "Fernández", @@ -129,7 +129,7 @@ public static List MakeStarting11() }, new() { - Id = 9, + Id = Guid.NewGuid(), FirstName = "Alexis", LastName = "Mac Allister", DateOfBirth = new DateTime(1998, 12, 23, 0, 0, 0, DateTimeKind.Utc), @@ -142,7 +142,7 @@ public static List MakeStarting11() }, new() { - Id = 10, + Id = Guid.NewGuid(), FirstName = "Lionel", MiddleName = "Andrés", LastName = "Messi", @@ -156,7 +156,7 @@ public static List MakeStarting11() }, new() { - Id = 11, + Id = Guid.NewGuid(), FirstName = "Julián", LastName = "Álvarez", DateOfBirth = new DateTime(2000, 1, 30, 0, 0, 0, DateTimeKind.Utc), @@ -170,11 +170,13 @@ public static List MakeStarting11() ]; } - public static Player MakeFromStarting11ById(long id) + public static Player MakeFromStarting11(int squadNumber) { var player = - MakeStarting11().SingleOrDefault(player => player.Id == id) - ?? throw new ArgumentNullException($"Player with ID {id} not found."); + MakeStarting11().SingleOrDefault(player => player.SquadNumber == squadNumber) + ?? throw new ArgumentNullException( + $"Player with Squad Number {squadNumber} not found." + ); return player; } @@ -183,7 +185,6 @@ public static Player MakeNew() { return new() { - Id = 12, FirstName = "Leandro", MiddleName = "Daniel", LastName = "Paredes", @@ -207,7 +208,6 @@ public static PlayerRequestModel MakeRequestModelForCreate() return new PlayerRequestModel() { - Id = player.Id, FirstName = player.FirstName, MiddleName = player.MiddleName, LastName = player.LastName, @@ -225,7 +225,6 @@ public static PlayerResponseModel MakeResponseModelForCreate() return new PlayerResponseModel { - Id = player.Id, FullName = $"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(), Birth = $"{player.DateOfBirth:MMMM d, yyyy}", @@ -241,15 +240,16 @@ public static PlayerResponseModel MakeResponseModelForCreate() * Retrieve * ---------------------------------------------------------------------- */ - public static PlayerRequestModel MakeRequestModelForRetrieve(long id) + public static PlayerRequestModel MakeRequestModelForRetrieve(int squadNumber) { var player = - MakeStarting11().SingleOrDefault(player => player.Id == id) - ?? throw new ArgumentNullException($"Player with ID {id} not found."); + MakeStarting11().SingleOrDefault(player => player.SquadNumber == squadNumber) + ?? throw new ArgumentNullException( + $"Player with Squad Number {squadNumber} not found." + ); return new PlayerRequestModel { - Id = player.Id, FirstName = player.FirstName, MiddleName = player.MiddleName, LastName = player.LastName, @@ -261,15 +261,16 @@ public static PlayerRequestModel MakeRequestModelForRetrieve(long id) }; } - public static PlayerResponseModel MakeResponseModelForRetrieve(long id) + public static PlayerResponseModel MakeResponseModelForRetrieve(int squadNumber) { var player = - MakeStarting11().SingleOrDefault(player => player.Id == id) - ?? throw new ArgumentNullException($"Player with ID {id} not found."); + MakeStarting11().SingleOrDefault(player => player.SquadNumber == squadNumber) + ?? throw new ArgumentNullException( + $"Player with Squad Number {squadNumber} not found." + ); return new PlayerResponseModel { - Id = player.Id, FullName = $"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(), Birth = $"{player.DateOfBirth:MMMM d, yyyy}", @@ -286,7 +287,6 @@ public static List MakeResponseModelsForRetrieve() => .. MakeStarting11() .Select(player => new PlayerResponseModel { - Id = player.Id, FullName = $"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(), Birth = $"{player.DateOfBirth:MMMM d, yyyy}", @@ -302,13 +302,13 @@ .. MakeStarting11() * Update * ---------------------------------------------------------------------- */ - public static PlayerRequestModel MakeRequestModelForUpdate(long id) + public static PlayerRequestModel MakeRequestModelForUpdate(int squadNumber) { - return MakeRequestModelForRetrieve(id); + return MakeRequestModelForRetrieve(squadNumber); } - public static PlayerResponseModel MakeResponseModelForUpdate(long id) + public static PlayerResponseModel MakeResponseModelForUpdate(int squadNumber) { - return MakeResponseModelForRetrieve(id); + return MakeResponseModelForRetrieve(squadNumber); } } From 58a115fa9e718055b4f809202fd51ea22604dc6b Mon Sep 17 00:00:00 2001 From: Nano Taboada <87288+nanotaboada@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:59:56 -0300 Subject: [PATCH 2/3] chore(controllers): adjust route names --- .../Controllers/PlayerController.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs index 0b730df..06bf789 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs @@ -27,7 +27,7 @@ IValidator validator /// Created /// Bad Request /// Conflict - [HttpPost] + [HttpPost(Name = "Create")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -59,7 +59,7 @@ public async Task PostAsync([FromBody] PlayerRequestModel player) logger.LogInformation("POST /players created: {@Player}", result); return TypedResults.CreatedAtRoute( - routeName: "GetBySquadNumber", + routeName: "RetrieveBySquadNumber", routeValues: new { squadNumber = result.Dorsal }, value: result ); @@ -74,7 +74,7 @@ public async Task PostAsync([FromBody] PlayerRequestModel player) /// /// OK /// Not Found - [HttpGet] + [HttpGet(Name = "Retrieve")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetAsync() @@ -101,7 +101,7 @@ public async Task GetAsync() /// Not Found [Authorize(Roles = "Admin")] [ApiExplorerSettings(IgnoreApi = true)] - [HttpGet("{id:Guid}", Name = "GetById")] + [HttpGet("{id:Guid}", Name = "RetrieveById")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetByIdAsync([FromRoute] Guid id) @@ -125,7 +125,7 @@ public async Task GetByIdAsync([FromRoute] Guid id) /// The Squad Number of the Player /// OK /// Not Found - [HttpGet("squad/{squadNumber:int}")] + [HttpGet("squadNumber/{squadNumber:int}", Name = "RetrieveBySquadNumber")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) @@ -160,7 +160,7 @@ public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) /// No Content /// Bad Request /// Not Found - [HttpPut("{squadNumber:int}")] + [HttpPut("{squadNumber:int}", Name = "Update")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -204,7 +204,7 @@ [FromBody] PlayerRequestModel player /// The Squad Number of the Player /// No Content /// Not Found - [HttpDelete("{squadNumber:int}")] + [HttpDelete("{squadNumber:int}", Name = "Delete")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DeleteAsync([FromRoute] int squadNumber) From fa6abd1394764adf434b41bbefd7a194e7fdadaf Mon Sep 17 00:00:00 2001 From: Nano Taboada <87288+nanotaboada@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:26:49 -0300 Subject: [PATCH 3/3] refactor(data): drop legacy data seeding utilities, replaced by migrations --- .../20250414195445_SeedStarting11.cs | 2 +- .../Program.cs | 3 -- .../Utilities/ApplicationBuilderExtensions.cs | 45 ------------------- .../Utilities/DbContextUtils.cs | 15 ------- .../{Data => Utilities}/PlayerData.cs | 4 +- 5 files changed, 3 insertions(+), 66 deletions(-) delete mode 100644 src/Dotnet.Samples.AspNetCore.WebApi/Utilities/ApplicationBuilderExtensions.cs delete mode 100644 src/Dotnet.Samples.AspNetCore.WebApi/Utilities/DbContextUtils.cs rename src/Dotnet.Samples.AspNetCore.WebApi/{Data => Utilities}/PlayerData.cs (96%) diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.cs index aa4c9ff..198c334 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20250414195445_SeedStarting11.cs @@ -1,4 +1,4 @@ -using Dotnet.Samples.AspNetCore.WebApi.Data; +using Dotnet.Samples.AspNetCore.WebApi.Utilities; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs index 331a918..fb2e912 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -3,7 +3,6 @@ using Dotnet.Samples.AspNetCore.WebApi.Mappings; using Dotnet.Samples.AspNetCore.WebApi.Models; using Dotnet.Samples.AspNetCore.WebApi.Services; -using Dotnet.Samples.AspNetCore.WebApi.Utilities; using Dotnet.Samples.AspNetCore.WebApi.Validators; using FluentValidation; using Microsoft.EntityFrameworkCore; @@ -87,6 +86,4 @@ // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#endpoints app.MapControllers(); -await app.InitData(); - await app.RunAsync(); diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/ApplicationBuilderExtensions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/ApplicationBuilderExtensions.cs deleted file mode 100644 index 7572beb..0000000 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Dotnet.Samples.AspNetCore.WebApi.Data; -using Microsoft.EntityFrameworkCore; - -namespace Dotnet.Samples.AspNetCore.WebApi.Utilities -{ - /// - /// Provides extension methods for to - /// simplify database seeding operations. - /// - public static class ApplicationBuilderExtensions - { - /// - /// Ensures the SQLite database file exists and contains tables. - /// If the database is newly created, it seeds it with initial data. - /// Does not apply or validate migrations. - /// - /// The instance. - public static async Task InitData(this IApplicationBuilder app) - { - using var scope = app.ApplicationServices.CreateScope(); - var services = scope.ServiceProvider; - var logger = services.GetRequiredService>(); - var dbContext = services.GetRequiredService(); - try - { - await dbContext.Database.MigrateAsync(); - logger.LogInformation("Database successfully migrated."); - - if (!await dbContext.Players.AnyAsync()) - { - DbContextUtils.Seed(dbContext); - logger.LogInformation("DbContext successfully seeded."); - } - } - catch (Exception exception) - { - logger.LogError(exception, "An error occurred while initializing the database."); - throw new InvalidOperationException( - "Failed to initialize the database.", - exception - ); - } - } - } -} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/DbContextUtils.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/DbContextUtils.cs deleted file mode 100644 index 259001e..0000000 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/DbContextUtils.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Dotnet.Samples.AspNetCore.WebApi.Data; - -namespace Dotnet.Samples.AspNetCore.WebApi.Utilities; - -public static class DbContextUtils -{ - public static void Seed(PlayerDbContext context) - { - if (!context.Players.Any()) - { - context.Players.AddRange(PlayerData.MakeStarting11()); - context.SaveChanges(); - } - } -} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerData.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/PlayerData.cs similarity index 96% rename from src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerData.cs rename to src/Dotnet.Samples.AspNetCore.WebApi/Utilities/PlayerData.cs index e386b37..968e8f4 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerData.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/PlayerData.cs @@ -2,7 +2,7 @@ using Dotnet.Samples.AspNetCore.WebApi.Enums; using Dotnet.Samples.AspNetCore.WebApi.Models; -namespace Dotnet.Samples.AspNetCore.WebApi.Data; +namespace Dotnet.Samples.AspNetCore.WebApi.Utilities; public static class PlayerData { @@ -317,7 +317,7 @@ public static List MakeStarting11WithId() /// https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/deserialization /// /// A list of Players. - public static List CreateFromDeserializedJson() + public static List MakeStarting11FromDeserializedJson() { var players = new List();