Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c17984f1 | ||
|
|
ff6dbfb269 | ||
|
|
08b52806de | ||
|
|
53586d33a4 | ||
|
|
37ebb21182 | ||
|
|
d39426d328 | ||
|
|
803580786b | ||
|
|
49f4ca395f |
+156
@@ -0,0 +1,156 @@
|
|||||||
|
# Exercise: the discount that wouldn't map
|
||||||
|
|
||||||
|
You are in the **"after" situation** of this code base: the domain objects
|
||||||
|
(`Client`, `Order`) are plain — no `DbBase` inheritance, no knowledge of the
|
||||||
|
`DbContext`. Two hand-written mapping families lead out of the domain:
|
||||||
|
|
||||||
|
- `OrderDto` / `ClientDto` — transport shapes consumed by the WebApp;
|
||||||
|
- `OrderData` / `ClientData` — the data-only records the persistence framework
|
||||||
|
stores (one row per object, object graph flattened to ids).
|
||||||
|
|
||||||
|
The *before* situation was Active Record: the domain object and the stored
|
||||||
|
shape were one and the same class, so there was nothing to map. Now mapping is
|
||||||
|
explicit — and that is where this exercise lives.
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
- `Order.TotalInCents` — the order total, an integer in cents, persisted and
|
||||||
|
mapped one-on-one into `OrderDto` and `OrderData`.
|
||||||
|
- `DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage)` —
|
||||||
|
a pure domain service. It returns a **`DiscountedOrder`** (a separate domain
|
||||||
|
type, correlated to the order by `OrderId`) carrying the order's total, 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.
|
||||||
|
- 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 `DiscountedOrder` is **not** a one-on-one copy of the order, and it must
|
||||||
|
not become one:
|
||||||
|
|
||||||
|
- `Order` needs to learn the *discounted total* — but neither `OrderDto` nor
|
||||||
|
`OrderData` may gain it. The discount is derived data: it lives in the
|
||||||
|
domain only, and the storage schema stays untouched.
|
||||||
|
- The `DiscountedOrder.DiscountPercentage` is **not** copied onto the order —
|
||||||
|
it can be derived from the two totals (and `TotalInCents` never changes).
|
||||||
|
|
||||||
|
So the flow is: order → `DiscountedOrder` (calculate) → order (correlate +
|
||||||
|
copy, dropping what is derivable) → persistence (which stores *less* than the
|
||||||
|
domain now knows).
|
||||||
|
|
||||||
|
Work through the three steps below — each is small enough to hand to a coding
|
||||||
|
agent as a single instruction. Run the tests after each step:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/run-tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1 — teach the order its discounted total
|
||||||
|
|
||||||
|
Add a single property `DiscountedTotalInCents` (`int`, default 0) to the
|
||||||
|
**domain** `Order` in `src/After/Order.cs`. Do not touch `OrderDto`,
|
||||||
|
`OrderData`, or the mappers: the discounted total must exist in the domain
|
||||||
|
only. Acceptance: the solution builds and the full test suite still passes.
|
||||||
|
|
||||||
|
## Step 2 — correlate a discounted order onto its order
|
||||||
|
|
||||||
|
Create `tests/BeforeAfter.Tests/DiscountedOrderMappingTests.cs` from the stub
|
||||||
|
below and make the **first** test pass. The point is the correlation: the
|
||||||
|
`DiscountedOrder` knows the id of the order it was calculated from — use it
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EXERCISE — the mapping from a DiscountedOrder onto an Order is NOT
|
||||||
|
/// one-on-one. Make these tests pass, following EXERCISE.md step by step.
|
||||||
|
/// </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);
|
||||||
|
|
||||||
|
// TODO (step 2): correlate the discounted order to the saved order and
|
||||||
|
// copy the discounted total onto the order.
|
||||||
|
// Hints: DiscountedOrder.OrderId identifies the order; the discount
|
||||||
|
// percentage is deliberately NOT copied — it is derivable.
|
||||||
|
|
||||||
|
// ... your code here ...
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// TODO (step 3): correlate + copy as in step 2, then save the updated
|
||||||
|
// order through the context. Afterwards find the order's persisted
|
||||||
|
// OrderData row (hint: db.Find<OrderData>(...)) and assert that the
|
||||||
|
// stored total is STILL the original 150_00: the database knows
|
||||||
|
// nothing about discounts — derived data lives in the domain only.
|
||||||
|
|
||||||
|
// ... your code here ...
|
||||||
|
|
||||||
|
OrderData row = null!; // TODO (step 3): find the OrderData row for this order
|
||||||
|
Assert.Equal(150_00, row.TotalInCents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance: the first test is green, the second still red.
|
||||||
|
|
||||||
|
## Step 3 — save, and check what actually persisted
|
||||||
|
|
||||||
|
Make the **second** test pass: re-save the updated order through the context
|
||||||
|
(the context refreshes the stored row in place), then assert on the persisted
|
||||||
|
`OrderData` row. Note what you are *not* asserting: there is no discounted
|
||||||
|
column to check — the row simply keeps the original total.
|
||||||
|
|
||||||
|
Acceptance: the whole suite is green — including the pre-existing tests, which
|
||||||
|
guard exactly the invariants you must not break (the DTO and the persistence
|
||||||
|
records still map the same fields as before).
|
||||||
|
|
||||||
|
## Facilitator notes
|
||||||
|
|
||||||
|
- Expected end state per step: (1) one new domain-only property; (2) a mapping
|
||||||
|
fragment *outside* the `Mappers` class — correlation by id plus a field
|
||||||
|
copy, dropping the derivable percentage; (3) `db.Save(order)` after which
|
||||||
|
the `OrderData` row still shows `TotalInCents == 150_00`.
|
||||||
|
- Discussion points: why two mapping families out of the domain (transport vs
|
||||||
|
persistence) and why neither carries the discount; what an Active Record
|
||||||
|
"before" world would have done here (the discounted value would have been
|
||||||
|
a column, or nothing at all); what happens to `DiscountedTotalInCents` when
|
||||||
|
the order travels to the WebApp and back (the DTO round-trip silently drops
|
||||||
|
it — derived data must be recomputed, not transported).
|
||||||
|
- The unit tests in `DiscountCalculatorTests.cs` pin the calculation contract
|
||||||
|
(strict barrier, half-up rounding, validation); participants should not need
|
||||||
|
to touch them.
|
||||||
+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.
|
||||||
+6
-5
@@ -4,9 +4,10 @@ namespace After;
|
|||||||
/// The DB base class still exists in the "after" situation — it is part of the
|
/// The DB base class still exists in the "after" situation — it is part of the
|
||||||
/// *persistence* layer — but the direction of the dependency is flipped:
|
/// *persistence* layer — but the direction of the dependency is flipped:
|
||||||
/// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive
|
/// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive
|
||||||
/// from it. Only the <see cref="DbContext"/>'s own internal shadow entities do,
|
/// from it. Only the framework's own data-only persistence records do
|
||||||
/// so nothing outside the persistence layer can ever reach a
|
/// (<see cref="ClientData"/>, <see cref="OrderData"/>): storage wants data
|
||||||
/// <see cref="DbContext"/> through a domain object.
|
/// without behaviour, so the domain keeps the behaviour and the framework
|
||||||
|
/// keeps the data — the mappers bridge the two shapes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DbBase
|
public class DbBase
|
||||||
{
|
{
|
||||||
@@ -19,8 +20,8 @@ public class DbBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Back-reference to the owning <see cref="DbContext"/>. In the after
|
/// Back-reference to the owning <see cref="DbContext"/>. In the after
|
||||||
/// situation this lives only on the context's internal shadow entities —
|
/// situation this lives only on the persistence records — the plain
|
||||||
/// the plain domain objects that used to inherit it no longer do.
|
/// domain objects that used to inherit it no longer do.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DbContext? DbContext { get; internal set; }
|
public DbContext? DbContext { get; internal set; }
|
||||||
}
|
}
|
||||||
+121
-52
@@ -1,95 +1,164 @@
|
|||||||
namespace After;
|
namespace After;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal shadow wrappers — live *inside* the persistence layer so the
|
// The persistence side of the "after" situation. The (fake) framework stores
|
||||||
// domain never sees a DbContext back-reference. Subclassed per-entity-type
|
// its own data-only records — <see cref="ClientData"/> / <see cref="OrderData"/>,
|
||||||
// solely to make `Set<T>` strongly-typed.
|
// both <see cref="DbBase"/> subclasses — never the domain objects themselves.
|
||||||
|
// Domain state reaches storage only through explicit mapping; the context keeps
|
||||||
|
// a reference-identity map so a second <see cref="Save"/> of the same domain
|
||||||
|
// object refreshes its row instead of duplicating it.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>Lightweight shadow holder kept alive inside the context.</summary>
|
/// <summary>
|
||||||
public abstract class Shadow
|
/// Persisted shape of a <see cref="Client"/>: data only — the orders are
|
||||||
|
/// separate <see cref="OrderData"/> rows, so nothing more is needed here.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientData : DbBase
|
||||||
{
|
{
|
||||||
/// <summary>ID assigned when first persisted.</summary>
|
/// <summary>Display name of the client.</summary>
|
||||||
public Guid Id { get; set; }
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Back-reference to the owning <see cref="DbContext"/> (internal only).</summary>
|
|
||||||
public DbContext? DbContext { get; internal set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shadow holder for a persisted <see cref="Client"/>.</summary>
|
/// <summary>
|
||||||
public sealed class ClientShadow : Shadow { /* no extra data */ }
|
/// Persisted shape of an <see cref="Order"/>: data only. The object graph is
|
||||||
|
/// flattened for storage — the back-reference to the client becomes a foreign
|
||||||
|
/// key, because storage has ids, not object references.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrderData : DbBase
|
||||||
|
{
|
||||||
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Shadow holder for a persisted <see cref="Order"/>.</summary>
|
/// <summary>Order total in cents of the stored order.</summary>
|
||||||
public sealed class OrderShadow : Shadow { /* no extra data */ }
|
public int TotalInCents { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key to the owning client: <see cref="Guid.Empty"/> when the
|
||||||
|
/// order has no client, or when its client had no id yet at save time —
|
||||||
|
/// save clients before their orders.
|
||||||
|
/// </summary>
|
||||||
|
public Guid ClientId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The fake <c>DbContext</c>, refactored to use per-type shadow collections
|
/// The fake <c>DbContext</c>: per-type tables of data records, mirroring EF
|
||||||
/// (mirroring EF Core's <c>Set<T></c>) plus an instance-level
|
/// Core's <c>Set<T></c>. <see cref="Save(Client)"/> / <see cref="Save(Order)"/>
|
||||||
/// <c>Find<T></c> query method.
|
/// map the domain object's *current* state into its record — inserting on the
|
||||||
|
/// first save, refreshing in place on later ones.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DbContext
|
public class DbContext
|
||||||
{
|
{
|
||||||
// ── per-type shadow tables ───────────────────────────────────────
|
// ── per-type data tables ─────────────────────────────────────────
|
||||||
private readonly List<ClientShadow> _clients = new();
|
private readonly List<ClientData> _clients = new();
|
||||||
private readonly List<OrderShadow> _orders = new();
|
private readonly List<OrderData> _orders = new();
|
||||||
|
|
||||||
// ── fast lookup: domain object reference → its shadow ────────────
|
// ── fast lookup: domain object reference → its persisted record ──
|
||||||
private readonly Dictionary<object, Shadow> _byRef = new();
|
private readonly Dictionary<object, DbBase> _byRef = new();
|
||||||
|
|
||||||
// ── exposed per-type tables (read-only view) ────────────────────
|
// ── exposed per-type tables (read-only view) ─────────────────────
|
||||||
public IReadOnlyList<ClientShadow> Clients => _clients.AsReadOnly();
|
public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
|
||||||
public IReadOnlyList<OrderShadow> Orders => _orders.AsReadOnly();
|
public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
|
||||||
|
|
||||||
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
||||||
public IReadOnlyList<T> Set<T>() where T : Shadow
|
public IReadOnlyList<T> Set<T>() where T : DbBase
|
||||||
=> typeof(T) switch
|
=> typeof(T) switch
|
||||||
{
|
{
|
||||||
var t when t == typeof(ClientShadow) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
var t when t == typeof(ClientData) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
||||||
var t when t == typeof(OrderShadow) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
var t when t == typeof(OrderData) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
||||||
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── explicit persistence ────────────────────────────────────────
|
// ── explicit persistence: map + insert, or refresh ───────────────
|
||||||
|
|
||||||
/// <summary>Persist a <see cref="Client">:</summary>
|
/// <summary>
|
||||||
|
/// Persist a <see cref="Client"/>: the first save maps it to a fresh
|
||||||
|
/// <see cref="ClientData"/> row and writes the generated id back into the
|
||||||
|
/// domain object; a later save refreshes that row's data in place.
|
||||||
|
/// </summary>
|
||||||
public void Save(Client client)
|
public void Save(Client client)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
if (!_byRef.TryAdd(client, new ClientShadow { DbContext = this }))
|
var fresh = Mappers.ToClientData(client);
|
||||||
return; // already saved → id unchanged
|
if (_byRef.TryGetValue(client, out var existing))
|
||||||
var shadow = (ClientShadow)_byRef[client];
|
{
|
||||||
|
((ClientData)existing).Name = fresh.Name; // refresh, keep row identity
|
||||||
|
return;
|
||||||
|
}
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
shadow.Id = client.Id = id; // write-back identity
|
fresh.Id = client.Id = id; // write-back identity
|
||||||
|
fresh.DbContext = this;
|
||||||
|
_clients.Add(fresh);
|
||||||
|
_byRef[client] = fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Persist an <see cref="Order">.</summary>
|
/// <summary>
|
||||||
|
/// Persist an <see cref="Order"/> (same insert-or-refresh rule as
|
||||||
|
/// <see cref="Save(Client)"/>; save its client first so the foreign key
|
||||||
|
/// can be filled).
|
||||||
|
/// </summary>
|
||||||
public void Save(Order order)
|
public void Save(Order order)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(order);
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
if (!_byRef.TryAdd(order, new OrderShadow { DbContext = this }))
|
var fresh = Mappers.ToOrderData(order);
|
||||||
return; // already saved → id unchanged
|
if (_byRef.TryGetValue(order, out var existing))
|
||||||
var shadow = (OrderShadow)_byRef[order];
|
{
|
||||||
|
var row = (OrderData)existing; // refresh, keep row identity
|
||||||
|
row.Description = fresh.Description;
|
||||||
|
row.TotalInCents = fresh.TotalInCents;
|
||||||
|
row.ClientId = fresh.ClientId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
shadow.Id = order.Id = id; // write-back identity
|
fresh.Id = order.Id = id; // write-back identity
|
||||||
|
fresh.DbContext = this;
|
||||||
|
_orders.Add(fresh);
|
||||||
|
_byRef[order] = fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Persist every tracked entity (mirrors EF's <c>SaveChanges</c>).</summary>
|
// ── loading: reassemble domain objects from the stored rows ──────
|
||||||
public int SaveChanges()
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materialize the <see cref="Client"/> stored under <paramref name="id"/>,
|
||||||
|
/// including all of its orders (navigation fix-up wires
|
||||||
|
/// <see cref="Order.Client"/> back). Null when nothing is stored under it.
|
||||||
|
/// </summary>
|
||||||
|
public Client? LoadClient(Guid id)
|
||||||
{
|
{
|
||||||
var count = 0;
|
var row = Find<ClientData>(r => r.Id == id);
|
||||||
foreach (var shadow in _byRef.Values)
|
if (row is null) return null;
|
||||||
|
var client = Mappers.FromClientData(row);
|
||||||
|
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
|
||||||
|
client.Orders.Add(Mappers.FromOrderData(orderRow)); // fix-up wires Order.Client
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materialize the <see cref="Order"/> stored under <paramref name="id"/>,
|
||||||
|
/// including its client (holding just this one order), or null when
|
||||||
|
/// nothing is stored under it. Use <see cref="LoadClient"/> to load a
|
||||||
|
/// client with all of its orders.
|
||||||
|
/// </summary>
|
||||||
|
public Order? LoadOrder(Guid id)
|
||||||
|
{
|
||||||
|
var row = Find<OrderData>(r => r.Id == id);
|
||||||
|
if (row is null) return null;
|
||||||
|
var order = Mappers.FromOrderData(row);
|
||||||
|
if (row.ClientId != Guid.Empty)
|
||||||
{
|
{
|
||||||
if (shadow.Id != Guid.Empty) continue; // already had an id
|
var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
|
||||||
shadow.Id = Guid.NewGuid();
|
if (clientRow is not null)
|
||||||
count++;
|
{
|
||||||
|
var client = Mappers.FromClientData(clientRow);
|
||||||
|
client.Orders.Add(order); // fix-up wires Order.Client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return count;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── query helpers ───────────────────────────────────────────────
|
// ── query helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Search the per-type table for a shadow whose predicate matches.</summary>
|
/// <summary>Search the per-type table for a record whose predicate matches.</summary>
|
||||||
public T? Find<T>(Func<T, bool> predicate) where T : Shadow
|
public T? Find<T>(Func<T, bool> predicate) where T : DbBase
|
||||||
=> Set<T>().FirstOrDefault(predicate);
|
=> Set<T>().FirstOrDefault(predicate);
|
||||||
|
|
||||||
// ── tracking / status ───────────────────────────────────────────
|
// ── tracking / status ───────────────────────────────────────────
|
||||||
@@ -97,6 +166,6 @@ public class DbContext
|
|||||||
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
||||||
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
||||||
|
|
||||||
/// <summary>All shadow entities this context currently manages.</summary>
|
/// <summary>All data records this context currently manages.</summary>
|
||||||
public IReadOnlyList<Shadow> Tracked => _byRef.Values.ToList();
|
public IReadOnlyList<DbBase> Tracked => _byRef.Values.ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain service computing a discount for an <see cref="Order"/>. Pure
|
||||||
|
/// calculation: it reads the order and returns a <see cref="DiscountedOrder"/>;
|
||||||
|
/// it never mutates the order. Copying the result onto the order — and what
|
||||||
|
/// that does or does not persist — is the exercise (see EXERCISE.md).
|
||||||
|
/// </summary>
|
||||||
|
public static class DiscountCalculator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Calculate the discount for <paramref name="order"/>. The discount
|
||||||
|
/// applies only when the order's total is <em>strictly greater</em> than
|
||||||
|
/// the barrier — an order at exactly the barrier gets none.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="order">The order to discount; its <see cref="Order.TotalInCents"/> is the input.</param>
|
||||||
|
/// <param name="discountBarrierCents">Minimum total (in cents) required to qualify; the total must exceed it.</param>
|
||||||
|
/// <param name="discountPercentage">The discount as an integer percent (10 means 10 %), 0–100.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="DiscountedOrder"/> correlated to the order by id.
|
||||||
|
/// <see cref="DiscountedOrder.DiscountPercentage"/> is 0 when the barrier
|
||||||
|
/// was not passed, and <see cref="DiscountedOrder.DiscountedTotalInCents"/>
|
||||||
|
/// equals the total in that case.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="order"/> is null.</exception>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">
|
||||||
|
/// The total or barrier is negative, or the percentage is outside 0–100.
|
||||||
|
/// </exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// The discount amount is rounded <b>half-up</b> with pure integer math —
|
||||||
|
/// <c>(total × percentage + 50) / 100</c> — so 10 % of 155 cents is a
|
||||||
|
/// 16-cent discount (15.5 rounds up), and no floating point is involved.
|
||||||
|
/// </remarks>
|
||||||
|
public static DiscountedOrder CalculateDiscount(Order order, int discountBarrierCents, int discountPercentage)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(order.TotalInCents);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(discountBarrierCents);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(discountPercentage, 0);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(discountPercentage, 100);
|
||||||
|
|
||||||
|
if (order.TotalInCents <= discountBarrierCents)
|
||||||
|
return new DiscountedOrder
|
||||||
|
{
|
||||||
|
OrderId = order.Id,
|
||||||
|
TotalInCents = order.TotalInCents,
|
||||||
|
DiscountPercentage = 0,
|
||||||
|
DiscountedTotalInCents = order.TotalInCents,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Half-up rounding in integers: add half of the divisor before the
|
||||||
|
// integer division. 64-bit intermediate: cents × percent can overflow
|
||||||
|
// 32 bits for large orders.
|
||||||
|
var discount = (int)(((long)order.TotalInCents * discountPercentage + 50) / 100);
|
||||||
|
|
||||||
|
return new DiscountedOrder
|
||||||
|
{
|
||||||
|
OrderId = order.Id,
|
||||||
|
TotalInCents = order.TotalInCents,
|
||||||
|
DiscountPercentage = discountPercentage,
|
||||||
|
DiscountedTotalInCents = order.TotalInCents - discount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The result of discounting an <see cref="Order"/>: a separate domain type,
|
||||||
|
/// not a modified order. The discount is derived data — computed on demand,
|
||||||
|
/// correlated to its order by <see cref="OrderId"/>. What a caller does with
|
||||||
|
/// it (and what of it ever reaches <see cref="OrderDto"/> or
|
||||||
|
/// <see cref="OrderData"/>) is deliberately left to the caller.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DiscountedOrder
|
||||||
|
{
|
||||||
|
/// <summary>Primary key of the discounted <see cref="Order"/>.</summary>
|
||||||
|
public Guid OrderId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The order's total in cents, as read from it (the untouched input).</summary>
|
||||||
|
public int TotalInCents { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The percentage actually applied, as an integer percent. 0 when the
|
||||||
|
/// order did not pass the discount barrier.
|
||||||
|
/// </summary>
|
||||||
|
public int DiscountPercentage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The total after the discount, in cents.</summary>
|
||||||
|
public int DiscountedTotalInCents { get; init; }
|
||||||
|
}
|
||||||
+68
-4
@@ -1,13 +1,21 @@
|
|||||||
namespace After;
|
namespace After;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hand-written mappers between the plain domain and the DTOs (no AutoMapper —
|
/// Hand-written mappers out of the plain domain (no AutoMapper — the mapping
|
||||||
/// the mapping code is part of the illustration). Each public method maps in
|
/// code is part of the illustration). Each public method maps in exactly one
|
||||||
/// exactly one direction and never mutates its input.
|
/// direction and never mutates its input. Two families, two destinations:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="ClientDto"/>/<see cref="OrderDto"/> — transport to the
|
||||||
|
/// WebApp;</item>
|
||||||
|
/// <item><see cref="ClientData"/>/<see cref="OrderData"/> — the data-only
|
||||||
|
/// records the persistence framework stores.</item>
|
||||||
|
/// </list>
|
||||||
///
|
///
|
||||||
/// The <c>Client.Orders</c> / <c>Order.Client</c> back-reference cycle is
|
/// The <c>Client.Orders</c> / <c>Order.Client</c> back-reference cycle is
|
||||||
/// resolved with a reference-identity cache: the same instance maps to the
|
/// resolved with a reference-identity cache: the same instance maps to the
|
||||||
/// same DTO (and back), so the graph terminates and stays consistent.
|
/// same DTO (and back), so the graph terminates and stays consistent. The
|
||||||
|
/// persistence mapping needs no cache: records reference each other by id,
|
||||||
|
/// not by object, so the graph is flattened and cannot cycle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Mappers
|
public static class Mappers
|
||||||
{
|
{
|
||||||
@@ -64,6 +72,7 @@ public static class Mappers
|
|||||||
{
|
{
|
||||||
Id = order.Id,
|
Id = order.Id,
|
||||||
Description = order.Description,
|
Description = order.Description,
|
||||||
|
TotalInCents = order.TotalInCents,
|
||||||
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
||||||
};
|
};
|
||||||
seen[order] = dto;
|
seen[order] = dto;
|
||||||
@@ -94,9 +103,64 @@ public static class Mappers
|
|||||||
{
|
{
|
||||||
Id = dto.Id,
|
Id = dto.Id,
|
||||||
Description = dto.Description,
|
Description = dto.Description,
|
||||||
|
TotalInCents = dto.TotalInCents,
|
||||||
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
||||||
};
|
};
|
||||||
seen[dto] = order;
|
seen[dto] = order;
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Domain -> persistence (data-only records; graph flattened to ids)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Map a <see cref="Client"/> to its persistence record <see cref="ClientData"/>.</summary>
|
||||||
|
public static ClientData ToClientData(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
return new ClientData { Id = client.Id, Name = client.Name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map an <see cref="Order"/> to its persistence record <see cref="OrderData"/>:
|
||||||
|
/// the object graph is flattened — <see cref="Order.Client"/> becomes the
|
||||||
|
/// <see cref="OrderData.ClientId"/> foreign key (empty when the order has
|
||||||
|
/// no client, or when that client has not been saved yet).
|
||||||
|
/// </summary>
|
||||||
|
public static OrderData ToOrderData(Order order)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
return new OrderData
|
||||||
|
{
|
||||||
|
Id = order.Id,
|
||||||
|
Description = order.Description,
|
||||||
|
TotalInCents = order.TotalInCents,
|
||||||
|
ClientId = order.Client?.Id ?? Guid.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Persistence -> domain (read-back). A plain field copy for now; when
|
||||||
|
// the domain grows OrderLines, FromOrderData becomes the one-line
|
||||||
|
// reconstruction (row description + total become a single line) —
|
||||||
|
// see EXERCISE2.md.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Map a <see cref="ClientData"/> record back to a <see cref="Client"/>.</summary>
|
||||||
|
public static Client FromClientData(ClientData row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
return new Client { Id = row.Id, Name = row.Name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map an <see cref="OrderData"/> record back to an <see cref="Order"/>.
|
||||||
|
/// Reassembling the graph (wiring <see cref="Order.Client"/>) is the
|
||||||
|
/// context's job, not the mapper's.
|
||||||
|
/// </summary>
|
||||||
|
public static Order FromOrderData(OrderData row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
return new Order { Id = row.Id, Description = row.Description, TotalInCents = row.TotalInCents };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,13 @@ public class Order
|
|||||||
/// <summary>Free-text description of what this order is for.</summary>
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
public string Description { get; set; } = string.Empty;
|
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"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalInCents { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The client that owns this order. Set automatically by
|
/// The client that owns this order. Set automatically by
|
||||||
/// <see cref="ClientOrders"/> when the order is added to
|
/// <see cref="ClientOrders"/> when the order is added to
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public sealed record OrderDto
|
|||||||
/// <summary>Free-text description of what this order is for.</summary>
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
public string Description { get; init; } = string.Empty;
|
public string Description { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Order total in cents of the underlying order.</summary>
|
||||||
|
public int TotalInCents { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Back-reference to the owning client, or <c>null</c> for a bare order
|
/// Back-reference to the owning client, or <c>null</c> for a bare order
|
||||||
/// that has not been added to any client.
|
/// that has not been added to any client.
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ DbBase.Context = ctx;
|
|||||||
var jane = new Client { Name = "Jane Doe" };
|
var jane = new Client { Name = "Jane Doe" };
|
||||||
var bob = new Client { Name = "Bob Smith" };
|
var bob = new Client { Name = "Bob Smith" };
|
||||||
|
|
||||||
jane.Orders.Add(new Order { Description = "Design consultation" });
|
jane.Orders.Add(new Order { Description = "Design consultation", TotalInCents = 120_00 });
|
||||||
jane.Orders.Add(new Order { Description = "Website redesign" });
|
jane.Orders.Add(new Order { Description = "Website redesign", TotalInCents = 750_00 });
|
||||||
|
|
||||||
bob.Orders.Add(new Order { Description = "Monthly retainer" });
|
bob.Orders.Add(new Order { Description = "Monthly retainer", TotalInCents = 85_00 });
|
||||||
|
|
||||||
// Orders need to be attached too so they get IDs:
|
// Orders need to be attached too so they get IDs:
|
||||||
ctx.Attach(jane.Orders[0]);
|
ctx.Attach(jane.Orders[0]);
|
||||||
@@ -38,7 +38,7 @@ foreach (var client in DbBase.Context!.Tracked.OfType<Client>())
|
|||||||
{
|
{
|
||||||
Console.WriteLine($"{client.Name} [{client.Id}]");
|
Console.WriteLine($"{client.Name} [{client.Id}]");
|
||||||
foreach (var order in client.Orders)
|
foreach (var order in client.Orders)
|
||||||
Console.WriteLine($" - order [{order.Id}]: {order.Description}");
|
Console.WriteLine($" - order [{order.Id}]: {order.Description} ({order.TotalInCents} cents)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- find by name -------------------------------------------------------
|
// ----- find by name -------------------------------------------------------
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ public class Order : DbBase
|
|||||||
/// <summary>Free-text description of what this order is for.</summary>
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Order total in cents (integer — no floating-point money). Part of the
|
||||||
|
/// persisted state, like every other scalar.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalInCents { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The client that owns this order. Set automatically by
|
/// The client that owns this order. Set automatically by
|
||||||
/// <see cref="ClientOrders"/> when the order is added to
|
/// <see cref="ClientOrders"/> when the order is added to
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The after <see cref="DbContext"/> stores data-only persistence records
|
||||||
|
/// (<see cref="ClientData"/> / <see cref="OrderData"/>), never domain objects:
|
||||||
|
/// <see cref="DbContext"/>'s <c>Save</c> maps domain state into a row —
|
||||||
|
/// inserting on the first save, refreshing in place on later ones — and the
|
||||||
|
/// stored graph is flattened to ids.
|
||||||
|
/// </summary>
|
||||||
|
public class AfterPersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Save_maps_domain_state_into_data_records()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
var clientRow = Assert.Single(db.Clients);
|
||||||
|
Assert.Equal(client.Id, clientRow.Id);
|
||||||
|
Assert.Equal("Acme", clientRow.Name);
|
||||||
|
|
||||||
|
var orderRow = Assert.Single(db.Orders);
|
||||||
|
Assert.Equal(order.Id, orderRow.Id);
|
||||||
|
Assert.Equal("one", orderRow.Description);
|
||||||
|
Assert.Equal(150_00, orderRow.TotalInCents);
|
||||||
|
Assert.Equal(client.Id, orderRow.ClientId); // the graph is flattened to a key
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_order_without_client_stores_an_empty_client_id()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("orphan").Build();
|
||||||
|
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.Equal(Guid.Empty, Assert.Single(db.Orders).ClientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
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 = 99_00;
|
||||||
|
client.Name = "Acme GmbH";
|
||||||
|
db.Save(order);
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
// One row per saved object, refreshed with the *current* state:
|
||||||
|
var orderRow = Assert.Single(db.Orders);
|
||||||
|
Assert.Equal(order.Id, orderRow.Id);
|
||||||
|
Assert.Equal("one (edited)", orderRow.Description);
|
||||||
|
Assert.Equal(99_00, orderRow.TotalInCents);
|
||||||
|
Assert.Equal(client.Id, orderRow.ClientId);
|
||||||
|
|
||||||
|
var clientRow = Assert.Single(db.Clients);
|
||||||
|
Assert.Equal("Acme GmbH", clientRow.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rows_are_reachable_through_Set_and_Find()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.Single(db.Set<ClientData>());
|
||||||
|
Assert.Single(db.Set<OrderData>());
|
||||||
|
|
||||||
|
var found = db.Find<OrderData>(row => row.Description == "one");
|
||||||
|
Assert.Equal(order.Id, found!.Id);
|
||||||
|
Assert.Null(db.Find<OrderData>(row => row.Description == "no such order"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Read-back: LoadOrder / LoadClient reassemble domain objects from the
|
||||||
|
// stored rows (identity round-trip for now — OrderLines come later).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loading_an_order_restores_its_state_and_its_client()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
var loaded = db.LoadOrder(order.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(order.Id, loaded!.Id);
|
||||||
|
Assert.Equal("one", loaded.Description);
|
||||||
|
Assert.Equal(150_00, loaded.TotalInCents);
|
||||||
|
|
||||||
|
// The graph is reassembled: the loaded order knows its client again,
|
||||||
|
// and the client's collection holds the loaded order.
|
||||||
|
Assert.NotNull(loaded.Client);
|
||||||
|
Assert.Equal("Acme", loaded.Client!.Name);
|
||||||
|
Assert.Same(loaded, Assert.Single(loaded.Client.Orders));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loading_a_client_restores_it_with_all_of_its_orders()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
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(first);
|
||||||
|
db.Save(second);
|
||||||
|
|
||||||
|
var loaded = db.LoadClient(client.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal("Acme", loaded!.Name);
|
||||||
|
Assert.Equal(2, loaded.Orders.Count);
|
||||||
|
Assert.All(loaded.Orders, o => Assert.Same(loaded, o.Client));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loading_an_unknown_id_returns_null()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
|
||||||
|
Assert.Null(db.LoadOrder(Guid.NewGuid()));
|
||||||
|
Assert.Null(db.LoadClient(Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,12 +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" };
|
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(150_00, dto.TotalInCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -78,12 +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" };
|
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(150_00, order.TotalInCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -93,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);
|
||||||
@@ -128,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" };
|
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));
|
||||||
|
|
||||||
@@ -139,6 +146,7 @@ public class AfterTests
|
|||||||
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||||
Assert.Equal(order.Id, roundTripOrder.Id);
|
Assert.Equal(order.Id, roundTripOrder.Id);
|
||||||
Assert.Equal(order.Description, roundTripOrder.Description);
|
Assert.Equal(order.Description, roundTripOrder.Description);
|
||||||
|
Assert.Equal(order.TotalInCents, roundTripOrder.TotalInCents);
|
||||||
// The back-reference is still consistent on the reconstructed graph:
|
// The back-reference is still consistent on the reconstructed graph:
|
||||||
Assert.Same(roundTrip, roundTripOrder.Client);
|
Assert.Same(roundTrip, roundTripOrder.Client);
|
||||||
}
|
}
|
||||||
@@ -146,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];
|
||||||
|
|
||||||
@@ -159,6 +166,7 @@ public class AfterTests
|
|||||||
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||||
Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
|
Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
|
||||||
Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
|
Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
|
||||||
|
Assert.Equal(dtoOrder.TotalInCents, roundTripOrder.TotalInCents);
|
||||||
Assert.Same(roundTrip, roundTripOrder.ClientDto);
|
Assert.Same(roundTrip, roundTripOrder.ClientDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,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);
|
||||||
@@ -243,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" };
|
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));
|
||||||
|
|
||||||
@@ -262,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);
|
||||||
@@ -282,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;
|
||||||
|
|
||||||
@@ -296,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) ...
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="DiscountCalculator.CalculateDiscount"/>:
|
||||||
|
/// barrier semantics (strictly greater), integer-percent units, half-up
|
||||||
|
/// rounding of the discount amount, input validation, and the guarantee that
|
||||||
|
/// the calculation never mutates the order.
|
||||||
|
/// </summary>
|
||||||
|
public class DiscountCalculatorTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// The barrier: only a total strictly greater than it is discounted.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void A_total_below_the_barrier_gets_no_discount()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithTotal(50_00).Build();
|
||||||
|
|
||||||
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
|
Assert.Equal(0, discounted.DiscountPercentage);
|
||||||
|
Assert.Equal(50_00, discounted.DiscountedTotalInCents);
|
||||||
|
Assert.Equal(50_00, discounted.TotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_total_exactly_at_the_barrier_gets_no_discount()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithTotal(100_00).Build();
|
||||||
|
|
||||||
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
|
Assert.Equal(0, discounted.DiscountPercentage);
|
||||||
|
Assert.Equal(100_00, discounted.DiscountedTotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Above the barrier: percentage applied, total reduced.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void A_total_above_the_barrier_gets_the_percentage_discount()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithTotal(150_00).Build();
|
||||||
|
|
||||||
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
|
Assert.Equal(10, discounted.DiscountPercentage);
|
||||||
|
Assert.Equal(135_00, discounted.DiscountedTotalInCents); // 10 % of 150 = 15 off
|
||||||
|
Assert.Equal(150_00, discounted.TotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_zero_percentage_discounts_nothing_even_above_the_barrier()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithTotal(150_00).Build();
|
||||||
|
|
||||||
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 0);
|
||||||
|
|
||||||
|
Assert.Equal(0, discounted.DiscountPercentage);
|
||||||
|
Assert.Equal(150_00, discounted.DiscountedTotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_hundred_percent_discount_leaves_nothing()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithTotal(150_00).Build();
|
||||||
|
|
||||||
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 100);
|
||||||
|
|
||||||
|
Assert.Equal(100, discounted.DiscountPercentage);
|
||||||
|
Assert.Equal(0, discounted.DiscountedTotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Half-up rounding of the discount amount (integer math, no float).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Theory]
|
||||||
|
[InlineData(155, 16, 139)] // 15.5 rounds UP to 16
|
||||||
|
[InlineData(145, 15, 130)] // 14.5 rounds UP to 15
|
||||||
|
[InlineData(154, 15, 139)] // 15.4 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)
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithTotal(totalInCents).Build();
|
||||||
|
|
||||||
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100, discountPercentage: 10);
|
||||||
|
|
||||||
|
Assert.Equal(totalInCents - expectedDiscountCents, discounted.DiscountedTotalInCents);
|
||||||
|
Assert.Equal(expectedDiscountedTotal, discounted.DiscountedTotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Correlation and purity.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void The_result_is_correlated_to_the_order_by_id()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var order = ValidOrder().WithId(id).WithTotal(150_00).Build();
|
||||||
|
|
||||||
|
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
|
Assert.Equal(id, discounted.OrderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Calculating_does_not_mutate_the_order()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
|
||||||
|
DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
|
Assert.Equal("one", order.Description);
|
||||||
|
Assert.Equal(150_00, order.TotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Input validation: invalid arguments throw (loudly, not silently).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-1, 100_00, 10)] // negative total
|
||||||
|
[InlineData(150_00, -1, 10)] // negative barrier
|
||||||
|
[InlineData(150_00, 100_00, -1)] // percentage below 0
|
||||||
|
[InlineData(150_00, 100_00, 101)]// percentage above 100
|
||||||
|
public void Invalid_arguments_throw(int totalInCents, int discountBarrierCents, int discountPercentage)
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithTotal(totalInCents).Build();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
|
() => DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_null_order_throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(
|
||||||
|
() => DiscountCalculator.CalculateDiscount(null!, discountBarrierCents: 100_00, discountPercentage: 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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