diff --git a/.vscode/settings.json b/.vscode/settings.json index bb59eac..941053d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,7 @@ }, "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", - "cSpell.words": ["Encryptor"], + "cSpell.words": ["Dtos", "Encryptor", "Validatable"], "python-envs.defaultEnvManager": "ms-python.python:system", "search.exclude": { "**/Migrations": true diff --git a/src/FiscalOS.API/Accounts/AccountsExtensions.cs b/src/FiscalOS.API/Accounts/AccountsExtensions.cs index fbb5daa..3abab86 100644 --- a/src/FiscalOS.API/Accounts/AccountsExtensions.cs +++ b/src/FiscalOS.API/Accounts/AccountsExtensions.cs @@ -1,3 +1,5 @@ +using FiscalOS.API.Accounts.Add; + namespace FiscalOS.API.Accounts; internal static class AccountsExtensions diff --git a/src/FiscalOS.API/Auth/AuthExtensions.cs b/src/FiscalOS.API/Auth/AuthExtensions.cs index c22d045..a5f0ad8 100644 --- a/src/FiscalOS.API/Auth/AuthExtensions.cs +++ b/src/FiscalOS.API/Auth/AuthExtensions.cs @@ -1,3 +1,7 @@ +using FiscalOS.API.Auth.Login; +using FiscalOS.API.Auth.Logout; +using FiscalOS.API.Auth.Refresh; + namespace FiscalOS.API.Auth; internal static class AuthExtensions diff --git a/src/FiscalOS.API/Auth/Logout/Endpoint.cs b/src/FiscalOS.API/Auth/Logout/Endpoint.cs index de46d7b..9ef4b03 100644 --- a/src/FiscalOS.API/Auth/Logout/Endpoint.cs +++ b/src/FiscalOS.API/Auth/Logout/Endpoint.cs @@ -1,3 +1,5 @@ +namespace FiscalOS.API.Auth.Logout; + internal static class Endpoint { private const string Route = "/logout"; diff --git a/src/FiscalOS.API/Common/PagedQuery.cs b/src/FiscalOS.API/Common/PagedQuery.cs new file mode 100644 index 0000000..3ef2f4e --- /dev/null +++ b/src/FiscalOS.API/Common/PagedQuery.cs @@ -0,0 +1,36 @@ +namespace FiscalOS.API.Common; + +internal sealed record PagedQuery +{ + public int PageNumber { get; init; } + public int PageSize { get; init; } + + public static async ValueTask BindAsync(HttpContext context) + { + var pageNumber = int.TryParse(context.Request.Query["pageNumber"], out var pn) ? pn : 1; + var pageSize = int.TryParse(context.Request.Query["pageSize"], out var ps) ? ps : 1000; + + return new PagedQuery + { + PageNumber = pageNumber, + PageSize = pageSize + }; + } + + public Dictionary Validate() + { + var validationResults = new Dictionary(); + + if (PageNumber <= 0) + { + validationResults[nameof(PageNumber)] = ["PageNumber must be greater than 0."]; + } + + if (PageSize <= 0 || PageSize > 1000) + { + validationResults[nameof(PageSize)] = ["PageSize must be between 1 and 1000."]; + } + + return validationResults; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Common/PagedResponse.cs b/src/FiscalOS.API/Common/PagedResponse.cs new file mode 100644 index 0000000..a895ab3 --- /dev/null +++ b/src/FiscalOS.API/Common/PagedResponse.cs @@ -0,0 +1,34 @@ +namespace FiscalOS.API.Common; + +internal sealed record PagedResponse +{ + public int PageNumber { get; init; } + public int PageSize { get; init; } + public int TotalItems { get; init; } + public int TotalPages { get; init; } + public T[] Items { get; init; } = []; + + [JsonConstructor] + private PagedResponse() + { + } + + public static PagedResponse From( + int pageNumber, + int pageSize, + int totalItems, + T[] items + ) + { + var totalPages = (int)Math.Ceiling((double)totalItems / pageSize); + + return new PagedResponse + { + PageNumber = pageNumber, + PageSize = pageSize, + TotalItems = totalItems, + TotalPages = totalPages, + Items = items + }; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs b/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs index d783b9f..cac9a6c 100644 --- a/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs +++ b/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs @@ -1,3 +1,8 @@ +using FiscalOS.API.Institutions.Connect; +using FiscalOS.API.Institutions.Get; +using FiscalOS.API.Institutions.GetAvailable; +using FiscalOS.API.Institutions.Link; + namespace FiscalOS.API.Institutions; internal static class InstitutionsExtensions diff --git a/src/FiscalOS.API/Transactions/Get/Endpoint.cs b/src/FiscalOS.API/Transactions/Get/Endpoint.cs new file mode 100644 index 0000000..8617580 --- /dev/null +++ b/src/FiscalOS.API/Transactions/Get/Endpoint.cs @@ -0,0 +1,53 @@ +namespace FiscalOS.API.Transactions.Get; + +internal static class Endpoint +{ + private const string Route = "/"; + + public static RouteHandlerBuilder MapGetEndpoint(this RouteGroupBuilder groupBuilder) + { + return groupBuilder.MapGet(Route, HandleAsync); + } + + private static async Task HandleAsync( + HttpContext httpContext, + PagedQuery pagedQuery, + [FromServices] AppDbContext appDbContext, + CancellationToken ct + ) + { + var pagedQueryValidationResults = pagedQuery.Validate(); + + if (pagedQueryValidationResults.Count is not 0) + { + return Results.ValidationProblem(pagedQueryValidationResults); + } + + var userId = httpContext.GetUserId(); + var query = appDbContext.Transactions + .Where(t => t.UserId == userId); + + var transactions = await query + .Skip((pagedQuery.PageNumber - 1) * pagedQuery.PageSize) + .Take(pagedQuery.PageSize) + // TODO: Migrate Date to DateTime + // to avoid in-memory sorting + .AsAsyncEnumerable() + .OrderByDescending(t => t.Date) + .ToListAsync(ct); + + var count = await query + .Where(t => t.UserId == userId) + .CountAsync(ct); + + var transactionDtos = transactions.Select(TransactionDto.From).ToArray(); + var pagedResponse = PagedResponse.From( + pagedQuery.PageNumber, + pagedQuery.PageSize, + count, + transactionDtos + ); + + return Results.Ok(pagedResponse); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Transactions/TransactionDto.cs b/src/FiscalOS.API/Transactions/TransactionDto.cs new file mode 100644 index 0000000..a93d8c6 --- /dev/null +++ b/src/FiscalOS.API/Transactions/TransactionDto.cs @@ -0,0 +1,29 @@ +using Transaction = FiscalOS.Core.Transactions.Transaction; + +namespace FiscalOS.API.Transactions; + +internal sealed record TransactionDto +{ + public Guid Id { get; init; } + public string MerchantName { get; init; } = string.Empty; + public decimal Amount { get; init; } + public DateTimeOffset Date { get; init; } + public string Description { get; init; } = string.Empty; + + [JsonConstructor] + private TransactionDto() + { + } + + public static TransactionDto From(Transaction transaction) + { + return new TransactionDto + { + Id = transaction.Id, + MerchantName = transaction.MerchantName, + Amount = transaction.Amount, + Date = transaction.Date, + Description = transaction.Description + }; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Transactions/TransactionsExtensions.cs b/src/FiscalOS.API/Transactions/TransactionsExtensions.cs index 881961a..d3e649c 100644 --- a/src/FiscalOS.API/Transactions/TransactionsExtensions.cs +++ b/src/FiscalOS.API/Transactions/TransactionsExtensions.cs @@ -1,3 +1,7 @@ +using FiscalOS.API.Transactions.FireWebhook; +using FiscalOS.API.Transactions.Get; +using FiscalOS.API.Transactions.Webhook; + namespace FiscalOS.API.Transactions; internal static class TransactionsExtensions @@ -9,13 +13,14 @@ internal static class TransactionsExtensions var transactionsGroup = app.MapGroup(RouteGroupPrefix) .RequireAuthorization(); + transactionsGroup.MapGetEndpoint(); + transactionsGroup.MapWebhookEndpoint().AllowAnonymous(); + if (app.Environment.IsProduction() is false) { transactionsGroup.MapFireWebhookEndpoint(); } - transactionsGroup.MapWebhookEndpoint().AllowAnonymous(); - return transactionsGroup; } } \ No newline at end of file diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index 54abce4..b5c09bc 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -3,19 +3,11 @@ global using System.Security.Claims; global using System.Text.Json.Serialization; global using FiscalOS.API.Accounts; -global using FiscalOS.API.Accounts.Add; global using FiscalOS.API.Auth; -global using FiscalOS.API.Auth.Login; -global using FiscalOS.API.Auth.Refresh; +global using FiscalOS.API.Common; global using FiscalOS.API.Http; global using FiscalOS.API.Institutions; -global using FiscalOS.API.Institutions.Connect; -global using FiscalOS.API.Institutions.Get; -global using FiscalOS.API.Institutions.GetAvailable; -global using FiscalOS.API.Institutions.Link; global using FiscalOS.API.Transactions; -global using FiscalOS.API.Transactions.FireWebhook; -global using FiscalOS.API.Transactions.Webhook; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Identity; global using FiscalOS.Core.Queuing; diff --git a/src/FiscalOS.Web/src/components/NavSidebar.vue b/src/FiscalOS.Web/src/components/NavSidebar.vue index 0ef6351..5911724 100644 --- a/src/FiscalOS.Web/src/components/NavSidebar.vue +++ b/src/FiscalOS.Web/src/components/NavSidebar.vue @@ -1,102 +1,120 @@ diff --git a/src/FiscalOS.Web/src/components/TransactionCard.vue b/src/FiscalOS.Web/src/components/TransactionCard.vue new file mode 100644 index 0000000..e1c53c3 --- /dev/null +++ b/src/FiscalOS.Web/src/components/TransactionCard.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/src/FiscalOS.Web/src/composables/useTransactionService.ts b/src/FiscalOS.Web/src/composables/useTransactionService.ts new file mode 100644 index 0000000..7a50ee8 --- /dev/null +++ b/src/FiscalOS.Web/src/composables/useTransactionService.ts @@ -0,0 +1,27 @@ +import { ClientConfig, ClientFactoryKey } from '@/services/client'; +import { TransactionServiceFactoryKey } from '@/services/transactionService'; +import type { UserStore } from '@/stores/userStore'; +import { inject } from 'vue'; + +export function useTransactionService(store: UserStore) { + const clientFactory = inject(ClientFactoryKey); + const transactionServiceFactory = inject(TransactionServiceFactoryKey); + + if (clientFactory === undefined) { + throw new Error('Failed to inject client factory.'); + } + + if (transactionServiceFactory === undefined) { + throw new Error('Failed to inject transaction service factory.'); + } + + const clientConfig = new ClientConfig( + { Authorization: `Bearer ${store.user?.token}` }, + true, + store.refreshAccessToken + ); + const client = clientFactory.create(clientConfig); + const transactionService = transactionServiceFactory.create(client); + + return transactionService; +} diff --git a/src/FiscalOS.Web/src/main.ts b/src/FiscalOS.Web/src/main.ts index 487d805..ad78699 100644 --- a/src/FiscalOS.Web/src/main.ts +++ b/src/FiscalOS.Web/src/main.ts @@ -1,21 +1,29 @@ import './assets/css/main.css'; -import { createApp } from "vue"; -import { createPinia } from "pinia"; +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; -import App from "./App.vue"; -import router from "./router"; -import { ClientFactory, ClientFactoryKey } from "./services/client"; -import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService"; -import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService'; +import App from './App.vue'; +import router from './router'; +import { ClientFactory, ClientFactoryKey } from './services/client'; +import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService'; +import { + InstituionServiceFactoryKey, + InstitutionServiceFactory, +} from './services/institutionService'; +import { + TransactionServiceFactory, + TransactionServiceFactoryKey, +} from './services/transactionService'; const app = createApp(App); app.provide(ClientFactoryKey, new ClientFactory()); app.provide(AuthServiceFactoryKey, new AuthServiceFactory()); app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory()); +app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory()); app.use(createPinia()); app.use(router); -app.mount("#app"); +app.mount('#app'); diff --git a/src/FiscalOS.Web/src/router/index.ts b/src/FiscalOS.Web/src/router/index.ts index cb10e76..74ebf0c 100644 --- a/src/FiscalOS.Web/src/router/index.ts +++ b/src/FiscalOS.Web/src/router/index.ts @@ -62,6 +62,10 @@ const router = createRouter({ path: '/', component: () => import('../views/HomeView.vue'), }, + { + path: '/transactions', + component: () => import('../views/TransactionsView.vue'), + } ], }, ], diff --git a/src/FiscalOS.Web/src/services/transactionService.ts b/src/FiscalOS.Web/src/services/transactionService.ts new file mode 100644 index 0000000..6b889b1 --- /dev/null +++ b/src/FiscalOS.Web/src/services/transactionService.ts @@ -0,0 +1,72 @@ +import type { InjectionKey } from 'vue'; +import { ClientRequest, type IClient } from './client'; +import { Err, Ok, type Result } from 'ts-results'; + +type TransactionServiceFactoryKeyType = InjectionKey; + +export const TransactionServiceFactoryKey: TransactionServiceFactoryKeyType = + Symbol('AuthServiceFactory'); + +export interface ITransactionServiceFactory { + create: (client: IClient) => ITransactionService; +} + +export class TransactionServiceFactory implements ITransactionServiceFactory { + create(client: IClient): ITransactionService { + return new TransactionService(client); + } +} + +export interface ITransactionService { + get: (pageNumber?: number, pageSize?: number) => Promise, Error[]>>; +} + +export class TransactionService implements ITransactionService { + private readonly client: IClient; + private readonly endpoints = { + get: '/api/transactions', + }; + + constructor(client: IClient) { + this.client = client; + } + + async get(pageNumber: number = 1, pageSize: number = 500) { + const queryParams = new URLSearchParams({ + pageNumber: pageNumber.toString(), + pageSize: pageSize.toString(), + }); + const url = this.endpoints.get + '?' + queryParams.toString(); + const request = new ClientRequest(url); + + try { + const response = await this.client.get(request); + + if (response.ok === false) { + return Err([new Error('Failed to retrieve transactions.')]); + } + + const data = await response.json(); + return Ok(data as Page); + } catch (error) { + console.error(error); + return Err([new Error('Failed to retrieve transactions.')]); + } + } +} + +export type Page = { + pageNumber: number; + pageSize: number; + totalItems: number; + totalPages: number; + items: T[]; +}; + +export type Transaction = { + id: string; + merchantName: string; + description: string; + amount: number; + date: string; +}; diff --git a/src/FiscalOS.Web/src/views/HomeView.vue b/src/FiscalOS.Web/src/views/HomeView.vue index 764b903..1fa4d69 100644 --- a/src/FiscalOS.Web/src/views/HomeView.vue +++ b/src/FiscalOS.Web/src/views/HomeView.vue @@ -147,7 +147,7 @@ async function handleAccountAddClick() {
-
+
{{ institution.name }}
@@ -160,7 +160,7 @@ async function handleAccountAddClick() {
@@ -168,7 +168,7 @@ async function handleAccountAddClick() { Add
-
+
{{ accounts.name }}
diff --git a/src/FiscalOS.Web/src/views/TransactionsView.vue b/src/FiscalOS.Web/src/views/TransactionsView.vue new file mode 100644 index 0000000..f5f6bc5 --- /dev/null +++ b/src/FiscalOS.Web/src/views/TransactionsView.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props index 0604d02..731f913 100644 --- a/tests/Directory.Packages.props +++ b/tests/Directory.Packages.props @@ -10,6 +10,7 @@ + \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj b/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj index b8cae2a..5f02698 100644 --- a/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj +++ b/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/FiscalOS.API.Tests/Infra/HttpRequestBuilder.cs b/tests/FiscalOS.API.Tests/Infra/HttpRequestBuilder.cs index 8980f6d..940e2ed 100644 --- a/tests/FiscalOS.API.Tests/Infra/HttpRequestBuilder.cs +++ b/tests/FiscalOS.API.Tests/Infra/HttpRequestBuilder.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.WebUtilities; + namespace FiscalOS.API.Tests.Infra; internal sealed class HttpRequestBuilder @@ -8,6 +10,7 @@ internal sealed class HttpRequestBuilder private string? _bearerToken; private readonly Dictionary _cookies = []; private readonly Dictionary _headers = []; + private readonly Dictionary _queryParameters = []; private HttpRequestBuilder() { @@ -68,6 +71,12 @@ internal sealed class HttpRequestBuilder return this; } + public HttpRequestBuilder WithQueryParameter(string name, string value) + { + _queryParameters[name] = value; + return this; + } + public HttpRequestBuilder Post(Uri uri) { _method = HttpMethod.Post; @@ -103,7 +112,11 @@ internal sealed class HttpRequestBuilder throw new InvalidOperationException("URI must be set before building the request."); } - var request = new HttpRequestMessage(_method, _uri); + var uri = _queryParameters.Count > 0 + ? QueryHelpers.AddQueryString(_uri.ToString(), _queryParameters) + : _uri.ToString(); + + var request = new HttpRequestMessage(_method, uri); if (_body is not null) { diff --git a/tests/FiscalOS.API.Tests/Integration/Transactions/GetTests.cs b/tests/FiscalOS.API.Tests/Integration/Transactions/GetTests.cs new file mode 100644 index 0000000..43f5f87 --- /dev/null +++ b/tests/FiscalOS.API.Tests/Integration/Transactions/GetTests.cs @@ -0,0 +1,101 @@ +using FiscalOS.API.Transactions; + +namespace FiscalOS.API.Tests.Integration.Transactions; + +public class GetTests(TestApi testApi) : IntegrationTest(testApi) +{ + private static readonly Uri GetUri = new("/transactions", UriKind.Relative); + + [Fact] + public async Task Get_WhenNotLoggedIn_ItShouldReturn401WithProblemDetails() + { + using var request = HttpRequestBuilder.New() + .Get(GetUri) + .Build(); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Get_WhenCalledWithInvalidPageNumber_ItShouldReturn400WithProblemDetails() + { + using var request = HttpRequestBuilder.New() + .Get(GetUri) + .WithQueryParameter("pageNumber", "-1") + .WithUserId(Guid.NewGuid()) + .Build(); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await response.Should().BeValidationProblemDetails(new Dictionary() + { + ["PageNumber"] = ["PageNumber must be greater than 0."], + }); + } + + [Theory] + [InlineData(0)] + [InlineData(1001)] + public async Task Get_WhenCalledWithInvalidPageSize_ItShouldReturn400WithProblemDetails(int pageSize) + { + using var request = HttpRequestBuilder.New() + .Get(GetUri) + .WithQueryParameter("pageSize", pageSize.ToString(CultureInfo.InvariantCulture)) + .WithUserId(Guid.NewGuid()) + .Build(); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await response.Should().BeValidationProblemDetails(new Dictionary() + { + ["PageSize"] = ["PageSize must be between 1 and 1000."], + }); + } + + [Fact] + public async Task Get_WhenCalled_ItShouldReturn200WithPagedResponseOfTransactions() + { + var user = await Api.ExecuteAsync(static async (context, ct, sp) => + { + var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); + + var user = UserBuilder.Create() + .WithInstitution(static ib => + { + ib.WithMetadata(); + ib.WithAccount(static ab => + { + ab.WithMetadata(); + ab.WithTransaction(static tb => + { + tb.WithMetadata(); + }); + }); + }) + .Build(); + + await context.AddAsync(user, ct); + await context.SaveChangesAsync(ct); + return user; + }, TestContext.Current.CancellationToken); + + using var request = HttpRequestBuilder.New() + .Get(GetUri) + .WithUserId(user.Id) + .Build(); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + (await response.Should() + .BeJsonContentOfType>(HttpStatusCode.OK)) + .Which + .Items + .Should() + .BeEquivalentTo( + user.Transactions.Select(TransactionDto.From) + ); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Unit/PagedResponseTests.cs b/tests/FiscalOS.API.Tests/Unit/PagedResponseTests.cs new file mode 100644 index 0000000..bfbf805 --- /dev/null +++ b/tests/FiscalOS.API.Tests/Unit/PagedResponseTests.cs @@ -0,0 +1,21 @@ +namespace FiscalOS.API.Tests.Unit; + +public class PagedResponseTests +{ + [Fact] + public void From_WhenCalled_ItShouldReturnPageWithCorrectValues() + { + var pageNumber = 1; + var pageSize = 10; + var totalItems = 25; + string[] items = ["test"]; + + var results = PagedResponse.From(pageNumber, pageSize, totalItems, items); + + results.PageNumber.Should().Be(pageNumber); + results.PageSize.Should().Be(pageSize); + results.TotalItems.Should().Be(totalItems); + results.TotalPages.Should().Be(3); + results.Items.Should().BeEquivalentTo(items); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Usings.cs b/tests/FiscalOS.API.Tests/Usings.cs index a6e01c0..041f1b3 100644 --- a/tests/FiscalOS.API.Tests/Usings.cs +++ b/tests/FiscalOS.API.Tests/Usings.cs @@ -1,3 +1,4 @@ +global using System.Globalization; global using System.IdentityModel.Tokens.Jwt; global using System.Net; global using System.Net.Http.Headers; @@ -10,6 +11,7 @@ global using System.Text.Json; global using AwesomeAssertions.Execution; global using AwesomeAssertions.Primitives; +global using FiscalOS.API.Common; global using FiscalOS.API.Tests.Assertions; global using FiscalOS.API.Tests.Infra; global using FiscalOS.Core.Authentication; @@ -18,6 +20,7 @@ global using FiscalOS.Core.Security; global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Data; +global using FiscalOS.Tests.Common.Data; global using Going.Plaid; global using Going.Plaid.Entity;