From 1a908840f812a48a7d3201ef94e485c44fc805b3 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:50:20 +0100 Subject: [PATCH 01/11] implement DELETE --- Directory.Packages.props | 1 - .../Endpoints/MedicationEndpoints.cs | 41 +++++++++++++++---- src/Medications.Api/Medications.Api.csproj | 1 + src/Medications.Api/Program.cs | 2 + src/Medications.AppHost/AppHost.cs | 15 +------ .../Medications.AppHost.csproj | 4 -- 6 files changed, 39 insertions(+), 25 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d5b8811..9c2bc78 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,6 @@ - \ No newline at end of file diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index 279d1f6..06fdb22 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -8,27 +8,39 @@ namespace Medications.Api.Endpoints; public static class MedicationEndpoints { + private const string GetMedicationsEndpoint = "GetMedications"; + private const string GetMedicationEndpoint = "GetMedication"; + private const string CreateMedicationEndpoint = "CreateMedication"; + private const string DeleteMedicationEndpoint = "CreateMedication"; + + public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/medications") .WithTags("Medications"); - group.MapGet("/", GetAllMedications) - .WithName("GetMedications") + group.MapGet("/", GetMedications) + .WithName(GetMedicationsEndpoint) .WithDescription("Retrieve all medications."); group.MapGet("/{id}", GetMedication) - .WithName("GetMedication") + .WithName(GetMedicationEndpoint) .WithDescription("Retrieve a specific medication by its ID."); group.MapPost("/", CreateMedication) - .WithName("CreateMedication") + .WithName(CreateMedicationEndpoint) .WithDescription("Create a new medication"); + group.MapDelete("/{id}", DeleteMedication) + .WithName(DeleteMedicationEndpoint) + .WithDescription("Delete a medication"); + return endpoints; } - private static async Task>> GetAllMedications(MedicationsDbContext dbContext) + #region Private Methods + + private static async Task>> GetMedications(MedicationsDbContext dbContext) { var medications = await dbContext.Medications.ToListAsync(); @@ -38,9 +50,9 @@ private static async Task>> GetAllMedications(Medica } private static async Task, NotFound>> GetMedication( - int id, MedicationsDbContext dbContext) + int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) { - var medication = await dbContext.Medications.FindAsync(id); + var medication = await dbContext.Medications.FindAsync(id, cancellationToken); if (medication is null) return TypedResults.NotFound(); @@ -63,4 +75,19 @@ private static async Task> CreateMedication( return TypedResults.CreatedAtRoute(medicationResponse, "GetMedication", new { id = medicationResponse.Id }); } + + private static async Task> DeleteMedication( + int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) + { + var medication = await dbContext.Medications.FindAsync(id, cancellationToken); + + if (medication is null) return TypedResults.NotFound(); + + dbContext.Medications.Remove(medication); + await dbContext.SaveChangesAsync(cancellationToken); + return TypedResults.NoContent(); + } + + #endregion + } diff --git a/src/Medications.Api/Medications.Api.csproj b/src/Medications.Api/Medications.Api.csproj index 08df76d..8cfa09a 100644 --- a/src/Medications.Api/Medications.Api.csproj +++ b/src/Medications.Api/Medications.Api.csproj @@ -4,6 +4,7 @@ + diff --git a/src/Medications.Api/Program.cs b/src/Medications.Api/Program.cs index e4d51d5..231de75 100644 --- a/src/Medications.Api/Program.cs +++ b/src/Medications.Api/Program.cs @@ -1,6 +1,7 @@ using Medications.Api.Data; using Medications.Api.Endpoints; using Microsoft.EntityFrameworkCore; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase("Medications")); @@ -13,6 +14,7 @@ if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.MapScalarApiReference(); } app.UseHttpsRedirection(); diff --git a/src/Medications.AppHost/AppHost.cs b/src/Medications.AppHost/AppHost.cs index 6f0f455..e602bb6 100644 --- a/src/Medications.AppHost/AppHost.cs +++ b/src/Medications.AppHost/AppHost.cs @@ -1,19 +1,8 @@ using Projects; -using Scalar.Aspire; var builder = DistributedApplication.CreateBuilder(args); -var apiService = builder.AddProject("medications-api"); - -var scalar = builder.AddScalarApiReference("scalar", options => -{ - options - .WithTheme(ScalarTheme.Default) - .PreferHttpsEndpoint() - .AllowSelfSignedCertificates(); -}); - -scalar - .WithApiReference(apiService); +builder.AddProject("medications-api") + .WithUrlForEndpoint("https", url => new() { Url = "/scalar", DisplayText = "Scalar UI", DisplayOrder = 100 }); await builder.Build().RunAsync(); diff --git a/src/Medications.AppHost/Medications.AppHost.csproj b/src/Medications.AppHost/Medications.AppHost.csproj index 706261d..185a886 100644 --- a/src/Medications.AppHost/Medications.AppHost.csproj +++ b/src/Medications.AppHost/Medications.AppHost.csproj @@ -9,8 +9,4 @@ - - - - From 8baea684958309ca20a43dae91efda19734c2a67 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:28:01 +0100 Subject: [PATCH 02/11] test project setup and first unit test for delete method --- Directory.Packages.props | 8 ++- Medications.slnx | 7 ++- .../Endpoints/MedicationEndpoints.cs | 12 ++--- src/Medications.Api/Medications.Api.csproj | 1 + .../EndpointUnitTests.cs | 51 +++++++++++++++++++ .../Medications.Unit.Tests.csproj | 33 ++++++++++++ 6 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 tests/Medications.Unit.Tests/EndpointUnitTests.cs create mode 100644 tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index 9c2bc78..f17537f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,9 +3,15 @@ true + - + + + + + + \ No newline at end of file diff --git a/Medications.slnx b/Medications.slnx index f4c7203..cc3840a 100644 --- a/Medications.slnx +++ b/Medications.slnx @@ -1,6 +1,9 @@ - - + + + + + diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index 06fdb22..468892b 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -11,7 +11,7 @@ public static class MedicationEndpoints private const string GetMedicationsEndpoint = "GetMedications"; private const string GetMedicationEndpoint = "GetMedication"; private const string CreateMedicationEndpoint = "CreateMedication"; - private const string DeleteMedicationEndpoint = "CreateMedication"; + private const string DeleteMedicationEndpoint = "DeleteMedication"; public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBuilder endpoints) @@ -38,9 +38,9 @@ public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBu return endpoints; } - #region Private Methods + #region Internal Methods - private static async Task>> GetMedications(MedicationsDbContext dbContext) + internal static async Task>> GetMedications(MedicationsDbContext dbContext) { var medications = await dbContext.Medications.ToListAsync(); @@ -49,7 +49,7 @@ private static async Task>> GetMedications(Medicatio return TypedResults.Ok(medicationResponseList); } - private static async Task, NotFound>> GetMedication( + internal static async Task, NotFound>> GetMedication( int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) { var medication = await dbContext.Medications.FindAsync(id, cancellationToken); @@ -59,7 +59,7 @@ private static async Task, NotFound>> GetMedicati return TypedResults.Ok(medication.ToResponse()); } - private static async Task> CreateMedication( + internal static async Task> CreateMedication( CreateMedicationRequest request, MedicationsDbContext dbContext, TimeProvider timeProvider, @@ -76,7 +76,7 @@ private static async Task> CreateMedication( return TypedResults.CreatedAtRoute(medicationResponse, "GetMedication", new { id = medicationResponse.Id }); } - private static async Task> DeleteMedication( + internal static async Task> DeleteMedication( int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) { var medication = await dbContext.Medications.FindAsync(id, cancellationToken); diff --git a/src/Medications.Api/Medications.Api.csproj b/src/Medications.Api/Medications.Api.csproj index 8cfa09a..da4566d 100644 --- a/src/Medications.Api/Medications.Api.csproj +++ b/src/Medications.Api/Medications.Api.csproj @@ -5,6 +5,7 @@ + diff --git a/tests/Medications.Unit.Tests/EndpointUnitTests.cs b/tests/Medications.Unit.Tests/EndpointUnitTests.cs new file mode 100644 index 0000000..2812b69 --- /dev/null +++ b/tests/Medications.Unit.Tests/EndpointUnitTests.cs @@ -0,0 +1,51 @@ +using Medications.Api.Data; +using Medications.Api.Endpoints; +using Medications.Api.Entities; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace Medications.Unit.Tests +{ + public class EndpointUnitTests + { + [Fact] + public async Task DeleteMedicationById_WhenMedicationExists_ReturnsNoContent() + { + var medication1 = new Medication + { + Id = 1, + Name = "Test Medication", + Quantity = 10, + CreationDate = DateTimeOffset.UtcNow + }; + + var medication2 = new Medication + { + Id = 2, + Name = "Test Medication 2", + Quantity = 20, + CreationDate = DateTimeOffset.UtcNow + }; + + var dbContextOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + + var dbContext = new MedicationsDbContext(dbContextOptions); + + + dbContext.Medications.Add(medication1); + dbContext.Medications.Add(medication2); + dbContext.SaveChanges(); + + var result = await MedicationEndpoints.DeleteMedication(1, dbContext, CancellationToken.None); + + var value1 = dbContext.Medications.Find(1); + var value2 = dbContext.Medications.Find(2); + + + Assert.IsType(result.Result); + Assert.Null(value1); + Assert.NotNull(value2); + } + } +} diff --git a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj new file mode 100644 index 0000000..4b9167b --- /dev/null +++ b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file From 274e08021f26c0c682fa4ef77e50e63bb2d79a8c Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:40:34 +0100 Subject: [PATCH 03/11] cleanup --- Directory.Packages.props | 2 -- src/Medications.Api/Endpoints/MedicationEndpoints.cs | 12 +++--------- .../Medications.Unit.Tests.csproj | 2 -- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f17537f..e703b56 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,11 +3,9 @@ true - - diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index 468892b..78dfe76 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -13,7 +13,6 @@ public static class MedicationEndpoints private const string CreateMedicationEndpoint = "CreateMedication"; private const string DeleteMedicationEndpoint = "DeleteMedication"; - public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/medications") @@ -38,11 +37,9 @@ public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBu return endpoints; } - #region Internal Methods - - internal static async Task>> GetMedications(MedicationsDbContext dbContext) + internal static async Task>> GetMedications(MedicationsDbContext dbContext, CancellationToken cancellationToken) { - var medications = await dbContext.Medications.ToListAsync(); + var medications = await dbContext.Medications.ToListAsync(cancellationToken); var medicationResponseList = medications.Select(medication => medication.ToResponse()).ToList(); @@ -73,7 +70,7 @@ internal static async Task> CreateMedication( var medicationResponse = medication.ToResponse(); - return TypedResults.CreatedAtRoute(medicationResponse, "GetMedication", new { id = medicationResponse.Id }); + return TypedResults.CreatedAtRoute(medicationResponse, GetMedicationEndpoint, new { id = medicationResponse.Id }); } internal static async Task> DeleteMedication( @@ -87,7 +84,4 @@ internal static async Task> DeleteMedication( await dbContext.SaveChangesAsync(cancellationToken); return TypedResults.NoContent(); } - - #endregion - } diff --git a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj index 4b9167b..e77a9b8 100644 --- a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj +++ b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj @@ -12,9 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - all runtime; build; native; contentfiles; analyzers; buildtransitive From fba67cc87a6c092bd5cf6a7d7a2d73c3364da642 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:35:15 +0100 Subject: [PATCH 04/11] improvements --- .../Endpoints/MedicationEndpoints.cs | 44 ++++++---- src/Medications.Api/Medications.Api.csproj | 3 + src/Medications.Api/Program.cs | 18 +++++ .../EndpointUnitTests.cs | 80 ++++++++++++------- 4 files changed, 100 insertions(+), 45 deletions(-) diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index 78dfe76..d22e3b5 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -8,31 +8,36 @@ namespace Medications.Api.Endpoints; public static class MedicationEndpoints { - private const string GetMedicationsEndpoint = "GetMedications"; - private const string GetMedicationEndpoint = "GetMedication"; - private const string CreateMedicationEndpoint = "CreateMedication"; - private const string DeleteMedicationEndpoint = "DeleteMedication"; + private const string RouteGroupPrefix = "/api/medications"; + + private const string GetMedicationsEndpointName = "GetMedications"; + private const string GetMedicationEndpointName = "GetMedication"; + private const string CreateMedicationEndpointName = "CreateMedication"; + private const string DeleteMedicationEndpointName = "DeleteMedication"; public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBuilder endpoints) { - var group = endpoints.MapGroup("/api/medications") + var group = endpoints.MapGroup(RouteGroupPrefix) .WithTags("Medications"); group.MapGet("/", GetMedications) - .WithName(GetMedicationsEndpoint) + .WithName(GetMedicationsEndpointName) .WithDescription("Retrieve all medications."); group.MapGet("/{id}", GetMedication) - .WithName(GetMedicationEndpoint) - .WithDescription("Retrieve a specific medication by its ID."); + .WithName(GetMedicationEndpointName) + .WithDescription("Retrieve a specific medication by its ID.") + .ProducesProblem(StatusCodes.Status400BadRequest); group.MapPost("/", CreateMedication) - .WithName(CreateMedicationEndpoint) - .WithDescription("Create a new medication"); + .WithName(CreateMedicationEndpointName) + .WithDescription("Create a new medication") + .ProducesValidationProblem(); group.MapDelete("/{id}", DeleteMedication) - .WithName(DeleteMedicationEndpoint) - .WithDescription("Delete a medication"); + .WithName(DeleteMedicationEndpointName) + .WithDescription("Delete a medication") + .ProducesProblem(StatusCodes.Status400BadRequest); return endpoints; } @@ -46,9 +51,11 @@ internal static async Task>> GetMedications(Medicati return TypedResults.Ok(medicationResponseList); } - internal static async Task, NotFound>> GetMedication( + internal static async Task, NotFound, ProblemHttpResult>> GetMedication( int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) { + if (id <= 0) return InvalidId(); + var medication = await dbContext.Medications.FindAsync(id, cancellationToken); if (medication is null) return TypedResults.NotFound(); @@ -70,12 +77,14 @@ internal static async Task> CreateMedication( var medicationResponse = medication.ToResponse(); - return TypedResults.CreatedAtRoute(medicationResponse, GetMedicationEndpoint, new { id = medicationResponse.Id }); + return TypedResults.CreatedAtRoute(medicationResponse, GetMedicationEndpointName, new { id = medicationResponse.Id }); } - internal static async Task> DeleteMedication( + internal static async Task> DeleteMedication( int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) { + if (id <= 0) return InvalidId(); + var medication = await dbContext.Medications.FindAsync(id, cancellationToken); if (medication is null) return TypedResults.NotFound(); @@ -84,4 +93,9 @@ internal static async Task> DeleteMedication( await dbContext.SaveChangesAsync(cancellationToken); return TypedResults.NoContent(); } + + private static ProblemHttpResult InvalidId() => + TypedResults.Problem( + detail: "Id must be greater than zero.", + statusCode: StatusCodes.Status400BadRequest); } diff --git a/src/Medications.Api/Medications.Api.csproj b/src/Medications.Api/Medications.Api.csproj index da4566d..043f62a 100644 --- a/src/Medications.Api/Medications.Api.csproj +++ b/src/Medications.Api/Medications.Api.csproj @@ -5,6 +5,9 @@ + + + diff --git a/src/Medications.Api/Program.cs b/src/Medications.Api/Program.cs index 231de75..1f6c836 100644 --- a/src/Medications.Api/Program.cs +++ b/src/Medications.Api/Program.cs @@ -8,6 +8,16 @@ builder.Services.AddOpenApi(); builder.Services.AddValidation(); builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddProblemDetails(options => +{ + options.CustomizeProblemDetails = context => + { + if (context.Exception is BadHttpRequestException badRequest) + { + context.ProblemDetails.Detail ??= badRequest.Message; + } + }; +}); var app = builder.Build(); @@ -17,6 +27,14 @@ app.MapScalarApiReference(); } +app.UseExceptionHandler(new ExceptionHandlerOptions +{ + StatusCodeSelector = ex => ex is BadHttpRequestException badRequest + ? badRequest.StatusCode + : StatusCodes.Status500InternalServerError +}); +app.UseStatusCodePages(); + app.UseHttpsRedirection(); app.MapMedicationEndpoints(); diff --git a/tests/Medications.Unit.Tests/EndpointUnitTests.cs b/tests/Medications.Unit.Tests/EndpointUnitTests.cs index 2812b69..b9768f0 100644 --- a/tests/Medications.Unit.Tests/EndpointUnitTests.cs +++ b/tests/Medications.Unit.Tests/EndpointUnitTests.cs @@ -1,6 +1,7 @@ using Medications.Api.Data; using Medications.Api.Endpoints; using Medications.Api.Entities; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; @@ -11,41 +12,60 @@ public class EndpointUnitTests [Fact] public async Task DeleteMedicationById_WhenMedicationExists_ReturnsNoContent() { - var medication1 = new Medication - { - Id = 1, - Name = "Test Medication", - Quantity = 10, - CreationDate = DateTimeOffset.UtcNow - }; - - var medication2 = new Medication - { - Id = 2, - Name = "Test Medication 2", - Quantity = 20, - CreationDate = DateTimeOffset.UtcNow - }; - - var dbContextOptions = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; - - var dbContext = new MedicationsDbContext(dbContextOptions); - - - dbContext.Medications.Add(medication1); - dbContext.Medications.Add(medication2); - dbContext.SaveChanges(); + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); var result = await MedicationEndpoints.DeleteMedication(1, dbContext, CancellationToken.None); - var value1 = dbContext.Medications.Find(1); - var value2 = dbContext.Medications.Find(2); + Assert.IsType(result.Result); + Assert.Null(dbContext.Medications.Find(1)); + Assert.NotNull(dbContext.Medications.Find(2)); + } + [Fact] + public async Task DeleteMedicationById_WhenMedicationDoesNotExist_ReturnsNotFound() + { + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); - Assert.IsType(result.Result); - Assert.Null(value1); - Assert.NotNull(value2); + var result = await MedicationEndpoints.DeleteMedication(3, dbContext, CancellationToken.None); + + Assert.IsType(result.Result); + Assert.NotNull(dbContext.Medications.Find(1)); + Assert.NotNull(dbContext.Medications.Find(2)); + } + + [Fact] + public async Task DeleteMedicationById_WhenIdIsNegative_ReturnsBadRequest() + { + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); + + var result = await MedicationEndpoints.DeleteMedication(-1, dbContext, CancellationToken.None); + + var problem = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); + Assert.NotNull(dbContext.Medications.Find(1)); + Assert.NotNull(dbContext.Medications.Find(2)); } + + private static MedicationsDbContext CreateContext(params Medication[] seed) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbContext = new MedicationsDbContext(options); + + dbContext.Medications.AddRange(seed); + dbContext.SaveChanges(); + + return dbContext; + } + + private static Medication NewMedication(int id) => new() + { + Id = id, + Name = $"Test Medication {id}", + Quantity = 10 * id, + CreationDate = DateTimeOffset.UtcNow + }; } } From 21a7fc1f900f3b7d4953dc4a957a6e249a2e1034 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:40:53 +0100 Subject: [PATCH 05/11] add tests to CI --- .github/workflows/dotnet.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b7c04c7..c3d15c6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -24,5 +24,5 @@ jobs: run: dotnet restore - name: Build run: dotnet build --no-restore - # - name: Test - # run: dotnet test --no-build --verbosity normal + - name: Test + run: dotnet test --no-build --verbosity normal From 1183de827ffd1dd810f83c7f7d72d6eefcdc3be6 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:19:56 +0100 Subject: [PATCH 06/11] better CI with sonarCloud scan --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++ .github/workflows/dotnet.yml | 28 ---------------- 2 files changed, 62 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/dotnet.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..873ef43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 # full history so Sonar can attribute new code via blame + + - name: Setup .NET + uses: actions/setup-dotnet@v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Set up JDK 21 for SonarQube analysis + if: env.SONAR_TOKEN != '' + uses: actions/setup-java@v5.7.0 + with: + distribution: temurin + java-version: 21 + + - name: Cache SonarQube packages + if: env.SONAR_TOKEN != '' + uses: actions/cache@v6.1.0 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Begin Sonar analysis + if: env.SONAR_TOKEN != '' + run: | + dotnet tool install --global dotnet-sonarscanner + dotnet-sonarscanner begin \ + /k:"fernandotonacoder_medications-rest-api" \ + /o:"fernandotonacoder" \ + /d:sonar.token="$SONAR_TOKEN" \ + /d:sonar.host.url="https://sonarcloud.io" \ + /d:sonar.cs.cobertura.reportsPaths="**/coverage.cobertura.xml" + + - name: Build + run: dotnet build --no-incremental + + - name: Test + run: dotnet test --no-build --collect:"XPlat Code Coverage" + + - name: End Sonar analysis + if: env.SONAR_TOKEN != '' + run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN" diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml deleted file mode 100644 index c3d15c6..0000000 --- a/.github/workflows/dotnet.yml +++ /dev/null @@ -1,28 +0,0 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net - -name: .NET - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v7.0.1 - - name: Setup .NET - uses: actions/setup-dotnet@v6.0.0 - with: - dotnet-version: 10.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal From c2cca6ed38318bb245adcb08695618a60c53fc17 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:30:54 +0100 Subject: [PATCH 07/11] address snarcloud detected issues --- src/Medications.Api/Endpoints/MedicationEndpoints.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index d22e3b5..388435b 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -56,7 +56,7 @@ internal static async Task, NotFound, ProblemHttp { if (id <= 0) return InvalidId(); - var medication = await dbContext.Medications.FindAsync(id, cancellationToken); + var medication = await dbContext.Medications.FindAsync([id], cancellationToken); if (medication is null) return TypedResults.NotFound(); @@ -85,7 +85,7 @@ internal static async Task> Dele { if (id <= 0) return InvalidId(); - var medication = await dbContext.Medications.FindAsync(id, cancellationToken); + var medication = await dbContext.Medications.FindAsync([id], cancellationToken); if (medication is null) return TypedResults.NotFound(); From e4ce65b6586b96f35e174405a8449e701e8eab58 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:36:03 +0100 Subject: [PATCH 08/11] rename job --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 873ef43..a9fc611 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: jobs: - build: + CI: runs-on: ubuntu-latest From 4ca1a5ab2bdd047c8e88c80a2ea96c733750a1cc Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:38:45 +0100 Subject: [PATCH 09/11] job rename --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9fc611..23b537a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: jobs: - CI: + build-and-test: runs-on: ubuntu-latest From 23d41f09c787c378d5c5466e69aef6bb32f512a1 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:53:52 +0100 Subject: [PATCH 10/11] tests --- .github/workflows/ci.yml | 5 +- Directory.Packages.props | 1 + Medications.slnx | 1 + .../Endpoints/MedicationEndpoints.cs | 3 +- tests/.gitkeep | 0 .../CreateMedicationRequestUnitTests.cs | 43 +++++++++ ...nitTests.cs => DeleteEndpointUnitTests.cs} | 18 ++-- .../GetAllEndpointUnitTests.cs | 62 +++++++++++++ .../GetByIdEndpointUnitTests.cs | 87 +++++++++++++++++++ .../MedicationMappingUnitTests.cs | 42 +++++++++ .../Medications.Unit.Tests.csproj | 4 + .../PostEndpointUnitTests.cs | 51 +++++++++++ tests/test.runsettings | 38 ++++++++ 13 files changed, 345 insertions(+), 10 deletions(-) delete mode 100644 tests/.gitkeep create mode 100644 tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs rename tests/Medications.Unit.Tests/{EndpointUnitTests.cs => DeleteEndpointUnitTests.cs} (78%) create mode 100644 tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs create mode 100644 tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs create mode 100644 tests/Medications.Unit.Tests/MedicationMappingUnitTests.cs create mode 100644 tests/Medications.Unit.Tests/PostEndpointUnitTests.cs create mode 100644 tests/test.runsettings diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b537a..fa63b4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v7.0.1 with: - fetch-depth: 0 # full history so Sonar can attribute new code via blame + fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v6.0.0 @@ -55,7 +55,8 @@ jobs: run: dotnet build --no-incremental - name: Test - run: dotnet test --no-build --collect:"XPlat Code Coverage" + # RunSettingsFilePath points at tests/test.runsettings in cobertura format + run: dotnet test --no-build - name: End Sonar analysis if: env.SONAR_TOKEN != '' diff --git a/Directory.Packages.props b/Directory.Packages.props index e703b56..008f955 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/Medications.slnx b/Medications.slnx index cc3840a..ba4ce66 100644 --- a/Medications.slnx +++ b/Medications.slnx @@ -4,6 +4,7 @@ + diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index 388435b..6e23302 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -42,7 +42,8 @@ public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBu return endpoints; } - internal static async Task>> GetMedications(MedicationsDbContext dbContext, CancellationToken cancellationToken) + internal static async Task>> GetMedications( + MedicationsDbContext dbContext, CancellationToken cancellationToken) { var medications = await dbContext.Medications.ToListAsync(cancellationToken); diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs b/tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs new file mode 100644 index 0000000..336bb79 --- /dev/null +++ b/tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs @@ -0,0 +1,43 @@ +using Medications.Api.Contracts; +using System.ComponentModel.DataAnnotations; + +namespace Medications.Unit.Tests +{ + public class CreateMedicationRequestUnitTests + { + [Fact] + public void Name_WhenEmptyString_IsInvalid() + { + var results = Validate(new CreateMedicationRequest { Name = "", Quantity = 10 }); + + var error = Assert.Single(results); + Assert.Equal("The Name field is required.", error.ErrorMessage); + Assert.Contains(nameof(CreateMedicationRequest.Name), error.MemberNames); + Assert.DoesNotContain(nameof(CreateMedicationRequest.Quantity), error.MemberNames); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Quantity_WhenNotGreaterThanZero_IsInvalid(int quantity) + { + var results = Validate(new CreateMedicationRequest { Name = "Aspirin", Quantity = quantity }); + + var error = Assert.Single(results); + Assert.Equal("Quantity must be greater than 0", error.ErrorMessage); + Assert.DoesNotContain(nameof(CreateMedicationRequest.Name), error.MemberNames); + Assert.Contains(nameof(CreateMedicationRequest.Quantity), error.MemberNames); + } + + private static List Validate(CreateMedicationRequest request) + { + var results = new List(); + Validator.TryValidateObject( + request, + new ValidationContext(request), + results, + validateAllProperties: true); + return results; + } + } +} diff --git a/tests/Medications.Unit.Tests/EndpointUnitTests.cs b/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs similarity index 78% rename from tests/Medications.Unit.Tests/EndpointUnitTests.cs rename to tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs index b9768f0..376516d 100644 --- a/tests/Medications.Unit.Tests/EndpointUnitTests.cs +++ b/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs @@ -7,10 +7,10 @@ namespace Medications.Unit.Tests { - public class EndpointUnitTests + public class DeleteEndpointUnitTests { [Fact] - public async Task DeleteMedicationById_WhenMedicationExists_ReturnsNoContent() + public async Task DeleteMedication_WhenMedicationExists_ReturnsNoContent() { await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); @@ -22,7 +22,7 @@ public async Task DeleteMedicationById_WhenMedicationExists_ReturnsNoContent() } [Fact] - public async Task DeleteMedicationById_WhenMedicationDoesNotExist_ReturnsNotFound() + public async Task DeleteMedication_WhenMedicationDoesNotExist_ReturnsNotFound() { await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); @@ -33,17 +33,20 @@ public async Task DeleteMedicationById_WhenMedicationDoesNotExist_ReturnsNotFoun Assert.NotNull(dbContext.Medications.Find(2)); } - [Fact] - public async Task DeleteMedicationById_WhenIdIsNegative_ReturnsBadRequest() + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task DeleteMedication_WhenIdNotGreaterThanZero_ReturnsBadRequest(int id) { await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); - var result = await MedicationEndpoints.DeleteMedication(-1, dbContext, CancellationToken.None); + var result = await MedicationEndpoints.DeleteMedication(id, dbContext, CancellationToken.None); var problem = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); Assert.NotNull(dbContext.Medications.Find(1)); Assert.NotNull(dbContext.Medications.Find(2)); + Assert.Equal("Id must be greater than zero.", problem.ProblemDetails.Detail); } private static MedicationsDbContext CreateContext(params Medication[] seed) @@ -65,7 +68,8 @@ private static MedicationsDbContext CreateContext(params Medication[] seed) Id = id, Name = $"Test Medication {id}", Quantity = 10 * id, - CreationDate = DateTimeOffset.UtcNow + CreationDate = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero) }; + } } diff --git a/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs b/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs new file mode 100644 index 0000000..dd497c4 --- /dev/null +++ b/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs @@ -0,0 +1,62 @@ +using Medications.Api.Contracts; +using Medications.Api.Data; +using Medications.Api.Endpoints; +using Medications.Api.Entities; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace Medications.Unit.Tests +{ + public class GetAllEndpointUnitTests + { + [Fact] + public async Task GetAllMedications_WhenMedicationsExist_ReturnsOK() + { + var medication1 = NewMedication(1); + var medication2 = NewMedication(2); + await using var dbContext = CreateContext(medication1, medication2); + + var result = await MedicationEndpoints.GetMedications(dbContext, CancellationToken.None); + + var response = Assert.IsType>>(result); + Assert.Equal(StatusCodes.Status200OK, response.StatusCode); + Assert.NotNull(response.Value); + + Assert.NotNull(response.Value[0]); + Assert.Equal(medication1.Id, response.Value[0].Id); + Assert.Equal(medication1.Name, response.Value[0].Name); + Assert.Equal(medication1.Quantity, response.Value[0].Quantity); + Assert.Equal(medication1.CreationDate, response.Value[0].CreationDate); + + Assert.NotNull(response.Value[1]); + Assert.Equal(medication2.Id, response.Value[1].Id); + Assert.Equal(medication2.Name, response.Value[1].Name); + Assert.Equal(medication2.Quantity, response.Value[1].Quantity); + Assert.Equal(medication2.CreationDate, response.Value[1].CreationDate); + } + + + private static MedicationsDbContext CreateContext(params Medication[] seed) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbContext = new MedicationsDbContext(options); + + dbContext.Medications.AddRange(seed); + dbContext.SaveChanges(); + + return dbContext; + } + + private static Medication NewMedication(int id) => new() + { + Id = id, + Name = $"Test Medication {id}", + Quantity = 10 * id, + CreationDate = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero) + }; + } +} diff --git a/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs b/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs new file mode 100644 index 0000000..f606806 --- /dev/null +++ b/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs @@ -0,0 +1,87 @@ +using Medications.Api.Contracts; +using Medications.Api.Data; +using Medications.Api.Endpoints; +using Medications.Api.Entities; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace Medications.Unit.Tests +{ + public class GetByIdEndpointUnitTests + { + [Fact] + public async Task GetMedicationById_WhenMedicationExists_ReturnsOK() + { + var medication1 = NewMedication(1); + var medication2 = NewMedication(2); + await using var dbContext = CreateContext(medication1, medication2); + + var result1 = await MedicationEndpoints.GetMedication(1, dbContext, CancellationToken.None); + var result2 = await MedicationEndpoints.GetMedication(2, dbContext, CancellationToken.None); + + var response1 = Assert.IsType>(result1.Result); + Assert.Equal(StatusCodes.Status200OK, response1.StatusCode); + Assert.NotNull(response1.Value); + Assert.Equal(medication1.Id, response1.Value.Id); + Assert.Equal(medication1.Name, response1.Value.Name); + Assert.Equal(medication1.Quantity, response1.Value.Quantity); + Assert.Equal(medication1.CreationDate, response1.Value.CreationDate); + + var response2 = Assert.IsType>(result2.Result); + Assert.Equal(StatusCodes.Status200OK, response2.StatusCode); + Assert.NotNull(response2.Value); + Assert.Equal(medication2.Id, response2.Value.Id); + Assert.Equal(medication2.Name, response2.Value.Name); + Assert.Equal(medication2.Quantity, response2.Value.Quantity); + Assert.Equal(medication2.CreationDate, response2.Value.CreationDate); + } + + [Fact] + public async Task GetMedicationById_WhenMedicationDoesNotExist_ReturnsNotFound() + { + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); + + var result1 = await MedicationEndpoints.GetMedication(3, dbContext, CancellationToken.None); + + var response1 = Assert.IsType(result1.Result); + Assert.Equal(StatusCodes.Status404NotFound, response1.StatusCode); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task GetMedicationById_WhenNotGreaterThanZero_ReturnsBadRequest(int id) + { + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); + + var result = await MedicationEndpoints.GetMedication(id, dbContext, CancellationToken.None); + + var problem = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); + Assert.Equal("Id must be greater than zero.", problem.ProblemDetails.Detail); + } + + private static MedicationsDbContext CreateContext(params Medication[] seed) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbContext = new MedicationsDbContext(options); + + dbContext.Medications.AddRange(seed); + dbContext.SaveChanges(); + + return dbContext; + } + + private static Medication NewMedication(int id) => new() + { + Id = id, + Name = $"Test Medication {id}", + Quantity = 10 * id, + CreationDate = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero) + }; + } +} diff --git a/tests/Medications.Unit.Tests/MedicationMappingUnitTests.cs b/tests/Medications.Unit.Tests/MedicationMappingUnitTests.cs new file mode 100644 index 0000000..fb990a4 --- /dev/null +++ b/tests/Medications.Unit.Tests/MedicationMappingUnitTests.cs @@ -0,0 +1,42 @@ +using Medications.Api.Contracts; +using Medications.Api.Entities; +using Medications.Api.Mapping; +using Microsoft.Extensions.Time.Testing; + +namespace Medications.Unit.Tests +{ + public class MedicationMappingUnitTests + { + [Fact] + public void ToMedication_MapsCorrectly() + { + var clock = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero)); + var request = new CreateMedicationRequest { Name = " Aspirin ", Quantity = 5 }; + + var medication = request.ToMedication(clock); + + Assert.Equal(clock.GetUtcNow(), medication.CreationDate); + Assert.Equal(request.Name.Trim(), medication.Name); + Assert.Equal(5, medication.Quantity); + } + + [Fact] + public void ToResponse_MapsCorrectly() + { + var medication = new Medication + { + Name = "Aspirin", + Quantity = 5, + CreationDate = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero) + }; + + var response = medication.ToResponse(); + + Assert.Equal(medication.Id, response.Id); + Assert.Equal(medication.Name, response.Name); + Assert.Equal(medication.Quantity, response.Quantity); + Assert.Equal(medication.CreationDate, response.CreationDate); + } + + } +} diff --git a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj index e77a9b8..3fd85d6 100644 --- a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj +++ b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj @@ -5,6 +5,9 @@ enable enable false + + $(MSBuildThisFileDirectory)..\test.runsettings @@ -12,6 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs b/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs new file mode 100644 index 0000000..f740631 --- /dev/null +++ b/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs @@ -0,0 +1,51 @@ +using Medications.Api.Contracts; +using Medications.Api.Data; +using Medications.Api.Endpoints; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Time.Testing; + +namespace Medications.Unit.Tests +{ + public class PostEndpointUnitTests + { + [Fact] + public async Task PostMedication_Returns201CreatedAtRoute() + { + await using var dbContext = CreateContext(); + var medicationRequest = GenerateCreateMedicationRequest("Paracetamol", 10); + var clock = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero)); + + var result = await MedicationEndpoints.CreateMedication( + medicationRequest, dbContext, clock, CancellationToken.None); + + var response = Assert.IsType>(result); + Assert.NotNull(response.Value); + Assert.Equal(1, response.Value.Id); + Assert.Equal("GetMedication", response.RouteName); + Assert.Equal(1, response.RouteValues["id"]); + Assert.Equal(medicationRequest.Name, response.Value.Name); + Assert.Equal(medicationRequest.Quantity, response.Value.Quantity); + + var persistedMedication = dbContext.Medications.Find(1); + Assert.NotNull(persistedMedication); + Assert.Equal(medicationRequest.Name, persistedMedication.Name); + Assert.Equal(medicationRequest.Quantity, persistedMedication.Quantity); + Assert.Equal(clock.GetUtcNow(), persistedMedication.CreationDate); + } + + private static MedicationsDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new MedicationsDbContext(options); + } + + private static CreateMedicationRequest GenerateCreateMedicationRequest(string name, int quantity) + { + return new() { Name = name, Quantity = quantity }; + } + } +} diff --git a/tests/test.runsettings b/tests/test.runsettings new file mode 100644 index 0000000..8084fb0 --- /dev/null +++ b/tests/test.runsettings @@ -0,0 +1,38 @@ + + + + + + + + + cobertura + GeneratedCodeAttribute + + + + + + + + + + .*tests\.dll$ + + + + + ^System\.CodeDom\.Compiler\.GeneratedCodeAttribute$ + ^System\.Diagnostics\.CodeAnalysis\.ExcludeFromCodeCoverageAttribute$ + + + + + + + + + From 71fd9330fa5c905067e053c08b5bf814da358613 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:07:49 +0100 Subject: [PATCH 11/11] cleanup --- .../Endpoints/MedicationEndpoints.cs | 4 +- .../DeleteEndpointUnitTests.cs | 29 +------- .../GetAllEndpointUnitTests.cs | 35 +++------- .../GetByIdEndpointUnitTests.cs | 66 +++++-------------- .../PostEndpointUnitTests.cs | 12 +--- .../TestDbContextFactory.cs | 36 ++++++++++ 6 files changed, 68 insertions(+), 114 deletions(-) create mode 100644 tests/Medications.Unit.Tests/TestDbContextFactory.cs diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index 6e23302..0c91d00 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -45,7 +45,9 @@ public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBu internal static async Task>> GetMedications( MedicationsDbContext dbContext, CancellationToken cancellationToken) { - var medications = await dbContext.Medications.ToListAsync(cancellationToken); + var medications = await dbContext.Medications + .OrderBy(medication => medication.Id) + .ToListAsync(cancellationToken); var medicationResponseList = medications.Select(medication => medication.ToResponse()).ToList(); diff --git a/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs b/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs index 376516d..42ea574 100644 --- a/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs +++ b/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs @@ -1,9 +1,7 @@ -using Medications.Api.Data; using Medications.Api.Endpoints; -using Medications.Api.Entities; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.EntityFrameworkCore; +using static Medications.Unit.Tests.TestDbContextFactory; namespace Medications.Unit.Tests { @@ -44,32 +42,9 @@ public async Task DeleteMedication_WhenIdNotGreaterThanZero_ReturnsBadRequest(in var problem = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); + Assert.Equal("Id must be greater than zero.", problem.ProblemDetails.Detail); Assert.NotNull(dbContext.Medications.Find(1)); Assert.NotNull(dbContext.Medications.Find(2)); - Assert.Equal("Id must be greater than zero.", problem.ProblemDetails.Detail); } - - private static MedicationsDbContext CreateContext(params Medication[] seed) - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - - var dbContext = new MedicationsDbContext(options); - - dbContext.Medications.AddRange(seed); - dbContext.SaveChanges(); - - return dbContext; - } - - private static Medication NewMedication(int id) => new() - { - Id = id, - Name = $"Test Medication {id}", - Quantity = 10 * id, - CreationDate = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero) - }; - } } diff --git a/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs b/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs index dd497c4..5bf4082 100644 --- a/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs +++ b/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs @@ -1,10 +1,7 @@ using Medications.Api.Contracts; -using Medications.Api.Data; using Medications.Api.Endpoints; -using Medications.Api.Entities; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.EntityFrameworkCore; +using static Medications.Unit.Tests.TestDbContextFactory; namespace Medications.Unit.Tests { @@ -20,43 +17,31 @@ public async Task GetAllMedications_WhenMedicationsExist_ReturnsOK() var result = await MedicationEndpoints.GetMedications(dbContext, CancellationToken.None); var response = Assert.IsType>>(result); - Assert.Equal(StatusCodes.Status200OK, response.StatusCode); Assert.NotNull(response.Value); + Assert.Equal(2, response.Value.Count); - Assert.NotNull(response.Value[0]); + // Index order is meaningful because the endpoint orders by id. Assert.Equal(medication1.Id, response.Value[0].Id); Assert.Equal(medication1.Name, response.Value[0].Name); Assert.Equal(medication1.Quantity, response.Value[0].Quantity); Assert.Equal(medication1.CreationDate, response.Value[0].CreationDate); - Assert.NotNull(response.Value[1]); Assert.Equal(medication2.Id, response.Value[1].Id); Assert.Equal(medication2.Name, response.Value[1].Name); Assert.Equal(medication2.Quantity, response.Value[1].Quantity); Assert.Equal(medication2.CreationDate, response.Value[1].CreationDate); } - - private static MedicationsDbContext CreateContext(params Medication[] seed) + [Fact] + public async Task GetAllMedications_WhenNoMedicationsExist_ReturnsEmptyList() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; + await using var dbContext = CreateContext(); - var dbContext = new MedicationsDbContext(options); - - dbContext.Medications.AddRange(seed); - dbContext.SaveChanges(); + var result = await MedicationEndpoints.GetMedications(dbContext, CancellationToken.None); - return dbContext; + var response = Assert.IsType>>(result); + Assert.NotNull(response.Value); + Assert.Empty(response.Value); } - - private static Medication NewMedication(int id) => new() - { - Id = id, - Name = $"Test Medication {id}", - Quantity = 10 * id, - CreationDate = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero) - }; } } diff --git a/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs b/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs index f606806..3d6f5fc 100644 --- a/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs +++ b/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs @@ -1,40 +1,29 @@ using Medications.Api.Contracts; -using Medications.Api.Data; using Medications.Api.Endpoints; -using Medications.Api.Entities; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.EntityFrameworkCore; +using static Medications.Unit.Tests.TestDbContextFactory; namespace Medications.Unit.Tests { public class GetByIdEndpointUnitTests { - [Fact] - public async Task GetMedicationById_WhenMedicationExists_ReturnsOK() + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task GetMedicationById_WhenMedicationExists_ReturnsOK(int id) { - var medication1 = NewMedication(1); - var medication2 = NewMedication(2); - await using var dbContext = CreateContext(medication1, medication2); - - var result1 = await MedicationEndpoints.GetMedication(1, dbContext, CancellationToken.None); - var result2 = await MedicationEndpoints.GetMedication(2, dbContext, CancellationToken.None); + var expected = NewMedication(id); + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); - var response1 = Assert.IsType>(result1.Result); - Assert.Equal(StatusCodes.Status200OK, response1.StatusCode); - Assert.NotNull(response1.Value); - Assert.Equal(medication1.Id, response1.Value.Id); - Assert.Equal(medication1.Name, response1.Value.Name); - Assert.Equal(medication1.Quantity, response1.Value.Quantity); - Assert.Equal(medication1.CreationDate, response1.Value.CreationDate); + var result = await MedicationEndpoints.GetMedication(id, dbContext, CancellationToken.None); - var response2 = Assert.IsType>(result2.Result); - Assert.Equal(StatusCodes.Status200OK, response2.StatusCode); - Assert.NotNull(response2.Value); - Assert.Equal(medication2.Id, response2.Value.Id); - Assert.Equal(medication2.Name, response2.Value.Name); - Assert.Equal(medication2.Quantity, response2.Value.Quantity); - Assert.Equal(medication2.CreationDate, response2.Value.CreationDate); + var response = Assert.IsType>(result.Result); + Assert.NotNull(response.Value); + Assert.Equal(expected.Id, response.Value.Id); + Assert.Equal(expected.Name, response.Value.Name); + Assert.Equal(expected.Quantity, response.Value.Quantity); + Assert.Equal(expected.CreationDate, response.Value.CreationDate); } [Fact] @@ -42,10 +31,9 @@ public async Task GetMedicationById_WhenMedicationDoesNotExist_ReturnsNotFound() { await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); - var result1 = await MedicationEndpoints.GetMedication(3, dbContext, CancellationToken.None); + var result = await MedicationEndpoints.GetMedication(3, dbContext, CancellationToken.None); - var response1 = Assert.IsType(result1.Result); - Assert.Equal(StatusCodes.Status404NotFound, response1.StatusCode); + Assert.IsType(result.Result); } [Theory] @@ -61,27 +49,5 @@ public async Task GetMedicationById_WhenNotGreaterThanZero_ReturnsBadRequest(int Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); Assert.Equal("Id must be greater than zero.", problem.ProblemDetails.Detail); } - - private static MedicationsDbContext CreateContext(params Medication[] seed) - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - - var dbContext = new MedicationsDbContext(options); - - dbContext.Medications.AddRange(seed); - dbContext.SaveChanges(); - - return dbContext; - } - - private static Medication NewMedication(int id) => new() - { - Id = id, - Name = $"Test Medication {id}", - Quantity = 10 * id, - CreationDate = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero) - }; } } diff --git a/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs b/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs index f740631..4f0e553 100644 --- a/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs +++ b/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs @@ -1,9 +1,8 @@ using Medications.Api.Contracts; -using Medications.Api.Data; using Medications.Api.Endpoints; using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Time.Testing; +using static Medications.Unit.Tests.TestDbContextFactory; namespace Medications.Unit.Tests { @@ -34,15 +33,6 @@ public async Task PostMedication_Returns201CreatedAtRoute() Assert.Equal(clock.GetUtcNow(), persistedMedication.CreationDate); } - private static MedicationsDbContext CreateContext() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - - return new MedicationsDbContext(options); - } - private static CreateMedicationRequest GenerateCreateMedicationRequest(string name, int quantity) { return new() { Name = name, Quantity = quantity }; diff --git a/tests/Medications.Unit.Tests/TestDbContextFactory.cs b/tests/Medications.Unit.Tests/TestDbContextFactory.cs new file mode 100644 index 0000000..84eb8b4 --- /dev/null +++ b/tests/Medications.Unit.Tests/TestDbContextFactory.cs @@ -0,0 +1,36 @@ +using Medications.Api.Data; +using Medications.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Medications.Unit.Tests +{ + /// + /// Shared fixture helpers. + /// + internal static class TestDbContextFactory + { + internal static readonly DateTimeOffset CreationDate = new(2026, 1, 15, 9, 30, 0, TimeSpan.Zero); + + internal static MedicationsDbContext CreateContext(params Medication[] seed) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbContext = new MedicationsDbContext(options); + + dbContext.Medications.AddRange(seed); + dbContext.SaveChanges(); + + return dbContext; + } + + internal static Medication NewMedication(int id) => new() + { + Id = id, + Name = $"Test Medication {id}", + Quantity = 10 * id, + CreationDate = CreationDate + }; + } +}