Exercise baseline
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\TestData\TestData.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
using Domain;
|
||||
|
||||
namespace TestData;
|
||||
|
||||
/// <summary>Test data builder for a <see cref="Client"/> (TestDataBuilder pattern); <see cref="ValidClient"/> starts fresh — 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,33 @@
|
||||
using Domain;
|
||||
|
||||
namespace TestData;
|
||||
|
||||
/// <summary>Test data builder for an <see cref="Order"/> (TestDataBuilder pattern); the only place tests touch an order's construction, so tests survive domain evolution.</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).</summary>
|
||||
public OrderBuilder WithTotal(int totalInCents)
|
||||
{
|
||||
_order.TotalInCents = totalInCents;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Order Build() => _order;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<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>
|
||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,163 @@
|
||||
using Domain;
|
||||
using static TestData.ClientBuilder;
|
||||
using static TestData.OrderBuilder;
|
||||
|
||||
namespace Web.Tests;
|
||||
|
||||
/// <summary>The hand-written <see cref="DtoMappers"/> map both ways, never mutate their input, and round-trip losslessly; domain objects come from the test-data builders, DTO initializers stay literal.</summary>
|
||||
public class DtoMapperTests
|
||||
{
|
||||
// (1) Mappers map all scalar fields, in both directions.
|
||||
[Fact]
|
||||
public void ToClientDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var client = ValidClient().WithId(id).WithName("Acme").Build();
|
||||
|
||||
var dto = DtoMappers.ToClientDto(client);
|
||||
|
||||
Assert.Equal(id, dto.Id);
|
||||
Assert.Equal("Acme", dto.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToOrderDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
|
||||
|
||||
var dto = DtoMappers.ToOrderDto(order);
|
||||
|
||||
Assert.Equal(id, dto.Id);
|
||||
Assert.Equal("one", dto.Description);
|
||||
Assert.Equal(150_00, dto.TotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromClientDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var dto = new ClientDto { Id = id, Name = "Acme" };
|
||||
|
||||
var client = DtoMappers.FromClientDto(dto);
|
||||
|
||||
Assert.Equal(id, client.Id);
|
||||
Assert.Equal("Acme", client.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromOrderDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
|
||||
|
||||
var order = DtoMappers.FromOrderDto(dto);
|
||||
|
||||
Assert.Equal(id, order.Id);
|
||||
Assert.Equal("one", order.Description);
|
||||
Assert.Equal(150_00, order.TotalInCents);
|
||||
}
|
||||
|
||||
// (2) Mappers map the relations, and the back-references stay consistent.
|
||||
[Fact]
|
||||
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
||||
{
|
||||
var order = ValidOrder().WithDescription("one").Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var dto = DtoMappers.ToClientDto(client);
|
||||
var orderDto = Assert.Single(dto.Orders);
|
||||
|
||||
// The order is mapped with the client's data:
|
||||
Assert.Equal(order.Id, orderDto.Id);
|
||||
Assert.Equal(order.Description, orderDto.Description);
|
||||
|
||||
// The back-reference cycle is resolved to the SAME dto reference:
|
||||
Assert.Same(dto, orderDto.ClientDto);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromClientDto_wires_the_order_client_back_reference()
|
||||
{
|
||||
var orderDto = new OrderDto { Description = "one" };
|
||||
var dto = new ClientDto { Name = "Acme", Orders = new[] { orderDto } };
|
||||
|
||||
var client = DtoMappers.FromClientDto(dto);
|
||||
var order = Assert.Single(client.Orders);
|
||||
|
||||
Assert.Equal(orderDto.Description, order.Description);
|
||||
// Navigation fix-up: the reconstructed order points at the client:
|
||||
Assert.Same(client, order.Client);
|
||||
}
|
||||
|
||||
// (3) Round-trips preserve all state (compared by values, not reference).
|
||||
[Fact]
|
||||
public void Domain_round_trip_preserves_all_state()
|
||||
{
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var roundTrip = DtoMappers.FromClientDto(DtoMappers.ToClientDto(client));
|
||||
|
||||
Assert.Equal(client.Id, roundTrip.Id);
|
||||
Assert.Equal(client.Name, roundTrip.Name);
|
||||
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||
Assert.Equal(order.Id, roundTripOrder.Id);
|
||||
Assert.Equal(order.Description, roundTripOrder.Description);
|
||||
Assert.Equal(order.TotalInCents, roundTripOrder.TotalInCents);
|
||||
// The back-reference is still consistent on the reconstructed graph:
|
||||
Assert.Same(roundTrip, roundTripOrder.Client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dto_round_trip_preserves_all_state()
|
||||
{
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
var dto = DtoMappers.ToClientDto(client);
|
||||
var dtoOrder = dto.Orders[0];
|
||||
|
||||
var roundTrip = DtoMappers.ToClientDto(DtoMappers.FromClientDto(dto));
|
||||
|
||||
Assert.Equal(dto.Id, roundTrip.Id);
|
||||
Assert.Equal(dto.Name, roundTrip.Name);
|
||||
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||
Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
|
||||
Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
|
||||
Assert.Equal(dtoOrder.TotalInCents, roundTripOrder.TotalInCents);
|
||||
Assert.Same(roundTrip, roundTripOrder.ClientDto);
|
||||
}
|
||||
|
||||
// (4) Mappers do not mutate their inputs (snapshot fields, map, compare).
|
||||
[Fact]
|
||||
public void ToClientDto_does_not_mutate_the_domain()
|
||||
{
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
var ordersCount = client.Orders.Count;
|
||||
|
||||
DtoMappers.ToClientDto(client);
|
||||
DtoMappers.ToOrderDto(order);
|
||||
|
||||
Assert.Equal("Acme", client.Name);
|
||||
Assert.Equal(ordersCount, client.Orders.Count);
|
||||
Assert.Same(client, order.Client); // back-reference untouched
|
||||
Assert.Equal("one", order.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromClientDto_does_not_mutate_the_dto()
|
||||
{
|
||||
var orderDto = new OrderDto { Description = "one" };
|
||||
var dto = new ClientDto { Id = Guid.NewGuid(), Name = "Acme", Orders = new[] { orderDto } };
|
||||
|
||||
DtoMappers.FromClientDto(dto);
|
||||
DtoMappers.FromOrderDto(orderDto);
|
||||
|
||||
Assert.Equal("Acme", dto.Name);
|
||||
Assert.Same(orderDto, Assert.Single(dto.Orders)); // collection untouched
|
||||
Assert.Null(orderDto.ClientDto); // back-reference untouched
|
||||
Assert.Equal("one", orderDto.Description);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<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 Web).
|
||||
Deliberately NO reference to Db — mirroring the Web layer. -->
|
||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Web\Web.csproj" />
|
||||
<ProjectReference Include="..\TestData\TestData.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using Domain;
|
||||
using System.Reflection;
|
||||
using static TestData.ClientBuilder;
|
||||
using static TestData.OrderBuilder;
|
||||
|
||||
namespace Web.Tests;
|
||||
|
||||
/// <summary>The WebApp consumes only DTOs; persistence types are excluded at compile time (no Web → Db project reference).</summary>
|
||||
public class WebAppTests
|
||||
{
|
||||
// The WebApp's public API references no domain type (reflection).
|
||||
private static readonly Type[] BannedTypes = [typeof(Client), typeof(Order)];
|
||||
|
||||
private static IEnumerable<Type> TypeAndArguments(Type t)
|
||||
{
|
||||
yield return t;
|
||||
if (t.IsGenericType)
|
||||
foreach (var arg in t.GetGenericArguments())
|
||||
foreach (var nested in TypeAndArguments(arg))
|
||||
yield return nested;
|
||||
if (t.IsArray && t.GetElementType() is { } elem)
|
||||
foreach (var nested in TypeAndArguments(elem))
|
||||
yield return nested;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebApp_public_api_references_no_domain_types()
|
||||
{
|
||||
var app = typeof(WebApp);
|
||||
var banned = BannedTypes.ToList();
|
||||
|
||||
foreach (var m in app.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||
{
|
||||
var signature = m.GetParameters()
|
||||
.Select(p => p.ParameterType)
|
||||
.Append(m.ReturnType)
|
||||
.ToList();
|
||||
foreach (var t in signature.SelectMany(TypeAndArguments))
|
||||
Assert.False(banned.Contains(t),
|
||||
$"WebApp.{m.Name}({string.Join(", ", signature)}) references banned type {t}");
|
||||
}
|
||||
|
||||
foreach (var p in app.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||
foreach (var t in TypeAndArguments(p.PropertyType))
|
||||
Assert.False(banned.Contains(t),
|
||||
$"WebApp.{p.Name} references banned type {t}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebApp_consumes_dtos()
|
||||
{
|
||||
var app = new WebApp();
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("order one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var view = app.ShowClient(DtoMappers.ToClientDto(client));
|
||||
|
||||
Assert.Contains("Acme", view);
|
||||
Assert.Contains("order one", view);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user