tests(infra.tests): enhance AccountBuilder with metadata handling and expand PlaidTransactionSyncerTests

This commit is contained in:
Stevan Freeborn
2026-03-09 04:29:01 -05:00
parent 98d56fefd7
commit 76df36b18f
3 changed files with 300 additions and 0 deletions
@@ -29,6 +29,12 @@ internal sealed class AccountBuilder
return this;
}
public AccountBuilder WithMetadata(AccountMetadata metadata)
{
_metadata = metadata;
return this;
}
public AccountBuilder WithTransaction(Action<TransactionBuilder>? action = null)
{
var tb = TransactionBuilder.Create();
@@ -1,5 +1,295 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Unit;
public class PlaidTransactionSyncerTests
{
private readonly Mock<ILogger<PlaidTransactionSyncer>> _mockLogger = new();
private readonly Mock<IPlaidTransactionService> _mockPlaidTransactionService = new();
private readonly Mock<IPlaidTransactionProcessor> _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<string>(), It.IsAny<string>(), It.IsAny<string?>()),
Times.Never()
);
_mockPlaidTransactionProcessor.Verify(
p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()),
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<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
_mockPlaidTransactionProcessor.Verify(
p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()),
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<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
_mockPlaidTransactionProcessor.Verify(
p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()),
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<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
_mockPlaidTransactionProcessor.Verify(
p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()),
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<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
_mockPlaidTransactionProcessor
.Setup(p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
_mockPlaidTransactionProcessor
.Setup(p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(r1, r2));
_mockPlaidTransactionProcessor
.Setup(p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()))
.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<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()), 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<TransactionsSyncResponse> AsyncResponses(params TransactionsSyncResponse[] responses)
{
foreach (var r in responses)
{
yield return r;
}
}
private sealed class DummyMetadata : AccountMetadata
{
public DummyMetadata() : base("dummy")
{
}
}
}
+4
View File
@@ -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;