diff --git a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs index ad687384..67085bf6 100644 --- a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs +++ b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using AutoMapper; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -31,18 +30,24 @@ namespace OneGround.ZGW.Notificaties.Web.Controllers.v1; [ZgwApiVersion(Api.LatestVersion_1_0)] public class AbonnementController : ZGWControllerBase { + private readonly MapsterMapper.IMapper _mapsterMapper; + private readonly IZgwRequestMerger _zgwRequestMerger; private readonly IValidatorService _validatorService; public AbonnementController( ILogger logger, IMediator mediator, - IMapper mapper, - IRequestMerger requestMerger, + AutoMapper.IMapper mapper, + MapsterMapper.IMapper mapsterMapper, + IRequestMerger requestMerger, // unused here; ZGWControllerBase's constructor still requires it + IZgwRequestMerger zgwRequestMerger, IErrorResponseBuilder errorResponseBuilder, IValidatorService validatorService ) : base(logger, mediator, mapper, requestMerger, errorResponseBuilder) { + _mapsterMapper = mapsterMapper; + _zgwRequestMerger = zgwRequestMerger; _validatorService = validatorService; } @@ -62,7 +67,7 @@ public async Task GetAllAsync() var result = await _mediator.Send(new GetAllAbonnementenQuery()); - var abonnementenResponse = _mapper.Map>(result.Result); + var abonnementenResponse = _mapsterMapper.Map>(result.Result); return Ok(abonnementenResponse); } @@ -89,7 +94,7 @@ public async Task GetAsync(Guid id) return _errorResponseBuilder.NotFound(); } - var abonnementResponse = _mapper.Map(result.Result); + var abonnementResponse = _mapsterMapper.Map(result.Result); return Ok(abonnementResponse); } @@ -109,7 +114,7 @@ public async Task CreateAsync([FromBody] AbonnementRequestDto abo { _logger.LogDebug("{ControllerMethod} called with {@FromBody}", nameof(CreateAsync), abonnementRequest); - Abonnement abonnement = _mapper.Map(abonnementRequest); + Abonnement abonnement = _mapsterMapper.Map(abonnementRequest); var result = await _mediator.Send(new CreateAbonnementCommand { Abonnement = abonnement }); @@ -118,7 +123,7 @@ public async Task CreateAsync([FromBody] AbonnementRequestDto abo return _errorResponseBuilder.BadRequest(result.Errors); } - var abonnementResponse = _mapper.Map(result.Result); + var abonnementResponse = _mapsterMapper.Map(result.Result); return Created(abonnementResponse.Url, abonnementResponse); } @@ -139,7 +144,7 @@ public async Task UpdateAsync([FromBody] AbonnementRequestDto abo { _logger.LogDebug("{ControllerMethod} called with {@FromBody}, {Uuid}", nameof(UpdateAsync), abonnementRequest, id); - Abonnement abonnement = _mapper.Map(abonnementRequest); + Abonnement abonnement = _mapsterMapper.Map(abonnementRequest); var result = await _mediator.Send(new UpdateAbonnementCommand { Abonnement = abonnement, Id = id }); @@ -153,7 +158,7 @@ public async Task UpdateAsync([FromBody] AbonnementRequestDto abo return _errorResponseBuilder.BadRequest(result.Errors); } - var abonnementResponse = _mapper.Map(result.Result); + var abonnementResponse = _mapsterMapper.Map(result.Result); return Ok(abonnementResponse); } @@ -181,7 +186,7 @@ public async Task PartialUpdateAsync([FromBody] JObject partialAb return _errorResponseBuilder.NotFound(); } - AbonnementRequestDto mergedAbonnementRequest = _requestMerger.MergePartialUpdateToObjectRequest( + AbonnementRequestDto mergedAbonnementRequest = _zgwRequestMerger.MergePartialUpdateToObjectRequest( resultGet.Result, partialAbonnementRequest ); @@ -191,7 +196,7 @@ public async Task PartialUpdateAsync([FromBody] JObject partialAb return _errorResponseBuilder.BadRequest(validationResult); } - Abonnement mergedAbonnement = _mapper.Map(mergedAbonnementRequest); + Abonnement mergedAbonnement = _mapsterMapper.Map(mergedAbonnementRequest); var resultUpd = await _mediator.Send(new UpdateAbonnementCommand { Abonnement = mergedAbonnement, Id = id }); @@ -200,7 +205,7 @@ public async Task PartialUpdateAsync([FromBody] JObject partialAb return _errorResponseBuilder.BadRequest(resultUpd.Errors); } - var abonnementResponse = _mapper.Map(resultUpd.Result); + var abonnementResponse = _mapsterMapper.Map(resultUpd.Result); return Ok(abonnementResponse); } diff --git a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/KanaalController.cs b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/KanaalController.cs index 0ee85973..20bda58a 100644 --- a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/KanaalController.cs +++ b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/KanaalController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using AutoMapper; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -30,14 +29,20 @@ namespace OneGround.ZGW.Notificaties.Web.Controllers.v1; [ZgwApiVersion(Api.LatestVersion_1_0)] public class KanaalController : ZGWControllerBase { + private readonly MapsterMapper.IMapper _mapsterMapper; + public KanaalController( ILogger logger, IMediator mediator, - IMapper mapper, + AutoMapper.IMapper mapper, + MapsterMapper.IMapper mapsterMapper, IRequestMerger requestMerger, IErrorResponseBuilder errorResponseBuilder ) - : base(logger, mediator, mapper, requestMerger, errorResponseBuilder) { } + : base(logger, mediator, mapper, requestMerger, errorResponseBuilder) + { + _mapsterMapper = mapsterMapper; + } /// /// Alle KANAALen opvragen. @@ -57,7 +62,7 @@ public async Task GetAllAsync(string naam) var result = await _mediator.Send(new GetAllKanalenQuery(naam)); - var kanalenResponse = _mapper.Map>(result.Result); + var kanalenResponse = _mapsterMapper.Map>(result.Result); return Ok(kanalenResponse); } @@ -84,7 +89,7 @@ public async Task GetAsync(Guid id) return _errorResponseBuilder.NotFound(); } - var kanaalResponse = _mapper.Map(result.Result); + var kanaalResponse = _mapsterMapper.Map(result.Result); return Ok(kanaalResponse); } @@ -104,7 +109,7 @@ public async Task CreateAsync([FromBody] KanaalRequestDto kanaalR { _logger.LogDebug("{ControllerMethod} called with {@FromBody}", nameof(CreateAsync), kanaalRequest); - Kanaal kanaal = _mapper.Map(kanaalRequest); + Kanaal kanaal = _mapsterMapper.Map(kanaalRequest); var result = await _mediator.Send(new CreateKanaalCommand { Kanaal = kanaal }); @@ -113,7 +118,7 @@ public async Task CreateAsync([FromBody] KanaalRequestDto kanaalR return _errorResponseBuilder.BadRequest(result.Errors); } - var kanaalResponse = _mapper.Map(result.Result); + var kanaalResponse = _mapsterMapper.Map(result.Result); return Created(kanaalResponse.Url, kanaalResponse); } diff --git a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/NotificatiesController.cs b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/NotificatiesController.cs index 1151a659..e35364f2 100644 --- a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/NotificatiesController.cs +++ b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/NotificatiesController.cs @@ -1,5 +1,4 @@ using System.Threading.Tasks; -using AutoMapper; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -32,14 +31,20 @@ namespace OneGround.ZGW.Notificaties.Web.Controllers.v1; [ZgwApiVersion(Api.LatestVersion_1_0)] public class NotificatiesController : ZGWControllerBase { + private readonly MapsterMapper.IMapper _mapsterMapper; + public NotificatiesController( ILogger logger, IMediator mediator, - IMapper mapper, + AutoMapper.IMapper mapper, + MapsterMapper.IMapper mapsterMapper, IRequestMerger requestMerger, IErrorResponseBuilder errorResponseBuilder ) - : base(logger, mediator, mapper, requestMerger, errorResponseBuilder) { } + : base(logger, mediator, mapper, requestMerger, errorResponseBuilder) + { + _mapsterMapper = mapsterMapper; + } /// /// Publiceer een notificatie. @@ -57,7 +62,7 @@ public async Task NotificeerAsync([FromBody] NotificatieDto notif { _logger.LogDebug("{ControllerMethod} called with {@FromBody}", nameof(NotificeerAsync), notificatieRequest); - var notificatie = _mapper.Map(notificatieRequest); + var notificatie = _mapsterMapper.Map(notificatieRequest); var result = await _mediator.Send(new QueueNotificatieCommand { Notificatie = notificatie }); diff --git a/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/DomainToResponseProfile.cs b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/DomainToResponseProfile.cs deleted file mode 100644 index ce580ff4..00000000 --- a/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/DomainToResponseProfile.cs +++ /dev/null @@ -1,32 +0,0 @@ -using AutoMapper; -using OneGround.ZGW.Common.Web.Mapping.ValueResolvers; -using OneGround.ZGW.Notificaties.Contracts.v1; -using OneGround.ZGW.Notificaties.Contracts.v1.Requests; -using OneGround.ZGW.Notificaties.Contracts.v1.Responses; -using OneGround.ZGW.Notificaties.DataModel; -using OneGround.ZGW.Notificaties.Web.Extensions; - -namespace OneGround.ZGW.Notificaties.Web.MappingProfiles.v1; - -public class DomainToResponseProfile : Profile -{ - public DomainToResponseProfile() - { - CreateMap() - .ForMember(dest => dest.Url, opt => opt.MapFrom()) - .ForMember(dest => dest.Auth, opt => opt.MapFrom(src => "")) - .ForMember(dest => dest.Kanalen, opt => opt.MapFrom(src => src.AbonnementKanalen)); - - CreateMap() - .ForMember(dest => dest.Naam, opt => opt.MapFrom(src => src.Kanaal.Naam)) - .ForMember(dest => dest.Filters, opt => opt.MapFrom(src => src.FiltersToDictionary())); - - CreateMap(); - - CreateMap().ForMember(dest => dest.Url, opt => opt.MapFrom()); - - // Note: This map is used to merge an existing KANAAL/ABONNEMENT with the PATCH operation - CreateMap(); - CreateMap().ForMember(dest => dest.Kanalen, opt => opt.MapFrom(src => src.AbonnementKanalen)); - } -} diff --git a/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/DomainToResponseRegister.cs b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/DomainToResponseRegister.cs new file mode 100644 index 00000000..2901b41d --- /dev/null +++ b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/DomainToResponseRegister.cs @@ -0,0 +1,34 @@ +using Mapster; +using OneGround.ZGW.Common.Web.Mapping.Mapster; +using OneGround.ZGW.Notificaties.Contracts.v1; +using OneGround.ZGW.Notificaties.Contracts.v1.Requests; +using OneGround.ZGW.Notificaties.Contracts.v1.Responses; +using OneGround.ZGW.Notificaties.DataModel; +using OneGround.ZGW.Notificaties.Web.Extensions; + +namespace OneGround.ZGW.Notificaties.Web.MappingProfiles.v1; + +public class DomainToResponseRegister : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config + .NewConfig() + .Map(dest => dest.Url, src => MapsterUrlResolver.ResolveUrl(src)) + .Map(dest => dest.Auth, src => "") + .Map(dest => dest.Kanalen, src => src.AbonnementKanalen); + + config + .NewConfig() + .Map(dest => dest.Naam, src => src.Kanaal.Naam) + .Map(dest => dest.Filters, src => src.FiltersToDictionary()); + + config.NewConfig(); + + config.NewConfig().Map(dest => dest.Url, src => MapsterUrlResolver.ResolveUrl(src)); + + // Note: These maps are used to merge an existing KANAAL/ABONNEMENT with the PATCH operation + config.NewConfig(); + config.NewConfig().Map(dest => dest.Kanalen, src => src.AbonnementKanalen); + } +} diff --git a/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainProfile.cs b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainProfile.cs deleted file mode 100644 index feb41a21..00000000 --- a/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainProfile.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Collections.Generic; -using AutoMapper; -using OneGround.ZGW.Common.Helpers; -using OneGround.ZGW.Notificaties.Contracts.v1; -using OneGround.ZGW.Notificaties.Contracts.v1.Requests; -using OneGround.ZGW.Notificaties.DataModel; - -namespace OneGround.ZGW.Notificaties.Web.MappingProfiles.v1; - -public class RequestToDomainProfile : Profile -{ - public RequestToDomainProfile() - { - CreateMap() - .ForMember(dest => dest.Id, opt => opt.Ignore()) - .ForMember(dest => dest.AbonnementKanalen, opt => opt.MapFrom(src => src.Kanalen)) - .ForMember(dest => dest.Blocked, opt => opt.Ignore()) - .ForMember(dest => dest.Owner, opt => opt.Ignore()); - - CreateMap() - .ForMember(dest => dest.Id, opt => opt.Ignore()) - .ForMember(dest => dest.Kanaal, opt => opt.Ignore()) - .AfterMap((src, dst) => dst.Kanaal = new Kanaal { Naam = src.Naam }) - .ForMember(dest => dest.KanaalId, opt => opt.Ignore()) - .ForMember(dest => dest.AbonnementId, opt => opt.Ignore()) - .ForMember(dest => dest.Abonnement, opt => opt.Ignore()) - .ForMember(dest => dest.Filters, opt => opt.MapFrom(src => ConvertFilterValueDictionaryToList(src.Filters))); - - CreateMap() - .ForMember(dest => dest.Id, opt => opt.Ignore()) - .ForMember(dest => dest.AbonnementKanaal, opt => opt.Ignore()) - .ForMember(dest => dest.AbonnementKanaalId, opt => opt.Ignore()); - - CreateMap() - .ForMember(dest => dest.Id, opt => opt.Ignore()) - .ForMember(dest => dest.CreatedBy, opt => opt.Ignore()) - .ForMember(dest => dest.ModifiedBy, opt => opt.Ignore()) - .ForMember(dest => dest.CreationTime, opt => opt.Ignore()) - .ForMember(dest => dest.ModificationTime, opt => opt.Ignore()) - .ForMember(dest => dest.AbonnementKanalen, opt => opt.Ignore()); - - CreateMap() - .ForMember(dest => dest.AanmaakDatum, opt => opt.MapFrom(src => ProfileHelper.DateTimeFromString(src.Aanmaakdatum))); - } - - private static IEnumerable ConvertFilterValueDictionaryToList(IDictionary dictionary) - { - if (dictionary != null) - { - foreach (var filter in dictionary) - { - yield return new FilterValue { Key = filter.Key, Value = filter.Value }; - } - } - } -} diff --git a/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs new file mode 100644 index 00000000..da2d7f31 --- /dev/null +++ b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Linq; +using Mapster; +using OneGround.ZGW.Common.Helpers; +using OneGround.ZGW.Notificaties.Contracts.v1; +using OneGround.ZGW.Notificaties.Contracts.v1.Requests; +using OneGround.ZGW.Notificaties.DataModel; + +namespace OneGround.ZGW.Notificaties.Web.MappingProfiles.v1; + +public class RequestToDomainRegister : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config + .NewConfig() + .Ignore(dest => dest.Id) + .Ignore(dest => dest.Blocked) + .Ignore(dest => dest.Owner) + .Map(dest => dest.AbonnementKanalen, src => src.Kanalen); + + config + .NewConfig() + .Ignore(dest => dest.Id) + .Ignore(dest => dest.Kanaal) + .Ignore(dest => dest.KanaalId) + .Ignore(dest => dest.AbonnementId) + .Ignore(dest => dest.Abonnement) + // Ignore()+AfterMapping, not Map(): FilterValue references back to AbonnementKanaal, and + // mapping into that cyclic type via Map() makes the mapper try to compile a depth-guarded + // recursive function for it, which never terminates and crashes the process. AfterMapping + // runs outside that compiled pipeline, so assigning here avoids it entirely. + .Ignore(dest => dest.Filters) + .AfterMapping( + (src, dst) => + { + dst.Kanaal = new Kanaal { Naam = src.Naam }; + dst.Filters = ConvertFilterValueDictionaryToList(src.Filters).ToList(); + } + ); + + config + .NewConfig() + .Ignore(dest => dest.Id) + .Ignore(dest => dest.AbonnementKanaal) + .Ignore(dest => dest.AbonnementKanaalId); + + config + .NewConfig() + .Ignore(dest => dest.Id) + .Ignore(dest => dest.CreatedBy) + .Ignore(dest => dest.ModifiedBy) + .Ignore(dest => dest.CreationTime) + .Ignore(dest => dest.ModificationTime) + .Ignore(dest => dest.AbonnementKanalen); + + config.NewConfig().Map(dest => dest.AanmaakDatum, src => ProfileHelper.DateTimeFromString(src.Aanmaakdatum)); + } + + private static IEnumerable ConvertFilterValueDictionaryToList(IDictionary dictionary) + { + if (dictionary != null) + { + foreach (var filter in dictionary) + { + yield return new FilterValue { Key = filter.Key, Value = filter.Value }; + } + } + } +} diff --git a/src/OneGround.ZGW.Notificaties.Web/Startup.cs b/src/OneGround.ZGW.Notificaties.Web/Startup.cs index d4b35341..e8fd38a1 100644 --- a/src/OneGround.ZGW.Notificaties.Web/Startup.cs +++ b/src/OneGround.ZGW.Notificaties.Web/Startup.cs @@ -70,6 +70,7 @@ public void ConfigureServices(IServiceCollection services) }; c.ApiServiceSettings.RegisterSharedAudittrailHandlers = false; + c.ApiServiceSettings.EnableMapster = true; } ); diff --git a/src/Tests/OneGround.ZGW.Besluiten.WebApi.UnitTests/MappingTests/BrcMapsterCompileTests.cs b/src/Tests/OneGround.ZGW.Besluiten.WebApi.UnitTests/MappingTests/BrcMapsterCompileTests.cs new file mode 100644 index 00000000..6b66df79 --- /dev/null +++ b/src/Tests/OneGround.ZGW.Besluiten.WebApi.UnitTests/MappingTests/BrcMapsterCompileTests.cs @@ -0,0 +1,48 @@ +using Mapster; +using Microsoft.Extensions.DependencyInjection; +using OneGround.ZGW.Besluiten.Web; +using OneGround.ZGW.Common.Web.Extensions.ServiceCollection.ZGWApiExtensions; +using Xunit; + +namespace OneGround.ZGW.Besluiten.WebApi.UnitTests.MappingTests; + +public class BrcMapsterCompileTests +{ + /// + /// Compiles every registered type pair up front, which is the only way to catch a register that + /// cannot be compiled at all. + /// + /// + /// A register whose mapped member (or collection element) type navigates back to its owning entity + /// makes the mapper emit a depth-guarded recursive function it can never finish building. That + /// overflows the stack, which is uncatchable and kills the process rather than failing a request, + /// so it must be caught here rather than at runtime. + /// + /// Two properties make this fact worth keeping even though other tests also map these types. + /// It needs no input data, so unlike a mapping fact it cannot be defeated by fixture values that + /// miss the bad path. And it must resolve the config from AddZgwMapster: a hand-rolled + /// omits the global settings that trigger the failure and would + /// stay green regardless of what the registers contain. + /// + /// + /// When this fails it reports as a crashed/aborted test run rather than a failed assertion, and + /// takes the rest of this project's tests with it. That is the failure looking exactly as it + /// should - do not read an abort here as flakiness. + /// + /// + [Fact] + public void AddZgwMapster_config_compiles_every_registered_type_pair() + { + var services = new ServiceCollection(); + + // Same assembly Startup passes: AddZGWApi forwards Assembly.GetCallingAssembly(), and Startup + // lives in the .Web project. No other registrations are needed - Compile() only builds the + // mapping plans; DI-backed resolvers are not invoked until an actual Map() call. + services.AddZgwMapster(typeof(Startup).Assembly, enable: true); + + using var provider = services.BuildServiceProvider(); + var config = provider.GetRequiredService(); + + config.Compile(); + } +} diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseProfileTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseRegisterTests.cs similarity index 65% rename from src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseProfileTests.cs rename to src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseRegisterTests.cs index 2c316535..87340780 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseProfileTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseRegisterTests.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Linq; using AutoFixture; -using AutoMapper; +using Mapster; +using MapsterMapper; +using Microsoft.Extensions.DependencyInjection; using Moq; -using OneGround.ZGW.Common.Web.Mapping.ValueResolvers; using OneGround.ZGW.Common.Web.Services.UriServices; using OneGround.ZGW.DataAccess; using OneGround.ZGW.Notificaties.Contracts.v1; @@ -14,44 +15,45 @@ namespace OneGround.ZGW.Notificaties.WebApi.UnitTests.MappingTests; -public class DomainToResponseProfileTests +public class DomainToResponseRegisterTests : IDisposable { private readonly OmitOnRecursionFixture _fixture = new OmitOnRecursionFixture(); private readonly Mock _mockedUriService = new Mock(); + private readonly ServiceProvider _provider; + private readonly IServiceScope _scope; private readonly IMapper _mapper; - public DomainToResponseProfileTests() + public DomainToResponseRegisterTests() { - var configuration = new MapperConfiguration(config => - { - config.AddProfile(new DomainToResponseProfile()); - config.ShouldMapMethod = (m => false); - }); + _mockedUriService.Setup(s => s.GetUri(It.IsAny())).Returns(e => e.Url); - configuration.AssertConfigurationIsValid(); + var config = new TypeAdapterConfig(); + new DomainToResponseRegister().Register(config); + config.Compile(); - _mockedUriService.Setup(s => s.GetUri(It.IsAny())).Returns(e => e.Url); + // MapsterUrlResolver resolves IEntityUriService lazily via MapContext at Map()-call time, so + // provider/scope must live for the class's lifetime (disposed in Dispose(), not constructor-scoped). + var services = new ServiceCollection(); + services.AddSingleton(_mockedUriService.Object); + services.AddSingleton(config); + services.AddScoped(); + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateScope(); + _mapper = _scope.ServiceProvider.GetRequiredService(); + } - _mapper = configuration.CreateMapper(t => - { - if (t == typeof(UrlResolver)) - { - return new UrlResolver(_mockedUriService.Object); - } - throw new NotImplementedException($"Mapper is missing the service: {t})"); - }); + public void Dispose() + { + _scope.Dispose(); + _provider.Dispose(); } [Fact] public void Kanaal_Maps_To_KanaalResponseDto() { - // Setup var value = _fixture.Create(); - - // Act var result = _mapper.Map(value); - // Assert Assert.Equal(value.DocumentatieLink, result.DocumentatieLink); Assert.Equal(value.Naam, result.Naam); Assert.Equal(value.Filters, result.Filters); @@ -61,13 +63,9 @@ public void Kanaal_Maps_To_KanaalResponseDto() [Fact] public void Abonnement_Maps_To_AbonnementResponseDto() { - // Setup var value = _fixture.Create(); - - // Act var result = _mapper.Map(value); - // Assert Assert.Equal("", result.Auth); Assert.Equal(value.CallbackUrl, result.CallbackUrl); Assert.Equal(value.AbonnementKanalen.Count, result.Kanalen.Count); @@ -77,13 +75,9 @@ public void Abonnement_Maps_To_AbonnementResponseDto() [Fact] public void AbonnementKanalen_Maps_To_AbonnementKanaalResponseDto() { - // Setup var value = _fixture.Create(); - - // Act var result = _mapper.Map(value); - // Assert Assert.Equal(value.Kanaal.Naam, result.Naam); Assert.Equal(value.Filters.Count, result.Filters.Count); Assert.Equal(value.Filters.Select(f => f.Value), result.Filters.Values); diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs new file mode 100644 index 00000000..fefaea87 --- /dev/null +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoFixture; +using MapsterMapper; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json.Linq; +using OneGround.ZGW.Common.Web.Extensions.ServiceCollection.ZGWApiExtensions; +using OneGround.ZGW.Common.Web.Mapping; +using OneGround.ZGW.Common.Web.Services; +using OneGround.ZGW.Common.Web.Services.UriServices; +using OneGround.ZGW.DataAccess; +using OneGround.ZGW.Notificaties.Contracts.v1; +using OneGround.ZGW.Notificaties.Contracts.v1.Requests; +using OneGround.ZGW.Notificaties.DataModel; +using OneGround.ZGW.Notificaties.Web; +using OneGround.ZGW.Notificaties.Web.Controllers.v1; +using Xunit; + +namespace OneGround.ZGW.Notificaties.WebApi.UnitTests.MappingTests; + +/// +/// Guards the mapping contract NRC depends on OUTSIDE its controllers: the PATCH merge via +/// . The per-register tests in this folder build an isolated +/// TypeAdapterConfig and cannot see that path — they passed while PATCH was broken at runtime. +/// +/// +/// NRC has no audit trail (ApiServiceSettings.RegisterSharedAudittrailHandlers is false) and no +/// expanders, so unlike BRC the PATCH merge is the only out-of-controller mapping consumer. +/// +/// Note the division of labour between the two merge-related facts here. +/// resolves +/// IZgwRequestMerger directly, so it proves the register still serves the merge but CANNOT detect a +/// controller wired to the AutoMapper-backed IRequestMerger — it passes either way. +/// is the fact that catches +/// that, and it is cheap because the controller and its constructor are public. +/// +/// +public class NrcMapperContractTests : IDisposable +{ + private readonly OmitOnRecursionFixture _fixture = new OmitOnRecursionFixture(); + private readonly ServiceProvider _provider; + private readonly IServiceScope _scope; + private readonly IZgwMapper _zgwMapper; + private readonly IZgwRequestMerger _zgwRequestMerger; + private readonly IMapper _mapsterMapper; + + public NrcMapperContractTests() + { + var mockedUriService = new Mock(); + mockedUriService.Setup(s => s.GetUri(It.IsAny())).Returns(e => e.Url); + + var services = new ServiceCollection(); + services.AddSingleton(mockedUriService.Object); + + // Mirrors Startup exactly: same extensions, same order, same assembly, EnableMapster on. + services.AddAutoMapper(typeof(Startup).Assembly); + services.AddZgwMapster(typeof(Startup).Assembly, enable: true); + + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateScope(); + _zgwMapper = _scope.ServiceProvider.GetRequiredService(); + _zgwRequestMerger = _scope.ServiceProvider.GetRequiredService(); + _mapsterMapper = _scope.ServiceProvider.GetRequiredService(); + } + + public void Dispose() + { + _scope.Dispose(); + _provider.Dispose(); + } + + [Fact] + public void NRC_resolves_the_Mapster_backed_mapper() + { + // NRC consumes no IZgwMapper today, so this guards the routing rather than a live call path — + // it becomes load-bearing the moment RegisterSharedAudittrailHandlers is turned on. + Assert.IsType(_zgwMapper); + } + + [Fact] + public void RequestMerger_can_merge_a_PATCH_onto_an_existing_Abonnement() + { + var existing = _fixture.Create(); + var patch = new JObject { ["callbackUrl"] = "https://example.test/new" }; + + var merged = _zgwRequestMerger.MergePartialUpdateToObjectRequest(existing, patch); + + // The patched field comes from the JObject; the untouched fields can only come from the existing + // entity having been mapped in first, which is the step that needs the register. Kanalen is the + // load-bearing one — it cannot convention-map, because the source member is AbonnementKanalen. + Assert.Equal("https://example.test/new", merged.CallbackUrl); + Assert.Equal(existing.Auth, merged.Auth); + Assert.Equal(existing.AbonnementKanalen.Count, merged.Kanalen.Count); + } + + [Fact] + public void AbonnementController_depends_on_the_Mapster_backed_merger() + { + // NRC has no AutoMapper maps left, so a PATCH routed through the AutoMapper-backed + // IRequestMerger throws at runtime. There are no controller-level tests in this repo and the + // MediatR queries are internal, so assert the dependency itself: this is the invariant that a + // controller which PATCHes in a Mapster-only service must depend on the Mapster merger. + var parameterTypes = typeof(AbonnementController).GetConstructors().Single().GetParameters().Select(p => p.ParameterType).ToArray(); + + Assert.Contains(typeof(IZgwRequestMerger), parameterTypes); + } + + [Fact] + public void AbonnementRequestDto_with_a_kanaal_maps_to_Abonnement_without_crashing() + { + // Guards against an uncatchable StackOverflowException, not a regular exception. Must resolve + // IMapper from the real AddZgwMapster-built provider (see constructor) - a hand-rolled + // TypeAdapterConfig without its settings would stay green regardless of the register. + var dto = new AbonnementRequestDto + { + CallbackUrl = "https://example.test/callback", + Auth = "the-auth", + Kanalen = new List + { + new() + { + Naam = "zaken", + Filters = new Dictionary { ["resource"] = "zaakinformatieobject" }, + }, + }, + }; + + var result = _mapsterMapper.Map(dto); + + Assert.Single(result.AbonnementKanalen); + Assert.Equal("zaken", result.AbonnementKanalen[0].Kanaal.Naam); + Assert.Single(result.AbonnementKanalen[0].Filters); + Assert.Equal("resource", result.AbonnementKanalen[0].Filters[0].Key); + } +} diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterCompileTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterCompileTests.cs new file mode 100644 index 00000000..d205060a --- /dev/null +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterCompileTests.cs @@ -0,0 +1,48 @@ +using Mapster; +using Microsoft.Extensions.DependencyInjection; +using OneGround.ZGW.Common.Web.Extensions.ServiceCollection.ZGWApiExtensions; +using OneGround.ZGW.Notificaties.Web; +using Xunit; + +namespace OneGround.ZGW.Notificaties.WebApi.UnitTests.MappingTests; + +public class NrcMapsterCompileTests +{ + /// + /// Compiles every registered type pair up front, which is the only way to catch a register that + /// cannot be compiled at all. + /// + /// + /// A register whose mapped member (or collection element) type navigates back to its owning entity + /// makes the mapper emit a depth-guarded recursive function it can never finish building. That + /// overflows the stack, which is uncatchable and kills the process rather than failing a request, + /// so it must be caught here rather than at runtime. + /// + /// Two properties make this fact worth keeping even though other tests also map these types. + /// It needs no input data, so unlike a mapping fact it cannot be defeated by fixture values that + /// miss the bad path. And it must resolve the config from AddZgwMapster: a hand-rolled + /// omits the global settings that trigger the failure and would + /// stay green regardless of what the registers contain. + /// + /// + /// When this fails it reports as a crashed/aborted test run rather than a failed assertion, and + /// takes the rest of this project's tests with it. That is the failure looking exactly as it + /// should - do not read an abort here as flakiness. + /// + /// + [Fact] + public void AddZgwMapster_config_compiles_every_registered_type_pair() + { + var services = new ServiceCollection(); + + // Same assembly Startup passes: AddZGWApi forwards Assembly.GetCallingAssembly(), and Startup + // lives in the .Web project. No other registrations are needed - Compile() only builds the + // mapping plans; DI-backed resolvers are not invoked until an actual Map() call. + services.AddZgwMapster(typeof(Startup).Assembly, enable: true); + + using var provider = services.BuildServiceProvider(); + var config = provider.GetRequiredService(); + + config.Compile(); + } +} diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs new file mode 100644 index 00000000..7a678470 --- /dev/null +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs @@ -0,0 +1,41 @@ +using System; +using MapsterMapper; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using OneGround.ZGW.Common.Web.Extensions.ServiceCollection.ZGWApiExtensions; +using OneGround.ZGW.Common.Web.Services.UriServices; +using OneGround.ZGW.DataAccess; +using OneGround.ZGW.Notificaties.Contracts.v1.Responses; +using OneGround.ZGW.Notificaties.DataModel; +using OneGround.ZGW.Notificaties.Web.MappingProfiles.v1; +using Xunit; + +namespace OneGround.ZGW.Notificaties.WebApi.UnitTests.MappingTests; + +public class NrcMapsterWiringTests +{ + [Fact] + public void AddZgwMapster_discovers_NRC_registers_from_the_web_assembly() + { + var mockedUriService = new Mock(); + mockedUriService.Setup(s => s.GetUri(It.IsAny())).Returns("https://example.test/resolved-via-di"); + + var services = new ServiceCollection(); + services.AddSingleton(mockedUriService.Object); + services.AddZgwMapster(typeof(DomainToResponseRegister).Assembly, enable: true); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var mapper = scope.ServiceProvider.GetRequiredService(); + + var result = mapper.Map(new Kanaal { Id = Guid.NewGuid(), Naam = "zaken" }); + + // Kanaal.Url is a computed, read-only property (`/kanaal/{Id}`) that Mapster's default + // convention would otherwise copy by name; the mocked literal below is distinguishable from + // that value, so a same-name convention copy can't satisfy the assertion — this only passes + // if MapsterUrlResolver actually ran through DI via config.Scan discovery. A mock that echoed + // the source's own value would be a false positive. + Assert.Equal("https://example.test/resolved-via-di", result.Url); + mockedUriService.Verify(s => s.GetUri(It.IsAny()), Times.AtLeastOnce()); + } +} diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainRegisterTests.cs similarity index 65% rename from src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs rename to src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainRegisterTests.cs index 798db48c..e07baaf5 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainRegisterTests.cs @@ -1,9 +1,9 @@ using System; using System.Linq; using AutoFixture; -using AutoMapper; -using AutoMapper.Internal; -using OneGround.ZGW.Common.Web; +using Mapster; +using MapsterMapper; +using OneGround.ZGW.Common.Web.Mapping.Mapster; using OneGround.ZGW.Notificaties.Contracts.v1; using OneGround.ZGW.Notificaties.Contracts.v1.Requests; using OneGround.ZGW.Notificaties.DataModel; @@ -12,34 +12,29 @@ namespace OneGround.ZGW.Notificaties.WebApi.UnitTests.MappingTests; -public class RequestToDomainProfileTests +public class RequestToDomainRegisterTests { private readonly OmitOnRecursionFixture _fixture = new OmitOnRecursionFixture(); private readonly IMapper _mapper; - public RequestToDomainProfileTests() + public RequestToDomainRegisterTests() { - var configuration = new MapperConfiguration(config => - { - config.AddProfile(new RequestToDomainProfile()); - config.Internal().Mappers.Insert(0, new NullableEnumMapper()); - }); - - configuration.AssertConfigurationIsValid(); - - _mapper = configuration.CreateMapper(); + var config = new TypeAdapterConfig(); + // The seam's global nullable-enum rule lives in AddZgwMapster, not in the register; this test + // builds config directly, so register it here too for parity with production (harmless if the + // profile maps no nullable enums). + config.RegisterNullableEnumRule(); + new RequestToDomainRegister().Register(config); + config.Compile(); + _mapper = new Mapper(config); } [Fact] public void KanaalRequestDto_Maps_To_Kanaal() { - // Setup var value = _fixture.Create(); - - // Act var result = _mapper.Map(value); - // Assert Assert.Equal(value.DocumentatieLink, result.DocumentatieLink); Assert.Equal(value.Naam, result.Naam); Assert.Equal(value.Filters, result.Filters); @@ -48,28 +43,31 @@ public void KanaalRequestDto_Maps_To_Kanaal() [Fact] public void AbonnementRequestDto_Maps_To_Abonnement() { - // Setup var value = _fixture.Create(); - - // Act var result = _mapper.Map(value); - // Assert Assert.Equal(value.Auth, result.Auth); Assert.Equal(value.CallbackUrl, result.CallbackUrl); Assert.Equal(value.Kanalen.Count, result.AbonnementKanalen.Count); + + // The nested AbonnementKanaalDto -> AbonnementKanaal mapping runs as a collection element here + // (not top-level). This asserts each element's AfterMapping fired for the nested case too: + // dst.Kanaal is set from the source element's Naam. + Assert.NotEmpty(result.AbonnementKanalen); + for (var i = 0; i < result.AbonnementKanalen.Count; i++) + { + Assert.NotNull(result.AbonnementKanalen[i].Kanaal); + Assert.Equal(value.Kanalen[i].Naam, result.AbonnementKanalen[i].Kanaal.Naam); + } } [Fact] public void AbonnementKanalenRequestDto_Maps_To_AbonnementKanaal() { - // Setup var value = _fixture.Create(); - - // Act var result = _mapper.Map(value); - // Assert + // Kanaal is set by AfterMapping from src.Naam. Assert.Equal(value.Naam, result.Kanaal.Naam); Assert.Equal(value.Filters.Count, result.Filters.Count); Assert.Equal(value.Filters.Values, result.Filters.Select(f => f.Value)); @@ -82,21 +80,16 @@ public void NotificatieDto_Maps_To_Notificatie() _fixture.Customize(c => c.With(p => p.Aanmaakdatum, DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"))); var value = _fixture.Create(); - - // Act var result = _mapper.Map(value); - // Assert Assert.Equal(value.Kanaal, result.Kanaal); - Assert.Equal(value.HoofdObject, result.HoofdObject); Assert.Equal(value.Resource, result.Resource); Assert.Equal(value.ResourceUrl, result.ResourceUrl); Assert.Equal(value.Actie, result.Actie); Assert.Equal(value.Aanmaakdatum, result.AanmaakDatum.ToString("yyyy-MM-ddTHH:mm:ssZ")); - Assert.Equal(value.Kenmerken.Count, result.Kenmerken.Count); Assert.Equal(value.Kenmerken.Select(k => k.Key), result.Kenmerken.Select(k => k.Key)); - Assert.Equal(value.Kenmerken.Select(k => k.Key), result.Kenmerken.Select(k => k.Key)); + Assert.Equal(value.Kenmerken.Select(k => k.Value), result.Kenmerken.Select(k => k.Value)); } } diff --git a/src/Tests/OneGround.ZGW.Referentielijsten.WebApi.UnitTests/MappingTests/RlMapsterCompileTests.cs b/src/Tests/OneGround.ZGW.Referentielijsten.WebApi.UnitTests/MappingTests/RlMapsterCompileTests.cs new file mode 100644 index 00000000..69f7e535 --- /dev/null +++ b/src/Tests/OneGround.ZGW.Referentielijsten.WebApi.UnitTests/MappingTests/RlMapsterCompileTests.cs @@ -0,0 +1,52 @@ +using Mapster; +using Microsoft.Extensions.DependencyInjection; +using OneGround.ZGW.Common.Web.Extensions.ServiceCollection.ZGWApiExtensions; +using OneGround.ZGW.Referentielijsten.Web; +using Xunit; + +namespace OneGround.ZGW.Referentielijsten.WebApi.UnitTests.MappingTests; + +public class RlMapsterCompileTests +{ + /// + /// Compiles every registered type pair up front, which is the only way to catch a register that + /// cannot be compiled at all. + /// + /// + /// A register whose mapped member (or collection element) type navigates back to its owning entity + /// makes the mapper emit a depth-guarded recursive function it can never finish building. That + /// overflows the stack, which is uncatchable and kills the process rather than failing a request, + /// so it must be caught here rather than at runtime. + /// + /// Two properties make this fact worth keeping even though other tests also map these types. + /// It needs no input data, so unlike a mapping fact it cannot be defeated by fixture values that + /// miss the bad path. And it must resolve the config from AddZgwMapster: a hand-rolled + /// omits the global settings that trigger the failure and would + /// stay green regardless of what the registers contain. + /// + /// + /// RL has no EF entities of its own today, so nothing here is currently cyclic - this guards the + /// service against acquiring one later, at the cost of one fast test. + /// + /// + /// When this fails it reports as a crashed/aborted test run rather than a failed assertion, and + /// takes the rest of this project's tests with it. That is the failure looking exactly as it + /// should - do not read an abort here as flakiness. + /// + /// + [Fact] + public void AddZgwMapster_config_compiles_every_registered_type_pair() + { + var services = new ServiceCollection(); + + // Same assembly Startup passes: AddZGWApi forwards Assembly.GetCallingAssembly(), and Startup + // lives in the .Web project. No other registrations are needed - Compile() only builds the + // mapping plans; DI-backed resolvers are not invoked until an actual Map() call. + services.AddZgwMapster(typeof(Startup).Assembly, enable: true); + + using var provider = services.BuildServiceProvider(); + var config = provider.GetRequiredService(); + + config.Compile(); + } +}