Compare commits

...
Author SHA1 Message Date
Stevan Freeborn 8b779c1841 feat(infra): implement delete functionality for transactions with UI integration 2026-03-12 17:44:48 -05:00
Stevan Freeborn e47e08b9f0 feat(infra): add delete endpoint for transactions with corresponding integration tests 2026-03-12 17:29:41 -05:00
Stevan Freeborn f0c63c7ded fix(infra): simplify transaction processing validation by removing removed transactions check 2026-03-12 17:03:07 -05:00
Stevan Freeborn 66f4cd2959 fix(infra): add logging to PlaidTransactionProcessor for transaction sync processing 2026-03-12 16:42:44 -05:00
Stevan Freeborn 6a21e92aa6 fix(infra): include Metadata in transaction queries for added, modified, and removed transactions 2026-03-12 16:42:39 -05:00
Stevan Freeborn 88e8cfbf40 fix(infra): update PlaidClientOptions to retrieve environment variable for production check 2026-03-11 20:09:41 -05:00
Stevan Freeborn 559908a150 fix(infra): update transaction amount handling to use CanonicalAmount and ensure negative values in transaction tests 2026-03-11 20:01:23 -05:00
Stevan Freeborn e3acb2166d fix(api): update PlaidClientOptions to set environment to Production 2026-03-11 19:46:47 -05:00
Stevan Freeborn e6f70f4ab4 fix(api): update PlaidClientOptions to set environment to Development in production 2026-03-11 19:41:49 -05:00
Stevan Freeborn 6c8ee3e203 feat(api): enhance PlaidException for detailed error logging and improve GlobalExceptionHandler for specific Plaid errors 2026-03-11 19:28:15 -05:00
Stevan Freeborn cb7ed41296 fix(api): ensure exception handling middleware is correctly registered 2026-03-11 19:12:19 -05:00
Stevan Freeborn a168d318b2 chore: add SEQ_SERVER_URL and SEQ_API_KEY_HEADER to deployment environment variables 2026-03-11 18:58:39 -05:00
Stevan Freeborn 839d17de0d fix(web): remove disabled state styles for add institution button 2026-03-11 18:53:11 -05:00
Stevan Freeborn 9e1d04da80 feat(web): add styles for add account and institution buttons with disabled state 2026-03-11 18:52:33 -05:00
Stevan Freeborn a721c60ad9 feat(api,infra): implement GlobalExceptionHandler and enhance PlaidException for detailed error handling 2026-03-11 18:52:23 -05:00
Stevan Freeborn 64c64e8d95 feat(servicedefaults): add SeqOptions configuration and integrate with OpenTelemetry logging and tracing 2026-03-11 18:52:05 -05:00
Stevan Freeborn e30f19e939 fix(project): set appsettings.json as optional in Program.cs 2026-03-11 13:12:26 -05:00
Stevan Freeborn d33b1d28dd fix(editorconfig): disable CA1873 diagnostic severity 2026-03-11 12:55:11 -05:00
Stevan Freeborn 36e2f70da2 refactor(docker): remove unnecessary global.json copy from Dockerfile.api 2026-03-11 12:51:53 -05:00
Stevan Freeborn 9572097d38 refactor(docker): streamline Dockerfile by removing redundant .editorconfig copies and simplifying restore commands 2026-03-11 12:49:16 -05:00
Stevan Freeborn ff4d10edcc feat(docker): add .editorconfig files for API and AdminCLI projects 2026-03-11 12:41:54 -05:00
Stevan Freeborn 379f975e7f Merge pull request #17 from StevanFreeborn/stevanfreeborn/feat/display-transactions-in-web
feat: display transactions on the web
2026-03-11 12:32:41 -05:00
Stevan Freeborn 6bf7499981 feat(docker): update web service port mapping and remove api service port configuration 2026-03-11 12:31:24 -05:00
Stevan Freeborn cd69cea2ac refactor: remove Playwright dependencies and configuration, update test cases, and clean up code formatting
- Removed Playwright test dependencies from package.json and deleted playwright.config.ts.
- Updated LoginForm component and its tests for improved readability and consistency.
- Cleaned up App.vue and PublicLayout.vue by adjusting import formatting.
- Fixed missing newlines in CSS and other files for better formatting.
- Updated userStore to ensure proper object syntax.
- Refactored LoginView to enhance code clarity and maintainability.
2026-03-11 12:13:53 -05:00
Stevan Freeborn e3a75ccbc6 feat(api,web): update account and institution handling to use provider identifiers and enhance UI components for account addition 2026-03-11 10:51:28 -05:00
Stevan Freeborn 5eda5fbb76 feat(web): try to make pretty 2026-03-11 05:47:34 -05:00
Stevan Freeborn 4d1c01776f feat(web): enhance transaction display and account management features 2026-03-10 17:46:21 -05:00
Stevan Freeborn 3408f6ea4f feat(api,web): retrieve and display transactions 2026-03-10 07:42:51 -05:00
Stevan Freeborn e2d2058960 chore: update preferences for lambas 2026-03-10 07:42:15 -05:00
Stevan Freeborn 02147cffdb chore: add new test project to solution 2026-03-10 07:42:03 -05:00
Stevan Freeborn e13c83013b refactor(infra.tests): moved builders to common project 2026-03-10 07:41:43 -05:00
Stevan Freeborn c713a5448d tests(tests.common): add class library for sharing code amongst test projects 2026-03-10 07:41:23 -05:00
Stevan Freeborn 091fa6f01c feat: allow users to logout 2026-03-09 05:58:04 -05:00
Stevan Freeborn c8505081a5 chore: enable aspire to run with made mode 2026-03-09 05:58:04 -05:00
104 changed files with 2515 additions and 826 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
{
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj"
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj",
"defaultWatchEnabled": "true"
}
+11
View File
@@ -0,0 +1,11 @@
.git/
.vscode/
.aspire/
Coverage/
tests/
docker/data/
docker/keys/
**/bin/
**/obj/
**/node_modules/
**/.env
+2 -1
View File
@@ -84,6 +84,7 @@ dotnet_diagnostic.IDE0058.severity = none
dotnet_diagnostic.IDE0100.severity = none
dotnet_diagnostic.CA1515.severity = none
dotnet_diagnostic.CA1848.severity = none
dotnet_diagnostic.CA1873.severity = none
# var preferences
csharp_style_var_elsewhere = true:suggestion
@@ -94,7 +95,7 @@ csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
csharp_style_expression_bodied_lambdas = false:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
+100
View File
@@ -0,0 +1,100 @@
name: Deploy
on:
push:
branches:
- main
workflow_dispatch:
env:
WEB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fiscalos-web
API_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fiscalos-api
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.web
push: true
tags: |
${{ env.WEB_IMAGE }}:latest
${{ env.WEB_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.api
push: true
tags: |
${{ env.API_IMAGE }}:latest
${{ env.API_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Copy compose file to server
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: docker/docker-compose.prod.yml
target: ${{ secrets.DEPLOY_PATH }}
strip_components: 1
- name: Deploy on server
uses: appleboy/ssh-action@v1
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
IMAGE_TAG: ${{ github.sha }}
JWT_AUDIENCE: ${{ secrets.JWT_AUDIENCE }}
JWT_ISSUER: ${{ secrets.JWT_ISSUER }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_EXPIRY_IN_MINUTES: ${{ secrets.JWT_EXPIRY_IN_MINUTES }}
FILE_KEY_RING_PRIMARY_KEY_ID: ${{ secrets.FILE_KEY_RING_PRIMARY_KEY_ID }}
PLAID_CLIENT_ID: ${{ secrets.PLAID_CLIENT_ID }}
PLAID_SECRET: ${{ secrets.PLAID_SECRET }}
PLAID_WEBHOOK: ${{ secrets.PLAID_WEBHOOK }}
SEQ_SERVER_URL: ${{ secrets.SEQ_SERVER_URL }}
SEQ_API_KEY_HEADER: ${{ secrets.SEQ_API_KEY_HEADER }}
SEQ_API_KEY: ${{ secrets.SEQ_API_KEY }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: DOCKERHUB_USERNAME,IMAGE_TAG,JWT_AUDIENCE,JWT_ISSUER,JWT_SECRET,JWT_EXPIRY_IN_MINUTES,FILE_KEY_RING_PRIMARY_KEY_ID,PLAID_CLIENT_ID,PLAID_SECRET,PLAID_WEBHOOK,SEQ_SERVER_URL,SEQ_API_KEY_HEADER,SEQ_API_KEY
script: |
cd ${{ secrets.DEPLOY_PATH }}
cat > .env << EOF
DOCKERHUB_USERNAME=${DOCKERHUB_USERNAME}
IMAGE_TAG=${IMAGE_TAG}
JWT_AUDIENCE=${JWT_AUDIENCE}
JWT_ISSUER=${JWT_ISSUER}
JWT_SECRET=${JWT_SECRET}
JWT_EXPIRY_IN_MINUTES=${JWT_EXPIRY_IN_MINUTES}
FILE_KEY_RING_PRIMARY_KEY_ID=${FILE_KEY_RING_PRIMARY_KEY_ID}
PLAID_CLIENT_ID=${PLAID_CLIENT_ID}
PLAID_SECRET=${PLAID_SECRET}
PLAID_WEBHOOK=${PLAID_WEBHOOK}
SEQ_SERVER_URL=${SEQ_SERVER_URL}
SEQ_API_KEY_HEADER=${SEQ_API_KEY_HEADER}
SEQ_API_KEY=${SEQ_API_KEY}
EOF
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f
+4
View File
@@ -10,6 +10,10 @@ coverage/
appsettings*.json
!appsettings.Example.json
# docker bind-mount data
docker/data/
docker/keys/
# dotenv files
.env
+1 -1
View File
@@ -11,7 +11,7 @@
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["Encryptor"],
"cSpell.words": ["DOCKERHUB", "Dtos", "Encryptor", "fiscalos", "Validatable"],
"python-envs.defaultEnvManager": "ms-python.python:system",
"search.exclude": {
"**/Migrations": true
+1
View File
@@ -15,6 +15,7 @@
<Project Path="tests/FiscalOS.AppHost.Tests/FiscalOS.AppHost.Tests.csproj" />
<Project Path="tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj" />
<Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" />
<Project Path="tests/FiscalOS.Tests.Common/FiscalOS.Tests.Common.csproj" />
</Folder>
<Project Path="src/FiscalOS.AppHost/FiscalOS.AppHost.csproj" />
<Project Path="src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj" />
+39
View File
@@ -0,0 +1,39 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
COPY Directory.Build.props .
COPY .editorconfig .
COPY src/.editorconfig src/
COPY src/Directory.Build.props src/
COPY src/Directory.Packages.props src/
COPY src/FiscalOS.Core/FiscalOS.Core.csproj src/FiscalOS.Core/
COPY src/FiscalOS.Infra/FiscalOS.Infra.csproj src/FiscalOS.Infra/
COPY src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj src/FiscalOS.ServiceDefaults/
COPY src/FiscalOS.API/FiscalOS.API.csproj src/FiscalOS.API/
COPY src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj src/FiscalOS.AdminCLI/
RUN dotnet restore src/FiscalOS.API/FiscalOS.API.csproj
RUN dotnet restore src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj
COPY src/ src/
RUN dotnet publish src/FiscalOS.API/FiscalOS.API.csproj \
-c Release \
-o /publish/api \
--no-restore
RUN dotnet publish src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj \
-c Release \
-o /publish/admincli \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /publish/api .
COPY --from=build /publish/admincli /usr/local/lib/admincli
ENV PATH="/usr/local/lib/admincli:${PATH}"
EXPOSE 8080
ENTRYPOINT ["dotnet", "FiscalOS.API.dll"]
+13
View File
@@ -0,0 +1,13 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY src/FiscalOS.Web/package.json src/FiscalOS.Web/package-lock.json ./
RUN npm ci
COPY src/FiscalOS.Web/ .
RUN npm run build
FROM nginx:alpine AS final
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+30
View File
@@ -0,0 +1,30 @@
services:
web:
image: ${DOCKERHUB_USERNAME}/fiscalos-web:${IMAGE_TAG:-latest}
ports:
- "8081:80"
depends_on:
- api
restart: unless-stopped
api:
image: ${DOCKERHUB_USERNAME}/fiscalos-api:${IMAGE_TAG:-latest}
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AppDbContextOptions__DatabaseFilePath=/data/fiscalos.db
- JwtOptions__Audience=${JWT_AUDIENCE}
- JwtOptions__Issuer=${JWT_ISSUER}
- JwtOptions__Secret=${JWT_SECRET}
- JwtOptions__ExpiryInMinutes=${JWT_EXPIRY_IN_MINUTES}
- FileKeyRingOptions__KeysDirectoryPath=/keys
- FileKeyRingOptions__PrimaryKeyId=${FILE_KEY_RING_PRIMARY_KEY_ID}
- PlaidClientOptions__ClientId=${PLAID_CLIENT_ID}
- PlaidClientOptions__Secret=${PLAID_SECRET}
- PlaidClientOptions__Webhook=${PLAID_WEBHOOK}
- SeqOptions__ServerUrl=${SEQ_SERVER_URL}
- SeqOptions__ApiKeyHeader=${SEQ_API_KEY_HEADER}
- SeqOptions__ApiKey=${SEQ_API_KEY}
volumes:
- ./data:/data
- ./keys:/keys
restart: unless-stopped
+36
View File
@@ -0,0 +1,36 @@
services:
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
ports:
- "80:80"
depends_on:
- api
restart: unless-stopped
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AppDbContextOptions__DatabaseFilePath=/data/fiscalos.db
- JwtOptions__Audience=${JWT_AUDIENCE}
- JwtOptions__Issuer=${JWT_ISSUER}
- JwtOptions__Secret=${JWT_SECRET}
- JwtOptions__ExpiryInMinutes=${JWT_EXPIRY_IN_MINUTES}
- FileKeyRingOptions__KeysDirectoryPath=/keys
- FileKeyRingOptions__PrimaryKeyId=${FILE_KEY_RING_PRIMARY_KEY_ID}
- PlaidClientOptions__ClientId=${PLAID_CLIENT_ID}
- PlaidClientOptions__Secret=${PLAID_SECRET}
- PlaidClientOptions__Webhook=${PLAID_WEBHOOK}
- SeqOptions__ServerUrl=${SEQ_SERVER_URL}
- SeqOptions__ApiKeyHeader=${SEQ_API_KEY_HEADER}
- SeqOptions__ApiKey=${SEQ_API_KEY}
volumes:
- ./data:/data
- ./keys:/keys
restart: unless-stopped
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+4
View File
@@ -1,4 +1,8 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestPatch"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
@@ -1,3 +1,5 @@
using FiscalOS.API.Accounts.Add;
namespace FiscalOS.API.Accounts;
internal static class AccountsExtensions
+6 -6
View File
@@ -24,9 +24,9 @@ internal static class Endpoint
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId))
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.ProviderInstitutionId))
.ThenInclude(i => i.Metadata)
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.ProviderAccountId))
.ThenInclude(a => a.Metadata)
.AsSplitQuery()
.SingleOrDefaultAsync(u => u.Id == userId, ct);
@@ -40,7 +40,7 @@ internal static class Endpoint
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
});
}
@@ -50,7 +50,7 @@ internal static class Endpoint
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. The connected institution has no plaid metadata"],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
});
}
@@ -60,8 +60,8 @@ internal static class Endpoint
}
var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
var account = Account.From(request.PlaidAccountName, accountMetadata);
var accountMetadata = PlaidAccountMetadata.From(request.ProviderAccountId, request.ProviderAccountName);
var account = Account.From(request.ProviderAccountName, accountMetadata);
user.AddAccount(account);
user.Institutions.First().AddAccount(account);
+9 -9
View File
@@ -2,27 +2,27 @@ namespace FiscalOS.API.Accounts.Add;
public record Request : IValidatableObject
{
public string PlaidInstitutionId { get; init; } = string.Empty;
public string PlaidAccountId { get; init; } = string.Empty;
public string PlaidAccountName { get; init; } = string.Empty;
public string ProviderInstitutionId { get; init; } = string.Empty;
public string ProviderAccountId { get; init; } = string.Empty;
public string ProviderAccountName { get; init; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
if (string.IsNullOrWhiteSpace(ProviderInstitutionId))
{
var fieldName = nameof(PlaidInstitutionId);
var fieldName = nameof(ProviderInstitutionId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidAccountId))
if (string.IsNullOrWhiteSpace(ProviderAccountId))
{
var fieldName = nameof(PlaidAccountId);
var fieldName = nameof(ProviderAccountId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidAccountName))
if (string.IsNullOrWhiteSpace(ProviderAccountName))
{
var fieldName = nameof(PlaidAccountName);
var fieldName = nameof(ProviderAccountName);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
}
+5
View File
@@ -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
@@ -9,6 +13,7 @@ internal static class AuthExtensions
var authGroup = app.MapGroup(RouteGroupPrefix);
authGroup.MapLoginEndpoint();
authGroup.MapLogoutEndpoint();
authGroup.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens);
+40
View File
@@ -0,0 +1,40 @@
namespace FiscalOS.API.Auth.Logout;
internal static class Endpoint
{
private const string Route = "/logout";
public static RouteHandlerBuilder MapLogoutEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var refreshToken = httpContext.GetRefreshTokenFromCookie();
var user = await appDbContext.Users
.Include(u => u.RefreshTokens
.Where(r => r.Token == refreshToken)
)
.Where(u => u.Id == userId)
.SingleOrDefaultAsync(ct);
if (user is null || user.RefreshTokens.Any() is false)
{
return Results.Unauthorized();
}
user.RefreshTokens.First().Revoke();
await appDbContext.SaveChangesAsync(ct);
httpContext.ExpireRefreshTokenCookie();
return Results.NoContent();
}
}
@@ -0,0 +1,48 @@
using FiscalOS.Infra.Common;
using Microsoft.AspNetCore.Diagnostics;
internal sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger
) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken
)
{
switch (exception)
{
case PlaidException plaidException:
logger.LogError(
plaidException,
"Unhandled Plaid exception. PlaidRequestId: {PlaidRequestId}, PlaidStatusCode: {PlaidStatusCode}, PlaidErrorCode: {PlaidErrorCode}, PlaidErrorType: {PlaidErrorType}, PlaidErrorMessage: {PlaidErrorMessage}",
plaidException.RequestId,
plaidException.StatusCode,
plaidException.ErrorCode,
plaidException.ErrorType,
plaidException.ErrorMessage
);
break;
default:
logger.LogError(exception, "An unhandled exception occurred");
break;
}
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Type = exception.GetType().Name,
Title = "Uh oh something has gone wrong.",
Detail = exception.Message
}
});
}
}
+36
View File
@@ -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;
}
}
+34
View File
@@ -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
};
}
}
@@ -25,6 +25,21 @@ internal static class HttpContextExtensions
);
}
public static void ExpireRefreshTokenCookie(this HttpContext context)
{
context.Response.Cookies.Append(
RefreshTokenCookieName,
string.Empty,
new CookieOptions
{
HttpOnly = true,
Expires = DateTimeOffset.MinValue,
SameSite = SameSiteMode.Strict,
Secure = true
}
);
}
public static string GetRefreshTokenFromCookie(this HttpContext context)
{
return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty;
@@ -19,6 +19,7 @@ internal static class Endpoint
var user = await appDbContext.Users
.Include(u => u.Institutions)
.ThenInclude(i => i.Accounts)
.ThenInclude(a => a.Metadata)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user is null)
@@ -24,6 +24,7 @@ internal sealed record Response
internal sealed record AccountDto
{
public string Id { get; init; } = string.Empty;
public string ProviderId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
[JsonConstructor]
@@ -36,6 +37,9 @@ internal sealed record AccountDto
return new()
{
Id = account.Id.ToString(),
ProviderId = account.Metadata is PlaidAccountMetadata plaidMetadata
? plaidMetadata.PlaidId
: string.Empty,
Name = account.Name,
};
}
@@ -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
+2 -1
View File
@@ -3,6 +3,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddValidation();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(static options =>
@@ -39,9 +40,9 @@ app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseStatusCodePages();
app.UseExceptionHandler();
app.MapDefaultEndpoints();
app.MapAuthEndpoints();
app.MapAccountsEndpoints();
app.MapInstitutionsEndpoints();
@@ -0,0 +1,34 @@
namespace FiscalOS.API.Transactions.DeleteById;
internal static class Endpoint
{
private const string Route = "/{id}";
public static RouteHandlerBuilder MapDeleteEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapDelete(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromRoute] Guid id,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var existingTransaction = await appDbContext.Transactions
.SingleOrDefaultAsync(t => t.UserId == userId && t.Id == id, ct);
if (existingTransaction is null)
{
return Results.NotFound();
}
appDbContext.Transactions.Remove(existingTransaction);
await appDbContext.SaveChangesAsync(ct);
return Results.NoContent();
}
}
@@ -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,8 @@
using FiscalOS.API.Transactions.DeleteById;
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 +14,15 @@ internal static class TransactionsExtensions
var transactionsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization();
transactionsGroup.MapGetEndpoint();
transactionsGroup.MapDeleteEndpoint();
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
if (app.Environment.IsProduction() is false)
{
transactionsGroup.MapFireWebhookEndpoint();
}
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
return transactionsGroup;
}
}
+1 -9
View File
@@ -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;
@@ -23,5 +23,10 @@
"ClientId": "ClientId",
"Secret": "Secret",
"Webhook": "Webhook"
},
"SeqOptions": {
"ServerUrl": "ServerUrl",
"ApiKeyHeader": "ApiKeyHeader",
"ApiKey": "ApiKey"
}
}
@@ -2,6 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>fiscaloscli</AssemblyName>
</PropertyGroup>
<ItemGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
await Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration(
static c => c.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json")
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
)
.ConfigureLogging(static c => c.ClearProviders())
@@ -31,7 +31,7 @@ internal sealed class PlaidAccountService : IPlaidAccountService
var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName;
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var ltr = await _client.LinkTokenCreateAsync(new()
var linkTokenResponse = await _client.LinkTokenCreateAsync(new()
{
ClientName = $"{assemblyName}_{environmentName}",
Products = [Products.Transactions],
@@ -44,56 +44,76 @@ internal sealed class PlaidAccountService : IPlaidAccountService
Webhook = _options.Value.Webhook,
}).ConfigureAwait(false);
if (ltr.IsSuccessStatusCode is false)
if (linkTokenResponse.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to create link token");
throw new PlaidException(
"Unable to create link token",
linkTokenResponse.Error,
linkTokenResponse.RequestId,
(int?)linkTokenResponse.StatusCode
);
}
return ltr.LinkToken;
return linkTokenResponse.LinkToken;
}
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
{
var ptr = await _client.ItemPublicTokenExchangeAsync(new()
var publicTokenResponse = await _client.ItemPublicTokenExchangeAsync(new()
{
PublicToken = publicToken,
}).ConfigureAwait(false);
if (ptr.IsSuccessStatusCode is false)
if (publicTokenResponse.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to exchange public token for access token");
throw new PlaidException(
"Unable to exchange public token for access token",
publicTokenResponse.Error,
publicTokenResponse.RequestId,
(int?)publicTokenResponse.StatusCode
);
}
return (ptr.ItemId, ptr.AccessToken);
return (publicTokenResponse.ItemId, publicTokenResponse.AccessToken);
}
public async Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken)
{
var ar = await _client.AccountsGetAsync(new()
var accountResponse = await _client.AccountsGetAsync(new()
{
AccessToken = accessToken,
}).ConfigureAwait(false);
if (ar.IsSuccessStatusCode is false)
if (accountResponse.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to retrieve accounts");
throw new PlaidException(
"Unable to retrieve accounts",
accountResponse.Error,
accountResponse.RequestId,
(int?)accountResponse.StatusCode
);
}
return [.. ar.Accounts];
return [.. accountResponse.Accounts];
}
public async Task<ItemWithConsentFields> GetItemAsync(string accessToken)
{
var ar = await _client.ItemGetAsync(new()
var itemResponse = await _client.ItemGetAsync(new()
{
AccessToken = accessToken,
}).ConfigureAwait(false);
if (ar.IsSuccessStatusCode is false)
if (itemResponse.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to retrieve item");
throw new PlaidException(
"Unable to retrieve item",
itemResponse.Error,
itemResponse.RequestId,
(int?)itemResponse.StatusCode
);
}
return ar.Item;
return itemResponse.Item;
}
}
@@ -2,6 +2,13 @@ namespace FiscalOS.Infra.Common;
public class PlaidException : Exception
{
public PlaidError? Error { get; }
public string? RequestId { get; }
public int? StatusCode { get; }
public string? ErrorCode => Error?.ErrorCode;
public string? ErrorType => Error?.ErrorType;
public string? ErrorMessage => Error?.ErrorMessage;
public PlaidException()
{
}
@@ -13,4 +20,34 @@ public class PlaidException : Exception
public PlaidException(string message, Exception innerException) : base(message, innerException)
{
}
public PlaidException(
string message,
PlaidError? error = null,
string? plaidRequestId = null,
int? statusCode = null,
Exception? innerException = null
) : base(message, innerException)
{
Error = error;
RequestId = plaidRequestId;
StatusCode = statusCode;
if (error is not null)
{
Data[nameof(ErrorCode)] = error.ErrorCode;
Data[nameof(ErrorType)] = error.ErrorType;
Data[nameof(ErrorMessage)] = error.ErrorMessage;
}
if (plaidRequestId is not null)
{
Data[nameof(RequestId)] = plaidRequestId;
}
if (statusCode is not null)
{
Data[nameof(StatusCode)] = statusCode;
}
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Hosting;
namespace FiscalOS.Infra.DependencyInjection;
public static class ServiceCollectionExtensions
@@ -31,6 +33,14 @@ public static class ServiceCollectionExtensions
var logger = sp.GetRequiredService<ILogger<PlaidClient>>();
var clientOptions = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
var options = clientOptions.Value.ToPlaidOptions();
var env = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (env is not null && env.Equals("Production", StringComparison.OrdinalIgnoreCase))
{
options.Value.Environment = Going.Plaid.Environment.Production;
}
return new PlaidClient(options, factory, logger);
});
services.AddSingleton<IPlaidAccountService>(PlaidAccountService.From);
@@ -46,6 +46,7 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl
{
var existingTransactionIds = added.Select(t => t.TransactionId);
var existingTransactions = await _appDbContext.Transactions
.Include(t => t.Metadata)
.Where(t => t.Metadata is PlaidTransactionMetadata && existingTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ToListAsync(ct)
.ConfigureAwait(false);
@@ -89,7 +90,7 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl
account.Id,
addedTransaction.Merchant,
addedTransaction.Description,
addedTransaction.Amount,
addedTransaction.CanonicalAmount,
addedTransaction.PostedDate,
transactionMetadata
);
@@ -7,6 +7,7 @@ public static class PlaidExtensions
{
public string Merchant => transaction.MerchantName ?? "Unknown merchant";
public string Description => transaction.OriginalDescription ?? "";
public decimal? CanonicalAmount => transaction.Amount is decimal amount ? -amount : null;
public DateTimeOffset PostedDate => transaction.Datetime ?? (
transaction.Date.HasValue
? new DateTimeOffset(
@@ -46,6 +46,7 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
{
var modifiedTransactionIds = modified.Select(t => t.TransactionId);
var existingModifiedTransactions = await _appDbContext.Transactions
.Include(t => t.Metadata)
.Where(t => t.Metadata is PlaidTransactionMetadata && modifiedTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ToListAsync(ct)
.ConfigureAwait(false);
@@ -84,7 +85,7 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
existing.AccountId,
plaidModifiedTransaction.Merchant,
plaidModifiedTransaction.Description,
plaidModifiedTransaction.Amount,
plaidModifiedTransaction.CanonicalAmount,
plaidModifiedTransaction.PostedDate,
plaidMetadata
);
@@ -43,6 +43,7 @@ internal sealed class PlaidRemovedTransactionHandler : IPlaidRemovedTransactionH
try
{
removedCount = await _appDbContext.Transactions
.Include(t => t.Metadata)
.Where(t => t.Metadata is PlaidTransactionMetadata && removedIdsList.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
@@ -16,25 +16,29 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
private readonly IPlaidAddedTransactionHandler _addedHandler;
private readonly IPlaidModifiedTransactionHandler _modifiedHandler;
private readonly IPlaidRemovedTransactionHandler _removedHandler;
private readonly ILogger<PlaidTransactionProcessor> _logger;
private PlaidTransactionProcessor(
IPlaidAddedTransactionHandler addedHandler,
IPlaidModifiedTransactionHandler modifiedHandler,
IPlaidRemovedTransactionHandler removedHandler
IPlaidRemovedTransactionHandler removedHandler,
ILogger<PlaidTransactionProcessor> logger
)
{
_addedHandler = addedHandler;
_modifiedHandler = modifiedHandler;
_removedHandler = removedHandler;
_logger = logger;
}
internal static PlaidTransactionProcessor From(
IPlaidAddedTransactionHandler addedHandler,
IPlaidModifiedTransactionHandler modifiedHandler,
IPlaidRemovedTransactionHandler removedHandler
IPlaidRemovedTransactionHandler removedHandler,
ILogger<PlaidTransactionProcessor> logger
)
{
return new(addedHandler, modifiedHandler, removedHandler);
return new(addedHandler, modifiedHandler, removedHandler, logger);
}
internal static PlaidTransactionProcessor From(IServiceProvider provider)
@@ -42,7 +46,8 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
return new(
provider.GetRequiredService<IPlaidAddedTransactionHandler>(),
provider.GetRequiredService<IPlaidModifiedTransactionHandler>(),
provider.GetRequiredService<IPlaidRemovedTransactionHandler>()
provider.GetRequiredService<IPlaidRemovedTransactionHandler>(),
provider.GetRequiredService<ILogger<PlaidTransactionProcessor>>()
);
}
@@ -69,8 +74,18 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
cancellationToken
).ConfigureAwait(false);
_logger.LogInformation(
"Processed Plaid transactions sync for account {AccountId}: {NumAdded} of {TotalAdded} added, {NumModified} of {TotalModified} modified, {NumRemoved} of {TotalRemoved} removed",
account.Id,
numAdded,
syncResponse.Added.Count,
numModified,
syncResponse.Modified.Count,
numRemoved,
syncResponse.Removed.Count
);
return numAdded == syncResponse.Added.Count &&
numModified == syncResponse.Modified.Count &&
numRemoved == syncResponse.Removed.Count;
numModified == syncResponse.Modified.Count;
}
}
@@ -0,0 +1,14 @@
namespace FiscalOS.ServiceDefaults;
internal sealed class SeqOptions
{
public string ServerUrl { get; init; } = string.Empty;
public string ApiKeyHeader { get; init; } = string.Empty;
public string ApiKey { get; init; } = string.Empty;
public bool IsEnabled => string.IsNullOrWhiteSpace(ServerUrl) is false &&
string.IsNullOrWhiteSpace(ApiKeyHeader) is false &&
string.IsNullOrWhiteSpace(ApiKey) is false;
public string LogEndpoint => $"{ServerUrl}/ingest/otlp/v1/logs";
public string TraceEndpoint => $"{ServerUrl}/ingest/otlp/v1/traces";
public string AuthHeader => $"{ApiKeyHeader}={ApiKey}";
}
@@ -1,3 +1,8 @@
using Microsoft.Extensions.Configuration;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
namespace FiscalOS.ServiceDefaults;
public static class ServiceDefaultsExtensions
@@ -28,6 +33,7 @@ public static class ServiceDefaultsExtensions
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
logging.ParseStateValues = true;
});
builder.Services.AddOpenTelemetry()
@@ -76,11 +82,38 @@ public static class ServiceDefaultsExtensions
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var seqOptions = new SeqOptions();
builder.Configuration.GetSection(nameof(SeqOptions)).Bind(seqOptions);
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
var otel = builder.Services.AddOpenTelemetry();
if (seqOptions.IsEnabled)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
otel
.WithLogging(lb =>
{
lb.AddOtlpExporter(o =>
{
o.Protocol = OtlpExportProtocol.HttpProtobuf;
o.Endpoint = new Uri(seqOptions.LogEndpoint);
o.Headers = seqOptions.AuthHeader;
});
})
.WithTracing(tb =>
{
tb.AddOtlpExporter(o =>
{
o.Protocol = OtlpExportProtocol.HttpProtobuf;
o.Endpoint = new Uri(seqOptions.TraceEndpoint);
o.Headers = seqOptions.AuthHeader;
});
});
}
else if (useOtlpExporter)
{
otel.UseOtlpExporter();
}
return builder;
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}
-6
View File
@@ -1,6 +0,0 @@
import { test, expect } from '@playwright/test'
test('visits the app root url', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toHaveText('You did it!')
})
+8 -10
View File
@@ -1,10 +1,9 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
import { globalIgnores } from 'eslint/config';
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
import pluginVue from 'eslint-plugin-vue';
import pluginVitest from '@vitest/eslint-plugin';
import pluginOxlint from 'eslint-plugin-oxlint';
import skipFormatting from 'eslint-config-prettier/flat';
export default defineConfigWithVueTs(
{
@@ -15,7 +14,6 @@ export default defineConfigWithVueTs(
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
{
@@ -23,5 +21,5 @@ export default defineConfigWithVueTs(
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
skipFormatting
);
+123 -218
View File
@@ -16,7 +16,6 @@
"vue-router": "^5.0.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13",
@@ -29,7 +28,6 @@
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
@@ -1206,9 +1204,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1280,9 +1278,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1851,22 +1849,6 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1882,9 +1864,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1896,9 +1878,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1910,9 +1892,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1924,9 +1906,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1938,9 +1920,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1952,9 +1934,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1966,9 +1948,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1980,9 +1962,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1994,9 +1976,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -2008,9 +1990,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -2022,9 +2004,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -2036,9 +2018,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -2050,9 +2032,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -2064,9 +2046,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -2078,9 +2060,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -2092,9 +2074,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -2106,9 +2088,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -2120,9 +2102,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -2134,9 +2116,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -2148,9 +2130,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -2162,9 +2144,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -2176,9 +2158,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -2190,9 +2172,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -2204,9 +2186,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -2218,9 +2200,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -3257,9 +3239,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3878,15 +3860,15 @@
"license": "MIT"
},
"node_modules/editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "9.0.1",
"minimatch": "^9.0.1",
"semver": "^7.5.3"
},
"bin": {
@@ -3896,22 +3878,6 @@
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/editorconfig/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -4168,35 +4134,6 @@
"jsonc-parser": "^3.3.1"
}
},
"node_modules/eslint-plugin-playwright": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.7.0.tgz",
"integrity": "sha512-kUgwDZL3knnuJF53WSf5xNnB1aLPnX8furoh0PSrmmFIfMfIMmY3sNd4gtZ2MUUnaIX1/A9ndYtD7bhV1dj+1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"globals": "^17.3.0"
},
"engines": {
"node": ">=16.9.0"
},
"peerDependencies": {
"eslint": ">=8.40.0"
}
},
"node_modules/eslint-plugin-playwright/node_modules/globals": {
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-vue": {
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz",
@@ -4297,9 +4234,9 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -5391,13 +5328,13 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -5929,38 +5866,6 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -6155,9 +6060,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6171,31 +6076,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
-3
View File
@@ -8,7 +8,6 @@
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
@@ -25,7 +24,6 @@
"vue-router": "^5.0.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13",
@@ -38,7 +36,6 @@
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
-45
View File
@@ -1,45 +0,0 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
actionTimeout: 0,
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
trace: 'on-first-retry',
headless: !!process.env.CI,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
],
webServer: {
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { RouterView } from 'vue-router';
</script>
<template>
@@ -68,7 +68,8 @@ describe('LoginForm', () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
props: {
onValidSubmit: () => new Promise(resolve => {
onValidSubmit: () =>
new Promise(resolve => {
resolveSubmit = resolve;
}),
},
@@ -96,7 +97,8 @@ describe('LoginForm', () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
props: {
onValidSubmit: () => new Promise(resolve => {
onValidSubmit: () =>
new Promise(resolve => {
wasCalled = true;
resolve();
}),
+18
View File
@@ -24,6 +24,24 @@ input {
color: inherit;
}
select {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
}
option {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
}
option :hover {
background-color: var(--bg-element);
}
a {
text-decoration: none;
color: inherit;
@@ -0,0 +1,87 @@
<script setup lang="ts">
import CircleSpinner from '@/components/CircleSpinner.vue';
import type { AvailableAccount } from '@/services/institutionService';
import { ref, watchEffect } from 'vue';
const props = defineProps<{
availableAccounts: AvailableAccount[];
isSubmitting: boolean;
}>();
const emit = defineEmits<{
submit: [account: AvailableAccount];
}>();
const selectedAccount = ref<AvailableAccount | null>(null);
const selectRef = ref<HTMLSelectElement | null>(null);
watchEffect(() => {
if (props.availableAccounts.length > 0) {
selectRef.value?.focus();
}
});
function handleSubmit() {
if (selectedAccount.value === null) {
return;
}
emit('submit', selectedAccount.value);
}
</script>
<template>
<form
class="add-account-form"
@submit.prevent="handleSubmit"
>
<label for="account-select">Select account to add:</label>
<select
id="account-select"
ref="selectRef"
v-model="selectedAccount"
>
<option
:value="null"
disabled
hidden
>
Select an account
</option>
<option
v-for="account in availableAccounts"
:key="account.providerId"
:value="account"
>
{{ account.providerName }}
</option>
</select>
<button
class="add-account-button"
type="submit"
:disabled="isSubmitting"
>
<CircleSpinner v-if="isSubmitting" />
<span v-else>Add</span>
</button>
</form>
</template>
<style scoped>
.add-account-form {
display: flex;
gap: 0.25rem;
align-items: center;
}
.add-account-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.add-account-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>
@@ -0,0 +1,21 @@
<template>
<span class="spinner" />
</template>
<style scoped>
.spinner {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,236 @@
<script setup lang="ts">
import AddAccountForm from '@/components/AddAccountForm.vue';
import CircleSpinner from '@/components/CircleSpinner.vue';
import { useAccountService } from '@/composables/useAccountService';
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { AvailableAccount, Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import { ref } from 'vue';
const props = defineProps<{
institution: Institution;
}>();
const emit = defineEmits<{
accountAdded: [];
}>();
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
const accountService = useAccountService(userStore);
const loading = ref(false);
const addingAccount = ref(false);
const availableAccounts = ref<AvailableAccount[]>([]);
const showAddForm = ref(false);
async function handleAddAccountClick() {
loading.value = true;
const getAccountsResult = await institutionService.getAvailableAccounts(props.institution.id);
loading.value = false;
if (getAccountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
const notAlreadyAddedAccounts = getAccountsResult.val.filter(a => {
return props.institution.accounts.some(ac => ac.providerId === a.providerId) === false;
});
availableAccounts.value = notAlreadyAddedAccounts;
showAddForm.value = true;
}
async function handleFormSubmit(account: AvailableAccount) {
addingAccount.value = true;
try {
const addAccountResult = await accountService.add(
account.providerInstitutionId,
account.providerId,
account.providerName
);
if (addAccountResult.err) {
alert(addAccountResult.val.map(e => e.message).join('\n'));
return;
}
showAddForm.value = false;
emit('accountAdded');
} finally {
addingAccount.value = false;
}
}
</script>
<template>
<div class="institution-container">
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button
class="add-account-button"
type="button"
:disabled="loading"
@click="handleAddAccountClick"
>
<CircleSpinner v-if="loading" />
<span v-else>Add Account</span>
</button>
</div>
</div>
<div class="accounts-list">
<div
class="account-row"
v-for="account in institution.accounts"
:key="account.id"
>
<div class="account-card">{{ account.name }}</div>
</div>
</div>
<div
class="add-account-card"
v-if="showAddForm"
>
<AddAccountForm
:availableAccounts="availableAccounts"
:isSubmitting="addingAccount"
@submit="handleFormSubmit"
/>
</div>
</div>
</template>
<style scoped>
.institution-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.institution-card,
.account-card,
.add-account-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 0.25rem;
background: var(--bg-surface);
}
.institution-card,
.account-card {
position: relative;
}
.institution-card > div {
flex: 1;
}
.institution-card > div:last-of-type {
display: flex;
justify-content: flex-end;
}
.institution-card::before {
content: '';
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--brand-primary);
z-index: 1;
}
.institution-card::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
height: calc(50% + 0.5rem);
width: 2px;
background: var(--brand-primary);
}
.add-account-card {
margin-left: 2rem;
gap: 0.5rem;
}
.add-account-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.add-account-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.account-row {
position: relative;
padding-left: 2rem;
}
.account-row::before {
content: '';
position: absolute;
top: 0;
left: -1px;
height: calc(100% + 0.5rem);
width: 2px;
background: var(--brand-primary);
}
.account-row:not(:first-child)::before {
top: -0.5rem;
height: calc(100% + 1rem);
}
.account-row:last-child::before {
height: 50%;
}
.account-row:not(:first-child):last-child::before {
top: -0.5rem;
height: calc(50% + 0.5rem);
}
.account-row::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
width: calc(2rem + 1px);
height: 2px;
background: var(--brand-primary);
transform: translateY(-50%);
}
.account-card::before {
content: '';
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--brand-primary);
z-index: 1;
}
</style>
+50 -37
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref } from 'vue';
export type LoginFormState = {
export type LoginFormState = {
isLoggingIn: boolean;
username: {
value: string;
@@ -11,13 +11,13 @@ export type LoginFormState = {
value: string;
error: string;
};
};
};
const props = defineProps<{
const props = defineProps<{
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
}>();
}>();
const formState = ref<LoginFormState>({
const formState = ref<LoginFormState>({
isLoggingIn: false,
username: {
value: '',
@@ -27,25 +27,25 @@ const formState = ref<LoginFormState>({
value: '',
error: '',
},
});
});
function handleUsernameInput(e: Event) {
function handleUsernameInput(e: Event) {
formState.value.username.value = (e.currentTarget as HTMLInputElement).value;
if (formState.value.username.error.trim()) {
formState.value.username.error = '';
}
}
}
function handlePasswordInput(e: Event) {
function handlePasswordInput(e: Event) {
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
if (formState.value.password.error.trim()) {
formState.value.password.error = '';
}
}
}
function validateFormState() {
function validateFormState() {
let isValid = true;
if (!formState.value.username.value.trim()) {
@@ -59,9 +59,9 @@ function validateFormState() {
}
return isValid;
}
}
async function handleSubmit() {
async function handleSubmit() {
formState.value.isLoggingIn = true;
try {
@@ -72,28 +72,41 @@ async function handleSubmit() {
if (props.onValidSubmit !== undefined) {
await props.onValidSubmit(formState.value);
}
} finally {
formState.value.isLoggingIn = false;
}
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label for="username">Username</label>
<input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
<input
type="text"
name="username"
id="username"
:value="formState.username.value"
@input="handleUsernameInput"
/>
<div class="error">{{ formState.username.error }}</div>
</div>
<div>
<label for="password">Password</label>
<input type="password" name="password" id="password" :value="formState.password.value"
@input="handlePasswordInput" />
<input
type="password"
name="password"
id="password"
:value="formState.password.value"
@input="handlePasswordInput"
/>
<div class="error">{{ formState.password.error }}</div>
</div>
<div>
<button type="submit" :disabled="formState.isLoggingIn">
<button
type="submit"
:disabled="formState.isLoggingIn"
>
Login
</button>
</div>
@@ -101,47 +114,47 @@ async function handleSubmit() {
</template>
<style scoped>
form,
form>div {
form,
form > div {
display: flex;
flex-direction: column;
background-color: var(--bg-surface);
}
}
form {
form {
gap: 1rem;
padding: 1rem;
border-radius: 0.25rem;
}
}
form>div {
form > div {
gap: 0.25rem;
}
}
form>div label {
form > div label {
font-weight: 700;
}
}
form>div input {
form > div input {
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid black;
background-color: var(--bg-element);
}
}
form>div button {
form > div button {
background-color: var(--brand-primary);
padding: 0.5rem 0.25rem;
border-radius: 0.25rem;
}
}
form>div button:disabled {
form > div button:disabled {
opacity: 50%;
pointer-events: none;
}
}
form>div .error {
form > div .error {
color: var(--state-error);
font-size: 0.85rem;
}
}
</style>
+110 -14
View File
@@ -1,11 +1,14 @@
<script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { RouterLink, useRouter } from 'vue-router';
import RightArrowBracketIcon from './icons/RightArrowBracketIcon.vue';
const userStore = useUserStore();
const router = useRouter();
const authService = useAuthService(userStore);
const asideClasses = computed(() => ({
collapsed: userStore.user?.sidebarCollapsed,
@@ -15,12 +18,14 @@ import { useRouter } from 'vue-router';
userStore.toggleSidebar();
}
function handleLogout() {
// TODO: Also need to log user
// out on the server...which is
// basically just clearing
// refresh token cookie and revoking
// it in the database
async function handleLogout() {
const logoutResult = await authService.logout();
if (logoutResult.err) {
alert(logoutResult.val.map(e => e.message).join('\n'));
return;
}
userStore.logUserOut();
router.push({ path: '/public/login' });
}
@@ -35,13 +40,30 @@ import { useRouter } from 'vue-router';
>
<LeftArrowIcon />
</button>
<div class="sidebar-content">
<div class="top">
<nav>
<ul>
<li>
<RouterLink to="/accounts">Accounts</RouterLink>
</li>
<li>
<RouterLink to="/transactions">Transactions</RouterLink>
</li>
</ul>
</nav>
</div>
<div class="bottom">
<button
class="logout-button"
type="button"
@click="handleLogout"
>
<RightArrowBracketIcon />
Logout
</button>
</div>
</div>
</aside>
</template>
@@ -68,12 +90,79 @@ import { useRouter } from 'vue-router';
}
}
.logout-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
.sidebar-content {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
gap: 3rem;
flex: 1;
overflow: hidden;
opacity: 1;
visibility: visible;
transition-property: opacity, visibility;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
.top {
padding: 1rem;
}
nav ul {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
nav a {
display: flex;
width: 100%;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
nav a.router-link-exact-active,
nav a:hover {
background: var(--bg-element);
}
nav a.router-link-exact-active {
position: relative;
}
nav a.router-link-exact-active::before {
content: ' ';
position: absolute;
left: 0;
height: 1.25rem;
background: red;
border: 2px solid var(--brand-primary);
}
.bottom {
padding: 1rem;
}
.logout-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
background: var(--bg-element);
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
.logout-button svg {
--size: 1.5rem;
width: var(--size);
height: var(--size);
fill: var(--text-primary);
}
.toggle-button {
display: flex;
align-items: center;
@@ -90,6 +179,13 @@ import { useRouter } from 'vue-router';
transition-timing-function: var(--transition-function);
}
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
fill: var(--text-primary);
}
aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2);
}
@@ -98,9 +194,9 @@ import { useRouter } from 'vue-router';
transform: rotate(180deg);
}
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
aside.collapsed .sidebar-content {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
</style>
@@ -22,5 +22,16 @@
main {
flex: 1;
padding: 1rem;
padding-right: 0rem;
overflow: auto;
scroll-behavior: smooth;
scrollbar-color: var(--bg-surface) var(--bg-app);
scrollbar-gutter: stable;
}
@media screen and (max-width: 48rem) {
main {
margin-right: 1rem;
}
}
</style>
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { RouterView } from 'vue-router';
</script>
<template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { Transaction } from '@/services/transactionService';
defineProps<{
transaction: Transaction;
}>();
const emit = defineEmits<{
delete: [id: string];
}>();
function formatDate(date: string) {
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return formatter.format(new Date(date));
}
function formatAmount(amount: number): string {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
return formatter.format(amount);
}
</script>
<template>
<div class="card">
<div class="top">
<div class="left">
<div>{{ transaction.merchantName }}</div>
<div class="date">{{ formatDate(transaction.date) }}</div>
</div>
<div class="right">
<div>{{ formatAmount(transaction.amount) }}</div>
</div>
</div>
<div class="bottom">
<p>{{ transaction.description }}</p>
</div>
<div class="actions">
<button
class="delete-btn"
@click="emit('delete', transaction.id)"
>
Delete
</button>
</div>
</div>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--bg-surface);
border-radius: 0.25rem;
padding: 1rem;
}
.top {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date,
.bottom {
font-size: 0.875rem;
color: #666666;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.5rem;
}
.delete-btn {
background: none;
border: 0.0625rem solid var(--state-error);
border-radius: 0.25rem;
color: var(--state-error);
cursor: pointer;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.delete-btn:hover {
background: var(--state-error);
color: var(--text-primary);
}
</style>
@@ -0,0 +1,10 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"
>
<path
d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"
/>
</svg>
</template>
@@ -1,6 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"
>
<path
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z" />
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
import { AccountServiceFactoryKey } from '@/services/accountService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
export function useAccountService(store: UserStore) {
return useService(store, AccountServiceFactoryKey);
}
@@ -1,27 +1,7 @@
import { AuthServiceFactoryKey } from "@/services/authService";
import { ClientConfig, ClientFactoryKey } from "@/services/client";
import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
import { AuthServiceFactoryKey } from '@/services/authService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
export function useAuthService(store: UserStore) {
const clientFactory = inject(ClientFactoryKey);
const authServiceFactory = inject(AuthServiceFactoryKey);
if (clientFactory === undefined) {
throw new Error("Failed to inject client factory.")
}
if (authServiceFactory === undefined) {
throw new Error("Failed to inject auth service factory.")
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}`},
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
const authService = authServiceFactory.create(client);
return authService;
return useService(store, AuthServiceFactoryKey);
}
@@ -1,27 +1,7 @@
import { ClientConfig, ClientFactoryKey } from "@/services/client";
import { InstituionServiceFactoryKey } from "@/services/institutionService";
import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
import { InstitutionServiceFactoryKey } from '@/services/institutionService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
export function useInstitutionService(store: UserStore) {
const clientFactory = inject(ClientFactoryKey);
const institutionServiceFactory = inject(InstituionServiceFactoryKey);
if (clientFactory === undefined) {
throw new Error("Failed to inject client factory.")
}
if (institutionServiceFactory === undefined) {
throw new Error("Failed to inject institution service factory.")
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}`},
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
const institutionService = institutionServiceFactory.create(client);
return institutionService;
return useService(store, InstitutionServiceFactoryKey);
}
@@ -0,0 +1,33 @@
import type { InjectionKey } from 'vue';
import { inject } from 'vue';
import { ClientConfig, ClientFactoryKey, type IClient } from '@/services/client';
import type { UserStore } from '@/stores/userStore';
export interface IServiceFactory<TService> {
create: (client: IClient) => TService;
}
export function useService<TService>(
store: UserStore,
serviceFactoryKey: InjectionKey<IServiceFactory<TService>>
): TService {
const clientFactory = inject(ClientFactoryKey);
const serviceFactory = inject(serviceFactoryKey);
if (clientFactory === undefined) {
throw new Error('Failed to inject client factory.');
}
if (serviceFactory === undefined) {
throw new Error('Failed to inject service factory.');
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}` },
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
return serviceFactory.create(client);
}
@@ -0,0 +1,7 @@
import { TransactionServiceFactoryKey } from '@/services/transactionService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
export function useTransactionService(store: UserStore) {
return useService(store, TransactionServiceFactoryKey);
}
+19 -9
View File
@@ -1,21 +1,31 @@
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 {
InstitutionServiceFactoryKey,
InstitutionServiceFactory,
} from './services/institutionService';
import {
TransactionServiceFactory,
TransactionServiceFactoryKey,
} from './services/transactionService';
import { AccountServiceFactory, AccountServiceFactoryKey } from './services/accountService';
const app = createApp(App);
app.provide(ClientFactoryKey, new ClientFactory());
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
app.provide(InstitutionServiceFactoryKey, new InstitutionServiceFactory());
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
app.provide(AccountServiceFactoryKey, new AccountServiceFactory());
app.use(createPinia());
app.use(router);
app.mount("#app");
app.mount('#app');
+23 -1
View File
@@ -23,6 +23,9 @@ const router = createRouter({
{
path: 'login',
component: () => import('../views/LoginView.vue'),
meta: {
title: 'Login',
},
},
],
},
@@ -60,11 +63,30 @@ const router = createRouter({
children: [
{
path: '/',
component: () => import('../views/HomeView.vue'),
redirect: '/accounts',
},
{
path: '/accounts',
component: () => import('../views/AccountsView.vue'),
meta: {
title: 'Accounts',
},
},
{
path: '/transactions',
component: () => import('../views/TransactionsView.vue'),
meta: {
title: 'Transactions',
},
},
],
},
],
});
router.beforeEach((to, _, next) => {
document.title = to.meta.title ? `FiscalOS - ${to.meta.title}` : 'FiscalOS';
next();
});
export default router;
@@ -0,0 +1,58 @@
import type { InjectionKey } from 'vue';
import { ClientRequestWithBody, type IClient } from './client';
import { Err, Ok, type Result } from 'ts-results';
type AccountServiceFactoryKeyType = InjectionKey<IAccountServiceFactory>;
export const AccountServiceFactoryKey: AccountServiceFactoryKeyType =
Symbol('AccountServiceFactory');
export interface IAccountServiceFactory {
create: (client: IClient) => IAccountService;
}
export class AccountServiceFactory implements IAccountServiceFactory {
create(client: IClient): IAccountService {
return new AccountService(client);
}
}
export interface IAccountService {
add: (
providerInstitutionId: string,
providerAccountId: string,
providerAccountName: string
) => Promise<Result<boolean, Error[]>>;
}
export class AccountService implements IAccountService {
private readonly client: IClient;
private readonly endpoints = {
add: '/api/accounts',
};
constructor(client: IClient) {
this.client = client;
}
async add(providerInstitutionId: string, providerAccountId: string, providerAccountName: string) {
const request = new ClientRequestWithBody(this.endpoints.add, undefined, {
providerInstitutionId,
providerAccountId,
providerAccountName,
});
try {
const res = await this.client.post(request);
if (res.ok === false) {
return Err([new Error('Failed to add account.')]);
}
return Ok(true);
} catch (error) {
console.error(error);
return Err([new Error('Failed to add account.')]);
}
}
}
@@ -19,6 +19,7 @@ export class AuthServiceFactory implements IAuthServiceFactory {
export interface IAuthService {
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
logout: () => Promise<Result<boolean, Error[]>>;
}
export class AuthService implements IAuthService {
@@ -26,12 +27,30 @@ export class AuthService implements IAuthService {
private readonly endpoints = {
login: '/api/auth/login',
refreshToken: '/api/auth/refresh',
logout: '/api/auth/logout',
};
constructor(client: IClient) {
this.client = client;
}
async logout() {
const request = new ClientRequestWithBody(this.endpoints.logout, undefined);
try {
const res = await this.client.post(request);
if (res.ok === false) {
return Err([new Error('Failed to logout. Please try again.')]);
}
return Ok(true);
} catch (e) {
console.error(e);
return Err([new Error('Failed to logout. Please try again.')]);
}
}
async login(username: string, password: string) {
const request = new ClientRequestWithBody(
this.endpoints.login,
@@ -4,8 +4,9 @@ import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
Symbol('AuthServiceFactory');
export const InstitutionServiceFactoryKey: InstitutionServiceFactoryKeyType = Symbol(
'InstitutionServiceFactory'
);
export interface IInstitutionServiceFactory {
create: (client: IClient) => IInstitutionService;
@@ -136,8 +137,9 @@ type LinkTokenResponse = {
type Account = {
id: string;
providerId: string;
name: string;
}
};
export type Institution = {
id: string;
@@ -0,0 +1,92 @@
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(
'TransactionServiceFactory'
);
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[]>>;
deleteById: (id: string) => Promise<Result<boolean, Error[]>>;
}
export class TransactionService implements ITransactionService {
private readonly client: IClient;
private readonly endpoints = {
get: '/api/transactions',
};
constructor(client: IClient) {
this.client = client;
}
async deleteById(id: string): Promise<Result<boolean, Error[]>> {
const url = this.endpoints.get + '/' + id;
const request = new ClientRequest(url);
try {
const res = await this.client.delete(request);
if (res.ok === false) {
return Err([new Error('Failed to delete transaction.')]);
}
return Ok(true);
} catch (error) {
console.error(error);
return Err([new Error('Failed to delete transaction.')]);
}
}
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 res = await this.client.get(request);
if (res.ok === false) {
return Err([new Error('Failed to retrieve transactions.')]);
}
const data = await res.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;
};
+1 -1
View File
@@ -88,7 +88,7 @@ export const useUserStore = defineStore('userStore', () => {
const updatedUser = {
...user.value,
sidebarCollapsed: !user.value?.sidebarCollapsed,
}
};
user.value = updatedUser;
saveUserToLocalSotrage(updatedUser);
}
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import InstitutionCard from '@/components/InstitutionCard.vue';
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import {
usePlaidLink,
type PlaidLinkOnSuccessMetadata,
type PlaidLinkOptions,
} from '@jcss/vue-plaid-link';
import { nextTick, ref, watch } from 'vue';
type InstitutionData =
| { status: 'loading' }
| { status: 'loaded'; data: Institution[] }
| { status: 'errored'; errors: Error[] };
async function handleSuccess(publicToken: string, metadata: PlaidLinkOnSuccessMetadata) {
if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.');
return;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'));
return;
}
institutionsData.value = { status: 'loading' };
}
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: handleSuccess,
});
const { open } = usePlaidLink(plaidOptions);
const institutionsData = ref<InstitutionData>({ status: 'loading' });
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
async function institutionsDataWatcher(data: InstitutionData) {
if (data.status !== 'loading') {
return;
}
const institutionsResult = await institutionService.getInstitutions();
if (institutionsResult.err) {
institutionsData.value = { status: 'errored', errors: institutionsResult.val };
return;
}
institutionsData.value = { status: 'loaded', data: institutionsResult.val };
}
watch(institutionsData, institutionsDataWatcher, { immediate: true });
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
}
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
</script>
<template>
<div class="header">
<h1>Accounts</h1>
<button
class="add-institution-button"
type="button"
@click="handleAddInstitutionClick"
>
Add Institution
</button>
</div>
<div
v-if="institutionsData.status === 'loaded'"
class="institutions-container"
>
<InstitutionCard
v-for="institution in institutionsData.data"
:key="institution.id"
:institution="institution"
@accountAdded="institutionsData = { status: 'loading' }"
/>
</div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template>
<style scoped>
.header {
display: flex;
gap: 1rem;
align-items: center;
}
.institutions-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.add-institution-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
</style>
-210
View File
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { AvailableAccount, Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
import { nextTick, ref, watch } from 'vue';
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
type InstitutionData =
| {
status: 'loading';
}
| { status: 'loaded'; data: Institution[] }
| { status: 'errored'; errors: Error[] };
const institutionsData = ref<InstitutionData>({ status: 'loading' });
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: async (publicToken, metadata) => {
if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.');
return;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'));
return;
}
institutionsData.value = { status: 'loading' };
},
onLoad: () => console.log('loaded'),
onExit: () => console.log('exit'),
});
const { open } = usePlaidLink(plaidOptions);
watch(
institutionsData,
async data => {
if (data.status !== 'loading') {
return;
}
const institutionsResult = await institutionService.getInstitutions();
if (institutionsResult.err) {
institutionsData.value = {
status: 'errored',
errors: institutionsResult.val,
};
return;
}
institutionsData.value = {
status: 'loaded',
data: institutionsResult.val,
};
},
{ immediate: true }
);
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
}
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
const targetInstitutionOfAdd = ref<Institution | null>(null);
const availableAccounts = ref<AvailableAccount[]>([]);
const targetAccountOfAdd = ref<AvailableAccount | null>(null);
async function handleAddAccountClick(institution: Institution) {
const accountsResult = await institutionService.getAvailableAccounts(institution.id);
if (accountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
availableAccounts.value = accountsResult.val;
targetInstitutionOfAdd.value = institution;
}
async function handleAccountAddClick() {
if (targetAccountOfAdd.value === null) {
return;
}
const request = {
plaidInstitutionId: targetAccountOfAdd.value.providerInstitutionId,
plaidAccountId: targetAccountOfAdd.value.providerId,
plaidAccountName: targetAccountOfAdd.value.providerName,
accountAvailableBalance: targetAccountOfAdd.value.availableBalance,
accountCurrencyCode: targetAccountOfAdd.value.currencyCode,
};
try {
const res = await fetch('/api/accounts', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userStore.user?.token}`,
},
body: JSON.stringify(request),
});
if (res.ok === false) {
alert('Add failed');
return;
}
targetInstitutionOfAdd.value = null;
institutionsData.value = { status: 'loading' };
} catch (error) {
console.error(error);
}
}
</script>
<template>
<div>
<button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
Add Institution
</button>
</div>
<div v-if="institutionsData.status === 'loaded'" class="institutions-container">
<div v-for="institution in institutionsData.data" v-bind:key="institution.id">
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button class="add-account-button" type="button" @click="handleAddAccountClick(institution)">
Add Account
</button>
</div>
</div>
<div v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id">
<select v-model="targetAccountOfAdd">
<option v-for="account in availableAccounts" v-bind:key="account.providerId" :value="account">
{{ account.providerName }}
</option>
</select>
<button type="button" @click="handleAccountAddClick">
Add
</button>
</div>
<div v-for="accounts in institution.accounts" v-bind:key="accounts.id">
<div>{{ accounts.name }}</div>
</div>
</div>
</div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template>
<style scoped>
.institutions-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
}
.institution-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 0.25rem;
background: var(--bg-surface);
}
.institution-card>div {
flex: 1;
}
.institution-card>div:last-of-type {
display: flex;
justify-content: flex-end;
}
.add-institution-button,
.add-account-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
</style>
+9 -9
View File
@@ -1,14 +1,14 @@
<script setup lang="ts">
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { useRouter } from 'vue-router';
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { useRouter } from 'vue-router';
const router = useRouter();
const userStore = useUserStore();
const authService = useAuthService(userStore);
const router = useRouter();
const userStore = useUserStore();
const authService = useAuthService(userStore);
async function handleValideSubmit(formState: LoginFormState) {
async function handleValideSubmit(formState: LoginFormState) {
const loginResult = await authService.login(formState.username.value, formState.password.value);
if (loginResult.err) {
@@ -18,7 +18,7 @@ async function handleValideSubmit(formState: LoginFormState) {
userStore.logUserIn(loginResult.val.accessToken);
router.push('/');
}
}
</script>
<template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import TransactionCard from '@/components/TransactionCard.vue';
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[]>([]);
async function handleDelete(id: string) {
const result = await transactionService.deleteById(id);
if (result.err) {
alert(result.val.map(e => e.message).join('\n'));
return;
}
transactions.value = transactions.value.filter(t => t.id !== id);
}
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 class="transactions">
<li
v-for="transaction in transactions"
:key="transaction.id"
>
<TransactionCard
:transaction="transaction"
@delete="handleDelete"
/>
</li>
</ul>
</div>
</template>
<style scoped>
.transactions {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>
+1
View File
@@ -10,6 +10,7 @@
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" />
<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" />
</ItemGroup>
</Project>
@@ -22,6 +22,7 @@
<ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
</ItemGroup>
</Project>
@@ -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<string, string> _cookies = [];
private readonly Dictionary<string, string> _headers = [];
private readonly Dictionary<string, string?> _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)
{
@@ -1,3 +1,4 @@
using FiscalOS.API.Accounts.Add;
using FiscalOS.Core.Queuing;
using FiscalOS.Infra.Transactions.Plaid;
@@ -31,9 +32,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."],
});
}
@@ -45,9 +46,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
plaidAccountId = "accountId",
plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
providerAccountId = "accountId",
providerAccountName = "Some Account",
})
.Build();
@@ -55,7 +55,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
});
}
@@ -67,9 +67,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
plaidInstitutionId = "institutionId",
plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
providerInstitutionId = "institutionId",
providerAccountName = "Some Account",
})
.Build();
@@ -77,7 +76,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
});
}
@@ -89,9 +88,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
plaidInstitutionId = "institutionId",
plaidAccountId = "accountId",
accountCurrencyCode = "USD",
providerInstitutionId = "institutionId",
providerAccountId = "accountId",
})
.Build();
@@ -99,7 +97,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."],
});
}
@@ -111,10 +109,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
plaidInstitutionId = "id",
plaidAccountId = "id",
plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
providerInstitutionId = "id",
providerAccountId = "id",
providerAccountName = "Some Account",
})
.Build();
@@ -145,10 +142,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(user.Id)
.WithBody(new
{
plaidInstitutionId = "id",
plaidAccountId = "id",
plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
providerInstitutionId = "id",
providerAccountId = "id",
providerAccountName = "Some Account",
})
.Build();
@@ -156,7 +152,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
});
}
@@ -199,10 +195,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(user.Id)
.WithBody(new
{
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
accountCurrencyCode = "USD",
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
providerAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
providerAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
})
.Build();
@@ -252,20 +247,15 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
var newAccountId = "newAccountId";
var newAccountName = "New Account";
var expectedBalance = 100;
var expectedCurrencyCode = "USD";
using var request = HttpRequestBuilder.New()
.Post(AddUri)
.WithUserId(user.Id)
.WithBody(new
{
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
plaidAccountId = newAccountId,
plaidAccountName = newAccountName,
accountCurrentBalance = expectedBalance,
accountAvailableBalance = expectedBalance,
accountCurrencyCode = expectedCurrencyCode,
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
providerAccountId = newAccountId,
providerAccountName = newAccountName,
})
.Build();
@@ -0,0 +1,76 @@
using FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Tests.Integration.Auth;
public class LogoutTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri LogoutUri = new("/auth/logout", UriKind.Relative);
private static readonly Uri LoginUri = new("/auth/login", UriKind.Relative);
[Fact]
public async Task Logout_WhenCalled_ItShouldExpireRefreshTokenCookie()
{
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user = User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey);
context.Add(user);
await context.SaveChangesAsync(ct);
return user;
}, TestContext.Current.CancellationToken);
using var loginRequest = HttpRequestBuilder.New()
.Post(LoginUri)
.WithBody(new
{
username = "Stevan",
password = "@Password1",
})
.Build();
var loginResponse = await Client.SendAsync(loginRequest, TestContext.Current.CancellationToken);
var setCookieHeaders = loginResponse.Headers.GetValues("Set-Cookie");
var refreshTokenCookieHeader = setCookieHeaders.First(h => h.StartsWith("fiscalos_refresh_cookie", StringComparison.OrdinalIgnoreCase));
var refreshTokenCookieParts = refreshTokenCookieHeader.Trim().Split("=");
var accessToken = (await loginResponse.Should().BeJsonContentOfType<Response>(HttpStatusCode.OK))
.Which
.AccessToken;
var userRefreshTokens = await Api.ExecuteAsync(async (context, ct) =>
{
return await context.Set<RefreshToken>()
.Where(r => r.UserId == user.Id)
.ToListAsync(ct);
}, TestContext.Current.CancellationToken);
userRefreshTokens.Should()
.ContainSingle(r => r.IsExpired(DateTimeOffset.UtcNow) == false && r.Revoked == false);
using var logoutRequest = HttpRequestBuilder.New()
.Post(LogoutUri)
.WithBearerToken(accessToken)
.WithCookie(refreshTokenCookieParts[0], refreshTokenCookieParts[1])
.Build();
var logoutResponse = await Client.SendAsync(logoutRequest, TestContext.Current.CancellationToken);
logoutResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
logoutResponse.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
var updatedUserRefreshTokens = await Api.ExecuteAsync(async (context, ct) =>
{
return await context.Set<RefreshToken>()
.Where(r => r.UserId == user.Id)
.ToListAsync(ct);
}, TestContext.Current.CancellationToken);
updatedUserRefreshTokens.Should()
.ContainSingle(r => r.Revoked && r.IsExpired(DateTimeOffset.UtcNow) == false);
}
}
@@ -0,0 +1,120 @@
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.API.Tests.Integration.Transactions;
public class DeleteByIdTests(TestApi testApi) : IntegrationTest(testApi)
{
private static Uri GetDeleteByIdUri(Guid id)
{
return new($"/transactions/{id}", UriKind.Relative);
}
[Fact]
public async Task DeleteById_WhenCalledAndUnauthenticated_ItShouldREturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Delete(GetDeleteByIdUri(Guid.NewGuid()))
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task DeleteById_WhenCalledWithNonExistentTransaction_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Delete(GetDeleteByIdUri(Guid.NewGuid()))
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteById_WhenCalledWithTransactionIdThatDoesNotBelongToTheUser_ItShouldReturn404WithProblemDetails()
{
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()
.Delete(GetDeleteByIdUri(Guid.NewGuid()))
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteById_WhenCalledWithTransactionIdThatDoesExistAndBelongsToTheUser_ItShouldReturn204WithNoContent()
{
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()
.Delete(GetDeleteByIdUri(user.Transactions.First().Id))
.WithUserId(user.Id)
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
var deletedTransaction = await Api.ExecuteAsync(async (context, ct) =>
{
return await context.Set<Transaction>()
.SingleOrDefaultAsync(t => t.Id == user.Transactions.First().Id, ct);
}, TestContext.Current.CancellationToken);
deletedTransaction.Should().BeNull();
}
}
@@ -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);
}
}
+3
View File
@@ -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;
@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,3 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Integration;
public abstract class IntegrationTest : IAsyncLifetime
@@ -30,6 +30,9 @@ public class PlaidAddedTransactionHandlerTests : IntegrationTest
result.Should().Be(1);
account.Transactions.Should().HaveCount(2);
account.Transactions
.Single(t => t.Metadata is PlaidTransactionMetadata m && m.PlaidId == transaction.TransactionId)
.Amount.Should().Be(-transaction.Amount);
}
[Fact]
@@ -31,5 +31,6 @@ public class PlaidModifiedTransactionHandlerTests : IntegrationTest
result.Should().Be(1);
existingTransaction.MerchantName.Should().Be(transaction.MerchantName);
existingTransaction.Amount.Should().Be(-transaction.Amount);
}
}
@@ -5,6 +5,7 @@ public class PlaidTransactionProcessorTests
private readonly Mock<IPlaidAddedTransactionHandler> _mockAddedHandler = new();
private readonly Mock<IPlaidModifiedTransactionHandler> _mockModifiedHandler = new();
private readonly Mock<IPlaidRemovedTransactionHandler> _mockRemovedHandler = new();
private readonly Mock<ILogger<PlaidTransactionProcessor>> _mockLogger = new();
private readonly PlaidTransactionProcessor _sut;
public PlaidTransactionProcessorTests()
@@ -12,7 +13,8 @@ public class PlaidTransactionProcessorTests
_sut = PlaidTransactionProcessor.From(
_mockAddedHandler.Object,
_mockModifiedHandler.Object,
_mockRemovedHandler.Object
_mockRemovedHandler.Object,
_mockLogger.Object
);
}
@@ -21,7 +23,7 @@ public class PlaidTransactionProcessorTests
[InlineData(1, 1, 1, true)]
[InlineData(0, 1, 1, false)]
[InlineData(1, 0, 1, false)]
[InlineData(1, 1, 0, false)]
[InlineData(1, 1, 0, true)]
public async Task ProcessAsync_WhenCalledAndTransactionsAreHandled_ItShouldReturnCorrectResult(
int numAdded,
int numModified,
@@ -1,5 +1,3 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Unit;
public class PlaidTransactionSyncerTests
+1
View File
@@ -19,6 +19,7 @@ global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Security;
global using FiscalOS.Infra.Tests.Assertions;
global using FiscalOS.Infra.Tests.Mocks;
global using FiscalOS.Tests.Common.Data;
global using FiscalOS.Infra.Transactions.Plaid;
global using Going.Plaid;
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class AccountBuilder
public sealed class AccountBuilder
{
private string _name = "accountName";
private AccountMetadata? _metadata;
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class AccountMetadataBuilder
public sealed class AccountMetadataBuilder
{
private string _plaidId = "plaidId";
private string _plaidName = "plaidName";
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class InstitutionBuilder
public sealed class InstitutionBuilder
{
private string _name = "institutionName";
private InstitutionMetadata? _metadata;
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class InstitutionMetadataBuilder
public sealed class InstitutionMetadataBuilder
{
private string _plaidId = "plaidId";
private string _plaidName = "plaidName";
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Tests.Common.Data;
internal sealed class TransactionBuilder
public sealed class TransactionBuilder
{
private string _merchantName = "merchantName";
private string _description = "description";

Some files were not shown because too many files have changed in this diff Show More