diff --git a/README.md b/README.md index 17f82c1..2e7ed91 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,12 @@ # moisson-project Windows desktop application for accurate donation valuation and tax receipt generation for Moisson Montréal (Montréal, QC). +## Running tests + +```bash +dotnet test # all tests (Docker must be running) +dotnet test --filter "Category!=Integration" # unit tests only, no Docker needed +``` + +Integration tests use Testcontainers to start a disposable PostgreSQL 17 container, so Docker Desktop (or Docker Engine) must be running. + + diff --git a/src/MoissonMontreal.Api/Controllers/WeatherForecastController.cs b/src/MoissonMontreal.Api/Controllers/WeatherForecastController.cs deleted file mode 100644 index bf425b8..0000000 --- a/src/MoissonMontreal.Api/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace MoissonMontreal.Api.Controllers; - -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private static readonly string[] Summaries = - [ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - ]; - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } -} diff --git a/src/MoissonMontreal.Api/Data/MoissonDbContext.cs b/src/MoissonMontreal.Api/Data/MoissonDbContext.cs new file mode 100644 index 0000000..bc3c387 --- /dev/null +++ b/src/MoissonMontreal.Api/Data/MoissonDbContext.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore; + +namespace MoissonMontreal.Api.Data; + +public class MoissonDbContext(DbContextOptions options) : DbContext(options) +{ + // Les tables (DbSet, DbSet, ...) seront ajoutées avec l'issue du schéma. +} diff --git a/src/MoissonMontreal.Api/MoissonMontreal.Api.csproj b/src/MoissonMontreal.Api/MoissonMontreal.Api.csproj index a3c98d8..b7495b4 100644 --- a/src/MoissonMontreal.Api/MoissonMontreal.Api.csproj +++ b/src/MoissonMontreal.Api/MoissonMontreal.Api.csproj @@ -1,9 +1,10 @@ - + net10.0 enable enable + 61115b38-e487-4411-983f-5d85d34daf9f @@ -12,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/src/MoissonMontreal.Api/MoissonMontreal.Api.http b/src/MoissonMontreal.Api/MoissonMontreal.Api.http index 01669a9..ed734ad 100644 --- a/src/MoissonMontreal.Api/MoissonMontreal.Api.http +++ b/src/MoissonMontreal.Api/MoissonMontreal.Api.http @@ -1,6 +1,4 @@ @MoissonMontreal.Api_HostAddress = http://localhost:5297 -GET {{MoissonMontreal.Api_HostAddress}}/weatherforecast/ -Accept: application/json - +GET {{MoissonMontreal.Api_HostAddress}}/health ### diff --git a/src/MoissonMontreal.Api/Program.cs b/src/MoissonMontreal.Api/Program.cs index 666a9c5..fe32925 100644 --- a/src/MoissonMontreal.Api/Program.cs +++ b/src/MoissonMontreal.Api/Program.cs @@ -1,6 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using MoissonMontreal.Api.Data; + + var builder = WebApplication.CreateBuilder(args); +var connectionString = builder.Configuration.GetConnectionString("Moisson") + ?? throw new InvalidOperationException( + "La chaîne de connexion 'Moisson' est absente. Définissez ConnectionStrings__Moisson " + + "ou utilisez 'dotnet user-secrets'."); + + // Add services to the container. +builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); + +builder.Services.AddHealthChecks() + .AddDbContextCheck("database"); builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi @@ -14,10 +28,15 @@ app.MapOpenApi(); } -app.UseHttpsRedirection(); +// Health probes call /health over plain HTTP, so it must not be redirected. +app.UseWhen( + context => !context.Request.Path.StartsWithSegments("/health"), + branch => branch.UseHttpsRedirection()); app.UseAuthorization(); app.MapControllers(); +app.MapHealthChecks("/health"); + app.Run(); diff --git a/src/MoissonMontreal.Api/WeatherForecast.cs b/src/MoissonMontreal.Api/WeatherForecast.cs deleted file mode 100644 index be41ad1..0000000 --- a/src/MoissonMontreal.Api/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace MoissonMontreal.Api; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -} diff --git a/src/MoissonMontreal.Api/appsettings.Development.json b/src/MoissonMontreal.Api/appsettings.Development.json index 0c208ae..1e60eb8 100644 --- a/src/MoissonMontreal.Api/appsettings.Development.json +++ b/src/MoissonMontreal.Api/appsettings.Development.json @@ -1,4 +1,7 @@ { + "ConnectionStrings": { + "Moisson": "Host=localhost;Port=5432;Database=moisson_dev;Username=moisson;Password=moisson" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/MoissonMontreal.Tests/Api/HealthEndpointTests.cs b/tests/MoissonMontreal.Tests/Api/HealthEndpointTests.cs new file mode 100644 index 0000000..3c9d7ca --- /dev/null +++ b/tests/MoissonMontreal.Tests/Api/HealthEndpointTests.cs @@ -0,0 +1,40 @@ +using System.Net; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using MoissonMontreal.Tests.Fixtures; + +namespace MoissonMontreal.Tests.Api; + +[Trait("Category", "Integration")] +public class HealthEndpointTests(PostgresFixture postgres) : IClassFixture +{ + [Fact] + public async Task Health_ReturnsHealthy_WhenDatabaseIsReachable() + { + await using var factory = CreateFactory(postgres.ConnectionString); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Health_ReturnsUnhealthy_WhenDatabaseIsUnreachable() + { + // Nothing listens on port 1, so the connection is refused immediately. + await using var factory = CreateFactory("Host=127.0.0.1;Port=1;Database=unreachable;Username=test;Timeout=2"); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("Unhealthy", await response.Content.ReadAsStringAsync()); + } + + private static WebApplicationFactory CreateFactory(string connectionString) => + new WebApplicationFactory().WithWebHostBuilder(builder => builder + .UseEnvironment("Testing") + .UseSetting("ConnectionStrings:Moisson", connectionString)); +} diff --git a/tests/MoissonMontreal.Tests/Fixtures/PostgresFixture.cs b/tests/MoissonMontreal.Tests/Fixtures/PostgresFixture.cs new file mode 100644 index 0000000..145445b --- /dev/null +++ b/tests/MoissonMontreal.Tests/Fixtures/PostgresFixture.cs @@ -0,0 +1,17 @@ +using Testcontainers.PostgreSql; + +namespace MoissonMontreal.Tests.Fixtures; + +/// +/// Starts a single PostgreSQL container shared by every test of a class. +/// +public class PostgresFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17").Build(); + + public string ConnectionString => _container.GetConnectionString(); + + public Task InitializeAsync() => _container.StartAsync(); + + public Task DisposeAsync() => _container.DisposeAsync().AsTask(); +} \ No newline at end of file diff --git a/tests/MoissonMontreal.Tests/MoissonMontreal.Tests.csproj b/tests/MoissonMontreal.Tests/MoissonMontreal.Tests.csproj index bcb3521..4aae91e 100644 --- a/tests/MoissonMontreal.Tests/MoissonMontreal.Tests.csproj +++ b/tests/MoissonMontreal.Tests/MoissonMontreal.Tests.csproj @@ -9,7 +9,9 @@ + +