diff --git a/Fin-Backend.sln.DotSettings.user b/Fin-Backend.sln.DotSettings.user index 6ef6c84..e037b2a 100644 --- a/Fin-Backend.sln.DotSettings.user +++ b/Fin-Backend.sln.DotSettings.user @@ -1,7 +1,14 @@  ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> diff --git a/Fin.Api/Titles/TitleController.cs b/Fin.Api/Titles/TitleController.cs new file mode 100644 index 0000000..d60ecfd --- /dev/null +++ b/Fin.Api/Titles/TitleController.cs @@ -0,0 +1,54 @@ +using Fin.Application.Titles.Dtos; +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Services; +using Fin.Domain.Global.Classes; +using Fin.Domain.Titles.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Fin.Api.Titles; + +[Route("titles")] +[Authorize] +public class TitleController(ITitleService service) : ControllerBase +{ + [HttpGet] + public async Task> GetList([FromQuery] TitleGetListInput 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] TitleInput 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] TitleInput input) + { + var validationResult = await service.Update(id, input, autoSave: true); + return validationResult.Success ? Ok() : + validationResult.ErrorCode == TitleCreateOrUpdateErrorCode.TitleNotFound ? 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 == TitleDeleteErrorCode.TitleNotFound ? NotFound(validationResult) : + UnprocessableEntity(validationResult); + } +} \ No newline at end of file diff --git a/Fin.Application/Globals/Dtos/ValidationResultDto.cs b/Fin.Application/Globals/Dtos/ValidationResultDto.cs index 388284f..f13f09b 100644 --- a/Fin.Application/Globals/Dtos/ValidationResultDto.cs +++ b/Fin.Application/Globals/Dtos/ValidationResultDto.cs @@ -1,18 +1,130 @@ -namespace Fin.Application.Globals.Dtos; +#nullable enable +using Fin.Infrastructure.Errors; +using Fin.Infrastructure.ValidationsPipeline; -public class ValidationResultDto +namespace Fin.Application.Globals.Dtos; + +public class ValidationResultDto where TErroCode : struct, Enum +{ + public TDSuccess? Data { get; set; } + public TDError? ErrorData { get; set; } + public TErroCode? ErrorCode { get; set; } + + public bool Success + { + get + { + if (InternalSuccess.HasValue) return InternalSuccess.Value; + return ErrorCode == null; + } + set => InternalSuccess = value; + } + + public string Message + { + get + { + if (!string.IsNullOrWhiteSpace(InternalMessage)) + { + return InternalMessage; + } + + if (Success) return "Success"; + + return ErrorCode.HasValue ? ErrorCode.Value.GetErrorMessage() : string.Empty;; + } + set => InternalMessage = value; + } + + protected bool? InternalSuccess { get; set; } + protected string? InternalMessage { get; set; } + + public ValidationResultDto WithError(TErroCode errorCode, string? message = null) + { + ErrorCode = errorCode; + InternalMessage = message; + return this; + } + + public ValidationResultDto WithError(TErroCode errorCode, TDError errorData, + string? message = null) + { + ErrorCode = errorCode; + ErrorData = errorData; + InternalMessage = message; + return this; + } + + public ValidationResultDto WithSuccess(TDSuccess successData) + { + Data = successData; + return this; + } + + public static ValidationResultDto FromPipeline( + ValidationPipelineOutput pipelineOutput) + { + return new ValidationResultDto + { + ErrorData = pipelineOutput.Data, + ErrorCode = pipelineOutput.Code, + }; + } +} + +public class ValidationResultDto : ValidationResultDto + where TErroCode : struct, Enum { - public D? Data { get; set; } - public string Message { get; set; } - public bool Success { get; set; } - public E? ErrorCode { get; set; } + public new ValidationResultDto WithError(TErroCode errorCode, string? message = null) + { + ErrorCode = errorCode; + InternalMessage = message; + return this; + } + + public new ValidationResultDto WithSuccess(TDSuccess successData) + { + Data = successData; + return this; + } + + public static ValidationResultDto FromPipeline( + ValidationPipelineOutput pipelineOutput) + { + return new ValidationResultDto + { + ErrorCode = pipelineOutput.Code, + }; + } } -public class ValidationResultDto +public enum NoErrorCode { - public D? Data { get; set; } - public string Message { get; set; } - public bool Success { get; set; } - public Enum? ErrorCode { get; set; } + None = 0 } +public class ValidationResultDto : ValidationResultDto +{ + public new ValidationResultDto WithSuccess(TDSuccess successData) + { + Data = successData; + return this; + } +} + +public static class ValidationResultDtoExtensions +{ + public static ValidationResultDto ToValidationResult( + this ValidationPipelineOutput pipeline) + where TErrorCode : struct, Enum + { + return ValidationResultDto.FromPipeline(pipeline); + } + + public static ValidationResultDto ToValidationResult( + this ValidationPipelineOutput pipeline) + where TErrorCode : struct, Enum + { + return ValidationResultDto.FromPipeline(pipeline); + } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Dtos/TitleGetListInput.cs b/Fin.Application/Titles/Dtos/TitleGetListInput.cs new file mode 100644 index 0000000..aa4b713 --- /dev/null +++ b/Fin.Application/Titles/Dtos/TitleGetListInput.cs @@ -0,0 +1,13 @@ +using Fin.Domain.Global.Classes; +using Fin.Domain.Global.Enums; +using Fin.Domain.Titles.Enums; + +namespace Fin.Application.Titles.Dtos; + +public class TitleGetListInput: PagedFilteredAndSortedInput +{ + public List CategoryIds { get; set; } = []; + public MultiplyFilterOperator CategoryOperator { get; set; } + public List WalletIds { get; set; } = []; + public TitleType? Type { get; set; } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Enums/TitleCreateOrUpdateErrorCode.cs b/Fin.Application/Titles/Enums/TitleCreateOrUpdateErrorCode.cs new file mode 100644 index 0000000..4671e8b --- /dev/null +++ b/Fin.Application/Titles/Enums/TitleCreateOrUpdateErrorCode.cs @@ -0,0 +1,39 @@ +using Fin.Infrastructure.Errors; + +namespace Fin.Application.Titles.Enums; + +public enum TitleCreateOrUpdateErrorCode +{ + [ErrorMessage("Title not found")] + TitleNotFound = 0, + + [ErrorMessage("Description must have less than 100 characters.")] + DescriptionTooLong = 1, + + [ErrorMessage("Description is required.")] + DescriptionIsRequired = 2, + + [ErrorMessage("Wallet not found")] + WalletNotFound = 3, + + [ErrorMessage("Wallet is inactive")] + WalletInactive = 4, + + [ErrorMessage("Title date must be equal or after wallet creation date.")] + TitleDateMustBeEqualOrAfterWalletCreation = 5, + + [ErrorMessage("Some categories was not found")] + SomeCategoriesNotFound = 6, + + [ErrorMessage("Some categories is inactive")] + SomeCategoriesInactive = 7, + + [ErrorMessage("Some categories has incompatible types")] + SomeCategoriesHasIncompatibleTypes = 8, + + [ErrorMessage("Value must be greater than zero.")] + ValueMustBeGraterThanZero = 9, + + [ErrorMessage("Duplicated title in same date time until minute.")] + DuplicateTitleInSameDateTimeMinute = 10, +} \ No newline at end of file diff --git a/Fin.Application/Titles/Enums/TitleDeleteErrorCode.cs b/Fin.Application/Titles/Enums/TitleDeleteErrorCode.cs new file mode 100644 index 0000000..eacee6b --- /dev/null +++ b/Fin.Application/Titles/Enums/TitleDeleteErrorCode.cs @@ -0,0 +1,9 @@ +using Fin.Infrastructure.Errors; + +namespace Fin.Application.Titles.Enums; + +public enum TitleDeleteErrorCode +{ + [ErrorMessage("Title not found")] + TitleNotFound = 0, +} diff --git a/Fin.Application/Titles/Services/TitleService.cs b/Fin.Application/Titles/Services/TitleService.cs new file mode 100644 index 0000000..377ad2a --- /dev/null +++ b/Fin.Application/Titles/Services/TitleService.cs @@ -0,0 +1,137 @@ +using Fin.Application.Globals.Dtos; +using Fin.Application.Titles.Dtos; +using Fin.Application.Titles.Enums; +using Fin.Application.Wallets.Services; +using Fin.Domain.Global.Classes; +using Fin.Domain.Global.Enums; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Extensions; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Extensions; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.UnitOfWorks; +using Fin.Infrastructure.ValidationsPipeline; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Services; + +public interface ITitleService +{ + public Task Get(Guid id, CancellationToken cancellationToken = default); + + public Task> GetList(TitleGetListInput input, + CancellationToken cancellationToken = default); + + public Task> Create(TitleInput input, + bool autoSave = false, CancellationToken cancellationToken = default); + + public Task> Update(Guid id, TitleInput input, + bool autoSave = false, CancellationToken cancellationToken = default); + + public Task> Delete(Guid id, bool autoSave = false, + CancellationToken cancellationToken = default); +} + +public class TitleService( + IRepository titleRepository, + ITitleUpdateHelpService updateHelpService, + IWalletBalanceService balanceService, + IUnitOfWork unitOfWork, + IValidationPipelineOrchestrator validation +) : ITitleService, IAutoTransient +{ + public async Task<TitleOutput> Get(Guid id, CancellationToken cancellationToken = default) + { + var entity = await titleRepository.Query(false) + .Include(title => title.TitleCategories) + .FirstOrDefaultAsync(n => n.Id == id, cancellationToken); + return entity != null ? new TitleOutput(entity) : null; + } + + public async Task<PagedOutput<TitleOutput>> GetList(TitleGetListInput input, + CancellationToken cancellationToken = default) + { + return await titleRepository.Query(false) + .Include(title => title.TitleCategories) + .WhereIf(input.Type.HasValue, n => n.Type == input.Type) + .WhereIf(input.WalletIds.Any(), title => input.WalletIds.Contains(title.WalletId)) + .WhereIf(input.CategoryIds.Any() && input.CategoryOperator == MultiplyFilterOperator.And, title => + input.CategoryIds.All(id => title.TitleCategories.Any(c => c.Id == id))) + .WhereIf(input.CategoryIds.Any() && input.CategoryOperator == MultiplyFilterOperator.Or, + title => title.TitleCategories.Any(titleCategory => input.CategoryIds.Contains(titleCategory.Id))) + .ApplyDefaultTitleOrder() + .ApplyFilterAndSorter(input) + .Select(n => new TitleOutput(n)) + .ToPagedResult(input, cancellationToken); + } + + public async Task<ValidationResultDto<TitleOutput, TitleCreateOrUpdateErrorCode>> Create(TitleInput input, + bool autoSave = false, CancellationToken cancellationToken = default) + { + var validationResult = await ValidateInput<TitleOutput>(input, null, cancellationToken); + if (!validationResult.Success) return validationResult; + + var previousBalance = await balanceService.GetBalanceAt(input.WalletId, input.Date, cancellationToken); + var title = new Title(input, previousBalance); + + await using var scope = await unitOfWork.BeginTransactionAsync(cancellationToken); + await titleRepository.AddAsync(title, cancellationToken); + await balanceService.ReprocessBalanceFrom(title, autoSave: false, cancellationToken); + if (autoSave) await scope.CompleteAsync(cancellationToken); + + validationResult.Data = new TitleOutput(title); + return validationResult; + } + + public async Task<ValidationResultDto<bool, TitleCreateOrUpdateErrorCode>> Update(Guid id, TitleInput input, + bool autoSave = false, CancellationToken cancellationToken = default) + { + var validationResult = await ValidateInput<bool>(input, id, cancellationToken); + if (!validationResult.Success) return validationResult; + + var title = await titleRepository + .Include(title => title.TitleTitleCategories) + .FirstAsync(title => title.Id == id, cancellationToken); + var mustReprocess = title.MustReprocess(input); + + var context = await updateHelpService.PrepareUpdateContext(title, input, mustReprocess, cancellationToken); + + await using (var scope = await unitOfWork.BeginTransactionAsync(cancellationToken)) + { + await updateHelpService.UpdateTitleAndCategories(title, input, context.CategoriesToRemove, cancellationToken); + if (mustReprocess) await updateHelpService.ReprocessAffectedWallets(title, context, autoSave: false, cancellationToken); + if (autoSave) await scope.CompleteAsync(cancellationToken); + } + + return validationResult.WithSuccess(true); + } + + public async Task<ValidationResultDto<bool, TitleDeleteErrorCode>> Delete(Guid id, bool autoSave = false, + CancellationToken cancellationToken = default) + { + var validationResult = await validation.Validate<Guid, TitleDeleteErrorCode>(id, null, cancellationToken); + var validationResultDto = validationResult.ToValidationResult<bool, TitleDeleteErrorCode>(); + if (!validationResultDto.Success) return validationResultDto; + + var title = await titleRepository.Query(tracking: true).FirstAsync(title => title.Id == id, cancellationToken); + var titlesToReprocess = await updateHelpService.GetTitlesForReprocessing(title.WalletId, title.Date, title.Id, cancellationToken); + + await using (var scope = await unitOfWork.BeginTransactionAsync(cancellationToken)) + { + await titleRepository.DeleteAsync(title, cancellationToken); + await balanceService.ReprocessBalance(titlesToReprocess, title.PreviousBalance, autoSave: false, cancellationToken); + if (autoSave) await scope.CompleteAsync(cancellationToken); + } + + return validationResultDto.WithSuccess(true); + } + + private async Task<ValidationResultDto<TSuccess, TitleCreateOrUpdateErrorCode>> ValidateInput<TSuccess>( + TitleInput input, Guid? editingId = null, CancellationToken cancellationToken = default) + { + var validationResult = + await validation.Validate<TitleInput, TitleCreateOrUpdateErrorCode>(input, editingId, cancellationToken); + return validationResult.ToValidationResult<TSuccess, TitleCreateOrUpdateErrorCode>(); + } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Services/TitleUpdateHelpService.cs b/Fin.Application/Titles/Services/TitleUpdateHelpService.cs new file mode 100644 index 0000000..efb4de5 --- /dev/null +++ b/Fin.Application/Titles/Services/TitleUpdateHelpService.cs @@ -0,0 +1,187 @@ +using Fin.Application.Wallets.Services; +using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Services; + +public interface ITitleUpdateHelpService +{ + Task UpdateTitleAndCategories( + Title title, + TitleInput input, + List<TitleTitleCategory> categoriesToRemove, + CancellationToken cancellationToken); + + Task<UpdateTitleContext> PrepareUpdateContext( + Title title, + TitleInput input, + bool mustReprocess, + CancellationToken cancellationToken); + + Task<decimal> CalculatePreviousBalance( + Title title, + TitleInput input, + CancellationToken cancellationToken); + + Task ReprocessAffectedWallets( + Title title, + UpdateTitleContext titleContext, + bool autoSave, + CancellationToken cancellationToken); + + Task ReprocessPreviousWallet( + Title title, + UpdateTitleContext titleContext, + bool autoSave, + CancellationToken cancellationToken); + + Task ReprocessCurrentWallet( + Title title, + UpdateTitleContext titleContext, + bool autoSave, + CancellationToken cancellationToken); + + Task<List<Title>> GetTitlesForReprocessing( + Guid walletId, + DateTime fromDate, + Guid afterTitleId, + CancellationToken cancellationToken); +} + +public class TitleUpdateHelpService( + IRepository<Title> titleRepository, + IRepository<TitleTitleCategory> titleTitleCategoryRepository, + IWalletBalanceService balanceService +): ITitleUpdateHelpService, IAutoTransient +{ + public async Task UpdateTitleAndCategories( + Title title, + TitleInput input, + List<TitleTitleCategory> categoriesToRemove, + CancellationToken cancellationToken) + { + await titleRepository.UpdateAsync(title, cancellationToken); + foreach (var category in categoriesToRemove) + { + await titleTitleCategoryRepository.DeleteAsync(category, cancellationToken); + } + } + + public async Task<UpdateTitleContext> PrepareUpdateContext( + Title title, + TitleInput input, + bool mustReprocess, + CancellationToken cancellationToken) + { + var previousBalance = mustReprocess + ? await CalculatePreviousBalance(title, input, cancellationToken) + : title.PreviousBalance; + + var categoriesToRemove = title.UpdateAndReturnCategoriesToRemove(input, previousBalance); + + return new UpdateTitleContext( + PreviousWalletId: title.WalletId, + PreviousDate: title.Date, + PreviousBalance: title.PreviousBalance, + CategoriesToRemove: categoriesToRemove + ); + } + + public async Task<decimal> CalculatePreviousBalance( + Title title, + TitleInput input, + CancellationToken cancellationToken) + { + var balance = await balanceService.GetBalanceAt(input.WalletId, input.Date, cancellationToken); + + var isSameWallet = title.WalletId == input.WalletId; + var shouldAdjustBalance = isSameWallet && title.Date <= input.Date; + + return shouldAdjustBalance + ? balance - title.EffectiveValue + : balance; + } + + public async Task ReprocessAffectedWallets( + Title title, + UpdateTitleContext titleContext, + bool autoSave, + CancellationToken cancellationToken) + { + await ReprocessCurrentWallet(title, titleContext, autoSave, cancellationToken); + + if (titleContext.PreviousWalletId != title.WalletId) + { + await ReprocessPreviousWallet(title, titleContext, autoSave, cancellationToken); + } + } + + public async Task ReprocessPreviousWallet( + Title title, + UpdateTitleContext titleContext, + bool autoSave, + CancellationToken cancellationToken) + { + var titlesToReprocess = await GetTitlesForReprocessing( + titleContext.PreviousWalletId, + titleContext.PreviousDate, + title.Id, + cancellationToken); + + await balanceService.ReprocessBalance( + titlesToReprocess, + titleContext.PreviousBalance, + autoSave, + cancellationToken); + } + + public async Task ReprocessCurrentWallet( + Title title, + UpdateTitleContext titleContext, + bool autoSave, + CancellationToken cancellationToken) + { + var walletChanged = titleContext.PreviousWalletId != title.WalletId; + var reprocessFrom = walletChanged + ? title.Date + : titleContext.PreviousDate > title.Date + ? title.Date + : titleContext.PreviousDate; + + var titlesToReprocess = await GetTitlesForReprocessing( + title.WalletId, + reprocessFrom, + title.Id, + cancellationToken); + + await balanceService.ReprocessBalance( + titlesToReprocess, + title.ResultingBalance, + autoSave, + cancellationToken); + } + + public async Task<List<Title>> GetTitlesForReprocessing( + Guid walletId, + DateTime fromDate, + Guid afterTitleId, + CancellationToken cancellationToken) + { + return await titleRepository + .Where(t => t.WalletId == walletId) + .Where(t => t.Date >= fromDate && t.Id > afterTitleId) + .ToListAsync(cancellationToken); + } +} + +public record UpdateTitleContext( + Guid PreviousWalletId, + DateTime PreviousDate, + decimal PreviousBalance, + List<TitleTitleCategory> CategoriesToRemove +); + diff --git a/Fin.Application/Titles/Validations/Deletes/TitleDeleteMustExistValidation.cs b/Fin.Application/Titles/Validations/Deletes/TitleDeleteMustExistValidation.cs new file mode 100644 index 0000000..2df9820 --- /dev/null +++ b/Fin.Application/Titles/Validations/Deletes/TitleDeleteMustExistValidation.cs @@ -0,0 +1,18 @@ +using Fin.Application.Titles.Enums; +using Fin.Domain.Titles.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.ValidationsPipeline; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Validations.Deletes; + +public class TitleDeleteMustExistValidation(IRepository<Title> titleRepository): IValidationRule<Guid, TitleDeleteErrorCode>, IAutoTransient +{ + public async Task<ValidationPipelineOutput<TitleDeleteErrorCode>> ValidateAsync(Guid titleId, Guid? _, CancellationToken cancellationToken = default) + { + var validation = new ValidationPipelineOutput<TitleDeleteErrorCode>(); + var title = await titleRepository.Query(tracking: false).FirstOrDefaultAsync(t => t.Id == titleId, cancellationToken); + return title == null ? validation.AddError(TitleDeleteErrorCode.TitleNotFound) : validation; + } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputBasicFieldsValidation.cs b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputBasicFieldsValidation.cs new file mode 100644 index 0000000..bb22c83 --- /dev/null +++ b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputBasicFieldsValidation.cs @@ -0,0 +1,28 @@ +using Fin.Application.Titles.Enums; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.ValidationsPipeline; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Validations.UpdateOrCrestes; + +public class TitleInputBasicFieldsValidation: IValidationRule<TitleInput, TitleCreateOrUpdateErrorCode>, IAutoTransient +{ + public async Task<ValidationPipelineOutput<TitleCreateOrUpdateErrorCode>> ValidateAsync(TitleInput input, Guid? _, CancellationToken __ = default) + { + var validation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode>(); + + if (string.IsNullOrWhiteSpace(input.Description)) + validation.AddError(TitleCreateOrUpdateErrorCode.DescriptionIsRequired); + else if (input.Description.Length > 100) + validation.AddError(TitleCreateOrUpdateErrorCode.DescriptionTooLong); + + if (input.Value <= 0) + validation.AddError(TitleCreateOrUpdateErrorCode.ValueMustBeGraterThanZero); + await Task.CompletedTask; + + return validation; + } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputCategoriesValidation.cs b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputCategoriesValidation.cs new file mode 100644 index 0000000..b989175 --- /dev/null +++ b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputCategoriesValidation.cs @@ -0,0 +1,89 @@ +using Fin.Application.Titles.Enums; +using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.TitleCategories.Enums; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.ValidationsPipeline; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Validations.UpdateOrCrestes; + +public class TitleInputCategoriesValidation( + IRepository<Title> titleRepository, + IRepository<TitleCategory> categoryRepository + ): IValidationRule<TitleInput, TitleCreateOrUpdateErrorCode, List<Guid>>, IAutoTransient +{ + public async Task<ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>>> ValidateAsync(TitleInput input, Guid? editingId = null, CancellationToken cancellationToken = default) + { + var validation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>>(); + + var categories = await categoryRepository + .Where(category => input.TitleCategoriesIds.Contains(category.Id)) + .ToListAsync(cancellationToken); + + ValidateCategoriesExistence(input, categories, validation); + if (!validation.Success) return validation; + + var titleEditing = !editingId.HasValue ? null : await titleRepository + .Include(title => title.TitleTitleCategories) + .FirstOrDefaultAsync(title => title.Id == editingId.Value, cancellationToken); + ValidateCategoriesStatus(titleEditing, categories, validation); + if (!validation.Success) return validation; + + ValidateCategoriesCompatibility(input, categories, validation); + + return validation; + } + + private void ValidateCategoriesExistence( + TitleInput input, + List<TitleCategory> categories, + ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>> validation) + { + var foundCategoriesIds = categories.Select(category => category.Id).ToList(); + var notFoundCategories = input.TitleCategoriesIds + .Except(foundCategoriesIds) + .ToList(); + + if (notFoundCategories.Any()) + validation.AddError(TitleCreateOrUpdateErrorCode.SomeCategoriesNotFound, notFoundCategories); + } + + private void ValidateCategoriesStatus( + Title? titleEditing, + List<TitleCategory> categories, + ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>> validation) + { + var previousCategoriesIds = titleEditing?.TitleTitleCategories? + .Select(tc => tc.TitleCategoryId)? + .ToList() ?? new List<Guid>(); + + var inactiveCategoriesIds = categories + .Where(category => category.Inactivated + && !previousCategoriesIds.Contains(category.Id)) + .Select(category => category.Id) + .ToList(); + + if (inactiveCategoriesIds.Any()) + validation.AddError(TitleCreateOrUpdateErrorCode.SomeCategoriesInactive, inactiveCategoriesIds); + } + + private void ValidateCategoriesCompatibility( + TitleInput input, + List<TitleCategory> categories, + ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>> validation) + { + var incompatibleCategories = categories + .Where(category => !category.Type.IsCompatible(input.Type)) + .Select(c => c.Id) + .ToList(); + + if (incompatibleCategories.Any()) + validation.AddError( + TitleCreateOrUpdateErrorCode.SomeCategoriesHasIncompatibleTypes, + incompatibleCategories + ); + } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputDuplicatedValidation.cs b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputDuplicatedValidation.cs new file mode 100644 index 0000000..a8d2124 --- /dev/null +++ b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputDuplicatedValidation.cs @@ -0,0 +1,33 @@ +using Fin.Application.Titles.Enums; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.ValidationsPipeline; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Validations.UpdateOrCrestes; + +public class TitleInputDuplicatedValidation(IRepository<Title> titleRepository): IValidationRule<TitleInput, TitleCreateOrUpdateErrorCode>, IAutoTransient +{ + public async Task<ValidationPipelineOutput<TitleCreateOrUpdateErrorCode>> ValidateAsync(TitleInput input, Guid? editingId = null, CancellationToken cancellationToken = default) + { + var validation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode>(); + + var duplicateExists = await titleRepository.Query(tracking: false) + .Where(t => t.Description == input.Description.Trim() + && t.WalletId == input.WalletId + && t.Date.Year == input.Date.Year + && t.Date.Month == input.Date.Month + && t.Date.Day == input.Date.Day + && t.Date.Hour == input.Date.Hour + && t.Date.Minute == input.Date.Minute + && (!editingId.HasValue || t.Id != editingId.Value)) + .AnyAsync(cancellationToken); + + if (duplicateExists) + validation.AddError(TitleCreateOrUpdateErrorCode.DuplicateTitleInSameDateTimeMinute); + + return validation; + } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputMustExistValidation.cs b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputMustExistValidation.cs new file mode 100644 index 0000000..bfcffd9 --- /dev/null +++ b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputMustExistValidation.cs @@ -0,0 +1,21 @@ +using Fin.Application.Titles.Enums; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.ValidationsPipeline; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Validations.UpdateOrCrestes; + +public class TitleInputMustExistValidation(IRepository<Title> titleRepository): IValidationRule<TitleInput, TitleCreateOrUpdateErrorCode>, IAutoTransient +{ + public async Task<ValidationPipelineOutput<TitleCreateOrUpdateErrorCode>> ValidateAsync(TitleInput _, Guid? editingId = null, CancellationToken cancellationToken = default) + { + var validation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode>(); + if (!editingId.HasValue) return validation; + + var title = await titleRepository.Query(tracking: false).FirstOrDefaultAsync(t => t.Id == editingId, cancellationToken); + return title == null ? validation.AddError(TitleCreateOrUpdateErrorCode.TitleNotFound) : validation; + } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputWalletValidation.cs b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputWalletValidation.cs new file mode 100644 index 0000000..c5f6f7b --- /dev/null +++ b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputWalletValidation.cs @@ -0,0 +1,35 @@ +using Fin.Application.Titles.Enums; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.ValidationsPipeline; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Validations.UpdateOrCrestes; + +public class TitleInputWalletValidation(IRepository<Wallet> walletRepository): IValidationRule<TitleInput, TitleCreateOrUpdateErrorCode, List<Guid>>, IAutoTransient +{ + public async Task<ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>>> ValidateAsync(TitleInput input, Guid? _, CancellationToken cancellationToken = default) + { + var validation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>>(); + + var wallet = await walletRepository.Query(tracking: false) + .FirstOrDefaultAsync(t => t.Id == input.WalletId, cancellationToken); + + if (wallet == null) + { + validation.AddError(TitleCreateOrUpdateErrorCode.WalletNotFound); + return validation; + } + + if (wallet.Inactivated) + validation.AddError(TitleCreateOrUpdateErrorCode.WalletInactive); + + if (wallet.CreatedAt > input.Date) + validation.AddError(TitleCreateOrUpdateErrorCode.TitleDateMustBeEqualOrAfterWalletCreation); + + return validation; + } +} \ No newline at end of file diff --git a/Fin.Application/Users/Services/UserCreateService.cs b/Fin.Application/Users/Services/UserCreateService.cs index 3fd654f..5314079 100644 --- a/Fin.Application/Users/Services/UserCreateService.cs +++ b/Fin.Application/Users/Services/UserCreateService.cs @@ -240,7 +240,7 @@ private async Task<ValidationResultDto<UserDto>> ExecuteCreateUser(string creati InitialBalance = 0 }); - await using (await _unitOfWork.BeginTransactionAsync()) + await using (var scope = await _unitOfWork.BeginTransactionAsync()) { await _tenantRepository.AddAsync(tenant); firstWallet.TenantId = tenant.Id; @@ -249,7 +249,7 @@ private async Task<ValidationResultDto<UserDto>> ExecuteCreateUser(string creati await _credentialRepository.AddAsync(credential); await _userRememberUseSettingRepository.AddAsync(rememberUseSetting); await _notificationSettingsRepository.AddAsync(notificationSetting); - await _unitOfWork.CommitAsync(); + await scope.CompleteAsync(); } if (!string.IsNullOrWhiteSpace(creationToken)) diff --git a/Fin.Application/Users/Services/UserDeleteService.cs b/Fin.Application/Users/Services/UserDeleteService.cs index 033f3bd..96f3938 100644 --- a/Fin.Application/Users/Services/UserDeleteService.cs +++ b/Fin.Application/Users/Services/UserDeleteService.cs @@ -153,7 +153,7 @@ private async Task DeleteUser(Guid userId, CancellationToken cancellationToken = var notifications = notificationDeliveries.Select(n => n.Notification) .Where(n => n.UserDeliveries.Count == 1); - await using (await unitOfWork.BeginTransactionAsync(cancellationToken)) + await using (var scope = await unitOfWork.BeginTransactionAsync(cancellationToken)) { foreach (var notification in notifications) @@ -180,7 +180,7 @@ private async Task DeleteUser(Guid userId, CancellationToken cancellationToken = await emailSender.SendEmailAsync(userEmail, "Conta deletada", "Sua conta no FinApp foi deletada. Agora você não poderá mais acessar seus dados e eles foram removidos da plataforma."); - await unitOfWork.CommitAsync(cancellationToken); + await scope.CompleteAsync(cancellationToken); } } } \ No newline at end of file diff --git a/Fin.Application/Wallets/Enums/WalletDeleteErrorCode.cs b/Fin.Application/Wallets/Enums/WalletDeleteErrorCode.cs index e51230b..387055d 100644 --- a/Fin.Application/Wallets/Enums/WalletDeleteErrorCode.cs +++ b/Fin.Application/Wallets/Enums/WalletDeleteErrorCode.cs @@ -1,9 +1,18 @@ +using Fin.Infrastructure.Errors; + namespace Fin.Application.Wallets.Enums; public enum WalletDeleteErrorCode { + [ErrorMessage("Wallet not found")] WalletNotFound = 0, + + [ErrorMessage("Wallet in use by titles")] WalletInUseByTitles = 1, + + [ErrorMessage("Wallet in use by credit cards")] WalletInUseByCreditCards = 2, + + [ErrorMessage("Wallet in use by credit card and titles")] WalletInUseByCreditCardsAndTitle = 3, } \ No newline at end of file diff --git a/Fin.Application/Wallets/Services/WalletBalaceService.cs b/Fin.Application/Wallets/Services/WalletBalaceService.cs new file mode 100644 index 0000000..c140ea3 --- /dev/null +++ b/Fin.Application/Wallets/Services/WalletBalaceService.cs @@ -0,0 +1,90 @@ +using System.Runtime.InteropServices.JavaScript; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Extensions; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.DateTimes; +using Fin.Infrastructure.UnitOfWorks; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Wallets.Services; + +public interface IWalletBalanceService +{ + public Task<decimal> GetBalanceAt(Guid walletId, DateTime dateTime, CancellationToken cancellationToken = default); + public Task<decimal> GetBalanceNow(Guid walletId, CancellationToken cancellationToken = default); + public Task ReprocessBalance(Guid walletId, decimal newInitialBalance, bool autoSave = false , CancellationToken cancellationToken = default); + public Task ReprocessBalance(Wallet wallet, bool autoSave = false , CancellationToken cancellationToken = default); + public Task ReprocessBalance(List<Title> titles, decimal newInitialBalance, bool autoSave = false, CancellationToken cancellationToken = default); + public Task ReprocessBalanceFrom(Title title, bool autoSave = false, CancellationToken cancellationToken = default); + public Task ReprocessBalanceFrom(Guid titleId, bool autoSave = false, CancellationToken cancellationToken = default); +} + +public class WalletBalanceService( + IRepository<Wallet> walletRepository, + IRepository<Title> titleRepository, + IDateTimeProvider dateTimeProvider, + IUnitOfWork unitOfWork + ): IWalletBalanceService, IAutoTransient +{ + public async Task<decimal> GetBalanceAt(Guid walletId, DateTime dateTime, CancellationToken cancellationToken = default) + { + var wallet = await walletRepository.Query(tracking: false) + .Include(wallet => wallet.Titles) + .FirstAsync(wallet => wallet.Id == walletId, cancellationToken); + return wallet.CalculateBalanceAt(dateTime); + } + + public Task<decimal> GetBalanceNow(Guid walletId, CancellationToken cancellationToken = default) + { + var now = dateTimeProvider.UtcNow(); + return GetBalanceAt(walletId, now, cancellationToken); + } + + public async Task ReprocessBalance(Guid walletId, decimal newInitialBalance, bool autoSave = false, CancellationToken cancellationToken = default) + { + var wallet = await walletRepository.Query() + .Include(wallet => wallet.Titles) + .FirstAsync(wallet => wallet.Id == walletId, cancellationToken); + await ReprocessBalance(wallet.Titles.ToList(), newInitialBalance, autoSave, cancellationToken); + } + + public async Task ReprocessBalance(Wallet wallet, bool autoSave = false, CancellationToken cancellationToken = default) + { + await ReprocessBalance(wallet.Titles.ToList(), wallet.InitialBalance, autoSave, cancellationToken); + } + + public async Task ReprocessBalance(List<Title> titles, decimal initialBalance, bool autoSave = false, CancellationToken cancellationToken = default) + { + var orderedTitles = titles.ApplyDefaultTitleOrder().Reverse().ToList(); + var nextPreviousBalance = initialBalance; + foreach (var title in orderedTitles) + { + title.PreviousBalance = nextPreviousBalance; + nextPreviousBalance = title.ResultingBalance; + } + + await using var scope = await unitOfWork.BeginTransactionAsync(cancellationToken); + foreach (var title in orderedTitles) + await titleRepository.UpdateAsync(title, cancellationToken); + if (autoSave) await scope.CompleteAsync(cancellationToken); + } + + public async Task ReprocessBalanceFrom(Title fromTitle, bool autoSave = false, CancellationToken cancellationToken = default) + { + var titles = await titleRepository.Query(tracking: true) + .Where(title => title.WalletId == fromTitle.WalletId) + .Where(title => title.Date >= fromTitle.Date) + .Where(title => title.Id > fromTitle.Id) + .ToListAsync(cancellationToken); + await ReprocessBalance(titles, fromTitle.ResultingBalance, autoSave, cancellationToken); + } + + public async Task ReprocessBalanceFrom(Guid titleId, bool autoSave = false, CancellationToken cancellationToken = default) + { + var title = await titleRepository.Query(tracking: false) + .FirstAsync(title => title.Id == titleId, cancellationToken); + await ReprocessBalanceFrom(title, autoSave, cancellationToken); + } +} \ No newline at end of file diff --git a/Fin.Application/Wallets/Services/WalletService.cs b/Fin.Application/Wallets/Services/WalletService.cs index 5cf93f0..b08c2dd 100644 --- a/Fin.Application/Wallets/Services/WalletService.cs +++ b/Fin.Application/Wallets/Services/WalletService.cs @@ -7,6 +7,7 @@ using Fin.Infrastructure.AutoServices.Interfaces; using Fin.Infrastructure.Database.Extensions; using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.DateTimes; using Microsoft.EntityFrameworkCore; namespace Fin.Application.Wallets.Services; @@ -23,23 +24,26 @@ public interface IWalletService public class WalletService( IRepository<Wallet> repository, - IWalletValidationService validationService + IWalletValidationService validationService, + IDateTimeProvider dateTimeProvider ) : IWalletService, IAutoTransient { public async Task<WalletOutput> Get(Guid id) { - var entity = await repository.Query(false).FirstOrDefaultAsync(n => n.Id == id); - return entity != null ? new WalletOutput(entity) : null; + var entity = await repository.Query(false).Include(wallet => wallet.Titles).FirstOrDefaultAsync(n => n.Id == id); + return entity != null ? new WalletOutput(entity, dateTimeProvider.UtcNow()) : null; } public async Task<PagedOutput<WalletOutput>> GetList(WalletGetListInput input) { + var now = dateTimeProvider.UtcNow(); return await repository.Query(false) + .Include(wallet => wallet.Titles) .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)) + .Select(n => new WalletOutput(n, now)) .ToPagedResult(input); } @@ -50,7 +54,7 @@ public async Task<ValidationResultDto<WalletOutput, WalletCreateOrUpdateErrorCod var wallet = new Wallet(input); await repository.AddAsync(wallet, autoSave); - validation.Data = new WalletOutput(wallet); + validation.Data = new WalletOutput(wallet, dateTimeProvider.UtcNow()); return validation; } diff --git a/Fin.Application/Wallets/Services/WalletValidationService.cs b/Fin.Application/Wallets/Services/WalletValidationService.cs index c2a9e6e..bc2e75e 100644 --- a/Fin.Application/Wallets/Services/WalletValidationService.cs +++ b/Fin.Application/Wallets/Services/WalletValidationService.cs @@ -2,6 +2,7 @@ using Fin.Application.Globals.Dtos; using Fin.Application.Wallets.Enums; using Fin.Domain.CreditCards.Entities; +using Fin.Domain.Titles.Entities; using Fin.Domain.Wallets.Dtos; using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.AutoServices.Interfaces; @@ -22,6 +23,7 @@ public Task<ValidationResultDto<T, WalletCreateOrUpdateErrorCode>> ValidateInput public class WalletValidationService( IRepository<Wallet> walletRepository, IRepository<CreditCard> creditCardRepository, + IRepository<Title> titleRepository, IFinancialInstitutionService financialInstitutionService ) : IWalletValidationService, IAutoTransient { @@ -57,22 +59,21 @@ public async Task<ValidationResultDto<bool, WalletDeleteErrorCode>> ValidateDele if (!walletExists) { validationResult.ErrorCode = WalletDeleteErrorCode.WalletNotFound; - validationResult.Message = "Wallet not found to delete."; - return validationResult; - } - - var walletInUseByCreditCard = await creditCardRepository.Query().AnyAsync(n => n.DebitWalletId == walletId); - if (walletInUseByCreditCard) - { - validationResult.ErrorCode = WalletDeleteErrorCode.WalletInUseByCreditCards; - validationResult.Message = "Wallet in use by credit cards."; return validationResult; } - // TODO here validate relations + var walletInUseByCreditCard = await creditCardRepository.Query().AnyAsync(n => n.DebitWalletId == walletId); + var walletInUseByTitle = await titleRepository.Query().AnyAsync(n => n.WalletId == walletId); - validationResult.Success = true; - return validationResult; + return walletInUseByTitle switch + { + true when walletInUseByCreditCard => validationResult.WithError(WalletDeleteErrorCode + .WalletInUseByCreditCardsAndTitle), + true => validationResult.WithError(WalletDeleteErrorCode.WalletInUseByTitles), + false when walletInUseByCreditCard => validationResult.WithError(WalletDeleteErrorCode + .WalletInUseByCreditCards), + _ => validationResult + }; } public async Task<ValidationResultDto<T, WalletCreateOrUpdateErrorCode>> ValidateInput<T>(WalletInput input, diff --git a/Fin.Domain/Global/Enums/MultiplyFilterOperator.cs b/Fin.Domain/Global/Enums/MultiplyFilterOperator.cs new file mode 100644 index 0000000..1eb5a88 --- /dev/null +++ b/Fin.Domain/Global/Enums/MultiplyFilterOperator.cs @@ -0,0 +1,7 @@ +namespace Fin.Domain.Global.Enums; + +public enum MultiplyFilterOperator +{ + Or = 0, + And = 1 +} \ No newline at end of file diff --git a/Fin.Domain/TitleCategories/Entities/TitleCategory.cs b/Fin.Domain/TitleCategories/Entities/TitleCategory.cs index 605bbe2..94d18b6 100644 --- a/Fin.Domain/TitleCategories/Entities/TitleCategory.cs +++ b/Fin.Domain/TitleCategories/Entities/TitleCategory.cs @@ -1,6 +1,7 @@ using Fin.Domain.Global.Interfaces; using Fin.Domain.TitleCategories.Dtos; using Fin.Domain.TitleCategories.Enums; +using Fin.Domain.Titles.Entities; namespace Fin.Domain.TitleCategories.Entities; @@ -19,6 +20,9 @@ public class TitleCategory: IAuditedTenantEntity public DateTime UpdatedAt { get; set; } public Guid TenantId { get; set; } + public virtual ICollection<Title> Titles { get; set; } + public virtual ICollection<TitleTitleCategory> TitleTitleCategories { get; set; } + public TitleCategory() { } diff --git a/Fin.Domain/TitleCategories/Entities/TitleTitleCategory.cs b/Fin.Domain/TitleCategories/Entities/TitleTitleCategory.cs new file mode 100644 index 0000000..a47326c --- /dev/null +++ b/Fin.Domain/TitleCategories/Entities/TitleTitleCategory.cs @@ -0,0 +1,22 @@ +using Fin.Domain.Titles.Entities; + +namespace Fin.Domain.TitleCategories.Entities; + +public class TitleTitleCategory +{ + public Guid TitleId { get; set; } + public virtual Title Title { get; set; } + + public Guid TitleCategoryId { get; set; } + public virtual TitleCategory TitleCategory { get; set; } + + public TitleTitleCategory() + { + } + + public TitleTitleCategory(Guid categoryId, Guid titleId) + { + TitleId = titleId; + TitleCategoryId = categoryId; + } +} \ No newline at end of file diff --git a/Fin.Domain/TitleCategories/Enums/TitleCategoryType.cs b/Fin.Domain/TitleCategories/Enums/TitleCategoryType.cs index 398ee35..630c43c 100644 --- a/Fin.Domain/TitleCategories/Enums/TitleCategoryType.cs +++ b/Fin.Domain/TitleCategories/Enums/TitleCategoryType.cs @@ -1,3 +1,5 @@ +using Fin.Domain.Titles.Enums; + namespace Fin.Domain.TitleCategories.Enums; public enum TitleCategoryType: byte @@ -5,4 +7,18 @@ public enum TitleCategoryType: byte Expense = 0, Income = 1, Both = 2 +} + +public static class TitleCategoryTypeExtension +{ + public static bool IsCompatible(this TitleCategoryType titleCategoryType, TitleType titleType) + { + return titleCategoryType switch + { + TitleCategoryType.Expense => titleType == TitleType.Expense, + TitleCategoryType.Income => titleType == TitleType.Income, + TitleCategoryType.Both => true, + _ => throw new ArgumentOutOfRangeException(nameof(titleCategoryType), titleCategoryType, null) + }; + } } \ No newline at end of file diff --git a/Fin.Domain/Titles/Dtos/TitleInput.cs b/Fin.Domain/Titles/Dtos/TitleInput.cs new file mode 100644 index 0000000..3658a82 --- /dev/null +++ b/Fin.Domain/Titles/Dtos/TitleInput.cs @@ -0,0 +1,13 @@ +using Fin.Domain.Titles.Enums; + +namespace Fin.Domain.Titles.Dtos; + +public class TitleInput +{ + public decimal Value { get; set; } + public TitleType Type { get; set; } + public string Description { get; set; } + public DateTime Date { get; set; } + public Guid WalletId { get; set; } + public List<Guid> TitleCategoriesIds { get; set; } = []; +} \ No newline at end of file diff --git a/Fin.Domain/Titles/Dtos/TitleOutput.cs b/Fin.Domain/Titles/Dtos/TitleOutput.cs new file mode 100644 index 0000000..0cc98e3 --- /dev/null +++ b/Fin.Domain/Titles/Dtos/TitleOutput.cs @@ -0,0 +1,24 @@ +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; + +namespace Fin.Domain.Titles.Dtos; + +public class TitleOutput(Title title) +{ + public Guid Id { get; set; } = title.Id; + public string Description { get; set; } = title.Description; + public decimal Value { get; set; } = title.Value; + public decimal EffectiveValue { get; set; } = title.EffectiveValue; + public decimal PreviousBalance { get; set; } = title.PreviousBalance; + public decimal ResultingBalance { get; set; } = title.ResultingBalance; + public TitleType Type { get; set; } = title.Type; + public DateTime Date { get; set; } = title.Date; + public Guid WalletId { get; set; } = title.WalletId; + public List<Guid> TitleCategoriesIds { get; set; } = title.TitleCategories + .Select(x => x.Id).ToList(); + + public TitleOutput(): this(new Title()) + { + + } +} \ No newline at end of file diff --git a/Fin.Domain/Titles/Entities/Title.cs b/Fin.Domain/Titles/Entities/Title.cs new file mode 100644 index 0000000..2faa694 --- /dev/null +++ b/Fin.Domain/Titles/Entities/Title.cs @@ -0,0 +1,105 @@ +using System.Collections.ObjectModel; +using Fin.Domain.Global.Interfaces; +using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Enums; +using Fin.Domain.Wallets.Entities; + +namespace Fin.Domain.Titles.Entities; + +public class Title: IAuditedTenantEntity +{ + public decimal Value { get; set; } + public TitleType Type { get; set; } + + public string Description { get; set; } + public decimal PreviousBalance { get; set; } + public DateTime Date { get; set; } + public Guid WalletId { get; set; } + + + public decimal ResultingBalance => PreviousBalance + EffectiveValue; + public decimal EffectiveValue => (Value * (Type == TitleType.Expense ? -1 : 1)); + + public virtual Wallet Wallet { get; set; } + public ICollection<TitleCategory> TitleCategories { get; set; } = []; + public ICollection<TitleTitleCategory> TitleTitleCategories { 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 Guid TenantId { get; set; } + + public Title() + { + } + + public + Title(TitleInput input, decimal previousBalance) + { + Id = Guid.NewGuid(); + + UpdateBasicProperties(input, previousBalance); + + TitleTitleCategories = new Collection<TitleTitleCategory>( + input.TitleCategoriesIds + .Distinct() + .Select(categoryId => new TitleTitleCategory(categoryId, Id)) + .ToList() + ); + } + + public List<TitleTitleCategory> UpdateAndReturnCategoriesToRemove(TitleInput input, decimal previousBalance) + { + UpdateBasicProperties(input, previousBalance); + return SyncCategories(input.TitleCategoriesIds); + } + + public bool MustReprocess(TitleInput input) + { + return input.Date != Date + || input.Type != Type + || input.Value != Value + || input.WalletId != WalletId; + } + + private void UpdateBasicProperties(TitleInput input, decimal previousBalance) + { + Value = input.Value; + Type = input.Type; + Description = input.Description.Trim(); + Date = input.Date; + WalletId = input.WalletId; + PreviousBalance = previousBalance; + } + + private List<TitleTitleCategory> SyncCategories(List<Guid> newCategoryIds) + { + var updatedCategories = newCategoryIds.Select(userId => new TitleTitleCategory(userId, Id)).ToList(); + + var categoriesToDelete = new List<TitleTitleCategory>(); + foreach (var currentDelivery in TitleTitleCategories) + { + var index = updatedCategories.FindIndex(c => c.TitleCategoryId == currentDelivery.TitleCategoryId); + if (index != -1) continue; + categoriesToDelete.Add(currentDelivery); + } + + foreach (var currentDelivery in categoriesToDelete) + { + TitleTitleCategories.Remove(currentDelivery); + } + + foreach (var updatedDelivery in updatedCategories) + { + var index = TitleTitleCategories.ToList().FindIndex(c => c.TitleCategoryId == updatedDelivery.TitleCategoryId); + if (index != -1) continue; + TitleTitleCategories.Add(updatedDelivery); + } + + return categoriesToDelete; + } +} \ No newline at end of file diff --git a/Fin.Domain/Titles/Enums/TitleType.cs b/Fin.Domain/Titles/Enums/TitleType.cs new file mode 100644 index 0000000..6beb346 --- /dev/null +++ b/Fin.Domain/Titles/Enums/TitleType.cs @@ -0,0 +1,7 @@ +namespace Fin.Domain.Titles.Enums; + +public enum TitleType: byte +{ + Expense = 0, + Income = 1 +} \ No newline at end of file diff --git a/Fin.Domain/Titles/Extensions/TitleExtensions.cs b/Fin.Domain/Titles/Extensions/TitleExtensions.cs new file mode 100644 index 0000000..bd191b3 --- /dev/null +++ b/Fin.Domain/Titles/Extensions/TitleExtensions.cs @@ -0,0 +1,16 @@ +using Fin.Domain.Titles.Entities; + +namespace Fin.Domain.Titles.Extensions; + +public static class TitleExtensions +{ + public static IEnumerable<Title> ApplyDefaultTitleOrder(this IEnumerable<Title> titles) + { + return titles.OrderByDescending(m => m.Date).ThenByDescending(m => m.Id); + } + + public static IQueryable<Title> ApplyDefaultTitleOrder(this IQueryable<Title> titles) + { + return titles.OrderByDescending(m => m.Date).ThenByDescending(m => m.Id); + } +} \ No newline at end of file diff --git a/Fin.Domain/Wallets/Dtos/WalletOutput.cs b/Fin.Domain/Wallets/Dtos/WalletOutput.cs index b1f417a..491adff 100644 --- a/Fin.Domain/Wallets/Dtos/WalletOutput.cs +++ b/Fin.Domain/Wallets/Dtos/WalletOutput.cs @@ -2,7 +2,7 @@ namespace Fin.Domain.Wallets.Dtos; -public class WalletOutput(Wallet wallet) +public class WalletOutput(Wallet wallet, DateTime now) { public Guid Id { get; set; } = wallet.Id; public string Name { get; set; } = wallet.Name; @@ -11,9 +11,9 @@ public class WalletOutput(Wallet wallet) 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 decimal CurrentBalance { get; set; } = wallet.CalculateBalanceAt(now); - public WalletOutput(): this(new Wallet()) + public WalletOutput(): this(new Wallet(), DateTime.Now) { } } \ No newline at end of file diff --git a/Fin.Domain/Wallets/Entities/Wallet.cs b/Fin.Domain/Wallets/Entities/Wallet.cs index 613bdee..4934bf7 100644 --- a/Fin.Domain/Wallets/Entities/Wallet.cs +++ b/Fin.Domain/Wallets/Entities/Wallet.cs @@ -1,6 +1,8 @@ using Fin.Domain.CreditCards.Entities; using Fin.Domain.FinancialInstitutions.Entities; using Fin.Domain.Global.Interfaces; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Extensions; using Fin.Domain.Wallets.Dtos; namespace Fin.Domain.Wallets.Entities; @@ -23,10 +25,10 @@ public class Wallet: IAuditedTenantEntity public virtual FinancialInstitution FinancialInstitution { get; set; } public decimal InitialBalance { get; private set; } - public decimal CurrentBalance { get; set; } - - - public virtual ICollection<CreditCard> CreditCards { get; set; } + + + public virtual ICollection<CreditCard> CreditCards { get; set; } = []; + public virtual ICollection<Title> Titles { get; set; } = []; public Wallet() { @@ -39,7 +41,6 @@ public Wallet(WalletInput wallet) Icon = wallet.Icon; FinancialInstitutionId = wallet.FinancialInstitutionId; InitialBalance = wallet.InitialBalance; - CurrentBalance = wallet.InitialBalance; } public void Update(WalletInput wallet) @@ -49,8 +50,20 @@ public void Update(WalletInput wallet) 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; + + public decimal CalculateBalanceAt(DateTime dateTime) + { + if (dateTime < CreatedAt) return 0; + if (Titles.Count == 0 ) return InitialBalance; + + var lastTitle = Titles + .Where(title => title.Date <= dateTime) + .ApplyDefaultTitleOrder() + .FirstOrDefault(); + + return lastTitle?.ResultingBalance ?? InitialBalance; + } } \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Configurations/CreditCards/CreditCardConfiguration.cs b/Fin.Infrastructure/Database/Configurations/CreditCards/CreditCardConfiguration.cs index 1ff6919..98fca65 100644 --- a/Fin.Infrastructure/Database/Configurations/CreditCards/CreditCardConfiguration.cs +++ b/Fin.Infrastructure/Database/Configurations/CreditCards/CreditCardConfiguration.cs @@ -25,21 +25,21 @@ public void Configure(EntityTypeBuilder<CreditCard> builder) .HasOne(creditCard => creditCard.FinancialInstitution) .WithMany(financialInstitution => financialInstitution.CreditCards) .HasForeignKey(creditCard => creditCard.FinancialInstitutionId) - .IsRequired(false) + .IsRequired() .OnDelete(DeleteBehavior.Restrict); builder .HasOne(creditCard => creditCard.CardBrand) .WithMany(cardBrand => cardBrand.CreditCards) .HasForeignKey(creditCard => creditCard.CardBrandId) - .IsRequired(false) + .IsRequired() .OnDelete(DeleteBehavior.Restrict); builder .HasOne(creditCard => creditCard.DebitWallet) .WithMany(debitWallet => debitWallet.CreditCards) .HasForeignKey(creditCard => creditCard.DebitWalletId) - .IsRequired(false) + .IsRequired() .OnDelete(DeleteBehavior.Restrict); } } \ 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 1c91c66..b132321 100644 --- a/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs +++ b/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs @@ -1,4 +1,5 @@ using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.Titles.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,5 +16,21 @@ public void Configure(EntityTypeBuilder<TitleCategory> builder) builder.Property(x => x.Color).HasMaxLength(20).IsRequired(); builder.HasIndex(x => new {x.Name, x.TenantId}).IsUnique(); + + builder + .HasMany(x => x.Titles) + .WithMany(x => x.TitleCategories) + .UsingEntity<TitleTitleCategory>( + l => l + .HasOne(ttc => ttc.Title) + .WithMany(title => title.TitleTitleCategories) + .HasForeignKey(e => e.TitleId) + .OnDelete(DeleteBehavior.Cascade), + r => r + .HasOne(ttc => ttc.TitleCategory) + .WithMany(category => category.TitleTitleCategories) + .HasForeignKey(e => e.TitleCategoryId) + .OnDelete(DeleteBehavior.Cascade) + ); } } \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Configurations/Titles/TitleConfiguration.cs b/Fin.Infrastructure/Database/Configurations/Titles/TitleConfiguration.cs new file mode 100644 index 0000000..00f0d57 --- /dev/null +++ b/Fin.Infrastructure/Database/Configurations/Titles/TitleConfiguration.cs @@ -0,0 +1,31 @@ +using Fin.Domain.Titles.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fin.Infrastructure.Database.Configurations.Titles; + +public class TitleConfiguration: IEntityTypeConfiguration<Title> +{ + public void Configure(EntityTypeBuilder<Title> builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Description).HasMaxLength(100).IsRequired(); + + builder + .Property(title => title.PreviousBalance) + .HasColumnType("numeric(19,4)") + .HasPrecision(19, 4); + builder + .Property(title => title.Value) + .HasColumnType("numeric(19,4)") + .HasPrecision(19, 4); + + builder + .HasOne(title => title.Wallet) + .WithMany(wallet => wallet.Titles) + .HasForeignKey(title => title.WalletId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/Database/FinDbContext.cs b/Fin.Infrastructure/Database/FinDbContext.cs index f45101a..c1c2d5e 100644 --- a/Fin.Infrastructure/Database/FinDbContext.cs +++ b/Fin.Infrastructure/Database/FinDbContext.cs @@ -6,6 +6,7 @@ using Fin.Domain.Notifications.Entities; using Fin.Domain.Tenants.Entities; using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.Titles.Entities; using Fin.Domain.Users.Entities; using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.AmbientDatas; @@ -31,9 +32,12 @@ public class FinDbContext : DbContext public DbSet<Menu> Menus { get; set; } public DbSet<CardBrand> CardBrands { get; set; } - public DbSet<TitleCategory> TitleCategories { get; set; } public DbSet<Wallet> Wallets { get; set; } public DbSet<CreditCard> CreditCards { get; set; } + + public DbSet<TitleCategory> TitleCategories { get; set; } + public DbSet<TitleTitleCategory> TitleTitleCategories { get; set; } + public DbSet<Title> Titles { get; set; } private readonly IAmbientData _ambientData; diff --git a/Fin.Infrastructure/Database/Repositories/IRepository.cs b/Fin.Infrastructure/Database/Repositories/IRepository.cs index 93239d8..27e8db0 100644 --- a/Fin.Infrastructure/Database/Repositories/IRepository.cs +++ b/Fin.Infrastructure/Database/Repositories/IRepository.cs @@ -1,7 +1,8 @@ namespace Fin.Infrastructure.Database.Repositories; -public interface IRepository<T> where T : class +public interface IRepository<T>: IQueryable<T> where T : class { + [Obsolete("Unnecessary, now you can query direct on repository")] IQueryable<T> Query(bool tracking = true); Task AddAsync(T entity, bool autoSave = false, CancellationToken cancellationToken = default); Task AddAsync(T entity, CancellationToken cancellationToken); @@ -12,5 +13,9 @@ public interface IRepository<T> where T : class Task DeleteAsync(T entity, bool autoSave = false, CancellationToken cancellationToken = default); Task DeleteAsync(T entity, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken = default); + + Task<T?> FindAsync(object[] keyValues, CancellationToken cancellationToken = default); + Task<T?> FindAsync(object keyValue, CancellationToken cancellationToken = default); + IQueryable<T> AsNoTracking(); FinDbContext Context { get; } } \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Repositories/Repository.cs b/Fin.Infrastructure/Database/Repositories/Repository.cs index b8ff009..1dbd5b1 100644 --- a/Fin.Infrastructure/Database/Repositories/Repository.cs +++ b/Fin.Infrastructure/Database/Repositories/Repository.cs @@ -1,4 +1,6 @@ -using Microsoft.EntityFrameworkCore; +using System.Collections; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; namespace Fin.Infrastructure.Database.Repositories; @@ -15,6 +17,21 @@ public Repository(FinDbContext context) _dbSet = _context.Set<T>(); } + public Type ElementType => _dbSet.AsQueryable().ElementType; + public Expression Expression => _dbSet.AsQueryable().Expression; + public IQueryProvider Provider => _dbSet.AsQueryable().Provider; + + public IEnumerator<T> GetEnumerator() => _dbSet.AsQueryable().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IQueryable<T> AsNoTracking() => _dbSet.AsNoTracking(); + + public async Task<T?> FindAsync(object keyValue, CancellationToken cancellationToken = default) => + await _dbSet.FindAsync(new[] { keyValue }, cancellationToken); + + public async Task<T?> FindAsync(object[] keyValues, CancellationToken cancellationToken = default) => + await _dbSet.FindAsync(keyValues, cancellationToken); + public IQueryable<T> Query(bool tracking = true) { return tracking ? _dbSet : _dbSet.AsNoTracking(); @@ -32,7 +49,8 @@ public Task AddAsync(T entity, CancellationToken cancellationToken) return AddAsync(entity, false, cancellationToken); } - public async Task AddRangeAsync(IEnumerable<T> entities, bool autoSave = false, CancellationToken cancellationToken = default) + public async Task AddRangeAsync(IEnumerable<T> entities, bool autoSave = false, + CancellationToken cancellationToken = default) { await _dbSet.AddRangeAsync(entities, cancellationToken); if (autoSave) diff --git a/Fin.Infrastructure/Errors/ErrorMessageAttribute.cs b/Fin.Infrastructure/Errors/ErrorMessageAttribute.cs new file mode 100644 index 0000000..a5159cb --- /dev/null +++ b/Fin.Infrastructure/Errors/ErrorMessageAttribute.cs @@ -0,0 +1,32 @@ +using System.Reflection; + +namespace Fin.Infrastructure.Errors; + +[AttributeUsage(AttributeTargets.Field)] +public class ErrorMessageAttribute(string message) : Attribute +{ + public string Message { get; } = message; +} + +public static class ErrorMessageExtension +{ + public static string GetErrorMessage<TEnum>(this TEnum enumValue, bool throwIfNotFoundMessage = true) where TEnum : Enum + { + var type = enumValue.GetType(); + var memberInfo = type.GetMember(enumValue.ToString()); + + if (memberInfo.Length > 0) + { + var attribute = memberInfo[0].GetCustomAttribute<ErrorMessageAttribute>(); + if (attribute != null) + { + return attribute.Message; + } + } + + return throwIfNotFoundMessage + ? throw new ArgumentException($"Error message not found for {enumValue}") + : string.Empty; + } + +} \ No newline at end of file diff --git a/Fin.Infrastructure/Migrations/20251022011405_AddingTitle.Designer.cs b/Fin.Infrastructure/Migrations/20251022011405_AddingTitle.Designer.cs new file mode 100644 index 0000000..ba3086e --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251022011405_AddingTitle.Designer.cs @@ -0,0 +1,931 @@ +// <auto-generated /> +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("20251022011405_AddingTitle")] + partial class AddingTitle + { + /// <inheritdoc /> + 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.CardBrands.Entities.CardBrand", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("CardBrands", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<Guid>("CardBrandId") + .HasColumnType("uuid"); + + b.Property<int>("ClosingDay") + .HasColumnType("integer"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("DebitWalletId") + .HasColumnType("uuid"); + + b.Property<int>("DueDay") + .HasColumnType("integer"); + + b.Property<Guid>("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<decimal>("Limit") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CardBrandId"); + + b.HasIndex("DebitWalletId"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("CreditCards", "public"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Code") + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactive") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(true) + .HasColumnType("character varying(100)"); + + b.Property<int>("Type") + .HasColumnType("integer"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("FinancialInstitution", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Menus.Entities.Menu", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("FrontRoute") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<string>("KeyWords") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<bool>("OnlyForAdmin") + .HasColumnType("boolean"); + + b.Property<int>("Position") + .HasColumnType("integer"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Menus", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<bool>("Continuous") + .HasColumnType("boolean"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("HtmlBody") + .HasColumnType("text"); + + b.Property<string>("Link") + .HasColumnType("text"); + + b.Property<string>("NormalizedTextBody") + .HasColumnType("text"); + + b.Property<string>("NormalizedTitle") + .HasColumnType("text"); + + b.Property<int>("Severity") + .HasColumnType("integer"); + + b.Property<DateTime>("StartToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("StopToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("TextBody") + .HasColumnType("text"); + + b.Property<string>("Title") + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Ways") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.Property<Guid>("NotificationId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("BackgroundJobId") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Delivery") + .HasColumnType("boolean"); + + b.Property<bool>("Visualized") + .HasColumnType("boolean"); + + b.HasKey("NotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("NotificationUserDeliveries", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("AllowedWays") + .HasColumnType("text"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<bool>("Enabled") + .HasColumnType("boolean"); + + b.Property<string>("FirebaseTokens") + .HasColumnType("text"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserNotificationSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<TimeSpan>("NotifyOn") + .HasColumnType("interval"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("Ways") + .HasColumnType("text"); + + b.Property<string>("WeekDays") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRememberUseSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.Tenant", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Locale") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<string>("Timezone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tenants", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("TenantUsers", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("TitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleTitleCategory", b => + { + b.Property<Guid>("TitleCategoryId") + .HasColumnType("uuid"); + + b.Property<Guid>("TitleId") + .HasColumnType("uuid"); + + b.HasKey("TitleCategoryId", "TitleId"); + + b.HasIndex("TitleId"); + + b.ToTable("TitleTitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateTime>("Date") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<decimal>("PreviousBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<decimal>("Value") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<Guid>("WalletId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("Titles", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateOnly?>("BirthDate") + .HasColumnType("date"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property<string>("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<int>("Gender") + .HasColumnType("integer"); + + b.Property<string>("ImagePublicUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<bool>("IsActivity") + .HasColumnType("boolean"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<string>("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Users", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("EncryptedEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("EncryptedPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property<int>("FailLoginAttempts") + .HasColumnType("integer"); + + b.Property<string>("GoogleId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("ResetToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<Guid>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<bool>("Aborted") + .HasColumnType("boolean"); + + b.Property<DateTime?>("AbortedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateOnly>("DeleteEffectivatedAt") + .HasColumnType("date"); + + b.Property<DateTime>("DeleteRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid?>("UserAbortedId") + .HasColumnType("uuid"); + + b.Property<Guid>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<decimal>("CurrentBalance") + .HasColumnType("numeric"); + + b.Property<Guid?>("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<decimal>("InitialBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Wallets", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.HasOne("Fin.Domain.CardBrands.Entities.CardBrand", "CardBrand") + .WithMany("CreditCards") + .HasForeignKey("CardBrandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "DebitWallet") + .WithMany("CreditCards") + .HasForeignKey("DebitWalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("CreditCards") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CardBrand"); + + b.Navigation("DebitWallet"); + + b.Navigation("FinancialInstitution"); + }); + + 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.TitleCategories.Entities.TitleTitleCategory", b => + { + b.HasOne("Fin.Domain.TitleCategories.Entities.TitleCategory", "TitleCategory") + .WithMany() + .HasForeignKey("TitleCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Titles.Entities.Title", "Title") + .WithMany() + .HasForeignKey("TitleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Title"); + + b.Navigation("TitleCategory"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "Wallet") + .WithMany("Titles") + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + 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.CardBrands.Entities.CardBrand", b => + { + b.Navigation("CreditCards"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Navigation("CreditCards"); + + 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"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Navigation("CreditCards"); + + b.Navigation("Titles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fin.Infrastructure/Migrations/20251022011405_AddingTitle.cs b/Fin.Infrastructure/Migrations/20251022011405_AddingTitle.cs new file mode 100644 index 0000000..5fa8034 --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251022011405_AddingTitle.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + /// <inheritdoc /> + public partial class AddingTitle : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Titles", + schema: "public", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + Value = table.Column<decimal>(type: "numeric(19,4)", precision: 19, scale: 4, nullable: false), + Type = table.Column<byte>(type: "smallint", nullable: false), + Description = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), + PreviousBalance = table.Column<decimal>(type: "numeric(19,4)", precision: 19, scale: 4, nullable: false), + Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + WalletId = table.Column<Guid>(type: "uuid", nullable: false), + CreatedBy = table.Column<Guid>(type: "uuid", nullable: false), + UpdatedBy = table.Column<Guid>(type: "uuid", nullable: false), + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + TenantId = table.Column<Guid>(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Titles", x => x.Id); + table.ForeignKey( + name: "FK_Titles_Wallets_WalletId", + column: x => x.WalletId, + principalSchema: "public", + principalTable: "Wallets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TitleTitleCategories", + schema: "public", + columns: table => new + { + TitleId = table.Column<Guid>(type: "uuid", nullable: false), + TitleCategoryId = table.Column<Guid>(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TitleTitleCategories", x => new { x.TitleCategoryId, x.TitleId }); + table.ForeignKey( + name: "FK_TitleTitleCategories_TitleCategories_TitleCategoryId", + column: x => x.TitleCategoryId, + principalSchema: "public", + principalTable: "TitleCategories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TitleTitleCategories_Titles_TitleId", + column: x => x.TitleId, + principalSchema: "public", + principalTable: "Titles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Titles_WalletId", + schema: "public", + table: "Titles", + column: "WalletId"); + + migrationBuilder.CreateIndex( + name: "IX_TitleTitleCategories_TitleId", + schema: "public", + table: "TitleTitleCategories", + column: "TitleId"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TitleTitleCategories", + schema: "public"); + + migrationBuilder.DropTable( + name: "Titles", + schema: "public"); + } + } +} diff --git a/Fin.Infrastructure/Migrations/20251024013707_adjusts_on_wallet.Designer.cs b/Fin.Infrastructure/Migrations/20251024013707_adjusts_on_wallet.Designer.cs new file mode 100644 index 0000000..bf503ed --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251024013707_adjusts_on_wallet.Designer.cs @@ -0,0 +1,938 @@ +// <auto-generated /> +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("20251024013707_adjusts_on_wallet")] + partial class adjusts_on_wallet + { + /// <inheritdoc /> + 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.CardBrands.Entities.CardBrand", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("CardBrands", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<Guid>("CardBrandId") + .HasColumnType("uuid"); + + b.Property<int>("ClosingDay") + .HasColumnType("integer"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("DebitWalletId") + .HasColumnType("uuid"); + + b.Property<int>("DueDay") + .HasColumnType("integer"); + + b.Property<Guid>("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<decimal>("Limit") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CardBrandId"); + + b.HasIndex("DebitWalletId"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("CreditCards", "public"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Code") + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactive") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(true) + .HasColumnType("character varying(100)"); + + b.Property<int>("Type") + .HasColumnType("integer"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("FinancialInstitution", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Menus.Entities.Menu", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("FrontRoute") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<string>("KeyWords") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<bool>("OnlyForAdmin") + .HasColumnType("boolean"); + + b.Property<int>("Position") + .HasColumnType("integer"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Menus", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<bool>("Continuous") + .HasColumnType("boolean"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("HtmlBody") + .HasColumnType("text"); + + b.Property<string>("Link") + .HasColumnType("text"); + + b.Property<string>("NormalizedTextBody") + .HasColumnType("text"); + + b.Property<string>("NormalizedTitle") + .HasColumnType("text"); + + b.Property<int>("Severity") + .HasColumnType("integer"); + + b.Property<DateTime>("StartToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("StopToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("TextBody") + .HasColumnType("text"); + + b.Property<string>("Title") + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Ways") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.Property<Guid>("NotificationId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("BackgroundJobId") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Delivery") + .HasColumnType("boolean"); + + b.Property<bool>("Visualized") + .HasColumnType("boolean"); + + b.HasKey("NotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("NotificationUserDeliveries", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("AllowedWays") + .HasColumnType("text"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<bool>("Enabled") + .HasColumnType("boolean"); + + b.Property<string>("FirebaseTokens") + .HasColumnType("text"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserNotificationSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<TimeSpan>("NotifyOn") + .HasColumnType("interval"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("Ways") + .HasColumnType("text"); + + b.Property<string>("WeekDays") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRememberUseSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.Tenant", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Locale") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<string>("Timezone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tenants", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("TenantUsers", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("TitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleTitleCategory", b => + { + b.Property<Guid>("TitleCategoryId") + .HasColumnType("uuid"); + + b.Property<Guid>("TitleId") + .HasColumnType("uuid"); + + b.HasKey("TitleCategoryId", "TitleId"); + + b.HasIndex("TitleId"); + + b.ToTable("TitleTitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateTime>("Date") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<decimal>("PreviousBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<decimal>("Value") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<Guid>("WalletId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("Titles", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateOnly?>("BirthDate") + .HasColumnType("date"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property<string>("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<int>("Gender") + .HasColumnType("integer"); + + b.Property<string>("ImagePublicUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<bool>("IsActivity") + .HasColumnType("boolean"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<string>("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Users", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("EncryptedEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("EncryptedPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property<int>("FailLoginAttempts") + .HasColumnType("integer"); + + b.Property<string>("GoogleId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("ResetToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<Guid>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<bool>("Aborted") + .HasColumnType("boolean"); + + b.Property<DateTime?>("AbortedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateOnly>("DeleteEffectivatedAt") + .HasColumnType("date"); + + b.Property<DateTime>("DeleteRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid?>("UserAbortedId") + .HasColumnType("uuid"); + + b.Property<Guid>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<Guid?>("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<decimal>("InitialBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Wallets", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.HasOne("Fin.Domain.CardBrands.Entities.CardBrand", "CardBrand") + .WithMany("CreditCards") + .HasForeignKey("CardBrandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "DebitWallet") + .WithMany("CreditCards") + .HasForeignKey("DebitWalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("CreditCards") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CardBrand"); + + b.Navigation("DebitWallet"); + + b.Navigation("FinancialInstitution"); + }); + + 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.TitleCategories.Entities.TitleTitleCategory", b => + { + b.HasOne("Fin.Domain.TitleCategories.Entities.TitleCategory", "TitleCategory") + .WithMany("TitleTitleCategories") + .HasForeignKey("TitleCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Titles.Entities.Title", "Title") + .WithMany("TitleTitleCategories") + .HasForeignKey("TitleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Title"); + + b.Navigation("TitleCategory"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "Wallet") + .WithMany("Titles") + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + 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.CardBrands.Entities.CardBrand", b => + { + b.Navigation("CreditCards"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Navigation("CreditCards"); + + b.Navigation("Wallets"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Navigation("UserDeliveries"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Navigation("TitleTitleCategories"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Navigation("TitleTitleCategories"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Navigation("Credential"); + + b.Navigation("DeleteRequests"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Navigation("CreditCards"); + + b.Navigation("Titles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fin.Infrastructure/Migrations/20251024013707_adjusts_on_wallet.cs b/Fin.Infrastructure/Migrations/20251024013707_adjusts_on_wallet.cs new file mode 100644 index 0000000..ca06c09 --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251024013707_adjusts_on_wallet.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + /// <inheritdoc /> + public partial class adjusts_on_wallet : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CurrentBalance", + schema: "public", + table: "Wallets"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<decimal>( + name: "CurrentBalance", + schema: "public", + table: "Wallets", + type: "numeric", + nullable: false, + defaultValue: 0m); + } + } +} diff --git a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs index e26effc..3aacbe7 100644 --- a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs +++ b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs @@ -485,6 +485,71 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("TitleCategories", "public"); }); + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleTitleCategory", b => + { + b.Property<Guid>("TitleCategoryId") + .HasColumnType("uuid"); + + b.Property<Guid>("TitleId") + .HasColumnType("uuid"); + + b.HasKey("TitleCategoryId", "TitleId"); + + b.HasIndex("TitleId"); + + b.ToTable("TitleTitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateTime>("Date") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<decimal>("PreviousBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<decimal>("Value") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<Guid>("WalletId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("Titles", "public"); + }); + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => { b.Property<Guid>("Id") @@ -634,9 +699,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property<Guid>("CreatedBy") .HasColumnType("uuid"); - b.Property<decimal>("CurrentBalance") - .HasColumnType("numeric"); - b.Property<Guid?>("FinancialInstitutionId") .HasColumnType("uuid"); @@ -681,17 +743,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Fin.Domain.CardBrands.Entities.CardBrand", "CardBrand") .WithMany("CreditCards") .HasForeignKey("CardBrandId") - .OnDelete(DeleteBehavior.Restrict); + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "DebitWallet") .WithMany("CreditCards") .HasForeignKey("DebitWalletId") - .OnDelete(DeleteBehavior.Restrict); + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") .WithMany("CreditCards") .HasForeignKey("FinancialInstitutionId") - .OnDelete(DeleteBehavior.Restrict); + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); b.Navigation("CardBrand"); @@ -756,6 +821,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleTitleCategory", b => + { + b.HasOne("Fin.Domain.TitleCategories.Entities.TitleCategory", "TitleCategory") + .WithMany("TitleTitleCategories") + .HasForeignKey("TitleCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Titles.Entities.Title", "Title") + .WithMany("TitleTitleCategories") + .HasForeignKey("TitleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Title"); + + b.Navigation("TitleCategory"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "Wallet") + .WithMany("Titles") + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Wallet"); + }); + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => { b.HasOne("Fin.Domain.Users.Entities.User", "User") @@ -811,6 +906,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UserDeliveries"); }); + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Navigation("TitleTitleCategories"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Navigation("TitleTitleCategories"); + }); + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => { b.Navigation("Credential"); @@ -821,6 +926,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => { b.Navigation("CreditCards"); + + b.Navigation("Titles"); }); #pragma warning restore 612, 618 } diff --git a/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs b/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs index e00e410..357d3e8 100644 --- a/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs +++ b/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs @@ -94,6 +94,17 @@ public async Task SeedAsync() OnlyForAdmin = false, Position = MenuPosition.LeftTop, KeyWords = "credit card, cartao de crédito, cartão de credito, cartao" + }, + new() + { + Id = Guid.Parse("019a27f7-c052-7e62-b344-2112b0737691"), + FrontRoute = "/titles", + Name = "finCore.features.title.title", + Color = "#6d28d9", + Icon = "coins", + OnlyForAdmin = false, + Position = MenuPosition.LeftTop, + KeyWords = "titles, títulos, lançamentos, gostos, recebidos" } }; var defaultMenusIds = defaultMenus.Select(x => x.Id).ToList(); diff --git a/Fin.Infrastructure/UnitOfWorks/UnitOfWork.cs b/Fin.Infrastructure/UnitOfWorks/UnitOfWork.cs index 4a9727a..057771d 100644 --- a/Fin.Infrastructure/UnitOfWorks/UnitOfWork.cs +++ b/Fin.Infrastructure/UnitOfWorks/UnitOfWork.cs @@ -4,66 +4,77 @@ namespace Fin.Infrastructure.UnitOfWorks; -public interface IUnitOfWork : IDisposable, IAsyncDisposable +public interface IUnitOfWork { - Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default); - Task<int> CommitAsync(CancellationToken cancellationToken = default); - Task RollbackAsync(CancellationToken cancellationToken = default); - Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); + Task<UnitOfWork.ITransactionScope> BeginTransactionAsync(CancellationToken cancellationToken = default); bool IsInTransaction(); } -public class UnitOfWork(FinDbContext context): IUnitOfWork, IAutoScoped +public class UnitOfWork(FinDbContext context) : IUnitOfWork, IAutoScoped { - private IDbContextTransaction _transaction; - private bool _disposed; - - public async Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default) - { - _transaction ??= await context.Database.BeginTransactionAsync(cancellationToken); - return _transaction; - } + private readonly FinDbContext _context = context; - public async Task<int> CommitAsync(CancellationToken cancellationToken = default) - { - var result = await context.SaveChangesAsync(cancellationToken); - - if (IsInTransaction()) - await _transaction.CommitAsync(cancellationToken); - return result; - } + private IDbContextTransaction _transaction; + private int _transactionDepth; - public async Task RollbackAsync(CancellationToken cancellationToken = default) + public async Task<ITransactionScope> BeginTransactionAsync(CancellationToken cancellationToken = default) { - if (IsInTransaction()) + if (_transactionDepth == 0 && _transaction == null) { - await _transaction?.RollbackAsync(cancellationToken)!; + _transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + } - } - public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => await context.SaveChangesAsync(cancellationToken); + _transactionDepth++; + + return new TransactionScope(this, _transaction); + } public bool IsInTransaction() { return _transaction != null; } - - public void Dispose() + + public interface ITransactionScope: IAsyncDisposable { - if (_disposed) return; - - _transaction?.Dispose(); - context.Dispose(); - _disposed = true; + Task<int> CompleteAsync(CancellationToken cancellationToken = default); + Task RollbackAsync(CancellationToken cancellationToken = default); + Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); } - - public async ValueTask DisposeAsync() + + private class TransactionScope(UnitOfWork uow, IDbContextTransaction transaction) : ITransactionScope { - if (_disposed) return; + private bool _completed; + + public async Task<int> CompleteAsync(CancellationToken cancellationToken = default) + { + _completed = true; + return await uow._context.SaveChangesAsync(cancellationToken); + } - if (_transaction != null) await _transaction.DisposeAsync(); - await context.DisposeAsync(); + public async Task RollbackAsync(CancellationToken cancellationToken = default) + { + if (uow.IsInTransaction()) + { + await transaction.RollbackAsync(cancellationToken)!; + } + } - _disposed = true; + public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => await uow._context.SaveChangesAsync(cancellationToken); + + public async ValueTask DisposeAsync() + { + uow._transactionDepth--; + + if (uow._transactionDepth == 0 && uow.IsInTransaction()) + { + if (_completed) + await transaction.CommitAsync(); + else + await transaction.RollbackAsync(); + + await transaction.DisposeAsync(); + } + } } } \ No newline at end of file diff --git a/Fin.Infrastructure/ValidationsPipeline/IValidationRule.cs b/Fin.Infrastructure/ValidationsPipeline/IValidationRule.cs new file mode 100644 index 0000000..63607cd --- /dev/null +++ b/Fin.Infrastructure/ValidationsPipeline/IValidationRule.cs @@ -0,0 +1,12 @@ +namespace Fin.Infrastructure.ValidationsPipeline; + +public interface IValidationRule<TInput, TErrorCode, TErrorData> where TErrorCode : struct +{ + public Task<ValidationPipelineOutput<TErrorCode, TErrorData>> ValidateAsync(TInput input, Guid? editingId = null, CancellationToken cancellationToken = default); +} + +public interface IValidationRule<TInput, TErrorCode> where TErrorCode : struct +{ + public Task<ValidationPipelineOutput<TErrorCode>> ValidateAsync(TInput titleId, Guid? editingId = null, CancellationToken cancellationToken = default); + +} \ No newline at end of file diff --git a/Fin.Infrastructure/ValidationsPipeline/ValidationPipelineOrchestrator.cs b/Fin.Infrastructure/ValidationsPipeline/ValidationPipelineOrchestrator.cs new file mode 100644 index 0000000..3eefc47 --- /dev/null +++ b/Fin.Infrastructure/ValidationsPipeline/ValidationPipelineOrchestrator.cs @@ -0,0 +1,47 @@ +using Fin.Infrastructure.AutoServices.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Fin.Infrastructure.ValidationsPipeline; + +public interface IValidationPipelineOrchestrator +{ + Task<ValidationPipelineOutput<TErrorCode, TErrorData>> Validate<TInput, TErrorCode, TErrorData>(TInput input, Guid? editingId = null, CancellationToken cancellationToken = default) where TErrorCode: struct; + + Task<ValidationPipelineOutput<TErrorCode>> Validate<TInput, TErrorCode>(TInput input, Guid? editingId = null, CancellationToken cancellationToken = default) where TErrorCode: struct; +} + +public class ValidationPipelineOrchestrator(IServiceProvider serviceProvider): IValidationPipelineOrchestrator, IAutoTransient +{ + public async Task<ValidationPipelineOutput<TErrorCode, TErrorData>> Validate<TInput, TErrorCode, TErrorData>(TInput input, Guid? editingId = null, CancellationToken cancellationToken = default) where TErrorCode: struct + { + var rulesWithOutData = serviceProvider.GetServices<IValidationRule<TInput, TErrorCode>>(); + foreach (var rule in rulesWithOutData) + { + if (cancellationToken.IsCancellationRequested) break; + var validation = await rule.ValidateAsync(input, editingId, cancellationToken); + if (!validation.Success) return new ValidationPipelineOutput<TErrorCode, TErrorData>(validation); + } + + var rules = serviceProvider.GetServices<IValidationRule<TInput, TErrorCode, TErrorData>>(); + foreach (var rule in rules) + { + if (cancellationToken.IsCancellationRequested) break; + var validation = await rule.ValidateAsync(input, editingId, cancellationToken); + if (!validation.Success) return validation; + } + + return new ValidationPipelineOutput<TErrorCode, TErrorData>(); + } + + public async Task<ValidationPipelineOutput<TErrorCode>> Validate<TInput, TErrorCode>(TInput input, Guid? editingId = null, CancellationToken cancellationToken = default) where TErrorCode: struct + { + var rules = serviceProvider.GetServices<IValidationRule<TInput, TErrorCode>>(); + foreach (var rule in rules) + { + if (cancellationToken.IsCancellationRequested) break; + var validation = await rule.ValidateAsync(input, editingId, cancellationToken); + if (!validation.Success) return validation; + } + return new ValidationPipelineOutput<TErrorCode>(); + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/ValidationsPipeline/ValidationPipelineOutput.cs b/Fin.Infrastructure/ValidationsPipeline/ValidationPipelineOutput.cs new file mode 100644 index 0000000..6b1eafa --- /dev/null +++ b/Fin.Infrastructure/ValidationsPipeline/ValidationPipelineOutput.cs @@ -0,0 +1,45 @@ +#nullable enable +namespace Fin.Infrastructure.ValidationsPipeline; + +public class ValidationPipelineOutput<TErrorCode, TErrorData>(ValidationPipelineOutput<TErrorCode> validation) + where TErrorCode : struct +{ + public TErrorCode? Code { get; set; } = validation.Code; + public TErrorData? Data { get; set; } + + public bool Success => !Code.HasValue; + + public ValidationPipelineOutput(): this(new ValidationPipelineOutput<TErrorCode>()) + { + } + + public ValidationPipelineOutput<TErrorCode, TErrorData> AddError(TErrorCode code, TErrorData? data) + { + Code = code; + Data = data; + return this; + } + + public ValidationPipelineOutput<TErrorCode, TErrorData> AddError(TErrorCode code) + { + Code = code; + return this; + } +} + +public class ValidationPipelineOutput<TErrorCode> + where TErrorCode : struct +{ + public TErrorCode? Code { get; set; } + public bool Success => !Code.HasValue; + + public ValidationPipelineOutput() + { + } + + public ValidationPipelineOutput<TErrorCode> AddError(TErrorCode code) + { + Code = code; + return this; + } +} \ No newline at end of file diff --git a/Fin.Test/Authentications/AuthenticationServiceTest.cs b/Fin.Test/Authentications/AuthenticationServiceTest.cs index 6c60f67..76d3798 100644 --- a/Fin.Test/Authentications/AuthenticationServiceTest.cs +++ b/Fin.Test/Authentications/AuthenticationServiceTest.cs @@ -317,7 +317,7 @@ public async Task ResetPassword_Success() // Assert result.Success.Should().BeTrue(); - result.ErrorCode.Should().NotBeDefined(); + result.ErrorCode.Should().Be(null); result.Message.Should().NotBeNullOrEmpty(); result.Data.Should().BeTrue(); diff --git a/Fin.Test/Notifications/Services/NotificationServiceTest.cs b/Fin.Test/Notifications/Services/NotificationServiceTest.cs index 455aad2..8657cbb 100644 --- a/Fin.Test/Notifications/Services/NotificationServiceTest.cs +++ b/Fin.Test/Notifications/Services/NotificationServiceTest.cs @@ -219,7 +219,7 @@ public async Task Update_ShouldReschedule_WhenDateStaysForToday() UserIds = [TestUtils.Guids[1], TestUtils.Guids[2]] }); await resources.NotificationRepository.AddAsync(notification); - await UnitOfWork.SaveChangesAsync(); + await resources.NotificationRepository.Context.SaveChangesAsync(); var input = new NotificationInput { diff --git a/Fin.Test/TestUtils.cs b/Fin.Test/TestUtils.cs index a76717c..5bc5f62 100644 --- a/Fin.Test/TestUtils.cs +++ b/Fin.Test/TestUtils.cs @@ -2,6 +2,9 @@ using Fin.Domain.FinancialInstitutions.Entities; using Fin.Domain.FinancialInstitutions.Enums; using Fin.Domain.Tenants.Entities; +using Fin.Domain.TitleCategories.Dtos; +using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.TitleCategories.Enums; using Fin.Domain.Users.Entities; using Fin.Domain.Wallets.Dtos; using Fin.Domain.Wallets.Entities; @@ -24,6 +27,7 @@ public class BaseTest protected BaseTest() { + DateTimeProvider.Setup(dtp => dtp.UtcNow()).Returns(UtcDateTimes[0]); } protected async Task ConfigureLoggedAmbientAsync(bool isAdmin = true) @@ -43,6 +47,7 @@ public class BaseTestWithContext : BaseTest, IDisposable protected BaseTestWithContext() { var dateTimeProviderMockForContext = new Mock<IDateTimeProvider>(); + dateTimeProviderMockForContext.Setup(dtp => dtp.UtcNow()).Returns(UtcDateTimes[0]); Context = TestDbContextFactory.Create(out _connection, out _dbFilePath, AmbientData, dateTimeProviderMockForContext.Object, useFile: true); UnitOfWork = new UnitOfWork(Context); @@ -181,4 +186,22 @@ protected IRepository<T> GetRepository<T>() where T : class new(WalletsInputs[3]), new(WalletsInputs[4]) ]; + + public static List<TitleCategoryInput> TitleCategoriesInputs => + [ + new() { Name = Strings[0], Color = Strings[1], Icon = Strings[2], Type = TitleCategoryType.Income }, + new() { Name = Strings[2], Color = Strings[3], Icon = Strings[4], Type = TitleCategoryType.Expense }, + new() { Name = Strings[4], Color = Strings[5], Icon = Strings[6], Type = TitleCategoryType.Income }, + new() { Name = Strings[6], Color = Strings[7], Icon = Strings[8], Type = TitleCategoryType.Expense }, + new() { Name = Strings[8], Color = Strings[9], Icon = Strings[0], Type = TitleCategoryType.Income } + ]; + + public static List<TitleCategory> TitleCategories => + [ + new(TitleCategoriesInputs[0]), + new(TitleCategoriesInputs[1]), + new(TitleCategoriesInputs[2]), + new(TitleCategoriesInputs[3]), + new(TitleCategoriesInputs[4]) + ]; } \ No newline at end of file diff --git a/Fin.Test/Titles/TitleControllerTest.cs b/Fin.Test/Titles/TitleControllerTest.cs new file mode 100644 index 0000000..4a905af --- /dev/null +++ b/Fin.Test/Titles/TitleControllerTest.cs @@ -0,0 +1,417 @@ +using Fin.Api.Titles; +using Fin.Application.Globals.Dtos; +using Fin.Application.Titles.Dtos; +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Services; +using Fin.Domain.Global.Classes; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace Fin.Test.Titles; + +public class TitleControllerTest : TestUtils.BaseTest +{ + private readonly Mock<ITitleService> _serviceMock; + private readonly TitleController _controller; + + public TitleControllerTest() + { + _serviceMock = new Mock<ITitleService>(); + _controller = new TitleController(_serviceMock.Object); + } + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedResult() + { + // Arrange + var input = new TitleGetListInput + { + SkipCount = 0, + MaxResultCount = 10 + }; + + var expectedResult = new PagedOutput<TitleOutput> + { + Items = new List<TitleOutput> + { + new() + { + Id = TestUtils.Guids[0], + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0] + } + }, + TotalCount = 1 + }; + + _serviceMock + .Setup(s => s.GetList(input, It.IsAny<CancellationToken>())) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.Items.Should().HaveCount(1); + result.TotalCount.Should().Be(1); + result.Items.First().Id.Should().Be(TestUtils.Guids[0]); + } + + [Fact] + public async Task GetList_ShouldReturnEmptyResult_WhenNoTitlesExist() + { + // Arrange + var input = new TitleGetListInput + { + SkipCount = 0, + MaxResultCount = 10 + }; + + var expectedResult = new PagedOutput<TitleOutput> + { + Items = new List<TitleOutput>(), + TotalCount = 0 + }; + + _serviceMock + .Setup(s => s.GetList(input, It.IsAny<CancellationToken>())) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.Items.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + } + + #endregion + + #region Get + + [Fact] + public async Task Get_ShouldReturnOk_WhenTitleExists() + { + // Arrange + var titleId = TestUtils.Guids[0]; + var expectedTitle = new TitleOutput + { + Id = titleId, + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0] + }; + + _serviceMock + .Setup(s => s.Get(titleId, It.IsAny<CancellationToken>())) + .ReturnsAsync(expectedTitle); + + // Act + var result = await _controller.Get(titleId); + + // Assert + result.Result.Should().BeOfType<OkObjectResult>() + .Which.Value.Should().Be(expectedTitle); + } + + [Fact] + public async Task Get_ShouldReturnNotFound_WhenTitleDoesNotExist() + { + // Arrange + var titleId = TestUtils.Guids[0]; + + _serviceMock + .Setup(s => s.Get(titleId, It.IsAny<CancellationToken>())) + .ReturnsAsync((TitleOutput?)null); + + // Act + var result = await _controller.Get(titleId); + + // Assert + result.Result.Should().BeOfType<NotFoundResult>(); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnCreated_WhenInputIsValid() + { + // Arrange + var input = new TitleInput + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + + var createdTitle = new TitleOutput + { + Id = TestUtils.Guids[2], + Description = input.Description, + Value = input.Value, + Type = input.Type, + Date = input.Date + }; + + var successResult = new ValidationResultDto<TitleOutput, TitleCreateOrUpdateErrorCode> + { + Success = true, + Data = createdTitle + }; + + _serviceMock + .Setup(s => s.Create(input, It.IsAny<bool>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(successResult); + + // Act + var result = await _controller.Create(input); + + // Assert + result.Result.Should().BeOfType<CreatedResult>() + .Which.Value.Should().Be(createdTitle); + + var createdResult = result.Result as CreatedResult; + createdResult!.Location.Should().Be($"categories/{createdTitle.Id}"); + } + + [Fact] + public async Task Create_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var input = new TitleInput + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + var failureResult = new ValidationResultDto<TitleOutput, TitleCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = TitleCreateOrUpdateErrorCode.DescriptionIsRequired, + Message = "Description is required." + }; + + _serviceMock + .Setup(s => s.Create(input, It.IsAny<bool>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Create(input); + + // Assert + var unprocessableResult = result.Result + .Should().BeOfType<UnprocessableEntityObjectResult>() + .Subject; + unprocessableResult.Value.Should().BeEquivalentTo(failureResult); + } + + [Fact] + public async Task Create_ShouldReturnUnprocessableEntity_WhenWalletNotFound() + { + // Arrange + var input = new TitleInput + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + var failureResult = new ValidationResultDto<TitleOutput, TitleCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = TitleCreateOrUpdateErrorCode.WalletNotFound, + Message = "Wallet not found." + }; + + _serviceMock + .Setup(s => s.Create(input, It.IsAny<bool>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Create(input); + + // Assert + var unprocessableResult = result.Result + .Should().BeOfType<UnprocessableEntityObjectResult>() + .Subject; + unprocessableResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnOk_WhenUpdateIsSuccessful() + { + // Arrange + var titleId = TestUtils.Guids[0]; + var input = new TitleInput + { + Description = TestUtils.Strings[1], + Value = TestUtils.Decimals[1], + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = TestUtils.Guids[1], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[2] } + }; + + var successResult = new ValidationResultDto<bool, TitleCreateOrUpdateErrorCode> + { + Success = true + }; + + _serviceMock + .Setup(s => s.Update(titleId, input, It.IsAny<bool>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(successResult); + + // Act + var result = await _controller.Update(titleId, input); + + // Assert + result.Should().BeOfType<OkResult>(); + } + + [Fact] + public async Task Update_ShouldReturnNotFound_WhenTitleDoesNotExist() + { + // Arrange + var titleId = TestUtils.Guids[0]; + var input = new TitleInput + { + Description = TestUtils.Strings[1], + Value = TestUtils.Decimals[1], + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = TestUtils.Guids[1], + TitleCategoriesIds = new List<Guid>() + }; + + var failureResult = new ValidationResultDto<bool, TitleCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = TitleCreateOrUpdateErrorCode.TitleNotFound, + Message = "Title not found." + }; + + _serviceMock + .Setup(s => s.Update(titleId, input, It.IsAny<bool>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Update(titleId, input); + + // Assert + var notFoundResult = result.Should().BeOfType<NotFoundObjectResult>().Subject; + notFoundResult.Value.Should().BeEquivalentTo(failureResult); + } + + [Fact] + public async Task Update_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var titleId = TestUtils.Guids[0]; + var input = new TitleInput + { + Description = TestUtils.Strings[1], + Value = TestUtils.Decimals[1], + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = TestUtils.Guids[1], + TitleCategoriesIds = new List<Guid>() + }; + + var failureResult = new ValidationResultDto<bool, TitleCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = TitleCreateOrUpdateErrorCode.DescriptionIsRequired, + Message = "Description is required." + }; + + _serviceMock + .Setup(s => s.Update(titleId, input, It.IsAny<bool>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Update(titleId, input); + + // Assert + var unprocessableResult = result + .Should().BeOfType<UnprocessableEntityObjectResult>() + .Subject; + unprocessableResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnOk_WhenDeleteIsSuccessful() + { + // Arrange + var titleId = TestUtils.Guids[0]; + + var successResult = new ValidationResultDto<bool, TitleDeleteErrorCode> + { + Success = true + }; + + _serviceMock + .Setup(s => s.Delete(titleId, It.IsAny<bool>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(successResult); + + // Act + var result = await _controller.Delete(titleId); + + // Assert + result.Should().BeOfType<OkResult>(); + } + + [Fact] + public async Task Delete_ShouldReturnNotFound_WhenTitleDoesNotExist() + { + // Arrange + var titleId = TestUtils.Guids[0]; + + var failureResult = new ValidationResultDto<bool, TitleDeleteErrorCode> + { + Success = false, + ErrorCode = TitleDeleteErrorCode.TitleNotFound, + Message = "Title not found to delete." + }; + + _serviceMock + .Setup(s => s.Delete(titleId, It.IsAny<bool>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Delete(titleId); + + // Assert + var notFoundResult = result.Should().BeOfType<NotFoundObjectResult>().Subject; + notFoundResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/Titles/TitleServiceTest.cs b/Fin.Test/Titles/TitleServiceTest.cs new file mode 100644 index 0000000..16dab1a --- /dev/null +++ b/Fin.Test/Titles/TitleServiceTest.cs @@ -0,0 +1,655 @@ +using Fin.Application.Titles.Dtos; +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Services; +using Fin.Application.Wallets.Services; +using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.ValidationsPipeline; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace Fin.Test.Titles; + +public class TitleServiceTest : TestUtils.BaseTestWithContext +{ + private readonly Mock<ITitleUpdateHelpService> _updateHelpServiceMock; + private readonly Mock<IWalletBalanceService> _balanceServiceMock; + private readonly Mock<IValidationPipelineOrchestrator> _validationMock; + + public TitleServiceTest() + { + _updateHelpServiceMock = new Mock<ITitleUpdateHelpService>(); + _balanceServiceMock = new Mock<IWalletBalanceService>(); + _validationMock = new Mock<IValidationPipelineOrchestrator>(); + } + + #region Get + + [Fact] + public async Task Get_ShouldReturnTitle_WhenExists() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = TestUtils.Decimals[0] + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var titleInput = new TitleInput + { + Description = TestUtils.Strings[3], + Value = TestUtils.Decimals[1], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }; + var title = new Title(titleInput, 0m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + // Act + var result = await service.Get(title.Id); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(title.Id); + result.Description.Should().Be(titleInput.Description); + result.Value.Should().Be(titleInput.Value); + result.Type.Should().Be(titleInput.Type); + } + + [Fact] + public async Task Get_ShouldReturnNull_WhenDoesNotExist() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + var nonExistentId = TestUtils.Guids[9]; + + // Act + var result = await service.Get(nonExistentId); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedResult() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = TestUtils.Decimals[0] + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = "A - First", + Value = TestUtils.Decimals[1], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + var title2 = new Title(new TitleInput + { + Description = "B - Second", + Value = TestUtils.Decimals[2], + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + await resources.TitleRepository.AddAsync(title2, autoSave: true); + + var input = new TitleGetListInput + { + SkipCount = 0, + MaxResultCount = 10 + }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.TotalCount.Should().Be(2); + result.Items.Should().HaveCount(2); + } + + [Fact] + public async Task GetList_ShouldFilterByType() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = TestUtils.Decimals[0] + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var incomeTitle = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = TestUtils.Decimals[1], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + var expenseTitle = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = TestUtils.Decimals[2], + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + await resources.TitleRepository.AddAsync(incomeTitle, autoSave: true); + await resources.TitleRepository.AddAsync(expenseTitle, autoSave: true); + + var input = new TitleGetListInput + { + Type = TitleType.Income, + SkipCount = 0, + MaxResultCount = 10 + }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.TotalCount.Should().Be(1); + result.Items.Should().HaveCount(1); + result.Items.First().Type.Should().Be(TitleType.Income); + } + + [Fact] + public async Task GetList_ShouldFilterByWalletIds() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet1 = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = TestUtils.Decimals[0] + }); + var wallet2 = new Wallet(new WalletInput + { + Name = TestUtils.Strings[3], + Color = TestUtils.Strings[4], + Icon = TestUtils.Strings[5], + InitialBalance = TestUtils.Decimals[1] + }); + + await resources.WalletRepository.AddAsync(wallet1, autoSave: true); + await resources.WalletRepository.AddAsync(wallet2, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[6], + Value = TestUtils.Decimals[2], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet1.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[7], + Value = TestUtils.Decimals[3], + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet2.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + await resources.TitleRepository.AddAsync(title2, autoSave: true); + + var input = new TitleGetListInput + { + WalletIds = new List<Guid> { wallet1.Id }, + SkipCount = 0, + MaxResultCount = 10 + }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.TotalCount.Should().Be(1); + result.Items.Should().HaveCount(1); + result.Items.First().WalletId.Should().Be(wallet1.Id); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnSuccess_WhenInputIsValid() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = TestUtils.Decimals[0] + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var input = new TitleInput + { + Description = TestUtils.Strings[3], + Value = TestUtils.Decimals[1], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }; + + var successValidation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode> + { + Code = null + }; + + _validationMock + .Setup(v => v.Validate<TitleInput, TitleCreateOrUpdateErrorCode>( + input, + null, + It.IsAny<CancellationToken>())) + .ReturnsAsync(successValidation); + + _balanceServiceMock + .Setup(b => b.GetBalanceAt( + wallet.Id, + input.Date, + It.IsAny<CancellationToken>())) + .ReturnsAsync(100m); + + _balanceServiceMock + .Setup(b => b.ReprocessBalanceFrom( + It.IsAny<Title>(), + false, + It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + // Act + var result = await service.Create(input, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data.Description.Should().Be(input.Description); + result.Data.Value.Should().Be(input.Value); + result.Data.Type.Should().Be(input.Type); + + var dbTitle = await resources.TitleRepository.AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == result.Data.Id); + + dbTitle.Should().NotBeNull(); + dbTitle.Description.Should().Be(input.Description); + } + + [Fact] + public async Task Create_ShouldReturnFailure_WhenValidationFails() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = new TitleInput + { + Description = TestUtils.Strings[3], + Value = TestUtils.Decimals[1], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + var failureValidation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode> + { + Code = TitleCreateOrUpdateErrorCode.WalletNotFound + }; + + _validationMock + .Setup(v => v.Validate<TitleInput, TitleCreateOrUpdateErrorCode>( + input, + null, + It.IsAny<CancellationToken>())) + .ReturnsAsync(failureValidation); + + // Act + var result = await service.Create(input, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCreateOrUpdateErrorCode.WalletNotFound); + result.Data.Should().BeNull(); + + var count = await resources.TitleRepository.AsNoTracking().CountAsync(); + count.Should().Be(0); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnSuccess_WhenInputIsValid() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = TestUtils.Decimals[0] + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = TestUtils.Decimals[1], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var updateInput = new TitleInput + { + Description = "Updated Description", + Value = TestUtils.Decimals[2], + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }; + + var successValidation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode> + { + Code = null + }; + + _validationMock + .Setup(v => v.Validate<TitleInput, TitleCreateOrUpdateErrorCode>( + updateInput, + title.Id, + It.IsAny<CancellationToken>())) + .ReturnsAsync(successValidation); + + var context = new UpdateTitleContext( + PreviousWalletId: wallet.Id, + PreviousDate: title.Date, + PreviousBalance: title.PreviousBalance, + CategoriesToRemove: new List<TitleTitleCategory>() + ); + + _updateHelpServiceMock + .Setup(u => u.PrepareUpdateContext( + It.IsAny<Title>(), + updateInput, + true, + It.IsAny<CancellationToken>())) + .ReturnsAsync(context); + + _updateHelpServiceMock + .Setup(u => u.UpdateTitleAndCategories( + It.IsAny<Title>(), + updateInput, + It.IsAny<List<TitleTitleCategory>>(), + It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + _updateHelpServiceMock + .Setup(u => u.ReprocessAffectedWallets( + It.IsAny<Title>(), + context, + false, + It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + // Act + var result = await service.Update(title.Id, updateInput, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + } + + [Fact] + public async Task Update_ShouldReturnFailure_WhenValidationFails() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var titleId = TestUtils.Guids[0]; + var input = new TitleInput + { + Description = TestUtils.Strings[3], + Value = TestUtils.Decimals[1], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[1], + TitleCategoriesIds = new List<Guid>() + }; + + var failureValidation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode> + { + Code = TitleCreateOrUpdateErrorCode.TitleNotFound + }; + + _validationMock + .Setup(v => v.Validate<TitleInput, TitleCreateOrUpdateErrorCode>( + input, + titleId, + It.IsAny<CancellationToken>())) + .ReturnsAsync(failureValidation); + + // Act + var result = await service.Update(titleId, input, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleCreateOrUpdateErrorCode.TitleNotFound); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnSuccess_WhenTitleExists() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = TestUtils.Decimals[0] + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = TestUtils.Decimals[1], + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var successValidation = new ValidationPipelineOutput<TitleDeleteErrorCode> + { + Code = null + }; + + _validationMock + .Setup(v => v.Validate<Guid, TitleDeleteErrorCode>( + title.Id, + null, + It.IsAny<CancellationToken>())) + .ReturnsAsync(successValidation); + + _updateHelpServiceMock + .Setup(u => u.GetTitlesForReprocessing( + wallet.Id, + title.Date, + title.Id, + It.IsAny<CancellationToken>())) + .ReturnsAsync(new List<Title>()); + + _balanceServiceMock + .Setup(b => b.ReprocessBalance( + It.IsAny<List<Title>>(), + It.IsAny<decimal>(), + false, + It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + // Act + var result = await service.Delete(title.Id, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + + var dbTitle = await resources.TitleRepository.AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == title.Id); + dbTitle.Should().BeNull(); + } + + [Fact] + public async Task Delete_ShouldReturnFailure_WhenValidationFails() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var titleId = TestUtils.Guids[0]; + + var failureValidation = new ValidationPipelineOutput<TitleDeleteErrorCode> + { + Code = TitleDeleteErrorCode.TitleNotFound + }; + + _validationMock + .Setup(v => v.Validate<Guid, TitleDeleteErrorCode>( + titleId, + null, + It.IsAny<CancellationToken>())) + .ReturnsAsync(failureValidation); + + // Act + var result = await service.Delete(titleId, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(TitleDeleteErrorCode.TitleNotFound); + } + + #endregion + + private TitleService GetService(Resources resources) + { + return new TitleService( + resources.TitleRepository, + _updateHelpServiceMock.Object, + _balanceServiceMock.Object, + UnitOfWork, + _validationMock.Object + ); + } + + private Resources GetResources() + { + return new Resources + { + TitleRepository = GetRepository<Title>(), + WalletRepository = GetRepository<Wallet>() + }; + } + + private class Resources + { + public IRepository<Title> TitleRepository { get; set; } + public IRepository<Wallet> WalletRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Titles/TitleTest.cs b/Fin.Test/Titles/TitleTest.cs new file mode 100644 index 0000000..755ad15 --- /dev/null +++ b/Fin.Test/Titles/TitleTest.cs @@ -0,0 +1,683 @@ +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using FluentAssertions; + +namespace Fin.Test.Titles; + +public class TitleTest +{ + #region Constructor + + [Fact] + public void Constructor_ShouldInitializeWithEmptyConstructor() + { + // Act + var title = new Title(); + + // Assert + title.Should().NotBeNull(); + title.Id.Should().Be(Guid.Empty); + title.TitleCategories.Should().NotBeNull(); + title.TitleCategories.Should().BeEmpty(); + title.TitleTitleCategories.Should().NotBeNull(); + title.TitleTitleCategories.Should().BeEmpty(); + } + + [Fact] + public void Constructor_ShouldInitializeWithInputAndPreviousBalance() + { + // Arrange + var input = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test Description", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1], TestUtils.Guids[2] } + }; + var previousBalance = 50m; + + // Act + var title = new Title(input, previousBalance); + + // Assert + title.Should().NotBeNull(); + title.Id.Should().NotBe(Guid.Empty); + title.Value.Should().Be(input.Value); + title.Type.Should().Be(input.Type); + title.Description.Should().Be(input.Description); + title.Date.Should().Be(input.Date); + title.WalletId.Should().Be(input.WalletId); + title.PreviousBalance.Should().Be(previousBalance); + title.TitleTitleCategories.Should().HaveCount(2); + title.TitleTitleCategories.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[1]); + title.TitleTitleCategories.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[2]); + } + + [Fact] + public void Constructor_ShouldTrimDescription() + { + // Arrange + var input = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = " Test Description ", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var title = new Title(input, 0); + + // Assert + title.Description.Should().Be("Test Description"); + } + + [Fact] + public void Constructor_ShouldRemoveDuplicateCategoryIds() + { + // Arrange + var input = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1], TestUtils.Guids[1], TestUtils.Guids[2] } + }; + + // Act + var title = new Title(input, 0); + + // Assert + title.TitleTitleCategories.Should().HaveCount(2); + } + + #endregion + + #region Getters - ResultingBalance + + [Fact] + public void ResultingBalance_ShouldCalculateCorrectly_ForIncome() + { + // Arrange + var input = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var previousBalance = 50m; + + // Act + var title = new Title(input, previousBalance); + + // Assert + title.ResultingBalance.Should().Be(150m); // 50 + 100 + } + + [Fact] + public void ResultingBalance_ShouldCalculateCorrectly_ForExpense() + { + // Arrange + var input = new TitleInput + { + Value = 30m, + Type = TitleType.Expense, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var previousBalance = 100m; + + // Act + var title = new Title(input, previousBalance); + + // Assert + title.ResultingBalance.Should().Be(70m); // 100 - 30 + } + + [Fact] + public void ResultingBalance_ShouldBeNegative_WhenExpenseExceedsBalance() + { + // Arrange + var input = new TitleInput + { + Value = 150m, + Type = TitleType.Expense, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var previousBalance = 100m; + + // Act + var title = new Title(input, previousBalance); + + // Assert + title.ResultingBalance.Should().Be(-50m); // 100 - 150 + } + + [Fact] + public void ResultingBalance_ShouldBeZero_WhenPreviousBalanceIsZeroAndNoValue() + { + // Arrange + var input = new TitleInput + { + Value = 0m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var title = new Title(input, 0m); + + // Assert + title.ResultingBalance.Should().Be(0m); + } + + #endregion + + #region Getters - EffectiveValue + + [Fact] + public void EffectiveValue_ShouldBePositive_ForIncome() + { + // Arrange + var input = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var title = new Title(input, 0); + + // Assert + title.EffectiveValue.Should().Be(100m); + } + + [Fact] + public void EffectiveValue_ShouldBeNegative_ForExpense() + { + // Arrange + var input = new TitleInput + { + Value = 75m, + Type = TitleType.Expense, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var title = new Title(input, 0); + + // Assert + title.EffectiveValue.Should().Be(-75m); + } + + #endregion + + #region UpdateAndReturnCategoriesToRemove + + [Fact] + public void UpdateAndReturnCategoriesToRemove_ShouldUpdateBasicProperties() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Initial", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + var title = new Title(initialInput, 50m); + + var updateInput = new TitleInput + { + Value = 200m, + Type = TitleType.Expense, + Description = "Updated", + Date = DateTime.Now.AddDays(1), + WalletId = TestUtils.Guids[2], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + var newPreviousBalance = 150m; + + // Act + var result = title.UpdateAndReturnCategoriesToRemove(updateInput, newPreviousBalance); + + // Assert + title.Value.Should().Be(200m); + title.Type.Should().Be(TitleType.Expense); + title.Description.Should().Be("Updated"); + title.Date.Should().Be(updateInput.Date); + title.WalletId.Should().Be(TestUtils.Guids[2]); + title.PreviousBalance.Should().Be(150m); + result.Should().NotBeNull(); + } + + [Fact] + public void UpdateAndReturnCategoriesToRemove_ShouldAddNewCategories() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1], TestUtils.Guids[2], TestUtils.Guids[3] } + }; + + // Act + var result = title.UpdateAndReturnCategoriesToRemove(updateInput, 0); + + // Assert + title.TitleTitleCategories.Should().HaveCount(3); + title.TitleTitleCategories.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[1]); + title.TitleTitleCategories.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[2]); + title.TitleTitleCategories.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[3]); + result.Should().BeEmpty(); + } + + [Fact] + public void UpdateAndReturnCategoriesToRemove_ShouldRemoveCategories() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1], TestUtils.Guids[2], TestUtils.Guids[3] } + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + + // Act + var result = title.UpdateAndReturnCategoriesToRemove(updateInput, 0); + + // Assert + title.TitleTitleCategories.Should().HaveCount(1); + title.TitleTitleCategories.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[1]); + result.Should().HaveCount(2); + result.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[2]); + result.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[3]); + } + + [Fact] + public void UpdateAndReturnCategoriesToRemove_ShouldKeepExistingCategories() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1], TestUtils.Guids[2] } + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1], TestUtils.Guids[2] } + }; + + // Act + var result = title.UpdateAndReturnCategoriesToRemove(updateInput, 0); + + // Assert + title.TitleTitleCategories.Should().HaveCount(2); + title.TitleTitleCategories.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[1]); + title.TitleTitleCategories.Select(x => x.TitleCategoryId).Should().Contain(TestUtils.Guids[2]); + result.Should().BeEmpty(); + } + + [Fact] + public void UpdateAndReturnCategoriesToRemove_ShouldRemoveAllCategories() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1], TestUtils.Guids[2] } + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var result = title.UpdateAndReturnCategoriesToRemove(updateInput, 0); + + // Assert + title.TitleTitleCategories.Should().BeEmpty(); + result.Should().HaveCount(2); + } + + #endregion + + #region MustReprocess + + [Fact] + public void MustReprocess_ShouldReturnTrue_WhenDateChanges() + { + // Arrange + var initialDate = new DateTime(2025, 1, 1); + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = initialDate, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = initialDate.AddDays(1), + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var result = title.MustReprocess(updateInput); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void MustReprocess_ShouldReturnTrue_WhenTypeChanges() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Expense, + Description = "Test", + Date = title.Date, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var result = title.MustReprocess(updateInput); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void MustReprocess_ShouldReturnTrue_WhenValueChanges() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 200m, + Type = TitleType.Income, + Description = "Test", + Date = title.Date, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var result = title.MustReprocess(updateInput); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void MustReprocess_ShouldReturnTrue_WhenWalletIdChanges() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = title.Date, + WalletId = TestUtils.Guids[1], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var result = title.MustReprocess(updateInput); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void MustReprocess_ShouldReturnFalse_WhenOnlyDescriptionChanges() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Initial Description", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Updated Description", + Date = title.Date, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var result = title.MustReprocess(updateInput); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void MustReprocess_ShouldReturnFalse_WhenOnlyCategoriesChange() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = title.Date, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[2], TestUtils.Guids[3] } + }; + + // Act + var result = title.MustReprocess(updateInput); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void MustReprocess_ShouldReturnFalse_WhenNothingChanges() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = title.Date, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + + // Act + var result = title.MustReprocess(updateInput); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void MustReprocess_ShouldReturnTrue_WhenMultiplePropertiesChange() + { + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = new DateTime(2025, 1, 1), + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + var title = new Title(initialInput, 0); + + var updateInput = new TitleInput + { + Value = 200m, + Type = TitleType.Expense, + Description = "Updated", + Date = new DateTime(2025, 2, 1), + WalletId = TestUtils.Guids[1], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var result = title.MustReprocess(updateInput); + + // Assert + result.Should().BeTrue(); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/Titles/TitleUpdateHelpServiceTest.cs b/Fin.Test/Titles/TitleUpdateHelpServiceTest.cs new file mode 100644 index 0000000..11e194d --- /dev/null +++ b/Fin.Test/Titles/TitleUpdateHelpServiceTest.cs @@ -0,0 +1,714 @@ +using Fin.Application.Titles.Services; +using Fin.Application.Wallets.Services; +using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +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.Titles; + +public class TitleUpdateHelpServiceTest : TestUtils.BaseTestWithContext +{ + private readonly Mock<IWalletBalanceService> _balanceServiceMock; + + public TitleUpdateHelpServiceTest() + { + _balanceServiceMock = new Mock<IWalletBalanceService>(); + } + + #region UpdateTitleAndCategories + + [Fact] + public async Task UpdateTitleAndCategories_ShouldUpdateTitleAndRemoveCategories() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var titleCategory1 = TestUtils.TitleCategories[0]; + var titleCategory2 = TestUtils.TitleCategories[1]; + var titleCategory3 = TestUtils.TitleCategories[2]; + + await resources.TitleCategoryRepository.AddAsync(titleCategory1, autoSave: true); + await resources.TitleCategoryRepository.AddAsync(titleCategory2, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid> { titleCategory1.Id, titleCategory2.Id } + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + // Create categories to remove + var categoriesToRemove = title.TitleTitleCategories.Take(1).ToList(); + + var updateInput = new TitleInput + { + Description = "Updated Description", + Value = 600m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid> { titleCategory2.Id } + }; + + title.UpdateAndReturnCategoriesToRemove(updateInput, 1000m); + + // Act + await service.UpdateTitleAndCategories(title, updateInput, categoriesToRemove, CancellationToken.None); + await Context.SaveChangesAsync(); + + // Assert + var updatedTitle = await resources.TitleRepository.AsNoTracking() + .Include(t => t.TitleTitleCategories) + .FirstAsync(t => t.Id == title.Id); + + updatedTitle.Description.Should().Be("Updated Description"); + updatedTitle.Value.Should().Be(600m); + updatedTitle.TitleTitleCategories.Should().HaveCount(1); + } + + #endregion + + #region PrepareUpdateContext + + [Fact] + public async Task PrepareUpdateContext_ShouldReturnContext_WhenMustReprocess() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var input = new TitleInput + { + Description = "Updated", + Value = 600m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }; + + _balanceServiceMock + .Setup(b => b.GetBalanceAt( + wallet.Id, + input.Date, + It.IsAny<CancellationToken>())) + .ReturnsAsync(1500m); + + // Act + var context = await service.PrepareUpdateContext(title, input, mustReprocess: true, CancellationToken.None); + + // Assert + context.Should().NotBeNull(); + context.PreviousWalletId.Should().Be(wallet.Id); + context.PreviousDate.Should().Be(title.Date); + context.PreviousBalance.Should().Be(title.PreviousBalance); + context.CategoriesToRemove.Should().BeEmpty(); + } + + [Fact] + public async Task PrepareUpdateContext_ShouldNotRecalculateBalance_WhenMustNotReprocess() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var input = new TitleInput + { + Description = "Updated Description Only", + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var context = await service.PrepareUpdateContext(title, input, mustReprocess: false, CancellationToken.None); + + // Assert + context.Should().NotBeNull(); + context.PreviousBalance.Should().Be(title.PreviousBalance); + + // Verify GetBalanceAt was NOT called + _balanceServiceMock.Verify( + b => b.GetBalanceAt(It.IsAny<Guid>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + #endregion + + #region CalculatePreviousBalance + + [Fact] + public async Task CalculatePreviousBalance_ShouldReturnBalanceMinusTitleValue_WhenSameWalletAndEarlierDate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var input = new TitleInput + { + Description = "Updated", + Value = 600m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }; + + _balanceServiceMock + .Setup(b => b.GetBalanceAt( + wallet.Id, + input.Date, + It.IsAny<CancellationToken>())) + .ReturnsAsync(1500m); + + // Act + var previousBalance = await service.CalculatePreviousBalance(title, input, CancellationToken.None); + + // Assert + // 1500 - 500 (title.EffectiveValue) = 1000 + previousBalance.Should().Be(1000m); + } + + [Fact] + public async Task CalculatePreviousBalance_ShouldReturnRawBalance_WhenDifferentWallet() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet1 = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + var wallet2 = new Wallet(new WalletInput + { + Name = TestUtils.Strings[3], + Color = TestUtils.Strings[4], + Icon = TestUtils.Strings[5], + InitialBalance = 2000m + }); + await resources.WalletRepository.AddAsync(wallet1, autoSave: true); + await resources.WalletRepository.AddAsync(wallet2, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[6], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet1.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var input = new TitleInput + { + Description = "Updated", + Value = 600m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet2.Id, // Different wallet + TitleCategoriesIds = new List<Guid>() + }; + + _balanceServiceMock + .Setup(b => b.GetBalanceAt( + wallet2.Id, + input.Date, + It.IsAny<CancellationToken>())) + .ReturnsAsync(2000m); + + // Act + var previousBalance = await service.CalculatePreviousBalance(title, input, CancellationToken.None); + + // Assert + // Should return raw balance without adjustment + previousBalance.Should().Be(2000m); + } + + [Fact] + public async Task CalculatePreviousBalance_ShouldReturnRawBalance_WhenSameWalletButLaterDate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[5], // Later date + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var input = new TitleInput + { + Description = "Updated", + Value = 600m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], // Earlier date + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }; + + _balanceServiceMock + .Setup(b => b.GetBalanceAt( + wallet.Id, + input.Date, + It.IsAny<CancellationToken>())) + .ReturnsAsync(1000m); + + // Act + var previousBalance = await service.CalculatePreviousBalance(title, input, CancellationToken.None); + + // Assert + // Should return raw balance (no adjustment because title.Date > input.Date) + previousBalance.Should().Be(1000m); + } + + #endregion + + #region GetTitlesForReprocessing + + [Fact] + public async Task GetTitlesForReprocessing_ShouldReturnTitlesAfterDate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + title1.Id = TestUtils.Guids[0]; + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[2], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1500m); + title2.Id = TestUtils.Guids[1]; + + var title3 = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 300m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[4], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1300m); + title3.Id = TestUtils.Guids[2]; + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + await resources.TitleRepository.AddAsync(title2, autoSave: true); + await resources.TitleRepository.AddAsync(title3, autoSave: true); + + // Act + var titles = await service.GetTitlesForReprocessing( + wallet.Id, + TestUtils.UtcDateTimes[2], + title1.Id, + CancellationToken.None); + + // Assert + titles.Should().HaveCount(2); + titles.Should().Contain(t => t.Id == title2.Id); + titles.Should().Contain(t => t.Id == title3.Id); + titles.Should().NotContain(t => t.Id == title1.Id); + } + + [Fact] + public async Task GetTitlesForReprocessing_ShouldReturnEmpty_WhenNoTitlesAfterDate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + + // Act + var titles = await service.GetTitlesForReprocessing( + wallet.Id, + TestUtils.UtcDateTimes[5], + title1.Id, + CancellationToken.None); + + // Assert + titles.Should().BeEmpty(); + } + + [Fact] + public async Task GetTitlesForReprocessing_ShouldFilterByWallet() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet1 = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + var wallet2 = new Wallet(new WalletInput + { + Name = TestUtils.Strings[3], + Color = TestUtils.Strings[4], + Icon = TestUtils.Strings[5], + InitialBalance = 2000m + }); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[6], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet1.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + title1.Id = TestUtils.Guids[0]; + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[7], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[3], + WalletId = wallet1.Id, + TitleCategoriesIds = new List<Guid>() + }, 1500m); + title2.Id = TestUtils.Guids[1]; + + var title3 = new Title(new TitleInput + { + Description = TestUtils.Strings[8], + Value = 300m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[3], + WalletId = wallet2.Id, // Different wallet + TitleCategoriesIds = new List<Guid>() + }, 2000m); + title3.Id = TestUtils.Guids[2]; + wallet1.Titles.Add(title1); + wallet1.Titles.Add(title2); + wallet2.Titles.Add(title3); + + await resources.WalletRepository.AddRangeAsync([wallet1, wallet2], autoSave: true); + + // Act + var titles = await service.GetTitlesForReprocessing( + wallet1.Id, + TestUtils.UtcDateTimes[0], + title1.Id, + CancellationToken.None); + + // Assert + titles.Should().HaveCount(1); + titles.Should().Contain(t => t.Id == title2.Id); + titles.Should().NotContain(t => t.Id == title3.Id); + } + + #endregion + + #region ReprocessAffectedWallets + + [Fact] + public async Task ReprocessAffectedWallets_ShouldReprocessBothWallets_WhenWalletChanged() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet1 = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + var wallet2 = new Wallet(new WalletInput + { + Name = TestUtils.Strings[3], + Color = TestUtils.Strings[4], + Icon = TestUtils.Strings[5], + InitialBalance = 2000m + }); + await resources.WalletRepository.AddAsync(wallet1, autoSave: true); + await resources.WalletRepository.AddAsync(wallet2, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[6], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet2.Id, // Changed to wallet2 + TitleCategoriesIds = new List<Guid>() + }, 2000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var context = new UpdateTitleContext( + PreviousWalletId: wallet1.Id, // Was in wallet1 + PreviousDate: TestUtils.UtcDateTimes[0], + PreviousBalance: 1000m, + CategoriesToRemove: new List<TitleTitleCategory>() + ); + + _balanceServiceMock + .Setup(b => b.ReprocessBalance( + It.IsAny<List<Title>>(), + It.IsAny<decimal>(), + false, + It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + // Act + await service.ReprocessAffectedWallets(title, context, autoSave: false, CancellationToken.None); + + // Assert + // Should reprocess both wallets (current and previous) + _balanceServiceMock.Verify( + b => b.ReprocessBalance( + It.IsAny<List<Title>>(), + It.IsAny<decimal>(), + false, + It.IsAny<CancellationToken>()), + Times.Exactly(2)); + } + + [Fact] + public async Task ReprocessAffectedWallets_ShouldReprocessOnlyCurrentWallet_WhenWalletNotChanged() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var context = new UpdateTitleContext( + PreviousWalletId: wallet.Id, // Same wallet + PreviousDate: TestUtils.UtcDateTimes[0], + PreviousBalance: 1000m, + CategoriesToRemove: new List<TitleTitleCategory>() + ); + + _balanceServiceMock + .Setup(b => b.ReprocessBalance( + It.IsAny<List<Title>>(), + It.IsAny<decimal>(), + false, + It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + // Act + await service.ReprocessAffectedWallets(title, context, autoSave: false, CancellationToken.None); + + // Assert + // Should reprocess only current wallet + _balanceServiceMock.Verify( + b => b.ReprocessBalance( + It.IsAny<List<Title>>(), + It.IsAny<decimal>(), + false, + It.IsAny<CancellationToken>()), + Times.Once); + } + + #endregion + + private TitleUpdateHelpService GetService(Resources resources) + { + return new TitleUpdateHelpService( + resources.TitleRepository, + resources.TitleTitleCategoryRepository, + _balanceServiceMock.Object + ); + } + + private Resources GetResources() + { + return new Resources + { + TitleRepository = GetRepository<Title>(), + TitleTitleCategoryRepository = GetRepository<TitleTitleCategory>(), + TitleCategoryRepository = GetRepository<TitleCategory>(), + WalletRepository = GetRepository<Wallet>() + }; + } + + private class Resources + { + public IRepository<Title> TitleRepository { get; set; } + public IRepository<TitleTitleCategory> TitleTitleCategoryRepository { get; set; } + public IRepository<TitleCategory> TitleCategoryRepository { get; set; } + public IRepository<Wallet> WalletRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Titles/Validations/TitleDeleteMustExistValidationTest.cs b/Fin.Test/Titles/Validations/TitleDeleteMustExistValidationTest.cs new file mode 100644 index 0000000..41ff29b --- /dev/null +++ b/Fin.Test/Titles/Validations/TitleDeleteMustExistValidationTest.cs @@ -0,0 +1,84 @@ +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Validations.Deletes; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; + +namespace Fin.Test.Titles.Validations; + +public class TitleDeleteMustExistValidationTest : TestUtils.BaseTestWithContext +{ + #region ValidateAsync + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenTitleExists() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = TestUtils.Wallets[0]; + var titleId = TestUtils.Guids[0]; + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Date = TestUtils.UtcDateTimes[0], + Type = TitleType.Income + }, 0m); + title.Id = titleId; + title.Wallet = wallet; + title.Wallet.Id = wallet.Id; + await resources.TitleRepository.AddAsync(title, autoSave: true); + + // Act + var result = await service.ValidateAsync(titleId, null, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Code.Should().BeNull(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenTitleDoesNotExist() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var nonExistentTitleId = TestUtils.Guids[9]; + + // Act + var result = await service.ValidateAsync(nonExistentTitleId, null, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleDeleteErrorCode.TitleNotFound); + } + + #endregion + + private TitleDeleteMustExistValidation GetService(Resources resources) + { + return new TitleDeleteMustExistValidation(resources.TitleRepository); + } + + private Resources GetResources() + { + return new Resources + { + TitleRepository = GetRepository<Title>() + }; + } + + private class Resources + { + public IRepository<Title> TitleRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Titles/Validations/TitleInputBasicFieldsValidationTest.cs b/Fin.Test/Titles/Validations/TitleInputBasicFieldsValidationTest.cs new file mode 100644 index 0000000..9c7d935 --- /dev/null +++ b/Fin.Test/Titles/Validations/TitleInputBasicFieldsValidationTest.cs @@ -0,0 +1,6 @@ +namespace Fin.Test.Titles.Validations; + +public class TitleInputBasicFieldsValidationTest +{ + +} \ No newline at end of file diff --git a/Fin.Test/Titles/Validations/TitleInputCategoriesValidationTest.cs b/Fin.Test/Titles/Validations/TitleInputCategoriesValidationTest.cs new file mode 100644 index 0000000..ba387bc --- /dev/null +++ b/Fin.Test/Titles/Validations/TitleInputCategoriesValidationTest.cs @@ -0,0 +1,258 @@ +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Validations.UpdateOrCrestes; +using Fin.Domain.TitleCategories.Dtos; +using Fin.Domain.TitleCategories.Entities; +using Fin.Domain.TitleCategories.Enums; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Test.Titles.Validations; + +public class TitleInputCategoriesValidationTest : TestUtils.BaseTestWithContext +{ + private TitleInput GetValidInput() => new() + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + Type = TitleType.Income, + TitleCategoriesIds = new List<Guid>() + }; + + private async Task<TitleCategory> CreateCategoryInDatabase( + Resources resources, + Guid id, + TitleCategoryType type, + string name, + bool inactivated = false) + { + var category = new TitleCategory(new TitleCategoryInput + { + Type = type, + Name = name, + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2] + }); + + + category.Id = id; + + if (inactivated != category.Inactivated) + category.ToggleInactivated(); + + await resources.CategoryRepository.AddAsync(category, autoSave: true); + return category; + } + + private async Task<Title> CreateTitleInDatabase( + Resources resources, + Guid id, + List<TitleCategory> categories) + { + var input = new TitleInput + { + Value = TestUtils.Decimals[0], + Date = TestUtils.UtcDateTimes[0], + Type = TitleType.Income, + Description = TestUtils.Strings[0], + TitleCategoriesIds = categories.Select(c => c.Id).ToList() + }; + + var title = new Title(input, 0m); + title.Wallet = TestUtils.Wallets[0]; + title.Id = id; + await resources.TitleRepository.AddAsync(title, autoSave: true); + + // Eager load TitleCategories for verification + return await resources.TitleRepository + .Include(t => t.TitleCategories) + .FirstAsync(t => t.Id == id); + } + + #region ValidateAsync + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenCategoriesAreValid() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var cat1 = await CreateCategoryInDatabase(resources, TestUtils.Guids[1], TitleCategoryType.Income, TestUtils.Strings[1]); + var cat2 = await CreateCategoryInDatabase(resources, TestUtils.Guids[2], TitleCategoryType.Both, TestUtils.Strings[0]); + + var input = GetValidInput(); + input.TitleCategoriesIds.Add(cat1.Id); + input.TitleCategoriesIds.Add(cat2.Id); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Code.Should().BeNull(); + result.Data.Should().BeNull(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenSomeCategoriesNotFound() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var cat1 = await CreateCategoryInDatabase(resources, TestUtils.Guids[1], TitleCategoryType.Income, TestUtils.Strings[1]); + var notFoundId = TestUtils.Guids[9]; + + var input = GetValidInput(); + input.TitleCategoriesIds.Add(cat1.Id); + input.TitleCategoriesIds.Add(notFoundId); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomeCategoriesNotFound); + result.Data.Should().HaveCount(1); + result.Data.Should().BeEquivalentTo(new List<Guid> { notFoundId }); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenSomeCategoriesInactiveOnCreate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var cat1 = await CreateCategoryInDatabase(resources, TestUtils.Guids[1], TitleCategoryType.Income, TestUtils.Strings[1]); + var cat2_Inactive = await CreateCategoryInDatabase(resources, TestUtils.Guids[2], TitleCategoryType.Income, TestUtils.Strings[0], inactivated: true); + + var input = GetValidInput(); + input.TitleCategoriesIds.Add(cat1.Id); + input.TitleCategoriesIds.Add(cat2_Inactive.Id); + + // Act + var result = await service.ValidateAsync(input, editingId: null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomeCategoriesInactive); + result.Data.Should().HaveCount(1); + result.Data.Should().BeEquivalentTo(new List<Guid> { cat2_Inactive.Id }); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenSomeCategoriesHasIncompatibleTypes() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var cat1_Income = await CreateCategoryInDatabase(resources, TestUtils.Guids[1], TitleCategoryType.Income, TestUtils.Strings[0]); + var cat2_Expense = await CreateCategoryInDatabase(resources, TestUtils.Guids[2], TitleCategoryType.Expense, TestUtils.Strings[1]); + var cat3_All = await CreateCategoryInDatabase(resources, TestUtils.Guids[3], TitleCategoryType.Both, TestUtils.Strings[2]); + + var input = GetValidInput(); + input.Type = TitleType.Income; // Input is Income + input.TitleCategoriesIds.Add(cat1_Income.Id); + input.TitleCategoriesIds.Add(cat2_Expense.Id); // Incompatible + input.TitleCategoriesIds.Add(cat3_All.Id); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomeCategoriesHasIncompatibleTypes); + result.Data.Should().HaveCount(1); + result.Data.Should().BeEquivalentTo(new List<Guid> { cat2_Expense.Id }); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenCategoryIsInactiveButAlreadyOnTitle() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var cat1_Inactive = await CreateCategoryInDatabase(resources, TestUtils.Guids[1], TitleCategoryType.Income, TestUtils.Strings[1], inactivated: true); + var title = await CreateTitleInDatabase(resources, TestUtils.Guids[5], new List<TitleCategory> { cat1_Inactive }); + + var input = GetValidInput(); + input.TitleCategoriesIds.Add(cat1_Inactive.Id); + + // Act + var result = await service.ValidateAsync(input, editingId: title.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenAddingNewInactiveCategoryOnUpdate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var cat1_Active = await CreateCategoryInDatabase(resources, TestUtils.Guids[1], TitleCategoryType.Income, TestUtils.Strings[0]); + var cat2_Inactive = await CreateCategoryInDatabase(resources, TestUtils.Guids[2], TitleCategoryType.Income, TestUtils.Strings[1], inactivated: true); + var title = await CreateTitleInDatabase(resources, TestUtils.Guids[5], new List<TitleCategory> { cat1_Active }); + + var input = GetValidInput(); + input.TitleCategoriesIds.Add(cat1_Active.Id); + input.TitleCategoriesIds.Add(cat2_Inactive.Id); // Adding a new inactive category + + // Act + var result = await service.ValidateAsync(input, editingId: title.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomeCategoriesInactive); + result.Data.Should().HaveCount(1); + result.Data.Should().BeEquivalentTo(new List<Guid> { cat2_Inactive.Id }); + } + + #endregion + + private TitleInputCategoriesValidation GetService(Resources resources) + { + return new TitleInputCategoriesValidation( + resources.TitleRepository, + resources.CategoryRepository + ); + } + + private Resources GetResources() + { + return new Resources + { + TitleRepository = GetRepository<Title>(), + CategoryRepository = GetRepository<TitleCategory>() + }; + } + + private class Resources + { + public IRepository<Title> TitleRepository { get; set; } + public IRepository<TitleCategory> CategoryRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Titles/Validations/TitleInputDuplicatedValidationTest.cs b/Fin.Test/Titles/Validations/TitleInputDuplicatedValidationTest.cs new file mode 100644 index 0000000..3f016c6 --- /dev/null +++ b/Fin.Test/Titles/Validations/TitleInputDuplicatedValidationTest.cs @@ -0,0 +1,171 @@ +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Validations.UpdateOrCrestes; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; + +namespace Fin.Test.Titles.Validations; + +public class TitleInputDuplicatedValidationTest : TestUtils.BaseTestWithContext +{ + private TitleInput GetValidInput(DateTime date) => new() + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Date = date, + WalletId = TestUtils.Guids[0], + Type = TitleType.Income + }; + + private async Task<Title> CreateTitleInDatabase( + Resources resources, + TitleInput input, + Guid? id = null) + { + var title = new Title(input, 0m); + var wallet = TestUtils.Wallets[0]; + wallet.Id = title.WalletId; + title.Wallet = wallet; + if (id.HasValue) + title.Id = id.Value; + + await resources.TitleRepository.AddAsync(title, autoSave: true); + return title; + } + + #region ValidateAsync + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenTitleIsUnique() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var date = TestUtils.UtcDateTimes[0]; + var input = GetValidInput(date); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenDuplicateExistsOnCreate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var date = TestUtils.UtcDateTimes[0]; + var input = GetValidInput(date); + + await CreateTitleInDatabase(resources, input); + + // Act + var result = await service.ValidateAsync(input, editingId: null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.DuplicateTitleInSameDateTimeMinute); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenDuplicateExistsOnUpdateForSameEntity() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var date = TestUtils.UtcDateTimes[0]; + var input = GetValidInput(date); + + var existingTitle = await CreateTitleInDatabase(resources, input, TestUtils.Guids[1]); + + // Act + var result = await service.ValidateAsync(input, editingId: existingTitle.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenDuplicateExistsOnUpdateForAnotherEntity() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var date = TestUtils.UtcDateTimes[0]; + var input = GetValidInput(date); + + // Create the duplicate entry + await CreateTitleInDatabase(resources, input, TestUtils.Guids[1]); + + // The entity being edited is different + var editingId = TestUtils.Guids[2]; + + // Act + var result = await service.ValidateAsync(input, editingId: editingId); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.DuplicateTitleInSameDateTimeMinute); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenInputHasDifferentMinute() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var date1 = TestUtils.UtcDateTimes[0]; // e.g., 10:30:00 + var input1 = GetValidInput(date1); + + await CreateTitleInDatabase(resources, input1); + + var date2 = date1.AddMinutes(1); // e.g., 10:31:00 + var input2 = GetValidInput(date2); + + // Act + var result = await service.ValidateAsync(input2, editingId: null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + #endregion + + private TitleInputDuplicatedValidation GetService(Resources resources) + { + return new TitleInputDuplicatedValidation(resources.TitleRepository); + } + + private Resources GetResources() + { + return new Resources + { + TitleRepository = GetRepository<Title>() + }; + } + + private class Resources + { + public IRepository<Title> TitleRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Titles/Validations/TitleInputMustExistValidationTest.cs b/Fin.Test/Titles/Validations/TitleInputMustExistValidationTest.cs new file mode 100644 index 0000000..23e69f9 --- /dev/null +++ b/Fin.Test/Titles/Validations/TitleInputMustExistValidationTest.cs @@ -0,0 +1,115 @@ +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Validations.UpdateOrCrestes; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; + +namespace Fin.Test.Titles.Validations; + +public class TitleInputMustExistValidationTest : TestUtils.BaseTestWithContext +{ + private TitleInput GetValidInput() => new() + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + Type = TitleType.Income + }; + + private async Task<Title> CreateTitleInDatabase(Resources resources, Guid id) + { + var input = GetValidInput(); + + var title = new Title(input, 0m); + title.Id = id; + var wallet = TestUtils.Wallets[0]; + wallet.Id = title.WalletId; + title.Wallet = wallet; + await resources.TitleRepository.AddAsync(title, autoSave: true); + return title; + } + + #region ValidateAsync + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenTitleExists() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = GetValidInput(); + var existingTitle = await CreateTitleInDatabase(resources, TestUtils.Guids[0]); + + // Act + var result = await service.ValidateAsync(input, editingId: existingTitle.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Code.Should().BeNull(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenTitleDoesNotExist() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = GetValidInput(); + var nonExistentId = TestUtils.Guids[9]; + + // Act + var result = await service.ValidateAsync(input, editingId: nonExistentId); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.TitleNotFound); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenEditingIdIsNull() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = GetValidInput(); + + // Act + var result = await service.ValidateAsync(input, editingId: null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Code.Should().BeNull(); + } + + #endregion + + private TitleInputMustExistValidation GetService(Resources resources) + { + return new TitleInputMustExistValidation(resources.TitleRepository); + } + + private Resources GetResources() + { + return new Resources + { + TitleRepository = GetRepository<Title>() + }; + } + + private class Resources + { + public IRepository<Title> TitleRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Titles/Validations/TitleInputWalletValidationTest.cs b/Fin.Test/Titles/Validations/TitleInputWalletValidationTest.cs new file mode 100644 index 0000000..d07e8a1 --- /dev/null +++ b/Fin.Test/Titles/Validations/TitleInputWalletValidationTest.cs @@ -0,0 +1,175 @@ +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Validations.UpdateOrCrestes; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Enums; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; + +namespace Fin.Test.Titles.Validations; + +public class TitleInputWalletValidationTest : TestUtils.BaseTestWithContext +{ + private TitleInput GetValidInput(Guid walletId, DateTime date) => new() + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Date = date, + WalletId = walletId, + Type = TitleType.Income + }; + + private async Task<Wallet> CreateWalletInDatabase( + Resources resources, + Guid id, + DateTime createdAt, + bool inactivated = false) + { + var wallet = TestUtils.Wallets[0]; + wallet.Id = id; + wallet.CreatedAt = createdAt; + + if (inactivated) + wallet.ToggleInactivated(); + + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + return wallet; + } + + #region ValidateAsync + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenWalletIsValid() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var walletCreationDate = TestUtils.UtcDateTimes[0]; + var titleDate = walletCreationDate.AddDays(1); + var walletId = TestUtils.Guids[0]; + + await CreateWalletInDatabase(resources, walletId, walletCreationDate, inactivated: false); + var input = GetValidInput(walletId, titleDate); + + // Act + var result = await service.ValidateAsync(input, null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenWalletNotFound() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var nonExistentWalletId = TestUtils.Guids[9]; + var input = GetValidInput(nonExistentWalletId, TestUtils.UtcDateTimes[0]); + + // Act + var result = await service.ValidateAsync(input, null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.WalletNotFound); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenWalletIsInactive() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var walletCreationDate = TestUtils.UtcDateTimes[0]; + var titleDate = walletCreationDate.AddDays(1); + var walletId = TestUtils.Guids[0]; + + await CreateWalletInDatabase(resources, walletId, walletCreationDate, inactivated: true); + var input = GetValidInput(walletId, titleDate); + + // Act + var result = await service.ValidateAsync(input, null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.WalletInactive); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenTitleDateIsBeforeWalletCreation() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var walletCreationDate = TestUtils.UtcDateTimes[1]; + var walletId = TestUtils.Guids[0]; + var wallet = await CreateWalletInDatabase(resources, walletId, walletCreationDate, inactivated: false); + + var titleDate = wallet.CreatedAt.AddDays(-1); // Before wallet creation + var input = GetValidInput(walletId, titleDate); + + // Act + var result = await service.ValidateAsync(input, null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.TitleDateMustBeEqualOrAfterWalletCreation); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenTitleDateIsSameAsWalletCreation() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var walletCreationDate = TestUtils.UtcDateTimes[0]; + var titleDate = walletCreationDate; + var walletId = TestUtils.Guids[0]; + + await CreateWalletInDatabase(resources, walletId, walletCreationDate, inactivated: false); + var input = GetValidInput(walletId, titleDate); + + // Act + var result = await service.ValidateAsync(input, null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + #endregion + + private TitleInputWalletValidation GetService(Resources resources) + { + return new TitleInputWalletValidation(resources.WalletRepository); + } + + private Resources GetResources() + { + return new Resources + { + WalletRepository = GetRepository<Wallet>() + }; + } + + private class Resources + { + public IRepository<Wallet> WalletRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Users/UserCreateServiceTest.cs b/Fin.Test/Users/UserCreateServiceTest.cs index 6e9ac4d..98edd20 100644 --- a/Fin.Test/Users/UserCreateServiceTest.cs +++ b/Fin.Test/Users/UserCreateServiceTest.cs @@ -165,7 +165,7 @@ public async Task StartCreateUser_Success() // Assert result.Success.Should().BeTrue(); result.Data.Should().NotBeNull(); - result.ErrorCode.Should().NotBeDefined(); + result.ErrorCode.Should().Be(null); result.Data?.Email.Should().Be(input.Email); result.Data?.SentEmailDateTime.Should().Be(sentDateTime); diff --git a/Fin.Test/Wallets/Services/WalletBalanceServiceTest.cs b/Fin.Test/Wallets/Services/WalletBalanceServiceTest.cs new file mode 100644 index 0000000..b68a5df --- /dev/null +++ b/Fin.Test/Wallets/Services/WalletBalanceServiceTest.cs @@ -0,0 +1,615 @@ +using Fin.Application.Wallets.Services; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Test.Wallets.Services; + +public class WalletBalanceServiceTest : TestUtils.BaseTestWithContext +{ + #region GetBalanceAt + + [Fact] + public async Task GetBalanceAt_ShouldReturnInitialBalance_WhenNoTitlesExist() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + // Act + var result = await service.GetBalanceAt(wallet.Id, TestUtils.UtcDateTimes[0]); + + // Assert + result.Should().Be(1000m); + } + + [Fact] + public async Task GetBalanceAt_ShouldReturnZero_WhenDateIsBeforeWalletCreation() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var dateBeforeCreation = wallet.CreatedAt.AddDays(-1); + + // Act + var result = await service.GetBalanceAt(wallet.Id, dateBeforeCreation); + + // Assert + result.Should().Be(0m); + } + + [Fact] + public async Task GetBalanceAt_ShouldCalculateCorrectly_WithIncomeTitles() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 300m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1500m); + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + await resources.TitleRepository.AddAsync(title2, autoSave: true); + + // Act + var result = await service.GetBalanceAt(wallet.Id, TestUtils.UtcDateTimes[1]); + + // Assert + result.Should().Be(1800m); // 1000 + 500 + 300 + } + + [Fact] + public async Task GetBalanceAt_ShouldCalculateCorrectly_WithExpenseTitles() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 150m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 800m); + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + await resources.TitleRepository.AddAsync(title2, autoSave: true); + + // Act + var result = await service.GetBalanceAt(wallet.Id, TestUtils.UtcDateTimes[1]); + + // Assert + result.Should().Be(650m); // 1000 - 200 - 150 + } + + [Fact] + public async Task GetBalanceAt_ShouldCalculateCorrectly_WithMixedTitles() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var income = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + + var expense = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1500m); + + await resources.TitleRepository.AddAsync(income, autoSave: true); + await resources.TitleRepository.AddAsync(expense, autoSave: true); + + // Act + var result = await service.GetBalanceAt(wallet.Id, TestUtils.UtcDateTimes[1]); + + // Assert + result.Should().Be(1300m); // 1000 + 500 - 200 + } + + #endregion + + #region GetBalanceNow + + [Fact] + public async Task GetBalanceNow_ShouldReturnCurrentBalance() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = DateTimeProvider.Object.UtcNow().AddDays(-1), + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + + await resources.TitleRepository.AddAsync(title, autoSave: true); + + // Act + var result = await service.GetBalanceNow(wallet.Id); + + // Assert + result.Should().Be(1500m); // 1000 + 500 + } + + #endregion + + #region ReprocessBalance (by WalletId) + + [Fact] + public async Task ReprocessBalance_ByWalletId_ShouldUpdateAllTitlesBalances() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); // Wrong balance + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); // Wrong balance + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + await resources.TitleRepository.AddAsync(title2, autoSave: true); + + // Act + await service.ReprocessBalance(wallet.Id, 1000m, autoSave: true); + + // Assert + var reprocessedTitle1 = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title1.Id); + var reprocessedTitle2 = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title2.Id); + + reprocessedTitle1.PreviousBalance.Should().Be(1000m); + reprocessedTitle1.ResultingBalance.Should().Be(1500m); + + reprocessedTitle2.PreviousBalance.Should().Be(1500m); + reprocessedTitle2.ResultingBalance.Should().Be(1300m); + } + + #endregion + + #region ReprocessBalance (by Wallet entity) + + [Fact] + public async Task ReprocessBalance_ByWallet_ShouldUpdateAllTitlesBalances() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + + // Load wallet with titles + var walletWithTitles = await resources.WalletRepository + .Include(w => w.Titles) + .FirstAsync(w => w.Id == wallet.Id); + + // Act + await service.ReprocessBalance(walletWithTitles, autoSave: true); + + // Assert + var reprocessedTitle = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title1.Id); + + reprocessedTitle.PreviousBalance.Should().Be(1000m); + reprocessedTitle.ResultingBalance.Should().Be(1500m); + } + + #endregion + + #region ReprocessBalance (by Titles list) + + [Fact] + public async Task ReprocessBalance_ByTitlesList_ShouldUpdateBalancesInOrder() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 300m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + var title3 = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 200m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[2], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + await resources.TitleRepository.AddAsync(title2, autoSave: true); + await resources.TitleRepository.AddAsync(title3, autoSave: true); + + var titles = await resources.TitleRepository + .Where(t => t.WalletId == wallet.Id) + .ToListAsync(); + + // Act + await service.ReprocessBalance(titles, 1000m, autoSave: true); + + // Assert + var reprocessedTitle1 = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title1.Id); + var reprocessedTitle2 = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title2.Id); + var reprocessedTitle3 = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title3.Id); + + reprocessedTitle1.PreviousBalance.Should().Be(1000m); + reprocessedTitle1.ResultingBalance.Should().Be(1500m); + + reprocessedTitle2.PreviousBalance.Should().Be(1500m); + reprocessedTitle2.ResultingBalance.Should().Be(1200m); + + reprocessedTitle3.PreviousBalance.Should().Be(1200m); + reprocessedTitle3.ResultingBalance.Should().Be(1400m); + } + + [Fact] + public async Task ReprocessBalance_ByTitlesList_ShouldHandleEmptyList() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var emptyList = new List<Title>(); + + // Act + await service.ReprocessBalance(emptyList, 1000m, autoSave: true); + + // Assert + var count = await resources.TitleRepository.AsNoTracking().CountAsync(); + count.Should().Be(0); + } + + #endregion + + #region ReprocessBalanceFrom (by Title entity) + + [Fact] + public async Task ReprocessBalanceFrom_ByTitle_ShouldReprocessFollowingTitles() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + + await resources.TitleRepository.ExecuteDeleteAsync(); + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + title1.Id = TestUtils.Guids[0]; + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); // Wrong balance + + title2.Id = TestUtils.Guids[1]; + + var title3 = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 100m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[2], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); // Wrong balance + title3.Id = TestUtils.Guids[2]; + + await resources.TitleRepository.AddAsync(title1, autoSave: true); + await resources.TitleRepository.AddAsync(title2, autoSave: true); + await resources.TitleRepository.AddAsync(title3, autoSave: true); + + // Act + await service.ReprocessBalanceFrom(title1, autoSave: true); + + // Assert + var reprocessedTitle2 = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title2.Id); + var reprocessedTitle3 = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title3.Id); + + reprocessedTitle2.PreviousBalance.Should().Be(1500m); // title1.ResultingBalance + reprocessedTitle2.ResultingBalance.Should().Be(1300m); + + reprocessedTitle3.PreviousBalance.Should().Be(1300m); // title2.ResultingBalance + reprocessedTitle3.ResultingBalance.Should().Be(1400m); + } + + #endregion + + #region ReprocessBalanceFrom (by TitleId) + + [Fact] + public async Task ReprocessBalanceFrom_ByTitleId_ShouldReprocessFollowingTitles() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + 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 = 1000m + }); + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + title1.Id =TestUtils.Guids[0]; + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[2], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 0m); // Wrong balance + title2.Id =TestUtils.Guids[1]; + + wallet.Titles.Add(title1); + wallet.Titles.Add(title2); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + // Act + await service.ReprocessBalanceFrom(title1.Id, autoSave: true); + + // Assert + var reprocessedTitle2 = await resources.TitleRepository.AsNoTracking() + .FirstAsync(t => t.Id == title2.Id); + + reprocessedTitle2.PreviousBalance.Should().Be(1500m); + reprocessedTitle2.ResultingBalance.Should().Be(1300m); + } + + #endregion + + private WalletBalanceService GetService(Resources resources) + { + return new WalletBalanceService( + resources.WalletRepository, + resources.TitleRepository, + DateTimeProvider.Object, + UnitOfWork + ); + } + + private Resources GetResources() + { + return new Resources + { + WalletRepository = GetRepository<Wallet>(), + TitleRepository = GetRepository<Title>() + }; + } + + private class Resources + { + public IRepository<Wallet> WalletRepository { get; set; } + public IRepository<Title> TitleRepository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Wallets/Services/WalletServiceTest.cs b/Fin.Test/Wallets/Services/WalletServiceTest.cs index 54cfead..b3fac9f 100644 --- a/Fin.Test/Wallets/Services/WalletServiceTest.cs +++ b/Fin.Test/Wallets/Services/WalletServiceTest.cs @@ -138,7 +138,6 @@ public async Task Create_ShouldReturnSuccessAndWallet_WhenInputIsValid() dbWallet.Should().NotBeNull(); dbWallet.Name.Should().Be(input.Name); dbWallet.InitialBalance.Should().Be(input.InitialBalance); - dbWallet.CurrentBalance.Should().Be(input.InitialBalance); } [Fact] @@ -370,7 +369,7 @@ public async Task ToggleInactive_ShouldReturnFailure_WhenValidationFails() private WalletService GetService(Resources resources) { - return new WalletService(resources.WalletRepository, _validationServiceMock.Object); + return new WalletService(resources.WalletRepository, _validationServiceMock.Object, DateTimeProvider.Object); } private Resources GetResources() diff --git a/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs b/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs index 3dd2024..0faa394 100644 --- a/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs +++ b/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs @@ -3,9 +3,11 @@ using Fin.Application.Wallets.Services; using Fin.Domain.CreditCards.Entities; using Fin.Domain.FinancialInstitutions.Dtos; +using Fin.Domain.Titles.Entities; using Fin.Domain.Wallets.Dtos; using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.Errors; using FluentAssertions; using Moq; @@ -17,7 +19,7 @@ public class WalletValidationServiceTest : TestUtils.BaseTestWithContext private WalletValidationService GetService(Resources resources) { - return new WalletValidationService(resources.WalletRepository, resources.CreditCardRepository, resources.FakeFinancialInstitution.Object); + return new WalletValidationService(resources.WalletRepository, resources.CreditCardRepository, resources.TitleRepository, resources.FakeFinancialInstitution.Object); } private Resources GetResources() @@ -26,6 +28,7 @@ private Resources GetResources() { WalletRepository = GetRepository<Wallet>(), CreditCardRepository = GetRepository<CreditCard>(), + TitleRepository = GetRepository<Title>(), FakeFinancialInstitution = new Mock<IFinancialInstitutionService>() }; } @@ -34,6 +37,7 @@ private class Resources { public IRepository<Wallet> WalletRepository { get; set; } public IRepository<CreditCard> CreditCardRepository { get; set; } + public IRepository<Title> TitleRepository { get; set; } public Mock<IFinancialInstitutionService> FakeFinancialInstitution { get; set; } } @@ -112,7 +116,7 @@ public async Task ValidateDelete_ShouldReturnFailure_WhenWalletNotFound() result.Should().NotBeNull(); result.Success.Should().BeFalse(); result.ErrorCode.Should().Be(WalletDeleteErrorCode.WalletNotFound); - result.Message.Should().Be("Wallet not found to delete."); + result.Message.Should().Be(WalletDeleteErrorCode.WalletNotFound.GetErrorMessage()); } #endregion diff --git a/Fin.Test/Wallets/WalletTest.cs b/Fin.Test/Wallets/WalletTest.cs new file mode 100644 index 0000000..0728851 --- /dev/null +++ b/Fin.Test/Wallets/WalletTest.cs @@ -0,0 +1,571 @@ +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using FluentAssertions; + +namespace Fin.Test.Wallets; + +public class WalletEntityTest : TestUtils.BaseTest +{ + #region Constructor + + [Fact] + public void Constructor_ShouldInitializeWithInput() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = TestUtils.Guids[0], + InitialBalance = TestUtils.Decimals[0] + }; + + // Act + var wallet = new Wallet(input); + + // Assert + wallet.Should().NotBeNull(); + wallet.Name.Should().Be(input.Name); + wallet.Color.Should().Be(input.Color); + wallet.Icon.Should().Be(input.Icon); + wallet.FinancialInstitutionId.Should().Be(input.FinancialInstitutionId); + wallet.InitialBalance.Should().Be(input.InitialBalance); + wallet.Inactivated.Should().BeFalse(); + wallet.Titles.Should().BeEmpty(); + wallet.CreditCards.Should().BeEmpty(); + } + + [Fact] + public void Constructor_ShouldInitializeWithNullFinancialInstitution() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = TestUtils.Decimals[0] + }; + + // Act + var wallet = new Wallet(input); + + // Assert + wallet.Should().NotBeNull(); + wallet.FinancialInstitutionId.Should().BeNull(); + } + + [Fact] + public void Constructor_ShouldInitializeWithZeroBalance() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 0m + }; + + // Act + var wallet = new Wallet(input); + + // Assert + wallet.Should().NotBeNull(); + wallet.InitialBalance.Should().Be(0m); + } + + #endregion + + #region Update + + [Fact] + public void Update_ShouldUpdateAllProperties() + { + // Arrange + var originalInput = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = TestUtils.Guids[0], + InitialBalance = TestUtils.Decimals[0] + }; + var wallet = new Wallet(originalInput); + + var updateInput = new WalletInput + { + Name = TestUtils.Strings[3], + Color = TestUtils.Strings[4], + Icon = TestUtils.Strings[5], + FinancialInstitutionId = TestUtils.Guids[1], + InitialBalance = TestUtils.Decimals[1] + }; + + // Act + wallet.Update(updateInput); + + // Assert + wallet.Name.Should().Be(updateInput.Name); + wallet.Color.Should().Be(updateInput.Color); + wallet.Icon.Should().Be(updateInput.Icon); + wallet.FinancialInstitutionId.Should().Be(updateInput.FinancialInstitutionId); + wallet.InitialBalance.Should().Be(updateInput.InitialBalance); + } + + [Fact] + public void Update_ShouldAllowChangingToNullFinancialInstitution() + { + // Arrange + var originalInput = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = TestUtils.Guids[0], + InitialBalance = TestUtils.Decimals[0] + }; + var wallet = new Wallet(originalInput); + + var updateInput = new WalletInput + { + Name = TestUtils.Strings[3], + Color = TestUtils.Strings[4], + Icon = TestUtils.Strings[5], + FinancialInstitutionId = null, + InitialBalance = TestUtils.Decimals[1] + }; + + // Act + wallet.Update(updateInput); + + // Assert + wallet.FinancialInstitutionId.Should().BeNull(); + } + + [Fact] + public void Update_ShouldAllowChangingInitialBalance() + { + // Arrange + var originalInput = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(originalInput); + + var updateInput = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 2000m + }; + + // Act + wallet.Update(updateInput); + + // Assert + wallet.InitialBalance.Should().Be(2000m); + } + + #endregion + + #region ToggleInactivated + + [Fact] + public void ToggleInactivated_ShouldChangeFromFalseToTrue() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = TestUtils.Decimals[0] + }; + var wallet = new Wallet(input); + wallet.Inactivated.Should().BeFalse(); + + // Act + wallet.ToggleInactivated(); + + // Assert + wallet.Inactivated.Should().BeTrue(); + } + + [Fact] + public void ToggleInactivated_ShouldChangeFromTrueToFalse() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = TestUtils.Decimals[0] + }; + var wallet = new Wallet(input); + wallet.ToggleInactivated(); // Set to true + wallet.Inactivated.Should().BeTrue(); + + // Act + wallet.ToggleInactivated(); + + // Assert + wallet.Inactivated.Should().BeFalse(); + } + + [Fact] + public void ToggleInactivated_ShouldToggleMultipleTimes() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = TestUtils.Decimals[0] + }; + var wallet = new Wallet(input); + + // Act & Assert + wallet.Inactivated.Should().BeFalse(); + + wallet.ToggleInactivated(); + wallet.Inactivated.Should().BeTrue(); + + wallet.ToggleInactivated(); + wallet.Inactivated.Should().BeFalse(); + + wallet.ToggleInactivated(); + wallet.Inactivated.Should().BeTrue(); + } + + #endregion + + #region CalculateBalanceAt + + [Fact] + public void CalculateBalanceAt_ShouldReturnZero_WhenDateIsBeforeCreation() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(input); + wallet.CreatedAt = TestUtils.UtcDateTimes[5]; + + var dateBeforeCreation = TestUtils.UtcDateTimes[0]; // Earlier date + + // Act + var balance = wallet.CalculateBalanceAt(dateBeforeCreation); + + // Assert + balance.Should().Be(0m); + } + + [Fact] + public void CalculateBalanceAt_ShouldReturnInitialBalance_WhenNoTitlesExist() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(input); + wallet.CreatedAt = TestUtils.UtcDateTimes[0]; + + // Act + var balance = wallet.CalculateBalanceAt(TestUtils.UtcDateTimes[5]); + + // Assert + balance.Should().Be(1000m); + } + + [Fact] + public void CalculateBalanceAt_ShouldReturnInitialBalance_WhenNoTitlesBeforeDate() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(input); + wallet.CreatedAt = TestUtils.UtcDateTimes[0]; + + // Add title after the query date + var futureTitle = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[5], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + wallet.Titles.Add(futureTitle); + + // Act - Query before the title date + var balance = wallet.CalculateBalanceAt(TestUtils.UtcDateTimes[2]); + + // Assert + balance.Should().Be(1000m); // Should return initial balance + } + + [Fact] + public void CalculateBalanceAt_ShouldReturnCorrectBalance_WithSingleIncomeTitle() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(input); + wallet.CreatedAt = TestUtils.UtcDateTimes[0]; + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[2], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + wallet.Titles.Add(title); + + // Act + var balance = wallet.CalculateBalanceAt(TestUtils.UtcDateTimes[5]); + + // Assert + balance.Should().Be(1500m); // 1000 + 500 + } + + [Fact] + public void CalculateBalanceAt_ShouldReturnCorrectBalance_WithSingleExpenseTitle() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(input); + wallet.CreatedAt = TestUtils.UtcDateTimes[0]; + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 300m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[2], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + wallet.Titles.Add(title); + + // Act + var balance = wallet.CalculateBalanceAt(TestUtils.UtcDateTimes[5]); + + // Assert + balance.Should().Be(700m); // 1000 - 300 + } + + [Fact] + public void CalculateBalanceAt_ShouldReturnCorrectBalance_WithMultipleTitles() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(input); + wallet.CreatedAt = TestUtils.UtcDateTimes[0]; + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[2], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1500m); + + var title3 = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 300m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[3], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1300m); + + wallet.Titles.Add(title1); + wallet.Titles.Add(title2); + wallet.Titles.Add(title3); + + // Act + var balance = wallet.CalculateBalanceAt(TestUtils.UtcDateTimes[5]); + + // Assert + balance.Should().Be(1600m); // 1000 + 500 - 200 + 300 + } + + [Fact] + public void CalculateBalanceAt_ShouldReturnLastTitleBalance_WhenMultipleTitlesOnSameDate() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(input); + wallet.CreatedAt = TestUtils.UtcDateTimes[0]; + + var sameDate = TestUtils.UtcDateTimes[2]; + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = sameDate, + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + }, 1000m); + title1.Id = TestUtils.Guids[0]; + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 200m, + Type = TitleType.Expense, + Date = sameDate, + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1500m); + title2.Id = TestUtils.Guids[1]; + + wallet.Titles.Add(title1); + wallet.Titles.Add(title2); + + // Act + var balance = wallet.CalculateBalanceAt(TestUtils.UtcDateTimes[5]); + + // Assert + // Should return the last title's balance (ordered by Date desc) + balance.Should().Be(1300m); // Last title2: 1500 - 200 + } + + [Fact] + public void CalculateBalanceAt_ShouldOnlyConsiderTitlesUpToDate() + { + // Arrange + var input = new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + FinancialInstitutionId = null, + InitialBalance = 1000m + }; + var wallet = new Wallet(input); + wallet.CreatedAt = TestUtils.UtcDateTimes[0]; + + var title1 = new Title(new TitleInput + { + Description = TestUtils.Strings[3], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1000m); + + var title2 = new Title(new TitleInput + { + Description = TestUtils.Strings[4], + Value = 200m, + Type = TitleType.Expense, + Date = TestUtils.UtcDateTimes[2], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1500m); + + var title3 = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 300m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[5], // Future title + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>() + }, 1300m); + + wallet.Titles.Add(title1); + wallet.Titles.Add(title2); + wallet.Titles.Add(title3); + + // Act - Query at date between title2 and title3 + var balance = wallet.CalculateBalanceAt(TestUtils.UtcDateTimes[3]); + + // Assert + balance.Should().Be(1300m); // Should only consider title1 and title2 + } + + #endregion +} \ No newline at end of file diff --git a/ai_docs/best_pratices_and_standards.md b/ai_docs/best_pratices_and_standards.md new file mode 100644 index 0000000..85fdf10 --- /dev/null +++ b/ai_docs/best_pratices_and_standards.md @@ -0,0 +1,123 @@ +## Consolidated Code Patterns and Best Practices Documentation + +This document synthesizes the architectural patterns and best practices observed across the `Wallet` and `Title` feature implementations. It serves as an extensive template for maintaining and extending the codebase, optimized for technical comprehension and AI processing. + +----- + +### I. Domain Layer: Encapsulation and Integrity + +The Domain layer (`Entities` and related DTOs) enforces business rules and maintains data integrity through strict encapsulation. + +#### 1\. Entity Encapsulation (State Protection) + +* **Principle:** Entity properties must use `private set` (`Wallet.Name`, `Title.Value`). +* **Practice:** State mutation must occur exclusively through public methods defined on the entity itself (e.g., `Wallet.Update(input)`, `Title.ToggleInactivated()`). This ensures that business invariants are checked and maintained during any state change. + +#### 2\. Entity Behavioral Logic + +* **Principle:** Complex logic that relies solely on the entity's internal state belongs to the entity. +* **Practice:** Methods like `Wallet.CalculateBalanceAt(DateTime)` and calculated properties like `Title.EffectiveValue` and `Title.ResultingBalance` reside within the entity, guaranteeing data derivation is consistent with the current state. + +#### 3\. Many-to-Many Synchronization + +* **Principle:** Relationship maintenance logic should be contained within the owning entity. +* **Practice:** The `Title.SyncCategories` and `UpdateAndReturnCategoriesToRemove` methods abstract the complexity of managing the relational table (`TitleTitleCategory`), handling additions and deletions based on the input DTO list. + +----- + +### II. Application Layer: Flow Control and Standardization + +The Application layer defines the boundaries and standardizes the input/output of feature operations. + +#### 1\. Service Boundary and SRP + +* **Interface Definition:** All services must implement a dedicated interface (e.g., `IWalletService`, `ITitleService`) to facilitate mocking, testing, and dependency inversion. +* **Delegation of Concerns:** Application Services strictly focus on **orchestration** (Transaction, Persistence, Flow Control) and delegate specialized concerns: + * **Validation:** Delegated to the `IWalletValidationService` or `IValidationPipelineOrchestrator`. + * **Complex Logic:** Delegated to specialized helper services (e.g., `ITitleUpdateHelpService` for balance reprocessing). + +#### 2\. Standardized Result Handling + +* **Pattern:** All mutating service operations must return a generic `ValidationResultDto<TSuccess, TErrorCode>`. +* **Benefit:** Provides a consistent contract for API controllers to map success data or specific business error codes to HTTP responses (200/201 vs. 422/404). + +#### 3\. Optimized Query Projection + +* **Principle:** Minimize memory consumption and processing time by projecting data early. +* **Practice:** For list operations, use `.Select(n => new OutputDTO(n))` directly on the `IQueryable<T>` before materialization (`.ToListAsync()`). This ensures only necessary fields are retrieved from the database and mapped efficiently. + +----- + +### III. Advanced Validation Pipeline Pattern + +The system leverages a sophisticated, extensible validation pipeline for complex and cross-cutting checks. + +#### 1\. Rule Modularity and Discovery + +* **Contract:** Individual rules implement `IValidationRule<TInput, TErrorCode>` (or the fully parameterized version). +* **Discovery:** Rules are registered in DI and automatically discovered and executed by the `ValidationPipelineOrchestrator` based on the `TInput` type. + +#### 2\. Fail-Fast Execution + +* **Principle:** Minimize resource consumption by stopping validation immediately upon the first discovered error. +* **Practice:** The `ValidationPipelineOrchestrator` iterates through rules and returns as soon as a validation returns `!Success`. + +#### 3\. Granular Error Reporting + +* **Detailed Output:** Rules that require returning auxiliary data (e.g., lists of IDs that failed) use `ValidationPipelineOutput<TErrorCode, TErrorData>`. + * *Example:* `TitleInputCategoriesValidation` returns a `List<Guid>` of categories not found or inactive. +* **Error Enums:** Error codes are defined using specific enumerations (e.g., `WalletCreateOrUpdateErrorCode`), increasing clarity and allowing for precise client-side error handling. + +#### 4\. Validation Query Efficiency + +* **Práctica:** Validation queries must be minimal and non-tracking (e.g., `repository.Query(tracking: false).AnyAsync(...)` or `FirstOrDefaultAsync(tracking: false)`). This avoids unnecessary data materialization and EF Core overhead. + +----- + +### IV. Complex Business Flow: Reprocessing and Transactions (Title Feature) + +The `Title` update/delete operations illustrate best practices for managing complex state dependencies and transactional integrity. + +#### 1\. Explicit Transaction Control + +* **Pattern:** All critical financial operations that involve multiple database steps (`Update`, `Create`, `Delete`) are wrapped in an explicit Unit of Work scope. + ```csharp + await using (var scope = await unitOfWork.BeginTransactionAsync(cancellationToken)) { /* ... logic ... */ } + ``` +* **Benefit:** Guarantees atomicity (ACID), ensuring that if the balance re-calculation fails, the core entity change is rolled back. + +#### 2\. State Context Management + +* **Pattern:** Use an **immutable C\# `record`** (`UpdateTitleContext`) to capture the essential **previous state** of the entity *before* modification. +* **Benefit:** This historical context (`PreviousWalletId`, `PreviousDate`, etc.) is vital for correcting balances in dependent aggregates *after* the primary entity update is committed. + +#### 3\. Delegated Reprocessing Logic + +* **Specialized Service:** The complex logic for balance correction is isolated in `ITitleUpdateHelpService`. +* **Conditional Execution:** The entity method `Title.MustReprocess(input)` provides a quick check to execute the heavy reprocessing logic *only* when financial-critical fields (Value, Type, Date, WalletId) have changed. +* **Two-Wallet Correction:** The service explicitly manages the complex case of a Wallet change: + 1. `ReprocessPreviousWallet`: Corrects the balance stream on the **old wallet** starting from the old date/title. + 2. `ReprocessCurrentWallet`: Corrects the balance stream on the **new wallet** (or the current wallet) starting from the appropriate effective date. + +----- + +### V. Infrastructure and Data Access + +#### 1\. EF Core Configuration (Integrity) + +* **Precision:** Financial fields must enforce high precision via configuration: `.HasColumnType("numeric(19,4)").HasPrecision(19, 4)`. +* **Uniqueness:** Composite unique indexes are defined for tenant isolation (e.g., `builder.HasIndex(x => new {x.Name, x.TenantId}).IsUnique()`). +* **Relationship Restriction:** Foreign key constraints are set to `OnDelete(DeleteBehavior.Restrict)` for related financial entities (e.g., `Title` to `Wallet`), preventing accidental cascade deletions and enforcing application-level deletion checks. + +#### 2\. Repository Tracking Control + +* **Read Operations:** Use `repository.Query(false)` to disable change tracking for display and reading (performance). +* **Write Operations:** Use `repository.Query()` (default tracking enabled) before fetching the entity to be updated, minimizing database hits. + +#### 3\. API Controller Mapping + +* **Minimalism:** Controllers are thin wrappers. +* **Status Code Mapping:** Standardized mapping from the service result (`ValidationResultDto`) to HTTP status codes: + * **Success:** `Created(201)` / `Ok(200)`. + * **Business Error (Generic):** `UnprocessableEntity(422)`. + * **Business Error (Specific):** Check for `*NotFound` error codes and return `NotFound(404)`. \ No newline at end of file diff --git a/ai_docs/errormessage_attribute.md b/ai_docs/errormessage_attribute.md new file mode 100644 index 0000000..c9a0e1e --- /dev/null +++ b/ai_docs/errormessage_attribute.md @@ -0,0 +1,61 @@ +## Error Message Utility Documentation + +This document describes a simple utility pattern designed to **associate human-readable messages directly with enumeration members** using C\# attributes, and provides an extension method to retrieve these messages dynamically. + +### I. Attribute Definition: `ErrorMessageAttribute` + +This attribute acts as the container for the descriptive error message. + +| Component | Description | +| :--- | :--- | +| **Name** | `ErrorMessageAttribute` | +| **Usage** | Applicable only to **Enum Fields** (`AttributeTargets.Field`). | +| **Constructor** | Takes a single `string message` argument, which is stored in the read-only `Message` property. | +| **Purpose** | Decouples the presentation layer's error string from the internal error code definition, allowing for localized or descriptive messages without modifying the enumeration structure. | + +#### Usage Example (C\#) + +```csharp +using Fin.Infrastructure.Errors; + +public enum WalletDeleteErrorCode +{ + // The message is attached directly to the enum field + [ErrorMessage("Wallet not found to delete.")] + WalletNotFound = 0, + + [ErrorMessage("Wallet is currently in use.")] + WalletInUse = 1, +} +``` + +----- + +### II. Retrieval Mechanism: `ErrorMessageExtension` + +This static class provides the reflection-based method to access the message defined by the attribute. + +#### `GetErrorMessage<TEnum>(this TEnum enumValue, bool throwIfNotFoundMessage = true)` + +| Parameter | Type | Description | +| :--- | :--- | :--- | +| `enumValue` | `TEnum` | The enumeration value (the error code) for which to retrieve the message. | +| `throwIfNotFoundMessage` | `bool` | If `true` (default), throws an `ArgumentException` if the attribute is missing. If `false`, returns an empty string. | + +#### Mechanism Details + +1. **Reflection:** Uses `GetType().GetMember(enumValue.ToString())` to retrieve the `MemberInfo` for the specific enum field. +2. **Attribute Search:** Calls `GetCustomAttribute<ErrorMessageAttribute>()` on the member. +3. **Result:** Returns the `attribute.Message` if found. + +#### Usage Example (C\#) + +```csharp +WalletDeleteErrorCode code = WalletDeleteErrorCode.WalletNotFound; + +// Retrieves the string "Wallet not found to delete." +string message = code.GetErrorMessage(); + +// Example of integrating with logging or response handling: +// Console.WriteLine(message); +``` \ No newline at end of file diff --git a/ai_docs/i_ambient_data.md b/ai_docs/i_ambient_data.md new file mode 100644 index 0000000..bc11f62 --- /dev/null +++ b/ai_docs/i_ambient_data.md @@ -0,0 +1,819 @@ +# IAmbientData Documentation + +## Overview + +`IAmbientData` provides access to the current user's context information throughout the application. It stores and exposes data about the authenticated user, including tenant isolation, user identity, and authorization level. This is the single source of truth for "who is making the request" in the system. + +## Purpose + +- Store current user's context (tenant, user ID, display name, admin status) +- Enable multi-tenancy through tenant isolation +- Provide authorization information +- Support audit trails (who created/updated entities) +- Integrate with Entity Framework interceptors for automatic tenant filtering + +--- + +## Interface Definition + +```csharp +public interface IAmbientData +{ + public Guid? TenantId { get; } + public Guid? UserId { get; } + public string? DisplayName { get; } + public bool IsAdmin { get; } + public bool IsLogged { get; } + + public void SetData(Guid tenantId, Guid userId, string displayName, bool isAdmin); + public void SetNotLogged(); +} +``` + +--- + +## Properties + +### TenantId + +```csharp +public Guid? TenantId { get; } +``` + +**Description**: The unique identifier of the current user's tenant (organization). + +**Type**: `Guid?` (nullable) + +**Value**: +- `Guid`: When user is logged in +- `null`: When no user is authenticated + +**Usage**: +- Multi-tenancy data isolation +- Automatic tenant filtering in queries +- Tenant assignment on entity creation + +**Example**: +```csharp +var currentTenantId = _ambientData.TenantId; +if (currentTenantId.HasValue) +{ + // User is logged in with a tenant + var wallets = await repository.AsNoTracking() + .Where(w => w.TenantId == currentTenantId.Value) + .ToListAsync(); +} +``` + +**Important**: All tenant-scoped entities automatically have their `TenantId` set by the `TenantEntityInterceptor`. + +--- + +### UserId + +```csharp +public Guid? UserId { get; } +``` + +**Description**: The unique identifier of the currently authenticated user. + +**Type**: `Guid?` (nullable) + +**Value**: +- `Guid`: When user is logged in +- `null`: When no user is authenticated + +**Usage**: +- Identify who is performing the action +- Audit trails (CreatedBy, UpdatedBy) +- User-specific filtering + +**Example**: +```csharp +var currentUserId = _ambientData.UserId; +if (currentUserId.HasValue) +{ + var myWallets = await repository.AsNoTracking() + .Where(w => w.CreatedBy == currentUserId.Value) + .ToListAsync(); +} +``` + +**Important**: All audited entities automatically have `CreatedBy` and `UpdatedBy` set by the `AuditedEntityInterceptor`. + +--- + +### DisplayName + +```csharp +public string? DisplayName { get; } +``` + +**Description**: The display name of the currently authenticated user. + +**Type**: `string?` (nullable) + +**Value**: +- `string`: User's display name when logged in +- `null`: When no user is authenticated + +**Usage**: +- Display current user information in UI +- Logging and error messages +- User-friendly audit information + +**Example**: +```csharp +var userName = _ambientData.DisplayName ?? "Anonymous"; +_logger.LogInformation($"Action performed by {userName}"); +``` + +--- + +### IsAdmin + +```csharp +public bool IsAdmin { get; } +``` + +**Description**: Indicates whether the current user has administrator privileges. + +**Type**: `bool` + +**Value**: +- `true`: User is an administrator +- `false`: User is a regular user or not logged in + +**Usage**: +- Authorization checks +- Feature access control +- Admin-only operations + +**Example**: +```csharp +if (_ambientData.IsAdmin) +{ + // Allow admin-only operation + await adminService.PerformAdminAction(); +} +else +{ + return Forbidden(); +} +``` + +**Best Practice**: Use this for coarse-grained authorization. For fine-grained permissions, implement a proper permission system. + +--- + +### IsLogged + +```csharp +public bool IsLogged { get; } +``` + +**Description**: Indicates whether a user is currently authenticated. + +**Type**: `bool` + +**Value**: +- `true`: User is authenticated (TenantId and UserId are not null) +- `false`: No user is authenticated + +**Usage**: +- Check authentication status +- Guard clauses for authenticated-only operations +- Conditional logic based on authentication + +**Example**: +```csharp +if (!_ambientData.IsLogged) +{ + return Unauthorized(); +} + +// Proceed with authenticated operation +var userId = _ambientData.UserId.Value; // Safe because IsLogged is true +``` + +**Implementation Note**: Typically returns `TenantId.HasValue && UserId.HasValue`. + +--- + +## Methods + +### SetData + +```csharp +public void SetData(Guid tenantId, Guid userId, string displayName, bool isAdmin); +``` + +**Description**: Sets the ambient data for an authenticated user. + +**Parameters**: +- `tenantId`: The tenant (organization) identifier +- `userId`: The user identifier +- `displayName`: The user's display name +- `isAdmin`: Whether the user is an administrator + +**When to use**: +- After successful authentication +- In middleware after validating JWT token +- In tests to simulate logged-in users + +**Example (Authentication Middleware)**: +```csharp +public class AuthenticationMiddleware +{ + private readonly IAmbientData _ambientData; + + public async Task InvokeAsync(HttpContext context) + { + var token = context.Request.Headers["Authorization"].ToString(); + + if (!string.IsNullOrEmpty(token)) + { + var claims = ValidateToken(token); + + _ambientData.SetData( + tenantId: Guid.Parse(claims.TenantId), + userId: Guid.Parse(claims.UserId), + displayName: claims.DisplayName, + isAdmin: claims.IsAdmin + ); + } + + await _next(context); + } +} +``` + +**Example (Tests)**: +```csharp +[Fact] +public async Task Create_ShouldSetCreatedBy_WhenUserIsLogged() +{ + // Arrange + await ConfigureLoggedAmbientAsync(isAdmin: false); + // This sets TenantId, UserId, DisplayName, and IsAdmin + + var service = GetService(); + + // Act + var result = await service.Create(input, true); + + // Assert + result.Data.CreatedBy.Should().Be(_ambientData.UserId); +} +``` + +**Important**: This method should only be called by authentication/authorization infrastructure, not by business logic. + +--- + +### SetNotLogged + +```csharp +public void SetNotLogged(); +``` + +**Description**: Clears the ambient data, indicating no user is authenticated. + +**When to use**: +- After logout +- When authentication fails +- In middleware when no valid token is present +- In tests for anonymous scenarios + +**Example (Logout)**: +```csharp +public class AuthenticationService +{ + private readonly IAmbientData _ambientData; + + public void Logout() + { + _ambientData.SetNotLogged(); + // Clear session, cookies, etc. + } +} +``` + +**Example (Tests)**: +```csharp +[Fact] +public async Task Get_ShouldReturnUnauthorized_WhenNotLogged() +{ + // Arrange + _ambientData.SetNotLogged(); // Simulate anonymous user + var controller = new WalletController(_service); + + // Act + var result = await controller.GetList(); + + // Assert + result.Should().BeOfType<UnauthorizedResult>(); +} +``` + +**Effect**: After calling this method: +- `TenantId` becomes `null` +- `UserId` becomes `null` +- `DisplayName` becomes `null` +- `IsAdmin` becomes `false` +- `IsLogged` becomes `false` + +--- + +## Usage Patterns + +### Pattern 1: Service with User Context + +```csharp +public class WalletService +{ + private readonly IRepository<Wallet> _repository; + private readonly IAmbientData _ambientData; + + public WalletService( + IRepository<Wallet> repository, + IAmbientData ambientData) + { + _repository = repository; + _ambientData = ambientData; + } + + public async Task<WalletOutput> Get(Guid id) + { + if (!_ambientData.IsLogged) + { + throw new UnauthorizedException(); + } + + // TenantId automatically filtered by interceptor + var wallet = await _repository.FindAsync(id); + + if (wallet == null) + { + return null; + } + + return MapToOutput(wallet); + } +} +``` + +### Pattern 2: Admin-Only Operation + +```csharp +public class AdminService +{ + private readonly IAmbientData _ambientData; + + public async Task<Result> PerformAdminOperation() + { + if (!_ambientData.IsLogged) + { + return Result.Unauthorized(); + } + + if (!_ambientData.IsAdmin) + { + return Result.Forbidden(); + } + + // Perform admin-only operation + await DoAdminStuff(); + + return Result.Success(); + } +} +``` + +### Pattern 3: Audit Logging + +```csharp +public class AuditLogger +{ + private readonly IAmbientData _ambientData; + private readonly ILogger _logger; + + public void LogAction(string action, string entityType, Guid entityId) + { + var userName = _ambientData.DisplayName ?? "Anonymous"; + var userId = _ambientData.UserId?.ToString() ?? "N/A"; + var tenantId = _ambientData.TenantId?.ToString() ?? "N/A"; + + _logger.LogInformation( + "Action: {Action}, EntityType: {EntityType}, EntityId: {EntityId}, " + + "User: {UserName} ({UserId}), Tenant: {TenantId}", + action, entityType, entityId, userName, userId, tenantId + ); + } +} +``` + +### Pattern 4: User-Specific Query + +```csharp +public class MyItemsService +{ + private readonly IRepository<Item> _repository; + private readonly IAmbientData _ambientData; + + public async Task<List<ItemOutput>> GetMyItems() + { + if (!_ambientData.IsLogged) + { + return new List<ItemOutput>(); + } + + var currentUserId = _ambientData.UserId.Value; + + var items = await _repository.AsNoTracking() + .Where(i => i.CreatedBy == currentUserId) + .OrderByDescending(i => i.CreatedAt) + .ToListAsync(); + + return items.Select(MapToOutput).ToList(); + } +} +``` + +### Pattern 5: Conditional Authorization + +```csharp +public class WalletService +{ + private readonly IAmbientData _ambientData; + + public async Task<Result> Delete(Guid walletId) + { + if (!_ambientData.IsLogged) + { + return Result.Unauthorized(); + } + + var wallet = await _repository.FindAsync(walletId); + + if (wallet == null) + { + return Result.NotFound(); + } + + // Allow if admin OR if user owns the wallet + if (!_ambientData.IsAdmin && wallet.CreatedBy != _ambientData.UserId) + { + return Result.Forbidden(); + } + + await _repository.DeleteAsync(wallet, autoSave: true); + + return Result.Success(); + } +} +``` + +--- + +## Integration with Entity Framework + +### Automatic Tenant Filtering + +The `TenantEntityInterceptor` automatically filters queries by `TenantId`: + +```csharp +// This query +var wallets = await repository.AsNoTracking().ToListAsync(); + +// Automatically becomes +var wallets = await repository.AsNoTracking() + .Where(w => w.TenantId == _ambientData.TenantId) + .ToListAsync(); +``` + +**Entities affected**: All entities implementing `ITenantEntity` interface. + +### Automatic Audit Fields + +The `AuditedEntityInterceptor` automatically sets audit fields: + +```csharp +// On Create +entity.CreatedBy = _ambientData.UserId; +entity.CreatedAt = DateTime.UtcNow; + +// On Update +entity.UpdatedBy = _ambientData.UserId; +entity.UpdatedAt = DateTime.UtcNow; +``` + +**Entities affected**: All entities implementing `IAuditedEntity` or `IAuditedTenantEntity` interfaces. + +--- + +## Usage in Tests + +### Test Pattern 1: Simulating Logged User + +```csharp +public class WalletServiceTest : TestUtils.BaseTestWithContext +{ + [Fact] + public async Task Create_ShouldSucceed_WhenUserIsLogged() + { + // Arrange + await ConfigureLoggedAmbientAsync(isAdmin: false); + // Sets TenantId, UserId, DisplayName, IsAdmin + + var service = GetService(); + var input = new WalletInput { /* ... */ }; + + // Act + var result = await service.Create(input, true); + + // Assert + result.Success.Should().BeTrue(); + + // Verify ambient data was used + var dbWallet = await repository.FindAsync(result.Data.Id); + dbWallet.CreatedBy.Should().Be(AmbientData.UserId); + dbWallet.TenantId.Should().Be(AmbientData.TenantId); + } +} +``` + +### Test Pattern 2: Simulating Admin User + +```csharp +[Fact] +public async Task DeleteAll_ShouldSucceed_WhenUserIsAdmin() +{ + // Arrange + await ConfigureLoggedAmbientAsync(isAdmin: true); + + var service = GetService(); + + // Act + var result = await service.DeleteAll(); + + // Assert + result.Success.Should().BeTrue(); +} +``` + +### Test Pattern 3: Simulating Anonymous User + +```csharp +[Fact] +public async Task Get_ShouldReturnUnauthorized_WhenNotLogged() +{ + // Arrange + AmbientData.SetNotLogged(); + var service = GetService(); + + // Act & Assert + await Assert.ThrowsAsync<UnauthorizedException>( + async () => await service.Get(Guid.NewGuid()) + ); +} +``` + +### Test Pattern 4: Multiple Users (Advanced) + +```csharp +[Fact] +public async Task User_ShouldOnlySeeOwnTenantData() +{ + // Arrange - User 1 + await ConfigureLoggedAmbientAsync(isAdmin: false); + var tenant1Id = AmbientData.TenantId.Value; + + var wallet1 = new Wallet(input1); + await repository.AddAsync(wallet1, autoSave: true); + + // Switch to User 2 (different tenant) + AmbientData.SetData( + tenantId: TestUtils.Guids[5], // Different tenant + userId: TestUtils.Guids[6], + displayName: "User 2", + isAdmin: false + ); + + var wallet2 = new Wallet(input2); + await repository.AddAsync(wallet2, autoSave: true); + + // Act - Query as User 1 + AmbientData.SetData(tenant1Id, TestUtils.Guids[0], "User 1", false); + + var walletsForUser1 = await repository.AsNoTracking().ToListAsync(); + + // Assert - Should only see User 1's tenant data + walletsForUser1.Should().HaveCount(1); + walletsForUser1.First().Id.Should().Be(wallet1.Id); +} +``` + +--- + +## Best Practices + +### DO + +1. **Always check IsLogged before accessing user data**: +```csharp +if (!_ambientData.IsLogged) +{ + throw new UnauthorizedException(); // GOOD +} + +var userId = _ambientData.UserId.Value; // Safe +``` + +2. **Use IsAdmin for coarse-grained authorization**: +```csharp +if (_ambientData.IsAdmin) +{ + // Allow admin operation +} // GOOD +``` + +3. **Inject IAmbientData in services that need user context**: +```csharp +public class MyService +{ + private readonly IAmbientData _ambientData; + + public MyService(IAmbientData ambientData) // GOOD + { + _ambientData = ambientData; + } +} +``` + +4. **Use ConfigureLoggedAmbientAsync in tests**: +```csharp +await ConfigureLoggedAmbientAsync(isAdmin: true); // GOOD +``` + +5. **Provide default values for nullable properties**: +```csharp +var userName = _ambientData.DisplayName ?? "Anonymous"; // GOOD +``` + +### DO NOT + +1. **Do not access UserId or TenantId without checking IsLogged**: +```csharp +var userId = _ambientData.UserId.Value; // BAD - might be null + +// Do this instead +if (!_ambientData.IsLogged) +{ + throw new UnauthorizedException(); +} +var userId = _ambientData.UserId.Value; // GOOD +``` + +2. **Do not call SetData from business logic**: +```csharp +public class WalletService +{ + public void SomeMethod() + { + _ambientData.SetData(...); // BAD - only authentication should set this + } +} +``` + +3. **Do not use ambient data for fine-grained permissions**: +```csharp +if (_ambientData.IsAdmin) +{ + // Only check: can user delete wallet X? + // For complex permissions, use a proper permission system +} // Consider using a permission service instead +``` + +4. **Do not store mutable state in ambient data**: +```csharp +// AmbientData is for read-only user context +// Do not try to modify user state through it +``` + +5. **Do not bypass tenant filtering**: +```csharp +// The interceptor handles this automatically +// Do not try to query cross-tenant unless you're building admin tools +var wallets = await repository.AsNoTracking() + .Where(w => w.TenantId != _ambientData.TenantId) // BAD - bypassing isolation + .ToListAsync(); +``` + +--- + +## Common Scenarios + +### Scenario 1: Checking if User Can Modify Entity + +```csharp +public async Task<bool> CanModify(Guid entityId) +{ + if (!_ambientData.IsLogged) + return false; + + // Admins can modify anything + if (_ambientData.IsAdmin) + return true; + + var entity = await repository.FindAsync(entityId); + + // User can modify if they created it + return entity?.CreatedBy == _ambientData.UserId; +} +``` + +### Scenario 2: Filtering by Current User + +```csharp +public async Task<List<Wallet>> GetMyWallets() +{ + if (!_ambientData.IsLogged) + return new List<Wallet>(); + + return await repository.AsNoTracking() + .Where(w => w.CreatedBy == _ambientData.UserId.Value) + .ToListAsync(); +} +``` + +### Scenario 3: Admin Override + +```csharp +public async Task<List<Wallet>> GetWallets(Guid? specificUserId = null) +{ + if (!_ambientData.IsLogged) + throw new UnauthorizedException(); + + // Admin can query any user's wallets + if (_ambientData.IsAdmin && specificUserId.HasValue) + { + return await repository.AsNoTracking() + .Where(w => w.CreatedBy == specificUserId.Value) + .ToListAsync(); + } + + // Regular users only see their own + return await repository.AsNoTracking() + .Where(w => w.CreatedBy == _ambientData.UserId.Value) + .ToListAsync(); +} +``` + +--- + +## Troubleshooting + +### Problem: TenantId is null in service + +**Cause**: User is not authenticated or SetData was not called. + +**Solution**: +```csharp +if (!_ambientData.IsLogged) +{ + throw new UnauthorizedException("User must be logged in"); +} +``` + +### Problem: Entity has wrong TenantId + +**Cause**: TenantEntityInterceptor not configured or ambient data set incorrectly. + +**Solution**: Ensure interceptor is registered in DbContext and ambient data is set correctly in middleware. + +### Problem: Cannot access data from other tenant (even as admin) + +**Cause**: TenantEntityInterceptor filters by current tenant automatically. + +**Solution**: For admin cross-tenant access, you may need to bypass the interceptor or use Context directly (not recommended for most cases). + +### Problem: CreatedBy/UpdatedBy not set + +**Cause**: AuditedEntityInterceptor not configured or user not logged in. + +**Solution**: Ensure interceptor is registered and user is authenticated. + +--- + +## Summary + +`IAmbientData` provides: + +- **User Context**: Access to current user's identity and tenant +- **Multi-Tenancy**: Automatic tenant isolation through interceptors +- **Authorization**: Basic admin/user role checking +- **Audit Trail**: Automatic tracking of who created/updated entities +- **Testability**: Easy simulation of different user contexts in tests + +Always check `IsLogged` before accessing user-specific properties and use the ambient data as a read-only source of truth for the current user's context. \ No newline at end of file diff --git a/ai_docs/i_repository.md b/ai_docs/i_repository.md new file mode 100644 index 0000000..4fad6b9 --- /dev/null +++ b/ai_docs/i_repository.md @@ -0,0 +1,881 @@ +# IRepository Documentation + +## Overview + +`IRepository<T>` is the generic repository interface that provides data access operations for entities in the Fin system. It abstracts Entity Framework Core operations and implements the Repository Pattern, offering a consistent API for CRUD operations across all entities. + +## Interface Definition + +```csharp +public interface IRepository<T> : IQueryable<T> where T : class +``` + +The repository implements `IQueryable<T>`, allowing it to be used directly in LINQ queries while providing additional data access methods. + +--- + +## Properties + +### Context + +```csharp +FinDbContext Context { get; } +``` + +**Description**: Provides access to the underlying Entity Framework DbContext. + +**Usage**: Use when you need direct access to the context for advanced scenarios. + +**Example**: +```csharp +var repository = GetRepository<Wallet>(); +var context = repository.Context; +``` + +**Warning**: Avoid using Context directly unless absolutely necessary. Prefer repository methods. + +--- + +## Methods + +### Query Operations + +#### Direct Query on Repository (Recommended) + +Since `IRepository<T>` implements `IQueryable<T>`, you can query directly on the repository without calling any method. + +**How it works**: The repository itself is queryable with tracking enabled by default. + +**Examples**: + +```csharp +// Query directly on repository (with tracking by default) +var wallet = await repository + .FirstOrDefaultAsync(w => w.Id == walletId); + +// Modify and save +wallet.Name = "New Name"; +await repository.UpdateAsync(wallet, true); + +// Complex query with tracking +var activeWallets = await repository + .Include(w => w.FinancialInstitution) + .Where(w => !w.Inactivated) + .Where(w => w.InitialBalance > 0) + .OrderByDescending(w => w.CreatedAt) + .Skip(10) + .Take(20) + .ToListAsync(); +``` + +**For read-only queries, use AsNoTracking()**: + +```csharp +// Read-only query (better performance) +var wallets = await repository.AsNoTracking() + .Where(w => w.Inactivated == false) + .OrderBy(w => w.Name) + .ToListAsync(); + +// Read-only with includes +var wallet = await repository.AsNoTracking() + .Include(w => w.FinancialInstitution) + .FirstOrDefaultAsync(w => w.Id == walletId); +``` + +**Best Practices**: +- Query directly on repository for operations that modify entities +- Use `AsNoTracking()` for read-only queries (better performance) +- Prefer specific queries over loading all data + +#### Query(bool tracking = true) - OBSOLETE + +```csharp +[Obsolete("Unnecessary, now you can query direct on repository")] +IQueryable<T> Query(bool tracking = true); +``` + +**Status**: This method is obsolete and will be removed in future versions. + +**Migration**: +```csharp +// OLD (obsolete) +var wallets = await repository.Query(false) + .Where(w => !w.Inactivated) + .ToListAsync(); + +// NEW (recommended) +var wallets = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .ToListAsync(); + +// OLD (obsolete) +var wallet = await repository.Query() + .FirstOrDefaultAsync(w => w.Id == walletId); + +// NEW (recommended) +var wallet = await repository + .FirstOrDefaultAsync(w => w.Id == walletId); +``` + +**Do not use this method in new code.** + +#### AsNoTracking() + +```csharp +IQueryable<T> AsNoTracking(); +``` + +**Description**: Returns a no-tracking query for read-only operations. + +**Returns**: `IQueryable<T>` configured for no-tracking (better performance) + +**When to use**: For all read-only queries where you won't modify the entities + +**Examples**: +```csharp +// Simple read-only query +var wallets = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .ToListAsync(); + +// Count query +var count = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .CountAsync(); + +// Complex read-only query +var walletNames = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .Select(w => w.Name) + .ToListAsync(); +``` + +**Best Practice**: Always use `AsNoTracking()` for read-only queries to improve performance. + +--- + +### Find Operations + +#### FindAsync(object keyValue) + +```csharp +Task<T?> FindAsync(object keyValue, CancellationToken cancellationToken = default); +``` + +**Description**: Finds an entity by its primary key value. + +**Parameters**: +- `keyValue`: The primary key value (usually a Guid) +- `cancellationToken`: Optional cancellation token + +**Returns**: The entity if found, `null` otherwise + +**When to use**: When you know the primary key and need to fetch a single entity + +**Example**: +```csharp +var wallet = await repository.FindAsync(walletId); +if (wallet == null) +{ + return NotFound(); +} +``` + +#### FindAsync(object[] keyValues) + +```csharp +Task<T?> FindAsync(object[] keyValues, CancellationToken cancellationToken = default); +``` + +**Description**: Finds an entity by composite primary key values. + +**Parameters**: +- `keyValues`: Array of primary key values for composite keys +- `cancellationToken`: Optional cancellation token + +**Returns**: The entity if found, `null` otherwise + +**When to use**: When the entity has a composite primary key + +**Example**: +```csharp +// For entities with composite keys (e.g., TitleTitleCategory) +var titleCategory = await repository.FindAsync(new object[] { titleId, categoryId }); +``` + +**Note**: Most entities in Fin use single Guid keys, so the single-parameter version is more common. + +--- + +### Create Operations + +#### AddAsync(T entity, bool autoSave) + +```csharp +Task AddAsync(T entity, bool autoSave = false, CancellationToken cancellationToken = default); +``` + +**Description**: Adds a new entity to the database. + +**Parameters**: +- `entity`: The entity to add +- `autoSave` (default: `false`): Whether to automatically save changes + - `true`: Calls SaveChanges immediately + - `false`: Changes are staged but not saved (requires manual SaveChanges) +- `cancellationToken`: Optional cancellation token + +**When to use**: +- Adding a single entity +- Use `autoSave: true` in tests or simple operations +- Use `autoSave: false` when adding multiple entities or within a transaction + +**Examples**: + +```csharp +// Simple add with auto-save (common in tests) +var wallet = new Wallet(input); +await repository.AddAsync(wallet, autoSave: true); + +// Add without auto-save (requires manual save) +var wallet = new Wallet(input); +await repository.AddAsync(wallet, autoSave: false); +await repository.SaveChangesAsync(); + +// Multiple operations in a transaction +var wallet1 = new Wallet(input1); +var wallet2 = new Wallet(input2); +await repository.AddAsync(wallet1, autoSave: false); +await repository.AddAsync(wallet2, autoSave: false); +await repository.SaveChangesAsync(); // Save all at once +``` + +#### AddAsync(T entity) + +```csharp +Task AddAsync(T entity, CancellationToken cancellationToken); +``` + +**Description**: Adds entity without auto-save (requires manual SaveChanges). + +**Parameters**: +- `entity`: The entity to add +- `cancellationToken`: Cancellation token + +**Example**: +```csharp +await repository.AddAsync(wallet, cancellationToken); +await repository.SaveChangesAsync(cancellationToken); +``` + +#### AddRangeAsync(IEnumerable<T> entities, bool autoSave) + +```csharp +Task AddRangeAsync(IEnumerable<T> entities, bool autoSave = false, CancellationToken cancellationToken = default); +``` + +**Description**: Adds multiple entities in a single operation. + +**Parameters**: +- `entities`: Collection of entities to add +- `autoSave` (default: `false`): Whether to automatically save changes +- `cancellationToken`: Optional cancellation token + +**When to use**: When adding multiple entities at once (more efficient than multiple AddAsync calls) + +**Example**: +```csharp +var wallets = new List<Wallet> +{ + new Wallet(input1), + new Wallet(input2), + new Wallet(input3) +}; + +await repository.AddRangeAsync(wallets, autoSave: true); +``` + +**Performance Note**: AddRangeAsync is more efficient than calling AddAsync multiple times. + +#### AddRangeAsync(IEnumerable<T> entities) + +```csharp +Task AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken); +``` + +**Description**: Adds multiple entities without auto-save. + +**Example**: +```csharp +await repository.AddRangeAsync(wallets, cancellationToken); +await repository.SaveChangesAsync(cancellationToken); +``` + +--- + +### Update Operations + +#### UpdateAsync(T entity, bool autoSave) + +```csharp +Task UpdateAsync(T entity, bool autoSave = false, CancellationToken cancellationToken = default); +``` + +**Description**: Updates an existing entity in the database. + +**Parameters**: +- `entity`: The entity with modified values +- `autoSave` (default: `false`): Whether to automatically save changes +- `cancellationToken`: Optional cancellation token + +**When to use**: +- Updating entity properties +- Entity must be tracked by the context or explicitly marked as modified + +**Examples**: + +```csharp +// Update with auto-save +var wallet = await repository.FindAsync(walletId); +wallet.Name = "Updated Name"; +await repository.UpdateAsync(wallet, autoSave: true); + +// Update without auto-save +var wallet = await repository.Query().FirstAsync(w => w.Id == walletId); +wallet.Name = "Updated Name"; +await repository.UpdateAsync(wallet, autoSave: false); +await repository.SaveChangesAsync(); + +// Multiple updates +var wallet1 = await repository.FindAsync(id1); +var wallet2 = await repository.FindAsync(id2); +wallet1.Name = "New Name 1"; +wallet2.Name = "New Name 2"; +await repository.UpdateAsync(wallet1, autoSave: false); +await repository.UpdateAsync(wallet2, autoSave: false); +await repository.SaveChangesAsync(); // Save all at once +``` + +**Important**: The entity must be tracked by the context. If you query with `tracking: false`, you must manually attach the entity before updating. + +#### UpdateAsync(T entity) + +```csharp +Task UpdateAsync(T entity, CancellationToken cancellationToken); +``` + +**Description**: Updates entity without auto-save. + +**Example**: +```csharp +await repository.UpdateAsync(wallet, cancellationToken); +await repository.SaveChangesAsync(cancellationToken); +``` + +--- + +### Delete Operations + +#### DeleteAsync(T entity, bool autoSave) + +```csharp +Task DeleteAsync(T entity, bool autoSave = false, CancellationToken cancellationToken = default); +``` + +**Description**: Deletes an entity from the database. + +**Parameters**: +- `entity`: The entity to delete +- `autoSave` (default: `false`): Whether to automatically save changes +- `cancellationToken`: Optional cancellation token + +**When to use**: When you need to remove an entity from the database + +**Examples**: + +```csharp +// Delete with auto-save +var wallet = await repository.FindAsync(walletId); +if (wallet != null) +{ + await repository.DeleteAsync(wallet, autoSave: true); +} + +// Delete without auto-save +var wallet = await repository.Query().FirstOrDefaultAsync(w => w.Id == walletId); +if (wallet != null) +{ + await repository.DeleteAsync(wallet, autoSave: false); + await repository.SaveChangesAsync(); +} + +// Delete multiple entities +var walletsToDelete = await repository.Query() + .Where(w => w.Inactivated) + .ToListAsync(); + +foreach (var wallet in walletsToDelete) +{ + await repository.DeleteAsync(wallet, autoSave: false); +} +await repository.SaveChangesAsync(); // Delete all at once +``` + +#### DeleteAsync(T entity) + +```csharp +Task DeleteAsync(T entity, CancellationToken cancellationToken); +``` + +**Description**: Deletes entity without auto-save. + +**Example**: +```csharp +await repository.DeleteAsync(wallet, cancellationToken); +await repository.SaveChangesAsync(cancellationToken); +``` + +--- + +### Save Operations + +#### SaveChangesAsync() + +```csharp +Task SaveChangesAsync(CancellationToken cancellationToken = default); +``` + +**Description**: Persists all pending changes to the database. + +**Parameters**: +- `cancellationToken`: Optional cancellation token + +**When to use**: When you use `autoSave: false` on Add, Update, or Delete operations + +**Example**: +```csharp +// Multiple operations in a single transaction +await repository.AddAsync(wallet1, autoSave: false); +await repository.AddAsync(wallet2, autoSave: false); +await repository.UpdateAsync(wallet3, autoSave: false); +await repository.DeleteAsync(wallet4, autoSave: false); + +// All changes are saved together (atomic operation) +await repository.SaveChangesAsync(); +``` + +**Important**: All changes since the last SaveChanges are saved together. If any operation fails, all changes are rolled back. + +--- + +## Usage Patterns + +### Pattern 1: Simple CRUD Operations + +```csharp +// Create +var wallet = new Wallet(input); +await repository.AddAsync(wallet, autoSave: true); + +// Read +var wallet = await repository.FindAsync(walletId); + +// Update +wallet.Name = "New Name"; +await repository.UpdateAsync(wallet, autoSave: true); + +// Delete +await repository.DeleteAsync(wallet, autoSave: true); +``` + +### Pattern 2: Query with Filters + +```csharp +// With tracking (for entities you will modify) +var wallet = await repository + .FirstOrDefaultAsync(w => w.Id == walletId); +wallet.Name = "Updated"; +await repository.UpdateAsync(wallet, autoSave: true); + +// Read-only (better performance) +var activeWallets = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .OrderBy(w => w.Name) + .ToListAsync(); +``` + +### Pattern 3: Complex Query with Includes + +```csharp +// With tracking +var wallet = await repository + .Include(w => w.FinancialInstitution) + .Include(w => w.Titles) + .FirstOrDefaultAsync(w => w.Id == walletId); + +// Read-only +var wallets = await repository.AsNoTracking() + .Include(w => w.FinancialInstitution) + .Where(w => !w.Inactivated) + .ToListAsync(); +``` + +### Pattern 4: Batch Operations + +```csharp +var wallets = new List<Wallet> +{ + new Wallet(input1), + new Wallet(input2), + new Wallet(input3) +}; + +// All added in a single database round-trip +await repository.AddRangeAsync(wallets, autoSave: true); +``` + +### Pattern 5: Transaction with Multiple Operations + +```csharp +// Start transaction (all or nothing) +var wallet = new Wallet(input); +await repository.AddAsync(wallet, autoSave: false); + +var title = new Title(titleInput); +await titleRepository.AddAsync(title, autoSave: false); + +// Save all changes atomically +await repository.SaveChangesAsync(); +``` + +### Pattern 6: Pagination + +```csharp +// Query with pagination +var pagedWallets = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .OrderBy(w => w.Name) + .Skip(input.SkipCount) + .Take(input.MaxResultCount) + .ToListAsync(); + +// Count total (also read-only) +var totalCount = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .CountAsync(); +``` + +--- + +## AutoSave Parameter Decision Guide + +### Use `autoSave: true` when: +- Performing a single, simple operation +- In tests (for simplicity) +- Operation does not need to be part of a larger transaction +- No related entities need to be modified together + +```csharp +// Single operation - use autoSave: true +var wallet = new Wallet(input); +await repository.AddAsync(wallet, autoSave: true); +``` + +### Use `autoSave: false` when: +- Performing multiple related operations +- Operations need to be atomic (all succeed or all fail) +- Working with related entities +- Performance optimization (batch saves) + +```csharp +// Multiple operations - use autoSave: false +var wallet = new Wallet(input); +await repository.AddAsync(wallet, autoSave: false); + +var title = new Title(titleInput); +await titleRepository.AddAsync(title, autoSave: false); + +// Save all together (atomic) +await repository.SaveChangesAsync(); +``` + +--- + +## Tracking vs No-Tracking + +### Use Tracking (Direct Query on Repository): +- When you will modify the entity +- When you need change tracking +- For Update operations + +```csharp +// Will modify - use tracking (query directly on repository) +var wallet = await repository + .FirstOrDefaultAsync(w => w.Id == walletId); +wallet.Name = "New Name"; +await repository.UpdateAsync(wallet, autoSave: true); +``` + +### Use No-Tracking (`AsNoTracking()`): +- Read-only operations +- Better performance (no change tracking overhead) +- When you won't modify the entity +- For queries that return many entities + +```csharp +// Read-only - use no-tracking +var wallets = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .ToListAsync(); +``` + +**Performance Impact**: No-tracking queries are significantly faster and use less memory, especially for large result sets. + +--- + +## Common Patterns in Tests + +### Test Pattern 1: Add and Verify + +```csharp +[Fact] +public async Task Create_ShouldPersistToDatabase() +{ + // Arrange + var repository = GetRepository<Wallet>(); + var wallet = new Wallet(input); + + // Act + await repository.AddAsync(wallet, autoSave: true); + + // Assert - Verify in database + var dbWallet = await repository.AsNoTracking() + .FirstOrDefaultAsync(w => w.Id == wallet.Id); + + dbWallet.Should().NotBeNull(); + dbWallet.Name.Should().Be(wallet.Name); +} +``` + +### Test Pattern 2: Query with Filter + +```csharp +[Fact] +public async Task GetList_ShouldFilterByInactivated() +{ + // Arrange + var repository = GetRepository<Wallet>(); + await repository.AddAsync(new Wallet(activeInput), autoSave: true); + + var inactiveWallet = new Wallet(inactiveInput); + inactiveWallet.ToggleInactivated(); + await repository.AddAsync(inactiveWallet, autoSave: true); + + // Act + var inactiveWallets = await repository.AsNoTracking() + .Where(w => w.Inactivated) + .ToListAsync(); + + // Assert + inactiveWallets.Should().HaveCount(1); +} +``` + +### Test Pattern 3: Update and Verify + +```csharp +[Fact] +public async Task Update_ShouldModifyEntity() +{ + // Arrange + var repository = GetRepository<Wallet>(); + var wallet = new Wallet(input); + await repository.AddAsync(wallet, autoSave: true); + + // Act + wallet.Name = "Updated Name"; + await repository.UpdateAsync(wallet, autoSave: true); + + // Assert + var dbWallet = await repository.AsNoTracking() + .FirstAsync(w => w.Id == wallet.Id); + + dbWallet.Name.Should().Be("Updated Name"); +} +``` + +--- + +## Best Practices + +### DO + +1. **Use AsNoTracking for read-only queries**: +```csharp +var wallets = await repository.AsNoTracking() // GOOD + .Where(w => !w.Inactivated) + .ToListAsync(); +``` + +2. **Query directly on repository for tracking**: +```csharp +var wallet = await repository // GOOD - tracking enabled + .FirstOrDefaultAsync(w => w.Id == walletId); +wallet.Name = "New Name"; +await repository.UpdateAsync(wallet, autoSave: true); +``` + +3. **Use autoSave: true in tests**: +```csharp +await repository.AddAsync(wallet, autoSave: true); // GOOD in tests +``` + +4. **Use autoSave: false for multiple operations**: +```csharp +await repository.AddAsync(entity1, autoSave: false); +await repository.AddAsync(entity2, autoSave: false); +await repository.SaveChangesAsync(); // GOOD +``` + +5. **Use FindAsync for single entity by ID**: +```csharp +var wallet = await repository.FindAsync(walletId); // GOOD +``` + +6. **Check for null after queries**: +```csharp +var wallet = await repository.FindAsync(walletId); +if (wallet == null) +{ + return NotFound(); // GOOD +} +``` + +### DO NOT + +1. **Do not use Query() method (obsolete)**: +```csharp +var wallets = await repository.Query(false) // BAD - obsolete + .ToListAsync(); + +// Use this instead +var wallets = await repository.AsNoTracking() // GOOD + .ToListAsync(); +``` + +2. **Do not use tracking for read-only operations**: +```csharp +var wallets = await repository // BAD - unnecessary tracking + .ToListAsync(); + +// Use this instead +var wallets = await repository.AsNoTracking() // GOOD + .ToListAsync(); +``` + +3. **Do not mix autoSave: true with transactions**: +```csharp +await repository.AddAsync(entity1, autoSave: true); // BAD +await repository.AddAsync(entity2, autoSave: true); // BAD +// These are separate transactions, not atomic + +// Use this instead +await repository.AddAsync(entity1, autoSave: false); // GOOD +await repository.AddAsync(entity2, autoSave: false); // GOOD +await repository.SaveChangesAsync(); // Atomic +``` + +4. **Do not forget SaveChanges with autoSave: false**: +```csharp +await repository.AddAsync(wallet, autoSave: false); // BAD - changes not saved +// Add this +await repository.SaveChangesAsync(); // GOOD +``` + +5. **Do not load unnecessary data**: +```csharp +var wallets = await repository.AsNoTracking() + .Include(w => w.Titles) // BAD - loading all titles when not needed + .ToListAsync(); +``` + +6. **Do not use direct query when FindAsync is sufficient**: +```csharp +var wallet = await repository + .FirstOrDefaultAsync(w => w.Id == walletId); // BAD + +// Use this instead +var wallet = await repository.FindAsync(walletId); // GOOD +``` + +--- + +## Performance Considerations + +### Query Performance + +**Efficient**: +```csharp +// Specific columns only +var names = await repository.AsNoTracking() + .Select(w => new { w.Id, w.Name }) + .ToListAsync(); + +// Filtered before loading +var activeWallets = await repository.AsNoTracking() + .Where(w => !w.Inactivated) + .ToListAsync(); +``` + +**Inefficient**: +```csharp +// Loading all then filtering in memory +var allWallets = await repository.AsNoTracking().ToListAsync(); +var activeWallets = allWallets.Where(w => !w.Inactivated).ToList(); // BAD +``` + +### Batch Operations + +**Efficient**: +```csharp +// Single database call +await repository.AddRangeAsync(wallets, autoSave: true); +``` + +**Inefficient**: +```csharp +// Multiple database calls +foreach (var wallet in wallets) +{ + await repository.AddAsync(wallet, autoSave: true); // BAD - N database calls +} +``` + +### Tracking Overhead + +**Efficient**: +```csharp +// No tracking for read-only +var count = await repository.AsNoTracking().CountAsync(); +``` + +**Inefficient**: +```csharp +// Unnecessary tracking overhead +var count = await repository.CountAsync(); // BAD - uses tracking by default +``` + +--- + +## Summary + +`IRepository<T>` provides: + +- **Abstraction** over Entity Framework Core +- **Consistent API** for all entities +- **Flexible querying** with IQueryable +- **Transaction support** with autoSave parameter +- **Performance optimization** with tracking control +- **Simplicity** in tests and production code + +Always consider tracking requirements and transaction boundaries when using the repository to ensure optimal performance and data consistency. \ No newline at end of file diff --git a/ai_docs/queryable_extensions.md b/ai_docs/queryable_extensions.md new file mode 100644 index 0000000..e282d02 --- /dev/null +++ b/ai_docs/queryable_extensions.md @@ -0,0 +1,77 @@ +## QueryableExtensions C\# Documentation + +This static class provides **fluent extension methods** for `IQueryable<T>` (Entity Framework Core) and `IEnumerable<T>`, enabling **dynamic, standardized data retrieval** through filtering, sorting, and pagination. + +----- + +### Core Functionality and Contracts Overview + +The extensions facilitate building complex database queries using standardized **input contracts** derived from API requests. + +| Function | Method | Input Contract | Key Mechanism | +| :--- | :--- | :--- | :--- | +| **Unified Retrieval** | `ApplyFilterAndSorter` (Preferred) | `IFilteredAndSortedInput` | Chained dynamic query building. | +| **Dynamic Sorting** | `ApplySorter` | `ISortedInput` | Expression Trees (`OrderBy`/`ThenBy`). | +| **Dynamic Filtering** | `ApplyFilter` | `IFilteredInput` | Database-optimized `LIKE` or `ILike` text search. | +| **Pagination** | `ToPagedResult` | `IPagedInput` | `CountAsync`, `Skip`, `Take`, `ToListAsync`. | +| **Conditional Where** | `WhereIf` | N/A | Conditional LINQ predicate application. | + +----- + +### Input Contracts and Models + +These structures define the required format for client requests to interact with the extension methods. + +#### Interfaces + +| Interface | Purpose | Required Properties | +| :--- | :--- | :--- | +| `IPagedInput` | Defines pagination limits. | `SkipCount` (`int`), `MaxResultCount` (`int`) | +| `ISortedInput` | Defines a list of dynamic sort criteria. | `Sorts` (`List<SortedProperty>`) | +| `IFilteredInput` | Defines a single partial search/filter. | `Filter` (`FilteredProperty`) | +| `IFilteredAndSortedInput` | **Composite Contract.** Combines filtering and sorting. | Inherits `IFilteredInput` and `ISortedInput`. | + +#### Data Models + +| Model | Purpose | Key Fields | Notes | +| :--- | :--- | :--- | :--- | +| `SortedProperty` | Single sort instruction. | `Property` (`string`), `Desc` (`bool`) | Used by `ApplySorter`. Property name is matched **case-insensitively**. | +| `FilteredProperty` | Single text search criterion. | `Property` (`string`), `Filter` (`string`) | Used by `ApplyFilter`. Property must be a **string**. | +| `PagedOutput<T>` | Standard query output container. | `Items` (`List<T>`), `TotalCount` (`int`) | Returned by `ToPagedResult`. | +| `PagedFilteredAndSortedInput` | Concrete Request DTO. | Implements all input interfaces. | Provides defaults: `SkipCount = 0`, `MaxResultCount = 25`. | + +----- + +### Preferred Usage Pattern (Unified Retrieval) + +**Always prefer `ApplyFilterAndSorter`** for applying dynamic criteria, as it ensures the correct sequence of operations (Filter then Sort) and maximizes code clarity. + +```csharp +// Input DTO implements IFilteredAndSortedInput and IPagedInput +public async Task<PagedOutput<UserDto>> GetPagedUsers(UserQueryInput input, CancellationToken ct) +{ + IQueryable<User> query = _dbContext.Users; + + // 1. Apply unified criteria (Filter + Sort) + query = query.ApplyFilterAndSorter(input); + + // 2. Execute query and paginate + return await query.ToPagedResult<UserDto>(input, ct); +} +``` + +----- + +### Implementation Details (For Maintenance/Extensibility) + +#### Dynamic Sorting (`ApplySorter`) + +* Uses **Reflection** to locate the property and **Expression Trees** to dynamically construct the lambda expression for `OrderBy`/`ThenBy`. +* Relies on the internal `GetMethodName` helper to choose `OrderBy` (first sort) vs. `ThenBy` (subsequent sorts). + +#### Dynamic Filtering (`ApplyFilter`) + +* Applies a `LIKE '%value%'` pattern. +* **Database-Agnostic Handling:** + * **PostgreSQL:** Uses `NpgsqlDbFunctionsExtensions.ILike` for native case-insensitivity. + * **Others (e.g., SQL Server):** Uses `DbFunctionsExtensions.Like` combined with explicit `string.ToLower()` calls on both sides of the comparison for simulated case-insensitivity. diff --git a/ai_docs/test_best_practices_and_standards.md b/ai_docs/test_best_practices_and_standards.md new file mode 100644 index 0000000..7214f45 --- /dev/null +++ b/ai_docs/test_best_practices_and_standards.md @@ -0,0 +1,1041 @@ +# Testing Best Practices and Standards + +## Overview + +This document outlines the testing standards, patterns, and best practices used in the Fin system. It covers test organization, naming conventions, mocking strategies, and common patterns for different types of tests. + +--- + +## Test Organization + +### 1. Test Structure + +Tests are organized following the same structure as the application: + +``` +Fin.Test/ +├── Entities/ +│ └── EntityNameTest.cs +├── Services/ +│ ├── ServiceNameTest.cs +│ └── ValidationServiceNameTest.cs +└── Controllers/ + └── ControllerNameTest.cs +``` + +### 2. Test Class Naming + +**Pattern**: `{ClassName}Test` + +**Examples**: +- `TitleEntityTest` - Tests for Title entity +- `WalletServiceTest` - Tests for WalletService +- `WalletValidationServiceTest` - Tests for WalletValidationService +- `WalletControllerTest` - Tests for WalletController + +### 3. Test Method Naming + +**Pattern**: `{MethodName}_Should{ExpectedBehavior}_When{Condition}` + +**Examples**: +```csharp +Get_ShouldReturnWallet_WhenExists +Create_ShouldReturnSuccess_WhenInputIsValid +Update_ShouldReturnFailure_WhenValidationFails +ValidateInput_ShouldReturnFailure_WhenNameIsRequired +``` + +**Alternative for simple scenarios**: +```csharp +Constructor_ShouldInitialize +ResultingBalance_ShouldBePositive_ForIncome +``` + +--- + +## Test Patterns by Layer + +### Entity Tests + +Entity tests verify domain logic without database access. Use plain test classes without base classes. + +**Characteristics**: +- No database required +- No base class inheritance (unless using BaseTest for AmbientData) +- Focus on business logic and property calculations +- Test constructors, methods, and computed properties + +**Example**: +```csharp +public class TitleEntityTest +{ + [Fact] + public void Constructor_ShouldInitializeWithInputAndPreviousBalance() + { + // Arrange + var input = new TitleInput + { + Value = TestUtils.Decimals[0], + Type = TitleType.Income, + Description = TestUtils.Strings[0], + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> { TestUtils.Guids[1] } + }; + + // Act + var title = new Title(input, 50m); + + // Assert + title.Should().NotBeNull(); + title.Value.Should().Be(TestUtils.Decimals[0]); + title.PreviousBalance.Should().Be(50m); + } + + [Fact] + public void ResultingBalance_ShouldCalculateCorrectly_ForIncome() + { + // Arrange + var input = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = TestUtils.Strings[0], + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>() + }; + + // Act + var title = new Title(input, 50m); + + // Assert + title.ResultingBalance.Should().Be(150m); + } +} +``` + +--- + +### Service Tests + +Service tests verify business logic with database interaction. Use `BaseTestWithContext` for database access. + +**Characteristics**: +- Inherit from `BaseTestWithContext` +- Use Resources pattern for dependency organization +- Mock external service dependencies +- Test both success and failure scenarios +- Verify database state after operations + +**Pattern Structure**: +```csharp +public class ServiceNameTest : TestUtils.BaseTestWithContext +{ + private readonly Mock<IDependencyService> _dependencyMock; + + public ServiceNameTest() + { + _dependencyMock = new Mock<IDependencyService>(); + } + + #region MethodName + + [Fact] + public async Task MethodName_ShouldReturnSuccess_WhenValid() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + // Setup test data... + + // Act + var result = await service.MethodName(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + // Verify database state... + } + + #endregion + + private ServiceName GetService(Resources resources) + { + return new ServiceName( + resources.Repository, + _dependencyMock.Object + ); + } + + private Resources GetResources() + { + return new Resources + { + Repository = GetRepository<Entity>() + }; + } + + private class Resources + { + public IRepository<Entity> Repository { get; set; } + } +} +``` + +**Example**: +```csharp +public class WalletServiceTest : TestUtils.BaseTestWithContext +{ + private readonly Mock<IWalletValidationService> _validationServiceMock; + + public WalletServiceTest() + { + _validationServiceMock = new Mock<IWalletValidationService>(); + } + + #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<WalletOutput, WalletCreateOrUpdateErrorCode> + { + Success = true + }; + _validationServiceMock + .Setup(v => v.ValidateInput<WalletOutput>(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); + } + + [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<WalletOutput, WalletCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = WalletCreateOrUpdateErrorCode.NameIsRequired, + Message = "Name is required." + }; + _validationServiceMock + .Setup(v => v.ValidateInput<WalletOutput>(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(); + + var count = await resources.WalletRepository.Query(false).CountAsync(); + count.Should().Be(0); + } + + #endregion + + private WalletService GetService(Resources resources) + { + return new WalletService( + resources.WalletRepository, + _validationServiceMock.Object, + DateTimeProvider.Object + ); + } + + private Resources GetResources() + { + return new Resources + { + WalletRepository = GetRepository<Wallet>() + }; + } + + private class Resources + { + public IRepository<Wallet> WalletRepository { get; set; } + } +} +``` + +--- + +### Validation Service Tests + +Validation services contain complex business rules. Tests focus on validation scenarios. + +**Characteristics**: +- Inherit from `BaseTestWithContext` +- Test all validation rules independently +- Use Theory tests for multiple similar cases +- Mock external service dependencies +- Focus on error codes and messages + +**Example**: +```csharp +public class WalletValidationServiceTest : TestUtils.BaseTestWithContext +{ + #region ValidateInput + + private WalletInput GetValidInput() => new() + { + Name = "New Wallet", + Color = "#FFFFFF", + Icon = "fa-icon", + InitialBalance = 0m, + FinancialInstitutionId = null + }; + + [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<bool>(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [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<bool>(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); + + // Act + var result = await service.ValidateInput<bool>(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."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenNameAlreadyInUseOnCreate() + { + // Arrange + var resources = GetResources(); + var service = GetService(resources); + var existingName = TestUtils.Strings[0]; + + 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<bool>(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(WalletCreateOrUpdateErrorCode.NameAlreadyInUse); + result.Message.Should().Be("Name is already in use."); + } + + #endregion + + private WalletValidationService GetService(Resources resources) + { + return new WalletValidationService( + resources.WalletRepository, + resources.CreditCardRepository, + resources.TitleRepository, + resources.FakeFinancialInstitution.Object + ); + } + + private Resources GetResources() + { + return new Resources + { + WalletRepository = GetRepository<Wallet>(), + CreditCardRepository = GetRepository<CreditCard>(), + TitleRepository = GetRepository<Title>(), + FakeFinancialInstitution = new Mock<IFinancialInstitutionService>() + }; + } + + private class Resources + { + public IRepository<Wallet> WalletRepository { get; set; } + public IRepository<CreditCard> CreditCardRepository { get; set; } + public IRepository<Title> TitleRepository { get; set; } + public Mock<IFinancialInstitutionService> FakeFinancialInstitution { get; set; } + } +} +``` + +--- + +### Controller Tests + +Controller tests verify HTTP response mapping and routing. Use `BaseTest` and mock all dependencies. + +**Characteristics**: +- Inherit from `BaseTest` (no database needed) +- Mock all service dependencies +- Test HTTP status codes and response types +- Verify correct method calls to services +- Test all endpoint scenarios + +**Pattern Structure**: +```csharp +public class ControllerNameTest : TestUtils.BaseTest +{ + private readonly Mock<IService> _serviceMock; + private readonly ControllerName _controller; + + public ControllerNameTest() + { + _serviceMock = new Mock<IService>(); + _controller = new ControllerName(_serviceMock.Object); + } + + #region MethodName + + [Fact] + public async Task MethodName_ShouldReturnOk_WhenSuccess() + { + // Test implementation... + } + + [Fact] + public async Task MethodName_ShouldReturnNotFound_WhenNotExists() + { + // Test implementation... + } + + #endregion +} +``` + +**Example**: +```csharp +public class WalletControllerTest : TestUtils.BaseTest +{ + private readonly Mock<IWalletService> _serviceMock; + private readonly WalletController _controller; + + public WalletControllerTest() + { + _serviceMock = new Mock<IWalletService>(); + _controller = new WalletController(_serviceMock.Object); + } + + #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<OkObjectResult>() + .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<NotFoundResult>(); + } + + #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<WalletOutput, WalletCreateOrUpdateErrorCode> + { + 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<CreatedResult>() + .Which.Value.Should().Be(createdWallet); + + var createdResult = result.Result as CreatedResult; + createdResult.Location.Should().Be($"categories/{createdWallet.Id}"); + } + + [Fact] + public async Task Create_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var input = new WalletInput(); + var failureResult = new ValidationResultDto<WalletOutput, WalletCreateOrUpdateErrorCode> + { + 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 unprocessableResult = result.Result + .Should().BeOfType<UnprocessableEntityObjectResult>() + .Subject; + unprocessableResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion +} +``` + +--- + +## Common Testing Patterns + +### 1. Arrange-Act-Assert (AAA) + +Always use the AAA pattern for test organization: + +```csharp +[Fact] +public async Task MethodName_ShouldExpectedBehavior_WhenCondition() +{ + // Arrange - Setup test data and dependencies + var resources = GetResources(); + var service = GetService(resources); + var input = CreateTestInput(); + + // Act - Execute the method being tested + var result = await service.MethodName(input); + + // Assert - Verify the results + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); +} +``` + +### 2. Resources Pattern + +Use a Resources class to organize dependencies: + +```csharp +private Resources GetResources() +{ + return new Resources + { + Repository = GetRepository<Entity>(), + AnotherRepository = GetRepository<AnotherEntity>(), + ServiceMock = new Mock<IService>() + }; +} + +private class Resources +{ + public IRepository<Entity> Repository { get; set; } + public IRepository<AnotherEntity> AnotherRepository { get; set; } + public Mock<IService> ServiceMock { get; set; } +} +``` + +### 3. Helper Methods + +Create helper methods for common setup: + +```csharp +private WalletInput GetValidInput() => new() +{ + Name = "Valid Name", + Color = "#FFFFFF", + Icon = "fa-icon", + InitialBalance = 0m +}; + +private async Task<Wallet> CreateWalletInDatabase(Resources resources) +{ + var wallet = new Wallet(GetValidInput()); + await resources.WalletRepository.AddAsync(wallet, true); + return wallet; +} +``` + +### 4. Theory Tests + +Use Theory tests for multiple similar scenarios: + +```csharp +[Theory] +[InlineData(null)] +[InlineData("")] +[InlineData(" ")] +public async Task Validate_ShouldReturnFailure_WhenNameIsInvalid(string name) +{ + // Arrange + var input = GetValidInput(); + input.Name = name; + + // Act + var result = await service.ValidateInput(input); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCode.NameIsRequired); +} +``` + +### 5. Database State Verification + +Always verify database state after operations: + +```csharp +[Fact] +public async Task Create_ShouldPersistToDatabase() +{ + // Arrange & Act + var result = await service.Create(input, true); + + // Assert - Verify return value + result.Success.Should().BeTrue(); + + // Assert - Verify database state + var dbEntity = await repository.Query(false) + .FirstOrDefaultAsync(e => e.Id == result.Data.Id); + + dbEntity.Should().NotBeNull(); + dbEntity.Name.Should().Be(input.Name); +} +``` + +### 6. Mock Setup Pattern + +Setup mocks clearly with expected behavior: + +```csharp +// Success scenario +var successResult = new ValidationResultDto<Output, ErrorCode> +{ + Success = true, + Data = expectedData +}; +_mockService + .Setup(s => s.Method(input)) + .ReturnsAsync(successResult); + +// Failure scenario +var failureResult = new ValidationResultDto<Output, ErrorCode> +{ + Success = false, + ErrorCode = ErrorCode.SomeError, + Message = "Error message." +}; +_mockService + .Setup(s => s.Method(input)) + .ReturnsAsync(failureResult); +``` + +--- + +## Test Organization by Method + +### Region Organization + +Use regions to group tests by method: + +```csharp +public class ServiceTest : TestUtils.BaseTestWithContext +{ + #region Get + + [Fact] + public async Task Get_ShouldReturnEntity_WhenExists() + { + // Test implementation... + } + + [Fact] + public async Task Get_ShouldReturnNull_WhenNotExists() + { + // Test implementation... + } + + #endregion + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedResult() + { + // Test implementation... + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnSuccess_WhenValid() + { + // Test implementation... + } + + [Fact] + public async Task Create_ShouldReturnFailure_WhenInvalid() + { + // Test implementation... + } + + #endregion +} +``` + +--- + +## Testing Tools and Libraries + +### 1. xUnit + +Main testing framework. + +**Usage**: +```csharp +[Fact] // Single test +public void TestMethod() { } + +[Theory] // Parameterized test +[InlineData(value1)] +[InlineData(value2)] +public void TestMethod(string value) { } +``` + +### 2. FluentAssertions + +Readable assertion library. + +**Common assertions**: +```csharp +// Null checks +result.Should().NotBeNull(); +result.Should().BeNull(); + +// Boolean +result.Success.Should().BeTrue(); +result.Success.Should().BeFalse(); + +// Equality +result.Name.Should().Be("Expected"); +result.Id.Should().Be(expectedId); + +// Collections +list.Should().HaveCount(3); +list.Should().BeEmpty(); +list.Should().Contain(item); +list.First().Name.Should().Be("First"); + +// Types +result.Should().BeOfType<OkObjectResult>(); +result.Result.Should().BeOfType<NotFoundResult>(); + +// Object comparison +result.Should().BeEquivalentTo(expected); + +// Chaining +result.Should().NotBeNull() + .And.BeOfType<ValidationResultDto>() + .Which.Success.Should().BeTrue(); +``` + +### 3. Moq + +Mocking framework for dependencies. + +**Common patterns**: +```csharp +// Create mock +var mock = new Mock<IService>(); + +// Setup method return +mock.Setup(s => s.Method(param)) + .ReturnsAsync(result); + +// Setup with any parameter +mock.Setup(s => s.Method(It.IsAny<Type>())) + .ReturnsAsync(result); + +// Verify method was called +mock.Verify(s => s.Method(param), Times.Once); + +// Setup property +mock.Setup(s => s.Property).Returns(value); +``` + +### 4. Entity Framework Core InMemory + +SQLite in-memory database for testing. + +**Usage** (handled by TestUtils): +```csharp +// Automatically configured in BaseTestWithContext +public class MyTest : TestUtils.BaseTestWithContext +{ + [Fact] + public async Task MyTest() + { + // Context and repositories are ready to use + var repository = GetRepository<Entity>(); + } +} +``` + +--- + +## Best Practices + +### DO + +1. **Use TestUtils data** for consistency: +```csharp +Name = TestUtils.Strings[0] +Id = TestUtils.Guids[0] +Date = TestUtils.UtcDateTimes[0] +``` + +2. **Test both success and failure paths**: +```csharp +MethodName_ShouldReturnSuccess_WhenValid +MethodName_ShouldReturnFailure_WhenInvalid +``` + +3. **Verify database state** after operations: +```csharp +var dbEntity = await repository.Query(false) + .FirstOrDefaultAsync(e => e.Id == id); +dbEntity.Should().NotBeNull(); +``` + +4. **Use descriptive test names**: +```csharp +Create_ShouldReturnFailure_WhenNameAlreadyInUse // GOOD +TestCreate1 // BAD +``` + +5. **Group tests** by method using regions + +6. **Setup mocks clearly** with expected behavior + +7. **Use Theory tests** for multiple similar scenarios + +8. **Isolate tests** - each test should be independent + +### DO NOT + +1. **Do not share state** between tests: +```csharp +// BAD - Shared field +private Wallet _sharedWallet; + +// GOOD - Create in each test +var wallet = new Wallet(input); +``` + +2. **Do not use magic strings**: +```csharp +Name = "Test Wallet" // BAD +Name = TestUtils.Strings[0] // GOOD +``` + +3. **Do not test multiple concerns** in one test: +```csharp +// BAD - Tests creation AND update +Create_ShouldCreateAndAllowUpdate + +// GOOD - Separate tests +Create_ShouldReturnSuccess +Update_ShouldReturnSuccess +``` + +4. **Do not ignore Arrange section**: +```csharp +// BAD - Setup in Act +var result = await service.Create(new Input { Name = "Test" }); + +// GOOD - Clear Arrange +var input = new Input { Name = TestUtils.Strings[0] }; +var result = await service.Create(input); +``` + +5. **Do not use inheritance** for test classes unless necessary (BaseTest, BaseTestWithContext) + +--- + +## Testing Checklist + +### Entity Tests +- [ ] Constructor with valid input +- [ ] Constructor with invalid input +- [ ] All public methods +- [ ] Computed properties/getters +- [ ] Business logic edge cases + +### Service Tests +- [ ] Get - exists and not exists +- [ ] GetList - with and without filters +- [ ] GetList - pagination +- [ ] Create - success and all failure scenarios +- [ ] Update - success and all failure scenarios +- [ ] Delete - success and failure +- [ ] Toggle operations +- [ ] Database state verification + +### Validation Service Tests +- [ ] All required field validations +- [ ] All length validations +- [ ] Unique constraint validations +- [ ] Foreign key validations +- [ ] Business rule validations +- [ ] Create vs Update scenarios + +### Controller Tests +- [ ] All HTTP methods (GET, POST, PUT, DELETE) +- [ ] Success responses (Ok, Created) +- [ ] Error responses (NotFound, UnprocessableEntity) +- [ ] Response body verification +- [ ] Location header verification (for Created) + +--- + +## Summary + +The testing standards ensure: + +- **Consistency** across all tests +- **Maintainability** through clear patterns +- **Coverage** of all scenarios +- **Isolation** of test cases +- **Readability** for future developers + +Always follow these patterns and use TestUtils for consistent, maintainable tests. \ No newline at end of file diff --git a/ai_docs/test_utils.md b/ai_docs/test_utils.md new file mode 100644 index 0000000..fdab743 --- /dev/null +++ b/ai_docs/test_utils.md @@ -0,0 +1,591 @@ +# TestUtils Documentation + +## Overview + +TestUtils is a utility class that provides testing infrastructure and pre-configured test data for unit and integration tests in the Fin system. It offers base classes, in-memory database contexts, and collections of reusable test data. + +## Purpose + +- Standardize test creation across the project +- Simplify test scenario configuration +- Provide consistent and reusable test data +- Manage isolated database contexts for each test +- Automatically configure ambient data and required providers + +--- + +## Base Classes + +### 1. BaseTest + +Simple base class for tests that do not require database access. + +#### Properties + +```csharp +protected Mock<IDateTimeProvider> DateTimeProvider // Mock for date/time control +protected AmbientData AmbientData // Application context data +``` + +#### Methods + +```csharp +protected async Task ConfigureLoggedAmbientAsync(bool isAdmin = true) +``` + +**Description**: Configures AmbientData simulating an authenticated user. + +**Parameters**: +- `isAdmin` (default: `true`): Defines if the user is an administrator + +**When to use**: In tests that need to simulate a logged-in user but do not need to persist data in the database. + +**Usage example**: +```csharp +public class MyServiceTest : TestUtils.BaseTest +{ + [Fact] + public async Task MyTest() + { + // Arrange + await ConfigureLoggedAmbientAsync(isAdmin: true); + var service = new MyService(AmbientData); + + // Act & Assert... + } +} +``` + +--- + +### 2. BaseTestWithContext + +Base class for tests that require database access. Automatically creates a SQLite context in memory or file. + +#### Properties + +```csharp +protected readonly FinDbContext Context // Entity Framework context +protected readonly UnitOfWork UnitOfWork // Unit of Work for transactions +``` + +#### Characteristics + +- Implements `IDisposable` for automatic cleanup +- Creates isolated SQLite database for each test +- Automatically configures interceptors (Audit and Tenant) +- Cleans up resources at test completion + +#### Methods + +##### `ConfigureLoggedAmbientAsync(bool isAdmin = true)` + +Overridden version that persists the user in the database before configuring AmbientData. + +**Difference from BaseTest**: +- `BaseTest`: Only simulates the user in AmbientData +- `BaseTestWithContext`: Creates the user in the database AND configures AmbientData + +**Usage example**: +```csharp +public class TitleServiceTest : TestUtils.BaseTestWithContext +{ + [Fact] + public async Task Create_ShouldSucceed() + { + // Arrange + await ConfigureLoggedAmbientAsync(isAdmin: true); + var service = GetService(); + + // Act + var result = await service.Create(input); + + // Assert + result.Should().NotBeNull(); + } +} +``` + +##### `GetRepository<T>()` + +Creates an instance of `IRepository<T>` connected to the test context. + +**Usage example**: +```csharp +var titleCategoryRepository = GetRepository<TitleCategory>(); +await titleCategoryRepository.AddAsync(titleCategory, true); +``` + +#### Resources Pattern + +Common pattern to organize repositories using an internal Resources class: + +```csharp +public class TitleCategoryServiceTest : TestUtils.BaseTestWithContext +{ + private TitleCategoryService GetService(Resources resources) + { + return new TitleCategoryService(resources.TitleCategoryRepository); + } + + private Resources GetResources() + { + return new Resources + { + TitleCategoryRepository = GetRepository<TitleCategory>() + }; + } + + private class Resources + { + public IRepository<TitleCategory> TitleCategoryRepository { get; set; } + } +} +``` + +--- + +## Pre-configured Test Data + +TestUtils provides static lists of ready-to-use test data. This data is consistent and reusable across all tests. + +### 1. Guids (List<Guid>) + +10 unique pre-defined GUIDs. + +**Common usage**: +- Entity IDs +- Foreign keys +- Test identifiers + +**Example**: +```csharp +var titleCategory = new TitleCategory(new TitleCategoryInput +{ + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[3] +}); + +var result = await service.Get(TestUtils.Guids[9]); // Non-existent ID +``` + +### 2. Strings (List<string>) + +10 varied strings for different purposes. + +**Content**: +- `[0]`: "alpha-923" (identifier) +- `[1]`: "John Doe" (name) +- `[2]`: "sample@test.com" (email) +- `[3]`: "lorem ipsum" (text) +- `[4]`: "token_ABC123" (token) +- `[5]`: "password123!" (password) +- `[6]`: "Order#987654" (order) +- `[7]`: "Hello, World!" (message) +- `[8]`: "A1B2C3D4" (code) +- `[9]`: "Zebra@Night" (unique name) + +**Example**: +```csharp +var input = new TitleCategoryInput +{ + Name = TestUtils.Strings[0], // "alpha-923" + Color = TestUtils.Strings[1], // "John Doe" + Icon = TestUtils.Strings[2] // "sample@test.com" +}; +``` + +### 3. Decimals (List<decimal>) + +10 decimal values for financial tests. + +**Content**: +- Positive, negative, and zero values +- Different scales (small, medium, large) +- Values with decimal places + +**Example**: +```csharp +var walletInput = new WalletInput +{ + Name = TestUtils.Strings[0], + InitialBalance = TestUtils.Decimals[0] // 100.00m +}; +``` + +### 4. UtcDateTimes (List<DateTime>) + +10 UTC dates/times for temporal tests. + +**Characteristics**: +- All with `DateTimeKind.Utc` +- Cover different years (2023-2030) +- Different times of day + +**Example**: +```csharp +var input = new TitleInput +{ + Date = TestUtils.UtcDateTimes[0], // 2023-01-01 00:00:00 UTC + Value = 100m +}; +``` + +### 5. TimeSpans (List<TimeSpan>) + +10 time intervals for tests. + +**Common usage**: +- Durations +- Intervals between events +- Times of day + +### 6. CardBrands (List<CardBrand>) + +5 pre-configured card brands. + +**Example**: +```csharp +var cardBrand = TestUtils.CardBrands[0]; +await repository.AddAsync(cardBrand, true); +``` + +### 7. FinancialInstitutions (List<FinancialInstitution>) + +5 financial institutions with different types. + +**Types included**: +- Bank +- DigitalBank +- FoodCard + +### 8. WalletsInputs (List<WalletInput>) + +5 ready-to-use inputs for creating wallets. + +### 9. Wallets (List<Wallet>) + +5 already instantiated Wallet entities. + +**Example**: +```csharp +var wallet = TestUtils.Wallets[0]; +await Context.Wallets.AddAsync(wallet); +await Context.SaveChangesAsync(); +``` + +--- + +## TestDbContextFactory + +Factory class responsible for creating and destroying database contexts for tests. + +### `Create()` + +Creates an isolated SQLite context for tests. + +**Parameters**: +- `out SqliteConnection connection`: Returns the created connection +- `out string dbFilePath`: Returns the file path (if `useFile = true`) +- `IAmbientData ambientData`: Environment data +- `IDateTimeProvider dateTimeProvider`: Date/time provider +- `bool useFile` (default: `false`): Whether to use physical file or memory + +**Operation modes**: + +1. **Memory** (`useFile = false`): + - Faster + - Does not leave files on disk + - Data is lost when connection closes + +2. **File** (`useFile = true`): + - Useful for debugging + - Can inspect database after test + - Slower + - Requires file cleanup + +**Automatic configuration**: +- Audit Interceptor (CreatedAt, UpdatedAt, CreatedBy, UpdatedBy) +- Tenant Interceptor (filtering by TenantId) +- Database schema created automatically (`EnsureCreated`) + +**Internal usage**: Called automatically by `BaseTestWithContext` constructor. + +### `Destroy()` + +Cleans up resources and deletes database file if necessary. + +**Parameters**: +- `SqliteConnection connection`: Connection to be closed +- `string dbFilePath`: File to be deleted (if exists) + +**Internal usage**: Called automatically by `BaseTestWithContext.Dispose()`. + +--- + +## Usage Patterns + +### Pattern 1: Simple entity test (no database) + +```csharp +public class TitleEntityTest +{ + [Fact] + public void Constructor_ShouldInitialize() + { + // Arrange + var input = new TitleInput + { + Value = TestUtils.Decimals[0], + Type = TitleType.Income, + Description = TestUtils.Strings[0], + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid> + { + TestUtils.Guids[1], + TestUtils.Guids[2] + } + }; + + // Act + var title = new Title(input, 50m); + + // Assert + title.Should().NotBeNull(); + title.Value.Should().Be(TestUtils.Decimals[0]); + } +} +``` + +### Pattern 2: Service test (with database) + +```csharp +public class TitleCategoryServiceTest : TestUtils.BaseTestWithContext +{ + [Fact] + public async Task Create_ShouldReturnSuccess_WhenInputIsValid() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = new TitleCategoryInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2] + }; + + // Act + var result = await service.Create(input, true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + + var dbEntity = await resources.TitleCategoryRepository + .Query(false) + .FirstOrDefaultAsync(a => a.Id == result.Data.Id); + + dbEntity.Should().NotBeNull(); + dbEntity.Name.Should().Be(input.Name); + } + + private TitleCategoryService GetService(Resources resources) + { + return new TitleCategoryService(resources.TitleCategoryRepository); + } + + private Resources GetResources() + { + return new Resources + { + TitleCategoryRepository = GetRepository<TitleCategory>() + }; + } + + private class Resources + { + public IRepository<TitleCategory> TitleCategoryRepository { get; set; } + } +} +``` + +### Pattern 3: Test with pre-populated data + +```csharp +[Fact] +public async Task GetList_ShouldReturnOrderedResults() +{ + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + // Populate database with test data + await resources.Repository.AddAsync( + new TitleCategory(new TitleCategoryInput + { + Name = "C", + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[3] + }), true); + + await resources.Repository.AddAsync( + new TitleCategory(new TitleCategoryInput + { + Name = "A", + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[3] + }), true); + + var input = new TitleCategoryGetListInput + { + MaxResultCount = 10, + SkipCount = 0 + }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Items.First().Name.Should().Be("A"); + result.Items.Last().Name.Should().Be("C"); +} +``` + +--- + +## Best Practices + +### DO + +1. **Use TestUtils data** for consistency: + ```csharp + Name = TestUtils.Strings[0] // GOOD + ``` + +2. **Always call `ConfigureLoggedAmbientAsync()`** in tests with `BaseTestWithContext`: + ```csharp + await ConfigureLoggedAmbientAsync(); // GOOD + ``` + +3. **Use Resources pattern** to organize dependencies: + ```csharp + private Resources GetResources() { ... } // GOOD + ``` + +4. **Validate both return value and database state**: + ```csharp + result.Success.Should().BeTrue(); + var dbEntity = await repository.Query(false).FirstAsync(...); + dbEntity.Should().NotBeNull(); // GOOD + ``` + +### DO NOT + +1. **Do not create hardcoded data**: + ```csharp + Name = "Test Category" // BAD + Name = TestUtils.Strings[0] // GOOD + ``` + +2. **Do not reuse the same index for different fields**: + ```csharp + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[0], // BAD + Icon = TestUtils.Strings[0] + ``` + + Better: + ```csharp + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], // GOOD + Icon = TestUtils.Strings[2] + ``` + +3. **Do not forget to Dispose** (but `BaseTestWithContext` handles this automatically): + ```csharp + public class MyTest : BaseTestWithContext // GOOD - Automatic Dispose + ``` + +4. **Do not use BaseTestWithContext if you do not need the database**: + ```csharp + // Pure entity test? Use normal class or BaseTest + public class TitleEntityTest // GOOD + + // Service test? Use BaseTestWithContext + public class TitleServiceTest : BaseTestWithContext // GOOD + ``` + +--- + +## Advantages of TestUtils + +1. **Isolation**: Each test has its own database +2. **Automatic cleanup**: Dispose handles resource cleanup +3. **Consistency**: Standardized test data across the project +4. **Simplicity**: Less boilerplate code in each test +5. **Reusability**: Base classes prevent duplication +6. **Realism**: Interceptors work as in production +7. **Flexibility**: Supports in-memory and file-based tests + +--- + +## Troubleshooting + +### Problem: "Table X not found" + +**Cause**: Context was not created correctly. + +**Solution**: Verify you are inheriting from `BaseTestWithContext` and the database was created: +```csharp +public class MyTest : TestUtils.BaseTestWithContext // CORRECT +``` + +### Problem: "TenantId null" + +**Cause**: `ConfigureLoggedAmbientAsync()` was not called. + +**Solution**: +```csharp +[Fact] +public async Task MyTest() +{ + await ConfigureLoggedAmbientAsync(); // ADD THIS + // rest of test... +} +``` + +### Problem: "Guid already exists" + +**Cause**: Attempting to insert entity with duplicate ID. + +**Solution**: Use different IDs from `TestUtils.Guids` or let the constructor generate: +```csharp +var entity1 = new Entity { Id = TestUtils.Guids[0] }; +var entity2 = new Entity { Id = TestUtils.Guids[1] }; // Different ID +``` + +### Problem: Test fails with "Database locked" + +**Cause**: Attempting to access database with `useFile = true` concurrently. + +**Solution**: Use `useFile = false` (default) or ensure tests do not execute in parallel. + +--- + +## Summary + +TestUtils is the foundation for all system tests. It: + +- Provides testing infrastructure (database, ambient data) +- Offers ready-to-use test data +- Manages automatic resource cleanup +- Standardizes test structure +- Accelerates new test creation + +Always use base classes and pre-configured data to maintain consistent and maintainable tests. \ No newline at end of file diff --git a/ai_docs/validation_pipeline.md b/ai_docs/validation_pipeline.md new file mode 100644 index 0000000..46d3665 --- /dev/null +++ b/ai_docs/validation_pipeline.md @@ -0,0 +1,135 @@ +## Validation Pipeline System Documentation + +This documentation covers the core components for implementing **complex, sequential validation logic** using a Pipeline pattern based on Dependency Injection (DI) and C\# generics. + +----- + +### I. Core Data Contract: `ValidationPipelineOutput` + +This class hierarchy is the standardized result structure returned by all validation rules and the orchestrator. It ensures the validation outcome is clear and consistent. + +#### `ValidationPipelineOutput<TErrorCode, TErrorData>` + +This is the fully parameterized output, including an error code and optional detailed error data. + +| Generic | Constraint | Description | +| :--- | :--- | :--- | +| `TErrorCode` | `struct` | The enumeration type defining specific error codes. | +| `TErrorData` | None | Type for detailed data about the error (e.g., field names, validation failure counts). | + +| Property | Type | Description | +| :--- | :--- | :--- | +| **`Success`** | `bool` | Returns `true` if `Code` is `null` (no error found). | +| `Code` | `TErrorCode?` | The specific error code if validation failed. | +| `Data` | `TErrorData?` | Optional detailed error payload. | + +**Fluent Methods:** + +* `AddError(TErrorCode code, TErrorData? data)`: Sets the error code and error data. +* `AddError(TErrorCode code)`: Sets only the error code. + +#### `ValidationPipelineOutput<TErrorCode>` + +The simplified output used when the validation rule does not need to return additional error data. + +* Inherits `Success` logic and includes the `Code` property. + +----- + +### II. The Rules: `IValidationRule` + +These interfaces define the contract for individual validation steps that will be executed by the orchestrator. Rules are automatically discovered via DI based on the input type (`TInput`). + +#### `IValidationRule<TInput, TErrorCode, TErrorData>` + +* **Usage:** For complex rules that may return **detailed error data** (`TErrorData`). +* **Method:** `Task<ValidationPipelineOutput<TErrorCode, TErrorData>> ValidateAsync(TInput input, Guid? editingId = null, CancellationToken cancellationToken = default)` + +#### `IValidationRule<TInput, TErrorCode>` + +* **Usage:** For simple rules that only need to return an **error code**. +* **Method:** `Task<ValidationPipelineOutput<TErrorCode>> ValidateAsync(TInput titleId, Guid? editingId = null, CancellationToken cancellationToken = default)` + +----- + +### III. The Execution Engine: `ValidationPipelineOrchestrator` + +The orchestrator is responsible for discovering all registered validation rules for a given input type (`TInput`) and executing them sequentially. Execution stops immediately upon the first failure (`Success == false`). + +#### `IValidationPipelineOrchestrator` + +Defines the service contract for triggering the pipeline validation. + +#### `ValidationPipelineOrchestrator` Implementation + +* **Dependency:** Requires `IServiceProvider` (injected via constructor) to dynamically fetch all rules registered in DI. +* **DI Registration:** Implements `IAutoTransient`, suggesting it is registered as a transient service. + +**Execution Flow (in `Validate<TInput, TErrorCode, TErrorData>`):** + +1. **Iterates** through all registered `IValidationRule<TInput, TErrorCode>` (Rules without data). +2. **If any rule fails**, it immediately wraps the result into the full `TErrorData` output and returns. +3. **If all simple rules pass**, it iterates through all registered `IValidationRule<TInput, TErrorCode, TErrorData>` (Rules with data). +4. **If any rule fails**, it immediately returns the specific `TErrorData` output. +5. **If all rules pass**, it returns a successful `ValidationPipelineOutput`. + +----- + +### Usage Example (C\#) + +This illustrates how a system service would trigger the pipeline and how a rule is implemented. + +#### 1\. Example Error Enumeration + +```csharp +public enum UserValidationError { + EmailFormatInvalid = 1, + UserAlreadyExists = 2, + PasswordTooWeak = 3 +} + +public class UserErrorDetails { public string Field { get; set; } } +``` + +#### 2\. Example Rule Implementation + +```csharp +// The rule validates email format and returns specific error details +public class EmailFormatRule : IValidationRule<CreateUserCommand, UserValidationError, UserErrorDetails> +{ + public async Task<ValidationPipelineOutput<UserValidationError, UserErrorDetails>> ValidateAsync( + CreateUserCommand input, Guid? editingId = null, CancellationToken cancellationToken = default) + { + if (!IsValidEmail(input.Email)) + { + var errorData = new UserErrorDetails { Field = "Email" }; + return new ValidationPipelineOutput<UserValidationError, UserErrorDetails>() + .AddError(UserValidationError.EmailFormatInvalid, errorData); + } + return new ValidationPipelineOutput<UserValidationError, UserErrorDetails>(); + } + // ... validation logic +} +``` + +#### 3\. Execution in a Service + +```csharp +public async Task<ValidationResultDto<Guid, UserErrorDetails, UserValidationError>> ProcessUserCreation( + CreateUserCommand input, IValidationPipelineOrchestrator orchestrator) +{ + // Executes the entire pipeline, stopping on the first failure + var validationResult = await orchestrator.Validate<CreateUserCommand, UserValidationError, UserErrorDetails>(input); + + if (!validationResult.Success) + { + // Converts pipeline output to the standard DTO format for API return + return new ValidationResultDto<Guid, UserErrorDetails, UserValidationError>() + .WithError(validationResult.Code.Value, validationResult.Data); + } + + // Logic for successful creation... + return new ValidationResultDto<Guid, UserErrorDetails, UserValidationError>() + .WithSuccess(Guid.NewGuid()); +} +``` \ No newline at end of file diff --git a/ai_docs/validation_result_dto.md b/ai_docs/validation_result_dto.md new file mode 100644 index 0000000..b1e3fc6 --- /dev/null +++ b/ai_docs/validation_result_dto.md @@ -0,0 +1,91 @@ +## ValidationResultDto C\# Documentation for AI + +This document describes the C\# class hierarchy for handling **operation results**, specifically focusing on **validation and business logic outcomes**. It is designed to be concise, effective, and assertive for AI processing. + +### Purpose + +The `ValidationResultDto` classes provide a **standardized, immutable-like container** for the result of an operation, clearly distinguishing between **success** (with data) and **failure** (with an error code and optional error data/message). + +It's primarily used as the **return type** for application service methods or API controllers, ensuring a consistent contract for result handling. + +### Core Class: `ValidationResultDto<TDSuccess, TDError, TErroCode>` + +| Generics | Description | Constraint | +| :--- | :--- | :--- | +| `TDSuccess` | Type of the **success data** (e.g., an entity, DTO). | `class?` or `struct?` | +| `TDError` | Type of the **error data** (e.g., a validation details DTO). | `class?` or `struct?` | +| `TErroCode` | Type of the **error enumeration** (e.g., `UserErrorCodes`). | `struct, Enum` | + +#### Key Properties + +| Property | Type | Description | +| :--- | :--- | :--- | +| `Success` | `bool` | **True** if the result is a success (no `ErrorCode`). | +| `Data` | `TDSuccess?` | The **successful result payload**. Non-null on success. | +| `ErrorCode` | `TErroCode?` | The **specific error code** on failure. Null on success. | +| `ErrorData` | `TDError?` | **Optional detailed error information**. | +| `Message` | `string` | Human-readable message. Defaults to "Success" or the error message derived from `ErrorCode`. | + +#### Builders (Fluent API) + +| Method | Purpose | Usage Example (C\#) | +| :--- | :--- | :--- | +| `WithSuccess(TDSuccess)` | Creates a **success result**. | `new Result().WithSuccess(userDto);` | +| `WithError(TErroCode, message?)` | Creates an **error result** (no `ErrorData`). | `new Result().WithError(Code.NotFound);` | +| `WithError(TErroCode, TDError, message?)` | Creates an **error result** with `ErrorData`. | `new Result().WithError(Code.Invalid, details);` | + +#### Static Factory + +* `FromPipeline(ValidationPipelineOutput<TErroCode, TDError>)`: Converts a result from a validation pipeline (likely a failure) into the DTO. + +### Specialized Subclasses (Reduced Complexity) + +The base class is extended for common scenarios, simplifying usage by defaulting generic types. + +1. **`ValidationResultDto<TDSuccess, TErroCode>`**: + + * **Defaults `TDError` to `object`**. + * Used when the error context is primarily defined by the `TErroCode` alone. + +2. **`ValidationResultDto<TDSuccess>`**: + + * **Defaults `TDError` to `object` and `TErroCode` to `NoErrorCode`**. + * Simplest form, suitable for operations where errors are *not* handled via this DTO or the only possible error is generic/implicit (e.g., exceptions). Primarily for success results. + +### 🛠️ Good Practices & Usage + +* **Immutability:** While properties have setters, the **fluent builder methods** (`WithSuccess`, `WithError`) are the preferred way to instantiate and configure the DTO, promoting a functional, immutable-like pattern. +* **Result Checking:** Always check the **`Success`** property first before accessing `Data` or `ErrorCode`. +* **Decoupling:** Use the `TErroCode` generic parameter to keep business logic error codes separate from HTTP status codes or infrastructure errors. +* **Extension Methods:** The `ValidationResultDtoExtensions.ToValidationResult` methods simplify converting `ValidationPipelineOutput` types directly into the required DTO flavor. + +#### C\# Usage Example + +```csharp +// 1. Define types +public enum UserError { NotFound, InvalidEmail } +public class UserDto { /* ... */ } +public class UserErrorDetails { public string FieldName { get; set; } } + +// 2. Class instance creation (The "Fluent" Way) +var successResult = new ValidationResultDto<UserDto, UserErrorDetails, UserError>() + .WithSuccess(new UserDto { Name = "Alice" }); + +var errorWithData = new ValidationResultDto<UserDto, UserErrorDetails, UserError>() + .WithError( + UserError.InvalidEmail, + new UserErrorDetails { FieldName = "Email" }, + "Invalid email format provided" + ); + +// 3. Consumption in Application Logic +if (successResult.Success) +{ + Console.WriteLine($"User: {successResult.Data.Name}"); // Access Data +} +else +{ + Console.WriteLine($"Error: {successResult.ErrorCode}"); // Access ErrorCode + // Console.WriteLine(errorWithData.ErrorData.FieldName); // Access ErrorData +} +``` \ No newline at end of file