From 588c6131bc9bf6de0e26d923e67e311119f32bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thom=C3=A1s=20Henrique?= Date: Mon, 8 Jun 2026 13:14:27 -0300 Subject: [PATCH] feat(orders): POST /orders grava pedido + OrderPlaced no outbox (fase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primeiro uso real do outbox da fase 1: criar um pedido persiste o agregado Order e o evento OrderPlaced na MESMA transação (sem dual-write, ADR-001). - Domain: agregado Order (Pending/Confirmed/Cancelled) com factory Place() + guards - Infrastructure: OrdersDbContext deriva de MessagingDbContext (order+outbox+inbox no schema orders, ADR-005); OrderConfiguration snake_case; design-time factory - Migrations EF reais (InitialCreate: schema orders + 3 tabelas + ix_outbox_pending); dotnet-ef como tool local (.config/dotnet-tools.json) - Feature PlaceOrder: handler cria Order + outbox.Add(OrderPlaced) + 1 SaveChanges - Host: DI completa, migrate no startup, endpoint POST /orders - Teste de integração (Testcontainers Postgres) prova persistência atômica Infra: EF Core alinhado a 10.0.4 (versão contra a qual o Npgsql 10.0.2 foi compilado) para unificar o grafo e zerar o aviso MSB3277. Migrations marcadas como código gerado no .editorconfig. Build limpo (0/0); unit 4/4; integration PlaceOrder + Outbox verdes. --- .config/dotnet-tools.json | 13 ++ .editorconfig | 5 + Directory.Packages.props | 8 +- .../BuildingBlocks.Persistence.csproj | 1 + src/Services/Orders/Domain/Order.cs | 55 ++++++++ src/Services/Orders/Features/PlaceOrder.cs | 32 +++++ .../Infrastructure/OrderConfiguration.cs | 21 +++ .../Orders/Infrastructure/OrdersDbContext.cs | 24 ++++ .../Infrastructure/OrdersDbContextFactory.cs | 20 +++ .../20260608160223_InitialCreate.Designer.cs | 123 ++++++++++++++++++ .../20260608160223_InitialCreate.cs | 88 +++++++++++++ .../OrdersDbContextModelSnapshot.cs | 120 +++++++++++++++++ src/Services/Orders/Program.cs | 37 +++++- src/Services/Orders/Services.Orders.csproj | 7 + src/Services/Orders/appsettings.json | 12 +- tests/Integration/PlaceOrderTests.cs | 70 ++++++++++ tests/Integration/Tests.Integration.csproj | 1 + 17 files changed, 629 insertions(+), 8 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 src/Services/Orders/Domain/Order.cs create mode 100644 src/Services/Orders/Features/PlaceOrder.cs create mode 100644 src/Services/Orders/Infrastructure/OrderConfiguration.cs create mode 100644 src/Services/Orders/Infrastructure/OrdersDbContext.cs create mode 100644 src/Services/Orders/Infrastructure/OrdersDbContextFactory.cs create mode 100644 src/Services/Orders/Migrations/20260608160223_InitialCreate.Designer.cs create mode 100644 src/Services/Orders/Migrations/20260608160223_InitialCreate.cs create mode 100644 src/Services/Orders/Migrations/OrdersDbContextModelSnapshot.cs create mode 100644 tests/Integration/PlaceOrderTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..7dcefc3 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.8", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index bceadf0..4b91992 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,3 +29,8 @@ indent_size = 2 [*.md] trim_trailing_whitespace = false + +# EF Core migrations are generated code — don't hold them to our style rules. +[**/Migrations/*.cs] +generated_code = true +dotnet_diagnostic.IDE0161.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index 0ca608e..2a6ff7d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,10 +11,12 @@ + - - - + + + diff --git a/src/BuildingBlocks/Persistence/BuildingBlocks.Persistence.csproj b/src/BuildingBlocks/Persistence/BuildingBlocks.Persistence.csproj index fe06f3e..b6decf4 100644 --- a/src/BuildingBlocks/Persistence/BuildingBlocks.Persistence.csproj +++ b/src/BuildingBlocks/Persistence/BuildingBlocks.Persistence.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Services/Orders/Domain/Order.cs b/src/Services/Orders/Domain/Order.cs new file mode 100644 index 0000000..7361fda --- /dev/null +++ b/src/Services/Orders/Domain/Order.cs @@ -0,0 +1,55 @@ +namespace Services.Orders.Domain; + +public enum OrderStatus +{ + Pending, + Confirmed, + Cancelled, +} + +/// +/// Order aggregate. Created in ; later moved to +/// Confirmed/Cancelled by the saga (phases 4-5). Business rules live here, not in handlers. +/// +public sealed class Order +{ + public Guid Id { get; private set; } + + public string Sku { get; private set; } = null!; + + public int Quantity { get; private set; } + + public decimal Amount { get; private set; } + + public OrderStatus Status { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + private Order() + { + // EF Core materialization. + } + + private Order(Guid id, string sku, int quantity, decimal amount, DateTimeOffset createdAt) + { + Id = id; + Sku = sku; + Quantity = quantity; + Amount = amount; + Status = OrderStatus.Pending; + CreatedAt = createdAt; + } + + public static Order Place(string sku, int quantity, decimal amount, DateTimeOffset createdAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sku); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + return new Order(Guid.CreateVersion7(), sku, quantity, amount, createdAt); + } + + public void Confirm() => Status = OrderStatus.Confirmed; + + public void Cancel() => Status = OrderStatus.Cancelled; +} diff --git a/src/Services/Orders/Features/PlaceOrder.cs b/src/Services/Orders/Features/PlaceOrder.cs new file mode 100644 index 0000000..489d54e --- /dev/null +++ b/src/Services/Orders/Features/PlaceOrder.cs @@ -0,0 +1,32 @@ +using BuildingBlocks.Messaging; +using Contracts; +using Services.Orders.Domain; +using Services.Orders.Infrastructure; + +namespace Services.Orders.Features; + +public sealed record PlaceOrderRequest(string Sku, int Quantity, decimal Amount); + +public sealed record PlaceOrderResponse(Guid OrderId); + +/// +/// Creates a Pending order and enqueues into the outbox in the +/// SAME unit of work, then commits once. This is the first real use of the phase-1 outbox: +/// state change and integration event are atomic — no dual-write (ADR-001). +/// +public sealed class PlaceOrderHandler(OrdersDbContext db, IOutbox outbox, TimeProvider clock) +{ + public async Task HandleAsync(PlaceOrderRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + var order = Order.Place(request.Sku, request.Quantity, request.Amount, clock.GetUtcNow()); + + db.Orders.Add(order); + outbox.Add(new OrderPlaced(order.Id, order.Sku, order.Quantity, order.Amount)); + + await db.SaveChangesAsync(ct); + + return new PlaceOrderResponse(order.Id); + } +} diff --git a/src/Services/Orders/Infrastructure/OrderConfiguration.cs b/src/Services/Orders/Infrastructure/OrderConfiguration.cs new file mode 100644 index 0000000..27c3db5 --- /dev/null +++ b/src/Services/Orders/Infrastructure/OrderConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Services.Orders.Domain; + +namespace Services.Orders.Infrastructure; + +internal sealed class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("orders"); + + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.Sku).HasColumnName("sku").HasMaxLength(100).IsRequired(); + builder.Property(x => x.Quantity).HasColumnName("quantity"); + builder.Property(x => x.Amount).HasColumnName("amount").HasPrecision(18, 2); + builder.Property(x => x.Status).HasColumnName("status").HasConversion().HasMaxLength(20); + builder.Property(x => x.CreatedAt).HasColumnName("created_at"); + } +} diff --git a/src/Services/Orders/Infrastructure/OrdersDbContext.cs b/src/Services/Orders/Infrastructure/OrdersDbContext.cs new file mode 100644 index 0000000..da90405 --- /dev/null +++ b/src/Services/Orders/Infrastructure/OrdersDbContext.cs @@ -0,0 +1,24 @@ +using BuildingBlocks.Persistence; +using Microsoft.EntityFrameworkCore; +using Services.Orders.Domain; + +namespace Services.Orders.Infrastructure; + +/// +/// The Orders service context. Derives from so the +/// orders table and the outbox/inbox tables share one transaction — +/// the order and its OrderPlaced event commit atomically (ADR-001). Everything lives in +/// the orders schema (ADR-005). +/// +public sealed class OrdersDbContext(DbContextOptions options) : MessagingDbContext(options) +{ + public DbSet Orders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("orders"); + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/Services/Orders/Infrastructure/OrdersDbContextFactory.cs b/src/Services/Orders/Infrastructure/OrdersDbContextFactory.cs new file mode 100644 index 0000000..4ac7ccd --- /dev/null +++ b/src/Services/Orders/Infrastructure/OrdersDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Services.Orders.Infrastructure; + +/// +/// Design-time factory so dotnet ef migrations can build the context without +/// spinning the web host. The connection string here is never used at runtime. +/// +public sealed class OrdersDbContextFactory : IDesignTimeDbContextFactory +{ + public OrdersDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=dcl;Username=dcl;Password=dcl;SearchPath=orders") + .Options; + + return new OrdersDbContext(options); + } +} diff --git a/src/Services/Orders/Migrations/20260608160223_InitialCreate.Designer.cs b/src/Services/Orders/Migrations/20260608160223_InitialCreate.Designer.cs new file mode 100644 index 0000000..fac5153 --- /dev/null +++ b/src/Services/Orders/Migrations/20260608160223_InitialCreate.Designer.cs @@ -0,0 +1,123 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Services.Orders.Infrastructure; + +#nullable disable + +namespace Services.Orders.Migrations +{ + [DbContext(typeof(OrdersDbContext))] + [Migration("20260608160223_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("orders") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BuildingBlocks.Persistence.InboxMessage", b => + { + b.Property("MessageId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.HasKey("MessageId"); + + b.ToTable("inbox", "orders"); + }); + + modelBuilder.Entity("BuildingBlocks.Persistence.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt") + .HasDatabaseName("ix_outbox_pending") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox", "orders"); + }); + + modelBuilder.Entity("Services.Orders.Domain.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("quantity"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("sku"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("orders", "orders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Orders/Migrations/20260608160223_InitialCreate.cs b/src/Services/Orders/Migrations/20260608160223_InitialCreate.cs new file mode 100644 index 0000000..a195c17 --- /dev/null +++ b/src/Services/Orders/Migrations/20260608160223_InitialCreate.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Services.Orders.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "orders"); + + migrationBuilder.CreateTable( + name: "inbox", + schema: "orders", + columns: table => new + { + message_id = table.Column(type: "uuid", nullable: false), + processed_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_inbox", x => x.message_id); + }); + + migrationBuilder.CreateTable( + name: "orders", + schema: "orders", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + sku = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + quantity = table.Column(type: "integer", nullable: false), + amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_orders", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "outbox", + schema: "orders", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + payload = table.Column(type: "jsonb", nullable: false), + occurred_at = table.Column(type: "timestamp with time zone", nullable: false), + processed_at = table.Column(type: "timestamp with time zone", nullable: true), + attempts = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_outbox_pending", + schema: "orders", + table: "outbox", + column: "occurred_at", + filter: "processed_at IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "inbox", + schema: "orders"); + + migrationBuilder.DropTable( + name: "orders", + schema: "orders"); + + migrationBuilder.DropTable( + name: "outbox", + schema: "orders"); + } + } +} diff --git a/src/Services/Orders/Migrations/OrdersDbContextModelSnapshot.cs b/src/Services/Orders/Migrations/OrdersDbContextModelSnapshot.cs new file mode 100644 index 0000000..9ac53cc --- /dev/null +++ b/src/Services/Orders/Migrations/OrdersDbContextModelSnapshot.cs @@ -0,0 +1,120 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Services.Orders.Infrastructure; + +#nullable disable + +namespace Services.Orders.Migrations +{ + [DbContext(typeof(OrdersDbContext))] + partial class OrdersDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("orders") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BuildingBlocks.Persistence.InboxMessage", b => + { + b.Property("MessageId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.HasKey("MessageId"); + + b.ToTable("inbox", "orders"); + }); + + modelBuilder.Entity("BuildingBlocks.Persistence.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt") + .HasDatabaseName("ix_outbox_pending") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox", "orders"); + }); + + modelBuilder.Entity("Services.Orders.Domain.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("quantity"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("sku"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("orders", "orders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Orders/Program.cs b/src/Services/Orders/Program.cs index 1b2cd1e..90b5d39 100644 --- a/src/Services/Orders/Program.cs +++ b/src/Services/Orders/Program.cs @@ -1,13 +1,42 @@ +using BuildingBlocks.Messaging; +using BuildingBlocks.Persistence; +using Microsoft.EntityFrameworkCore; +using Services.Orders.Features; +using Services.Orders.Infrastructure; + var builder = WebApplication.CreateBuilder(args); +var connectionString = builder.Configuration.GetConnectionString("Postgres") + ?? "Host=localhost;Database=dcl;Username=dcl;Password=dcl;SearchPath=orders"; + +// One scoped context instance serves both the domain (OrdersDbContext) and the outbox +// (MessagingDbContext) so they share a unit of work. +builder.Services.AddDbContext(o => o.UseNpgsql(connectionString)); +builder.Services.AddScoped(sp => sp.GetRequiredService()); + +builder.Services.AddOutboxInbox(); +builder.Services.AddRabbitMqPublisher(builder.Configuration.GetSection("RabbitMq").Bind); +builder.Services.AddOutboxDispatcher(); + +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddScoped(); + var app = builder.Build(); +// Apply migrations on startup (lab convenience; a real deploy would run them separately). +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + app.MapGet("/health", () => Results.Ok(new { status = "ok" })); -// Entry point of the checkout saga. Implementation (persist Order + write OrderPlaced -// to the outbox in one transaction) lands with dotnet-dev — see spec §10, phase 2. -app.MapPost("/orders", () => - Results.StatusCode(StatusCodes.Status501NotImplemented)); +app.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) => +{ + var response = await handler.HandleAsync(request, ct); + return Results.Created($"/orders/{response.OrderId}", response); +}); app.Run(); diff --git a/src/Services/Orders/Services.Orders.csproj b/src/Services/Orders/Services.Orders.csproj index 724fae3..2348bc6 100644 --- a/src/Services/Orders/Services.Orders.csproj +++ b/src/Services/Orders/Services.Orders.csproj @@ -6,6 +6,13 @@ + + + + all + + + net10.0 enable diff --git a/src/Services/Orders/appsettings.json b/src/Services/Orders/appsettings.json index 10f68b8..503210d 100644 --- a/src/Services/Orders/appsettings.json +++ b/src/Services/Orders/appsettings.json @@ -5,5 +5,15 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "Postgres": "Host=localhost;Database=dcl;Username=dcl;Password=dcl;SearchPath=orders" + }, + "RabbitMq": { + "Host": "localhost", + "Port": 5672, + "Username": "guest", + "Password": "guest", + "Exchange": "dcl.events" + } } diff --git a/tests/Integration/PlaceOrderTests.cs b/tests/Integration/PlaceOrderTests.cs new file mode 100644 index 0000000..be20a45 --- /dev/null +++ b/tests/Integration/PlaceOrderTests.cs @@ -0,0 +1,70 @@ +using BuildingBlocks.Messaging; +using BuildingBlocks.Persistence; +using Microsoft.EntityFrameworkCore; +using Services.Orders.Domain; +using Services.Orders.Features; +using Services.Orders.Infrastructure; +using Shouldly; +using Testcontainers.PostgreSql; + +namespace Tests.Integration; + +/// +/// Proves phase 2 (spec §10.2): placing an order persists the Order AND writes the +/// OrderPlaced event to the outbox in a single transaction — no dual-write. +/// +public sealed class PlaceOrderTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine").Build(); + + public async Task InitializeAsync() + { + await _postgres.StartAsync(); + + await using var db = CreateDbContext(); + await db.Database.MigrateAsync(); + } + + public Task DisposeAsync() => _postgres.DisposeAsync().AsTask(); + + [Fact] + public async Task Placing_an_order_persists_it_and_writes_OrderPlaced_to_the_outbox_atomically() + { + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + var ct = cts.Token; + + // Act: place an order through the real handler (EF outbox + single SaveChanges). + Guid orderId; + await using (var db = CreateDbContext()) + { + var handler = new PlaceOrderHandler(db, new EfOutbox(db, new JsonEventSerializer()), TimeProvider.System); + var response = await handler.HandleAsync(new PlaceOrderRequest("SKU-1", 2, 49.90m), ct); + orderId = response.OrderId; + } + + orderId.ShouldNotBe(Guid.Empty); + + // Assert against a fresh context: the order is Pending... + await using (var db = CreateDbContext()) + { + var order = await db.Orders.SingleAsync(ct); + order.Id.ShouldBe(orderId); + order.Status.ShouldBe(OrderStatus.Pending); + order.Sku.ShouldBe("SKU-1"); + + // ...and exactly one pending outbox row of type OrderPlaced referencing the order. + var outbox = await db.Outbox.SingleAsync(ct); + outbox.Type.ShouldBe(nameof(Contracts.OrderPlaced)); + outbox.ProcessedAt.ShouldBeNull(); + outbox.Payload.ShouldContain(orderId.ToString()); + } + } + + private OrdersDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_postgres.GetConnectionString()) + .Options; + return new OrdersDbContext(options); + } +} diff --git a/tests/Integration/Tests.Integration.csproj b/tests/Integration/Tests.Integration.csproj index efcd553..2a4b7cd 100644 --- a/tests/Integration/Tests.Integration.csproj +++ b/tests/Integration/Tests.Integration.csproj @@ -15,6 +15,7 @@ +