diff --git a/README.md b/README.md index cfb2ae4..f4b6f8f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ relatórios diários, semanais e mensais para um gerenciamento mais eficaz, bem ### :money_with_wings: Transações - ✅ criar transação; -- ⬜ listar transações; +- ✅ listar transações; - ✅ visualizar transação; - ⬜ editar transação; - ⬜ excluir transação; diff --git a/src/MyWallet/Features/Transactions/LIst/ListTransactions.Endpoint.cs b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Endpoint.cs new file mode 100644 index 0000000..15a524b --- /dev/null +++ b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Endpoint.cs @@ -0,0 +1,21 @@ +using MyWallet.Shared.Errors; +using MyWallet.Shared.Features; + +namespace MyWallet.Features.Transactions.List; + +public sealed class ListTransactionEndpoint : IEndpoint +{ + public void Build(IEndpointRouteBuilder builder) => + builder.MapGet("transactions", ListTransactionsAsync) + .RequireAuthorization(); + + private static Task ListTransactionsAsync( + [AsParameters] ListTransactionsRequest request, + ISender sender, + HttpContext context, + CancellationToken cancellationToken) + { + return sender.Send(request.ToQuery(), cancellationToken) + .ToResponseAsync(Results.Ok, context); + } +} \ No newline at end of file diff --git a/src/MyWallet/Features/Transactions/LIst/ListTransactions.Handler.cs b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Handler.cs new file mode 100644 index 0000000..52263f1 --- /dev/null +++ b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Handler.cs @@ -0,0 +1,81 @@ +using MyWallet.Domain.Wallets; +using MyWallet.Shared.Features; +using MyWallet.Shared.Persistence; + +namespace MyWallet.Features.Transactions.List; + +public sealed class ListTransactionsHandler(IWalletRepository walletRepository, IDbContext db) + : IQueryHandler +{ + public async Task> Handle(ListTransactionsQuery query, + CancellationToken cancellationToken) + { + if (!await walletRepository.ExistsAsync(new WalletId(query.WalletId), cancellationToken)) + { + return Shared.TransactionErrors.WalletNotFound; + } + + var total = await CountTotalTransactionsAsync(query, cancellationToken); + if (total is 0) + { + return new ListTransactionsResponse( + Items: [], + Page: query.Page, + Limit: query.Limit, + Total: total) + { + WalletId = query.WalletId, + From = query.From, + To = query.To + }; + } + + var transactions = await ListTransactionsAsync(query, cancellationToken); + + return new ListTransactionsResponse( + Items: transactions, + Page: query.Page, + Limit: query.Limit, + Total: total) + { + WalletId = query.WalletId, + From = query.From, + To = query.To + }; + } + + private Task CountTotalTransactionsAsync(ListTransactionsQuery query, + CancellationToken cancellationToken) + { + return db.ExecuteScalarAsync( + sql: """ + SELECT COUNT(*) + FROM transactions t + WHERE t.wallet_id = @WalletId AND (t.date BETWEEN @From AND @To) + """, + param: query, + cancellationToken); + } + + private async Task> ListTransactionsAsync(ListTransactionsQuery query, + CancellationToken cancellationToken) + { + return await db.QueryAsync( + sql: """ + SELECT + t.id, + t.type, + t.name, + (SELECT c.name FROM categories c WHERE c.id = t.category_id) AS category, + t.amount, + t.currency, + t.date + FROM transactions t + WHERE t.wallet_id = @WalletId AND (t.date BETWEEN @From AND @To) + ORDER BY t.date DESC, t.created_at DESC + LIMIT @Limit OFFSET @Offset + """, + param: query, + cancellationToken); + } +} \ No newline at end of file diff --git a/src/MyWallet/Features/Transactions/LIst/ListTransactions.Query.cs b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Query.cs new file mode 100644 index 0000000..44a583d --- /dev/null +++ b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Query.cs @@ -0,0 +1,20 @@ +using MyWallet.Shared.Features; + +namespace MyWallet.Features.Transactions.List; + +public sealed record ListTransactionsQuery : IQuery, IHaveUser +{ + public required Ulid WalletId { get; init; } + + public required DateOnly From { get; init; } + + public required DateOnly To { get; init; } + + public required int Page { get; init; } + + public required int Limit { get; init; } + + public Ulid UserId { get; set; } + + public int Offset => (Page - 1) * Limit; +} \ No newline at end of file diff --git a/src/MyWallet/Features/Transactions/LIst/ListTransactions.Request.cs b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Request.cs new file mode 100644 index 0000000..5f06553 --- /dev/null +++ b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Request.cs @@ -0,0 +1,23 @@ +namespace MyWallet.Features.Transactions.List; + +public sealed record ListTransactionsRequest +{ + public required Ulid WalletId { get; init; } + + public required DateOnly From { get; init; } + + public DateOnly? To { get; init; } + + public int? Page { get; init; } + + public int? Limit { get; init; } + + public ListTransactionsQuery ToQuery() => new() + { + WalletId = WalletId, + From = From, + To = To ?? DateOnly.MaxValue, + Page = Page ?? 1, + Limit = Limit ?? 10 + }; +} \ No newline at end of file diff --git a/src/MyWallet/Features/Transactions/LIst/ListTransactions.Response.cs b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Response.cs new file mode 100644 index 0000000..046a385 --- /dev/null +++ b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Response.cs @@ -0,0 +1,33 @@ +using MyWallet.Shared.Contracts; + +namespace MyWallet.Features.Transactions.List; + +public sealed record TransactionResponse +{ + public required Ulid Id { get; init; } + + public required string Type { get; init; } + + public required string Name { get; init; } + + public required string Category { get; init; } + + public required decimal Amount { get; init; } + + public required string Currency { get; init; } + + public required DateOnly Date { get; init; } +} + +public sealed record ListTransactionsResponse( + IEnumerable Items, + int Page, + int Limit, + int Total) : PageResponse(Items, Page, Limit, Total) +{ + public required Ulid WalletId { get; init; } + + public required DateOnly From { get; init; } + + public required DateOnly To { get; init; } +} \ No newline at end of file diff --git a/src/MyWallet/Features/Transactions/LIst/ListTransactions.Security.cs b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Security.cs new file mode 100644 index 0000000..ba98c4c --- /dev/null +++ b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Security.cs @@ -0,0 +1,11 @@ +using MyWallet.Shared.Security; + +namespace MyWallet.Features.Transactions.List; + +public sealed class ListTransactionsSecurity : IAuthorizer +{ + public IEnumerable GetRequirements(ListTransactionsQuery query) + { + yield return new WalletOwnerRequirement(query.UserId, query.WalletId); + } +} \ No newline at end of file diff --git a/src/MyWallet/Features/Transactions/LIst/ListTransactions.Validator.cs b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Validator.cs new file mode 100644 index 0000000..598224e --- /dev/null +++ b/src/MyWallet/Features/Transactions/LIst/ListTransactions.Validator.cs @@ -0,0 +1,19 @@ +using MyWallet.Shared.Validations; + +namespace MyWallet.Features.Transactions.List; + +public sealed class ListTransactionsValidator : AbstractValidator +{ + public ListTransactionsValidator() + { + RuleFor(q => q.To) + .Must((query, to) => query.From <= to) + .WithMessage("Must be greater than or equal to 'from' date."); + + RuleFor(q => q.Page) + .PageNumber(); + + RuleFor(q => q.Limit) + .PageLimit(); + } +} \ No newline at end of file diff --git a/src/MyWallet/Shared/Contracts/PageResponse.cs b/src/MyWallet/Shared/Contracts/PageResponse.cs index 762c303..308e1e8 100644 --- a/src/MyWallet/Shared/Contracts/PageResponse.cs +++ b/src/MyWallet/Shared/Contracts/PageResponse.cs @@ -1,6 +1,6 @@ namespace MyWallet.Shared.Contracts; -public sealed record PageResponse(IEnumerable Items, int Page, int Limit, int Total) +public record PageResponse(IEnumerable Items, int Page, int Limit, int Total) { public static PageResponse Empty(int page, int limit) => new([], page, limit, 0); diff --git a/src/MyWallet/_requests/transactions/list-transactions.http b/src/MyWallet/_requests/transactions/list-transactions.http new file mode 100644 index 0000000..e32b124 --- /dev/null +++ b/src/MyWallet/_requests/transactions/list-transactions.http @@ -0,0 +1,2 @@ +GET {{baseUrl}}/transactions?walletId=01J5GXMBHYMZAT9YN8H7J3DS4J&from=2024-08-17 +Authorization: Bearer {{accessToken}} \ No newline at end of file diff --git a/test/MyWallet.IntegrationTests/Features/Transactions/ListTransactionsTests.cs b/test/MyWallet.IntegrationTests/Features/Transactions/ListTransactionsTests.cs new file mode 100644 index 0000000..0925ec6 --- /dev/null +++ b/test/MyWallet.IntegrationTests/Features/Transactions/ListTransactionsTests.cs @@ -0,0 +1,226 @@ +using System.Net.Http.Json; +using MyWallet.Domain; +using MyWallet.Domain.Categories; +using MyWallet.Domain.Transactions; +using MyWallet.Domain.Users; +using MyWallet.Domain.Wallets; +using MyWallet.Features.Transactions.List; + +namespace MyWallet.IntegrationTests.Features.Transactions; + +public sealed class ListTransactionsTests(TestApplicationFactory app) : IntegrationTest(app) +{ + private static readonly (TransactionType type, decimal amount, DateOnly date)[] Transactions = + [ + (TransactionType.Expense, 97.5m, DateOnly.Parse("2024-07-20")), + (TransactionType.Expense, 180m, DateOnly.Parse("2024-07-20")), + (TransactionType.Expense, 257.32m, DateOnly.Parse("2024-07-20")), + (TransactionType.Income, 990m, DateOnly.Parse("2024-07-20")), + (TransactionType.Income, 150m, DateOnly.Parse("2024-07-22")), + (TransactionType.Expense, 150m, DateOnly.Parse("2024-07-22")), + (TransactionType.Expense, 498m, DateOnly.Parse("2024-07-22")), + (TransactionType.Income, 80.98m, DateOnly.Parse("2024-07-22")), + (TransactionType.Expense, 78m, DateOnly.Parse("2024-07-23")), + (TransactionType.Income, 888.75m, DateOnly.Parse("2024-07-24")), + (TransactionType.Income, 80.69m, DateOnly.Parse("2024-07-24")), + (TransactionType.Expense, 700m, DateOnly.Parse("2024-07-25")) + ]; + + private IUserRepository userRepository = null!; + private IWalletRepository walletRepository = null!; + private ICategoryRepository categoryRepository = null!; + private ITransactionRepository transactionRepository = null!; + + private string accessToken = null!; + + private WalletId walletId = null!; + private CategoryId categoryId = null!; + + public override async Task InitializeAsync() + { + userRepository = GetRequiredService(); + walletRepository = GetRequiredService(); + categoryRepository = GetRequiredService(); + transactionRepository = GetRequiredService(); + + await CreateUserAsync(); + await CreateWalletAsync(); + await CreateCategoryAsync(); + await CreateTransactionsAsync(); + } + + [Fact] + public async Task ListTransactions_WhenRequestIsValid_ShouldReturnOk() + { + // Arrange + var client = CreateClient(accessToken); + var request = Requests.Transactions.ListTransactions( + walletId: walletId.Value, + from: Transactions.Min(t => t.date), + limit: 10); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var listTransactionsResponse = await response.Content + .ReadFromJsonAsync(); + + listTransactionsResponse.Should().NotBeNull(); + + listTransactionsResponse!.Items.Should().HaveCount(10); + listTransactionsResponse.Total.Should().Be(Transactions.Length); + listTransactionsResponse.Page.Should().Be(1); + listTransactionsResponse.Limit.Should().Be(10); + } + + [Fact] + public async Task ListTransactions_WhenUserDoesNotOwnWallet_ShouldReturnForbidden() + { + // Arrange + var otherUser = await Factories.User.CreateDefaultWithServiceProvider( + Services, + id: UserId.New(), + email: Constants.User.Email2); + + await userRepository.AddAsync(otherUser.Value); + + var otherUserAccessToken = CreateAccessToken(otherUser.Value); + + var client = CreateClient(otherUserAccessToken); + var request = Requests.Transactions.ListTransactions( + walletId: walletId.Value, + from: DateOnly.MinValue); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task ListTransactions_WhenUserDoesNotHaveTransactions_ShouldReturnOkWithEmptyList() + { + // Arrange + var client = CreateClient(accessToken); + var request = Requests.Transactions.ListTransactions( + walletId: walletId.Value, + from: DateOnly.MaxValue); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var listTransactionsResponse = await response.Content + .ReadFromJsonAsync(); + + listTransactionsResponse.Should().NotBeNull(); + + listTransactionsResponse!.Items.Should().BeEmpty(); + listTransactionsResponse.Total.Should().Be(0); + listTransactionsResponse.Page.Should().Be(1); + listTransactionsResponse.Limit.Should().Be(10); + } + + [Fact] + public async Task ListTransactions_WhenWalletDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var client = CreateClient(accessToken); + var request = Requests.Transactions.ListTransactions( + walletId: WalletId.New().Value, + from: DateOnly.MinValue); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ListTransaction_WhenUserIsNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var client = CreateClient(); + var request = Requests.Transactions.ListTransactions( + walletId: walletId.Value, + from: DateOnly.MinValue); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ListTransaction_WhenUserDoesNotOwnTransaction_ShouldReturnForbidden() + { + // Arrange + var otherUser = await Factories.User.CreateDefaultWithServiceProvider( + Services, + id: UserId.New(), + email: Constants.User.Email2); + + await userRepository.AddAsync(otherUser.Value); + + var otherUserAccessToken = CreateAccessToken(otherUser.Value); + + var client = CreateClient(otherUserAccessToken); + var request = Requests.Transactions.ListTransactions( + walletId: walletId.Value, + from: DateOnly.MinValue); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + private async Task CreateUserAsync() + { + var user = await Factories.User.CreateDefaultWithServiceProvider(Services); + await userRepository.AddAsync(user.Value); + + accessToken = CreateAccessToken(user.Value); + } + + private async Task CreateWalletAsync() + { + var wallet = Factories.Wallet.CreateDefault(); + await walletRepository.AddAsync(wallet); + + walletId = wallet.Id; + } + + private async Task CreateCategoryAsync() + { + var category = Factories.Category.CreateDefault(); + await categoryRepository.AddAsync(category); + + categoryId = category.Id; + } + + private async Task CreateTransactionsAsync() + { + foreach (var (type, amount, date) in Transactions) + { + var transaction = Factories.Transaction.CreateDefault( + id: TransactionId.New(), + walletId: walletId, + categoryId: categoryId, + type: type, + amount: Amount.Create(amount).Value, + date: date); + + await transactionRepository.AddAsync(transaction.Value); + } + } +} \ No newline at end of file diff --git a/test/MyWallet.IntegrationTests/Shared/Requests/Requests.Transactions.cs b/test/MyWallet.IntegrationTests/Shared/Requests/Requests.Transactions.cs index 090c9c4..6fa2832 100644 --- a/test/MyWallet.IntegrationTests/Shared/Requests/Requests.Transactions.cs +++ b/test/MyWallet.IntegrationTests/Shared/Requests/Requests.Transactions.cs @@ -1,9 +1,35 @@ +using System.Text; + namespace MyWallet.IntegrationTests.Shared.Requests; internal static partial class Requests { public static class Transactions { + public static HttpRequestMessage ListTransactions( + Ulid walletId, + DateOnly from, + DateOnly? to = null, + int page = 1, + int limit = 10) + { + var url = new StringBuilder(); + + url.Append($"{BasePath}/transactions"); + url.Append($"?walletId={walletId}"); + url.Append($"&from={from}"); + + if (to is not null) + { + url.Append($"&to={to}"); + } + + url.Append($"&page={page}"); + url.Append($"&limit={limit}"); + + return new HttpRequestMessage(HttpMethod.Get, url.ToString()); + } + public static HttpRequestMessage GetTransaction(Ulid transactionId) => new(HttpMethod.Get, $"{BasePath}/transactions/{transactionId}"); diff --git a/test/MyWallet.UnitTests/Features/Transactions/LIst/ListTransactionsHandlerTests.cs b/test/MyWallet.UnitTests/Features/Transactions/LIst/ListTransactionsHandlerTests.cs new file mode 100644 index 0000000..5cddeae --- /dev/null +++ b/test/MyWallet.UnitTests/Features/Transactions/LIst/ListTransactionsHandlerTests.cs @@ -0,0 +1,140 @@ +using MyWallet.Domain.Transactions; +using MyWallet.Domain.Wallets; +using MyWallet.Features.Transactions.List; +using MyWallet.Shared.Persistence; +using TransactionErrors = MyWallet.Features.Transactions.Shared.TransactionErrors; + +namespace MyWallet.UnitTests.Features.Transactions.LIst; + +[TestSubject(typeof(ListTransactionsHandler))] +public sealed class ListTransactionsHandlerTests +{ + private readonly IWalletRepository walletRepository = A.Fake(); + private readonly IDbContext db = A.Fake(); + + private readonly ListTransactionsHandler sut; + + private static readonly ListTransactionsQuery Query = new() + { + WalletId = Constants.Wallet.Id.Value, + From = DateOnly.FromDateTime(DateTime.Now), + To = DateOnly.FromDateTime(DateTime.Now.AddDays(5)), + Page = 1, + Limit = 3, + UserId = Constants.User.Id.Value + }; + + public ListTransactionsHandlerTests() + { + sut = new ListTransactionsHandler(walletRepository, db); + + A.CallTo(() => walletRepository.ExistsAsync( + A.That.Matches(v => v.Value == Query.WalletId), + A._)) + .Returns(true); + } + + [Fact] + public async Task Handle_WhenCalled_ShouldReturnPageWithTransactions() + { + // Arrange + var transactions = new List + { + ToTransactionResponse(Factories.Transaction.CreateDefault().Value), + ToTransactionResponse(Factories.Transaction.CreateDefault().Value), + ToTransactionResponse(Factories.Transaction.CreateDefault().Value), + ToTransactionResponse(Factories.Transaction.CreateDefault().Value), + ToTransactionResponse(Factories.Transaction.CreateDefault().Value), + ToTransactionResponse(Factories.Transaction.CreateDefault().Value) + }; + + A.CallTo(() => db.ExecuteScalarAsync( + A.Ignored, + A.Ignored, + A.Ignored)) + .Returns(transactions.Count); + + A.CallTo(() => db.QueryAsync( + A.Ignored, + A.Ignored, + A.Ignored)) + .Returns(transactions[..3]); + + // Act + var result = await sut.Handle(Query, CancellationToken.None); + + // Assert + result.IsError.Should().BeFalse(); + result.Value.Should().BeEquivalentTo(new + { + Items = transactions[..3], + Query.Page, + Query.Limit, + Total = transactions.Count, + Query.WalletId, + Query.From, + Query.To + }); + } + + [Fact] + public async Task Handle_WhenNoTransactions_ShouldReturnEmptyPage() + { + // Arrange + A.CallTo(() => db.ExecuteScalarAsync( + A.Ignored, + A.Ignored, + A.Ignored)) + .Returns(0); + + // Act + var result = await sut.Handle(Query, CancellationToken.None); + + // Assert + result.IsError.Should().BeFalse(); + result.Value.Should().BeEquivalentTo(new + { + Items = Array.Empty(), + Query.Page, + Query.Limit, + Total = 0, + Query.WalletId, + Query.From, + Query.To + }); + + A.CallTo(() => db.QueryAsync( + A.Ignored, + A.Ignored, + A.Ignored)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Handle_WhenWalletDoesNotExist_ShouldReturnError() + { + // Arrange + A.CallTo(() => walletRepository.ExistsAsync( + A.That.Matches(v => v.Value == Query.WalletId), + A._)) + .Returns(false); + + // Act + var result = await sut.Handle(Query, CancellationToken.None); + + // Assert + result.IsError.Should().BeTrue(); + result.FirstError.Should().Be(TransactionErrors.WalletNotFound); + } + + private static TransactionResponse ToTransactionResponse(Transaction transaction) => new() + { + Id = Ulid.NewUlid(), + Type = transaction.Type.Name, + Name = transaction.Name.Value, + Category = Constants.Category.Name.Value, + Amount = transaction.Amount.Value, + Currency = transaction.Currency.Name, + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(Random.Shared.Next(1, 5))) + }; +} \ No newline at end of file