Exercise baseline

This commit is contained in:
2026-09-17 20:42:49 +01:00
commit 863c7a6349
36 changed files with 3898 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
using Domain;
using static TestData.ClientBuilder;
using static TestData.OrderBuilder;
namespace Db.Tests;
/// <summary>The fake <see cref="DbContext"/> stores data-only records, never domain objects; Save maps state into rows (insert or refresh), the graph 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.
[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()));
}
// Tracking: explicit Save, distinct ids; a saved object keeps no reference to the context.
[Fact]
public void DbContext_saves_plain_domain_objects_and_tracks_them()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("one").Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
Assert.Equal(Guid.Empty, client.Id);
db.Save(client);
db.Save(order);
Assert.NotEqual(Guid.Empty, client.Id);
Assert.NotEqual(Guid.Empty, order.Id);
Assert.NotEqual(client.Id, order.Id);
Assert.True(db.IsTracked(client));
Assert.True(db.IsTracked(order));
Assert.Equal(2, db.Tracked.Count);
}
[Fact]
public void Saving_twice_does_not_reassign_the_id()
{
var db = new DbContext();
var client = ValidClient().WithName("Acme").Build();
db.Save(client);
var id = client.Id;
db.Save(client);
Assert.Equal(id, client.Id);
Assert.Single(db.Tracked);
}
[Fact]
public void A_saved_domain_object_keeps_no_reference_to_the_context()
{
var db = new DbContext();
var client = ValidClient().WithName("Acme").Build();
db.Save(client);
// Saved (the id was assigned by the context) ...
Assert.NotEqual(Guid.Empty, client.Id);
Assert.True(db.IsTracked(client));
// No way back to the context through the domain object (see DomainStaysPlainTests): the only "receipt" is the plain Id value.
Assert.Equal(db.Tracked.Single(t => t.Id == client.Id).Id, client.Id);
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Domain is referenced explicitly because these tests use domain
types directly (it also flows in transitively through Db). -->
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
<ProjectReference Include="..\..\src\Db\Db.csproj" />
<ProjectReference Include="..\TestData\TestData.csproj" />
</ItemGroup>
</Project>
+30
View File
@@ -0,0 +1,30 @@
using Domain;
using System.Reflection;
namespace Db.Tests;
/// <summary>The domain stayed clean: Client/Order do NOT derive from <see cref="DbBase"/> and expose no <see cref="DbContext"/>-typed member; asserted from the persistence side, where both are visible.</summary>
public class DomainStaysPlainTests
{
private const BindingFlags AllMembers =
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
// The leak is gone: no DbBase in the base chain, no DbContext-typed member (reflection).
[Theory]
[InlineData(typeof(Client))]
[InlineData(typeof(Order))]
public void Domain_objects_are_plain__no_DbBase_no_DbContext_member(Type type)
{
// Not DbBase, and not a derived type either (walk the base chain):
for (var t = type; t is not null && t != typeof(object); t = t.BaseType)
Assert.NotSame(typeof(DbBase), t);
// No DbContext-typed member, public or private:
foreach (var p in type.GetProperties(AllMembers))
Assert.True(p.PropertyType != typeof(DbContext),
$"{type.Name}.{p.Name} leaks a DbContext-typed property");
foreach (var f in type.GetFields(AllMembers))
Assert.True(f.FieldType != typeof(DbContext),
$"{type.Name}.{f.Name} leaks a DbContext-typed field");
}
}