From 728139481c4d18567b7feea152dca82846118c9d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 30 Jan 2026 07:09:14 -0600 Subject: [PATCH 1/6] refactor: migrate from AutoMapper to Mapperly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace AutoMapper (runtime reflection) with Mapperly (compile-time source generation) - Add Riok.Mapperly 4.2.1 to Exceptionless.Web - Remove AutoMapper 14.0.0 from Exceptionless.Core Breaking changes: - Controllers now use abstract mapping methods instead of generic MapAsync - Base controllers require derived classes to implement MapToModel, MapToViewModel, MapToViewModels Mapping structure: - Created dedicated mapper files per type in src/Exceptionless.Web/Mapping/ - OrganizationMapper: NewOrganization -> Organization, Organization -> ViewOrganization - ProjectMapper: NewProject -> Project, Project -> ViewProject - TokenMapper: NewToken -> Token, Token -> ViewToken - UserMapper: User -> ViewUser - WebHookMapper: NewWebHook -> WebHook - InvoiceMapper: Stripe.Invoice -> InvoiceGridModel - ApiMapper facade delegates to individual mappers Testing: - Added comprehensive unit tests for all mappers (29 tests) - Tests follow backend-testing skill patterns Benefits: - Compile-time type safety for mappings - Better performance (no runtime reflection) - Cleaner separation of concerns with per-type mappers refactor: improve Mapperly mapper safety and configuration - Add [MapperIgnoreTarget] for computed/populated-later properties on OrganizationMapper (IsOverRequestLimit, IsThrottled, ProjectCount, StackCount, EventCount) and ProjectMapper (HasPremiumFeatures, OrganizationName, StackCount, EventCount) - Upgrade UserMapper to RequiredMappingStrategy.Target since it only maps Model→ViewModel; new ViewUser properties will produce compile warnings unless explicitly mapped or ignored - Add [MapperIgnoreTarget] for ViewUser.IsInvite (manually constructed) - Let Mapperly generate collection methods (MapToViewTokens, MapToViewUsers) natively instead of manual .Select().ToList() - Add SuspensionCode enum→string mapping tests - Fix StackService ValueTuple serialization bug (restore StackUsageKey) - Update Mapperly 4.2.1 → 4.3.1 --- src/Exceptionless.Core/Bootstrapper.cs | 16 -- .../Exceptionless.Core.csproj | 1 - src/Exceptionless.Core/Models/CoreMappings.cs | 5 - .../Services/StackService.cs | 1 + src/Exceptionless.Web/Bootstrapper.cs | 38 +---- .../Base/ReadOnlyRepositoryApiController.cs | 45 +++--- .../Base/RepositoryApiController.cs | 32 +++- .../Controllers/EventController.cs | 20 ++- .../Controllers/OrganizationController.cs | 25 +++- .../Controllers/ProjectController.cs | 21 ++- .../Controllers/StackController.cs | 9 +- .../Controllers/TokenController.cs | 15 +- .../Controllers/UserController.cs | 12 +- .../Controllers/WebHookController.cs | 9 +- .../Exceptionless.Web.csproj | 1 + src/Exceptionless.Web/Mapping/ApiMapper.cs | 76 ++++++++++ .../Mapping/InvoiceMapper.cs | 21 +++ .../Mapping/OrganizationMapper.cs | 41 +++++ .../Mapping/ProjectMapper.cs | 32 ++++ src/Exceptionless.Web/Mapping/TokenMapper.cs | 19 +++ src/Exceptionless.Web/Mapping/UserMapper.cs | 19 +++ .../Mapping/WebHookMapper.cs | 14 ++ .../Data/event-serialization-response.json | 87 ++++++----- .../Mapping/InvoiceMapperTests.cs | 104 +++++++++++++ .../Mapping/OrganizationMapperTests.cs | 140 ++++++++++++++++++ .../Mapping/ProjectMapperTests.cs | 123 +++++++++++++++ .../Mapping/TokenMapperTests.cs | 111 ++++++++++++++ .../Mapping/UserMapperTests.cs | 111 ++++++++++++++ .../Mapping/WebHookMapperTests.cs | 76 ++++++++++ 29 files changed, 1059 insertions(+), 165 deletions(-) delete mode 100644 src/Exceptionless.Core/Models/CoreMappings.cs create mode 100644 src/Exceptionless.Web/Mapping/ApiMapper.cs create mode 100644 src/Exceptionless.Web/Mapping/InvoiceMapper.cs create mode 100644 src/Exceptionless.Web/Mapping/OrganizationMapper.cs create mode 100644 src/Exceptionless.Web/Mapping/ProjectMapper.cs create mode 100644 src/Exceptionless.Web/Mapping/TokenMapper.cs create mode 100644 src/Exceptionless.Web/Mapping/UserMapper.cs create mode 100644 src/Exceptionless.Web/Mapping/WebHookMapper.cs create mode 100644 tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs create mode 100644 tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs create mode 100644 tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs create mode 100644 tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs create mode 100644 tests/Exceptionless.Tests/Mapping/UserMapperTests.cs create mode 100644 tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 04290b3d4a..f264439dca 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using AutoMapper; using Exceptionless.Core.Authentication; using Exceptionless.Core.Billing; using Exceptionless.Core.Configuration; @@ -199,21 +198,6 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddTransient(); - - services.AddTransient(); - services.AddSingleton(s => - { - var profiles = s.GetServices(); - var c = new MapperConfiguration(cfg => - { - cfg.ConstructServicesUsing(s.GetRequiredService); - - foreach (var profile in profiles) - cfg.AddProfile(profile); - }); - - return c.CreateMapper(); - }); } public static void LogConfiguration(IServiceProvider serviceProvider, AppOptions appOptions, ILogger logger) diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index 1fef65ec47..a706da2023 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -20,7 +20,6 @@ - diff --git a/src/Exceptionless.Core/Models/CoreMappings.cs b/src/Exceptionless.Core/Models/CoreMappings.cs deleted file mode 100644 index d450665d90..0000000000 --- a/src/Exceptionless.Core/Models/CoreMappings.cs +++ /dev/null @@ -1,5 +0,0 @@ -using AutoMapper; - -namespace Exceptionless.Core.Models; - -public class CoreMappings : Profile { } diff --git a/src/Exceptionless.Core/Services/StackService.cs b/src/Exceptionless.Core/Services/StackService.cs index 84671040ea..cb7d8124fa 100644 --- a/src/Exceptionless.Core/Services/StackService.cs +++ b/src/Exceptionless.Core/Services/StackService.cs @@ -7,6 +7,7 @@ namespace Exceptionless.Core.Services; /// /// Identifies a stack for deferred usage counter updates. +/// Records serialize correctly with STJ unlike ValueTuples which serialize to {}. /// public record StackUsageKey(string OrganizationId, string ProjectId, string StackId); diff --git a/src/Exceptionless.Web/Bootstrapper.cs b/src/Exceptionless.Web/Bootstrapper.cs index 9983e3b117..f83edd3615 100644 --- a/src/Exceptionless.Web/Bootstrapper.cs +++ b/src/Exceptionless.Web/Bootstrapper.cs @@ -1,16 +1,12 @@ -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Extensions; using Exceptionless.Core.Jobs.WorkItemHandlers; -using Exceptionless.Core.Models; -using Exceptionless.Core.Models.Data; using Exceptionless.Core.Queues.Models; using Exceptionless.Web.Hubs; -using Exceptionless.Web.Models; +using Exceptionless.Web.Mapping; using Foundatio.Extensions.Hosting.Startup; using Foundatio.Jobs; using Foundatio.Messaging; -using Token = Exceptionless.Core.Models.Token; namespace Exceptionless.Web; @@ -21,7 +17,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); - services.AddTransient(); + services.AddSingleton(); Core.Bootstrapper.RegisterServices(services, appOptions); Insulation.Bootstrapper.RegisterServices(services, appOptions, appOptions.RunJobsInProcess); @@ -46,34 +42,4 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddStartupAction(); } - - public class ApiMappings : Profile - { - public ApiMappings(TimeProvider timeProvider) - { - CreateMap(); - - CreateMap(); - CreateMap().AfterMap((o, vo) => - { - vo.IsOverMonthlyLimit = o.IsOverMonthlyLimit(timeProvider); - }); - - CreateMap().AfterMap((si, igm) => - { - igm.Id = igm.Id.Substring(3); - igm.Date = si.Created; - }); - - CreateMap(); - CreateMap().AfterMap((p, vp) => vp.HasSlackIntegration = p.Data is not null && p.Data.ContainsKey(Project.KnownDataKeys.SlackToken)); - - CreateMap().ForMember(m => m.Type, m => m.Ignore()); - CreateMap(); - - CreateMap(); - - CreateMap(); - } - } } diff --git a/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs b/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs index ab1f535120..c78f38c620 100644 --- a/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs +++ b/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs @@ -1,25 +1,28 @@ -using AutoMapper; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queries.Validation; +using Exceptionless.Web.Mapping; using Foundatio.Repositories; using Foundatio.Repositories.Models; using Microsoft.AspNetCore.Mvc; namespace Exceptionless.Web.Controllers; -public abstract class ReadOnlyRepositoryApiController : ExceptionlessApiController where TRepository : ISearchableReadOnlyRepository where TModel : class, IIdentity, new() where TViewModel : class, IIdentity, new() +public abstract class ReadOnlyRepositoryApiController : ExceptionlessApiController + where TRepository : ISearchableReadOnlyRepository + where TModel : class, IIdentity, new() + where TViewModel : class, IIdentity, new() { protected readonly TRepository _repository; protected static readonly bool _isOwnedByOrganization = typeof(IOwnedByOrganization).IsAssignableFrom(typeof(TModel)); protected static readonly bool _isOrganization = typeof(TModel) == typeof(Organization); protected static readonly bool _supportsSoftDeletes = typeof(ISupportSoftDeletes).IsAssignableFrom(typeof(TModel)); protected static readonly IReadOnlyCollection EmptyModels = new List(0).AsReadOnly(); - protected readonly IMapper _mapper; + protected readonly ApiMapper _mapper; protected readonly IAppQueryValidator _validator; protected readonly ILogger _logger; - public ReadOnlyRepositoryApiController(TRepository repository, IMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider) + public ReadOnlyRepositoryApiController(TRepository repository, ApiMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider) { _repository = repository; _mapper = mapper; @@ -38,9 +41,21 @@ protected async Task> GetByIdImplAsync(string id) protected virtual async Task> OkModelAsync(TModel model) { - return Ok(await MapAsync(model, true)); + var viewModel = MapToViewModel(model); + await AfterResultMapAsync([viewModel]); + return Ok(viewModel); } + /// + /// Maps a domain model to a view model. Override in derived controllers. + /// + protected abstract TViewModel MapToViewModel(TModel model); + + /// + /// Maps a collection of domain models to view models. Override in derived controllers. + /// + protected abstract List MapToViewModels(IEnumerable models); + protected virtual async Task GetModelAsync(string id, bool useCache = true) { if (String.IsNullOrEmpty(id)) @@ -69,24 +84,6 @@ protected virtual async Task> GetModelsAsync(string[ return models; } - protected async Task MapAsync(object source, bool isResult = false) - { - var destination = _mapper.Map(source); - if (isResult) - await AfterResultMapAsync(new List(new[] { destination })); - - return destination; - } - - protected async Task> MapCollectionAsync(object source, bool isResult = false) - { - var destination = _mapper.Map>(source); - if (isResult) - await AfterResultMapAsync(destination); - - return destination; - } - protected virtual Task AfterResultMapAsync(ICollection models) { foreach (var model in models.OfType()) diff --git a/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs b/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs index 774e403ad6..c7820c6c9d 100644 --- a/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs +++ b/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs @@ -1,8 +1,8 @@ -using AutoMapper; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queries.Validation; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Utility; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -10,17 +10,27 @@ namespace Exceptionless.Web.Controllers; -public abstract class RepositoryApiController : ReadOnlyRepositoryApiController where TRepository : ISearchableRepository where TModel : class, IIdentity, new() where TViewModel : class, IIdentity, new() where TNewModel : class, new() where TUpdateModel : class, new() +public abstract class RepositoryApiController : ReadOnlyRepositoryApiController + where TRepository : ISearchableRepository + where TModel : class, IIdentity, new() + where TViewModel : class, IIdentity, new() + where TNewModel : class, new() + where TUpdateModel : class, new() { - public RepositoryApiController(TRepository repository, IMapper mapper, IAppQueryValidator validator, + public RepositoryApiController(TRepository repository, ApiMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) { } + /// + /// Maps a new model (from API input) to a domain model. Override in derived controllers. + /// + protected abstract TModel MapToModel(TNewModel newModel); + protected async Task> PostImplAsync(TNewModel value) { if (value is null) return BadRequest(); - var mapped = await MapAsync(value); + var mapped = MapToModel(value); // if no organization id is specified, default to the user's 1st associated org. if (!_isOrganization && mapped is IOwnedByOrganization orgModel && String.IsNullOrEmpty(orgModel.OrganizationId) && GetAssociatedOrganizationIds().Count > 0) orgModel.OrganizationId = Request.GetDefaultOrganizationId()!; @@ -32,7 +42,9 @@ protected async Task> PostImplAsync(TNewModel value) var model = await AddModelAsync(mapped); await AfterAddAsync(model); - return Created(new Uri(GetEntityLink(model.Id) ?? throw new InvalidOperationException()), await MapAsync(model, true)); + var viewModel = MapToViewModel(model); + await AfterResultMapAsync([viewModel]); + return Created(new Uri(GetEntityLink(model.Id) ?? throw new InvalidOperationException()), viewModel); } protected async Task> UpdateModelAsync(string id, Func> modelUpdateFunc) @@ -50,7 +62,9 @@ protected async Task> UpdateModelAsync(string id, Func< if (typeof(TViewModel) == typeof(TModel)) return Ok(model); - return Ok(await MapAsync(model, true)); + var viewModel = MapToViewModel(model); + await AfterResultMapAsync([viewModel]); + return Ok(viewModel); } protected async Task> UpdateModelsAsync(string[] ids, Func> modelUpdateFunc) @@ -70,7 +84,9 @@ protected async Task> UpdateModelsAsync(string[] ids, F if (typeof(TViewModel) == typeof(TModel)) return Ok(models); - return Ok(await MapAsync(models, true)); + var viewModels = MapToViewModels(models); + await AfterResultMapAsync(viewModels); + return Ok(viewModels); } protected virtual string? GetEntityLink(string id) diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index 6daf3ef7bd..e19a21f68f 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -1,5 +1,4 @@ using System.Text; -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; @@ -16,6 +15,7 @@ using Exceptionless.Core.Services; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Exceptionless.Web.Utility.OpenApi; @@ -60,7 +60,7 @@ public EventController(IEventRepository repository, FormattingPluginManager formattingPluginManager, ICacheClient cacheClient, JsonSerializerSettings jsonSerializerSettings, - IMapper mapper, + ApiMapper mapper, PersistentEventQueryValidator validator, AppOptions appOptions, TimeProvider timeProvider, @@ -82,6 +82,11 @@ ILoggerFactory loggerFactory DefaultDateField = EventIndex.Alias.Date; } + // Mapping implementations - PersistentEvent uses itself as view model (no mapping needed) + protected override PersistentEvent MapToModel(PersistentEvent newModel) => newModel; + protected override PersistentEvent MapToViewModel(PersistentEvent model) => model; + protected override List MapToViewModels(IEnumerable models) => models.ToList(); + /// /// Count /// @@ -813,9 +818,14 @@ public async Task SetUserDescriptionAsync(string referenceId, Use // Set the project for the configuration response filter. Request.SetProject(project); - var eventUserDescription = await MapAsync(description); - eventUserDescription.ProjectId = project.Id; - eventUserDescription.ReferenceId = referenceId; + var eventUserDescription = new EventUserDescription + { + ProjectId = project.Id, + ReferenceId = referenceId, + EmailAddress = description.EmailAddress, + Description = description.Description, + Data = description.Data + }; await _eventUserDescriptionQueue.EnqueueAsync(eventUserDescription); return StatusCode(StatusCodes.Status202Accepted); diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 20d8b48e76..79be92327c 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -1,5 +1,4 @@ -using AutoMapper; -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -12,6 +11,7 @@ using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Services; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Caching; @@ -55,7 +55,7 @@ public OrganizationController( UsageService usageService, IMailer mailer, IMessagePublisher messagePublisher, - IMapper mapper, + ApiMapper mapper, IAppQueryValidator validator, AppOptions options, TimeProvider timeProvider, @@ -74,6 +74,11 @@ public OrganizationController( _options = options; } + // Mapping implementations + protected override Organization MapToModel(NewOrganization newModel) => _mapper.MapToOrganization(newModel); + protected override ViewOrganization MapToViewModel(Organization model) => _mapper.MapToViewOrganization(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewOrganizations(models); + /// /// Get all /// @@ -82,10 +87,11 @@ public OrganizationController( public async Task>> GetAllAsync(string? mode = null) { var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray()); - var viewOrganizations = await MapCollectionAsync(organizations, true); + var viewOrganizations = MapToViewModels(organizations); + await AfterResultMapAsync(viewOrganizations); if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return Ok(await PopulateOrganizationStatsAsync(viewOrganizations.ToList())); + return Ok(await PopulateOrganizationStatsAsync(viewOrganizations)); return Ok(viewOrganizations); } @@ -98,7 +104,8 @@ public async Task>> GetForAdm page = GetPage(page); limit = GetLimit(limit); var organizations = await _repository.GetByCriteriaAsync(criteria, o => o.PageNumber(page).PageLimit(limit), sort, paid, suspended); - var viewOrganizations = (await MapCollectionAsync(organizations.Documents, true)).ToList(); + var viewOrganizations = MapToViewModels(organizations.Documents); + await AfterResultMapAsync(viewOrganizations); if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) return OkWithResourceLinks(await PopulateOrganizationStatsAsync(viewOrganizations), organizations.HasMore, page, organizations.Total); @@ -127,7 +134,9 @@ public async Task> GetAsync(string id, string? mo if (organization is null) return NotFound(); - var viewOrganization = await MapAsync(organization, true); + var viewOrganization = MapToViewModel(organization); + await AfterResultMapAsync([viewOrganization]); + if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) return Ok(await PopulateOrganizationStatsAsync(viewOrganization)); @@ -306,7 +315,7 @@ public async Task>> GetInvoic var client = new StripeClient(_options.StripeOptions.StripeApiKey); var invoiceService = new InvoiceService(client); var invoiceOptions = new InvoiceListOptions { Customer = organization.StripeCustomerId, Limit = limit + 1, EndingBefore = before, StartingAfter = after }; - var invoices = (await MapCollectionAsync(await invoiceService.ListAsync(invoiceOptions), true)).ToList(); + var invoices = _mapper.MapToInvoiceGridModels(await invoiceService.ListAsync(invoiceOptions)); return OkWithResourceLinks(invoices.Take(limit).ToList(), invoices.Count > limit); } diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs index cf3b6d6c86..42d3d207c2 100644 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ b/src/Exceptionless.Web/Controllers/ProjectController.cs @@ -1,4 +1,3 @@ -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; @@ -10,6 +9,7 @@ using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Services; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Jobs; @@ -46,7 +46,7 @@ public ProjectController( IQueue workItemQueue, BillingManager billingManager, SlackService slackService, - IMapper mapper, + ApiMapper mapper, IAppQueryValidator validator, AppOptions options, UsageService usageService, @@ -66,6 +66,11 @@ ILoggerFactory loggerFactory _usageService = usageService; } + // Mapping implementations + protected override Project MapToModel(NewProject newModel) => _mapper.MapToProject(newModel); + protected override ViewProject MapToViewModel(Project model) => _mapper.MapToViewProject(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewProjects(models); + /// /// Get all /// @@ -87,10 +92,11 @@ public async Task>> GetAllAsync(st var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; var projects = await _repository.GetByFilterAsync(sf, filter, sort, o => o.PageNumber(page).PageLimit(limit)); - var viewProjects = await MapCollectionAsync(projects.Documents, true); + var viewProjects = MapToViewModels(projects.Documents); + await AfterResultMapAsync(viewProjects); if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return OkWithResourceLinks(await PopulateProjectStatsAsync(viewProjects.ToList()), projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); + return OkWithResourceLinks(await PopulateProjectStatsAsync(viewProjects), projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); return OkWithResourceLinks(viewProjects, projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); } @@ -117,7 +123,8 @@ public async Task>> GetByOrganizat limit = GetLimit(limit, 1000); var sf = new AppFilter(organization); var projects = await _repository.GetByFilterAsync(sf, filter, sort, o => o.PageNumber(page).PageLimit(limit)); - var viewProjects = (await MapCollectionAsync(projects.Documents, true)).ToList(); + var viewProjects = MapToViewModels(projects.Documents); + await AfterResultMapAsync(viewProjects); if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) return OkWithResourceLinks(await PopulateProjectStatsAsync(viewProjects), projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); @@ -139,7 +146,9 @@ public async Task> GetAsync(string id, string? mode = if (project is null) return NotFound(); - var viewProject = await MapAsync(project, true); + var viewProject = MapToViewModel(project); + await AfterResultMapAsync([viewProject]); + if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) return Ok(await PopulateProjectStatsAsync(viewProject)); diff --git a/src/Exceptionless.Web/Controllers/StackController.cs b/src/Exceptionless.Web/Controllers/StackController.cs index 588b48ae02..eef9d13441 100644 --- a/src/Exceptionless.Web/Controllers/StackController.cs +++ b/src/Exceptionless.Web/Controllers/StackController.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; @@ -13,6 +12,7 @@ using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Utility; using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Foundatio.Caching; using Foundatio.Queues; @@ -52,7 +52,7 @@ public StackController( ICacheClient cacheClient, FormattingPluginManager formattingPluginManager, SemanticVersionParser semanticVersionParser, - IMapper mapper, + ApiMapper mapper, StackQueryValidator validator, AppOptions options, TimeProvider timeProvider, @@ -75,6 +75,11 @@ ILoggerFactory loggerFactory DefaultDateField = StackIndex.Alias.LastOccurrence; } + // Mapping implementations - Stack uses itself as view model (no mapping needed) + protected override Stack MapToModel(Stack newModel) => newModel; + protected override Stack MapToViewModel(Stack model) => model; + protected override List MapToViewModels(IEnumerable models) => models.ToList(); + /// /// Get by id /// diff --git a/src/Exceptionless.Web/Controllers/TokenController.cs b/src/Exceptionless.Web/Controllers/TokenController.cs index d849c35cf1..0aaf0b4058 100644 --- a/src/Exceptionless.Web/Controllers/TokenController.cs +++ b/src/Exceptionless.Web/Controllers/TokenController.cs @@ -1,10 +1,10 @@ -using AutoMapper; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queries.Validation; using Exceptionless.Core.Repositories; using Exceptionless.Web.Controllers; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Repositories; @@ -23,7 +23,7 @@ public class TokenController : RepositoryApiController _mapper.MapToToken(newModel); + protected override ViewToken MapToViewModel(Token model) => _mapper.MapToViewToken(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewTokens(models); + /// /// Get by organization /// @@ -50,7 +55,8 @@ public async Task>> GetByOrganizatio page = GetPage(page); limit = GetLimit(limit); var tokens = await _repository.GetByTypeAndOrganizationIdAsync(TokenType.Access, organizationId, o => o.PageNumber(page).PageLimit(limit)); - var viewTokens = (await MapCollectionAsync(tokens.Documents, true)).ToList(); + var viewTokens = MapToViewModels(tokens.Documents); + await AfterResultMapAsync(viewTokens); return OkWithResourceLinks(viewTokens, tokens.HasMore && !NextPageExceedsSkipLimit(page, limit), page, tokens.Total); } @@ -74,7 +80,8 @@ public async Task>> GetByProjectAsyn page = GetPage(page); limit = GetLimit(limit); var tokens = await _repository.GetByTypeAndProjectIdAsync(TokenType.Access, projectId, o => o.PageNumber(page).PageLimit(limit)); - var viewTokens = (await MapCollectionAsync(tokens.Documents, true)).ToList(); + var viewTokens = MapToViewModels(tokens.Documents); + await AfterResultMapAsync(viewTokens); return OkWithResourceLinks(viewTokens, tokens.HasMore && !NextPageExceedsSkipLimit(page, limit), page, tokens.Total); } diff --git a/src/Exceptionless.Web/Controllers/UserController.cs b/src/Exceptionless.Web/Controllers/UserController.cs index e52dcba5eb..a06bcdc17d 100644 --- a/src/Exceptionless.Web/Controllers/UserController.cs +++ b/src/Exceptionless.Web/Controllers/UserController.cs @@ -1,4 +1,3 @@ -using AutoMapper; using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; @@ -8,6 +7,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Caching; @@ -31,7 +31,7 @@ public class UserController : RepositoryApiController throw new NotSupportedException("Users cannot be created via API mapping."); + protected override ViewUser MapToViewModel(User model) => _mapper.MapToViewUser(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewUsers(models); + /// /// Get current user /// @@ -90,7 +95,8 @@ public async Task>> GetByOrganization return Ok(Enumerable.Empty()); var results = await _repository.GetByOrganizationIdAsync(organizationId, o => o.PageLimit(MAXIMUM_SKIP)); - var users = (await MapCollectionAsync(results.Documents, true)).ToList(); + var users = MapToViewModels(results.Documents); + await AfterResultMapAsync(users); if (!Request.IsGlobalAdmin()) users.ForEach(u => u.Roles.Remove(AuthorizationRoles.GlobalAdmin)); diff --git a/src/Exceptionless.Web/Controllers/WebHookController.cs b/src/Exceptionless.Web/Controllers/WebHookController.cs index 55bd42870d..f80d49b0a4 100644 --- a/src/Exceptionless.Web/Controllers/WebHookController.cs +++ b/src/Exceptionless.Web/Controllers/WebHookController.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using AutoMapper; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -8,6 +7,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.Web.Controllers; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Foundatio.Repositories; using Microsoft.AspNetCore.Authorization; @@ -22,13 +22,18 @@ public class WebHookController : RepositoryApiController _mapper.MapToWebHook(newModel); + protected override WebHook MapToViewModel(WebHook model) => model; + protected override List MapToViewModels(IEnumerable models) => models.ToList(); + /// /// Get by project /// diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 17a0a6e791..d2fc17f4c2 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Exceptionless.Web/Mapping/ApiMapper.cs b/src/Exceptionless.Web/Mapping/ApiMapper.cs new file mode 100644 index 0000000000..450c22a472 --- /dev/null +++ b/src/Exceptionless.Web/Mapping/ApiMapper.cs @@ -0,0 +1,76 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Mapping; + +/// +/// Facade for all API type mappers. Delegates to type-specific mappers. +/// Uses compile-time source generation for type-safe, performant mappings. +/// +public class ApiMapper +{ + private readonly OrganizationMapper _organizationMapper; + private readonly ProjectMapper _projectMapper; + private readonly TokenMapper _tokenMapper; + private readonly UserMapper _userMapper; + private readonly WebHookMapper _webHookMapper; + private readonly InvoiceMapper _invoiceMapper; + + public ApiMapper(TimeProvider timeProvider) + { + _organizationMapper = new OrganizationMapper(timeProvider); + _projectMapper = new ProjectMapper(); + _tokenMapper = new TokenMapper(); + _userMapper = new UserMapper(); + _webHookMapper = new WebHookMapper(); + _invoiceMapper = new InvoiceMapper(); + } + + // Organization mappings + public Organization MapToOrganization(NewOrganization source) + => _organizationMapper.MapToOrganization(source); + + public ViewOrganization MapToViewOrganization(Organization source) + => _organizationMapper.MapToViewOrganization(source); + + public List MapToViewOrganizations(IEnumerable source) + => _organizationMapper.MapToViewOrganizations(source); + + // Project mappings + public Project MapToProject(NewProject source) + => _projectMapper.MapToProject(source); + + public ViewProject MapToViewProject(Project source) + => _projectMapper.MapToViewProject(source); + + public List MapToViewProjects(IEnumerable source) + => _projectMapper.MapToViewProjects(source); + + // Token mappings + public Token MapToToken(NewToken source) + => _tokenMapper.MapToToken(source); + + public ViewToken MapToViewToken(Token source) + => _tokenMapper.MapToViewToken(source); + + public List MapToViewTokens(IEnumerable source) + => _tokenMapper.MapToViewTokens(source); + + // User mappings + public ViewUser MapToViewUser(User source) + => _userMapper.MapToViewUser(source); + + public List MapToViewUsers(IEnumerable source) + => _userMapper.MapToViewUsers(source); + + // WebHook mappings + public WebHook MapToWebHook(NewWebHook source) + => _webHookMapper.MapToWebHook(source); + + // Invoice mappings + public InvoiceGridModel MapToInvoiceGridModel(Stripe.Invoice source) + => _invoiceMapper.MapToInvoiceGridModel(source); + + public List MapToInvoiceGridModels(IEnumerable source) + => _invoiceMapper.MapToInvoiceGridModels(source); +} diff --git a/src/Exceptionless.Web/Mapping/InvoiceMapper.cs b/src/Exceptionless.Web/Mapping/InvoiceMapper.cs new file mode 100644 index 0000000000..82d278779f --- /dev/null +++ b/src/Exceptionless.Web/Mapping/InvoiceMapper.cs @@ -0,0 +1,21 @@ +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapper for Stripe Invoice to InvoiceGridModel. +/// Note: Created manually due to required properties and custom transformations. +/// +public class InvoiceMapper +{ + public InvoiceGridModel MapToInvoiceGridModel(Stripe.Invoice source) + => new() + { + Id = source.Id[3..], // Strip "in_" prefix + Date = source.Created, + Paid = source.Paid + }; + + public List MapToInvoiceGridModels(IEnumerable source) + => source.Select(MapToInvoiceGridModel).ToList(); +} diff --git a/src/Exceptionless.Web/Mapping/OrganizationMapper.cs b/src/Exceptionless.Web/Mapping/OrganizationMapper.cs new file mode 100644 index 0000000000..09ace1eea4 --- /dev/null +++ b/src/Exceptionless.Web/Mapping/OrganizationMapper.cs @@ -0,0 +1,41 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for Organization types. +/// Computed/populated-later properties are explicitly ignored via MapperIgnoreTarget. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class OrganizationMapper +{ + private readonly TimeProvider _timeProvider; + + public OrganizationMapper(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + + public partial Organization MapToOrganization(NewOrganization source); + + [MapperIgnoreTarget(nameof(ViewOrganization.IsOverMonthlyLimit))] + [MapperIgnoreTarget(nameof(ViewOrganization.IsOverRequestLimit))] + [MapperIgnoreTarget(nameof(ViewOrganization.IsThrottled))] + [MapperIgnoreTarget(nameof(ViewOrganization.ProjectCount))] + [MapperIgnoreTarget(nameof(ViewOrganization.StackCount))] + [MapperIgnoreTarget(nameof(ViewOrganization.EventCount))] + private partial ViewOrganization MapToViewOrganizationCore(Organization source); + + public ViewOrganization MapToViewOrganization(Organization source) + { + var result = MapToViewOrganizationCore(source); + result.IsOverMonthlyLimit = source.IsOverMonthlyLimit(_timeProvider); + return result; + } + + public List MapToViewOrganizations(IEnumerable source) + => source.Select(MapToViewOrganization).ToList(); +} diff --git a/src/Exceptionless.Web/Mapping/ProjectMapper.cs b/src/Exceptionless.Web/Mapping/ProjectMapper.cs new file mode 100644 index 0000000000..34b8e48b5e --- /dev/null +++ b/src/Exceptionless.Web/Mapping/ProjectMapper.cs @@ -0,0 +1,32 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for Project types. +/// Computed/populated-later properties are explicitly ignored via MapperIgnoreTarget. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class ProjectMapper +{ + public partial Project MapToProject(NewProject source); + + [MapperIgnoreTarget(nameof(ViewProject.HasSlackIntegration))] + [MapperIgnoreTarget(nameof(ViewProject.HasPremiumFeatures))] + [MapperIgnoreTarget(nameof(ViewProject.OrganizationName))] + [MapperIgnoreTarget(nameof(ViewProject.StackCount))] + [MapperIgnoreTarget(nameof(ViewProject.EventCount))] + private partial ViewProject MapToViewProjectCore(Project source); + + public ViewProject MapToViewProject(Project source) + { + var result = MapToViewProjectCore(source); + result.HasSlackIntegration = source.Data is not null && source.Data.ContainsKey(Project.KnownDataKeys.SlackToken); + return result; + } + + public List MapToViewProjects(IEnumerable source) + => source.Select(MapToViewProject).ToList(); +} diff --git a/src/Exceptionless.Web/Mapping/TokenMapper.cs b/src/Exceptionless.Web/Mapping/TokenMapper.cs new file mode 100644 index 0000000000..156d55449f --- /dev/null +++ b/src/Exceptionless.Web/Mapping/TokenMapper.cs @@ -0,0 +1,19 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for Token types. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class TokenMapper +{ + [MapperIgnoreTarget(nameof(Token.Type))] + public partial Token MapToToken(NewToken source); + + public partial ViewToken MapToViewToken(Token source); + + public partial List MapToViewTokens(IEnumerable source); +} diff --git a/src/Exceptionless.Web/Mapping/UserMapper.cs b/src/Exceptionless.Web/Mapping/UserMapper.cs new file mode 100644 index 0000000000..76b946148b --- /dev/null +++ b/src/Exceptionless.Web/Mapping/UserMapper.cs @@ -0,0 +1,19 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for User types. +/// Uses RequiredMappingStrategy.Target so new ViewUser properties +/// produce compile warnings unless explicitly mapped or ignored. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] +public partial class UserMapper +{ + [MapperIgnoreTarget(nameof(ViewUser.IsInvite))] + public partial ViewUser MapToViewUser(User source); + + public partial List MapToViewUsers(IEnumerable source); +} diff --git a/src/Exceptionless.Web/Mapping/WebHookMapper.cs b/src/Exceptionless.Web/Mapping/WebHookMapper.cs new file mode 100644 index 0000000000..a9dbb1bf51 --- /dev/null +++ b/src/Exceptionless.Web/Mapping/WebHookMapper.cs @@ -0,0 +1,14 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for WebHook types. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class WebHookMapper +{ + public partial WebHook MapToWebHook(NewWebHook source); +} diff --git a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json index b1a558af13..de54fcfb41 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json +++ b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json @@ -1,48 +1,45 @@ { - "id": "", - "organization_id": "537650f3b77efe23a47914f3", - "project_id": "537650f3b77efe23a47914f4", - "stack_id": "", - "is_first_occurrence": true, - "created_utc": "2026-01-15T12:00:00", - "type": "error", - "date": "2026-01-15T12:00:00+00:00", - "tags": [ - "test", - "serialization" - ], - "message": "Test error for serialization verification", - "data": { - "@request": { - "user_agent": "TestAgent/1.0", - "http_method": "POST", - "is_secure": true, - "host": "test.example.com", - "port": 443, - "path": "/api/test", - "client_ip_address": "10.0.0.100", - "data": { - "@is_bot": false - } - }, - "@submission_client": { - "user_agent": "fluentrest", - "version": "11.0.0.0" - }, - "@environment": { - "processor_count": 8, - "total_physical_memory": 17179869184, - "available_physical_memory": 8589934592, - "command_line": "TestApp.exe --test", - "process_name": "TestApp", - "process_id": "12345", - "process_memory_size": 104857600, - "thread_id": "1", - "o_s_name": "Windows 11", - "o_s_version": "10.0.22621", - "ip_address": "192.168.1.100", - "machine_name": "TEST-MACHINE", - "runtime_version": ".NET 8.0.1" + "id": "", + "organization_id": "537650f3b77efe23a47914f3", + "project_id": "537650f3b77efe23a47914f4", + "stack_id": "", + "is_first_occurrence": true, + "created_utc": "2026-01-15T12:00:00", + "type": "error", + "date": "2026-01-15T12:00:00+00:00", + "tags": ["test", "serialization"], + "message": "Test error for serialization verification", + "data": { + "@request": { + "user_agent": "TestAgent/1.0", + "http_method": "POST", + "is_secure": true, + "host": "test.example.com", + "port": 443, + "path": "/api/test", + "client_ip_address": "10.0.0.100", + "data": { + "@is_bot": false + } + }, + "@submission_client": { + "user_agent": "fluentrest", + "version": "11.0.0.0" + }, + "@environment": { + "processor_count": 8, + "total_physical_memory": 17179869184, + "available_physical_memory": 8589934592, + "command_line": "TestApp.exe --test", + "process_name": "TestApp", + "process_id": "12345", + "process_memory_size": 104857600, + "thread_id": "1", + "o_s_name": "Windows 11", + "o_s_version": "10.0.22621", + "ip_address": "192.168.1.100", + "machine_name": "TEST-MACHINE", + "runtime_version": ".NET 8.0.1" + } } - } } diff --git a/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs new file mode 100644 index 0000000000..7e28a4feb2 --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs @@ -0,0 +1,104 @@ +using Exceptionless.Web.Mapping; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class InvoiceMapperTests +{ + private readonly InvoiceMapper _mapper; + + public InvoiceMapperTests() + { + _mapper = new InvoiceMapper(); + } + + [Fact] + public void MapToInvoiceGridModel_WithValidInvoice_StripsIdPrefix() + { + // Arrange + var source = new Stripe.Invoice + { + Id = "in_abc123", + Created = new DateTime(2025, 1, 15, 12, 0, 0, DateTimeKind.Utc), + Paid = true + }; + + // Act + var result = _mapper.MapToInvoiceGridModel(source); + + // Assert + Assert.Equal("abc123", result.Id); + } + + [Fact] + public void MapToInvoiceGridModel_WithValidInvoice_MapsDateAndPaid() + { + // Arrange + var expectedDate = new DateTime(2025, 1, 15, 12, 0, 0, DateTimeKind.Utc); + var source = new Stripe.Invoice + { + Id = "in_test123", + Created = expectedDate, + Paid = true + }; + + // Act + var result = _mapper.MapToInvoiceGridModel(source); + + // Assert + Assert.Equal(expectedDate, result.Date); + Assert.True(result.Paid); + } + + [Fact] + public void MapToInvoiceGridModel_WithUnpaidInvoice_PaidIsFalse() + { + // Arrange + var source = new Stripe.Invoice + { + Id = "in_unpaid", + Created = DateTime.UtcNow, + Paid = false + }; + + // Act + var result = _mapper.MapToInvoiceGridModel(source); + + // Assert + Assert.False(result.Paid); + } + + [Fact] + public void MapToInvoiceGridModels_WithMultipleInvoices_MapsAll() + { + // Arrange + var invoices = new List + { + new() { Id = "in_invoice1", Created = DateTime.UtcNow, Paid = true }, + new() { Id = "in_invoice2", Created = DateTime.UtcNow, Paid = false }, + new() { Id = "in_invoice3", Created = DateTime.UtcNow, Paid = true } + }; + + // Act + var result = _mapper.MapToInvoiceGridModels(invoices); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("invoice1", result[0].Id); + Assert.Equal("invoice2", result[1].Id); + Assert.Equal("invoice3", result[2].Id); + } + + [Fact] + public void MapToInvoiceGridModels_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var invoices = new List(); + + // Act + var result = _mapper.MapToInvoiceGridModels(invoices); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs b/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs new file mode 100644 index 0000000000..e8821e25fc --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs @@ -0,0 +1,140 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class OrganizationMapperTests +{ + private readonly OrganizationMapper _mapper; + + public OrganizationMapperTests() + { + _mapper = new OrganizationMapper(TimeProvider.System); + } + + [Fact] + public void MapToOrganization_WithValidNewOrganization_MapsName() + { + // Arrange + var source = new NewOrganization { Name = "Test Organization" }; + + // Act + var result = _mapper.MapToOrganization(source); + + // Assert + Assert.Equal("Test Organization", result.Name); + } + + [Fact] + public void MapToViewOrganization_WithValidOrganization_MapsAllProperties() + { + // Arrange + var source = new Organization + { + Id = "org123", + Name = "Test Organization", + PlanId = "free", + IsSuspended = false + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.Equal("org123", result.Id); + Assert.Equal("Test Organization", result.Name); + Assert.Equal("free", result.PlanId); + Assert.False(result.IsSuspended); + } + + [Fact] + public void MapToViewOrganization_WithSuspendedOrganization_MapsIsSuspended() + { + // Arrange + var source = new Organization + { + Id = "org123", + Name = "Suspended Org", + IsSuspended = true + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.True(result.IsSuspended); + } + + [Fact] + public void MapToViewOrganization_WithSuspensionCode_MapsEnumToString() + { + // Arrange + var source = new Organization + { + Id = "org123", + Name = "Suspended Org", + IsSuspended = true, + SuspensionCode = SuspensionCode.Billing + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.Equal("Billing", result.SuspensionCode); + } + + [Fact] + public void MapToViewOrganization_WithNullSuspensionCode_MapsToNull() + { + // Arrange + var source = new Organization + { + Id = "org123", + Name = "Active Org", + SuspensionCode = null + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.Null(result.SuspensionCode); + } + + [Fact] + public void MapToViewOrganizations_WithMultipleOrganizations_MapsAll() + { + // Arrange + var organizations = new List + { + new() { Id = "org1", Name = "Organization 1" }, + new() { Id = "org2", Name = "Organization 2" }, + new() { Id = "org3", Name = "Organization 3" } + }; + + // Act + var result = _mapper.MapToViewOrganizations(organizations); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("org1", result[0].Id); + Assert.Equal("org2", result[1].Id); + Assert.Equal("org3", result[2].Id); + } + + [Fact] + public void MapToViewOrganizations_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var organizations = new List(); + + // Act + var result = _mapper.MapToViewOrganizations(organizations); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs b/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs new file mode 100644 index 0000000000..7a41c5ea43 --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs @@ -0,0 +1,123 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class ProjectMapperTests +{ + private readonly ProjectMapper _mapper; + + public ProjectMapperTests() + { + _mapper = new ProjectMapper(); + } + + [Fact] + public void MapToProject_WithValidNewProject_MapsNameAndOrganizationId() + { + // Arrange + var source = new NewProject + { + Name = "Test Project", + OrganizationId = "org123" + }; + + // Act + var result = _mapper.MapToProject(source); + + // Assert + Assert.Equal("Test Project", result.Name); + Assert.Equal("org123", result.OrganizationId); + } + + [Fact] + public void MapToViewProject_WithValidProject_MapsAllProperties() + { + // Arrange + var source = new Project + { + Id = "proj123", + Name = "Test Project", + OrganizationId = "org123", + DeleteBotDataEnabled = true + }; + + // Act + var result = _mapper.MapToViewProject(source); + + // Assert + Assert.Equal("proj123", result.Id); + Assert.Equal("Test Project", result.Name); + Assert.Equal("org123", result.OrganizationId); + Assert.True(result.DeleteBotDataEnabled); + } + + [Fact] + public void MapToViewProject_WithSlackToken_SetsHasSlackIntegration() + { + // Arrange + var source = new Project + { + Id = "proj123", + Name = "Project with Slack", + Data = new DataDictionary { { Project.KnownDataKeys.SlackToken, "test-token" } } + }; + + // Act + var result = _mapper.MapToViewProject(source); + + // Assert + Assert.True(result.HasSlackIntegration); + } + + [Fact] + public void MapToViewProject_WithoutSlackToken_HasSlackIntegrationIsFalse() + { + // Arrange + var source = new Project + { + Id = "proj123", + Name = "Project without Slack" + }; + + // Act + var result = _mapper.MapToViewProject(source); + + // Assert + Assert.False(result.HasSlackIntegration); + } + + [Fact] + public void MapToViewProjects_WithMultipleProjects_MapsAll() + { + // Arrange + var projects = new List + { + new() { Id = "proj1", Name = "Project 1" }, + new() { Id = "proj2", Name = "Project 2" } + }; + + // Act + var result = _mapper.MapToViewProjects(projects); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("proj1", result[0].Id); + Assert.Equal("proj2", result[1].Id); + } + + [Fact] + public void MapToViewProjects_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var projects = new List(); + + // Act + var result = _mapper.MapToViewProjects(projects); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs new file mode 100644 index 0000000000..13ecf7d837 --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs @@ -0,0 +1,111 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class TokenMapperTests +{ + private readonly TokenMapper _mapper; + + public TokenMapperTests() + { + _mapper = new TokenMapper(); + } + + [Fact] + public void MapToToken_WithValidNewToken_MapsOrganizationIdAndProjectId() + { + // Arrange + var source = new NewToken + { + OrganizationId = "org123", + ProjectId = "proj123", + Notes = "Test token" + }; + + // Act + var result = _mapper.MapToToken(source); + + // Assert + Assert.Equal("org123", result.OrganizationId); + Assert.Equal("proj123", result.ProjectId); + Assert.Equal("Test token", result.Notes); + } + + [Fact] + public void MapToToken_WithNewToken_DoesNotSetTokenType() + { + // Arrange + var source = new NewToken + { + OrganizationId = "org123" + }; + + // Act + var result = _mapper.MapToToken(source); + + // Assert - TokenType is ignored in mapping, so it defaults to Authentication + Assert.Equal(TokenType.Authentication, result.Type); + } + + [Fact] + public void MapToViewToken_WithValidToken_MapsAllProperties() + { + // Arrange + var source = new Token + { + Id = "token123", + OrganizationId = "org123", + ProjectId = "proj123", + UserId = "user123", + Notes = "Test notes", + Type = TokenType.Access + }; + + // Act + var result = _mapper.MapToViewToken(source); + + // Assert + Assert.Equal("token123", result.Id); + Assert.Equal("org123", result.OrganizationId); + Assert.Equal("proj123", result.ProjectId); + Assert.Equal("user123", result.UserId); + Assert.Equal("Test notes", result.Notes); + } + + [Fact] + public void MapToViewTokens_WithMultipleTokens_MapsAll() + { + // Arrange + var tokens = new List + { + new() { Id = "token1", OrganizationId = "org1" }, + new() { Id = "token2", OrganizationId = "org2" }, + new() { Id = "token3", OrganizationId = "org3" } + }; + + // Act + var result = _mapper.MapToViewTokens(tokens); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("token1", result[0].Id); + Assert.Equal("token2", result[1].Id); + Assert.Equal("token3", result[2].Id); + } + + [Fact] + public void MapToViewTokens_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var tokens = new List(); + + // Act + var result = _mapper.MapToViewTokens(tokens); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs new file mode 100644 index 0000000000..88e6de08c4 --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs @@ -0,0 +1,111 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class UserMapperTests +{ + private readonly UserMapper _mapper; + + public UserMapperTests() + { + _mapper = new UserMapper(); + } + + [Fact] + public void MapToViewUser_WithValidUser_MapsAllProperties() + { + // Arrange + var source = new User + { + Id = "user123", + EmailAddress = "test@example.com", + FullName = "Test User", + IsEmailAddressVerified = true, + IsActive = true + }; + + // Act + var result = _mapper.MapToViewUser(source); + + // Assert + Assert.Equal("user123", result.Id); + Assert.Equal("test@example.com", result.EmailAddress); + Assert.Equal("Test User", result.FullName); + Assert.True(result.IsEmailAddressVerified); + Assert.True(result.IsActive); + } + + [Fact] + public void MapToViewUser_WithRoles_MapsRoles() + { + // Arrange + var source = new User + { + Id = "user123", + EmailAddress = "admin@example.com", + Roles = new HashSet { "user", "admin" } + }; + + // Act + var result = _mapper.MapToViewUser(source); + + // Assert + Assert.Contains("user", result.Roles); + Assert.Contains("admin", result.Roles); + } + + [Fact] + public void MapToViewUser_WithOrganizationIds_MapsOrganizationIds() + { + // Arrange + var source = new User + { + Id = "user123", + EmailAddress = "user@example.com", + OrganizationIds = new HashSet { "org1", "org2", "org3" } + }; + + // Act + var result = _mapper.MapToViewUser(source); + + // Assert + Assert.Equal(3, result.OrganizationIds.Count); + Assert.Contains("org1", result.OrganizationIds); + Assert.Contains("org2", result.OrganizationIds); + Assert.Contains("org3", result.OrganizationIds); + } + + [Fact] + public void MapToViewUsers_WithMultipleUsers_MapsAll() + { + // Arrange + var users = new List + { + new() { Id = "user1", EmailAddress = "user1@example.com" }, + new() { Id = "user2", EmailAddress = "user2@example.com" } + }; + + // Act + var result = _mapper.MapToViewUsers(users); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("user1", result[0].Id); + Assert.Equal("user2", result[1].Id); + } + + [Fact] + public void MapToViewUsers_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var users = new List(); + + // Act + var result = _mapper.MapToViewUsers(users); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs b/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs new file mode 100644 index 0000000000..7536290212 --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs @@ -0,0 +1,76 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class WebHookMapperTests +{ + private readonly WebHookMapper _mapper; + + public WebHookMapperTests() + { + _mapper = new WebHookMapper(); + } + + [Fact] + public void MapToWebHook_WithValidNewWebHook_MapsAllProperties() + { + // Arrange + var source = new NewWebHook + { + OrganizationId = "org123", + ProjectId = "proj123", + Url = "https://example.com/webhook", + EventTypes = ["error", "log"] + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + Assert.Equal("org123", result.OrganizationId); + Assert.Equal("proj123", result.ProjectId); + Assert.Equal("https://example.com/webhook", result.Url); + Assert.Contains("error", result.EventTypes); + Assert.Contains("log", result.EventTypes); + } + + [Fact] + public void MapToWebHook_WithNullProjectId_MapsWithNullProjectId() + { + // Arrange + var source = new NewWebHook + { + OrganizationId = "org123", + Url = "https://example.com/webhook" + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + Assert.Equal("org123", result.OrganizationId); + Assert.Null(result.ProjectId); + Assert.Equal("https://example.com/webhook", result.Url); + } + + [Fact] + public void MapToWebHook_WithEmptyEventTypes_MapsEmptyEventTypes() + { + // Arrange + var source = new NewWebHook + { + OrganizationId = "org123", + Url = "https://example.com/webhook", + EventTypes = [] + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + Assert.Empty(result.EventTypes); + } +} From 770173ac6d16e7f2b8058969cefd0e3ad6986904 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 15 Mar 2026 21:08:57 -0500 Subject: [PATCH 2/6] PR feedback --- .../Data/event-serialization-response.json | 2 +- .../Controllers/Data/openapi.json | 48 +++++-------------- .../Mapping/InvoiceMapperTests.cs | 2 +- .../Mapping/OrganizationMapperTests.cs | 32 ++++++------- .../Mapping/ProjectMapperTests.cs | 32 ++++++------- .../Mapping/TokenMapperTests.cs | 46 +++++++++--------- .../Mapping/UserMapperTests.cs | 36 +++++++------- .../Mapping/WebHookMapperTests.cs | 24 +++++----- 8 files changed, 99 insertions(+), 123 deletions(-) diff --git a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json index de54fcfb41..9d89be4cef 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json +++ b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json @@ -14,7 +14,7 @@ "user_agent": "TestAgent/1.0", "http_method": "POST", "is_secure": true, - "host": "test.example.com", + "host": "test.localhost", "port": 443, "path": "/api/test", "client_ip_address": "10.0.0.100", diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 449b03abf5..7bd902af81 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -5272,29 +5272,16 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] + "$ref": "#/components/schemas/NotificationSettings" } }, "application/*+json": { "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] + "$ref": "#/components/schemas/NotificationSettings" } } - } + }, + "required": true }, "responses": { "200": { @@ -5340,29 +5327,16 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] + "$ref": "#/components/schemas/NotificationSettings" } }, "application/*+json": { "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] + "$ref": "#/components/schemas/NotificationSettings" } } - } + }, + "required": true }, "responses": { "200": { @@ -7461,7 +7435,6 @@ "stack_id", "is_first_occurrence", "created_utc", - "idx", "date" ], "type": "object", @@ -7504,7 +7477,10 @@ "format": "date-time" }, "idx": { - "type": "object", + "type": [ + "null", + "object" + ], "additionalProperties": { }, "description": "Used to store primitive data type custom data values for searching the event." }, diff --git a/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs index 7e28a4feb2..b5822934b6 100644 --- a/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs @@ -37,7 +37,7 @@ public void MapToInvoiceGridModel_WithValidInvoice_MapsDateAndPaid() var expectedDate = new DateTime(2025, 1, 15, 12, 0, 0, DateTimeKind.Utc); var source = new Stripe.Invoice { - Id = "in_test123", + Id = "in_5f8a3b2c1d4e", Created = expectedDate, Paid = true }; diff --git a/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs b/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs index e8821e25fc..7875ab22be 100644 --- a/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs @@ -33,8 +33,8 @@ public void MapToViewOrganization_WithValidOrganization_MapsAllProperties() // Arrange var source = new Organization { - Id = "org123", - Name = "Test Organization", + Id = "537650f3b77efe23a47914f3", + Name = "Acme Organization", PlanId = "free", IsSuspended = false }; @@ -43,8 +43,8 @@ public void MapToViewOrganization_WithValidOrganization_MapsAllProperties() var result = _mapper.MapToViewOrganization(source); // Assert - Assert.Equal("org123", result.Id); - Assert.Equal("Test Organization", result.Name); + Assert.Equal("537650f3b77efe23a47914f3", result.Id); + Assert.Equal("Acme Organization", result.Name); Assert.Equal("free", result.PlanId); Assert.False(result.IsSuspended); } @@ -55,8 +55,8 @@ public void MapToViewOrganization_WithSuspendedOrganization_MapsIsSuspended() // Arrange var source = new Organization { - Id = "org123", - Name = "Suspended Org", + Id = "537650f3b77efe23a47914f3", + Name = "Suspended Organization", IsSuspended = true }; @@ -73,8 +73,8 @@ public void MapToViewOrganization_WithSuspensionCode_MapsEnumToString() // Arrange var source = new Organization { - Id = "org123", - Name = "Suspended Org", + Id = "537650f3b77efe23a47914f3", + Name = "Suspended Organization", IsSuspended = true, SuspensionCode = SuspensionCode.Billing }; @@ -92,8 +92,8 @@ public void MapToViewOrganization_WithNullSuspensionCode_MapsToNull() // Arrange var source = new Organization { - Id = "org123", - Name = "Active Org", + Id = "537650f3b77efe23a47914f3", + Name = "Active Organization", SuspensionCode = null }; @@ -110,9 +110,9 @@ public void MapToViewOrganizations_WithMultipleOrganizations_MapsAll() // Arrange var organizations = new List { - new() { Id = "org1", Name = "Organization 1" }, - new() { Id = "org2", Name = "Organization 2" }, - new() { Id = "org3", Name = "Organization 3" } + new() { Id = "537650f3b77efe23a47914f3", Name = "Organization 1" }, + new() { Id = "1ecd0826e447ad1e78877666", Name = "Organization 2" }, + new() { Id = "1ecd0826e447ad1e78877777", Name = "Organization 3" } }; // Act @@ -120,9 +120,9 @@ public void MapToViewOrganizations_WithMultipleOrganizations_MapsAll() // Assert Assert.Equal(3, result.Count); - Assert.Equal("org1", result[0].Id); - Assert.Equal("org2", result[1].Id); - Assert.Equal("org3", result[2].Id); + Assert.Equal("537650f3b77efe23a47914f3", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78877666", result[1].Id); + Assert.Equal("1ecd0826e447ad1e78877777", result[2].Id); } [Fact] diff --git a/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs b/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs index 7a41c5ea43..6e2130946a 100644 --- a/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs @@ -20,16 +20,16 @@ public void MapToProject_WithValidNewProject_MapsNameAndOrganizationId() // Arrange var source = new NewProject { - Name = "Test Project", - OrganizationId = "org123" + Name = "Disintegrating Pistol", + OrganizationId = "537650f3b77efe23a47914f3" }; // Act var result = _mapper.MapToProject(source); // Assert - Assert.Equal("Test Project", result.Name); - Assert.Equal("org123", result.OrganizationId); + Assert.Equal("Disintegrating Pistol", result.Name); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); } [Fact] @@ -38,9 +38,9 @@ public void MapToViewProject_WithValidProject_MapsAllProperties() // Arrange var source = new Project { - Id = "proj123", - Name = "Test Project", - OrganizationId = "org123", + Id = "537650f3b77efe23a47914f4", + Name = "Disintegrating Pistol", + OrganizationId = "537650f3b77efe23a47914f3", DeleteBotDataEnabled = true }; @@ -48,9 +48,9 @@ public void MapToViewProject_WithValidProject_MapsAllProperties() var result = _mapper.MapToViewProject(source); // Assert - Assert.Equal("proj123", result.Id); - Assert.Equal("Test Project", result.Name); - Assert.Equal("org123", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f4", result.Id); + Assert.Equal("Disintegrating Pistol", result.Name); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); Assert.True(result.DeleteBotDataEnabled); } @@ -60,7 +60,7 @@ public void MapToViewProject_WithSlackToken_SetsHasSlackIntegration() // Arrange var source = new Project { - Id = "proj123", + Id = "537650f3b77efe23a47914f4", Name = "Project with Slack", Data = new DataDictionary { { Project.KnownDataKeys.SlackToken, "test-token" } } }; @@ -78,7 +78,7 @@ public void MapToViewProject_WithoutSlackToken_HasSlackIntegrationIsFalse() // Arrange var source = new Project { - Id = "proj123", + Id = "537650f3b77efe23a47914f4", Name = "Project without Slack" }; @@ -95,8 +95,8 @@ public void MapToViewProjects_WithMultipleProjects_MapsAll() // Arrange var projects = new List { - new() { Id = "proj1", Name = "Project 1" }, - new() { Id = "proj2", Name = "Project 2" } + new() { Id = "537650f3b77efe23a47914f4", Name = "Project 1" }, + new() { Id = "1ecd0826e447ad1e78877a66", Name = "Project 2" } }; // Act @@ -104,8 +104,8 @@ public void MapToViewProjects_WithMultipleProjects_MapsAll() // Assert Assert.Equal(2, result.Count); - Assert.Equal("proj1", result[0].Id); - Assert.Equal("proj2", result[1].Id); + Assert.Equal("537650f3b77efe23a47914f4", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78877a66", result[1].Id); } [Fact] diff --git a/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs index 13ecf7d837..0ddef59499 100644 --- a/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs @@ -20,18 +20,18 @@ public void MapToToken_WithValidNewToken_MapsOrganizationIdAndProjectId() // Arrange var source = new NewToken { - OrganizationId = "org123", - ProjectId = "proj123", - Notes = "Test token" + OrganizationId = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + Notes = "API access token" }; // Act var result = _mapper.MapToToken(source); // Assert - Assert.Equal("org123", result.OrganizationId); - Assert.Equal("proj123", result.ProjectId); - Assert.Equal("Test token", result.Notes); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f4", result.ProjectId); + Assert.Equal("API access token", result.Notes); } [Fact] @@ -40,7 +40,7 @@ public void MapToToken_WithNewToken_DoesNotSetTokenType() // Arrange var source = new NewToken { - OrganizationId = "org123" + OrganizationId = "537650f3b77efe23a47914f3" }; // Act @@ -56,11 +56,11 @@ public void MapToViewToken_WithValidToken_MapsAllProperties() // Arrange var source = new Token { - Id = "token123", - OrganizationId = "org123", - ProjectId = "proj123", - UserId = "user123", - Notes = "Test notes", + Id = "88cd0826e447a44e78877ab1", + OrganizationId = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + UserId = "1ecd0826e447ad1e78822555", + Notes = "Access token notes", Type = TokenType.Access }; @@ -68,11 +68,11 @@ public void MapToViewToken_WithValidToken_MapsAllProperties() var result = _mapper.MapToViewToken(source); // Assert - Assert.Equal("token123", result.Id); - Assert.Equal("org123", result.OrganizationId); - Assert.Equal("proj123", result.ProjectId); - Assert.Equal("user123", result.UserId); - Assert.Equal("Test notes", result.Notes); + Assert.Equal("88cd0826e447a44e78877ab1", result.Id); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f4", result.ProjectId); + Assert.Equal("1ecd0826e447ad1e78822555", result.UserId); + Assert.Equal("Access token notes", result.Notes); } [Fact] @@ -81,9 +81,9 @@ public void MapToViewTokens_WithMultipleTokens_MapsAll() // Arrange var tokens = new List { - new() { Id = "token1", OrganizationId = "org1" }, - new() { Id = "token2", OrganizationId = "org2" }, - new() { Id = "token3", OrganizationId = "org3" } + new() { Id = "88cd0826e447a44e78877ab1", OrganizationId = "537650f3b77efe23a47914f3" }, + new() { Id = "88cd0826e447a44e78877ab2", OrganizationId = "1ecd0826e447ad1e78877666" }, + new() { Id = "88cd0826e447a44e78877ab3", OrganizationId = "1ecd0826e447ad1e78877777" } }; // Act @@ -91,9 +91,9 @@ public void MapToViewTokens_WithMultipleTokens_MapsAll() // Assert Assert.Equal(3, result.Count); - Assert.Equal("token1", result[0].Id); - Assert.Equal("token2", result[1].Id); - Assert.Equal("token3", result[2].Id); + Assert.Equal("88cd0826e447a44e78877ab1", result[0].Id); + Assert.Equal("88cd0826e447a44e78877ab2", result[1].Id); + Assert.Equal("88cd0826e447a44e78877ab3", result[2].Id); } [Fact] diff --git a/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs index 88e6de08c4..2ccf1283cc 100644 --- a/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs @@ -19,9 +19,9 @@ public void MapToViewUser_WithValidUser_MapsAllProperties() // Arrange var source = new User { - Id = "user123", - EmailAddress = "test@example.com", - FullName = "Test User", + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "user@localhost", + FullName = "Eric Smith", IsEmailAddressVerified = true, IsActive = true }; @@ -30,9 +30,9 @@ public void MapToViewUser_WithValidUser_MapsAllProperties() var result = _mapper.MapToViewUser(source); // Assert - Assert.Equal("user123", result.Id); - Assert.Equal("test@example.com", result.EmailAddress); - Assert.Equal("Test User", result.FullName); + Assert.Equal("1ecd0826e447ad1e78822555", result.Id); + Assert.Equal("user@localhost", result.EmailAddress); + Assert.Equal("Eric Smith", result.FullName); Assert.True(result.IsEmailAddressVerified); Assert.True(result.IsActive); } @@ -43,8 +43,8 @@ public void MapToViewUser_WithRoles_MapsRoles() // Arrange var source = new User { - Id = "user123", - EmailAddress = "admin@example.com", + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "admin@localhost", Roles = new HashSet { "user", "admin" } }; @@ -62,9 +62,9 @@ public void MapToViewUser_WithOrganizationIds_MapsOrganizationIds() // Arrange var source = new User { - Id = "user123", - EmailAddress = "user@example.com", - OrganizationIds = new HashSet { "org1", "org2", "org3" } + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "user@localhost", + OrganizationIds = new HashSet { "537650f3b77efe23a47914f3", "1ecd0826e447ad1e78877666", "1ecd0826e447ad1e78877777" } }; // Act @@ -72,9 +72,9 @@ public void MapToViewUser_WithOrganizationIds_MapsOrganizationIds() // Assert Assert.Equal(3, result.OrganizationIds.Count); - Assert.Contains("org1", result.OrganizationIds); - Assert.Contains("org2", result.OrganizationIds); - Assert.Contains("org3", result.OrganizationIds); + Assert.Contains("537650f3b77efe23a47914f3", result.OrganizationIds); + Assert.Contains("1ecd0826e447ad1e78877666", result.OrganizationIds); + Assert.Contains("1ecd0826e447ad1e78877777", result.OrganizationIds); } [Fact] @@ -83,8 +83,8 @@ public void MapToViewUsers_WithMultipleUsers_MapsAll() // Arrange var users = new List { - new() { Id = "user1", EmailAddress = "user1@example.com" }, - new() { Id = "user2", EmailAddress = "user2@example.com" } + new() { Id = "1ecd0826e447ad1e78822555", EmailAddress = "user1@localhost" }, + new() { Id = "1ecd0826e447ad1e78822666", EmailAddress = "user2@localhost" } }; // Act @@ -92,8 +92,8 @@ public void MapToViewUsers_WithMultipleUsers_MapsAll() // Assert Assert.Equal(2, result.Count); - Assert.Equal("user1", result[0].Id); - Assert.Equal("user2", result[1].Id); + Assert.Equal("1ecd0826e447ad1e78822555", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78822666", result[1].Id); } [Fact] diff --git a/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs b/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs index 7536290212..0deff4747d 100644 --- a/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs @@ -20,9 +20,9 @@ public void MapToWebHook_WithValidNewWebHook_MapsAllProperties() // Arrange var source = new NewWebHook { - OrganizationId = "org123", - ProjectId = "proj123", - Url = "https://example.com/webhook", + OrganizationId = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + Url = "https://localhost/webhook", EventTypes = ["error", "log"] }; @@ -30,9 +30,9 @@ public void MapToWebHook_WithValidNewWebHook_MapsAllProperties() var result = _mapper.MapToWebHook(source); // Assert - Assert.Equal("org123", result.OrganizationId); - Assert.Equal("proj123", result.ProjectId); - Assert.Equal("https://example.com/webhook", result.Url); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f4", result.ProjectId); + Assert.Equal("https://localhost/webhook", result.Url); Assert.Contains("error", result.EventTypes); Assert.Contains("log", result.EventTypes); } @@ -43,17 +43,17 @@ public void MapToWebHook_WithNullProjectId_MapsWithNullProjectId() // Arrange var source = new NewWebHook { - OrganizationId = "org123", - Url = "https://example.com/webhook" + OrganizationId = "537650f3b77efe23a47914f3", + Url = "https://localhost/webhook" }; // Act var result = _mapper.MapToWebHook(source); // Assert - Assert.Equal("org123", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); Assert.Null(result.ProjectId); - Assert.Equal("https://example.com/webhook", result.Url); + Assert.Equal("https://localhost/webhook", result.Url); } [Fact] @@ -62,8 +62,8 @@ public void MapToWebHook_WithEmptyEventTypes_MapsEmptyEventTypes() // Arrange var source = new NewWebHook { - OrganizationId = "org123", - Url = "https://example.com/webhook", + OrganizationId = "537650f3b77efe23a47914f3", + Url = "https://localhost/webhook", EventTypes = [] }; From dbac90b35cb566597a6517293455e50c2d950c26 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 15 Mar 2026 21:30:30 -0500 Subject: [PATCH 3/6] fix: align serialization input with localhost domain and update stale AutoMapper comments The event-serialization-input.json still used test.example.com while the response baseline was updated to test.localhost, causing CI failure. Also updated stale AutoMapper references in test comments to reflect Mapperly migration. --- .../Controllers/Data/event-serialization-input.json | 2 +- .../Controllers/OrganizationControllerTests.cs | 2 +- tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-input.json b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-input.json index a9bfac892d..9e16ff0d4c 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-input.json +++ b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-input.json @@ -22,7 +22,7 @@ "http_method": "POST", "user_agent": "TestAgent/1.0", "is_secure": true, - "host": "test.example.com", + "host": "test.localhost", "path": "/api/test", "port": 443 } diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index d8d7379b68..2e8b23ce73 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -155,7 +155,7 @@ public async Task GetAsync_ViewOrganization_IncludesIsOverMonthlyLimit() .StatusCodeShouldBeOk() ); - // Assert - IsOverMonthlyLimit is computed by AfterMap in AutoMapper + // Assert - IsOverMonthlyLimit is computed by OrganizationMapper Assert.NotNull(viewOrg); // The value can be true or false depending on usage, but the property should be set Assert.IsType(viewOrg.IsOverMonthlyLimit); diff --git a/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs b/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs index a930108b58..83a9828ef0 100644 --- a/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs @@ -27,7 +27,7 @@ protected override async Task ResetDataAsync() [Fact] public async Task PostAsync_NewWebHook_MapsAllPropertiesToWebHook() { - // Arrange - Test AutoMapper: NewWebHook -> WebHook + // Arrange - Test Mapperly: NewWebHook -> WebHook var newWebHook = new NewWebHook { EventTypes = [WebHook.KnownEventTypes.StackPromoted, WebHook.KnownEventTypes.NewError], From a398a8f5795fdd896355632d73dcd3d32413d3b6 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 15 Mar 2026 21:40:55 -0500 Subject: [PATCH 4/6] Apply suggestion from @niemyjski --- src/Exceptionless.Core/Services/StackService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Exceptionless.Core/Services/StackService.cs b/src/Exceptionless.Core/Services/StackService.cs index cb7d8124fa..84671040ea 100644 --- a/src/Exceptionless.Core/Services/StackService.cs +++ b/src/Exceptionless.Core/Services/StackService.cs @@ -7,7 +7,6 @@ namespace Exceptionless.Core.Services; /// /// Identifies a stack for deferred usage counter updates. -/// Records serialize correctly with STJ unlike ValueTuples which serialize to {}. /// public record StackUsageKey(string OrganizationId, string ProjectId, string StackId); From 058ae392d34be98df6b90aa8af21b66f15adeb13 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 15 Mar 2026 21:46:52 -0500 Subject: [PATCH 5/6] fix: deep-copy Roles and OrganizationIds in UserMapper Mapperly does shallow reference assignments for collection properties, which means mutating ViewUser.Roles (e.g. removing GlobalAdmin in UserController) would also mutate the source User model. Fixed by using a core mapper plus manual deep-copy via HashSet constructor. Added regression test. --- src/Exceptionless.Web/Mapping/UserMapper.cs | 20 +++++++++++++++++-- .../Mapping/UserMapperTests.cs | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/Mapping/UserMapper.cs b/src/Exceptionless.Web/Mapping/UserMapper.cs index 76b946148b..272121897a 100644 --- a/src/Exceptionless.Web/Mapping/UserMapper.cs +++ b/src/Exceptionless.Web/Mapping/UserMapper.cs @@ -8,12 +8,28 @@ namespace Exceptionless.Web.Mapping; /// Mapperly-based mapper for User types. /// Uses RequiredMappingStrategy.Target so new ViewUser properties /// produce compile warnings unless explicitly mapped or ignored. +/// Deep-copies collection properties (Roles, OrganizationIds) to prevent +/// controller-side mutations from affecting the source User model. /// [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] public partial class UserMapper { [MapperIgnoreTarget(nameof(ViewUser.IsInvite))] - public partial ViewUser MapToViewUser(User source); + [MapperIgnoreTarget(nameof(ViewUser.Roles))] + [MapperIgnoreTarget(nameof(ViewUser.OrganizationIds))] + private partial ViewUser MapToViewUserCore(User source); - public partial List MapToViewUsers(IEnumerable source); + public ViewUser MapToViewUser(User source) + { + var result = MapToViewUserCore(source); + result = result with + { + Roles = new HashSet(source.Roles), + OrganizationIds = new HashSet(source.OrganizationIds) + }; + return result; + } + + public List MapToViewUsers(IEnumerable source) + => source.Select(MapToViewUser).ToList(); } diff --git a/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs index 2ccf1283cc..3e0ef718aa 100644 --- a/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs @@ -108,4 +108,24 @@ public void MapToViewUsers_WithEmptyList_ReturnsEmptyList() // Assert Assert.Empty(result); } + + [Fact] + public void MapToViewUser_MutatingRoles_DoesNotAffectSource() + { + // Arrange + var source = new User + { + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "admin@localhost", + Roles = new HashSet { "user", "admin", "global" } + }; + + // Act + var result = _mapper.MapToViewUser(source); + result.Roles.Remove("global"); + + // Assert — source User.Roles is unaffected + Assert.Contains("global", source.Roles); + Assert.DoesNotContain("global", result.Roles); + } } From bc9b5aa13ee000c7c3853945cae713d7dca59a6b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 15 Mar 2026 21:52:37 -0500 Subject: [PATCH 6/6] refactor: clarify TokenMapper Type test name and comment NewToken has no Type property, so the test was really just asserting the C# enum default (Authentication=0). Rename the test and update the comment to explain: NewToken lacks Type, [MapperIgnoreTarget] makes the intent explicit, and the controller sets Type=Access in AddModelAsync. --- tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs index 0ddef59499..6ec4059a50 100644 --- a/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs @@ -35,7 +35,7 @@ public void MapToToken_WithValidNewToken_MapsOrganizationIdAndProjectId() } [Fact] - public void MapToToken_WithNewToken_DoesNotSetTokenType() + public void MapToToken_TypeNotCarriedFromNewToken_DefaultsToAuthenticationEnumZero() { // Arrange var source = new NewToken @@ -46,7 +46,9 @@ public void MapToToken_WithNewToken_DoesNotSetTokenType() // Act var result = _mapper.MapToToken(source); - // Assert - TokenType is ignored in mapping, so it defaults to Authentication + // Assert - NewToken has no Type property, and TokenMapper explicitly ignores Token.Type + // via [MapperIgnoreTarget], so it stays at the C# enum default (Authentication = 0). + // The controller sets Type = TokenType.Access in AddModelAsync after mapping. Assert.Equal(TokenType.Authentication, result.Type); }