From e963c11fa84233f7a252cd8c7e0c29b73a9d5c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Wed, 8 Jul 2026 18:27:44 +0300 Subject: [PATCH 01/15] feat(nrc): add Mapster DomainToResponseRegister and convert its tests Co-Authored-By: Claude Opus 4.8 --- .../v1/DomainToResponseRegister.cs | 34 +++++++++++ .../DomainToResponseProfileTests.cs | 56 +++++++++---------- 2 files changed, 59 insertions(+), 31 deletions(-) create mode 100644 src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/DomainToResponseRegister.cs 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/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseProfileTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseProfileTests.cs index 2c316535..01e86ab9 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseProfileTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseProfileTests.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 DomainToResponseProfileTests : 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() { - 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); From 691f4d19b6b74d0cebfedbf854c71f48f7da19ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 9 Jul 2026 08:47:19 +0300 Subject: [PATCH 02/15] feat(nrc): add Mapster RequestToDomainRegister and convert its tests Port the request-to-domain mapping profile to a Mapster IRegister, including the first AfterMapping use (Kanaal set from source Naam) and the dictionary->list Filters conversion. AutoMapper profile stays in place; nothing wires the register into production yet. Co-Authored-By: Claude Opus 4.8 --- .../v1/RequestToDomainRegister.cs | 59 +++++++++++++++++++ .../RequestToDomainProfileTests.cs | 42 ++++--------- 2 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs 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..ff319690 --- /dev/null +++ b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +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) + .Map(dest => dest.Filters, src => ConvertFilterValueDictionaryToList(src.Filters)) + .AfterMapping((src, dst) => dst.Kanaal = new Kanaal { Naam = src.Naam }); + + 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/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs index 798db48c..0da06fe7 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.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; @@ -19,27 +19,22 @@ public class RequestToDomainProfileTests public RequestToDomainProfileTests() { - 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,13 +43,9 @@ 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); @@ -63,13 +54,10 @@ public void AbonnementRequestDto_Maps_To_Abonnement() [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 +70,15 @@ 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)); } } From edda9a549448822ac6d0b48acab2d771aca57f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 9 Jul 2026 08:54:21 +0300 Subject: [PATCH 03/15] test(nrc): assert nested-element AfterMapping fires for AbonnementKanalen collection Co-Authored-By: Claude Opus 4.8 --- .../MappingTests/RequestToDomainProfileTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs index 0da06fe7..1a1e02bd 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs @@ -49,6 +49,16 @@ public void AbonnementRequestDto_Maps_To_Abonnement() 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] From 96a34bcada5cdd99748cd1de119ba13002d57482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 9 Jul 2026 11:16:03 +0300 Subject: [PATCH 04/15] test(nrc): add temporary AutoMapper-vs-Mapster JSON parity guard Co-Authored-By: Claude Opus 4.8 --- .../MappingTests/MapsterMappingParityTests.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/MapsterMappingParityTests.cs diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/MapsterMappingParityTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/MapsterMappingParityTests.cs new file mode 100644 index 00000000..beb4aed0 --- /dev/null +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/MapsterMappingParityTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using AutoMapper; +using Mapster; +using MapsterMapper; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; +using OneGround.ZGW.Common.Web.Mapping.Mapster; +using OneGround.ZGW.Common.Web.Mapping.ValueResolvers; +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; +using AutoMapperIMapper = AutoMapper.IMapper; +using MapsterIMapper = MapsterMapper.IMapper; + +namespace OneGround.ZGW.Notificaties.WebApi.UnitTests.MappingTests; + +public class MapsterMappingParityTests : IDisposable +{ + private readonly AutoMapperIMapper _autoMapper; + private readonly ServiceProvider _provider; + private readonly IServiceScope _scope; + private readonly MapsterIMapper _mapster; + + public MapsterMappingParityTests() + { + var mockedUriService = new Mock(); + mockedUriService.Setup(s => s.GetUri(It.IsAny())).Returns(e => e.Url); + + var amConfig = new MapperConfiguration(c => c.AddProfile(new DomainToResponseProfile())); + _autoMapper = amConfig.CreateMapper(t => + t == typeof(UrlResolver) ? new UrlResolver(mockedUriService.Object) : throw new NotImplementedException() + ); + + // Mirror the production Mapster global config from AddZgwMapster so this harness is a faithful + // stand-in for production Mapster. Without EmptyCollectionIfNull, a null source collection maps + // to null (Mapster default) instead of the empty collection AutoMapper's AllowNullCollections=false + // baseline produces — a config gap in the test, not a register bug. + var config = new TypeAdapterConfig(); + config.Default.MaxDepth(200); + config.Default.AddDestinationTransform(DestinationTransform.EmptyCollectionIfNull); + config.RegisterNullableEnumRule(); + new DomainToResponseRegister().Register(config); + config.Compile(); + var services = new ServiceCollection(); + services.AddSingleton(mockedUriService.Object); + services.AddSingleton(config); + services.AddScoped(); + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateScope(); + _mapster = _scope.ServiceProvider.GetRequiredService(); + } + + public void Dispose() + { + _scope.Dispose(); + _provider.Dispose(); + } + + private static Abonnement SampleAbonnement() + { + var kanaal = new Kanaal { Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), Naam = "zaken" }; + return new Abonnement + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), + CallbackUrl = "https://example/callback", + Auth = "secret-should-be-hidden", + AbonnementKanalen = new List + { + new() + { + Kanaal = kanaal, + Filters = new List + { + new() { Key = "bron", Value = "x" }, + }, + }, + }, + }; + } + + [Fact] + public void AbonnementResponseDto_Mapster_matches_AutoMapper() + { + var input = SampleAbonnement(); + var expected = JsonConvert.SerializeObject(_autoMapper.Map(input)); + var actual = JsonConvert.SerializeObject(_mapster.Map(input)); + Assert.Equal(expected, actual); + } + + [Fact] + public void KanaalResponseDto_Mapster_matches_AutoMapper() + { + var input = new Kanaal { Id = Guid.Parse("33333333-3333-3333-3333-333333333333"), Naam = "documenten" }; + var expected = JsonConvert.SerializeObject(_autoMapper.Map(input)); + var actual = JsonConvert.SerializeObject(_mapster.Map(input)); + Assert.Equal(expected, actual); + } +} From 6baae2094ecdd109e88015fd9050eb3d8fa8b523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 9 Jul 2026 11:27:25 +0300 Subject: [PATCH 05/15] test(nrc): verify AddZgwMapster discovers NRC's Mapster registers Co-Authored-By: Claude Opus 4.8 --- .../MappingTests/NrcMapsterWiringTests.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs 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..3527559d --- /dev/null +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs @@ -0,0 +1,42 @@ +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); + + 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" }); + + // Distinct literal (not derivable from the source) so this only passes if MapsterUrlResolver + // actually ran through DI via config.Scan discovery — not if a same-name convention copy + // satisfied it. (Kanaal has no `Url` source member anyway; the URL comes only from the resolver. + // Kanaal.Url is a computed, read-only property, so it's never assigned directly — the assertion + // below is only satisfiable via the DI-backed resolver, matching a bug the AC migration's own + // wiring test found and fixed: a mock that echoes the source's own value is a false positive.) + Assert.Equal("https://example.test/resolved-via-di", result.Url); + mockedUriService.Verify(s => s.GetUri(It.IsAny()), Times.AtLeastOnce()); + } +} From ba644d99844837052ccc1e93658c3daf44d0f114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 9 Jul 2026 11:34:49 +0300 Subject: [PATCH 06/15] docs(nrc): correct factually-wrong comment about Kanaal.Url in wiring test Co-Authored-By: Claude Opus 4.8 --- .../MappingTests/NrcMapsterWiringTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs index 3527559d..9716b9ca 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs @@ -30,12 +30,12 @@ public void AddZgwMapster_discovers_NRC_registers_from_the_web_assembly() var result = mapper.Map(new Kanaal { Id = Guid.NewGuid(), Naam = "zaken" }); - // Distinct literal (not derivable from the source) so this only passes if MapsterUrlResolver - // actually ran through DI via config.Scan discovery — not if a same-name convention copy - // satisfied it. (Kanaal has no `Url` source member anyway; the URL comes only from the resolver. - // Kanaal.Url is a computed, read-only property, so it's never assigned directly — the assertion - // below is only satisfiable via the DI-backed resolver, matching a bug the AC migration's own - // wiring test found and fixed: a mock that echoes the source's own value is a false positive.) + // 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. Matches a bug the + // AC migration's own wiring test found and fixed: a mock that echoes the source's own value + // is a false positive. Assert.Equal("https://example.test/resolved-via-di", result.Url); mockedUriService.Verify(s => s.GetUri(It.IsAny()), Times.AtLeastOnce()); } From 4c78ece2b585f5d7f84590d9b48197b364472ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 9 Jul 2026 11:37:53 +0300 Subject: [PATCH 07/15] feat(nrc): route controllers through MapsterMapper.IMapper Co-Authored-By: Claude Opus 4.8 --- .../Controllers/v1/AbonnementController.cs | 22 ++++++++++--------- .../Controllers/v1/KanaalController.cs | 19 ++++++++++------ .../Controllers/v1/NotificatiesController.cs | 13 +++++++---- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs index ad687384..723909cb 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,21 @@ namespace OneGround.ZGW.Notificaties.Web.Controllers.v1; [ZgwApiVersion(Api.LatestVersion_1_0)] public class AbonnementController : ZGWControllerBase { + private readonly MapsterMapper.IMapper _mapsterMapper; private readonly IValidatorService _validatorService; public AbonnementController( ILogger logger, IMediator mediator, - IMapper mapper, + AutoMapper.IMapper mapper, + MapsterMapper.IMapper mapsterMapper, IRequestMerger requestMerger, IErrorResponseBuilder errorResponseBuilder, IValidatorService validatorService ) : base(logger, mediator, mapper, requestMerger, errorResponseBuilder) { + _mapsterMapper = mapsterMapper; _validatorService = validatorService; } @@ -62,7 +64,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 +91,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 +111,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 +120,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 +141,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 +155,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); } @@ -191,7 +193,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 +202,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 }); From 1b06cc1668a7cdd35659d08dab9f8b038006edf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 9 Jul 2026 11:52:07 +0300 Subject: [PATCH 08/15] refactor(nrc): remove AutoMapper profiles now that NRC runs on Mapster Co-Authored-By: Claude Opus 4.8 --- .../v1/DomainToResponseProfile.cs | 32 ------ .../v1/RequestToDomainProfile.cs | 56 ---------- .../MappingTests/MapsterMappingParityTests.cs | 103 ------------------ 3 files changed, 191 deletions(-) delete mode 100644 src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/DomainToResponseProfile.cs delete mode 100644 src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainProfile.cs delete mode 100644 src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/MapsterMappingParityTests.cs 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/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/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/MapsterMappingParityTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/MapsterMappingParityTests.cs deleted file mode 100644 index beb4aed0..00000000 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/MapsterMappingParityTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.Collections.Generic; -using AutoMapper; -using Mapster; -using MapsterMapper; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Newtonsoft.Json; -using OneGround.ZGW.Common.Web.Mapping.Mapster; -using OneGround.ZGW.Common.Web.Mapping.ValueResolvers; -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; -using AutoMapperIMapper = AutoMapper.IMapper; -using MapsterIMapper = MapsterMapper.IMapper; - -namespace OneGround.ZGW.Notificaties.WebApi.UnitTests.MappingTests; - -public class MapsterMappingParityTests : IDisposable -{ - private readonly AutoMapperIMapper _autoMapper; - private readonly ServiceProvider _provider; - private readonly IServiceScope _scope; - private readonly MapsterIMapper _mapster; - - public MapsterMappingParityTests() - { - var mockedUriService = new Mock(); - mockedUriService.Setup(s => s.GetUri(It.IsAny())).Returns(e => e.Url); - - var amConfig = new MapperConfiguration(c => c.AddProfile(new DomainToResponseProfile())); - _autoMapper = amConfig.CreateMapper(t => - t == typeof(UrlResolver) ? new UrlResolver(mockedUriService.Object) : throw new NotImplementedException() - ); - - // Mirror the production Mapster global config from AddZgwMapster so this harness is a faithful - // stand-in for production Mapster. Without EmptyCollectionIfNull, a null source collection maps - // to null (Mapster default) instead of the empty collection AutoMapper's AllowNullCollections=false - // baseline produces — a config gap in the test, not a register bug. - var config = new TypeAdapterConfig(); - config.Default.MaxDepth(200); - config.Default.AddDestinationTransform(DestinationTransform.EmptyCollectionIfNull); - config.RegisterNullableEnumRule(); - new DomainToResponseRegister().Register(config); - config.Compile(); - var services = new ServiceCollection(); - services.AddSingleton(mockedUriService.Object); - services.AddSingleton(config); - services.AddScoped(); - _provider = services.BuildServiceProvider(); - _scope = _provider.CreateScope(); - _mapster = _scope.ServiceProvider.GetRequiredService(); - } - - public void Dispose() - { - _scope.Dispose(); - _provider.Dispose(); - } - - private static Abonnement SampleAbonnement() - { - var kanaal = new Kanaal { Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), Naam = "zaken" }; - return new Abonnement - { - Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), - CallbackUrl = "https://example/callback", - Auth = "secret-should-be-hidden", - AbonnementKanalen = new List - { - new() - { - Kanaal = kanaal, - Filters = new List - { - new() { Key = "bron", Value = "x" }, - }, - }, - }, - }; - } - - [Fact] - public void AbonnementResponseDto_Mapster_matches_AutoMapper() - { - var input = SampleAbonnement(); - var expected = JsonConvert.SerializeObject(_autoMapper.Map(input)); - var actual = JsonConvert.SerializeObject(_mapster.Map(input)); - Assert.Equal(expected, actual); - } - - [Fact] - public void KanaalResponseDto_Mapster_matches_AutoMapper() - { - var input = new Kanaal { Id = Guid.Parse("33333333-3333-3333-3333-333333333333"), Naam = "documenten" }; - var expected = JsonConvert.SerializeObject(_autoMapper.Map(input)); - var actual = JsonConvert.SerializeObject(_mapster.Map(input)); - Assert.Equal(expected, actual); - } -} From 4abf36fb996882759c17a0e4de526edc7c134bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 16 Jul 2026 13:13:45 +0300 Subject: [PATCH 09/15] Added mapster extenssion --- src/OneGround.ZGW.Notificaties.Web/Startup.cs | 1 + 1 file changed, 1 insertion(+) 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; } ); From 204279f3d332ed46aab87fc148b8366014999fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Wed, 5 Aug 2026 16:26:27 +0300 Subject: [PATCH 10/15] fix(nrc): enable Mapster in the wiring test so it exercises the seam AddZgwMapster's enable parameter defaults to false, so the one-argument call registered nothing and GetRequiredService() threw. Also drop a comment reference to an AC wiring test that does not exist. Co-Authored-By: Claude Opus 5 --- .../MappingTests/NrcMapsterWiringTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs index 9716b9ca..7a678470 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterWiringTests.cs @@ -22,7 +22,7 @@ public void AddZgwMapster_discovers_NRC_registers_from_the_web_assembly() var services = new ServiceCollection(); services.AddSingleton(mockedUriService.Object); - services.AddZgwMapster(typeof(DomainToResponseRegister).Assembly); + services.AddZgwMapster(typeof(DomainToResponseRegister).Assembly, enable: true); using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); @@ -33,9 +33,8 @@ public void AddZgwMapster_discovers_NRC_registers_from_the_web_assembly() // 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. Matches a bug the - // AC migration's own wiring test found and fixed: a mock that echoes the source's own value - // is a false positive. + // 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()); } From dcf3b5ed26c1d6aa7bcaea463c235f19896389cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Wed, 5 Aug 2026 16:30:31 +0300 Subject: [PATCH 11/15] test(nrc): guard the PATCH merge contract and the controller's merger dependency The per-register mapping tests build an isolated TypeAdapterConfig and cannot see the PATCH merge path, so they stayed green while PATCH threw at runtime. Two facts for two failure modes: the merge fact proves the Mapster register still serves Abonnement -> AbonnementRequestDto, and the reflection fact proves the controller depends on the Mapster-backed merger. The latter fails at this commit, which is the defect it exists to catch. Co-Authored-By: Claude Opus 5 --- .../MappingTests/NrcMapperContractTests.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs 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..ff77fd35 --- /dev/null +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using AutoFixture; +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.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; + + 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(); + } + + 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); + } +} From fd9faa95b6869b55947a29591faec79dbbe45b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Wed, 5 Aug 2026 16:36:29 +0300 Subject: [PATCH 12/15] fix(nrc): merge PATCH bodies through the Mapster-backed request merger The AutoMapper-backed IRequestMerger needs an Abonnement -> AbonnementRequestDto map, which no longer exists now that the profiles are gone, so every PATCH on an abonnement threw AutoMapperMappingException. IZgwRequestMerger resolves the same pair from the Mapster register. IRequestMerger stays in the constructor because ZGWControllerBase still requires it. The other two controllers call no merger and are unchanged. Co-Authored-By: Claude Opus 5 --- .../Controllers/v1/AbonnementController.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs index 723909cb..67085bf6 100644 --- a/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs +++ b/src/OneGround.ZGW.Notificaties.Web/Controllers/v1/AbonnementController.cs @@ -31,6 +31,7 @@ namespace OneGround.ZGW.Notificaties.Web.Controllers.v1; public class AbonnementController : ZGWControllerBase { private readonly MapsterMapper.IMapper _mapsterMapper; + private readonly IZgwRequestMerger _zgwRequestMerger; private readonly IValidatorService _validatorService; public AbonnementController( @@ -38,13 +39,15 @@ public AbonnementController( IMediator mediator, AutoMapper.IMapper mapper, MapsterMapper.IMapper mapsterMapper, - IRequestMerger requestMerger, + 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; } @@ -183,7 +186,7 @@ public async Task PartialUpdateAsync([FromBody] JObject partialAb return _errorResponseBuilder.NotFound(); } - AbonnementRequestDto mergedAbonnementRequest = _requestMerger.MergePartialUpdateToObjectRequest( + AbonnementRequestDto mergedAbonnementRequest = _zgwRequestMerger.MergePartialUpdateToObjectRequest( resultGet.Result, partialAbonnementRequest ); From ea137ee59298be2a274d40fcd99a7ff98f48e2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Wed, 5 Aug 2026 16:42:05 +0300 Subject: [PATCH 13/15] test(nrc): name the mapping tests after registers and assert Kenmerken values No AutoMapper profile exists any more, so *ProfileTests was misleading; other migrated services already use *RegisterTests. The second Kenmerken assertion was a verbatim duplicate of the key comparison and was dropped during the migration. Restore it as the value comparison it was meant to be. Co-Authored-By: Claude Opus 5 --- ...ponseProfileTests.cs => DomainToResponseRegisterTests.cs} | 4 ++-- ...DomainProfileTests.cs => RequestToDomainRegisterTests.cs} | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) rename src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/{DomainToResponseProfileTests.cs => DomainToResponseRegisterTests.cs} (96%) rename src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/{RequestToDomainProfileTests.cs => RequestToDomainRegisterTests.cs} (95%) 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 96% 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 01e86ab9..87340780 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseProfileTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/DomainToResponseRegisterTests.cs @@ -15,7 +15,7 @@ namespace OneGround.ZGW.Notificaties.WebApi.UnitTests.MappingTests; -public class DomainToResponseProfileTests : IDisposable +public class DomainToResponseRegisterTests : IDisposable { private readonly OmitOnRecursionFixture _fixture = new OmitOnRecursionFixture(); private readonly Mock _mockedUriService = new Mock(); @@ -23,7 +23,7 @@ public class DomainToResponseProfileTests : IDisposable private readonly IServiceScope _scope; private readonly IMapper _mapper; - public DomainToResponseProfileTests() + public DomainToResponseRegisterTests() { _mockedUriService.Setup(s => s.GetUri(It.IsAny())).Returns(e => e.Url); 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 95% 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 1a1e02bd..e07baaf5 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainProfileTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/RequestToDomainRegisterTests.cs @@ -12,12 +12,12 @@ 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 config = new TypeAdapterConfig(); // The seam's global nullable-enum rule lives in AddZgwMapster, not in the register; this test @@ -90,5 +90,6 @@ public void NotificatieDto_Maps_To_Notificatie() 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.Value), result.Kenmerken.Select(k => k.Value)); } } From d2a9505cfd31f39d17c3f15b885c4ae33d0068a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 6 Aug 2026 14:52:08 +0300 Subject: [PATCH 14/15] Fix --- .../v1/RequestToDomainRegister.cs | 15 +++++++-- .../MappingTests/NrcMapperContractTests.cs | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs index ff319690..da2d7f31 100644 --- a/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs +++ b/src/OneGround.ZGW.Notificaties.Web/MappingProfiles/v1/RequestToDomainRegister.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Mapster; using OneGround.ZGW.Common.Helpers; using OneGround.ZGW.Notificaties.Contracts.v1; @@ -25,8 +26,18 @@ public void Register(TypeAdapterConfig config) .Ignore(dest => dest.KanaalId) .Ignore(dest => dest.AbonnementId) .Ignore(dest => dest.Abonnement) - .Map(dest => dest.Filters, src => ConvertFilterValueDictionaryToList(src.Filters)) - .AfterMapping((src, dst) => dst.Kanaal = new Kanaal { Naam = src.Naam }); + // 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() diff --git a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs index ff77fd35..fefaea87 100644 --- a/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs +++ b/src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapperContractTests.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Linq; using AutoFixture; +using MapsterMapper; using Microsoft.Extensions.DependencyInjection; using Moq; using Newtonsoft.Json.Linq; @@ -9,6 +11,7 @@ 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; @@ -41,6 +44,7 @@ public class NrcMapperContractTests : IDisposable private readonly IServiceScope _scope; private readonly IZgwMapper _zgwMapper; private readonly IZgwRequestMerger _zgwRequestMerger; + private readonly IMapper _mapsterMapper; public NrcMapperContractTests() { @@ -58,6 +62,7 @@ public NrcMapperContractTests() _scope = _provider.CreateScope(); _zgwMapper = _scope.ServiceProvider.GetRequiredService(); _zgwRequestMerger = _scope.ServiceProvider.GetRequiredService(); + _mapsterMapper = _scope.ServiceProvider.GetRequiredService(); } public void Dispose() @@ -101,4 +106,32 @@ public void AbonnementController_depends_on_the_Mapster_backed_merger() 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); + } } From 840a30bf56d2fc9cf2d1926d6c91d7e6a8f1cfef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Monika=20Ragauskien=C4=97?= Date: Thu, 6 Aug 2026 15:48:50 +0300 Subject: [PATCH 15/15] Prevention for Mapster to map entities into it own entity --- .../MappingTests/BrcMapsterCompileTests.cs | 48 +++++++++++++++++ .../MappingTests/NrcMapsterCompileTests.cs | 48 +++++++++++++++++ .../MappingTests/RlMapsterCompileTests.cs | 52 +++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 src/Tests/OneGround.ZGW.Besluiten.WebApi.UnitTests/MappingTests/BrcMapsterCompileTests.cs create mode 100644 src/Tests/OneGround.ZGW.Notificaties.WebApi.UnitTests/MappingTests/NrcMapsterCompileTests.cs create mode 100644 src/Tests/OneGround.ZGW.Referentielijsten.WebApi.UnitTests/MappingTests/RlMapsterCompileTests.cs 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/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.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(); + } +}