Compare commits

...
22 changed files with 530 additions and 73 deletions
+1
View File
@@ -12,6 +12,7 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj" />
<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" />
</Folder>
@@ -0,0 +1,34 @@
namespace FiscalOS.API.Institutions.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,
[FromServices] AppDbContext appDbContext
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user is null)
{
return Results.Unauthorized();
}
var institutionDtos = user.Institutions
.Select(InstitutionDto.FromInstitution)
.OrderBy(dto => dto.Name);
return Results.Ok(Response.From(institutionDtos));
}
}
@@ -0,0 +1,39 @@
namespace FiscalOS.API.Institutions.Get;
internal sealed record Response
{
public IEnumerable<InstitutionDto> Institutions { get; init; } = [];
[JsonConstructor]
private Response()
{
}
public static Response From(IEnumerable<InstitutionDto> institutions)
{
return new Response
{
Institutions = institutions,
};
}
}
internal sealed record InstitutionDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
[JsonConstructor]
private InstitutionDto()
{
}
public static InstitutionDto FromInstitution(Institution institution)
{
return new InstitutionDto
{
Id = institution.Id.ToString(),
Name = institution.Name,
};
}
}
@@ -12,6 +12,7 @@ internal static class InstitutionsExtensions
institutionsGroup.MapConnectEndpoint();
institutionsGroup.MapGetAvailableEndpoint();
institutionsGroup.MapLinkEndpoint();
institutionsGroup.MapGetEndpoint();
return institutionsGroup;
}
+1
View File
@@ -2,6 +2,7 @@ global using System.ComponentModel.DataAnnotations;
global using System.Security.Claims;
global using System.Text.Json.Serialization;
global using FiscalOS.API.Institutions.Get;
global using FiscalOS.API.Accounts;
global using FiscalOS.API.Accounts.Add;
global using FiscalOS.API.Auth;
+5 -3
View File
@@ -1,10 +1,12 @@
using FiscalOS.AppHost;
var builder = DistributedApplication.CreateBuilder(args);
var api = builder.AddProject<Projects.FiscalOS_API>("API")
var api = builder.AddProject<Projects.FiscalOS_API>(ProjectNames.API)
.WithHttpHealthCheck("/health");
builder.AddViteApp("web", "../FiscalOS.Web")
builder.AddViteApp(ProjectNames.Web, "../FiscalOS.Web")
.WithReference(api)
.WithHttpsEndpoint(port: 7061, env: "PORT");
builder.Build().Run();
builder.Build().Run();
+7
View File
@@ -0,0 +1,7 @@
namespace FiscalOS.AppHost;
public static class ProjectNames
{
public const string API = "api";
public const string Web = "web";
}
@@ -15,6 +15,7 @@ button {
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: inherit;
}
input {
+31 -4
View File
@@ -1,14 +1,28 @@
<script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { computed, ref } from 'vue';
import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const userStore = useUserStore();
const router = useRouter();
const isCollapsed = ref(false);
const asideClasses = computed(() => ({
collapsed: isCollapsed.value,
collapsed: userStore.user?.sidebarCollapsed,
}));
function handleToggleButtonClick() {
isCollapsed.value = !isCollapsed.value;
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
userStore.logUserOut();
router.push({ path: '/public/login' });
}
</script>
@@ -21,6 +35,13 @@
>
<LeftArrowIcon />
</button>
<button
class="logout-button"
type="button"
@click="handleLogout"
>
Logout
</button>
</aside>
</template>
@@ -47,6 +68,12 @@
}
}
.logout-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.toggle-button {
display: flex;
align-items: center;
@@ -21,6 +21,6 @@
main {
flex: 1;
border: 1px solid blue;
padding: 1rem;
}
</style>
@@ -1,6 +1,6 @@
import { Err, Ok, Result } from 'ts-results';
import { type InjectionKey } from 'vue';
import { ClientRequestWithBody, type IClient } from './client';
import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
@@ -20,6 +20,7 @@ export class InstitutionServiceFactory implements InstitutionServiceFactory {
export interface IInstitutionService {
createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>;
connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>;
getInstitutions: () => Promise<Result<Institution[], Error[]>>;
}
export class InstitutionService implements IInstitutionService {
@@ -27,6 +28,7 @@ export class InstitutionService implements IInstitutionService {
private readonly endpoints = {
link: '/api/institutions/link',
connect: '/api/institutions/connect',
institutions: '/api/institutions',
};
constructor(client: IClient) {
@@ -84,8 +86,32 @@ export class InstitutionService implements IInstitutionService {
return Err([new Error('Failed to connect institution')]);
}
}
async getInstitutions() {
const request = new ClientRequest(this.endpoints.institutions);
try {
const res = await this.client.get(request);
if (res.ok === false) {
return Err([new Error('Failed to get institutions')]);
}
const data = await res.json();
return Ok(data.institutions as Institution[]);
} catch (e) {
console.error(e);
return Err([new Error('Failed to get institutions')]);
}
}
}
type LinkTokenResponse = {
linkToken: string;
};
export type Institution = {
id: string;
name: string;
};
+21 -1
View File
@@ -10,6 +10,7 @@ export type User = {
id: string;
expiresAtInSeconds: number;
token: string;
sidebarCollapsed: boolean;
};
type JwtTokenPayload = {
@@ -22,6 +23,10 @@ function getUserFromLocalStorage(): User | null {
return user === null ? null : JSON.parse(user);
}
function saveUserToLocalSotrage(user: User) {
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export type UserStore = ReturnType<typeof useUserStore>;
export const useUserStore = defineStore('userStore', () => {
@@ -35,9 +40,10 @@ export const useUserStore = defineStore('userStore', () => {
id: sub,
expiresAtInSeconds: exp,
token: jwtToken,
sidebarCollapsed: false,
};
localStorage.setItem(USER_KEY, JSON.stringify(loggedInUser));
user.value = loggedInUser;
saveUserToLocalSotrage(loggedInUser);
}
function logUserOut() {
@@ -74,10 +80,24 @@ export const useUserStore = defineStore('userStore', () => {
return { response, accessToken: refreshResult.val.accessToken };
}
async function toggleSidebar() {
if (user.value === null) {
return;
}
const updatedUser = {
...user.value,
sidebarCollapsed: !user.value?.sidebarCollapsed,
}
user.value = updatedUser;
saveUserToLocalSotrage(updatedUser);
}
return {
user: user,
logUserIn,
logUserOut,
refreshAccessToken,
toggleSidebar,
};
});
+137 -63
View File
@@ -1,82 +1,156 @@
<script setup lang="ts">
import { useInstitutionService } from '@/composables/useInstitutionService';
import { useUserStore } from '@/stores/userStore';
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
import { nextTick, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { 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 router = useRouter();
const institutionService = useInstitutionService(userStore);
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: async (publicToken, metadata) => {
if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.');
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;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'))
return;
}
},
onLoad: () => console.log('loaded'),
onExit: () => console.log('exit'),
});
const { open } = usePlaidLink(plaidOptions);
await nextTick();
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
userStore.logUserOut();
router.push({ path: '/public/login' });
}
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
open();
}
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
</script>
<template>
<h1>Home View</h1>
<button class="logout-button" type="button" @click="handleLogout">
Logout
</button>
<div>
<button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
<button
class="add-institution-button"
type="button"
@click="handleAddInstitutionClick"
>
Add Institution
</button>
</div>
<div
v-if="institutionsData.status === 'loaded'"
class="institutions-container"
>
<div
class="institution-card"
v-for="institution in institutionsData.data"
v-bind:key="institution.id"
>
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button
class="add-account-button"
type="button"
>
Add Account
</button>
</div>
</div>
</div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template>
<style scoped>
.logout-button,
.add-institution-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.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>
+3
View File
@@ -3,9 +3,12 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.1.1" />
<PackageVersion Include="AwesomeAssertions" Version="9.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageVersion Include="Microsoft.Playwright.Xunit.v3" Version="1.58.0" />
<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.mtp-v2" Version="3.2.2" />
</ItemGroup>
@@ -0,0 +1,71 @@
using FiscalOS.API.Institutions.Get;
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Tests.Integration.Institutions;
public class GetTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri GetUri = new("/institutions", UriKind.Relative);
[Fact]
public async Task Get_WhenCalledWithoutValidToken_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.Build();
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Get_WhenCalledByNonExistentUser_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithUserId(Guid.NewGuid())
.Build();
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Get_WhenCalledByUser_ItShouldReturn200WithListOfInstitutions()
{
var (user, institution) = await 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("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct);
var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken);
var institution = Institution.From("Some Bank", plaidMetadata);
user.AddInstitution(institution);
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
return (user, institution);
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithUserId(user.Id)
.Build();
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
(await res.Should().BeJsonContentOfType<Response>(HttpStatusCode.OK))
.Which.Institutions.Should().BeEquivalentTo(
[
InstitutionDto.FromInstitution(institution),
]);
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>FiscalOS.AppHost.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" />
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.Playwright.Xunit.v3" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3.mtp-v2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\FiscalOS.AppHost\FiscalOS.AppHost.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
using Aspire.Hosting;
using Aspire.Hosting.Testing;
namespace FiscalOS.AppHost.Tests.Infra;
public sealed class AspireFixture : IAsyncLifetime
{
private DistributedApplication _app = null!;
public async ValueTask InitializeAsync()
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.FiscalOS_AppHost>();
_app = await appHost.BuildAsync();
await _app.StartAsync();
}
public async ValueTask DisposeAsync()
{
await _app.StopAsync();
await _app.DisposeAsync();
}
public Uri GetBaseWebUri()
{
return _app.GetEndpoint(ProjectNames.Web, "https");
}
}
@@ -0,0 +1,48 @@
namespace FiscalOS.AppHost.Tests.Infra;
public abstract class BaseTest(
AspireFixture aspire,
PlaywrightFixture playwright
) : IAsyncLifetime, IClassFixture<AspireFixture>, IClassFixture<PlaywrightFixture>
{
protected AspireFixture AspireFixture { get; } = aspire;
protected PlaywrightFixture PlaywrightFixture { get; } = playwright;
protected IBrowserContext Context { get; private set; } = null!;
protected IPage Page { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
Context = await PlaywrightFixture.Browser.NewContextAsync(new()
{
BaseURL = AspireFixture.GetBaseWebUri().ToString(),
});
Page = await Context.NewPageAsync();
}
public async ValueTask DisposeAsync()
{
await Page.CloseAsync();
await Context.CloseAsync();
await Context.DisposeAsync();
GC.SuppressFinalize(this);
}
public static ILocatorAssertions Expect(ILocator locator)
{
return Assertions.Expect(locator);
}
public static IPageAssertions Expect(IPage page)
{
return Assertions.Expect(page);
}
public static IAPIResponseAssertions Expect(IAPIResponse response)
{
return Assertions.Expect(response);
}
}
@@ -0,0 +1,26 @@
namespace FiscalOS.AppHost.Tests.Infra;
public sealed class PlaywrightFixture : IAsyncLifetime
{
private IPlaywright? _playwright;
internal IBrowser Browser { get; set; } = null!;
public async ValueTask InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
Browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = false,
});
}
public async ValueTask DisposeAsync()
{
await Browser.CloseAsync();
await Browser.DisposeAsync();
_playwright?.Dispose();
}
}
@@ -0,0 +1,14 @@
namespace FiscalOS.AppHost.Tests;
public class LoginPageTests(
AspireFixture aspire,
PlaywrightFixture playwright
) : BaseTest(aspire, playwright)
{
[Fact]
public async Task LoginPage_WhenNavigatedTo_ItShouldDisplaysLoginButton()
{
await Page.GotoAsync("/public/login");
await Expect(Page.GetByText("Login")).ToBeVisibleAsync();
}
}
+4
View File
@@ -0,0 +1,4 @@
global using FiscalOS.AppHost.Tests.Infra;
global using Microsoft.Playwright;
global using Microsoft.Playwright.Xunit.v3;
@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}