feat(api,web): retrieve and display transactions
This commit is contained in:
Vendored
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"cSpell.words": ["Encryptor"],
|
"cSpell.words": ["Dtos", "Encryptor", "Validatable"],
|
||||||
"python-envs.defaultEnvManager": "ms-python.python:system",
|
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"**/Migrations": true
|
"**/Migrations": true
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using FiscalOS.API.Accounts.Add;
|
||||||
|
|
||||||
namespace FiscalOS.API.Accounts;
|
namespace FiscalOS.API.Accounts;
|
||||||
|
|
||||||
internal static class AccountsExtensions
|
internal static class AccountsExtensions
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
using FiscalOS.API.Auth.Login;
|
||||||
|
using FiscalOS.API.Auth.Logout;
|
||||||
|
using FiscalOS.API.Auth.Refresh;
|
||||||
|
|
||||||
namespace FiscalOS.API.Auth;
|
namespace FiscalOS.API.Auth;
|
||||||
|
|
||||||
internal static class AuthExtensions
|
internal static class AuthExtensions
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
namespace FiscalOS.API.Auth.Logout;
|
||||||
|
|
||||||
internal static class Endpoint
|
internal static class Endpoint
|
||||||
{
|
{
|
||||||
private const string Route = "/logout";
|
private const string Route = "/logout";
|
||||||
|
|||||||
@@ -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<PagedQuery> 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<string, string[]> Validate()
|
||||||
|
{
|
||||||
|
var validationResults = new Dictionary<string, string[]>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace FiscalOS.API.Common;
|
||||||
|
|
||||||
|
internal sealed record PagedResponse<T>
|
||||||
|
{
|
||||||
|
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<T> From(
|
||||||
|
int pageNumber,
|
||||||
|
int pageSize,
|
||||||
|
int totalItems,
|
||||||
|
T[] items
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
|
||||||
|
|
||||||
|
return new PagedResponse<T>
|
||||||
|
{
|
||||||
|
PageNumber = pageNumber,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalItems = totalItems,
|
||||||
|
TotalPages = totalPages,
|
||||||
|
Items = items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
namespace FiscalOS.API.Institutions;
|
||||||
|
|
||||||
internal static class InstitutionsExtensions
|
internal static class InstitutionsExtensions
|
||||||
|
|||||||
@@ -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<IResult> 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<TransactionDto>.From(
|
||||||
|
pagedQuery.PageNumber,
|
||||||
|
pagedQuery.PageSize,
|
||||||
|
count,
|
||||||
|
transactionDtos
|
||||||
|
);
|
||||||
|
|
||||||
|
return Results.Ok(pagedResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
using FiscalOS.API.Transactions.FireWebhook;
|
||||||
|
using FiscalOS.API.Transactions.Get;
|
||||||
|
using FiscalOS.API.Transactions.Webhook;
|
||||||
|
|
||||||
namespace FiscalOS.API.Transactions;
|
namespace FiscalOS.API.Transactions;
|
||||||
|
|
||||||
internal static class TransactionsExtensions
|
internal static class TransactionsExtensions
|
||||||
@@ -9,13 +13,14 @@ internal static class TransactionsExtensions
|
|||||||
var transactionsGroup = app.MapGroup(RouteGroupPrefix)
|
var transactionsGroup = app.MapGroup(RouteGroupPrefix)
|
||||||
.RequireAuthorization();
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
transactionsGroup.MapGetEndpoint();
|
||||||
|
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
|
||||||
|
|
||||||
if (app.Environment.IsProduction() is false)
|
if (app.Environment.IsProduction() is false)
|
||||||
{
|
{
|
||||||
transactionsGroup.MapFireWebhookEndpoint();
|
transactionsGroup.MapFireWebhookEndpoint();
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
|
|
||||||
|
|
||||||
return transactionsGroup;
|
return transactionsGroup;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,19 +3,11 @@ global using System.Security.Claims;
|
|||||||
global using System.Text.Json.Serialization;
|
global using System.Text.Json.Serialization;
|
||||||
|
|
||||||
global using FiscalOS.API.Accounts;
|
global using FiscalOS.API.Accounts;
|
||||||
global using FiscalOS.API.Accounts.Add;
|
|
||||||
global using FiscalOS.API.Auth;
|
global using FiscalOS.API.Auth;
|
||||||
global using FiscalOS.API.Auth.Login;
|
global using FiscalOS.API.Common;
|
||||||
global using FiscalOS.API.Auth.Refresh;
|
|
||||||
global using FiscalOS.API.Http;
|
global using FiscalOS.API.Http;
|
||||||
global using FiscalOS.API.Institutions;
|
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;
|
||||||
global using FiscalOS.API.Transactions.FireWebhook;
|
|
||||||
global using FiscalOS.API.Transactions.Webhook;
|
|
||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
global using FiscalOS.Core.Identity;
|
global using FiscalOS.Core.Identity;
|
||||||
global using FiscalOS.Core.Queuing;
|
global using FiscalOS.Core.Queuing;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
|
|||||||
import { useAuthService } from '@/composables/useAuthService';
|
import { useAuthService } from '@/composables/useAuthService';
|
||||||
import { useUserStore } from '@/stores/userStore';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { RouterLink, useRouter } from 'vue-router';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -32,10 +32,28 @@ async function handleLogout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside :class="asideClasses">
|
<aside :class="asideClasses">
|
||||||
<button @click="handleToggleButtonClick" type="button" class="toggle-button">
|
<button
|
||||||
|
@click="handleToggleButtonClick"
|
||||||
|
type="button"
|
||||||
|
class="toggle-button"
|
||||||
|
>
|
||||||
<LeftArrowIcon />
|
<LeftArrowIcon />
|
||||||
</button>
|
</button>
|
||||||
<button class="logout-button" type="button" @click="handleLogout">
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<RouterLink to="/">Accounts</RouterLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<RouterLink to="/transactions">Transactions</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<button
|
||||||
|
class="logout-button"
|
||||||
|
type="button"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Transaction } from '@/services/transactionService';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
transaction: Transaction
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,21 +1,29 @@
|
|||||||
import './assets/css/main.css';
|
import './assets/css/main.css';
|
||||||
|
|
||||||
import { createApp } from "vue";
|
import { createApp } from 'vue';
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from 'pinia';
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from './App.vue';
|
||||||
import router from "./router";
|
import router from './router';
|
||||||
import { ClientFactory, ClientFactoryKey } from "./services/client";
|
import { ClientFactory, ClientFactoryKey } from './services/client';
|
||||||
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
|
import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService';
|
||||||
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
|
import {
|
||||||
|
InstituionServiceFactoryKey,
|
||||||
|
InstitutionServiceFactory,
|
||||||
|
} from './services/institutionService';
|
||||||
|
import {
|
||||||
|
TransactionServiceFactory,
|
||||||
|
TransactionServiceFactoryKey,
|
||||||
|
} from './services/transactionService';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
app.provide(ClientFactoryKey, new ClientFactory());
|
app.provide(ClientFactoryKey, new ClientFactory());
|
||||||
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
||||||
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
|
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
|
||||||
|
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
|
||||||
|
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
app.mount("#app");
|
app.mount('#app');
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ const router = createRouter({
|
|||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('../views/HomeView.vue'),
|
component: () => import('../views/HomeView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/transactions',
|
||||||
|
component: () => import('../views/TransactionsView.vue'),
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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<ITransactionServiceFactory>;
|
||||||
|
|
||||||
|
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<Result<Page<Transaction>, 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<Transaction>);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return Err([new Error('Failed to retrieve transactions.')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Page<T> = {
|
||||||
|
pageNumber: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
items: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Transaction = {
|
||||||
|
id: string;
|
||||||
|
merchantName: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
@@ -147,7 +147,7 @@ async function handleAccountAddClick() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="institutionsData.status === 'loaded'" class="institutions-container">
|
<div v-if="institutionsData.status === 'loaded'" class="institutions-container">
|
||||||
<div v-for="institution in institutionsData.data" v-bind:key="institution.id">
|
<div v-for="institution in institutionsData.data" :key="institution.id">
|
||||||
<div class="institution-card">
|
<div class="institution-card">
|
||||||
<div>
|
<div>
|
||||||
<div>{{ institution.name }}</div>
|
<div>{{ institution.name }}</div>
|
||||||
@@ -160,7 +160,7 @@ async function handleAccountAddClick() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id">
|
<div v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id">
|
||||||
<select v-model="targetAccountOfAdd">
|
<select v-model="targetAccountOfAdd">
|
||||||
<option v-for="account in availableAccounts" v-bind:key="account.providerId" :value="account">
|
<option v-for="account in availableAccounts" :key="account.providerId" :value="account">
|
||||||
{{ account.providerName }}
|
{{ account.providerName }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -168,7 +168,7 @@ async function handleAccountAddClick() {
|
|||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="accounts in institution.accounts" v-bind:key="accounts.id">
|
<div v-for="accounts in institution.accounts" :key="accounts.id">
|
||||||
<div>{{ accounts.name }}</div>
|
<div>{{ accounts.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTransactionService } from '@/composables/useTransactionService';
|
||||||
|
import type { Transaction } from '@/services/transactionService';
|
||||||
|
import { useUserStore } from '@/stores/userStore';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const transactionService = useTransactionService(userStore);
|
||||||
|
|
||||||
|
const transactions = ref<Transaction[]>([]);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const result = await transactionService.get();
|
||||||
|
|
||||||
|
if (result.err) {
|
||||||
|
alert(result.val.map(e => e.message).join('\n'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions.value = result.val.items;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Transactions</h1>
|
||||||
|
<ul>
|
||||||
|
<li v-for="transaction in transactions" :key="transaction.id">
|
||||||
|
<div class="card">
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<div>{{ transaction.merchantName }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{{ transaction.amount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bottom">
|
||||||
|
<p>{{ transaction.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
|
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
|
||||||
<PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" />
|
<PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" />
|
||||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
|
||||||
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
|
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" />
|
<ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" />
|
||||||
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
|
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
|
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
|
||||||
|
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
namespace FiscalOS.API.Tests.Infra;
|
namespace FiscalOS.API.Tests.Infra;
|
||||||
|
|
||||||
internal sealed class HttpRequestBuilder
|
internal sealed class HttpRequestBuilder
|
||||||
@@ -8,6 +10,7 @@ internal sealed class HttpRequestBuilder
|
|||||||
private string? _bearerToken;
|
private string? _bearerToken;
|
||||||
private readonly Dictionary<string, string> _cookies = [];
|
private readonly Dictionary<string, string> _cookies = [];
|
||||||
private readonly Dictionary<string, string> _headers = [];
|
private readonly Dictionary<string, string> _headers = [];
|
||||||
|
private readonly Dictionary<string, string?> _queryParameters = [];
|
||||||
|
|
||||||
private HttpRequestBuilder()
|
private HttpRequestBuilder()
|
||||||
{
|
{
|
||||||
@@ -68,6 +71,12 @@ internal sealed class HttpRequestBuilder
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public HttpRequestBuilder WithQueryParameter(string name, string value)
|
||||||
|
{
|
||||||
|
_queryParameters[name] = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public HttpRequestBuilder Post(Uri uri)
|
public HttpRequestBuilder Post(Uri uri)
|
||||||
{
|
{
|
||||||
_method = HttpMethod.Post;
|
_method = HttpMethod.Post;
|
||||||
@@ -103,7 +112,11 @@ internal sealed class HttpRequestBuilder
|
|||||||
throw new InvalidOperationException("URI must be set before building the request.");
|
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)
|
if (_body is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<string, string[]>()
|
||||||
|
{
|
||||||
|
["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<string, string[]>()
|
||||||
|
{
|
||||||
|
["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<IPasswordHasher>();
|
||||||
|
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||||
|
|
||||||
|
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<PagedResponse<TransactionDto>>(HttpStatusCode.OK))
|
||||||
|
.Which
|
||||||
|
.Items
|
||||||
|
.Should()
|
||||||
|
.BeEquivalentTo(
|
||||||
|
user.Transactions.Select(TransactionDto.From)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
global using System.Globalization;
|
||||||
global using System.IdentityModel.Tokens.Jwt;
|
global using System.IdentityModel.Tokens.Jwt;
|
||||||
global using System.Net;
|
global using System.Net;
|
||||||
global using System.Net.Http.Headers;
|
global using System.Net.Http.Headers;
|
||||||
@@ -10,6 +11,7 @@ global using System.Text.Json;
|
|||||||
global using AwesomeAssertions.Execution;
|
global using AwesomeAssertions.Execution;
|
||||||
global using AwesomeAssertions.Primitives;
|
global using AwesomeAssertions.Primitives;
|
||||||
|
|
||||||
|
global using FiscalOS.API.Common;
|
||||||
global using FiscalOS.API.Tests.Assertions;
|
global using FiscalOS.API.Tests.Assertions;
|
||||||
global using FiscalOS.API.Tests.Infra;
|
global using FiscalOS.API.Tests.Infra;
|
||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
@@ -18,6 +20,7 @@ global using FiscalOS.Core.Security;
|
|||||||
global using FiscalOS.Infra.Accounts.Plaid;
|
global using FiscalOS.Infra.Accounts.Plaid;
|
||||||
global using FiscalOS.Infra.Authentication;
|
global using FiscalOS.Infra.Authentication;
|
||||||
global using FiscalOS.Infra.Data;
|
global using FiscalOS.Infra.Data;
|
||||||
|
global using FiscalOS.Tests.Common.Data;
|
||||||
|
|
||||||
global using Going.Plaid;
|
global using Going.Plaid;
|
||||||
global using Going.Plaid.Entity;
|
global using Going.Plaid.Entity;
|
||||||
|
|||||||
Reference in New Issue
Block a user