diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index c2016d7f70..61607590c3 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; @@ -198,21 +197,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/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 a8c8d48c7e..b78be2aa53 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 4d2493e90a..4cf9be0e0c 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 3571be3493..06f59681ab 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/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); + } +}