tests(infra,api): refactor to allow further config of TestApi

This commit is contained in:
Stevan Freeborn
2026-03-09 04:29:01 -05:00
parent df697ed37c
commit 98d56fefd7
12 changed files with 123 additions and 83 deletions
+1 -2
View File
@@ -81,7 +81,6 @@ dotnet_remove_unnecessary_suppression_exclusions = none
# analyzer settings
dotnet_diagnostic.IDE0058.severity = none
dotnet_diagnostic.IDE0053.severity = when_on_single_line:suggestion
dotnet_diagnostic.IDE0100.severity = none
dotnet_diagnostic.CA1515.severity = none
dotnet_diagnostic.CA1848.severity = none
@@ -95,7 +94,7 @@ csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:suggestion
csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
@@ -13,7 +13,10 @@ internal sealed class HttpRequestBuilder
{
}
public static HttpRequestBuilder New() => new();
public static HttpRequestBuilder New()
{
return new();
}
public HttpRequestBuilder WithMethod(HttpMethod method)
{
+68
View File
@@ -2,6 +2,8 @@ namespace FiscalOS.API.Tests.Infra;
public class TestApi : WebApplicationFactory<Program>
{
private readonly List<Action<IWebHostBuilder>> _additionalConfigs = [];
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
@@ -25,5 +27,71 @@ public class TestApi : WebApplicationFactory<Program>
c.AddSingleton<IKeyRing>(TestKeyRing.From);
});
foreach (var config in _additionalConfigs)
{
config(builder);
}
}
public TestApi WithAdditionalConfig(Action<IWebHostBuilder> configuration)
{
var newApi = new TestApi();
newApi._additionalConfigs.AddRange(_additionalConfigs);
newApi._additionalConfigs.Add(configuration);
return newApi;
}
public async Task ExecuteAsync(Func<DbContext, CancellationToken, Task> action, CancellationToken ct)
{
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, ct);
}
public async Task ExecuteAsync(Func<DbContext, CancellationToken, IServiceProvider, Task> action, CancellationToken ct)
{
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, ct, scope.ServiceProvider);
}
public async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, Task<T>> action, CancellationToken ct)
{
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, ct);
}
public async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, IServiceProvider, Task<T>> action, CancellationToken ct)
{
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, ct, scope.ServiceProvider);
}
public async Task EnsureDbCreatedAsync()
{
await ExecuteAsync(static async (context, ct) =>
{
await context.Database.EnsureCreatedAsync(ct);
}, TestContext.Current.CancellationToken);
}
public async Task EnsureDbDeletedAsync()
{
await ExecuteAsync(static async (context, ct) =>
{
await context.Database.EnsureDeletedAsync(ct);
}, TestContext.Current.CancellationToken);
}
public override async ValueTask DisposeAsync()
{
await EnsureDbDeletedAsync();
await base.DisposeAsync();
GC.SuppressFinalize(this);
}
}
@@ -1,3 +1,6 @@
using FiscalOS.Core.Queuing;
using FiscalOS.Infra.Transactions.Plaid;
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
@@ -31,7 +34,6 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
["AccountCurrencyCode"] = ["The AccountCurrencyCode field is required."],
});
}
@@ -124,7 +126,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Add_WhenCalledWithPlaidInstitutionIdThatHasNotBeenAdded_ItShouldReturn400WithProblemDetails()
{
var user = await ExecuteAsync(static async (context, ct, sp) =>
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -161,7 +163,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Add_WhenCalledWithPlaidAccountIdThatHasAlreadyBeenAdded_ItShouldReturn409WithProblemDetails()
{
var (user, institution, account) = await ExecuteAsync(static async (context, ct, sp) =>
var (user, institution, account) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -212,7 +214,18 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Add_WhenCalledWithNewAccount_ItShouldReturn200()
{
var (user, institution) = await ExecuteAsync(async (context, ct, sp) =>
var mockQueue = new Mock<IAsyncQueue<SyncUpdatesQueueItem>>();
await using var testApi = Api
.WithAdditionalConfig(whb =>
{
whb.ConfigureTestServices(s =>
{
s.AddSingleton(mockQueue.Object);
});
});
var (user, institution) = await testApi.ExecuteAsync(async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -256,11 +269,13 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
})
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
var client = testApi.CreateClient();
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedUser = await ExecuteAsync(
var updatedUser = await testApi.ExecuteAsync(
async (context, ct) => await context.Set<User>()
.Include(u => u.Accounts)
.ThenInclude(a => a.Metadata)
@@ -279,12 +294,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
((PlaidAccountMetadata)a.Metadata).PlaidName == newAccountName
);
updatedUser.Accounts.First()
.Balances.Should().ContainSingle(
b => b.AccountId == updatedUser.Accounts.First().Id &&
b.Current == expectedBalance &&
b.Available == expectedBalance &&
b.CurrencyCode == expectedCurrencyCode
);
mockQueue.Verify(m => m.EnqueueAsync(
It.IsAny<SyncUpdatesQueueItem>(),
It.IsAny<CancellationToken>()
), Times.Once());
}
}
@@ -42,7 +42,7 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Login_WhenUserExistsButPasswordIsIncorrect_ItShouldReturn401WithProblemDetails()
{
await ExecuteAsync(static async (context, ct, sp) =>
await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -70,7 +70,7 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie()
{
await ExecuteAsync(static async (context, ct, sp) =>
await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -29,7 +29,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails()
{
var (users, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
var (users, refreshToken) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
@@ -61,7 +61,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeProblemDetails(HttpStatusCode.Forbidden);
var unrevokedTokensCountForUser2 = await ExecuteAsync(
var unrevokedTokensCountForUser2 = await Api.ExecuteAsync(
async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == users[1].Id && t.Revoked == false)
@@ -75,7 +75,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails()
{
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
var (user, refreshToken) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
@@ -108,7 +108,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails()
{
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
var (user, refreshToken) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
@@ -142,7 +142,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[InlineData(-5)]
public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset)
{
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
var (user, refreshToken) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
@@ -174,7 +174,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
await response.Should().BeJsonContentOfType<API.Auth.Refresh.Response>(HttpStatusCode.OK);
var oldRefreshTokenInDb = await ExecuteAsync(
var oldRefreshTokenInDb = await Api.ExecuteAsync(
async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true)
@@ -184,7 +184,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
oldRefreshTokenInDb.Should().NotBeNull();
var newRefreshTokenInDb = await ExecuteAsync(
var newRefreshTokenInDb = await Api.ExecuteAsync(
async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token)
@@ -92,7 +92,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Connect_WhenCalledWithPlaidInstitutionIdThatIsAlreadyConnected_ItShouldReturn409WithProblemDetails()
{
var (user, institution) = await ExecuteAsync(static async (context, ct, sp) =>
var (user, institution) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -136,7 +136,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
{
var plaidInstitutionId = "ins_109508";
var (user, publicToken) = await ExecuteAsync(async (context, ct, sp) =>
var (user, publicToken) = await Api.ExecuteAsync(async (context, ct, sp) =>
{
var plaidClient = sp.GetRequiredService<PlaidClient>();
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
@@ -171,7 +171,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedUser = await ExecuteAsync(
var updatedUser = await Api.ExecuteAsync(
async (context, ct) => await context.Set<User>()
.Include(u => u.Institutions)
.ThenInclude(i => i.Metadata)
@@ -39,7 +39,7 @@ public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task GetAvailable_WhenCalledWithNonExistentInstitutionId_ItShouldReturn404WithProblemDetails()
{
var user = await ExecuteAsync(static async (context, ct, sp) =>
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -65,7 +65,7 @@ public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task GetAvailable_WhenCalledWithConnectedInstitution_ItShouldReturn200WithAvailableAccounts()
{
var (user, institution, expectedAccounts) = await ExecuteAsync(static async (context, ct, sp) =>
var (user, institution, expectedAccounts) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -93,10 +93,10 @@ public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi)
});
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, exchangeTokenResponse.AccessToken, ct);
var institutionMetadata = PlaidInstitutionMetadata.From(
institutionId,
"Some Bank",
encryptedAccessToken,
exchangeTokenResponse.ItemId
institutionId,
"Some Bank",
encryptedAccessToken,
exchangeTokenResponse.ItemId
);
var institution = Institution.From("Some Bank", institutionMetadata);
@@ -36,7 +36,7 @@ public class GetTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Get_WhenCalledByUser_ItShouldReturn200WithListOfInstitutions()
{
var (user, institution) = await ExecuteAsync(static async (context, ct, sp) =>
var (user, institution) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -32,7 +32,7 @@ public class LinkTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Link_WhenCalledWithUserWhoExists_ItShouldReturnLinkToken()
{
var user = await ExecuteAsync(static async (context, ct, sp) =>
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -2,51 +2,17 @@ namespace FiscalOS.API.Tests.Integration;
public abstract class IntegrationTest(TestApi testApi) : IClassFixture<TestApi>, IAsyncLifetime
{
public HttpClient Client => testApi.CreateClient();
protected TestApi Api => testApi;
protected HttpClient Client => testApi.CreateClient();
public async ValueTask InitializeAsync()
{
await ExecuteAsync(static async (context, ct) =>
{
await context.Database.EnsureCreatedAsync(ct);
}, TestContext.Current.CancellationToken);
}
protected async Task ExecuteAsync(Func<DbContext, CancellationToken, Task> action, CancellationToken ct)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, ct);
}
protected async Task ExecuteAsync(Func<DbContext, CancellationToken, IServiceProvider, Task> action, CancellationToken ct)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, ct, scope.ServiceProvider);
}
protected async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, Task<T>> action, CancellationToken ct)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, ct);
}
protected async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, IServiceProvider, Task<T>> action, CancellationToken ct)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, ct, scope.ServiceProvider);
await Api.EnsureDbCreatedAsync();
}
public async ValueTask DisposeAsync()
{
await ExecuteAsync(static async (context, ct) =>
{
await context.Database.EnsureDeletedAsync(ct);
}, TestContext.Current.CancellationToken);
await Api.EnsureDbDeletedAsync();
GC.SuppressFinalize(this);
}
}
@@ -2,12 +2,4 @@ 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();
}
}
}