From 66f4046d2e1f8234e884294eadd156f78ca84f6a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:58:00 -0600 Subject: [PATCH] feat: implement transaction handling and add integration tests for Plaid transaction handlers --- src/FiscalOS.Core/Transactions/Transaction.cs | 21 ++++- .../Plaid/PlaidAddedTransactionHandler.cs | 8 ++ .../Plaid/PlaidModifiedTransactionHandler.cs | 9 +++ .../Plaid/PlaidTransactionProcessor.cs | 9 +++ .../Plaid/PlaidTransactionSyncer.cs | 9 +++ .../Integration/IntegrationTest.cs | 39 +++++++++ .../PlaidAddedTransactionHandlerTests.cs | 79 +++++++++++++++++++ .../PlaidModifiedTransactionHandlerTests.cs | 35 ++++++++ .../PlaidRemovedTransactionHandlerTests.cs | 37 +-------- .../Unit/PlaidTransactionProcessorTests.cs | 76 ++++++++++++++++++ .../Unit/PlaidTransactionSyncerTests.cs | 13 +++ 11 files changed, 298 insertions(+), 37 deletions(-) create mode 100644 tests/FiscalOS.Infra.Tests/Integration/PlaidAddedTransactionHandlerTests.cs create mode 100644 tests/FiscalOS.Infra.Tests/Integration/PlaidModifiedTransactionHandlerTests.cs create mode 100644 tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionProcessorTests.cs create mode 100644 tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionSyncerTests.cs diff --git a/src/FiscalOS.Core/Transactions/Transaction.cs b/src/FiscalOS.Core/Transactions/Transaction.cs index 6ad05e1..e58ffd4 100644 --- a/src/FiscalOS.Core/Transactions/Transaction.cs +++ b/src/FiscalOS.Core/Transactions/Transaction.cs @@ -2,13 +2,13 @@ namespace FiscalOS.Core.Transactions; public sealed class Transaction : Entity { - public Guid UserId { get; init; } + public Guid UserId { get; private set; } public User? User { get; init; } public Guid AccountId { get; init; } public Account? Account { get; init; } - public string MerchantName { get; init; } = string.Empty; + public string MerchantName { get; private set; } = string.Empty; public string Description { get; init; } = string.Empty; public decimal Amount { get; init; } public DateTimeOffset Date { get; init; } @@ -27,7 +27,7 @@ public sealed class Transaction : Entity TransactionMetadata metadata ) { - return From(Guid.Empty, Guid.Empty, merchantName, description, amount, date, metadata); + return From(Guid.Empty, Guid.Empty, Guid.Empty, merchantName, description, amount, date, metadata); } public static Transaction From( @@ -39,6 +39,20 @@ public sealed class Transaction : Entity DateTimeOffset? date, TransactionMetadata metadata ) + { + return From(Guid.Empty, userId, accountId, merchantName, description, amount, date, metadata); + } + + public static Transaction From( + Guid id, + Guid userId, + Guid accountId, + string merchantName, + string description, + decimal? amount, + DateTimeOffset? date, + TransactionMetadata metadata + ) { ArgumentNullException.ThrowIfNull(merchantName, nameof(merchantName)); ArgumentNullException.ThrowIfNull(description, nameof(description)); @@ -55,6 +69,7 @@ public sealed class Transaction : Entity return new() { + Id = id, UserId = userId, AccountId = accountId, MerchantName = merchantName, diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs index 8e99cbc..7b960aa 100644 --- a/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs @@ -26,6 +26,14 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl _appDbContext = appDbContext; } + public static PlaidAddedTransactionHandler From( + ILogger logger, + AppDbContext appDbContext + ) + { + return new(logger, appDbContext); + } + public static PlaidAddedTransactionHandler From(IServiceProvider serviceProvider) { return new( diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs index 97ace41..c523e54 100644 --- a/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs @@ -26,6 +26,14 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio _appDbContext = appDbContext; } + public static PlaidModifiedTransactionHandler From( + ILogger logger, + AppDbContext appDbContext + ) + { + return new(logger, appDbContext); + } + public static PlaidModifiedTransactionHandler From(IServiceProvider serviceProvider) { return new( @@ -71,6 +79,7 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio } var newTransactionData = Transaction.From( + existing.Id, existing.UserId, existing.AccountId, plaidModifiedTransaction.Merchant, diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionProcessor.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionProcessor.cs index 5c648ed..0d09e03 100644 --- a/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionProcessor.cs +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionProcessor.cs @@ -28,6 +28,15 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor _removedHandler = removedHandler; } + internal static PlaidTransactionProcessor From( + IPlaidAddedTransactionHandler addedHandler, + IPlaidModifiedTransactionHandler modifiedHandler, + IPlaidRemovedTransactionHandler removedHandler + ) + { + return new(addedHandler, modifiedHandler, removedHandler); + } + internal static PlaidTransactionProcessor From(IServiceProvider provider) { return new( diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionSyncer.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionSyncer.cs index c4eefff..95e88eb 100644 --- a/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionSyncer.cs +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionSyncer.cs @@ -28,6 +28,15 @@ internal sealed class PlaidTransactionSyncer : IPlaidTransactionSyncer _transactionProcessor = plaidTransactionProcessor; } + public static PlaidTransactionSyncer From( + ILogger logger, + IPlaidTransactionService plaidTransactionService, + IPlaidTransactionProcessor plaidTransactionProcessor + ) + { + return new(logger, plaidTransactionService, plaidTransactionProcessor); + } + public static PlaidTransactionSyncer From(IServiceProvider serviceProvider) { return new( diff --git a/tests/FiscalOS.Infra.Tests/Integration/IntegrationTest.cs b/tests/FiscalOS.Infra.Tests/Integration/IntegrationTest.cs index 6ca1dda..d741651 100644 --- a/tests/FiscalOS.Infra.Tests/Integration/IntegrationTest.cs +++ b/tests/FiscalOS.Infra.Tests/Integration/IntegrationTest.cs @@ -16,6 +16,45 @@ public abstract class IntegrationTest : IAsyncLifetime AppDbContext = new(options, _fileSystem); } + protected async Task CreateTestUserAsync() + { + var user = User.From( + "username", + "hashedPassword", + EncryptedDataKey.From("keyUsed", "encryptedKey") + ); + + var institutionMetadata = PlaidInstitutionMetadata.From( + "plaidId", + "plaidName", + "encryptedAccessToken", + "itemId" + ); + var institution = Institution.From("institutionName", institutionMetadata); + user.AddInstitution(institution); + + var accountMetadata = PlaidAccountMetadata.From("plaidId", "plaidName"); + var account = Account.From("some account", accountMetadata); + user.AddAccount(account); + institution.AddAccount(account); + + var transactionMetadata = PlaidTransactionMetadata.From("plaidId"); + var transaction = Transaction.From( + "merchantName", + "description", + 100, + DateTimeOffset.UtcNow, + transactionMetadata + ); + user.AddTransaction(transaction); + account.AddTransaction(transaction); + + await AppDbContext.AddAsync(user, TestContext.Current.CancellationToken); + await AppDbContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + return user; + } + public async ValueTask InitializeAsync() { await AppDbContext.Database.MigrateAsync(); diff --git a/tests/FiscalOS.Infra.Tests/Integration/PlaidAddedTransactionHandlerTests.cs b/tests/FiscalOS.Infra.Tests/Integration/PlaidAddedTransactionHandlerTests.cs new file mode 100644 index 0000000..c136a09 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Integration/PlaidAddedTransactionHandlerTests.cs @@ -0,0 +1,79 @@ +namespace FiscalOS.Infra.Tests.Integration; + +public class PlaidAddedTransactionHandlerTests : IntegrationTest +{ + private readonly Mock> _mockLogger = new(); + private readonly PlaidAddedTransactionHandler _sut; + + public PlaidAddedTransactionHandlerTests() + { + _sut = PlaidAddedTransactionHandler.From(_mockLogger.Object, AppDbContext); + } + + [Fact] + public async Task HandleAsync_WhenCalledWithNewTransaction_ItShouldAddTheTransaction() + { + var user = await CreateTestUserAsync(); + var account = user.Accounts.First(); + + var transaction = new Going.Plaid.Entity.Transaction() + { + TransactionId = Guid.NewGuid().ToString(), + MerchantName = "Test Merchant", + OriginalDescription = "Test transaction", + Amount = 100, + Datetime = DateTimeOffset.UtcNow, + Pending = false, + }; + + var result = await _sut.HandleAsync(account, [transaction], TestContext.Current.CancellationToken); + + result.Should().Be(1); + account.Transactions.Should().HaveCount(2); + } + + [Fact] + public async Task HandleAsync_WhenCalledWithPendingTransaction_ItShouldSkipTheTransaction() + { + var user = await CreateTestUserAsync(); + var account = user.Accounts.First(); + + var transaction = new Going.Plaid.Entity.Transaction() + { + TransactionId = Guid.NewGuid().ToString(), + MerchantName = "Test Merchant", + OriginalDescription = "Test transaction", + Amount = 100, + Datetime = DateTimeOffset.UtcNow, + Pending = true, + }; + + var result = await _sut.HandleAsync(account, [transaction], TestContext.Current.CancellationToken); + + result.Should().Be(1); + account.Transactions.Should().HaveCount(1); + } + + [Fact] + public async Task HandleAsync_WhenCalledWithExistingTransaction_ItShouldNotAddTheTransactionToTheDatabase() + { + var user = await CreateTestUserAsync(); + var account = user.Accounts.First(); + var existingTransaction = user.Transactions.First(); + + var transaction = new Going.Plaid.Entity.Transaction() + { + TransactionId = ((PlaidTransactionMetadata)existingTransaction.Metadata!).PlaidId, + MerchantName = "Test Merchant", + OriginalDescription = "Test transaction", + Amount = 100, + Datetime = DateTimeOffset.UtcNow, + Pending = false, + }; + + var result = await _sut.HandleAsync(account, [transaction], TestContext.Current.CancellationToken); + + result.Should().Be(1); + account.Transactions.Should().HaveCount(1); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Integration/PlaidModifiedTransactionHandlerTests.cs b/tests/FiscalOS.Infra.Tests/Integration/PlaidModifiedTransactionHandlerTests.cs new file mode 100644 index 0000000..7023198 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Integration/PlaidModifiedTransactionHandlerTests.cs @@ -0,0 +1,35 @@ +namespace FiscalOS.Infra.Tests.Integration; + +public class PlaidModifiedTransactionHandlerTests : IntegrationTest +{ + private readonly Mock> _mockLogger = new(); + private readonly PlaidModifiedTransactionHandler _sut; + + public PlaidModifiedTransactionHandlerTests() + { + _sut = PlaidModifiedTransactionHandler.From(_mockLogger.Object, AppDbContext); + } + + [Fact] + public async Task HandleAsync_WhenCalledWithExistingTransaction_ItShouldUpdateTheTransaction() + { + var user = await CreateTestUserAsync(); + var account = user.Accounts.First(); + var existingTransaction = account.Transactions.First(); + + var transaction = new Going.Plaid.Entity.Transaction() + { + TransactionId = ((PlaidTransactionMetadata)existingTransaction.Metadata!).PlaidId, + MerchantName = Guid.NewGuid().ToString(), + OriginalDescription = "Test transaction", + Amount = 100, + Datetime = DateTimeOffset.UtcNow, + Pending = false, + }; + + var result = await _sut.HandleAsync(account, [transaction], TestContext.Current.CancellationToken); + + result.Should().Be(1); + existingTransaction.MerchantName.Should().Be(transaction.MerchantName); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Integration/PlaidRemovedTransactionHandlerTests.cs b/tests/FiscalOS.Infra.Tests/Integration/PlaidRemovedTransactionHandlerTests.cs index e545a42..02e31ac 100644 --- a/tests/FiscalOS.Infra.Tests/Integration/PlaidRemovedTransactionHandlerTests.cs +++ b/tests/FiscalOS.Infra.Tests/Integration/PlaidRemovedTransactionHandlerTests.cs @@ -13,43 +13,12 @@ public class PlaidRemovedTransactionHandlerTests : IntegrationTest [Fact] public async Task HandleAsync_WhenCalled_ItShouldRemoveTheCorrectTransactions() { - var user = User.From( - "username", - "hashedPassword", - EncryptedDataKey.From("keyUsed", "encryptedKey") - ); - - var institutionMetadata = PlaidInstitutionMetadata.From( - "plaidId", - "plaidName", - "encryptedAccessToken", - "itemId" - ); - var institution = Institution.From("institutionName", institutionMetadata); - user.AddInstitution(institution); - - var accountMetadata = PlaidAccountMetadata.From("plaidId", "plaidName"); - var account = Account.From("some account", accountMetadata); - user.AddAccount(account); - institution.AddAccount(account); - - var transactionMetadata = PlaidTransactionMetadata.From("plaidId"); - var transaction = Transaction.From( - "merchantName", - "description", - 100, - DateTimeOffset.UtcNow, - transactionMetadata - ); - user.AddTransaction(transaction); - account.AddTransaction(transaction); - - await AppDbContext.AddAsync(user, TestContext.Current.CancellationToken); - await AppDbContext.SaveChangesAsync(TestContext.Current.CancellationToken); + var user = await CreateTestUserAsync(); + var testTransaction = user.Transactions.First(); var transactionToRemove = new Going.Plaid.Entity.RemovedTransaction() { - TransactionId = transactionMetadata.PlaidId, + TransactionId = ((PlaidTransactionMetadata)testTransaction.Metadata!).PlaidId, }; var result = await _sut.HandleAsync([transactionToRemove], TestContext.Current.CancellationToken); diff --git a/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionProcessorTests.cs b/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionProcessorTests.cs new file mode 100644 index 0000000..fd7fc6f --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionProcessorTests.cs @@ -0,0 +1,76 @@ +using Going.Plaid.Transactions; + +namespace FiscalOS.Infra.Tests.Unit; + +public class PlaidTransactionProcessorTests +{ + private readonly Mock _mockAddedHandler = new(); + private readonly Mock _mockModifiedHandler = new(); + private readonly Mock _mockRemovedHandler = new(); + private readonly PlaidTransactionProcessor _sut; + + public PlaidTransactionProcessorTests() + { + _sut = PlaidTransactionProcessor.From( + _mockAddedHandler.Object, + _mockModifiedHandler.Object, + _mockRemovedHandler.Object + ); + } + + [Theory] + [InlineData(0, 0, 0, false)] + [InlineData(1, 1, 1, true)] + [InlineData(0, 1, 1, false)] + [InlineData(1, 0, 1, false)] + [InlineData(1, 1, 0, false)] + public async Task ProcessAsync_WhenCalledAndTransactionsAreHandled_ItShouldReturnCorrectResult( + int numAdded, + int numModified, + int numRemoved, + bool expectedResult + ) + { + _mockAddedHandler + .Setup( + static m => m.HandleAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(numAdded); + + _mockModifiedHandler + .Setup( + static m => m.HandleAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(numModified); + + _mockRemovedHandler + .Setup( + static m => m.HandleAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(numRemoved); + + var metadata = PlaidAccountMetadata.From("plaidId", "plaidName"); + var account = Account.From("name", metadata); + var syncResponse = new TransactionsSyncResponse() + { + Added = [new()], + Modified = [new()], + Removed = [new()], + }; + + var result = await _sut.ProcessAsync(account, syncResponse, TestContext.Current.CancellationToken); + + result.Should().Be(expectedResult); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionSyncerTests.cs b/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionSyncerTests.cs new file mode 100644 index 0000000..36904f2 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/PlaidTransactionSyncerTests.cs @@ -0,0 +1,13 @@ +namespace FiscalOS.Infra.Tests.Unit; + +public class PlaidTransactionSyncerTests +{ + private readonly Mock> _mockSyncer = new(); + private + private readonly PlaidTransactionSyncer _sut; + + public PlaidTransactionSyncer() + { + _sut = PlaidTransactionSyncer.From(); + } +}