diff --git a/.idea/.idea.Fin-Backend/.idea/dataSources.xml b/.idea/.idea.Fin-Backend/.idea/dataSources.xml index cc31fb4..9ed83d1 100644 --- a/.idea/.idea.Fin-Backend/.idea/dataSources.xml +++ b/.idea/.idea.Fin-Backend/.idea/dataSources.xml @@ -6,6 +6,11 @@ true org.postgresql.Driver jdbc:postgresql://localhost:5432/postgres + + + + + $ProjectFileDir$ diff --git a/.idea/.idea.Fin-Backend/.idea/data_source_mapping.xml b/.idea/.idea.Fin-Backend/.idea/data_source_mapping.xml new file mode 100644 index 0000000..c970994 --- /dev/null +++ b/.idea/.idea.Fin-Backend/.idea/data_source_mapping.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Fin-Backend.sln.DotSettings.user b/Fin-Backend.sln.DotSettings.user index 1d58c68..ca2fb04 100644 --- a/Fin-Backend.sln.DotSettings.user +++ b/Fin-Backend.sln.DotSettings.user @@ -1,9 +1,10 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> + ForceIncluded + + \ No newline at end of file diff --git a/Fin.Api/FinancialInstitutions/FinancialInstitutionController.cs b/Fin.Api/FinancialInstitutions/FinancialInstitutionController.cs index 1b4c205..b08f72a 100644 --- a/Fin.Api/FinancialInstitutions/FinancialInstitutionController.cs +++ b/Fin.Api/FinancialInstitutions/FinancialInstitutionController.cs @@ -1,6 +1,8 @@ using Fin.Application.FinancialInstitutions; +using Fin.Application.FinancialInstitutions.Dtos; using Fin.Domain.FinancialInstitutions.Dtos; using Fin.Domain.Global.Classes; +using Fin.Infrastructure.Authentications.Constants; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,6 +26,7 @@ public async Task> Get([FromRoute] Guid } [HttpPost] + [Authorize(Roles = AuthenticationRoles.Admin)] public async Task> Create([FromBody] FinancialInstitutionInput input) { var institution = await service.Create(input, autoSave: true); @@ -31,6 +34,7 @@ public async Task> Create([FromBody] Fi } [HttpPut("{id:guid}")] + [Authorize(Roles = AuthenticationRoles.Admin)] public async Task Update([FromRoute] Guid id, [FromBody] FinancialInstitutionInput input) { var updated = await service.Update(id, input, autoSave: true); @@ -38,6 +42,7 @@ public async Task Update([FromRoute] Guid id, [FromBody] Financial } [HttpDelete("{id:guid}")] + [Authorize(Roles = AuthenticationRoles.Admin)] public async Task Delete([FromRoute] Guid id) { var deleted = await service.Delete(id, autoSave: true); @@ -45,6 +50,7 @@ public async Task Delete([FromRoute] Guid id) } [HttpPatch("{id:guid}/toggle-inactive")] + [Authorize(Roles = AuthenticationRoles.Admin)] public async Task ToggleInactive([FromRoute] Guid id) { var toggled = await service.ToggleInactive(id, autoSave: true); diff --git a/Fin.Api/Program.cs b/Fin.Api/Program.cs index ec44e2a..d74c9bb 100644 --- a/Fin.Api/Program.cs +++ b/Fin.Api/Program.cs @@ -4,6 +4,7 @@ using Fin.Infrastructure.Extensions; using Fin.Infrastructure.Seeders.Extensions; using Hangfire; +using NSwag; var builder = WebApplication.CreateBuilder(args); @@ -11,7 +12,18 @@ builder.Services .AddInfrastructure(builder.Configuration) - .AddOpenApiDocument() + .AddOpenApiDocument(config => + { + config.Title = "FinApp API"; + config.Version = "v1"; + + config.AddSecurity("Bearer", [], new OpenApiSecurityScheme + { + Type = OpenApiSecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + }); + }) .AddCors(options => { options.AddPolicy("AllowAngularLocalhost", diff --git a/Fin.Api/TitleCategories/TitleCategoryController.cs b/Fin.Api/TitleCategories/TitleCategoryController.cs index 3a66689..e27e322 100644 --- a/Fin.Api/TitleCategories/TitleCategoryController.cs +++ b/Fin.Api/TitleCategories/TitleCategoryController.cs @@ -1,5 +1,6 @@ using Fin.Application.TitleCategories; using Fin.Application.TitleCategories.Dtos; +using Fin.Application.TitleCategories.Enums; using Fin.Domain.Global.Classes; using Fin.Domain.TitleCategories.Dtos; using Microsoft.AspNetCore.Authorization; @@ -9,46 +10,52 @@ namespace Fin.Api.TitleCategories; [Route("title-categories")] [Authorize] -public class TitleCategoryController(ITitleCategoryService service): ControllerBase +public class TitleCategoryController(ITitleCategoryService service) : ControllerBase { [HttpGet] public async Task> GetList([FromQuery] TitleCategoryGetListInput input) { return await service.GetList(input); } - + [HttpGet("{id:guid}")] public async Task> Get([FromRoute] Guid id) { var category = await service.Get(id); - return category != null ? Ok(category) : NotFound(); + return category != null ? Ok(category) : NotFound(); } - + [HttpPost] public async Task> Create([FromBody] TitleCategoryInput input) { - var category = await service.Create(input, autoSave: true); - return category != null ? Created($"categories/{category.Id}", category) : UnprocessableEntity(); + var validationResult = await service.Create(input, autoSave: true); + return validationResult.Success + ? Created($"categories/{validationResult.Data?.Id}", validationResult.Data) + : UnprocessableEntity(validationResult); } - + [HttpPut("{id:guid}")] public async Task Update([FromRoute] Guid id, [FromBody] TitleCategoryInput input) { - var updated = await service.Update(id, input, autoSave: true); - return updated ? Ok() : UnprocessableEntity(); + var validationResult = await service.Update(id, input, autoSave: true); + return validationResult.Success + ? Ok() + : validationResult.ErrorCode == TitleCategoryCreateOrUpdateErrorCode.TitleCategoryNotFound + ? NotFound(validationResult) + : UnprocessableEntity(validationResult); } - + [HttpPut("toggle-inactivated/{id:guid}")] public async Task ToggleInactivated([FromRoute] Guid id) { var updated = await service.ToggleInactive(id, autoSave: true); - return updated ? Ok() : UnprocessableEntity(); + return updated ? Ok() : NotFound(); } - + [HttpDelete("{id:guid}")] public async Task Delete([FromRoute] Guid id) { var deleted = await service.Delete(id, autoSave: true); - return deleted ? Ok() : UnprocessableEntity(); + return deleted ? Ok() : NotFound(); } } \ No newline at end of file diff --git a/Fin.Api/Wallets/WalletController.cs b/Fin.Api/Wallets/WalletController.cs new file mode 100644 index 0000000..9cb941c --- /dev/null +++ b/Fin.Api/Wallets/WalletController.cs @@ -0,0 +1,63 @@ +using Fin.Application.Wallets.Dtos; +using Fin.Application.Wallets.Enums; +using Fin.Application.Wallets.Services; +using Fin.Domain.Global.Classes; +using Fin.Domain.Wallets.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Fin.Api.Wallets; + +[Route("wallets")] +[Authorize] +public class WalletController(IWalletService service) : ControllerBase +{ + [HttpGet] + public async Task> GetList([FromQuery] WalletGetListInput input) + { + return await service.GetList(input); + } + + [HttpGet("{id:guid}")] + public async Task> Get([FromRoute] Guid id) + { + var category = await service.Get(id); + return category != null ? Ok(category) : NotFound(); + } + + [HttpPost] + public async Task> Create([FromBody] WalletInput input) + { + var validationResult = await service.Create(input, autoSave: true); + return validationResult.Success + ? Created($"categories/{validationResult.Data?.Id}", validationResult.Data) + : UnprocessableEntity(validationResult); + } + + [HttpPut("{id:guid}")] + public async Task Update([FromRoute] Guid id, [FromBody] WalletInput input) + { + var validationResult = await service.Update(id, input, autoSave: true); + return validationResult.Success ? Ok() : + validationResult.ErrorCode == WalletCreateOrUpdateErrorCode.WalletNotFound ? NotFound(validationResult) : + UnprocessableEntity(validationResult); + } + + [HttpPut("toggle-inactivated/{id:guid}")] + public async Task ToggleInactivated([FromRoute] Guid id) + { + var validationResult = await service.ToggleInactive(id, autoSave: true); + return validationResult.Success ? Ok() : + validationResult.ErrorCode == WalletToggleInactiveErrorCode.WalletNotFound ? NotFound(validationResult) : + UnprocessableEntity(validationResult); + } + + [HttpDelete("{id:guid}")] + public async Task Delete([FromRoute] Guid id) + { + var validationResult = await service.Delete(id, autoSave: true); + return validationResult.Success ? Ok() : + validationResult.ErrorCode == WalletDeleteErrorCode.WalletNotFound ? NotFound(validationResult) : + UnprocessableEntity(validationResult); + } +} \ No newline at end of file diff --git a/Fin.Application/FinancialInstitutions/Dtos/FinancialInstitutionGetListInput.cs b/Fin.Application/FinancialInstitutions/Dtos/FinancialInstitutionGetListInput.cs index 7bfb950..acec3c3 100644 --- a/Fin.Application/FinancialInstitutions/Dtos/FinancialInstitutionGetListInput.cs +++ b/Fin.Application/FinancialInstitutions/Dtos/FinancialInstitutionGetListInput.cs @@ -1,6 +1,8 @@ using Fin.Domain.FinancialInstitutions.Enums; using Fin.Domain.Global.Classes; +namespace Fin.Application.FinancialInstitutions.Dtos; + public class FinancialInstitutionGetListInput : PagedFilteredAndSortedInput { public bool? Inactive { get; set; } diff --git a/Fin.Application/FinancialInstitutions/FinancialInstitutionService.cs b/Fin.Application/FinancialInstitutions/FinancialInstitutionService.cs index d00dfb4..4c26dda 100644 --- a/Fin.Application/FinancialInstitutions/FinancialInstitutionService.cs +++ b/Fin.Application/FinancialInstitutions/FinancialInstitutionService.cs @@ -1,3 +1,4 @@ +using Fin.Application.FinancialInstitutions.Dtos; using Fin.Domain.FinancialInstitutions.Dtos; using Fin.Domain.FinancialInstitutions.Entities; using Fin.Domain.Global.Classes; @@ -12,12 +13,12 @@ namespace Fin.Application.FinancialInstitutions; public interface IFinancialInstitutionService { - Task Get(Guid id); - Task> GetList(FinancialInstitutionGetListInput input); - Task Create(FinancialInstitutionInput input, bool autoSave = false); - Task Update(Guid id, FinancialInstitutionInput input, bool autoSave = false); - Task Delete(Guid id, bool autoSave = false); - Task ToggleInactive(Guid id, bool autoSave = false); + public Task Get(Guid id); + public Task> GetList(FinancialInstitutionGetListInput input); + public Task Create(FinancialInstitutionInput input, bool autoSave = false); + public Task Update(Guid id, FinancialInstitutionInput input, bool autoSave = false); + public Task Delete(Guid id, bool autoSave = false); + public Task ToggleInactive(Guid id, bool autoSave = false); } public class FinancialInstitutionService( @@ -70,9 +71,10 @@ public async Task Update(Guid id, FinancialInstitutionInput input, bool au public async Task Delete(Guid id, bool autoSave = false) { var institution = await repository.Query() + .Include(f => f.Wallets) .FirstOrDefaultAsync(f => f.Id == id); - if (institution == null) return false; - + if (institution == null || institution.Wallets.Any()) return false; + await repository.DeleteAsync(institution, autoSave); return true; } @@ -80,8 +82,9 @@ public async Task Delete(Guid id, bool autoSave = false) public async Task ToggleInactive(Guid id, bool autoSave = false) { var institution = await repository.Query() + .Include(f => f.Wallets) .FirstOrDefaultAsync(f => f.Id == id); - if (institution == null) return false; + if (institution == null || (!institution.Inactive && institution.Wallets.Any(w => !w.Inactivated))) return false; institution.ToggleInactive(); diff --git a/Fin.Application/Globals/Dtos/ValidationResultDto.cs b/Fin.Application/Globals/Dtos/ValidationResultDto.cs index 722c422..388284f 100644 --- a/Fin.Application/Globals/Dtos/ValidationResultDto.cs +++ b/Fin.Application/Globals/Dtos/ValidationResultDto.cs @@ -8,10 +8,11 @@ public class ValidationResultDto public E? ErrorCode { get; set; } } -public class ValidationResultDto +public class ValidationResultDto { public D? Data { get; set; } public string Message { get; set; } public bool Success { get; set; } public Enum? ErrorCode { get; set; } } + diff --git a/Fin.Application/Menus/MenuService.cs b/Fin.Application/Menus/MenuService.cs index 81997b4..23426df 100644 --- a/Fin.Application/Menus/MenuService.cs +++ b/Fin.Application/Menus/MenuService.cs @@ -55,7 +55,7 @@ public async Task> GetListForSideNav() public async Task Create(MenuInput input, bool autoSave = false) { - ValidarInput(input); + ValidateInput(input); var menu = new Menu(input); await repository.AddAsync(menu, autoSave); return new MenuOutput(menu); @@ -63,7 +63,7 @@ public async Task Create(MenuInput input, bool autoSave = false) public async Task Update(Guid id, MenuInput input, bool autoSave = false) { - ValidarInput(input); + ValidateInput(input); var menu = await repository.Query() .FirstOrDefaultAsync(u => u.Id == id); if (menu == null) return false; @@ -84,7 +84,7 @@ public async Task Delete(Guid id, bool autoSave = false) return true; } - private static void ValidarInput( MenuInput input) + private static void ValidateInput( MenuInput input) { if (string.IsNullOrWhiteSpace(input.FrontRoute)) throw new BadHttpRequestException("FrontRoute is required"); diff --git a/Fin.Application/TitleCategories/Enums/TitleCategoryCreateOrUpdateErrorCode.cs b/Fin.Application/TitleCategories/Enums/TitleCategoryCreateOrUpdateErrorCode.cs new file mode 100644 index 0000000..3a427c9 --- /dev/null +++ b/Fin.Application/TitleCategories/Enums/TitleCategoryCreateOrUpdateErrorCode.cs @@ -0,0 +1,13 @@ +namespace Fin.Application.TitleCategories.Enums; + +public enum TitleCategoryCreateOrUpdateErrorCode +{ + NameIsRequired = 0, + NameAlreadyInUse = 1, + NameTooLong = 2, + ColorIsRequired = 3, + ColorTooLong = 4, + IconIsRequired = 5, + IconTooLong = 6, + TitleCategoryNotFound = 7 +} \ No newline at end of file diff --git a/Fin.Application/TitleCategories/TitleCategoryService.cs b/Fin.Application/TitleCategories/TitleCategoryService.cs index d32ffff..3903504 100644 --- a/Fin.Application/TitleCategories/TitleCategoryService.cs +++ b/Fin.Application/TitleCategories/TitleCategoryService.cs @@ -1,11 +1,12 @@ -using Fin.Application.TitleCategories.Dtos; +using Fin.Application.Globals.Dtos; +using Fin.Application.TitleCategories.Dtos; +using Fin.Application.TitleCategories.Enums; using Fin.Domain.Global.Classes; using Fin.Domain.TitleCategories.Dtos; using Fin.Domain.TitleCategories.Entities; using Fin.Infrastructure.AutoServices.Interfaces; using Fin.Infrastructure.Database.Extensions; using Fin.Infrastructure.Database.Repositories; -using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; namespace Fin.Application.TitleCategories; @@ -14,8 +15,8 @@ public interface ITitleCategoryService { public Task Get(Guid id); public Task> GetList(TitleCategoryGetListInput input); - public Task Create(TitleCategoryInput input, bool autoSave = false); - public Task Update(Guid id, TitleCategoryInput input, bool autoSave = false); + public Task> Create(TitleCategoryInput input, bool autoSave = false); + public Task> Update(Guid id, TitleCategoryInput input, bool autoSave = false); public Task Delete(Guid id, bool autoSave = false); public Task ToggleInactive(Guid id, bool autoSave = false); } @@ -42,25 +43,28 @@ public async Task> GetList(TitleCategoryGetList .ToPagedResult(input); } - public async Task Create(TitleCategoryInput input, bool autoSave = false) + public async Task> Create(TitleCategoryInput input, bool autoSave = false) { - ValidarInput(input); + var validation = await ValidateInput(input); + if (!validation.Success) return validation; + var titleCategory = new TitleCategory(input); await repository.AddAsync(titleCategory, autoSave); - return new TitleCategoryOutput(titleCategory); + validation.Data = new TitleCategoryOutput(titleCategory); + return validation; } - public async Task Update(Guid id, TitleCategoryInput input, bool autoSave = false) + public async Task> Update(Guid id, TitleCategoryInput input, bool autoSave = false) { - ValidarInput(input); - var titleCategory = await repository.Query() - .FirstOrDefaultAsync(u => u.Id == id); - if (titleCategory == null) return false; - + var validation = await ValidateInput(input, id); + if (!validation.Success) return validation; + + var titleCategory = await repository.Query().FirstAsync(u => u.Id == id); titleCategory.Update(input); await repository.UpdateAsync(titleCategory, autoSave); - - return true; + + validation.Data = true; + return validation; } public async Task Delete(Guid id, bool autoSave = false) @@ -85,13 +89,70 @@ public async Task ToggleInactive(Guid id, bool autoSave = false) return true; } - private static void ValidarInput( TitleCategoryInput input) + private async Task> ValidateInput( TitleCategoryInput input, Guid? editingId = null) { + var validationResult = new ValidationResultDto(); + + if (editingId.HasValue) + { + var titleExists = await repository.Query() + .AnyAsync(n => n.Id == editingId.Value); + if (!titleExists) + { + validationResult.ErrorCode = TitleCategoryCreateOrUpdateErrorCode.TitleCategoryNotFound; + validationResult.Message = "Title category not found to edit."; + return validationResult; + } + } + if (string.IsNullOrWhiteSpace(input.Color)) - throw new BadHttpRequestException("FrontRoute is required"); - if (string.IsNullOrWhiteSpace(input.Name)) - throw new BadHttpRequestException("Name is required"); + { + validationResult.ErrorCode = TitleCategoryCreateOrUpdateErrorCode.ColorIsRequired; + validationResult.Message = "Color is required."; + return validationResult; + } + if (input.Color.Length > 20) + { + validationResult.ErrorCode = TitleCategoryCreateOrUpdateErrorCode.ColorTooLong; + validationResult.Message = "Color is too long. Max 20 characters."; + return validationResult; + } + if (string.IsNullOrWhiteSpace(input.Icon)) - throw new BadHttpRequestException("Icon is required"); + { + validationResult.ErrorCode = TitleCategoryCreateOrUpdateErrorCode.IconIsRequired; + validationResult.Message = "Icon is required."; + return validationResult; + } + if (input.Icon.Length > 20) + { + validationResult.ErrorCode = TitleCategoryCreateOrUpdateErrorCode.IconTooLong; + validationResult.Message = "Icon is too long. Max 20 characters."; + return validationResult; + } + + if (string.IsNullOrWhiteSpace(input.Name)) + { + validationResult.ErrorCode = TitleCategoryCreateOrUpdateErrorCode.NameIsRequired; + validationResult.Message = "Name is required."; + return validationResult; + } + if (input.Name.Length > 100) + { + validationResult.ErrorCode = TitleCategoryCreateOrUpdateErrorCode.NameTooLong; + validationResult.Message = "Name is too long. Max 100 characters."; + return validationResult; + } + var nameAlredInUse = await repository.Query() + .AnyAsync(n => n.Name == input.Name && (!editingId.HasValue || n.Id != editingId)); + if (nameAlredInUse) + { + validationResult.ErrorCode = TitleCategoryCreateOrUpdateErrorCode.NameAlreadyInUse; + validationResult.Message = "Name is already in use."; + return validationResult; + } + + validationResult.Success = true; + return validationResult; } } \ No newline at end of file diff --git a/Fin.Application/Users/Services/UserCreateService.cs b/Fin.Application/Users/Services/UserCreateService.cs index 77c5c15..3fd654f 100644 --- a/Fin.Application/Users/Services/UserCreateService.cs +++ b/Fin.Application/Users/Services/UserCreateService.cs @@ -9,6 +9,8 @@ using Fin.Domain.Users.Dtos; using Fin.Domain.Users.Entities; using Fin.Domain.Users.Factories; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.Authentications.Constants; using Fin.Infrastructure.AutoServices.Interfaces; using Fin.Infrastructure.Constants; @@ -37,6 +39,7 @@ public class UserCreateService : IUserCreateService, IAutoTransient private readonly IRepository _credentialRepository; private readonly IRepository _userRepository; private readonly IRepository _tenantRepository; + private readonly IRepository _walletRepository; private readonly IRepository _notificationSettingsRepository; private readonly IRepository _userRememberUseSettingRepository; @@ -59,7 +62,7 @@ public UserCreateService( IEmailSenderService emailSender, IConfirmationCodeGenerator codeGenerator, IRepository notificationSettingsRepository, - IRepository userRememberUseSettingRepository, IUnitOfWork unitOfWork) + IRepository userRememberUseSettingRepository, IUnitOfWork unitOfWork, IRepository walletRepository) { _credentialRepository = credentialRepository; _dateTimeProvider = dateTimeProvider; @@ -70,6 +73,7 @@ public UserCreateService( _notificationSettingsRepository = notificationSettingsRepository; _userRememberUseSettingRepository = userRememberUseSettingRepository; _unitOfWork = unitOfWork; + _walletRepository = walletRepository; _tenantRepository = tenantRepository; _userRepository = userRepository; @@ -200,33 +204,7 @@ public async Task> CreateUser(string creationToken, var user = new User(input, now); var credential = UserCredentialFactory.Create(user.Id, process.EncryptedEmail, process.EncryptedPassword, UserCredentialFactoryType.Password); - var tenant = new Tenant(now); - user.Tenants.Add(tenant); - - var notificationSetting = new UserNotificationSettings(user.Id, tenant.Id); - var rememberUseSetting = new UserRememberUseSetting(user.Id, tenant.Id); - - await using (await _unitOfWork.BeginTransactionAsync()) - { - await _tenantRepository.AddAsync(tenant); - await _userRepository.AddAsync(user); - await _credentialRepository.AddAsync(credential); - await _userRememberUseSettingRepository.AddAsync(rememberUseSetting); - await _notificationSettingsRepository.AddAsync(notificationSetting); - await _unitOfWork.CommitAsync(); - } - - await _cache.RemoveAsync(GenerateProcessCacheKey(creationToken)); - - user.Tenants.First().Users = null; - user.Credential.User = null; - - return new ValidationResultDto - { - Success = true, - Data = new UserDto(user), - Message = "Created user" - }; + return await ExecuteCreateUser(creationToken, user, credential); } public async Task> CreateUser(string googleId, string email, UserUpdateOrCreateInput input) @@ -244,15 +222,29 @@ public async Task> CreateUser(string googleId, stri var user = new User(input, now); var credential = UserCredentialFactory.Create(user.Id, encryptedEmail, googleId, UserCredentialFactoryType.Google); - var tenant = new Tenant(now); + return await ExecuteCreateUser(null, user, credential); + } + + private async Task> ExecuteCreateUser(string creationToken, User user, UserCredential credential) + { + var tenant = new Tenant(user.CreatedAt); user.Tenants.Add(tenant); - + var notificationSetting = new UserNotificationSettings(user.Id, tenant.Id); var rememberUseSetting = new UserRememberUseSetting(user.Id, tenant.Id); - + var firstWallet = new Wallet(new WalletInput + { + Color = "#fdc570", + Icon = "wallet", + Name = "Wallet", + InitialBalance = 0 + }); + await using (await _unitOfWork.BeginTransactionAsync()) { await _tenantRepository.AddAsync(tenant); + firstWallet.TenantId = tenant.Id; + await _walletRepository.AddAsync(firstWallet); await _userRepository.AddAsync(user); await _credentialRepository.AddAsync(credential); await _userRememberUseSettingRepository.AddAsync(rememberUseSetting); @@ -260,8 +252,12 @@ public async Task> CreateUser(string googleId, stri await _unitOfWork.CommitAsync(); } + if (!string.IsNullOrWhiteSpace(creationToken)) + await _cache.RemoveAsync(GenerateProcessCacheKey(creationToken)); + user.Tenants.First().Users = null; user.Credential.User = null; + return new ValidationResultDto { Success = true, diff --git a/Fin.Application/Wallets/Dtos/WalletGetListInput.cs b/Fin.Application/Wallets/Dtos/WalletGetListInput.cs new file mode 100644 index 0000000..c02c655 --- /dev/null +++ b/Fin.Application/Wallets/Dtos/WalletGetListInput.cs @@ -0,0 +1,8 @@ +using Fin.Domain.Global.Classes; + +namespace Fin.Application.Wallets.Dtos; + +public class WalletGetListInput: PagedFilteredAndSortedInput +{ + public bool? Inactivated { get; set; } +} \ No newline at end of file diff --git a/Fin.Application/Wallets/Enums/WalletCreateOrUpdateErrorCode.cs b/Fin.Application/Wallets/Enums/WalletCreateOrUpdateErrorCode.cs new file mode 100644 index 0000000..56e59e2 --- /dev/null +++ b/Fin.Application/Wallets/Enums/WalletCreateOrUpdateErrorCode.cs @@ -0,0 +1,15 @@ +namespace Fin.Application.Wallets.Enums; + +public enum WalletCreateOrUpdateErrorCode +{ + NameIsRequired = 0, + NameAlreadyInUse = 1, + NameTooLong = 2, + ColorIsRequired = 3, + ColorTooLong = 4, + IconIsRequired = 5, + IconTooLong = 6, + WalletNotFound = 7, + FinancialInstitutionNotFound = 8, + FinancialInstitutionInactivated = 9 +} \ No newline at end of file diff --git a/Fin.Application/Wallets/Enums/WalletDeleteErrorCode.cs b/Fin.Application/Wallets/Enums/WalletDeleteErrorCode.cs new file mode 100644 index 0000000..e51230b --- /dev/null +++ b/Fin.Application/Wallets/Enums/WalletDeleteErrorCode.cs @@ -0,0 +1,9 @@ +namespace Fin.Application.Wallets.Enums; + +public enum WalletDeleteErrorCode +{ + WalletNotFound = 0, + WalletInUseByTitles = 1, + WalletInUseByCreditCards = 2, + WalletInUseByCreditCardsAndTitle = 3, +} \ No newline at end of file diff --git a/Fin.Application/Wallets/Enums/WalletToggleInactiveErrorCode.cs b/Fin.Application/Wallets/Enums/WalletToggleInactiveErrorCode.cs new file mode 100644 index 0000000..5d34c50 --- /dev/null +++ b/Fin.Application/Wallets/Enums/WalletToggleInactiveErrorCode.cs @@ -0,0 +1,7 @@ +namespace Fin.Application.Wallets.Enums; + +public enum WalletToggleInactiveErrorCode +{ + WalletNotFound = 0, + WalletInUseByActivatedCreditCards = 1 +} \ No newline at end of file diff --git a/Fin.Application/Wallets/Services/WalletService.cs b/Fin.Application/Wallets/Services/WalletService.cs new file mode 100644 index 0000000..5cf93f0 --- /dev/null +++ b/Fin.Application/Wallets/Services/WalletService.cs @@ -0,0 +1,95 @@ +using Fin.Application.Globals.Dtos; +using Fin.Application.Wallets.Dtos; +using Fin.Application.Wallets.Enums; +using Fin.Domain.Global.Classes; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Extensions; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Wallets.Services; + +public interface IWalletService +{ + public Task Get(Guid id); + public Task> GetList(WalletGetListInput input); + public Task> Create(WalletInput input, bool autoSave = false); + public Task> Update(Guid id, WalletInput input, bool autoSave = false); + public Task> Delete(Guid id, bool autoSave = false); + public Task> ToggleInactive(Guid id, bool autoSave = false); +} + +public class WalletService( + IRepository repository, + IWalletValidationService validationService + ) : IWalletService, IAutoTransient +{ + public async Task Get(Guid id) + { + var entity = await repository.Query(false).FirstOrDefaultAsync(n => n.Id == id); + return entity != null ? new WalletOutput(entity) : null; + } + + public async Task> GetList(WalletGetListInput input) + { + return await repository.Query(false) + .WhereIf(input.Inactivated.HasValue, n => n.Inactivated == input.Inactivated.Value) + .OrderBy(m => m.Inactivated) + .ThenBy(m => m.Name) + .ApplyFilterAndSorter(input) + .Select(n => new WalletOutput(n)) + .ToPagedResult(input); + } + + public async Task> Create(WalletInput input, bool autoSave = false) + { + var validation = await validationService.ValidateInput(input); + if (!validation.Success) return validation; + + var wallet = new Wallet(input); + await repository.AddAsync(wallet, autoSave); + validation.Data = new WalletOutput(wallet); + return validation; + } + + public async Task> Update(Guid id, WalletInput input, bool autoSave = false) + { + var validation = await validationService.ValidateInput(input, id); + if (!validation.Success) return validation; + + var wallet = await repository.Query().FirstAsync(u => u.Id == id); + wallet.Update(input); + // TODO reprocesses CurrentBalance + await repository.UpdateAsync(wallet, autoSave); + + validation.Data = true; + return validation; + } + + public async Task> Delete(Guid id, bool autoSave = false) + { + var validation = await validationService.ValidateDelete(id); + if (!validation.Success) return validation; + + var wallet = await repository.Query().FirstAsync(u => u.Id == id); + await repository.DeleteAsync(wallet, autoSave); + + validation.Success = true; + return validation; + } + + public async Task> ToggleInactive(Guid id, bool autoSave = false) + { + var validation = await validationService.ValidateToggleInactive(id); + if (!validation.Success) return validation; + + var wallet = await repository.Query().FirstAsync(u => u.Id == id); + wallet.ToggleInactivated(); + await repository.UpdateAsync(wallet, autoSave); + + validation.Data = true; + return validation; + } +} \ No newline at end of file diff --git a/Fin.Application/Wallets/Services/WalletValidationService.cs b/Fin.Application/Wallets/Services/WalletValidationService.cs new file mode 100644 index 0000000..1e44eec --- /dev/null +++ b/Fin.Application/Wallets/Services/WalletValidationService.cs @@ -0,0 +1,151 @@ +using Fin.Application.FinancialInstitutions; +using Fin.Application.Globals.Dtos; +using Fin.Application.Wallets.Enums; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Wallets.Services; + +public interface IWalletValidationService +{ + public Task> ValidateToggleInactive(Guid walletId); + public Task> ValidateDelete(Guid walletId); + + public Task> ValidateInput(WalletInput input, + Guid? editingId = null); +} + +public class WalletValidationService( + IRepository repository, + IFinancialInstitutionService financialInstitutionService +) : IWalletValidationService, IAutoTransient +{ + public async Task> ValidateToggleInactive(Guid walletId) + { + var validationResult = new ValidationResultDto(); + + var wallet = await repository.Query(tracking: false).FirstOrDefaultAsync(n => n.Id == walletId); + if (wallet is null) + { + validationResult.ErrorCode = WalletToggleInactiveErrorCode.WalletNotFound; + validationResult.Message = "Wallet not found to toogle inactive."; + return validationResult; + } + + // TODO here validate relations + + validationResult.Success = true; + return validationResult; + } + + public async Task> ValidateDelete(Guid walletId) + { + var validationResult = new ValidationResultDto(); + + var walletExists = await repository.Query().AnyAsync(n => n.Id == walletId); + if (!walletExists) + { + validationResult.ErrorCode = WalletDeleteErrorCode.WalletNotFound; + validationResult.Message = "Wallet not found to delete."; + return validationResult; + } + + // TODO here validate relations + + validationResult.Success = true; + return validationResult; + } + + public async Task> ValidateInput(WalletInput input, + Guid? editingId = null) + { + var validationResult = new ValidationResultDto(); + + if (editingId.HasValue) + { + var walletExists = await repository.Query() + .AnyAsync(n => n.Id == editingId.Value); + if (!walletExists) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.WalletNotFound; + validationResult.Message = "Wallet not found to edit."; + return validationResult; + } + } + + if (string.IsNullOrWhiteSpace(input.Color)) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.ColorIsRequired; + validationResult.Message = "Color is required."; + return validationResult; + } + + if (input.Color.Length > 20) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.ColorTooLong; + validationResult.Message = "Color is too long. Max 20 characters."; + return validationResult; + } + + if (string.IsNullOrWhiteSpace(input.Icon)) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.IconIsRequired; + validationResult.Message = "Icon is required."; + return validationResult; + } + + if (input.Icon.Length > 20) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.IconTooLong; + validationResult.Message = "Icon is too long. Max 20 characters."; + return validationResult; + } + + if (string.IsNullOrWhiteSpace(input.Name)) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.NameIsRequired; + validationResult.Message = "Name is required."; + return validationResult; + } + + if (input.Name.Length > 100) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.NameTooLong; + validationResult.Message = "Name is too long. Max 100 characters."; + return validationResult; + } + + var nameAlredInUse = await repository.Query() + .AnyAsync(n => n.Name.ToLower() == input.Name.ToLower() && (!editingId.HasValue || n.Id != editingId)); + if (nameAlredInUse) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.NameAlreadyInUse; + validationResult.Message = "Name is already in use."; + return validationResult; + } + + if (input.FinancialInstitutionId.HasValue) + { + var financialInstitution = await financialInstitutionService.Get(input.FinancialInstitutionId.Value); + if (financialInstitution is null) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.FinancialInstitutionNotFound; + validationResult.Message = "Financial institution not found."; + return validationResult; + } + + if (financialInstitution.Inactive) + { + validationResult.ErrorCode = WalletCreateOrUpdateErrorCode.FinancialInstitutionInactivated; + validationResult.Message = "Financial institution is inactive."; + return validationResult; + } + } + + validationResult.Success = true; + return validationResult; + } +} \ No newline at end of file diff --git a/Fin.Domain/FinancialInstitutions/Entities/FinancialInstitution.cs b/Fin.Domain/FinancialInstitutions/Entities/FinancialInstitution.cs index aca544f..87cbbed 100644 --- a/Fin.Domain/FinancialInstitutions/Entities/FinancialInstitution.cs +++ b/Fin.Domain/FinancialInstitutions/Entities/FinancialInstitution.cs @@ -1,6 +1,7 @@ using Fin.Domain.FinancialInstitutions.Dtos; using Fin.Domain.FinancialInstitutions.Enums; using Fin.Domain.Global.Interfaces; +using Fin.Domain.Wallets.Entities; namespace Fin.Domain.FinancialInstitutions.Entities; @@ -13,11 +14,14 @@ public class FinancialInstitution : IAuditedEntity public string Color { get; set; } public bool Inactive { get; set; } + public virtual ICollection Wallets { get; set; } + public Guid Id { get; set; } public Guid CreatedBy { get; set; } public Guid UpdatedBy { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + public FinancialInstitution() { } diff --git a/Fin.Domain/Wallets/Dtos/WalletInput.cs b/Fin.Domain/Wallets/Dtos/WalletInput.cs new file mode 100644 index 0000000..76be974 --- /dev/null +++ b/Fin.Domain/Wallets/Dtos/WalletInput.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fin.Domain.Wallets.Dtos; + +public class WalletInput +{ + [Required] + public string Name { get; set; } + + [Required] + public string Color { get; set; } + + [Required] + public string Icon { get; set; } + + public Guid? FinancialInstitutionId { get; set; } + + [Required] + public decimal InitialBalance { get; set; } +} \ No newline at end of file diff --git a/Fin.Domain/Wallets/Dtos/WalletOutput.cs b/Fin.Domain/Wallets/Dtos/WalletOutput.cs new file mode 100644 index 0000000..b1f417a --- /dev/null +++ b/Fin.Domain/Wallets/Dtos/WalletOutput.cs @@ -0,0 +1,19 @@ +using Fin.Domain.Wallets.Entities; + +namespace Fin.Domain.Wallets.Dtos; + +public class WalletOutput(Wallet wallet) +{ + public Guid Id { get; set; } = wallet.Id; + public string Name { get; set; } = wallet.Name; + public string Color { get; set; } = wallet.Color; + public string Icon { get; set; } = wallet.Icon; + public bool Inactivated { get; set; } = wallet.Inactivated; + public Guid? FinancialInstitutionId { get; set; } = wallet.FinancialInstitutionId; + public decimal InitialBalance { get; set; } = wallet.InitialBalance; + public decimal CurrentBalance { get; set; } = wallet.CurrentBalance; + + public WalletOutput(): this(new Wallet()) + { + } +} \ No newline at end of file diff --git a/Fin.Domain/Wallets/Entities/Wallet.cs b/Fin.Domain/Wallets/Entities/Wallet.cs new file mode 100644 index 0000000..9a12f9f --- /dev/null +++ b/Fin.Domain/Wallets/Entities/Wallet.cs @@ -0,0 +1,52 @@ +using Fin.Domain.FinancialInstitutions.Entities; +using Fin.Domain.Global.Interfaces; +using Fin.Domain.Wallets.Dtos; + +namespace Fin.Domain.Wallets.Entities; + +public class Wallet: IAuditedTenantEntity +{ + public Guid Id { get; set; } + public Guid CreatedBy { get; set; } + public Guid UpdatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public Guid TenantId { get; set; } + + public string Name { get; private set; } + public string Color { get; private set; } + public string Icon { get; private set; } + public bool Inactivated { get; private set; } + + public Guid? FinancialInstitutionId { get; private set; } + public virtual FinancialInstitution FinancialInstitution { get; } + + public decimal InitialBalance { get; private set; } + public decimal CurrentBalance { get; set; } + + public Wallet() + { + } + + public Wallet(WalletInput wallet) + { + Name = wallet.Name; + Color = wallet.Color; + Icon = wallet.Icon; + FinancialInstitutionId = wallet.FinancialInstitutionId; + InitialBalance = wallet.InitialBalance; + CurrentBalance = wallet.InitialBalance; + } + + public void Update(WalletInput wallet) + { + Name = wallet.Name; + Color = wallet.Color; + Icon = wallet.Icon; + FinancialInstitutionId = wallet.FinancialInstitutionId; + InitialBalance = wallet.InitialBalance; + // Here we don't update CurrentBalance because It's titles must be reprocesses + } + + public void ToggleInactivated() => Inactivated = !Inactivated; +} \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs b/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs index 555d78b..1c91c66 100644 --- a/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs +++ b/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs @@ -13,5 +13,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Name).HasMaxLength(100).IsRequired(); builder.Property(x => x.Icon).HasMaxLength(20).IsRequired(); builder.Property(x => x.Color).HasMaxLength(20).IsRequired(); + + builder.HasIndex(x => new {x.Name, x.TenantId}).IsUnique(); } } \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Configurations/Wallets/WalletConfiguration.cs b/Fin.Infrastructure/Database/Configurations/Wallets/WalletConfiguration.cs new file mode 100644 index 0000000..3200381 --- /dev/null +++ b/Fin.Infrastructure/Database/Configurations/Wallets/WalletConfiguration.cs @@ -0,0 +1,31 @@ +using Fin.Domain.Wallets.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fin.Infrastructure.Database.Configurations.Wallets; + +public class WalletConfiguration: IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).HasMaxLength(100).IsRequired(); + builder.Property(x => x.Icon).HasMaxLength(20).IsRequired(); + builder.Property(x => x.Color).HasMaxLength(20).IsRequired(); + + builder.HasIndex(x => new {x.Name, x.TenantId}).IsUnique(); + + builder + .Property(p => p.InitialBalance) + .HasColumnType("numeric(19,4)") + .HasPrecision(19, 4); + + builder + .HasOne(wallet => wallet.FinancialInstitution) + .WithMany(financialInstitution => financialInstitution.Wallets) + .HasForeignKey(wallet => wallet.FinancialInstitutionId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/Database/FinDbContext.cs b/Fin.Infrastructure/Database/FinDbContext.cs index a91f6bb..eb8edbf 100644 --- a/Fin.Infrastructure/Database/FinDbContext.cs +++ b/Fin.Infrastructure/Database/FinDbContext.cs @@ -6,6 +6,7 @@ using Fin.Domain.Tenants.Entities; using Fin.Domain.TitleCategories.Entities; using Fin.Domain.Users.Entities; +using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.AmbientDatas; using Fin.Infrastructure.Database.Configurations; using Microsoft.EntityFrameworkCore; @@ -30,6 +31,8 @@ public class FinDbContext : DbContext public DbSet Menus { get; set; } public DbSet TitleCategories { get; set; } + + public DbSet Wallets { get; set; } private readonly IAmbientData _ambientData; diff --git a/Fin.Infrastructure/Migrations/20251007222924_adding_wallets.Designer.cs b/Fin.Infrastructure/Migrations/20251007222924_adding_wallets.Designer.cs new file mode 100644 index 0000000..d4f8207 --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251007222924_adding_wallets.Designer.cs @@ -0,0 +1,686 @@ +// +using System; +using Fin.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + [DbContext(typeof(FinDbContext))] + [Migration("20251007222924_adding_wallets")] + partial class adding_wallets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(true) + .HasColumnType("character varying(100)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("FinancialInstitution", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Menus.Entities.Menu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FrontRoute") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeyWords") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OnlyForAdmin") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Menus", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Continuous") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("HtmlBody") + .HasColumnType("text"); + + b.Property("Link") + .HasColumnType("text"); + + b.Property("NormalizedTextBody") + .HasColumnType("text"); + + b.Property("NormalizedTitle") + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("StartToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property("StopToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property("TextBody") + .HasColumnType("text"); + + b.Property("Title") + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("Ways") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("BackgroundJobId") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Delivery") + .HasColumnType("boolean"); + + b.Property("Visualized") + .HasColumnType("boolean"); + + b.HasKey("NotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("NotificationUserDeliveries", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedWays") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FirebaseTokens") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserNotificationSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NotifyOn") + .HasColumnType("interval"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Ways") + .HasColumnType("text"); + + b.Property("WeekDays") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRememberUseSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Locale") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Timezone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tenants", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("TenantUsers", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("TitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("ImagePublicUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActivity") + .HasColumnType("boolean"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Users", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EncryptedPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("FailLoginAttempts") + .HasColumnType("integer"); + + b.Property("GoogleId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResetToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EncryptedEmail") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Credentials", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserDeleteRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Aborted") + .HasColumnType("boolean"); + + b.Property("AbortedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeleteEffectivatedAt") + .HasColumnType("date"); + + b.Property("DeleteRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserAbortedId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAbortedId"); + + b.HasIndex("UserId"); + + b.ToTable("UserDeleteRequests", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("CurrentBalance") + .HasColumnType("numeric"); + + b.Property("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("InitialBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Wallets", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.HasOne("Fin.Domain.Notifications.Entities.Notification", "Notification") + .WithMany("UserDeliveries") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.HasOne("Fin.Domain.Tenants.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithOne("Credential") + .HasForeignKey("Fin.Domain.Users.Entities.UserCredential", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserDeleteRequest", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "UserAborted") + .WithMany() + .HasForeignKey("UserAbortedId"); + + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany("DeleteRequests") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("UserAborted"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("Wallets") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("FinancialInstitution"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Navigation("Wallets"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Navigation("UserDeliveries"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Navigation("Credential"); + + b.Navigation("DeleteRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fin.Infrastructure/Migrations/20251007222924_adding_wallets.cs b/Fin.Infrastructure/Migrations/20251007222924_adding_wallets.cs new file mode 100644 index 0000000..fdc0f8e --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251007222924_adding_wallets.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + /// + public partial class adding_wallets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Wallets", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + UpdatedBy = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Color = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Icon = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Inactivated = table.Column(type: "boolean", nullable: false), + FinancialInstitutionId = table.Column(type: "uuid", nullable: true), + InitialBalance = table.Column(type: "numeric(19,4)", precision: 19, scale: 4, nullable: false), + CurrentBalance = table.Column(type: "numeric", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Wallets", x => x.Id); + table.ForeignKey( + name: "FK_Wallets_FinancialInstitution_FinancialInstitutionId", + column: x => x.FinancialInstitutionId, + principalSchema: "public", + principalTable: "FinancialInstitution", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_TitleCategories_Name_TenantId", + schema: "public", + table: "TitleCategories", + columns: new[] { "Name", "TenantId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Wallets_FinancialInstitutionId", + schema: "public", + table: "Wallets", + column: "FinancialInstitutionId"); + + migrationBuilder.CreateIndex( + name: "IX_Wallets_Name_TenantId", + schema: "public", + table: "Wallets", + columns: new[] { "Name", "TenantId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Wallets", + schema: "public"); + + migrationBuilder.DropIndex( + name: "IX_TitleCategories_Name_TenantId", + schema: "public", + table: "TitleCategories"); + } + } +} diff --git a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs index 9b6facd..e121eb3 100644 --- a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs +++ b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs @@ -370,6 +370,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Name", "TenantId") + .IsUnique(); + b.ToTable("TitleCategories", "public"); }); @@ -505,6 +508,65 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserDeleteRequests", "public"); }); + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("CurrentBalance") + .HasColumnType("numeric"); + + b.Property("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("InitialBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Wallets", "public"); + }); + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => { b.HasOne("Fin.Domain.Notifications.Entities.Notification", "Notification") @@ -589,6 +651,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UserAborted"); }); + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("Wallets") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("FinancialInstitution"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Navigation("Wallets"); + }); + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => { b.Navigation("UserDeliveries"); diff --git a/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs b/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs index d86820f..ced8b95 100644 --- a/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs +++ b/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs @@ -18,6 +18,17 @@ public async Task SeedAsync() var defaultMenus = new List { + new() + { + Id = Guid.Parse("E517345A-837D-42B8-8281-FD8DC32F9150"), + FrontRoute = "/wallets", + Name = "finCore.features.wallet.title", + Color = "#fdc570", + Icon = "wallet", + OnlyForAdmin = false, + Position = MenuPosition.LeftTop, + KeyWords = "wallets, carteiras, conta, account, billetera, cuenta" + }, new() { Id = Guid.Parse("0199b289-82a7-7069-9230-05250b55fd47"), diff --git a/Fin.Test/FinancialInstitutions/Controllers/FinancialInstitutionControllerTest.cs b/Fin.Test/FinancialInstitutions/Controllers/FinancialInstitutionControllerTest.cs new file mode 100644 index 0000000..bc4a202 --- /dev/null +++ b/Fin.Test/FinancialInstitutions/Controllers/FinancialInstitutionControllerTest.cs @@ -0,0 +1,216 @@ +using Fin.Api.FinancialInstitutions; +using Fin.Application.FinancialInstitutions; +using Fin.Application.FinancialInstitutions.Dtos; +using Fin.Domain.FinancialInstitutions.Dtos; +using Fin.Domain.Global.Classes; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace Fin.Test.FinancialInstitutions.Controllers; + +public class FinancialInstitutionControllerTest : TestUtils.BaseTest +{ + private readonly Mock _serviceMock; + private readonly FinancialInstitutionController _controller; + + public FinancialInstitutionControllerTest() + { + _serviceMock = new Mock(); + _controller = new FinancialInstitutionController(_serviceMock.Object); + } + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedOutput() + { + // Arrange + var input = new FinancialInstitutionGetListInput(); + var expectedOutput = new PagedOutput(1, + [ + new FinancialInstitutionOutput { Id = TestUtils.Guids[0], Name = TestUtils.Strings[1] } + ]); + + _serviceMock.Setup(s => s.GetList(input)).ReturnsAsync(expectedOutput); + + // Act + var result = await _controller.GetList(input); + + // Assert + result.Should().BeEquivalentTo(expectedOutput); + } + + #endregion + + #region Get + + [Fact] + public async Task Get_ShouldReturnOk_WhenInstitutionExists() + { + // Arrange + var institutionId = TestUtils.Guids[0]; + var expectedInstitution = new FinancialInstitutionOutput { Id = institutionId, Name = TestUtils.Strings[1] }; + _serviceMock.Setup(s => s.Get(institutionId)).ReturnsAsync(expectedInstitution); + + // Act + var result = await _controller.Get(institutionId); + + // Assert + result.Result.Should().BeOfType() + .Which.Value.Should().Be(expectedInstitution); + } + + [Fact] + public async Task Get_ShouldReturnNotFound_WhenInstitutionDoesNotExist() + { + // Arrange + var institutionId = TestUtils.Guids[0]; + _serviceMock.Setup(s => s.Get(institutionId)).ReturnsAsync((FinancialInstitutionOutput)null); + + // Act + var result = await _controller.Get(institutionId); + + // Assert + result.Result.Should().BeOfType(); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnCreated_WhenCreationSucceeds() + { + // Arrange + var input = new FinancialInstitutionInput { Name = TestUtils.Strings[1] }; + var createdInstitution = new FinancialInstitutionOutput { Id = TestUtils.Guids[0], Name = TestUtils.Strings[1] }; + _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync(createdInstitution); + + // Act + var result = await _controller.Create(input); + + // Assert + result.Result.Should().BeOfType() + .Which.Value.Should().Be(createdInstitution); + + (result.Result as CreatedResult)?.Location.Should().Be($"financial-institutions/{createdInstitution.Id}"); + } + + [Fact] + public async Task Create_ShouldReturnUnprocessableEntity_WhenCreationFails() + { + // Arrange + var input = new FinancialInstitutionInput(); + // Null return from service can indicate internal validation failure (BadHttpRequestException) + _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync((FinancialInstitutionOutput)null); + + // Act + var result = await _controller.Create(input); + + // Assert + result.Result.Should().BeOfType(); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnOk_WhenUpdateSucceeds() + { + // Arrange + var institutionId = TestUtils.Guids[0]; + var input = new FinancialInstitutionInput { Name = TestUtils.Strings[1] }; + _serviceMock.Setup(s => s.Update(institutionId, input, true)).ReturnsAsync(true); + + // Act + var result = await _controller.Update(institutionId, input); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task Update_ShouldReturnNotFound_WhenInstitutionDoesNotExist() + { + // Arrange + var institutionId = TestUtils.Guids[0]; + var input = new FinancialInstitutionInput(); + _serviceMock.Setup(s => s.Update(institutionId, input, true)).ReturnsAsync(false); + + // Act + var result = await _controller.Update(institutionId, input); + + // Assert + result.Should().BeOfType(); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnOk_WhenDeleteSucceeds() + { + // Arrange + var institutionId = TestUtils.Guids[0]; + _serviceMock.Setup(s => s.Delete(institutionId, true)).ReturnsAsync(true); + + // Act + var result = await _controller.Delete(institutionId); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_ShouldReturnNotFound_WhenDeleteFails() + { + // Arrange + var institutionId = TestUtils.Guids[0]; + // Service returns false if not found or if it has relations + _serviceMock.Setup(s => s.Delete(institutionId, true)).ReturnsAsync(false); + + // Act + var result = await _controller.Delete(institutionId); + + // Assert + result.Should().BeOfType(); + } + + #endregion + + #region ToggleInactive + + [Fact] + public async Task ToggleInactive_ShouldReturnOk_WhenToggleSucceeds() + { + // Arrange + var institutionId = TestUtils.Guids[0]; + _serviceMock.Setup(s => s.ToggleInactive(institutionId, true)).ReturnsAsync(true); + + // Act + var result = await _controller.ToggleInactive(institutionId); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnNotFound_WhenToggleFails() + { + // Arrange + var institutionId = TestUtils.Guids[0]; + // Service returns false if not found or if it has active wallets + _serviceMock.Setup(s => s.ToggleInactive(institutionId, true)).ReturnsAsync(false); + + // Act + var result = await _controller.ToggleInactive(institutionId); + + // Assert + result.Should().BeOfType(); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/FinancialInstitutions/Services/FinancialInstitutionServiceTest.cs b/Fin.Test/FinancialInstitutions/Services/FinancialInstitutionServiceTest.cs new file mode 100644 index 0000000..f0b5a02 --- /dev/null +++ b/Fin.Test/FinancialInstitutions/Services/FinancialInstitutionServiceTest.cs @@ -0,0 +1,466 @@ +using Fin.Application.FinancialInstitutions; +using Fin.Application.FinancialInstitutions.Dtos; +using Fin.Domain.FinancialInstitutions.Dtos; +using Fin.Domain.FinancialInstitutions.Enums; +using Fin.Domain.FinancialInstitutions.Entities; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Fin.Domain.Wallets.Entities; +using Fin.Domain.Wallets.Dtos; + +namespace Fin.Test.FinancialInstitutions.Services; + +public class FinancialInstitutionServiceTest : TestUtils.BaseTestWithContext +{ + private FinancialInstitutionService GetService(Resources resources) + { + return new FinancialInstitutionService(resources.FinancialInstitutionRepository); + } + + private Resources GetResources() + { + return new Resources + { + FinancialInstitutionRepository = GetRepository(), + WalletRepository = GetRepository() + }; + } + + private class Resources + { + public IRepository FinancialInstitutionRepository { get; set; } + public IRepository WalletRepository { get; set; } + } + + // Helper for valid input + private FinancialInstitutionInput GetValidInput(string name = "Test FI") => new() + { + Name = name, + Code = "123", + Type = FinancialInstitutionType.Bank, + Icon = "fa-bank", + Color = "#123456" + }; + + #region Get + + [Fact] + public async Task Get_ShouldReturnFinancialInstitution_WhenExists() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + var institution = new FinancialInstitution(input); + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + + // Act + var result = await service.Get(institution.Id); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(institution.Id); + result.Name.Should().Be(institution.Name); + } + + [Fact] + public async Task Get_ShouldReturnNull_WhenNotExists() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + // Act + var result = await service.Get(TestUtils.Guids[9]); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedResult_WithSortingAndPaging() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + await resources.FinancialInstitutionRepository.AddAsync(new FinancialInstitution(GetValidInput("Z")), true); + await resources.FinancialInstitutionRepository.AddAsync(new FinancialInstitution(GetValidInput("A")), true); + await resources.FinancialInstitutionRepository.AddAsync(new FinancialInstitution(GetValidInput("M")), true); + + var input = new FinancialInstitutionGetListInput { MaxResultCount = 2, SkipCount = 0 }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.TotalCount.Should().Be(3); + result.Items.Should().HaveCount(2); + // Default sort is Inactive (false first) then Name (asc). + result.Items.First().Name.Should().Be("A"); + result.Items.Last().Name.Should().Be("M"); + } + + [Fact] + public async Task GetList_ShouldFilterByInactiveAndType() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + // Active Bank + await resources.FinancialInstitutionRepository.AddAsync(new FinancialInstitution(GetValidInput("Bank1")), true); + + // Inactive Bank + var inactiveBank = new FinancialInstitution(GetValidInput("Bank2")); + inactiveBank.ToggleInactive(); + await resources.FinancialInstitutionRepository.AddAsync(inactiveBank, true); + + // Active DigitalBank + var validInput = GetValidInput("Digital1"); + validInput.Type = FinancialInstitutionType.DigitalBank; + await resources.FinancialInstitutionRepository.AddAsync(new FinancialInstitution(validInput), true); + + var input = new FinancialInstitutionGetListInput { Inactive = true, Type = FinancialInstitutionType.Bank, MaxResultCount = 10, SkipCount = 0 }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.TotalCount.Should().Be(1); + result.Items.Should().HaveCount(1); + result.Items.First().Name.Should().Be("Bank2"); + result.Items.First().Inactive.Should().BeTrue(); + result.Items.First().Type.Should().Be(FinancialInstitutionType.Bank); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldAddInstitutionAndReturnOutput() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + + // Act + var result = await service.Create(input, true); + + // Assert + result.Should().NotBeNull(); + result.Name.Should().Be(input.Name); + var dbInstitution = await resources.FinancialInstitutionRepository.Query(false).FirstOrDefaultAsync(a => a.Id == result.Id); + dbInstitution.Should().NotBeNull(); + dbInstitution.Name.Should().Be(input.Name); + } + + [Fact] + public async Task Create_ShouldThrowException_WhenNameIsMissing() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Name = null; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await service.Create(input, true) + ); + exception.Message.Should().Be("Name is required"); + } + + [Fact] + public async Task Create_ShouldThrowException_WhenNameAlreadyExists() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var name = TestUtils.Strings[0]; + await resources.FinancialInstitutionRepository.AddAsync(new FinancialInstitution(GetValidInput(name)), true); + var input = GetValidInput(name); // Duplicate name + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await service.Create(input, true) + ); + + exception.Message.Should().Be("A financial institution with this name already exists"); + } + + [Fact] + public async Task Create_ShouldThrowException_WhenIconIsTooLong() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Icon = new string('x', 21); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await service.Create(input, true) + ); + + exception.Message.Should().Be("Icon must be at most 20 characters long"); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldModifyInstitutionAndReturnTrue() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var existingName = TestUtils.Strings[0]; + var institution = new FinancialInstitution(GetValidInput(existingName)); + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + + var input = GetValidInput(TestUtils.Strings[1]); + input.Code = "999"; + + // Act + var result = await service.Update(institution.Id, input, true); + + // Assert + result.Should().BeTrue(); + var dbInstitution = await resources.FinancialInstitutionRepository.Query(false).FirstAsync(f => f.Id == institution.Id); + dbInstitution.Name.Should().Be(input.Name); + dbInstitution.Code.Should().Be("999"); + } + + [Fact] + public async Task Update_ShouldReturnFalse_WhenInstitutionNotFound() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var nonExistentId = TestUtils.Guids[9]; + + // Act + var result = await service.Update(nonExistentId, GetValidInput(), true); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task Update_ShouldThrowException_WhenNameConflict() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + // Institution A (Target name conflict) + await resources.FinancialInstitutionRepository.AddAsync(new FinancialInstitution(GetValidInput("Bank A")), true); + + // Institution B (Institution to update) + var institutionB = new FinancialInstitution(GetValidInput("Bank B")); + await resources.FinancialInstitutionRepository.AddAsync(institutionB, true); + + var input = GetValidInput("Bank A"); // Attempt to use A's name + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await service.Update(institutionB.Id, input, true) + ); + + exception.Message.Should().Be("A financial institution with this name already exists"); + } + + [Fact] + public async Task Update_ShouldSucceed_WhenNameIsUnchanged() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var name = TestUtils.Strings[0]; + var institution = new FinancialInstitution(GetValidInput(name)); + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + + var input = GetValidInput(name); + input.Color = "#AAAAAA"; + + // Act + var result = await service.Update(institution.Id, input, true); + + // Assert + result.Should().BeTrue(); + var dbInstitution = await resources.FinancialInstitutionRepository.Query(false).FirstAsync(f => f.Id == institution.Id); + dbInstitution.Color.Should().Be("#AAAAAA"); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnTrue_WhenInstitutionDeleted() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var institution = new FinancialInstitution(GetValidInput()); + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + + // Act + var result = await service.Delete(institution.Id, true); + + // Assert + result.Should().BeTrue(); + (await resources.FinancialInstitutionRepository.Query(false).FirstOrDefaultAsync(f => f.Id == institution.Id)).Should().BeNull(); + } + + [Fact] + public async Task Delete_ShouldReturnFalse_WhenInstitutionNotFound() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + // Act + var result = await service.Delete(TestUtils.Guids[9], true); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task Delete_ShouldReturnFalse_WhenInstitutionHasWallets() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var institution = new FinancialInstitution(GetValidInput()); + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + + // Add a related wallet + await resources.WalletRepository.AddAsync(new Wallet(new WalletInput { Name = "W1", Color = "#FFF", Icon = "I", InitialBalance = 0m, FinancialInstitutionId = institution.Id }), true); + + // Act + var result = await service.Delete(institution.Id, true); + + // Assert + result.Should().BeFalse(); + (await resources.FinancialInstitutionRepository.Query(false).FirstOrDefaultAsync(f => f.Id == institution.Id)).Should().NotBeNull(); + } + + #endregion + + #region ToggleInactive + + [Fact] + public async Task ToggleInactive_ShouldReturnTrue_AndDeactivate() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var institution = new FinancialInstitution(GetValidInput()); + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + institution.Inactive.Should().BeFalse(); + + // Act + var result = await service.ToggleInactive(institution.Id, true); + + // Assert + result.Should().BeTrue(); + var dbInstitution = await resources.FinancialInstitutionRepository.Query(false).FirstAsync(f => f.Id == institution.Id); + dbInstitution.Inactive.Should().BeTrue(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnTrue_AndReactivate() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var institution = new FinancialInstitution(GetValidInput()); + institution.ToggleInactive(); // Initial state: Inactive + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + institution.Inactive.Should().BeTrue(); + + // Act + var result = await service.ToggleInactive(institution.Id, true); + + // Assert + result.Should().BeTrue(); + var dbInstitution = await resources.FinancialInstitutionRepository.Query(false).FirstAsync(f => f.Id == institution.Id); + dbInstitution.Inactive.Should().BeFalse(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnFalse_WhenInstitutionNotFound() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + // Act + var result = await service.ToggleInactive(TestUtils.Guids[9], true); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnFalse_WhenHasActiveWallets() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var institution = new FinancialInstitution(GetValidInput()); // Active + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + + // Add an active related wallet + await resources.WalletRepository.AddAsync(new Wallet(new WalletInput { Name = "W1", Color = "#FFF", Icon = "I", InitialBalance = 0m, FinancialInstitutionId = institution.Id }), true); + + // Act + var result = await service.ToggleInactive(institution.Id, true); + + // Assert + result.Should().BeFalse(); + // Verify institution status did not change + var dbInstitution = await resources.FinancialInstitutionRepository.Query(false).FirstAsync(f => f.Id == institution.Id); + dbInstitution.Inactive.Should().BeFalse(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnTrue_WhenHasOnlyInactiveWallets() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var institution = new FinancialInstitution(GetValidInput()); // Active + await resources.FinancialInstitutionRepository.AddAsync(institution, true); + + // Add an inactive related wallet + var inactiveWallet = new Wallet(new WalletInput { Name = "W1", Color = "#FFF", Icon = "I", InitialBalance = 0m, FinancialInstitutionId = institution.Id }); + inactiveWallet.ToggleInactivated(); + await resources.WalletRepository.AddAsync(inactiveWallet, true); + + // Act + var result = await service.ToggleInactive(institution.Id, true); // Deactivating institution + + // Assert + result.Should().BeTrue(); + // Verify institution status changed + var dbInstitution = await resources.FinancialInstitutionRepository.Query(false).FirstAsync(f => f.Id == institution.Id); + dbInstitution.Inactive.Should().BeTrue(); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/TitleCategories/Controllers/TitleCategoryControllerTest.cs b/Fin.Test/TitleCategories/Controllers/TitleCategoryControllerTest.cs index 7303c7c..c639251 100644 --- a/Fin.Test/TitleCategories/Controllers/TitleCategoryControllerTest.cs +++ b/Fin.Test/TitleCategories/Controllers/TitleCategoryControllerTest.cs @@ -1,11 +1,13 @@ using Fin.Api.TitleCategories; using Fin.Application.TitleCategories; using Fin.Application.TitleCategories.Dtos; +using Fin.Application.TitleCategories.Enums; using Fin.Domain.Global.Classes; using Fin.Domain.TitleCategories.Dtos; using FluentAssertions; using Microsoft.AspNetCore.Mvc; using Moq; +using Fin.Application.Globals.Dtos; namespace Fin.Test.TitleCategories.Controllers; @@ -85,7 +87,13 @@ public async Task Create_ShouldReturnCreated_WhenInputIsValid() // Arrange var input = new TitleCategoryInput { Name = TestUtils.Strings[1], Color = TestUtils.Strings[2], Icon = TestUtils.Strings[3] }; var createdCategory = new TitleCategoryOutput { Id = TestUtils.Guids[0], Name = TestUtils.Strings[1] }; - _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync(createdCategory); + var successResult = new ValidationResultDto + { + Success = true, + Data = createdCategory + }; + + _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync(successResult); // Act var result = await _controller.Create(input); @@ -102,13 +110,21 @@ public async Task Create_ShouldReturnUnprocessableEntity_WhenCreationFails() { // Arrange var input = new TitleCategoryInput(); - _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync((TitleCategoryOutput)null); + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = TitleCategoryCreateOrUpdateErrorCode.NameIsRequired, + Message = "Name is required." + }; + + _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync(failureResult); // Act var result = await _controller.Create(input); // Assert - result.Result.Should().BeOfType(); + var unprocessableEntityResult = result.Result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); } #endregion @@ -121,7 +137,13 @@ public async Task Update_ShouldReturnOk_WhenUpdateSucceeds() // Arrange var categoryId = TestUtils.Guids[0]; var input = new TitleCategoryInput { Name = TestUtils.Strings[1], Color = TestUtils.Strings[2], Icon = TestUtils.Strings[3] }; - _serviceMock.Setup(s => s.Update(categoryId, input, true)).ReturnsAsync(true); + var successResult = new ValidationResultDto + { + Success = true, + Data = true + }; + + _serviceMock.Setup(s => s.Update(categoryId, input, true)).ReturnsAsync(successResult); // Act var result = await _controller.Update(categoryId, input); @@ -131,18 +153,49 @@ public async Task Update_ShouldReturnOk_WhenUpdateSucceeds() } [Fact] - public async Task Update_ShouldReturnUnprocessableEntity_WhenUpdateFails() + public async Task Update_ShouldReturnNotFound_WhenCategoryDoesNotExist() + { + // Arrange + var categoryId = TestUtils.Guids[0]; + var input = new TitleCategoryInput { Name = TestUtils.Strings[1], Color = TestUtils.Strings[2], Icon = TestUtils.Strings[3] }; + var notFoundResult = new ValidationResultDto + { + Success = false, + ErrorCode = TitleCategoryCreateOrUpdateErrorCode.TitleCategoryNotFound, + Message = "Title category not found to edit." + }; + + _serviceMock.Setup(s => s.Update(categoryId, input, true)).ReturnsAsync(notFoundResult); + + // Act + var result = await _controller.Update(categoryId, input); + + // Assert + var notFoundObjectResult = result.Should().BeOfType().Subject; + notFoundObjectResult.Value.Should().BeEquivalentTo(notFoundResult); + } + + [Fact] + public async Task Update_ShouldReturnUnprocessableEntity_WhenUpdateFailsForOtherReasons() { // Arrange var categoryId = TestUtils.Guids[0]; var input = new TitleCategoryInput(); - _serviceMock.Setup(s => s.Update(categoryId, input, true)).ReturnsAsync(false); + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = TitleCategoryCreateOrUpdateErrorCode.NameAlreadyInUse, + Message = "Name is already in use." + }; + + _serviceMock.Setup(s => s.Update(categoryId, input, true)).ReturnsAsync(failureResult); // Act var result = await _controller.Update(categoryId, input); // Assert - result.Should().BeOfType(); + var unprocessableEntityResult = result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); } #endregion @@ -164,7 +217,7 @@ public async Task ToggleInactivated_ShouldReturnOk_WhenToggleSucceeds() } [Fact] - public async Task ToggleInactivated_ShouldReturnUnprocessableEntity_WhenToggleFails() + public async Task ToggleInactivated_ShouldReturnNotFound_WhenToggleFails() { // Arrange var categoryId = TestUtils.Guids[0]; @@ -174,7 +227,7 @@ public async Task ToggleInactivated_ShouldReturnUnprocessableEntity_WhenToggleFa var result = await _controller.ToggleInactivated(categoryId); // Assert - result.Should().BeOfType(); + result.Should().BeOfType(); } #endregion @@ -196,7 +249,7 @@ public async Task Delete_ShouldReturnOk_WhenDeleteSucceeds() } [Fact] - public async Task Delete_ShouldReturnUnprocessableEntity_WhenDeleteFails() + public async Task Delete_ShouldReturnNotFound_WhenDeleteFails() { // Arrange var categoryId = TestUtils.Guids[0]; @@ -206,7 +259,7 @@ public async Task Delete_ShouldReturnUnprocessableEntity_WhenDeleteFails() var result = await _controller.Delete(categoryId); // Assert - result.Should().BeOfType(); + result.Should().BeOfType(); } #endregion diff --git a/Fin.Test/TitleCategories/Services/TitleCategoryServiceTest.cs b/Fin.Test/TitleCategories/Services/TitleCategoryServiceTest.cs index 4082455..6549b05 100644 --- a/Fin.Test/TitleCategories/Services/TitleCategoryServiceTest.cs +++ b/Fin.Test/TitleCategories/Services/TitleCategoryServiceTest.cs @@ -1,10 +1,11 @@ +using Fin.Application.Globals.Dtos; using Fin.Application.TitleCategories; using Fin.Application.TitleCategories.Dtos; +using Fin.Application.TitleCategories.Enums; using Fin.Domain.TitleCategories.Dtos; using Fin.Domain.TitleCategories.Entities; using Fin.Infrastructure.Database.Repositories; using FluentAssertions; -using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; namespace Fin.Test.TitleCategories.Services; @@ -69,7 +70,6 @@ public async Task GetList_ShouldReturnPagedResult_WithoutFilter() result.Should().NotBeNull(); result.TotalCount.Should().Be(3); result.Items.Should().HaveCount(2); - // Default sort is Inactivated (false first) then Name (asc). All are not inactivated, so sort by Name. result.Items.First().Name.Should().Be("A"); result.Items.Last().Name.Should().Be("B"); } @@ -98,7 +98,7 @@ public async Task GetList_ShouldFilterByInactivatedTrue() result.Should().NotBeNull(); result.TotalCount.Should().Be(2); result.Items.Should().HaveCount(2); - result.Items.First().Name.Should().Be("Inactive1"); // Sorted by Name asc + result.Items.First().Name.Should().Be("Inactive1"); result.Items.Last().Name.Should().Be("Inactive2"); } @@ -124,7 +124,7 @@ public async Task GetList_ShouldFilterByInactivatedFalse() result.Should().NotBeNull(); result.TotalCount.Should().Be(2); result.Items.Should().HaveCount(2); - result.Items.First().Name.Should().Be("Active1"); // Sorted by Name asc + result.Items.First().Name.Should().Be("Active1"); result.Items.Last().Name.Should().Be("Active2"); } @@ -133,14 +133,12 @@ public async Task GetList_ShouldFilterByInactivatedFalse() #region Create [Fact] - public async Task Create() + public async Task Create_ShouldReturnSuccessAndTitleCategory_WhenInputIsValid() { // Arrange var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); - var input = new TitleCategoryInput { Name = TestUtils.Strings[0], @@ -153,7 +151,10 @@ public async Task Create() // Assert result.Should().NotBeNull(); - var dbTitleCategory = await resources.TitleCategoryRepository.Query(false).FirstOrDefaultAsync(a => a.Id == result.Id); + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + + var dbTitleCategory = await resources.TitleCategoryRepository.Query(false).FirstOrDefaultAsync(a => a.Id == result.Data.Id); dbTitleCategory.Should().NotBeNull(); dbTitleCategory.Name.Should().Be(input.Name); dbTitleCategory.Color.Should().Be(input.Color); @@ -162,64 +163,99 @@ public async Task Create() } [Fact] - public async Task Create_NameRequired() + public async Task Create_ShouldReturnFailure_NameRequired() { // Arrange var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); - var input = new TitleCategoryInput { Color = TestUtils.Strings[0], Icon = TestUtils.Strings[0] }; - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await service.Create(input, true)); - exception.Message.Should().Be("Name is required"); + // Act + var result = await service.Create(input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.NameIsRequired); + result.Message.Should().Be("Name is required."); } [Fact] - public async Task Create_ColorRequired() + public async Task Create_ShouldReturnFailure_ColorRequired() { // Arrange var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); - var input = new TitleCategoryInput { Name = TestUtils.Strings[0], Icon = TestUtils.Strings[0] }; - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await service.Create(input, true)); - // Note: The service validates 'Color' but throws an error message 'FrontRoute is required' - exception.Message.Should().Be("FrontRoute is required"); + // Act + var result = await service.Create(input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.ColorIsRequired); + result.Message.Should().Be("Color is required."); } [Fact] - public async Task Create_IconRequired() + public async Task Create_ShouldReturnFailure_IconRequired() { // Arrange var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); - var input = new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[0] }; - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await service.Create(input, true)); - exception.Message.Should().Be("Icon is required"); + // Act + var result = await service.Create(input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.IconIsRequired); + result.Message.Should().Be("Icon is required."); + } + + [Fact] + public async Task Create_ShouldReturnFailure_NameAlreadyInUse() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + var existingName = TestUtils.Strings[0]; + await resources.TitleCategoryRepository.AddAsync(new TitleCategory(new TitleCategoryInput { Name = existingName, Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2] }), true); + + var input = new TitleCategoryInput + { + Name = existingName, + Color = TestUtils.Strings[3], + Icon = TestUtils.Strings[4] + }; + + // Act + var result = await service.Create(input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.NameAlreadyInUse); + result.Message.Should().Be("Name is already in use."); } #endregion @@ -227,27 +263,32 @@ public async Task Create_IconRequired() #region Update [Fact] - public async Task Update_ShouldReturnFalse_WhenTitleCategoryNotFound() + public async Task Update_ShouldReturnFailure_WhenTitleCategoryNotFound() { // Arrange var resources = GetResources(); var service = GetService(resources); + var input = new TitleCategoryInput { Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3], Name = TestUtils.Strings[0] }; + // Act - var result = await service.Update(TestUtils.Guids[9], new TitleCategoryInput{ Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3], Name = TestUtils.Strings[0]}, true); + var result = await service.Update(TestUtils.Guids[9], input, true); // Assert - result.Should().BeFalse(); + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.TitleCategoryNotFound); + result.Message.Should().Be("Title category not found to edit."); + result.Data.Should().BeFalse(); } [Fact] - public async Task Update_ShouldReturnTrue() + public async Task Update_ShouldReturnSuccessAndTrue() { // Arrange var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); var titleCategory = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3] }); await resources.TitleCategoryRepository.AddAsync(titleCategory, true); @@ -257,70 +298,110 @@ public async Task Update_ShouldReturnTrue() var result = await service.Update(titleCategory.Id, input, true); // Assert - result.Should().BeTrue(); + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); - var dbTitleCategory = await resources.TitleCategoryRepository.Query(false).FirstOrDefaultAsync(a => a.Id == titleCategory.Id); - dbTitleCategory.Should().NotBeNull(); + var dbTitleCategory = await resources.TitleCategoryRepository.Query(false).FirstAsync(a => a.Id == titleCategory.Id); dbTitleCategory.Name.Should().Be(input.Name); dbTitleCategory.Color.Should().Be(input.Color); dbTitleCategory.Icon.Should().Be(input.Icon); } [Fact] - public async Task Update_ShouldThrow_NameRequired() + public async Task Update_ShouldReturnFailure_NameRequired() { - // Arrange var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); var titleCategory = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3] }); await resources.TitleCategoryRepository.AddAsync(titleCategory, true); var input = new TitleCategoryInput { Color = TestUtils.Strings[5], Icon = TestUtils.Strings[6] }; - // Act & asser - var exception = await Assert.ThrowsAsync(async () => await service.Update(titleCategory.Id, input, true)); - exception.Message.Should().Be("Name is required"); + // Act + var result = await service.Update(titleCategory.Id, input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.NameIsRequired); + result.Message.Should().Be("Name is required."); } [Fact] - public async Task Update_ShouldThrow_ColorRequired() + public async Task Update_ShouldReturnFailure_ColorRequired() { - // Arrange var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); var titleCategory = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3] }); await resources.TitleCategoryRepository.AddAsync(titleCategory, true); var input = new TitleCategoryInput { Name = TestUtils.Strings[5], Icon = TestUtils.Strings[6] }; - // Act & asser - var exception = await Assert.ThrowsAsync(async () => await service.Update(titleCategory.Id, input, true)); - exception.Message.Should().Be("FrontRoute is required"); + // Act + var result = await service.Update(titleCategory.Id, input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.ColorIsRequired); + result.Message.Should().Be("Color is required."); } [Fact] - public async Task Update_ShouldThrow_IconRequired() + public async Task Update_ShouldReturnFailure_IconRequired() { - // Arrange var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); var titleCategory = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3] }); await resources.TitleCategoryRepository.AddAsync(titleCategory, true); var input = new TitleCategoryInput { Name = TestUtils.Strings[5], Color = TestUtils.Strings[6] }; - // Act & asser - var exception = await Assert.ThrowsAsync(async () => await service.Update(titleCategory.Id, input, true)); - exception.Message.Should().Be("Icon is required"); + // Act + var result = await service.Update(titleCategory.Id, input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.IconIsRequired); + result.Message.Should().Be("Icon is required."); + } + + [Fact] + public async Task Update_ShouldReturnFailure_NameAlreadyInUse() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + var existingTitleCategory = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2] }); + await resources.TitleCategoryRepository.AddAsync(existingTitleCategory, true); + + var titleCategoryToUpdate = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[3], Color = TestUtils.Strings[4], Icon = TestUtils.Strings[5] }); + await resources.TitleCategoryRepository.AddAsync(titleCategoryToUpdate, true); + + var input = new TitleCategoryInput + { + Name = existingTitleCategory.Name, // Name already in use by another category + Color = TestUtils.Strings[6], + Icon = TestUtils.Strings[7] + }; + + // Act + var result = await service.Update(titleCategoryToUpdate.Id, input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCategoryCreateOrUpdateErrorCode.NameAlreadyInUse); + result.Message.Should().Be("Name is already in use."); } #endregion @@ -348,7 +429,6 @@ public async Task Delete_ShouldReturnTrue() var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); var titleCategory = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3] }); await resources.TitleCategoryRepository.AddAsync(titleCategory, true); @@ -385,7 +465,6 @@ public async Task ToggleInactive_ShouldDeactivate() var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); var titleCategory = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3] }); await resources.TitleCategoryRepository.AddAsync(titleCategory, true); titleCategory.Inactivated.Should().BeFalse(); @@ -407,7 +486,6 @@ public async Task ToggleInactive_ShouldReactivate() var resources = GetResources(); var service = GetService(resources); - DateTimeProvider.Setup(d => d.UtcNow()).Returns(TestUtils.UtcDateTimes[0]); var titleCategory = new TitleCategory(new TitleCategoryInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3] }); titleCategory.ToggleInactivated(); await resources.TitleCategoryRepository.AddAsync(titleCategory, true); diff --git a/Fin.Test/Users/UserCreateServiceTest.cs b/Fin.Test/Users/UserCreateServiceTest.cs index eb8167d..6e9ac4d 100644 --- a/Fin.Test/Users/UserCreateServiceTest.cs +++ b/Fin.Test/Users/UserCreateServiceTest.cs @@ -8,6 +8,7 @@ using Fin.Domain.Users.Dtos; using Fin.Domain.Users.Entities; using Fin.Domain.Users.Factories; +using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.Authentications.Constants; using Fin.Infrastructure.Constants; using Fin.Infrastructure.Database.Repositories; @@ -729,7 +730,8 @@ private UserCreateService GetService(Resources resources) resources.FakeCodeGenerator.Object, resources.UserNotificationSettings, resources.UserRememberUseSettings, - UnitOfWork + UnitOfWork, + resources.WalletRepository ); } @@ -742,6 +744,7 @@ private Resources GetResources() TenantRepository = GetRepository(), UserNotificationSettings = GetRepository(), UserRememberUseSettings = GetRepository(), + WalletRepository = GetRepository(), FakeCache = new Mock(), FakeEmailSender = new Mock(), FakeConfiguration = new Mock(), @@ -774,6 +777,7 @@ private class Resources public IRepository UserRepository { get; init; } public IRepository TenantRepository { get; init; } public IRepository UserNotificationSettings { get; init; } + public IRepository WalletRepository { get; init; } public IRepository UserRememberUseSettings { get; init; } public Mock FakeCache { get; init; } public Mock FakeConfiguration { get; init; } diff --git a/Fin.Test/Wallets/Controllers/WalletControllerTest.cs b/Fin.Test/Wallets/Controllers/WalletControllerTest.cs new file mode 100644 index 0000000..5222011 --- /dev/null +++ b/Fin.Test/Wallets/Controllers/WalletControllerTest.cs @@ -0,0 +1,315 @@ +using Fin.Api.Wallets; +using Fin.Application.Globals.Dtos; +using Fin.Application.Wallets.Dtos; +using Fin.Application.Wallets.Enums; +using Fin.Application.Wallets.Services; +using Fin.Domain.Global.Classes; +using Fin.Domain.Wallets.Dtos; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace Fin.Test.Wallets.Controllers; + +public class WalletControllerTest : TestUtils.BaseTest +{ + private readonly Mock _serviceMock; + private readonly WalletController _controller; + + public WalletControllerTest() + { + _serviceMock = new Mock(); + _controller = new WalletController(_serviceMock.Object); + } + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedOutput() + { + // Arrange + var input = new WalletGetListInput(); + var expectedOutput = new PagedOutput(1, + [ + new WalletOutput { Id = TestUtils.Guids[0], Name = TestUtils.Strings[1] } + ]); + + _serviceMock.Setup(s => s.GetList(input)).ReturnsAsync(expectedOutput); + + // Act + var result = await _controller.GetList(input); + + // Assert + result.Should().BeEquivalentTo(expectedOutput); + } + + #endregion + + #region Get + + [Fact] + public async Task Get_ShouldReturnOk_WhenWalletExists() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var expectedWallet = new WalletOutput { Id = walletId, Name = TestUtils.Strings[1] }; + _serviceMock.Setup(s => s.Get(walletId)).ReturnsAsync(expectedWallet); + + // Act + var result = await _controller.Get(walletId); + + // Assert + result.Result.Should().BeOfType() + .Which.Value.Should().Be(expectedWallet); + } + + [Fact] + public async Task Get_ShouldReturnNotFound_WhenWalletDoesNotExist() + { + // Arrange + var walletId = TestUtils.Guids[0]; + _serviceMock.Setup(s => s.Get(walletId)).ReturnsAsync((WalletOutput)null); + + // Act + var result = await _controller.Get(walletId); + + // Assert + result.Result.Should().BeOfType(); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnCreated_WhenInputIsValid() + { + // Arrange + var input = new WalletInput { Name = TestUtils.Strings[1], Color = TestUtils.Strings[2], Icon = TestUtils.Strings[3], InitialBalance = 100m }; + var createdWallet = new WalletOutput { Id = TestUtils.Guids[0], Name = TestUtils.Strings[1] }; + var successResult = new ValidationResultDto + { + Success = true, + Data = createdWallet + }; + _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync(successResult); + + // Act + var result = await _controller.Create(input); + + // Assert + result.Result.Should().BeOfType() + .Which.Value.Should().Be(createdWallet); + + (result.Result as CreatedResult)?.Location.Should().Be($"categories/{createdWallet.Id}"); + } + + [Fact] + public async Task Create_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var input = new WalletInput(); + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = WalletCreateOrUpdateErrorCode.NameIsRequired, + Message = "Name is required." + }; + _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync(failureResult); + + // Act + var result = await _controller.Create(input); + + // Assert + var unprocessableEntityResult = result.Result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnOk_WhenUpdateSucceeds() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var input = new WalletInput { Name = TestUtils.Strings[1], Color = TestUtils.Strings[2], Icon = TestUtils.Strings[3], InitialBalance = 100m }; + var successResult = new ValidationResultDto { Success = true, Data = true }; + _serviceMock.Setup(s => s.Update(walletId, input, true)).ReturnsAsync(successResult); + + // Act + var result = await _controller.Update(walletId, input); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task Update_ShouldReturnNotFound_WhenWalletDoesNotExist() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var input = new WalletInput(); + var notFoundResult = new ValidationResultDto + { + Success = false, + ErrorCode = WalletCreateOrUpdateErrorCode.WalletNotFound, + Message = "Wallet not found to edit." + }; + _serviceMock.Setup(s => s.Update(walletId, input, true)).ReturnsAsync(notFoundResult); + + // Act + var result = await _controller.Update(walletId, input); + + // Assert + var notFoundObjectResult = result.Should().BeOfType().Subject; + notFoundObjectResult.Value.Should().BeEquivalentTo(notFoundResult); + } + + [Fact] + public async Task Update_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var input = new WalletInput(); + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = WalletCreateOrUpdateErrorCode.NameAlreadyInUse, + Message = "Name is already in use." + }; + _serviceMock.Setup(s => s.Update(walletId, input, true)).ReturnsAsync(failureResult); + + // Act + var result = await _controller.Update(walletId, input); + + // Assert + var unprocessableEntityResult = result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion + + #region ToggleInactivated + + [Fact] + public async Task ToggleInactivated_ShouldReturnOk_WhenToggleSucceeds() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var successResult = new ValidationResultDto { Success = true, Data = true }; + _serviceMock.Setup(s => s.ToggleInactive(walletId, true)).ReturnsAsync(successResult); + + // Act + var result = await _controller.ToggleInactivated(walletId); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task ToggleInactivated_ShouldReturnNotFound_WhenWalletDoesNotExist() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var notFoundResult = new ValidationResultDto + { + Success = false, + ErrorCode = WalletToggleInactiveErrorCode.WalletNotFound, + Message = "Wallet not found." + }; + _serviceMock.Setup(s => s.ToggleInactive(walletId, true)).ReturnsAsync(notFoundResult); + + // Act + var result = await _controller.ToggleInactivated(walletId); + + // Assert + var notFoundObjectResult = result.Should().BeOfType().Subject; + notFoundObjectResult.Value.Should().BeEquivalentTo(notFoundResult); + } + + [Fact] + public async Task ToggleInactivated_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = WalletToggleInactiveErrorCode.WalletInUseByActivatedCreditCards, + Message = "Cannot inactive due to related items." + }; + _serviceMock.Setup(s => s.ToggleInactive(walletId, true)).ReturnsAsync(failureResult); + + // Act + var result = await _controller.ToggleInactivated(walletId); + + // Assert + var unprocessableEntityResult = result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnOk_WhenDeleteSucceeds() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var successResult = new ValidationResultDto { Success = true, Data = true }; + _serviceMock.Setup(s => s.Delete(walletId, true)).ReturnsAsync(successResult); + + // Act + var result = await _controller.Delete(walletId); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_ShouldReturnNotFound_WhenWalletDoesNotExist() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var notFoundResult = new ValidationResultDto + { + Success = false, + ErrorCode = WalletDeleteErrorCode.WalletNotFound, + Message = "Wallet not found." + }; + _serviceMock.Setup(s => s.Delete(walletId, true)).ReturnsAsync(notFoundResult); + + // Act + var result = await _controller.Delete(walletId); + + // Assert + var notFoundObjectResult = result.Should().BeOfType().Subject; + notFoundObjectResult.Value.Should().BeEquivalentTo(notFoundResult); + } + + [Fact] + public async Task Delete_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var walletId = TestUtils.Guids[0]; + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = WalletDeleteErrorCode.WalletInUseByCreditCardsAndTitle, + Message = "Cannot delete wallet with transactions." + }; + _serviceMock.Setup(s => s.Delete(walletId, true)).ReturnsAsync(failureResult); + + // Act + var result = await _controller.Delete(walletId); + + // Assert + var unprocessableEntityResult = result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/Wallets/Services/WalletServiceTest.cs b/Fin.Test/Wallets/Services/WalletServiceTest.cs new file mode 100644 index 0000000..54cfead --- /dev/null +++ b/Fin.Test/Wallets/Services/WalletServiceTest.cs @@ -0,0 +1,388 @@ +using Fin.Application.Globals.Dtos; +using Fin.Application.Wallets.Dtos; +using Fin.Application.Wallets.Enums; +using Fin.Application.Wallets.Services; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace Fin.Test.Wallets.Services; + +public class WalletServiceTest : TestUtils.BaseTestWithContext +{ + private readonly Mock _validationServiceMock; + + public WalletServiceTest() + { + _validationServiceMock = new Mock(); + } + + #region Get + + [Fact] + public async Task Get_ShouldReturnWallet_WhenExists() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + + // Act + var result = await service.Get(wallet.Id); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(wallet.Id); + result.Name.Should().Be(wallet.Name); + } + + [Fact] + public async Task Get_ShouldReturnNull_WhenNotExists() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + // Act + var result = await service.Get(TestUtils.Guids[9]); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedResult_WithoutFilter() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + await resources.WalletRepository.AddAsync(new Wallet(new WalletInput { Name = "C", Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3], InitialBalance = 10 }), true); + await resources.WalletRepository.AddAsync(new Wallet(new WalletInput { Name = "A", Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3], InitialBalance = 10 }), true); + await resources.WalletRepository.AddAsync(new Wallet(new WalletInput { Name = "B", Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3], InitialBalance = 10 }), true); + + var input = new WalletGetListInput { MaxResultCount = 2, SkipCount = 0 }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.TotalCount.Should().Be(3); + result.Items.Should().HaveCount(2); + // Default sort is Inactivated (false first) then Name (asc). + result.Items.First().Name.Should().Be("A"); + result.Items.Last().Name.Should().Be("B"); + } + + [Fact] + public async Task GetList_ShouldFilterByInactivatedTrue() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + await resources.WalletRepository.AddAsync(new Wallet(new WalletInput { Name = "Active1", Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3], InitialBalance = 10 }), true); + var inactive = new Wallet(new WalletInput { Name = "Inactive1", Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3], InitialBalance = 10 }); + inactive.ToggleInactivated(); + await resources.WalletRepository.AddAsync(inactive, true); + var inactive2 = new Wallet(new WalletInput { Name = "Inactive2", Color = TestUtils.Strings[1], Icon = TestUtils.Strings[3], InitialBalance = 10 }); + inactive2.ToggleInactivated(); + await resources.WalletRepository.AddAsync(inactive2, true); + + var input = new WalletGetListInput { Inactivated = true, MaxResultCount = 10, SkipCount = 0 }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.TotalCount.Should().Be(2); + result.Items.Should().HaveCount(2); + result.Items.First().Name.Should().Be("Inactive1"); + result.Items.Last().Name.Should().Be("Inactive2"); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnSuccessAndWallet_WhenInputIsValid() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 50.5m }; + + var successValidation = new ValidationResultDto { Success = true }; + _validationServiceMock.Setup(v => v.ValidateInput(input, null)).ReturnsAsync(successValidation); + + // Act + var result = await service.Create(input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + + var dbWallet = await resources.WalletRepository.Query(false).FirstOrDefaultAsync(a => a.Id == result.Data.Id); + dbWallet.Should().NotBeNull(); + dbWallet.Name.Should().Be(input.Name); + dbWallet.InitialBalance.Should().Be(input.InitialBalance); + dbWallet.CurrentBalance.Should().Be(input.InitialBalance); + } + + [Fact] + public async Task Create_ShouldReturnFailure_WhenValidationFails() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 50.5m }; + + var failureValidation = new ValidationResultDto + { + Success = false, + ErrorCode = WalletCreateOrUpdateErrorCode.NameIsRequired, + Message = "Name is required." + }; + _validationServiceMock.Setup(v => v.ValidateInput(input, null)).ReturnsAsync(failureValidation); + + // Act + var result = await service.Create(input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.NameIsRequired); + result.Data.Should().BeNull(); + + (await resources.WalletRepository.Query(false).CountAsync()).Should().Be(0); // Ensure no addition to DB + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnSuccess_WhenInputIsValid() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + + var input = new WalletInput { Name = TestUtils.Strings[4], Color = TestUtils.Strings[5], Icon = TestUtils.Strings[6], InitialBalance = 200m }; + + var successValidation = new ValidationResultDto { Success = true }; + _validationServiceMock.Setup(v => v.ValidateInput(input, wallet.Id)).ReturnsAsync(successValidation); + + // Act + var result = await service.Update(wallet.Id, input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + + var dbWallet = await resources.WalletRepository.Query(false).FirstAsync(a => a.Id == wallet.Id); + dbWallet.Name.Should().Be(input.Name); + dbWallet.InitialBalance.Should().Be(input.InitialBalance); + } + + [Fact] + public async Task Update_ShouldReturnFailure_WhenValidationFails() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + + var input = new WalletInput { Name = TestUtils.Strings[4], Color = TestUtils.Strings[5], Icon = TestUtils.Strings[6], InitialBalance = 200m }; + + var failureValidation = new ValidationResultDto + { + Success = false, + ErrorCode = WalletCreateOrUpdateErrorCode.WalletNotFound, + Message = "Wallet not found." + }; + _validationServiceMock.Setup(v => v.ValidateInput(input, wallet.Id)).ReturnsAsync(failureValidation); + + // Act + var result = await service.Update(wallet.Id, input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.WalletNotFound); + result.Data.Should().BeFalse(); + + var dbWallet = await resources.WalletRepository.Query(false).FirstAsync(a => a.Id == wallet.Id); + dbWallet.Name.Should().Be(TestUtils.Strings[0]); // Ensure no update happened + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnSuccess_WhenValid() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + + var successValidation = new ValidationResultDto { Success = true }; + _validationServiceMock.Setup(v => v.ValidateDelete(wallet.Id)).ReturnsAsync(successValidation); + + // Act + var result = await service.Delete(wallet.Id, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + (await resources.WalletRepository.Query(false).FirstOrDefaultAsync(a => a.Id == wallet.Id)).Should().BeNull(); + } + + [Fact] + public async Task Delete_ShouldReturnFailure_WhenValidationFails() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + + var failureValidation = new ValidationResultDto + { + Success = false, + ErrorCode = WalletDeleteErrorCode.WalletInUseByCreditCardsAndTitle, + Message = "Cannot delete wallet with transactions." + }; + _validationServiceMock.Setup(v => v.ValidateDelete(wallet.Id)).ReturnsAsync(failureValidation); + + // Act + var result = await service.Delete(wallet.Id, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletDeleteErrorCode.WalletInUseByCreditCardsAndTitle); + (await resources.WalletRepository.Query(false).FirstOrDefaultAsync(a => a.Id == wallet.Id)).Should().NotBeNull(); // Ensure no deletion + } + + #endregion + + #region ToggleInactive + + [Fact] + public async Task ToggleInactive_ShouldReturnSuccess_WhenValidAndDeactivate() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + wallet.Inactivated.Should().BeFalse(); + + var successValidation = new ValidationResultDto { Success = true }; + _validationServiceMock.Setup(v => v.ValidateToggleInactive(wallet.Id)).ReturnsAsync(successValidation); + + // Act + var result = await service.ToggleInactive(wallet.Id, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + var dbWallet = await resources.WalletRepository.Query(false).FirstAsync(a => a.Id == wallet.Id); + dbWallet.Inactivated.Should().BeTrue(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnSuccess_WhenValidAndReactivate() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + wallet.ToggleInactivated(); + await resources.WalletRepository.AddAsync(wallet, true); + wallet.Inactivated.Should().BeTrue(); + + var successValidation = new ValidationResultDto { Success = true }; + _validationServiceMock.Setup(v => v.ValidateToggleInactive(wallet.Id)).ReturnsAsync(successValidation); + + // Act + var result = await service.ToggleInactive(wallet.Id, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + var dbWallet = await resources.WalletRepository.Query(false).FirstAsync(a => a.Id == wallet.Id); + dbWallet.Inactivated.Should().BeFalse(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnFailure_WhenValidationFails() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + wallet.Inactivated.Should().BeFalse(); + + var failureValidation = new ValidationResultDto + { + Success = false, + ErrorCode = WalletToggleInactiveErrorCode.WalletNotFound, + Message = "Wallet not found." + }; + _validationServiceMock.Setup(v => v.ValidateToggleInactive(wallet.Id)).ReturnsAsync(failureValidation); + + // Act + var result = await service.ToggleInactive(wallet.Id, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletToggleInactiveErrorCode.WalletNotFound); + var dbWallet = await resources.WalletRepository.Query(false).FirstAsync(a => a.Id == wallet.Id); + dbWallet.Inactivated.Should().BeFalse(); // Ensure no status change + } + + #endregion + + private WalletService GetService(Resources resources) + { + return new WalletService(resources.WalletRepository, _validationServiceMock.Object); + } + + private Resources GetResources() + { + return new Resources + { + WalletRepository = GetRepository() + }; + } + + private class Resources + { + public IRepository WalletRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs b/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs new file mode 100644 index 0000000..1e49ba3 --- /dev/null +++ b/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs @@ -0,0 +1,464 @@ +using Fin.Application.FinancialInstitutions; +using Fin.Application.Wallets.Enums; +using Fin.Application.Wallets.Services; +using Fin.Domain.FinancialInstitutions.Dtos; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Moq; + +namespace Fin.Test.Wallets.Services; + +public class WalletValidationServiceTest : TestUtils.BaseTestWithContext +{ + + + private WalletValidationService GetService(Resources resources) + { + return new WalletValidationService(resources.WalletRepository, resources.FakeFinancialInstitution.Object); + } + + private Resources GetResources() + { + return new Resources + { + WalletRepository = GetRepository(), + FakeFinancialInstitution = new Mock() + }; + } + + private class Resources + { + public IRepository WalletRepository { get; set; } + public Mock FakeFinancialInstitution { get; set; } + } + + #region ValidateToggleInactive + + [Fact] + public async Task ValidateToggleInactive_ShouldReturnSuccess_WhenWalletExists() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + + // Act + var result = await service.ValidateToggleInactive(wallet.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateToggleInactive_ShouldReturnFailure_WhenWalletNotFound() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var nonExistentId = TestUtils.Guids[9]; + + // Act + var result = await service.ValidateToggleInactive(nonExistentId); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletToggleInactiveErrorCode.WalletNotFound); + result.Message.Should().Be("Wallet not found to toogle inactive."); + } + + #endregion + + + + #region ValidateDelete + + [Fact] + public async Task ValidateDelete_ShouldReturnSuccess_WhenWalletExists() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + + // Act + var result = await service.ValidateDelete(wallet.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateDelete_ShouldReturnFailure_WhenWalletNotFound() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var nonExistentId = TestUtils.Guids[9]; + + // Act + var result = await service.ValidateDelete(nonExistentId); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletDeleteErrorCode.WalletNotFound); + result.Message.Should().Be("Wallet not found to delete."); + } + + #endregion + + + + #region ValidateInput (Create and Update) + + // Helper to create a valid input + private WalletInput GetValidInput() => new() + { + Name = "New Wallet", + Color = "#FFFFFF", + Icon = "fa-icon", + InitialBalance = 0m, + FinancialInstitutionId = null // Default to null for base tests + }; + + [Fact] + public async Task ValidateInput_Create_ShouldReturnSuccess_WhenValid() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateInput_ShouldReturnSuccess_WhenFinancialInstitutionIdIsNull() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.FinancialInstitutionId = null; + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateInput_ShouldReturnSuccess_WhenFinancialInstitutionIsValid() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.FinancialInstitutionId = TestUtils.Guids[1]; + + // Mock a valid, active institution + var activeInstitution = new FinancialInstitutionOutput { Id = TestUtils.Guids[1], Inactive = false }; + resources.FakeFinancialInstitution.Setup(s => s.Get(TestUtils.Guids[1])).ReturnsAsync(activeInstitution); + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenFinancialInstitutionNotFound() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.FinancialInstitutionId = TestUtils.Guids[1]; + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.FinancialInstitutionNotFound); + result.Message.Should().Be("Financial institution not found."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenFinancialInstitutionInactivated() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.FinancialInstitutionId = TestUtils.Guids[1]; + + // Mock: Institution found but inactive + var inactiveInstitution = new FinancialInstitutionOutput { Id = TestUtils.Guids[1], Inactive = true }; + resources.FakeFinancialInstitution.Setup(s => s.Get(TestUtils.Guids[1])).ReturnsAsync(inactiveInstitution); + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.FinancialInstitutionInactivated); + result.Message.Should().Be("Financial institution is inactive."); + } + + + [Fact] + public async Task ValidateInput_Update_ShouldReturnSuccess_WhenValid() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var wallet = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 100 }); + await resources.WalletRepository.AddAsync(wallet, true); + var input = GetValidInput(); + + // Act + var result = await service.ValidateInput(input, wallet.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateInput_Update_ShouldReturnFailure_WhenWalletNotFound() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var nonExistentId = TestUtils.Guids[9]; + var input = GetValidInput(); + + // Act + var result = await service.ValidateInput(input, nonExistentId); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.WalletNotFound); + result.Message.Should().Be("Wallet not found to edit."); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateInput_ShouldReturnFailure_WhenNameIsRequired(string name) + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Name = name; + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.NameIsRequired); + result.Message.Should().Be("Name is required."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenNameTooLong() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Name = new string('A', 101); // Max 100 + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.NameTooLong); + result.Message.Should().Be("Name is too long. Max 100 characters."); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateInput_ShouldReturnFailure_WhenColorIsRequired(string color) + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Color = color; + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.ColorIsRequired); + result.Message.Should().Be("Color is required."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenColorTooLong() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Color = new string('A', 21); // Max 20 + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.ColorTooLong); + result.Message.Should().Be("Color is too long. Max 20 characters."); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateInput_ShouldReturnFailure_WhenIconIsRequired(string icon) + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Icon = icon; + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.IconIsRequired); + result.Message.Should().Be("Icon is required."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenIconTooLong() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Icon = new string('A', 21); // Max 20 + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.IconTooLong); + result.Message.Should().Be("Icon is too long. Max 20 characters."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenNameAlreadyInUseOnCreate() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var existingName = TestUtils.Strings[0]; + + // Existing Wallet + await resources.WalletRepository.AddAsync(new Wallet(new WalletInput { Name = existingName, Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 0m }), true); + + var input = GetValidInput(); + input.Name = existingName; + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.NameAlreadyInUse); + result.Message.Should().Be("Name is already in use."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnSuccess_WhenNameAlreadyInUseBySelfOnUpdate() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var existingName = TestUtils.Strings[0]; + + // Existing Wallet + var wallet = new Wallet(new WalletInput { Name = existingName, Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 0m }); + await resources.WalletRepository.AddAsync(wallet, true); + + var input = GetValidInput(); + input.Name = existingName; // Using the same name + + // Act + var result = await service.ValidateInput(input, wallet.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenNameAlreadyInUseByAnotherWalletOnUpdate() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + + // Wallet A - Its name will be the "forbidden" name + var walletA = new Wallet(new WalletInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], InitialBalance = 0m }); + await resources.WalletRepository.AddAsync(walletA, true); + + // Wallet B - Will try to use Wallet A's name + var walletB = new Wallet(new WalletInput { Name = TestUtils.Strings[3], Color = TestUtils.Strings[4], Icon = TestUtils.Strings[5], InitialBalance = 0m }); + await resources.WalletRepository.AddAsync(walletB, true); + + var input = GetValidInput(); + input.Name = walletA.Name; // Name already used by A + + // Act + var result = await service.ValidateInput(input, walletB.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.NameAlreadyInUse); + result.Message.Should().Be("Name is already in use."); + } + + #endregion +} \ No newline at end of file