Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c17984f1 | ||
|
|
ff6dbfb269 | ||
|
|
08b52806de |
+11
-6
@@ -22,6 +22,11 @@ explicit — and that is where this exercise lives.
|
|||||||
percentage *actually applied* (0 when the total does not strictly exceed the
|
percentage *actually applied* (0 when the total does not strictly exceed the
|
||||||
barrier), and the discounted total. Rounding is half-up, in integer math.
|
barrier), and the discounted total. Rounding is half-up, in integer math.
|
||||||
- Unit tests for all of the above already pass.
|
- 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
|
## The challenge
|
||||||
|
|
||||||
@@ -61,6 +66,8 @@ instead of assuming there is only one order around.
|
|||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using After;
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
|
||||||
namespace BeforeAfter.Tests;
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
@@ -74,9 +81,8 @@ public class DiscountedOrderMappingTests
|
|||||||
public void A_discounted_order_can_be_correlated_onto_its_order()
|
public void A_discounted_order_can_be_correlated_onto_its_order()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||||
var order = new Order { Description = "big order", TotalInCents = 150_00 };
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
|
|
||||||
@@ -98,9 +104,8 @@ public class DiscountedOrderMappingTests
|
|||||||
public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
|
public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||||
var order = new Order { Description = "big order", TotalInCents = 150_00 };
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
|
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
# Exercise 2: OrderLines — the domain evolves, the edges don't
|
||||||
|
|
||||||
|
> Run `EXERCISE.md` first (or apply its solution from the
|
||||||
|
> `exercise-1-solution` branch): this exercise continues from that state.
|
||||||
|
|
||||||
|
You are in the "after" situation: plain domain objects, two mapping families
|
||||||
|
out of the domain (DTOs for transport, `*Data` records for persistence), and
|
||||||
|
test data builders so tests don't reach into domain construction. The order
|
||||||
|
total is a stored `int TotalInCents`.
|
||||||
|
|
||||||
|
The scenario: orders should consist of **order lines** — each with its own
|
||||||
|
description and amount. The total becomes *derived* data: the sum of the
|
||||||
|
lines. And here is the point of the exercise — the persistence framework and
|
||||||
|
the DTO keep their flat shape (one `Description`, one `TotalInCents` per
|
||||||
|
order), so the domain changes while **nothing at the edges does**: same DTO,
|
||||||
|
same storage row, same WebApp.
|
||||||
|
|
||||||
|
## The trick, and the accepted loss
|
||||||
|
|
||||||
|
Storage holds one flat row per order. Reading an order back therefore
|
||||||
|
reconstructs it with **one synthetic order line**: description and amount
|
||||||
|
taken from the row's `Description` and `TotalInCents`. Calculation (discount,
|
||||||
|
totals) then just works on reloaded orders.
|
||||||
|
|
||||||
|
The honest limitation: an order with **more than one line** cannot round-trip
|
||||||
|
through storage — saving flattens it, reading back collapses it to one line.
|
||||||
|
That is fine as long as multi-line orders never reach the front-end. The
|
||||||
|
exercise includes a test that documents this loss instead of hiding it.
|
||||||
|
|
||||||
|
Work through the three steps — each is one instruction for a coding agent.
|
||||||
|
Run the tests after each step:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/run-tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1 — introduce OrderLines (and meet the compiler)
|
||||||
|
|
||||||
|
In `src/After`:
|
||||||
|
|
||||||
|
1. Add an `OrderLine` — a plain, mutable domain type in the same style as
|
||||||
|
`Order`/`Client` (only the DTOs are records):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class OrderLine(string description, int amountInCents)
|
||||||
|
{
|
||||||
|
public string Description { get; set; } = description;
|
||||||
|
public int AmountInCents { get; set; } = amountInCents;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In `Order`: keep `Description` (it stays stored — it is the order-level
|
||||||
|
description), add `public List<OrderLine> Lines { get; } = [];`, and turn
|
||||||
|
the total into a computed property (delete the stored one):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public int TotalInCents => Lines.Sum(line => line.AmountInCents);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update `OrderBuilder`: `WithTotal` must keep its API but can no longer set
|
||||||
|
a computed total. Store the request and express it in `Build()` as a single
|
||||||
|
line carrying the order's description — so `WithDescription`/`WithTotal`
|
||||||
|
work in any order:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private int? _totalInCents;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now build. The compiler enumerates every remaining *writer* of the total —
|
||||||
|
exactly two, both **inbound** mappers (`Mappers.FromOrderDto` and
|
||||||
|
`Mappers.FromOrderData`). Everything that only *reads* the total —
|
||||||
|
`ToOrderDto`, `ToOrderData`, `DiscountCalculator`, the WebApp — compiles
|
||||||
|
untouched. That is the whole lesson in one compiler output.
|
||||||
|
|
||||||
|
One test also writes the total (`order.TotalInCents = 99_00` in
|
||||||
|
`Saving_again_refreshes_the_row_instead_of_duplicating_it`); fix it now by
|
||||||
|
mutating through the new domain API instead: `order.Lines[0].AmountInCents = 99_00;`
|
||||||
|
|
||||||
|
Acceptance: the **only** remaining build errors are the two inbound mappers.
|
||||||
|
|
||||||
|
## Step 2 — reconstruct instead of write
|
||||||
|
|
||||||
|
A DTO (and a storage row) carries one description and one flat total, so the
|
||||||
|
reconstructed order is an order with **one line** — derived data cannot be
|
||||||
|
written, only re-derived. In both `Mappers.FromOrderDto` (`UnmapOrder`) and
|
||||||
|
`Mappers.FromOrderData`: keep the field copies as they are, drop the
|
||||||
|
`TotalInCents = ...` assignment, and after creating the order add
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
order.Lines.Add(new OrderLine(dto.Description, dto.TotalInCents)); // dto / row respectively
|
||||||
|
```
|
||||||
|
|
||||||
|
(always exactly one line — even for a zero total — so the reconstruction is
|
||||||
|
deterministic). `DbContext.LoadOrder` / `LoadClient` need no changes.
|
||||||
|
|
||||||
|
Acceptance: the full suite is green again, and `git diff --stat` shows the
|
||||||
|
change confined to the domain, the builder, that one test, and the two
|
||||||
|
inbound mapper bodies — `OrderDto`, `OrderData`, `DbContext`, `WebApp`, and
|
||||||
|
the outbound mappers are untouched.
|
||||||
|
|
||||||
|
## Step 3 — prove it: discount a reloaded order, and document the loss
|
||||||
|
|
||||||
|
Create `tests/BeforeAfter.Tests/OrderLinesEvolutionTests.cs` from the stub
|
||||||
|
below and make both tests pass:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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. Make these tests pass (see 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);
|
||||||
|
|
||||||
|
Order loaded = null!; // TODO (step 3a): load the order back from the context
|
||||||
|
|
||||||
|
// The discount must work 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);
|
||||||
|
|
||||||
|
Order loaded = null!; // TODO (step 3b): load the order back from the context
|
||||||
|
|
||||||
|
// Storage flattened the order: one line, the flat total, and the
|
||||||
|
// order-level description. Document this — don't hide it.
|
||||||
|
var line = Assert.Single(loaded.Lines);
|
||||||
|
Assert.Equal(150_00, line.AmountInCents);
|
||||||
|
Assert.Equal("mixed order", line.Description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance: the whole suite is green — including Exercise 1's tests
|
||||||
|
(`DiscountedOrderMappingTests`), which keep passing unchanged: the discount
|
||||||
|
flow reads the computed total exactly as it read the stored one.
|
||||||
|
|
||||||
|
## Facilitator notes
|
||||||
|
|
||||||
|
- Trainer's solutions live on the `exercise-1-solution` and
|
||||||
|
`exercise-2-solution` branches — keep them off the participant path.
|
||||||
|
|
||||||
|
- Expected diff per step: (1) `OrderLine` + `Order.Lines` + computed total +
|
||||||
|
builder internals + one test line — build red in exactly the two inbound
|
||||||
|
mappers; (2) two one-line reconstructions — green again; (3) two new tests.
|
||||||
|
If `git diff` touches `OrderDto`, `OrderData`, `DbContext`, `WebApp`, or the
|
||||||
|
outbound mappers, something went off the small-steps path.
|
||||||
|
- The `WithTotal` pain the builder absorbs: internally it now fabricates a
|
||||||
|
line; no test noticed. That is the TestDataBuilder dividend.
|
||||||
|
- Discussion points: what the next evolution would look like with a real
|
||||||
|
database (`order_line` rows and a foreign key — the persistence contract
|
||||||
|
would finally change, and only then); why the DTO cannot carry lines back
|
||||||
|
into the domain honestly (derived data is re-derived on the way in, never
|
||||||
|
transported); what the user of a reloaded multi-line order actually gets
|
||||||
|
(the flat total under the order's description) and why that must stay away
|
||||||
|
from the front-end.
|
||||||
|
- `Before` stays untouched throughout — its Active Record world would have
|
||||||
|
needed a schema change on day one, which is the contrast this exercise
|
||||||
|
series is built on.
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using After;
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
|
||||||
namespace BeforeAfter.Tests;
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
@@ -15,9 +17,8 @@ public class AfterPersistenceTests
|
|||||||
public void Save_maps_domain_state_into_data_records()
|
public void Save_maps_domain_state_into_data_records()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
var order = new Order { Description = "one", TotalInCents = 15000 };
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
|
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
@@ -29,7 +30,7 @@ public class AfterPersistenceTests
|
|||||||
var orderRow = Assert.Single(db.Orders);
|
var orderRow = Assert.Single(db.Orders);
|
||||||
Assert.Equal(order.Id, orderRow.Id);
|
Assert.Equal(order.Id, orderRow.Id);
|
||||||
Assert.Equal("one", orderRow.Description);
|
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
|
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()
|
public void An_order_without_client_stores_an_empty_client_id()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var order = new Order { Description = "orphan" };
|
var order = ValidOrder().WithDescription("orphan").Build();
|
||||||
|
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
|
|
||||||
@@ -48,14 +49,13 @@ public class AfterPersistenceTests
|
|||||||
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
|
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
var order = new Order { Description = "one" };
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
|
|
||||||
order.Description = "one (edited)";
|
order.Description = "one (edited)";
|
||||||
order.TotalInCents = 9900;
|
order.TotalInCents = 99_00;
|
||||||
client.Name = "Acme GmbH";
|
client.Name = "Acme GmbH";
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
@@ -64,7 +64,7 @@ public class AfterPersistenceTests
|
|||||||
var orderRow = Assert.Single(db.Orders);
|
var orderRow = Assert.Single(db.Orders);
|
||||||
Assert.Equal(order.Id, orderRow.Id);
|
Assert.Equal(order.Id, orderRow.Id);
|
||||||
Assert.Equal("one (edited)", orderRow.Description);
|
Assert.Equal("one (edited)", orderRow.Description);
|
||||||
Assert.Equal(9900, orderRow.TotalInCents);
|
Assert.Equal(99_00, orderRow.TotalInCents);
|
||||||
Assert.Equal(client.Id, orderRow.ClientId);
|
Assert.Equal(client.Id, orderRow.ClientId);
|
||||||
|
|
||||||
var clientRow = Assert.Single(db.Clients);
|
var clientRow = Assert.Single(db.Clients);
|
||||||
@@ -75,9 +75,8 @@ public class AfterPersistenceTests
|
|||||||
public void Rows_are_reachable_through_Set_and_Find()
|
public void Rows_are_reachable_through_Set_and_Find()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
var order = new Order { Description = "one" };
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
|
|
||||||
@@ -98,9 +97,8 @@ public class AfterPersistenceTests
|
|||||||
public void Loading_an_order_restores_its_state_and_its_client()
|
public void Loading_an_order_restores_its_state_and_its_client()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
var order = new Order { Description = "one", TotalInCents = 150_00 };
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
|
|
||||||
@@ -122,12 +120,12 @@ public class AfterPersistenceTests
|
|||||||
public void Loading_a_client_restores_it_with_all_of_its_orders()
|
public void Loading_a_client_restores_it_with_all_of_its_orders()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var first = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
client.Orders.Add(new Order { Description = "one", TotalInCents = 150_00 });
|
var second = ValidOrder().WithDescription("two").WithTotal(250_00).Build();
|
||||||
client.Orders.Add(new Order { Description = "two", TotalInCents = 250_00 });
|
var client = ValidClient().WithName("Acme").WithOrder(first).WithOrder(second).Build();
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
db.Save(client.Orders[0]);
|
db.Save(first);
|
||||||
db.Save(client.Orders[1]);
|
db.Save(second);
|
||||||
|
|
||||||
var loaded = db.LoadClient(client.Id);
|
var loaded = db.LoadClient(client.Id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using After;
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace BeforeAfter.Tests;
|
namespace BeforeAfter.Tests;
|
||||||
@@ -7,6 +9,11 @@ namespace BeforeAfter.Tests;
|
|||||||
/// Tests for the AFTER situation (Yak 02): plain domain objects (no
|
/// Tests for the AFTER situation (Yak 02): plain domain objects (no
|
||||||
/// <see cref="DbBase"/>), DTOs (records) carrying the data including the
|
/// <see cref="DbBase"/>), DTOs (records) carrying the data including the
|
||||||
/// relations, hand-written mappers, and a WebApp that consumes only DTOs.
|
/// 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>
|
/// </summary>
|
||||||
public class AfterTests
|
public class AfterTests
|
||||||
{
|
{
|
||||||
@@ -42,7 +49,7 @@ public class AfterTests
|
|||||||
public void ToClientDto_maps_all_scalar_fields()
|
public void ToClientDto_maps_all_scalar_fields()
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
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);
|
var dto = Mappers.ToClientDto(client);
|
||||||
|
|
||||||
@@ -54,13 +61,13 @@ public class AfterTests
|
|||||||
public void ToOrderDto_maps_all_scalar_fields()
|
public void ToOrderDto_maps_all_scalar_fields()
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
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);
|
var dto = Mappers.ToOrderDto(order);
|
||||||
|
|
||||||
Assert.Equal(id, dto.Id);
|
Assert.Equal(id, dto.Id);
|
||||||
Assert.Equal("one", dto.Description);
|
Assert.Equal("one", dto.Description);
|
||||||
Assert.Equal(15000, dto.TotalInCents);
|
Assert.Equal(150_00, dto.TotalInCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -79,13 +86,13 @@ public class AfterTests
|
|||||||
public void FromOrderDto_maps_all_scalar_fields()
|
public void FromOrderDto_maps_all_scalar_fields()
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
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);
|
var order = Mappers.FromOrderDto(dto);
|
||||||
|
|
||||||
Assert.Equal(id, order.Id);
|
Assert.Equal(id, order.Id);
|
||||||
Assert.Equal("one", order.Description);
|
Assert.Equal("one", order.Description);
|
||||||
Assert.Equal(15000, order.TotalInCents);
|
Assert.Equal(150_00, order.TotalInCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -95,9 +102,8 @@ public class AfterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
||||||
{
|
{
|
||||||
var client = new Client { Name = "Acme" };
|
var order = ValidOrder().WithDescription("one").Build();
|
||||||
var order = new Order { Description = "one" };
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
|
|
||||||
var dto = Mappers.ToClientDto(client);
|
var dto = Mappers.ToClientDto(client);
|
||||||
var orderDto = Assert.Single(dto.Orders);
|
var orderDto = Assert.Single(dto.Orders);
|
||||||
@@ -130,9 +136,8 @@ public class AfterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Domain_round_trip_preserves_all_state()
|
public void Domain_round_trip_preserves_all_state()
|
||||||
{
|
{
|
||||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||||
var order = new Order { Id = Guid.NewGuid(), Description = "one", TotalInCents = 15000 };
|
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
|
|
||||||
var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
|
var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
|
||||||
|
|
||||||
@@ -149,9 +154,8 @@ public class AfterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Dto_round_trip_preserves_all_state()
|
public void Dto_round_trip_preserves_all_state()
|
||||||
{
|
{
|
||||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
var dto = Mappers.ToClientDto(client);
|
var dto = Mappers.ToClientDto(client);
|
||||||
var dtoOrder = dto.Orders[0];
|
var dtoOrder = dto.Orders[0];
|
||||||
|
|
||||||
@@ -172,9 +176,8 @@ public class AfterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ToClientDto_does_not_mutate_the_domain()
|
public void ToClientDto_does_not_mutate_the_domain()
|
||||||
{
|
{
|
||||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").Build();
|
||||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
var ordersCount = client.Orders.Count;
|
var ordersCount = client.Orders.Count;
|
||||||
|
|
||||||
Mappers.ToClientDto(client);
|
Mappers.ToClientDto(client);
|
||||||
@@ -247,9 +250,8 @@ public class AfterTests
|
|||||||
public void After_WebApp_consumes_dtos()
|
public void After_WebApp_consumes_dtos()
|
||||||
{
|
{
|
||||||
var app = new WebApp();
|
var app = new WebApp();
|
||||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("order one").WithTotal(150_00).Build();
|
||||||
var order = new Order { Id = Guid.NewGuid(), Description = "order one", TotalInCents = 15000 };
|
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
|
|
||||||
var view = app.ShowClient(Mappers.ToClientDto(client));
|
var view = app.ShowClient(Mappers.ToClientDto(client));
|
||||||
|
|
||||||
@@ -266,9 +268,8 @@ public class AfterTests
|
|||||||
public void DbContext_saves_plain_domain_objects_and_tracks_them()
|
public void DbContext_saves_plain_domain_objects_and_tracks_them()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var order = ValidOrder().WithDescription("one").Build();
|
||||||
var order = new Order { Description = "one" };
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
client.Orders.Add(order);
|
|
||||||
|
|
||||||
Assert.Equal(Guid.Empty, client.Id);
|
Assert.Equal(Guid.Empty, client.Id);
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
@@ -286,7 +287,7 @@ public class AfterTests
|
|||||||
public void Saving_twice_does_not_reassign_the_id()
|
public void Saving_twice_does_not_reassign_the_id()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var client = ValidClient().WithName("Acme").Build();
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
var id = client.Id;
|
var id = client.Id;
|
||||||
|
|
||||||
@@ -300,7 +301,7 @@ public class AfterTests
|
|||||||
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var client = ValidClient().WithName("Acme").Build();
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
|
|
||||||
// Saved (the id was assigned by the context) ...
|
// Saved (the id was assigned by the context) ...
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using After;
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
|
||||||
namespace BeforeAfter.Tests;
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ public class DiscountCalculatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_total_below_the_barrier_gets_no_discount()
|
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);
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ public class DiscountCalculatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_total_exactly_at_the_barrier_gets_no_discount()
|
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);
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ public class DiscountCalculatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_total_above_the_barrier_gets_the_percentage_discount()
|
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);
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ public class DiscountCalculatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_zero_percentage_discounts_nothing_even_above_the_barrier()
|
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);
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 0);
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ public class DiscountCalculatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_hundred_percent_discount_leaves_nothing()
|
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);
|
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
|
[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)
|
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);
|
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()
|
public void The_result_is_correlated_to_the_order_by_id()
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
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);
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ public class DiscountCalculatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Calculating_does_not_mutate_the_order()
|
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);
|
DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ public class DiscountCalculatorTests
|
|||||||
[InlineData(150_00, 100_00, 101)]// percentage above 100
|
[InlineData(150_00, 100_00, 101)]// percentage above 100
|
||||||
public void Invalid_arguments_throw(int totalInCents, int discountBarrierCents, int discountPercentage)
|
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>(
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
() => DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage));
|
() => DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage));
|
||||||
|
|||||||
@@ -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,47 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
/// <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). How the requested
|
||||||
|
/// total is realised inside the order is an implementation detail that may
|
||||||
|
/// change as the domain evolves; this API stays.
|
||||||
|
/// </summary>
|
||||||
|
public OrderBuilder WithTotal(int totalInCents)
|
||||||
|
{
|
||||||
|
_order.TotalInCents = totalInCents;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order Build() => _order;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user