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.
312 lines
12 KiB
C#
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
|
|
}
|
|
} |