From 1a41c3c0b6ddcc1f9c812f9f6d68cd5f9afcfc1c Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:49:12 -0600 Subject: [PATCH] feat(api): implement storing connected insitution in db --- src/FiscalOS.API/Accounts/Connect/Endpoint.cs | 23 +++++- src/FiscalOS.API/Usings.cs | 1 + .../FiscalOS.API.Tests.csproj | 1 + tests/FiscalOS.API.Tests/Infra/TestApi.cs | 8 ++ .../Integration/ConnectTests.cs | 80 +++++++++++++++++-- tests/FiscalOS.API.Tests/Usings.cs | 4 + 6 files changed, 106 insertions(+), 11 deletions(-) diff --git a/src/FiscalOS.API/Accounts/Connect/Endpoint.cs b/src/FiscalOS.API/Accounts/Connect/Endpoint.cs index 099fbdb..e9c237b 100644 --- a/src/FiscalOS.API/Accounts/Connect/Endpoint.cs +++ b/src/FiscalOS.API/Accounts/Connect/Endpoint.cs @@ -1,3 +1,5 @@ +using FiscalOS.Core.Accounts; + namespace FiscalOS.API.Accounts.Connect; internal static class Endpoint @@ -12,7 +14,10 @@ internal static class Endpoint private static async Task HandleAsync( HttpContext httpContext, [FromBody] Request request, - [FromServices] AppDbContext appDbContext + [FromServices] AppDbContext appDbContext, + [FromServices] PlaidService plaidService, + [FromServices] IEncryptor encryptor, + CancellationToken ct ) { var userId = httpContext.GetUserId(); @@ -25,7 +30,7 @@ internal static class Endpoint ) ) .ThenInclude(i => i.Metadata) - .SingleOrDefaultAsync(u => u.Id == userId); + .SingleOrDefaultAsync(u => u.Id == userId, ct); if (user is null) { @@ -41,10 +46,22 @@ internal static class Endpoint return Results.Problem( statusCode: StatusCodes.Status409Conflict, title: "Institution already connected", - detail: "The user has already connected an institution with the provided Plaid Institution ID." + detail: "The user has already connected an institution with the provided Plaid Institution Id." ); } + var (itemId, accessToken) = await plaidService.ExchangeTokenAsync(request.PublicToken); + var item = await plaidService.GetItemAsync(accessToken); + + var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, accessToken, ct); + + var plaidMetadata = PlaidMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken); + var institution = Institution.From(item.InstitutionName, plaidMetadata); + + user.AddInstitution(institution); + + await appDbContext.SaveChangesAsync(ct); + return Results.Ok(); } } \ No newline at end of file diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index 3ff8349..ca2eeef 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -8,6 +8,7 @@ global using FiscalOS.API.Http; global using FiscalOS.API.Login; global using FiscalOS.API.Refresh; global using FiscalOS.Core.Authentication; +global using FiscalOS.Core.Security; global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Data; diff --git a/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj b/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj index b3e51ef..b8cae2a 100644 --- a/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj +++ b/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/FiscalOS.API.Tests/Infra/TestApi.cs b/tests/FiscalOS.API.Tests/Infra/TestApi.cs index 8df1981..4b25baf 100644 --- a/tests/FiscalOS.API.Tests/Infra/TestApi.cs +++ b/tests/FiscalOS.API.Tests/Infra/TestApi.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; + namespace FiscalOS.API.Tests.Infra; public class TestApi : WebApplicationFactory @@ -8,6 +10,12 @@ public class TestApi : WebApplicationFactory builder.ConfigureLogging(static c => c.ClearProviders()); + builder.ConfigureAppConfiguration(static c => + { + var testConfigPath = Path.Combine(AppContext.BaseDirectory, "appsettings.Test.json"); + c.AddJsonFile(testConfigPath, optional: false); + }); + builder.ConfigureTestServices(static c => { c.AddSingleton(Options.Create(new AppDbContextOptions() diff --git a/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs b/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs index d3df9d7..22af92f 100644 --- a/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs @@ -1,5 +1,4 @@ -using FiscalOS.Core.Accounts; -using FiscalOS.Infra.Accounts.Plaid; +using Institution = FiscalOS.Core.Accounts.Institution; namespace FiscalOS.API.Tests.Integration; @@ -111,24 +110,24 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Connect_WhenCalledWithPlaidInstitutionIdThatIsAlreadyConnected_ItShouldReturn409WithProblemDetails() { - var (user, institution) = await ExecuteDbContextAsync(static async (context, sp) => + var (user, institution) = await ExecuteAsync(static async (context, ct, sp) => { var passwordHasher = sp.GetRequiredService(); var encryptor = sp.GetRequiredService(); - var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct); var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); - var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", TestContext.Current.CancellationToken); + 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, TestContext.Current.CancellationToken); - await context.SaveChangesAsync(TestContext.Current.CancellationToken); + await context.AddAsync(user, ct); + await context.SaveChangesAsync(ct); return (user, institution); - }); + }, TestContext.Current.CancellationToken); var jwt = JwtTokenBuilder.New() .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) @@ -150,4 +149,69 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) await response.Should().BeProblemDetails(HttpStatusCode.Conflict); } + + [Fact] + public async Task Connect_WhenCalledWithInstitutionThatIsNotConnected_ItShouldReturn200() + { + var plaidInstitutionId = "ins_109508"; + + var (user, publicToken) = await ExecuteAsync(async (context, ct, sp) => + { + var plaidClient = sp.GetRequiredService(); + var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); + + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct); + var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); + + await context.AddAsync(user, ct); + await context.SaveChangesAsync(ct); + + var publicToken = await plaidClient.SandboxPublicTokenCreateAsync(new() + { + InstitutionId = plaidInstitutionId, + InitialProducts = [Products.Transactions], + }); + + return (user, publicToken.PublicToken); + }, TestContext.Current.CancellationToken); + + var jwt = JwtTokenBuilder.New() + .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) + .Build(); + + var json = JsonSerializer.Serialize(new + { + publicToken, + plaidInstitutionId, + }); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Post, ConnectUri) + { + Content = content, + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var updatedUser = await ExecuteAsync( + async (context, ct) => await context.Set() + .Include(u => u.Institutions) + .ThenInclude(i => i.Metadata) + .SingleAsync(u => u.Id == user.Id, ct), + TestContext.Current.CancellationToken + ); + + updatedUser.Institutions.Should().HaveCount(1); + + var institution = updatedUser.Institutions.First(); + institution.Name.Should().NotBeNullOrEmpty(); + + var metadata = institution.Metadata.As(); + metadata.PlaidId.Should().Be(plaidInstitutionId); + metadata.PlaidName.Should().Be(institution.Name); + metadata.EncryptedAccessToken.Should().NotBeNullOrEmpty(); + } } \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Usings.cs b/tests/FiscalOS.API.Tests/Usings.cs index e9f8ff4..b0e17c9 100644 --- a/tests/FiscalOS.API.Tests/Usings.cs +++ b/tests/FiscalOS.API.Tests/Usings.cs @@ -15,9 +15,13 @@ global using FiscalOS.API.Tests.Infra; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Identity; global using FiscalOS.Core.Security; +global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Data; +global using Going.Plaid; +global using Going.Plaid.Entity; + global using Microsoft.AspNetCore.Hosting; global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Mvc.Testing;