feat: implement transaction handling and add integration tests for Plaid transaction handlers

This commit is contained in:
Stevan Freeborn
2026-03-05 13:58:00 -06:00
parent 0f0ed561c3
commit 66f4046d2e
11 changed files with 298 additions and 37 deletions
+18 -3
View File
@@ -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,
@@ -26,6 +26,14 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl
_appDbContext = appDbContext;
}
public static PlaidAddedTransactionHandler From(
ILogger<PlaidAddedTransactionHandler> logger,
AppDbContext appDbContext
)
{
return new(logger, appDbContext);
}
public static PlaidAddedTransactionHandler From(IServiceProvider serviceProvider)
{
return new(
@@ -26,6 +26,14 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
_appDbContext = appDbContext;
}
public static PlaidModifiedTransactionHandler From(
ILogger<PlaidModifiedTransactionHandler> 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,
@@ -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(
@@ -28,6 +28,15 @@ internal sealed class PlaidTransactionSyncer : IPlaidTransactionSyncer
_transactionProcessor = plaidTransactionProcessor;
}
public static PlaidTransactionSyncer From(
ILogger<PlaidTransactionSyncer> logger,
IPlaidTransactionService plaidTransactionService,
IPlaidTransactionProcessor plaidTransactionProcessor
)
{
return new(logger, plaidTransactionService, plaidTransactionProcessor);
}
public static PlaidTransactionSyncer From(IServiceProvider serviceProvider)
{
return new(
@@ -16,6 +16,45 @@ public abstract class IntegrationTest : IAsyncLifetime
AppDbContext = new(options, _fileSystem);
}
protected async Task<User> 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();
@@ -0,0 +1,79 @@
namespace FiscalOS.Infra.Tests.Integration;
public class PlaidAddedTransactionHandlerTests : IntegrationTest
{
private readonly Mock<ILogger<PlaidAddedTransactionHandler>> _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);
}
}
@@ -0,0 +1,35 @@
namespace FiscalOS.Infra.Tests.Integration;
public class PlaidModifiedTransactionHandlerTests : IntegrationTest
{
private readonly Mock<ILogger<PlaidModifiedTransactionHandler>> _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);
}
}
@@ -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);
@@ -0,0 +1,76 @@
using Going.Plaid.Transactions;
namespace FiscalOS.Infra.Tests.Unit;
public class PlaidTransactionProcessorTests
{
private readonly Mock<IPlaidAddedTransactionHandler> _mockAddedHandler = new();
private readonly Mock<IPlaidModifiedTransactionHandler> _mockModifiedHandler = new();
private readonly Mock<IPlaidRemovedTransactionHandler> _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<Account>(),
It.IsAny<IEnumerable<Going.Plaid.Entity.Transaction>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(numAdded);
_mockModifiedHandler
.Setup(
static m => m.HandleAsync(
It.IsAny<Account>(),
It.IsAny<IEnumerable<Going.Plaid.Entity.Transaction>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(numModified);
_mockRemovedHandler
.Setup(
static m => m.HandleAsync(
It.IsAny<IEnumerable<Going.Plaid.Entity.RemovedTransaction>>(),
It.IsAny<CancellationToken>()
)
)
.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);
}
}
@@ -0,0 +1,13 @@
namespace FiscalOS.Infra.Tests.Unit;
public class PlaidTransactionSyncerTests
{
private readonly Mock<ILogger<PlaidTransactionSyncer>> _mockSyncer = new();
private
private readonly PlaidTransactionSyncer _sut;
public PlaidTransactionSyncer()
{
_sut = PlaidTransactionSyncer.From();
}
}