using After; using System.Reflection; namespace BeforeAfter.Tests; /// /// Tests for the AFTER situation (Yak 02): plain domain objects (no /// ), DTOs (records) carrying the data including the /// relations, hand-written mappers, and a WebApp that consumes only DTOs. /// 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 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 } }