Merge pull request 'add-verspakket-with-beoordeling' (#2) from add-verspakket-with-beoordeling into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-24 21:17:26 +02:00
8 changed files with 136 additions and 15 deletions
+13 -7
View File
@@ -1,4 +1,4 @@
# Copilot Instructions for Lutra
# Copilot Instructions for Lutra
Use these standards when generating or modifying code in this repository.
@@ -13,6 +13,8 @@ Use these standards when generating or modifying code in this repository.
- `Lutra.API`
- `Lutra.AppHost`
- `Lutra.Infrastructure.Migrator`
- `Lutra.Application.UnitTests` — xUnit unit tests for Application handlers
- integration tests for API behavior (`Lutra.API.IntegrationTests`)
- Database: PostgreSQL via EF Core
- Orchestration: .NET Aspire
- Messaging pattern: `Cortex.Mediator` for CQRS handling
@@ -31,10 +33,11 @@ Use these standards when generating or modifying code in this repository.
## Clean Architecture guidance
Use Jason Taylors CleanArchitecture project as a reference for intent and structure, but adapt to this codebases actual implementation.
Use Jason Taylor’s CleanArchitecture project as a reference for intent and structure, but adapt to this codebase’s actual implementation.
Where the reference template uses MediatR, this project uses `Cortex.Mediator`.
Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the repository already uses them or the change clearly requires them.
The established test stack is **xUnit** with **FluentAssertions** — both are already in use and should be used for all new tests.
Add FluentValidation, AutoMapper, Shouldly, Moq, or Respawn only if the repository already uses them or the change clearly requires them.
## Domain layer rules
@@ -57,6 +60,7 @@ Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the r
- Use `Cortex.Mediator` request and handler interfaces.
- Keep handlers focused on orchestration and application logic.
- Use `ILutraDbContext` rather than referencing the concrete DbContext directly when possible.
- Shared model types (DTOs, enums) that are not use-case-specific live in `Models/<FeatureArea>/` (e.g., `Models/Verspakketten/`, `Models/Supermarkten/`).
## Infrastructure rules
@@ -89,10 +93,12 @@ Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the r
## Testing guidance
- If tests exist, update or add tests alongside behavior changes.
- Follow the reference templates intent for test coverage:
- unit tests for business logic
- integration tests for infrastructure and data access
- functional tests for API behavior
- Follow the reference template's intent for test coverage:
- unit tests for business logic (`Lutra.Application.UnitTests`)
- integration tests for API behavior (`Lutra.API.IntegrationTests`)
- Test framework: **xUnit** (`xunit` v2 + `xunit.runner.visualstudio`).
- Assertions: **FluentAssertions**.
- Integration tests use **SQLite** (via `Microsoft.EntityFrameworkCore.Sqlite`) as the in-process test database -- not PostgreSQL. Use `LutraApiFactory` and `IntegrationTestBase` as the base infrastructure.
- Keep tests readable and focused on behavior.
## When making changes
@@ -91,7 +91,7 @@ public class VerspakkettenControllerTests(LutraApiFactory factory)
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var command = new CreateVerspakket.Command("Herfst Pakket", 1499, 3, supermarkt.Id);
var command = new CreateVerspakket.Command("Herfst Pakket", 1499, 3, supermarkt.Id, null);
var response = await Client.PostAsJsonAsync("/api/verspakketten", command);
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -100,10 +100,43 @@ public class VerspakkettenControllerTests(LutraApiFactory factory)
response.Headers.Location.Should().NotBeNull();
}
[Fact]
public async Task Post_CreatesVerspakket_WithBeoordeling_AndReturns201()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Picnic",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var command = new CreateVerspakket.Command(
"Herfst Pakket",
1499,
3,
supermarkt.Id,
new Lutra.Application.Models.Verspakketten.Beoordeling
{
CijferSmaak = 9,
CijferBereiden = 8,
Aanbevolen = true,
Tekst = "Heel goed"
});
var response = await Client.PostAsJsonAsync("/api/verspakketten", command);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<CreateVerspakket.Response>();
body!.Id.Should().NotBeEmpty();
var created = await Client.GetFromJsonAsync<GetVerspakket.Response>($"/api/verspakketten/{body.Id}");
created!.Verspakket.Beoordelingen.Should().ContainSingle();
created.Verspakket.Beoordelingen!.Single().CijferSmaak.Should().Be(9);
}
[Fact]
public async Task Post_ReturnsBadRequest_WhenSupermarktDoesNotExist()
{
var command = new CreateVerspakket.Command("Winter Pakket", 999, 2, Guid.NewGuid());
var command = new CreateVerspakket.Command("Winter Pakket", 999, 2, Guid.NewGuid(), null);
var response = await Client.PostAsJsonAsync("/api/verspakketten", command);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -1,6 +1,5 @@
using Cortex.Mediator;
using Lutra.Application.Supermarkten;
using Lutra.Application.Verspakketten;
using Microsoft.AspNetCore.Mvc;
namespace Lutra.API.Controllers;
+18
View File
@@ -14,6 +14,22 @@ namespace Lutra.API
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowLocalDevelopment", policy =>
policy.SetIsOriginAllowed(origin =>
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri))
{
return false;
}
return uri.Host is "localhost" or "127.0.0.1" or "[::1]";
})
.AllowAnyHeader()
.AllowAnyMethod());
});
builder.Services.AddCortexMediator(
handlerAssemblyMarkerTypes: [typeof(Program), typeof(GetVerspakketten)],
options => options.AddDefaultBehaviors()
@@ -37,6 +53,8 @@ namespace Lutra.API
app.UseHttpsRedirection();
app.UseCors("AllowLocalDevelopment");
app.UseAuthorization();
@@ -1,5 +1,6 @@
using FluentAssertions;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Lutra.Application.Verspakketten;
using Moq;
using Moq.EntityFrameworkCore;
@@ -30,7 +31,7 @@ public class CreateVerspakketHandlerTests
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, supermarktId);
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, supermarktId, null);
var result = await _handler.Handle(command, CancellationToken.None);
@@ -43,7 +44,7 @@ public class CreateVerspakketHandlerTests
{
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(new List<Domain.Entities.Supermarkt>());
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, Guid.NewGuid());
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, Guid.NewGuid(), null);
var act = () => _handler.Handle(command, CancellationToken.None);
@@ -68,7 +69,7 @@ public class CreateVerspakketHandlerTests
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => savedVerspakket = v);
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command("Zomer Pakket", 999, 4, supermarktId);
var command = new CreateVerspakket.Command("Zomer Pakket", 999, 4, supermarktId, null);
await _handler.Handle(command, CancellationToken.None);
@@ -78,4 +79,45 @@ public class CreateVerspakketHandlerTests
savedVerspakket.AantalPersonen.Should().Be(4);
savedVerspakket.SupermarktId.Should().Be(supermarktId);
}
[Fact]
public async Task Handle_WithBeoordeling_CreatesVerspakketWithBeoordeling()
{
var supermarktId = Guid.NewGuid();
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "Jumbo", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
Domain.Entities.Verspakket? savedVerspakket = null;
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => savedVerspakket = v);
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command(
"Zomer Pakket",
999,
4,
supermarktId,
new Beoordeling
{
CijferSmaak = 8,
CijferBereiden = 7,
Aanbevolen = true,
Tekst = "Lekker"
});
await _handler.Handle(command, CancellationToken.None);
savedVerspakket.Should().NotBeNull();
savedVerspakket!.Beoordelingen.Should().ContainSingle();
var beoordeling = savedVerspakket.Beoordelingen.Single();
beoordeling.CijferSmaak.Should().Be(8);
beoordeling.CijferBereiden.Should().Be(7);
beoordeling.Aanbevolen.Should().BeTrue();
beoordeling.Tekst.Should().Be("Lekker");
}
}
@@ -1,12 +1,17 @@
namespace Lutra.Application.Models.Verspakketten;
using System.ComponentModel.DataAnnotations;
namespace Lutra.Application.Models.Verspakketten;
public class Beoordeling
{
[Range(1, 10)]
public required int CijferSmaak { get; init; }
[Range(1, 10)]
public required int CijferBereiden { get; init; }
public required bool Aanbevolen { get; init; }
[MaxLength(1024)]
public string? Tekst { get; init; }
}
@@ -1,4 +1,5 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Models.Verspakketten;
using System.ComponentModel.DataAnnotations;
namespace Lutra.Application.Verspakketten;
@@ -9,5 +10,6 @@ public sealed partial class CreateVerspakket
string Naam,
int? PrijsInCenten,
[Range(1, 10)] int AantalPersonen,
Guid SupermarktId) : ICommand<Response>;
Guid SupermarktId,
Beoordeling? Beoordeling) : ICommand<Response>;
}
@@ -1,5 +1,6 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten;
@@ -31,6 +32,21 @@ public sealed partial class CreateVerspakket
ModifiedAt = now
};
if (request.Beoordeling is not null)
{
verspakket.AddBeoordeling(new Domain.Entities.Beoordeling
{
Id = Guid.NewGuid(),
CijferSmaak = request.Beoordeling.CijferSmaak,
CijferBereiden = request.Beoordeling.CijferBereiden,
Aanbevolen = request.Beoordeling.Aanbevolen,
Tekst = request.Beoordeling.Tekst,
VerspakketId = verspakket.Id,
CreatedAt = now,
ModifiedAt = now
});
}
await context.Verspaketten.AddAsync(verspakket, cancellationToken);
await context.SaveChangesAsync(cancellationToken);