diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa63b4d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + build-and-test: + + runs-on: ubuntu-latest + + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + + - 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 + # RunSettingsFilePath points at tests/test.runsettings in cobertura format + run: dotnet test --no-build + + - 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 b7c04c7..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 diff --git a/Directory.Packages.props b/Directory.Packages.props index d5b8811..008f955 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,12 @@ + - - + + + + + \ No newline at end of file diff --git a/Medications.slnx b/Medications.slnx index f4c7203..ba4ce66 100644 --- a/Medications.slnx +++ b/Medications.slnx @@ -1,6 +1,10 @@ - - + + + + + + diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index 279d1f6..0c91d00 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -8,46 +8,65 @@ namespace Medications.Api.Endpoints; public static class MedicationEndpoints { + 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("/", GetAllMedications) - .WithName("GetMedications") + group.MapGet("/", GetMedications) + .WithName(GetMedicationsEndpointName) .WithDescription("Retrieve all medications."); group.MapGet("/{id}", GetMedication) - .WithName("GetMedication") - .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("CreateMedication") - .WithDescription("Create a new medication"); + .WithName(CreateMedicationEndpointName) + .WithDescription("Create a new medication") + .ProducesValidationProblem(); + + group.MapDelete("/{id}", DeleteMedication) + .WithName(DeleteMedicationEndpointName) + .WithDescription("Delete a medication") + .ProducesProblem(StatusCodes.Status400BadRequest); return endpoints; } - private static async Task>> GetAllMedications(MedicationsDbContext dbContext) + internal static async Task>> GetMedications( + MedicationsDbContext dbContext, CancellationToken cancellationToken) { - var medications = await dbContext.Medications.ToListAsync(); + var medications = await dbContext.Medications + .OrderBy(medication => medication.Id) + .ToListAsync(cancellationToken); var medicationResponseList = medications.Select(medication => medication.ToResponse()).ToList(); return TypedResults.Ok(medicationResponseList); } - private static async Task, NotFound>> GetMedication( - int id, MedicationsDbContext dbContext) + internal static async Task, NotFound, ProblemHttpResult>> GetMedication( + int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) { - var medication = await dbContext.Medications.FindAsync(id); + if (id <= 0) return InvalidId(); + + var medication = await dbContext.Medications.FindAsync([id], cancellationToken); if (medication is null) return TypedResults.NotFound(); return TypedResults.Ok(medication.ToResponse()); } - private static async Task> CreateMedication( + internal static async Task> CreateMedication( CreateMedicationRequest request, MedicationsDbContext dbContext, TimeProvider timeProvider, @@ -61,6 +80,25 @@ private static async Task> CreateMedication( var medicationResponse = medication.ToResponse(); - return TypedResults.CreatedAtRoute(medicationResponse, "GetMedication", new { id = medicationResponse.Id }); + return TypedResults.CreatedAtRoute(medicationResponse, GetMedicationEndpointName, new { id = medicationResponse.Id }); } + + 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(); + + dbContext.Medications.Remove(medication); + 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 08df76d..043f62a 100644 --- a/src/Medications.Api/Medications.Api.csproj +++ b/src/Medications.Api/Medications.Api.csproj @@ -4,6 +4,11 @@ + + + + + diff --git a/src/Medications.Api/Program.cs b/src/Medications.Api/Program.cs index e4d51d5..1f6c836 100644 --- a/src/Medications.Api/Program.cs +++ b/src/Medications.Api/Program.cs @@ -1,20 +1,40 @@ 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")); 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(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + 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/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 @@ - - - - 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/DeleteEndpointUnitTests.cs b/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs new file mode 100644 index 0000000..42ea574 --- /dev/null +++ b/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs @@ -0,0 +1,50 @@ +using Medications.Api.Endpoints; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using static Medications.Unit.Tests.TestDbContextFactory; + +namespace Medications.Unit.Tests +{ + public class DeleteEndpointUnitTests + { + [Fact] + public async Task DeleteMedication_WhenMedicationExists_ReturnsNoContent() + { + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); + + var result = await MedicationEndpoints.DeleteMedication(1, dbContext, CancellationToken.None); + + Assert.IsType(result.Result); + Assert.Null(dbContext.Medications.Find(1)); + Assert.NotNull(dbContext.Medications.Find(2)); + } + + [Fact] + public async Task DeleteMedication_WhenMedicationDoesNotExist_ReturnsNotFound() + { + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); + + 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)); + } + + [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(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); + Assert.NotNull(dbContext.Medications.Find(1)); + Assert.NotNull(dbContext.Medications.Find(2)); + } + } +} diff --git a/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs b/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs new file mode 100644 index 0000000..5bf4082 --- /dev/null +++ b/tests/Medications.Unit.Tests/GetAllEndpointUnitTests.cs @@ -0,0 +1,47 @@ +using Medications.Api.Contracts; +using Medications.Api.Endpoints; +using Microsoft.AspNetCore.Http.HttpResults; +using static Medications.Unit.Tests.TestDbContextFactory; + +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.NotNull(response.Value); + Assert.Equal(2, response.Value.Count); + + // 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.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); + } + + [Fact] + public async Task GetAllMedications_WhenNoMedicationsExist_ReturnsEmptyList() + { + await using var dbContext = CreateContext(); + + var result = await MedicationEndpoints.GetMedications(dbContext, CancellationToken.None); + + var response = Assert.IsType>>(result); + Assert.NotNull(response.Value); + Assert.Empty(response.Value); + } + } +} diff --git a/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs b/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs new file mode 100644 index 0000000..3d6f5fc --- /dev/null +++ b/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs @@ -0,0 +1,53 @@ +using Medications.Api.Contracts; +using Medications.Api.Endpoints; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using static Medications.Unit.Tests.TestDbContextFactory; + +namespace Medications.Unit.Tests +{ + public class GetByIdEndpointUnitTests + { + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task GetMedicationById_WhenMedicationExists_ReturnsOK(int id) + { + var expected = NewMedication(id); + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); + + var result = await MedicationEndpoints.GetMedication(id, dbContext, CancellationToken.None); + + 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] + public async Task GetMedicationById_WhenMedicationDoesNotExist_ReturnsNotFound() + { + await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); + + var result = await MedicationEndpoints.GetMedication(3, dbContext, CancellationToken.None); + + Assert.IsType(result.Result); + } + + [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); + } + } +} 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 new file mode 100644 index 0000000..3fd85d6 --- /dev/null +++ b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + false + + $(MSBuildThisFileDirectory)..\test.runsettings + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs b/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs new file mode 100644 index 0000000..4f0e553 --- /dev/null +++ b/tests/Medications.Unit.Tests/PostEndpointUnitTests.cs @@ -0,0 +1,41 @@ +using Medications.Api.Contracts; +using Medications.Api.Endpoints; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Time.Testing; +using static Medications.Unit.Tests.TestDbContextFactory; + +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 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 + }; + } +} 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$ + + + + + + + + +