Files
db-subclass-to-dto/tests/BeforeAfter.Tests/AfterTests.cs
T
mostalive c22a1e2ffa Yak: csharp after situation (Yak 02) — plain domain + DTOs + mappers
After/: plain Client/Order (no DbBase), ClientDto/OrderDto records
carrying the relations both ways, hand-written Mappers with a
reference-identity cache for the back-reference cycle, DbContext with
explicit Save(domain) via shadow entities (flipped dependency), and a
WebApp that consumes only DTOs. AfterTests cover the 7 required
behaviours; full solution builds with 0 warnings, 26/26 tests pass.
2026-09-11 15:02:35 +01:00

312 lines
12 KiB
C#

using After;
using System.Reflection;
namespace BeforeAfter.Tests;
/// <summary>
/// Tests for the AFTER situation (Yak 02): plain domain objects (no
/// <see cref="DbBase"/>), DTOs (records) carrying the data including the
/// relations, hand-written mappers, and a WebApp that consumes only DTOs.
/// </summary>
public class AfterTests
{
private const BindingFlags AllMembers =
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
// ---------------------------------------------------------------------
// (1) The leak is gone: Client/Order do NOT derive from DbBase and expose
// no DbContext-typed property or field (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");
}
// ---------------------------------------------------------------------
// (2) Mappers map all scalar fields, in both directions.
// ---------------------------------------------------------------------
[Fact]
public void ToClientDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var client = new Client { Id = id, Name = "Acme" };
var dto = Mappers.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 = new Order { Id = id, Description = "one" };
var dto = Mappers.ToOrderDto(order);
Assert.Equal(id, dto.Id);
Assert.Equal("one", dto.Description);
}
[Fact]
public void FromClientDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var dto = new ClientDto { Id = id, Name = "Acme" };
var client = Mappers.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" };
var order = Mappers.FromOrderDto(dto);
Assert.Equal(id, order.Id);
Assert.Equal("one", order.Description);
}
// ---------------------------------------------------------------------
// (3) Mappers map the relations: Client.Orders -> ClientDto.Orders, and the
// Order.Client / OrderDto.ClientDto back-references stay consistent.
// ---------------------------------------------------------------------
[Fact]
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
{
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one" };
client.Orders.Add(order);
var dto = Mappers.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 = Mappers.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);
}
// ---------------------------------------------------------------------
// (4) Round-trips preserve all state (compared by values, not reference).
// ---------------------------------------------------------------------
[Fact]
public void Domain_round_trip_preserves_all_state()
{
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
client.Orders.Add(order);
var roundTrip = Mappers.FromClientDto(Mappers.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);
// 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 client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
client.Orders.Add(order);
var dto = Mappers.ToClientDto(client);
var dtoOrder = dto.Orders[0];
var roundTrip = Mappers.ToClientDto(Mappers.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.Same(roundTrip, roundTripOrder.ClientDto);
}
// ---------------------------------------------------------------------
// (5) Mappers do not mutate their inputs (snapshot fields, map, compare).
// ---------------------------------------------------------------------
[Fact]
public void ToClientDto_does_not_mutate_the_domain()
{
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
client.Orders.Add(order);
var ordersCount = client.Orders.Count;
Mappers.ToClientDto(client);
Mappers.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 } };
Mappers.FromClientDto(dto);
Mappers.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);
}
// ---------------------------------------------------------------------
// (6) The after WebApp consumes only DTOs: its public API references no
// domain (Client/Order) or persistence (DbContext/DbBase) type.
// ---------------------------------------------------------------------
private static readonly Type[] BannedTypes =
[typeof(Client), typeof(Order), typeof(DbContext), typeof(DbBase)];
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 After_WebApp_public_api_references_no_domain_or_db_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 After_WebApp_consumes_dtos()
{
var app = new WebApp();
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "order one" };
client.Orders.Add(order);
var view = app.ShowClient(Mappers.ToClientDto(client));
Assert.Contains("Acme", view);
Assert.Contains("order one", view);
}
// ---------------------------------------------------------------------
// (7) Persistence still works through the fake DbContext in the after
// world: explicit Save, distinct ids, tracking — and even a saved
// domain object keeps no back-reference to the context.
// ---------------------------------------------------------------------
[Fact]
public void DbContext_saves_plain_domain_objects_and_tracks_them()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one" };
client.Orders.Add(order);
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 = new Client { Name = "Acme" };
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 = new Client { Name = "Acme" };
db.Save(client);
// Saved (the id was assigned by the context) ...
Assert.NotEqual(Guid.Empty, client.Id);
Assert.True(db.IsTracked(client));
// ... but there is no way back to the context through the domain
// object: no DbContext-typed member exists on it (test 1), so the
// only "receipt" is the plain Id value.
var dto = Mappers.ToClientDto(client);
Assert.Equal(client.Id, dto.Id); // the id flows on into the DTO
}
}