Author SHA1 Message Date
mostalive 46b91fc3ce Exercise 2 solution: OrderLines in the domain, edges unchanged
OrderLine + computed TotalInCents; both inbound mappers reconstruct a
one-line order from the flat DTO/row (derived data is re-derived, not
written); builder realises WithTotal as a single line. Outbound mappers,
DTO, OrderData, DbContext and WebApp untouched — the small-steps payoff.

yak: exercise-2-solution-on-branch-exercise-2-solution-hw7e
2026-09-17 08:56:14 +01:00
mostalive 89a1158c93 Exercise 1 solution: DiscountedTotalInCents on the domain order + integration tests
Domain-only field: OrderDto and OrderData stay without it; the stored row
keeps the original total after saving a discounted order. The discount
percentage is not copied (derivable).

yak: exercise-1-solution-on-branch-exercise-1-solution-4s5q
2026-09-16 22:32:35 +01:00
mostalive 08b52806de TestDataBuilders: ValidOrder()/ValidClient() with explicit Build(); After tests migrated
Domain objects in After-side tests are constructed only through the
builders, so the upcoming OrderLines evolution changes builder internals
instead of every test. Before keeps object initializers (legacy world).

yak: testdatabuilders-validordervalidclient-after-test-migration-91vl
2026-09-16 22:32:13 +01:00
11 changed files with 319 additions and 71 deletions
+11 -6
View File
@@ -22,6 +22,11 @@ explicit — and that is where this exercise lives.
percentage *actually applied* (0 when the total does not strictly exceed the
barrier), and the discounted total. Rounding is half-up, in integer math.
- Unit tests for all of the above already pass.
- `ValidOrder()` / `ValidClient()` test data builders in the test project
(`tests/BeforeAfter.Tests/TestData/`) — construct domain objects in tests
through them, so tests survive domain evolution. Import their static
factories with `using static BeforeAfter.Tests.TestData.OrderBuilder;`
(and `...ClientBuilder;`).
## The challenge
@@ -61,6 +66,8 @@ instead of assuming there is only one order around.
```csharp
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
namespace BeforeAfter.Tests;
@@ -74,9 +81,8 @@ public class DiscountedOrderMappingTests
public void A_discounted_order_can_be_correlated_onto_its_order()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "big order", TotalInCents = 150_00 };
client.Orders.Add(order);
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
@@ -98,9 +104,8 @@ public class DiscountedOrderMappingTests
public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "big order", TotalInCents = 150_00 };
client.Orders.Add(order);
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
+8 -2
View File
@@ -103,9 +103,11 @@ public static class Mappers
{
Id = dto.Id,
Description = dto.Description,
TotalInCents = dto.TotalInCents,
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
};
// Derived data cannot be written, only re-derived: the flat DTO total
// becomes exactly one line.
order.Lines.Add(new OrderLine(dto.Description, dto.TotalInCents));
seen[dto] = order;
return order;
}
@@ -161,6 +163,10 @@ public static class Mappers
public static Order FromOrderData(OrderData row)
{
ArgumentNullException.ThrowIfNull(row);
return new Order { Id = row.Id, Description = row.Description, TotalInCents = row.TotalInCents };
var order = new Order { Id = row.Id, Description = row.Description };
// Read-back: the flat row becomes exactly one synthetic line, so the
// computed total — and everything calculated from it — keeps working.
order.Lines.Add(new OrderLine(row.Description, row.TotalInCents));
return order;
}
}
+19 -4
View File
@@ -18,11 +18,26 @@ public class Order
public string Description { get; set; } = string.Empty;
/// <summary>
/// Order total in cents (integer — no floating-point money). A normal
/// attribute: it is persisted in <see cref="OrderData"/> and mapped
/// one-on-one into <see cref="OrderDto.TotalInCents"/>.
/// Order total in cents (integer — no floating-point money), computed
/// from the order lines. Since the lines arrived this is derived data:
/// it cannot be written, only re-derived — which is exactly why the
/// inbound mappings (DTO, storage row) reconstruct a one-line order.
/// Persisted in <see cref="OrderData"/> and mapped one-on-one into
/// <see cref="OrderDto.TotalInCents"/>.
/// </summary>
public int TotalInCents { get; set; }
public int TotalInCents => Lines.Sum(line => line.AmountInCents);
/// <summary>The lines this order consists of. The total is their sum.</summary>
public List<OrderLine> Lines { get; } = [];
/// <summary>
/// The discounted total in cents, copied onto the order by the caller of
/// the discount flow (see <see cref="DiscountCalculator"/>). Derived
/// data: it lives in the domain only — neither <see cref="OrderDto"/>
/// nor <see cref="OrderData"/> carries it — and the applied percentage
/// is not stored either, being derivable from the two totals.
/// </summary>
public int DiscountedTotalInCents { get; set; }
/// <summary>
/// The client that owns this order. Set automatically by
+15
View File
@@ -0,0 +1,15 @@
namespace After;
/// <summary>
/// One line of an <see cref="Order"/>: its own description and amount. A
/// plain, mutable domain type in the same style as <see cref="Order"/> and
/// <see cref="Client"/> (only the DTOs are records).
/// </summary>
public class OrderLine(string description, int amountInCents)
{
/// <summary>Free-text description of what this line is for.</summary>
public string Description { get; set; } = description;
/// <summary>Line amount in cents (integer — no floating-point money).</summary>
public int AmountInCents { get; set; } = amountInCents;
}
@@ -1,4 +1,6 @@
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
namespace BeforeAfter.Tests;
@@ -15,9 +17,8 @@ public class AfterPersistenceTests
public void Save_maps_domain_state_into_data_records()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one", TotalInCents = 15000 };
client.Orders.Add(order);
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
@@ -29,7 +30,7 @@ public class AfterPersistenceTests
var orderRow = Assert.Single(db.Orders);
Assert.Equal(order.Id, orderRow.Id);
Assert.Equal("one", orderRow.Description);
Assert.Equal(15000, orderRow.TotalInCents);
Assert.Equal(150_00, orderRow.TotalInCents);
Assert.Equal(client.Id, orderRow.ClientId); // the graph is flattened to a key
}
@@ -37,7 +38,7 @@ public class AfterPersistenceTests
public void An_order_without_client_stores_an_empty_client_id()
{
var db = new DbContext();
var order = new Order { Description = "orphan" };
var order = ValidOrder().WithDescription("orphan").Build();
db.Save(order);
@@ -48,14 +49,13 @@ public class AfterPersistenceTests
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one" };
client.Orders.Add(order);
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
order.Description = "one (edited)";
order.TotalInCents = 9900;
order.Lines[0].AmountInCents = 99_00;
client.Name = "Acme GmbH";
db.Save(order);
db.Save(client);
@@ -64,7 +64,7 @@ public class AfterPersistenceTests
var orderRow = Assert.Single(db.Orders);
Assert.Equal(order.Id, orderRow.Id);
Assert.Equal("one (edited)", orderRow.Description);
Assert.Equal(9900, orderRow.TotalInCents);
Assert.Equal(99_00, orderRow.TotalInCents);
Assert.Equal(client.Id, orderRow.ClientId);
var clientRow = Assert.Single(db.Clients);
@@ -75,9 +75,8 @@ public class AfterPersistenceTests
public void Rows_are_reachable_through_Set_and_Find()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one" };
client.Orders.Add(order);
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
@@ -98,9 +97,8 @@ public class AfterPersistenceTests
public void Loading_an_order_restores_its_state_and_its_client()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one", TotalInCents = 150_00 };
client.Orders.Add(order);
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
@@ -122,12 +120,12 @@ public class AfterPersistenceTests
public void Loading_a_client_restores_it_with_all_of_its_orders()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
client.Orders.Add(new Order { Description = "one", TotalInCents = 150_00 });
client.Orders.Add(new Order { Description = "two", TotalInCents = 250_00 });
var first = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var second = ValidOrder().WithDescription("two").WithTotal(250_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(first).WithOrder(second).Build();
db.Save(client);
db.Save(client.Orders[0]);
db.Save(client.Orders[1]);
db.Save(first);
db.Save(second);
var loaded = db.LoadClient(client.Id);
+26 -25
View File
@@ -1,4 +1,6 @@
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
using System.Reflection;
namespace BeforeAfter.Tests;
@@ -7,6 +9,11 @@ namespace BeforeAfter.Tests;
/// Tests for the AFTER situation (Yak 02): plain domain objects (no
/// <see cref="DbBase"/>), DTOs (records) carrying the data including the
/// relations, hand-written mappers, and a WebApp that consumes only DTOs.
///
/// Domain objects in these tests are constructed through the TestDataBuilders
/// (<see cref="OrderBuilder.ValidOrder"/> / <see cref="ClientBuilder.ValidClient"/>)
/// so the tests survive domain evolution; DTO initializers stay literal —
/// DTOs are the transport shapes under test.
/// </summary>
public class AfterTests
{
@@ -42,7 +49,7 @@ public class AfterTests
public void ToClientDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var client = new Client { Id = id, Name = "Acme" };
var client = ValidClient().WithId(id).WithName("Acme").Build();
var dto = Mappers.ToClientDto(client);
@@ -54,13 +61,13 @@ public class AfterTests
public void ToOrderDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var order = new Order { Id = id, Description = "one", TotalInCents = 15000 };
var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
var dto = Mappers.ToOrderDto(order);
Assert.Equal(id, dto.Id);
Assert.Equal("one", dto.Description);
Assert.Equal(15000, dto.TotalInCents);
Assert.Equal(150_00, dto.TotalInCents);
}
[Fact]
@@ -79,13 +86,13 @@ public class AfterTests
public void FromOrderDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 15000 };
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
var order = Mappers.FromOrderDto(dto);
Assert.Equal(id, order.Id);
Assert.Equal("one", order.Description);
Assert.Equal(15000, order.TotalInCents);
Assert.Equal(150_00, order.TotalInCents);
}
// ---------------------------------------------------------------------
@@ -95,9 +102,8 @@ public class AfterTests
[Fact]
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
{
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one" };
client.Orders.Add(order);
var order = ValidOrder().WithDescription("one").Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
var dto = Mappers.ToClientDto(client);
var orderDto = Assert.Single(dto.Orders);
@@ -130,9 +136,8 @@ public class AfterTests
[Fact]
public void Domain_round_trip_preserves_all_state()
{
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one", TotalInCents = 15000 };
client.Orders.Add(order);
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
@@ -149,9 +154,8 @@ public class AfterTests
[Fact]
public void Dto_round_trip_preserves_all_state()
{
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
client.Orders.Add(order);
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
var dto = Mappers.ToClientDto(client);
var dtoOrder = dto.Orders[0];
@@ -172,9 +176,8 @@ public class AfterTests
[Fact]
public void ToClientDto_does_not_mutate_the_domain()
{
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
client.Orders.Add(order);
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").Build();
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
var ordersCount = client.Orders.Count;
Mappers.ToClientDto(client);
@@ -247,9 +250,8 @@ public class AfterTests
public void After_WebApp_consumes_dtos()
{
var app = new WebApp();
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "order one", TotalInCents = 15000 };
client.Orders.Add(order);
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("order one").WithTotal(150_00).Build();
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
var view = app.ShowClient(Mappers.ToClientDto(client));
@@ -266,9 +268,8 @@ public class AfterTests
public void DbContext_saves_plain_domain_objects_and_tracks_them()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one" };
client.Orders.Add(order);
var order = ValidOrder().WithDescription("one").Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
Assert.Equal(Guid.Empty, client.Id);
db.Save(client);
@@ -286,7 +287,7 @@ public class AfterTests
public void Saving_twice_does_not_reassign_the_id()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var client = ValidClient().WithName("Acme").Build();
db.Save(client);
var id = client.Id;
@@ -300,7 +301,7 @@ public class AfterTests
public void A_saved_domain_object_keeps_no_reference_to_the_context()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var client = ValidClient().WithName("Acme").Build();
db.Save(client);
// Saved (the id was assigned by the context) ...
@@ -1,4 +1,6 @@
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
namespace BeforeAfter.Tests;
@@ -16,7 +18,7 @@ public class DiscountCalculatorTests
[Fact]
public void A_total_below_the_barrier_gets_no_discount()
{
var order = new Order { Id = Guid.NewGuid(), Description = "small", TotalInCents = 50_00 };
var order = ValidOrder().WithTotal(50_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
@@ -28,7 +30,7 @@ public class DiscountCalculatorTests
[Fact]
public void A_total_exactly_at_the_barrier_gets_no_discount()
{
var order = new Order { Id = Guid.NewGuid(), TotalInCents = 100_00 };
var order = ValidOrder().WithTotal(100_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
@@ -42,7 +44,7 @@ public class DiscountCalculatorTests
[Fact]
public void A_total_above_the_barrier_gets_the_percentage_discount()
{
var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
var order = ValidOrder().WithTotal(150_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
@@ -54,7 +56,7 @@ public class DiscountCalculatorTests
[Fact]
public void A_zero_percentage_discounts_nothing_even_above_the_barrier()
{
var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
var order = ValidOrder().WithTotal(150_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 0);
@@ -65,7 +67,7 @@ public class DiscountCalculatorTests
[Fact]
public void A_hundred_percent_discount_leaves_nothing()
{
var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
var order = ValidOrder().WithTotal(150_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 100);
@@ -83,7 +85,7 @@ public class DiscountCalculatorTests
[InlineData(151, 15, 136)] // 15.1 rounds down to 15
public void The_discount_amount_is_rounded_half_up(int totalInCents, int expectedDiscountCents, int expectedDiscountedTotal)
{
var order = new Order { Id = Guid.NewGuid(), TotalInCents = totalInCents };
var order = ValidOrder().WithTotal(totalInCents).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100, discountPercentage: 10);
@@ -98,7 +100,7 @@ public class DiscountCalculatorTests
public void The_result_is_correlated_to_the_order_by_id()
{
var id = Guid.NewGuid();
var order = new Order { Id = id, TotalInCents = 150_00 };
var order = ValidOrder().WithId(id).WithTotal(150_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
@@ -108,7 +110,7 @@ public class DiscountCalculatorTests
[Fact]
public void Calculating_does_not_mutate_the_order()
{
var order = new Order { Id = Guid.NewGuid(), Description = "one", TotalInCents = 150_00 };
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
@@ -126,7 +128,7 @@ public class DiscountCalculatorTests
[InlineData(150_00, 100_00, 101)]// percentage above 100
public void Invalid_arguments_throw(int totalInCents, int discountBarrierCents, int discountPercentage)
{
var order = new Order { Id = Guid.NewGuid(), TotalInCents = totalInCents };
var order = ValidOrder().WithTotal(totalInCents).Build();
Assert.Throws<ArgumentOutOfRangeException>(
() => DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage));
@@ -0,0 +1,59 @@
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
namespace BeforeAfter.Tests;
/// <summary>
/// EXERCISE — the mapping from a <see cref="DiscountedOrder"/> onto an
/// <see cref="Order"/> is NOT one-on-one. (This is the solution participants
/// arrive at from the stub in EXERCISE.md.)
/// </summary>
public class DiscountedOrderMappingTests
{
[Fact] // EXERCISE STEP 2
public void A_discounted_order_can_be_correlated_onto_its_order()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
// 10 % for orders above €100:
var discounted = DiscountCalculator.CalculateDiscount(
order, discountBarrierCents: 100_00, discountPercentage: 10);
// Correlate: the discounted result belongs to the order carrying this id.
Assert.Equal(order.Id, discounted.OrderId);
// Copy the discounted total onto the order. The discount percentage is
// deliberately NOT copied — it is derivable from the two totals.
order.DiscountedTotalInCents = discounted.DiscountedTotalInCents;
Assert.Equal(135_00, order.DiscountedTotalInCents);
}
[Fact] // EXERCISE STEP 3
public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
var discounted = DiscountCalculator.CalculateDiscount(
order, discountBarrierCents: 100_00, discountPercentage: 10);
Assert.Equal(order.Id, discounted.OrderId);
order.DiscountedTotalInCents = discounted.DiscountedTotalInCents;
// Save the updated order: the context refreshes the stored row in place.
db.Save(order);
// The persisted record keeps the ORIGINAL total: the database knows
// nothing about discounts — derived data lives in the domain only.
var row = db.Find<OrderData>(candidate => candidate.Id == discounted.OrderId)!;
Assert.Equal(150_00, row.TotalInCents);
}
}
@@ -0,0 +1,51 @@
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
namespace BeforeAfter.Tests;
/// <summary>
/// EXERCISE 2 — the domain gained OrderLines; persistence still stores one
/// flat row per order. (This is the solution participants arrive at from the
/// stub in EXERCISE2.md.)
/// </summary>
public class OrderLinesEvolutionTests
{
[Fact] // EXERCISE STEP 3a
public void A_reloaded_order_can_be_discounted()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
var loaded = db.LoadOrder(order.Id)!;
// The discount works off the computed total of the RELOADED order:
var discounted = DiscountCalculator.CalculateDiscount(
loaded, discountBarrierCents: 100_00, discountPercentage: 10);
Assert.Equal(135_00, discounted.DiscountedTotalInCents);
Assert.Equal(10, discounted.DiscountPercentage);
}
[Fact] // EXERCISE STEP 3b — the accepted loss
public void A_multi_line_order_flattens_to_a_single_line_when_stored()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("mixed order").Build();
order.Lines.Add(new OrderLine("first item", 100_00));
order.Lines.Add(new OrderLine("second item", 50_00));
db.Save(order);
var loaded = db.LoadOrder(order.Id)!;
// Storage flattened the order: one line, the flat total, and the
// order-level description. Documented, not hidden.
var line = Assert.Single(loaded.Lines);
Assert.Equal(150_00, line.AmountInCents);
Assert.Equal("mixed order", line.Description);
}
}
@@ -0,0 +1,40 @@
using After;
namespace BeforeAfter.Tests.TestData;
/// <summary>
/// Test data builder for a <see cref="Client"/> with its orders
/// (TestDataBuilder pattern). <see cref="ValidClient"/> starts from a
/// <em>fresh</em> client, exactly what <c>new Client()</c> gives.
/// </summary>
public sealed class ClientBuilder
{
private readonly Client _client = new();
/// <summary>A valid, fresh client — the neutral starting point for tests.</summary>
public static ClientBuilder ValidClient() => new();
public ClientBuilder WithId(Guid id)
{
_client.Id = id;
return this;
}
public ClientBuilder WithName(string name)
{
_client.Name = name;
return this;
}
/// <summary>Adds an order to the client (navigation fix-up wires the back-reference).</summary>
public ClientBuilder WithOrder(Order order)
{
_client.Orders.Add(order);
return this;
}
/// <summary>Builds the given builder and adds its order — chaining sugar for <c>.WithOrder(builder.Build())</c>.</summary>
public ClientBuilder WithOrder(OrderBuilder order) => WithOrder(order.Build());
public Client Build() => _client;
}
@@ -0,0 +1,56 @@
using After;
namespace BeforeAfter.Tests.TestData;
/// <summary>
/// Test data builder for an <see cref="Order"/> (TestDataBuilder pattern).
/// <see cref="ValidOrder"/> starts from a <em>fresh</em> order —
/// <see cref="Guid.Empty"/> id, empty description, zero total, exactly what
/// <c>new Order()</c> gives — and every <c>With...</c> returns the builder for
/// chaining; <see cref="Build"/> hands back the order.
///
/// This class is the only place tests touch an order's *construction*: when
/// the domain evolves (e.g. OrderLines making the total computed), only these
/// internals change and the tests keep reading the same.
/// </summary>
public sealed class OrderBuilder
{
private readonly Order _order = new();
private int? _totalInCents;
/// <summary>A valid, fresh order — the neutral starting point for tests.</summary>
public static OrderBuilder ValidOrder() => new();
public OrderBuilder WithId(Guid id)
{
_order.Id = id;
return this;
}
public OrderBuilder WithDescription(string description)
{
_order.Description = description;
return this;
}
/// <summary>
/// Sets the total in cents (<c>150_00</c> = €150.00). Since OrderLines
/// arrived, the total is computed — the requested total is realised in
/// <see cref="Build"/> as a single line carrying the order's description,
/// so <c>WithDescription</c>/<c>WithTotal</c> work in any order. This API
/// stayed identical across that domain evolution; only these internals
/// changed.
/// </summary>
public OrderBuilder WithTotal(int totalInCents)
{
_totalInCents = totalInCents;
return this;
}
public Order Build()
{
if (_totalInCents is int total)
_order.Lines.Add(new OrderLine(_order.Description, total));
return _order;
}
}