From 1c0fdc205edcc640b4c486c7085e2bdd35f86887 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:14:51 -0500 Subject: [PATCH] tests(infra.tests): enhance AccountBuilder with metadata handling and expand PlaidTransactionSyncerTests --- .../Data/AccountBuilder.cs | 6 + .../Unit/PlaidTransactionSyncerTests.cs | 290 ++++++++++++++++++ tests/FiscalOS.Infra.Tests/Usings.cs | 4 + 3 files changed, 300 insertions(+) diff --git a/tests/FiscalOS.Infra.Tests/Data/AccountBuilder.cs b/tests/FiscalOS.Infra.Tests/Data/AccountBuilder.cs index 8778e97..3d8e35b 100644 --- a/tests/FiscalOS.Infra.Tests/Data/AccountBuilder.cs +++ b/tests/FiscalOS.Infra.Tests/Data/AccountBuilder.cs @@ -29,6 +29,12 @@ internal sealed class AccountBuilder return this; } + public AccountBuilder WithMetadata(AccountMetadata metadata) + { + _metadata = metadata; + return this; + } + public AccountBuilder WithTransaction(Action? action = null) { var tb = TransactionBuilder.Create(); diff --git a/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionSyncerTests.cs b/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionSyncerTests.cs index 0f20207..1f3e6cc 100644 --- a/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionSyncerTests.cs +++ b/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionSyncerTests.cs @@ -1,5 +1,295 @@ +using FiscalOS.Infra.Tests.Data; + namespace FiscalOS.Infra.Tests.Unit; public class PlaidTransactionSyncerTests { + private readonly Mock> _mockLogger = new(); + private readonly Mock _mockPlaidTransactionService = new(); + private readonly Mock _mockPlaidTransactionProcessor = new(); + private readonly PlaidTransactionSyncer _sut; + + public PlaidTransactionSyncerTests() + { + _sut = PlaidTransactionSyncer.From( + _mockLogger.Object, + _mockPlaidTransactionService.Object, + _mockPlaidTransactionProcessor.Object + ); + } + + [Fact] + public async Task SyncTransactionsForAccountAsync_WhenAccountHasNoPlaidMetadata_ItShouldNotCallService() + { + var account = AccountBuilder.Create() + .WithName("no-plaid") + .WithMetadata(new DummyMetadata()) + .Build(); + + await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None); + + _mockPlaidTransactionService.Verify( + s => s.SyncTransactionsForAccountAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never() + ); + + _mockPlaidTransactionProcessor.Verify( + p => p.ProcessAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never() + ); + } + + [Fact] + public async Task SyncTransactionsForAccountAsync_WhenResponseIsUnsuccessful_ItShouldStopProcessing() + { + var account = AccountBuilder.Create() + .WithName("acct") + .WithMetadata(amb => + { + amb.WithPlaidId("plaid-1") + .WithPlaidName("Plaid One"); + }) + .Build(); + + var response = new TransactionsSyncResponse() + { + RequestId = "req-1", + Error = new() { ErrorMessage = "boom" } + }; + + SetStatusCode(response, HttpStatusCode.InternalServerError); + + _mockPlaidTransactionService + .Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncResponses(response)); + + await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None); + + _mockPlaidTransactionProcessor.Verify( + p => p.ProcessAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task SyncTransactionsForAccountAsync_WhenResponseHasNoAccounts_ItShouldStopProcessing() + { + var account = AccountBuilder.Create() + .WithName("acct") + .WithMetadata(amb => + { + amb.WithPlaidId("plaid-2") + .WithPlaidName("Plaid Two"); + }) + .Build(); + + var response = new TransactionsSyncResponse() + { + RequestId = "req-2", + Accounts = [] + }; + SetStatusCode(response, HttpStatusCode.OK); + + _mockPlaidTransactionService + .Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncResponses(response)); + + await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None); + + _mockPlaidTransactionProcessor.Verify( + p => p.ProcessAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task SyncTransactionsForAccountAsync_WhenResponseMissingMatchingAccount_ItShouldStopProcessing() + { + var account = AccountBuilder.Create() + .WithName("acct") + .WithMetadata(amb => + { + amb.WithPlaidId("plaid-3") + .WithPlaidName("Plaid Three"); + }) + .Build(); + + var response = new TransactionsSyncResponse() + { + RequestId = "req-3", + Accounts = [new() { AccountId = "other" }] + }; + SetStatusCode(response, HttpStatusCode.OK); + + _mockPlaidTransactionService + .Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncResponses(response)); + + await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None); + + _mockPlaidTransactionProcessor.Verify( + p => p.ProcessAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task SyncTransactionsForAccountAsync_WhenProcessorReturnsFalse_ItShouldLeaveCursorUnchangedButAddsBalance() + { + var account = AccountBuilder.Create() + .WithName("acct") + .WithMetadata(amb => + { + amb.WithPlaidId("plaid-4") + .WithPlaidName("Plaid Four"); + }) + .Build(); + + var response = new TransactionsSyncResponse() + { + RequestId = "req-4", + Accounts = [ + new() + { + AccountId = "plaid-4", + Balances = new() { Current = 123.45m, Available = 100.00m, IsoCurrencyCode = "USD" } + } + ], + NextCursor = "next-1" + }; + SetStatusCode(response, HttpStatusCode.OK); + + _mockPlaidTransactionService + .Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncResponses(response)); + + _mockPlaidTransactionProcessor + .Setup(p => p.ProcessAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None); + + account.Balances.Should().HaveCount(1); + ((PlaidAccountMetadata)account.Metadata!).Cursor.Should().BeNull(); + } + + [Fact] + public async Task SyncTransactionsForAccountAsync_WhenProcessorSucceeds_ItShouldUpdateCursorAndAddsBalance() + { + var account = AccountBuilder.Create() + .WithName("acct") + .WithMetadata(amb => + { + amb.WithPlaidId("plaid-5") + .WithPlaidName("Plaid Five"); + }) + .Build(); + + var response = new TransactionsSyncResponse() + { + RequestId = "req-5", + Accounts = [ + new() + { + AccountId = "plaid-5", + Balances = new() { Current = 200m, Available = 150m, IsoCurrencyCode = "EUR" } + } + ], + NextCursor = "cursor-5" + }; + SetStatusCode(response, HttpStatusCode.OK); + + _mockPlaidTransactionService + .Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncResponses(response)); + + _mockPlaidTransactionProcessor + .Setup(p => p.ProcessAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None); + + account.Balances.Should().HaveCount(1); + var balance = account.Balances.Should().ContainSingle().Which; + balance.Current.Should().Be(200m); + balance.Available.Should().Be(150m); + balance.CurrencyCode.Should().Be("EUR"); + ((PlaidAccountMetadata)account.Metadata!).Cursor.Should().Be("cursor-5"); + } + + [Fact] + public async Task SyncTransactionsForAccountAsync_WhenMultipleSuccessfulResponses_ItShouldProcessAllAndUseLastCursor() + { + var account = AccountBuilder.Create() + .WithName("acct") + .WithMetadata(amb => + { + amb.WithPlaidId("plaid-6") + .WithPlaidName("Plaid Six"); + }) + .Build(); + + var r1 = new TransactionsSyncResponse() + { + RequestId = "req-6-1", + Accounts = [ + new() + { + AccountId = "plaid-6", + Balances = new() { Current = 10m, Available = 10m, IsoCurrencyCode = "USD" } + } + ], + NextCursor = "cursor-6-1" + }; + SetStatusCode(r1, HttpStatusCode.OK); + + var r2 = new TransactionsSyncResponse() + { + RequestId = "req-6-2", + Accounts = [ + new() + { + AccountId = "plaid-6", + Balances = new() { Current = 20m, Available = 20m, IsoCurrencyCode = "USD" } + } + ], + NextCursor = "cursor-6-2" + }; + SetStatusCode(r2, HttpStatusCode.OK); + + _mockPlaidTransactionService + .Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncResponses(r1, r2)); + + _mockPlaidTransactionProcessor + .Setup(p => p.ProcessAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None); + + account.Balances.Should().HaveCount(2); + ((PlaidAccountMetadata)account.Metadata!).Cursor.Should().Be("cursor-6-2"); + _mockPlaidTransactionProcessor.Verify(p => p.ProcessAsync(account, It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + private static void SetStatusCode(ResponseBase response, HttpStatusCode statusCode) => + typeof(ResponseBase) + .GetProperty(nameof(ResponseBase.StatusCode))! + .GetSetMethod(nonPublic: true)! + .Invoke(response, [statusCode]); + + private static async IAsyncEnumerable AsyncResponses(params TransactionsSyncResponse[] responses) + { + foreach (var r in responses) + { + yield return r; + } + } + + private sealed class DummyMetadata : AccountMetadata + { + public DummyMetadata() : base("dummy") + { + } + } } \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Usings.cs b/tests/FiscalOS.Infra.Tests/Usings.cs index e022772..1d2c2b9 100644 --- a/tests/FiscalOS.Infra.Tests/Usings.cs +++ b/tests/FiscalOS.Infra.Tests/Usings.cs @@ -1,6 +1,7 @@ global using System.Globalization; global using System.IdentityModel.Tokens.Jwt; global using System.IO.Abstractions; +global using System.Net; global using System.Security.Cryptography; global using System.Text; global using System.Text.Json; @@ -20,6 +21,9 @@ global using FiscalOS.Infra.Tests.Assertions; global using FiscalOS.Infra.Tests.Mocks; global using FiscalOS.Infra.Transactions.Plaid; +global using Going.Plaid; +global using Going.Plaid.Transactions; + global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Authorization.Policy;