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.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..272121897a --- /dev/null +++ b/src/Exceptionless.Web/Mapping/UserMapper.cs @@ -0,0 +1,35 @@ +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. +/// 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))] + [MapperIgnoreTarget(nameof(ViewUser.Roles))] + [MapperIgnoreTarget(nameof(ViewUser.OrganizationIds))] + private partial ViewUser MapToViewUserCore(User 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/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-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/Data/event-serialization-response.json b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json index b1a558af13..9d89be4cef 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.localhost", + "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/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/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], diff --git a/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs new file mode 100644 index 0000000000..b5822934b6 --- /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_5f8a3b2c1d4e", + 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..7875ab22be --- /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 = "537650f3b77efe23a47914f3", + Name = "Acme Organization", + PlanId = "free", + IsSuspended = false + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.Id); + Assert.Equal("Acme Organization", result.Name); + Assert.Equal("free", result.PlanId); + Assert.False(result.IsSuspended); + } + + [Fact] + public void MapToViewOrganization_WithSuspendedOrganization_MapsIsSuspended() + { + // Arrange + var source = new Organization + { + Id = "537650f3b77efe23a47914f3", + Name = "Suspended Organization", + 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 = "537650f3b77efe23a47914f3", + Name = "Suspended Organization", + 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 = "537650f3b77efe23a47914f3", + Name = "Active Organization", + 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 = "537650f3b77efe23a47914f3", Name = "Organization 1" }, + new() { Id = "1ecd0826e447ad1e78877666", Name = "Organization 2" }, + new() { Id = "1ecd0826e447ad1e78877777", Name = "Organization 3" } + }; + + // Act + var result = _mapper.MapToViewOrganizations(organizations); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("537650f3b77efe23a47914f3", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78877666", result[1].Id); + Assert.Equal("1ecd0826e447ad1e78877777", 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..6e2130946a --- /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 = "Disintegrating Pistol", + OrganizationId = "537650f3b77efe23a47914f3" + }; + + // Act + var result = _mapper.MapToProject(source); + + // Assert + Assert.Equal("Disintegrating Pistol", result.Name); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + } + + [Fact] + public void MapToViewProject_WithValidProject_MapsAllProperties() + { + // Arrange + var source = new Project + { + Id = "537650f3b77efe23a47914f4", + Name = "Disintegrating Pistol", + OrganizationId = "537650f3b77efe23a47914f3", + DeleteBotDataEnabled = true + }; + + // Act + var result = _mapper.MapToViewProject(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f4", result.Id); + Assert.Equal("Disintegrating Pistol", result.Name); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.True(result.DeleteBotDataEnabled); + } + + [Fact] + public void MapToViewProject_WithSlackToken_SetsHasSlackIntegration() + { + // Arrange + var source = new Project + { + Id = "537650f3b77efe23a47914f4", + 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 = "537650f3b77efe23a47914f4", + 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 = "537650f3b77efe23a47914f4", Name = "Project 1" }, + new() { Id = "1ecd0826e447ad1e78877a66", Name = "Project 2" } + }; + + // Act + var result = _mapper.MapToViewProjects(projects); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("537650f3b77efe23a47914f4", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78877a66", 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..6ec4059a50 --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs @@ -0,0 +1,113 @@ +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 = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + Notes = "API access token" + }; + + // Act + var result = _mapper.MapToToken(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f4", result.ProjectId); + Assert.Equal("API access token", result.Notes); + } + + [Fact] + public void MapToToken_TypeNotCarriedFromNewToken_DefaultsToAuthenticationEnumZero() + { + // Arrange + var source = new NewToken + { + OrganizationId = "537650f3b77efe23a47914f3" + }; + + // Act + var result = _mapper.MapToToken(source); + + // 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); + } + + [Fact] + public void MapToViewToken_WithValidToken_MapsAllProperties() + { + // Arrange + var source = new Token + { + Id = "88cd0826e447a44e78877ab1", + OrganizationId = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + UserId = "1ecd0826e447ad1e78822555", + Notes = "Access token notes", + Type = TokenType.Access + }; + + // Act + var result = _mapper.MapToViewToken(source); + + // Assert + 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] + public void MapToViewTokens_WithMultipleTokens_MapsAll() + { + // Arrange + var tokens = new List + { + new() { Id = "88cd0826e447a44e78877ab1", OrganizationId = "537650f3b77efe23a47914f3" }, + new() { Id = "88cd0826e447a44e78877ab2", OrganizationId = "1ecd0826e447ad1e78877666" }, + new() { Id = "88cd0826e447a44e78877ab3", OrganizationId = "1ecd0826e447ad1e78877777" } + }; + + // Act + var result = _mapper.MapToViewTokens(tokens); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("88cd0826e447a44e78877ab1", result[0].Id); + Assert.Equal("88cd0826e447a44e78877ab2", result[1].Id); + Assert.Equal("88cd0826e447a44e78877ab3", 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..3e0ef718aa --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs @@ -0,0 +1,131 @@ +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 = "1ecd0826e447ad1e78822555", + EmailAddress = "user@localhost", + FullName = "Eric Smith", + IsEmailAddressVerified = true, + IsActive = true + }; + + // Act + var result = _mapper.MapToViewUser(source); + + // Assert + 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); + } + + [Fact] + public void MapToViewUser_WithRoles_MapsRoles() + { + // Arrange + var source = new User + { + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "admin@localhost", + 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 = "1ecd0826e447ad1e78822555", + EmailAddress = "user@localhost", + OrganizationIds = new HashSet { "537650f3b77efe23a47914f3", "1ecd0826e447ad1e78877666", "1ecd0826e447ad1e78877777" } + }; + + // Act + var result = _mapper.MapToViewUser(source); + + // Assert + Assert.Equal(3, result.OrganizationIds.Count); + Assert.Contains("537650f3b77efe23a47914f3", result.OrganizationIds); + Assert.Contains("1ecd0826e447ad1e78877666", result.OrganizationIds); + Assert.Contains("1ecd0826e447ad1e78877777", result.OrganizationIds); + } + + [Fact] + public void MapToViewUsers_WithMultipleUsers_MapsAll() + { + // Arrange + var users = new List + { + new() { Id = "1ecd0826e447ad1e78822555", EmailAddress = "user1@localhost" }, + new() { Id = "1ecd0826e447ad1e78822666", EmailAddress = "user2@localhost" } + }; + + // Act + var result = _mapper.MapToViewUsers(users); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("1ecd0826e447ad1e78822555", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78822666", result[1].Id); + } + + [Fact] + public void MapToViewUsers_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var users = new List(); + + // Act + var result = _mapper.MapToViewUsers(users); + + // 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); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs b/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs new file mode 100644 index 0000000000..0deff4747d --- /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 = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + Url = "https://localhost/webhook", + EventTypes = ["error", "log"] + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + 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); + } + + [Fact] + public void MapToWebHook_WithNullProjectId_MapsWithNullProjectId() + { + // Arrange + var source = new NewWebHook + { + OrganizationId = "537650f3b77efe23a47914f3", + Url = "https://localhost/webhook" + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Null(result.ProjectId); + Assert.Equal("https://localhost/webhook", result.Url); + } + + [Fact] + public void MapToWebHook_WithEmptyEventTypes_MapsEmptyEventTypes() + { + // Arrange + var source = new NewWebHook + { + OrganizationId = "537650f3b77efe23a47914f3", + Url = "https://localhost/webhook", + EventTypes = [] + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + Assert.Empty(result.EventTypes); + } +}