feat: implement transaction handling and add integration tests for Plaid transaction handlers
This commit is contained in:
@@ -2,13 +2,13 @@ namespace FiscalOS.Core.Transactions;
|
|||||||
|
|
||||||
public sealed class Transaction : Entity
|
public sealed class Transaction : Entity
|
||||||
{
|
{
|
||||||
public Guid UserId { get; init; }
|
public Guid UserId { get; private set; }
|
||||||
public User? User { get; init; }
|
public User? User { get; init; }
|
||||||
|
|
||||||
public Guid AccountId { get; init; }
|
public Guid AccountId { get; init; }
|
||||||
public Account? Account { 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 string Description { get; init; } = string.Empty;
|
||||||
public decimal Amount { get; init; }
|
public decimal Amount { get; init; }
|
||||||
public DateTimeOffset Date { get; init; }
|
public DateTimeOffset Date { get; init; }
|
||||||
@@ -27,7 +27,7 @@ public sealed class Transaction : Entity
|
|||||||
TransactionMetadata metadata
|
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(
|
public static Transaction From(
|
||||||
@@ -39,6 +39,20 @@ public sealed class Transaction : Entity
|
|||||||
DateTimeOffset? date,
|
DateTimeOffset? date,
|
||||||
TransactionMetadata metadata
|
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(merchantName, nameof(merchantName));
|
||||||
ArgumentNullException.ThrowIfNull(description, nameof(description));
|
ArgumentNullException.ThrowIfNull(description, nameof(description));
|
||||||
@@ -55,6 +69,7 @@ public sealed class Transaction : Entity
|
|||||||
|
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
|
Id = id,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
AccountId = accountId,
|
AccountId = accountId,
|
||||||
MerchantName = merchantName,
|
MerchantName = merchantName,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl
|
|||||||
_appDbContext = appDbContext;
|
_appDbContext = appDbContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static PlaidAddedTransactionHandler From(
|
||||||
|
ILogger<PlaidAddedTransactionHandler> logger,
|
||||||
|
AppDbContext appDbContext
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return new(logger, appDbContext);
|
||||||
|
}
|
||||||
|
|
||||||
public static PlaidAddedTransactionHandler From(IServiceProvider serviceProvider)
|
public static PlaidAddedTransactionHandler From(IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
return new(
|
return new(
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
|
|||||||
_appDbContext = appDbContext;
|
_appDbContext = appDbContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static PlaidModifiedTransactionHandler From(
|
||||||
|
ILogger<PlaidModifiedTransactionHandler> logger,
|
||||||
|
AppDbContext appDbContext
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return new(logger, appDbContext);
|
||||||
|
}
|
||||||
|
|
||||||
public static PlaidModifiedTransactionHandler From(IServiceProvider serviceProvider)
|
public static PlaidModifiedTransactionHandler From(IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
return new(
|
return new(
|
||||||
@@ -71,6 +79,7 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
|
|||||||
}
|
}
|
||||||
|
|
||||||
var newTransactionData = Transaction.From(
|
var newTransactionData = Transaction.From(
|
||||||
|
existing.Id,
|
||||||
existing.UserId,
|
existing.UserId,
|
||||||
existing.AccountId,
|
existing.AccountId,
|
||||||
plaidModifiedTransaction.Merchant,
|
plaidModifiedTransaction.Merchant,
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
|
|||||||
_removedHandler = removedHandler;
|
_removedHandler = removedHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static PlaidTransactionProcessor From(
|
||||||
|
IPlaidAddedTransactionHandler addedHandler,
|
||||||
|
IPlaidModifiedTransactionHandler modifiedHandler,
|
||||||
|
IPlaidRemovedTransactionHandler removedHandler
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return new(addedHandler, modifiedHandler, removedHandler);
|
||||||
|
}
|
||||||
|
|
||||||
internal static PlaidTransactionProcessor From(IServiceProvider provider)
|
internal static PlaidTransactionProcessor From(IServiceProvider provider)
|
||||||
{
|
{
|
||||||
return new(
|
return new(
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ internal sealed class PlaidTransactionSyncer : IPlaidTransactionSyncer
|
|||||||
_transactionProcessor = plaidTransactionProcessor;
|
_transactionProcessor = plaidTransactionProcessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static PlaidTransactionSyncer From(
|
||||||
|
ILogger<PlaidTransactionSyncer> logger,
|
||||||
|
IPlaidTransactionService plaidTransactionService,
|
||||||
|
IPlaidTransactionProcessor plaidTransactionProcessor
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return new(logger, plaidTransactionService, plaidTransactionProcessor);
|
||||||
|
}
|
||||||
|
|
||||||
public static PlaidTransactionSyncer From(IServiceProvider serviceProvider)
|
public static PlaidTransactionSyncer From(IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
return new(
|
return new(
|
||||||
|
|||||||
@@ -16,6 +16,45 @@ public abstract class IntegrationTest : IAsyncLifetime
|
|||||||
AppDbContext = new(options, _fileSystem);
|
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()
|
public async ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
await AppDbContext.Database.MigrateAsync();
|
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]
|
[Fact]
|
||||||
public async Task HandleAsync_WhenCalled_ItShouldRemoveTheCorrectTransactions()
|
public async Task HandleAsync_WhenCalled_ItShouldRemoveTheCorrectTransactions()
|
||||||
{
|
{
|
||||||
var user = User.From(
|
var user = await CreateTestUserAsync();
|
||||||
"username",
|
var testTransaction = user.Transactions.First();
|
||||||
"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 transactionToRemove = new Going.Plaid.Entity.RemovedTransaction()
|
var transactionToRemove = new Going.Plaid.Entity.RemovedTransaction()
|
||||||
{
|
{
|
||||||
TransactionId = transactionMetadata.PlaidId,
|
TransactionId = ((PlaidTransactionMetadata)testTransaction.Metadata!).PlaidId,
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _sut.HandleAsync([transactionToRemove], TestContext.Current.CancellationToken);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user