diff --git a/AGENTS.md b/AGENTS.md index d09c707..5f1f575 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,11 @@ # BookStore — Agent Instructions ## Purpose + Use this file for agent-only context: build and test commands, conventions, and project patterns. For human-facing details, see README and docs. ## Quick Reference + - **Stack**: .NET 10, C# 14, Marten, Wolverine, HybridCache, Aspire - **Solution**: `BookStore.slnx` (new .NET 10 solution format) - **Common commands**: `dotnet restore`, `aspire run`, `dotnet test`, `dotnet format` @@ -11,6 +13,7 @@ Use this file for agent-only context: build and test commands, conventions, and - **Testing instructions**: `tests/AGENTS.md` ## Repository Map + - `src/BookStore.ApiService/`: Event-sourced API (Marten + Wolverine) - `src/BookStore.Web/`: Blazor frontend - `src/BookStore.AppHost/`: Aspire orchestration @@ -21,6 +24,7 @@ Use this file for agent-only context: build and test commands, conventions, and - `docs/`: architecture and guide material ## Major Patterns + - Modular monolith with event sourcing and CQRS - Wolverine command/handler write model and async projections - Marten projections for read models @@ -29,6 +33,7 @@ Use this file for agent-only context: build and test commands, conventions, and - Multi-tenancy with tenant-aware routing and storage ## Development Process (TDD) + 1. Define verification (test, command, or browser check) 2. Write verification first 3. Implement @@ -38,7 +43,8 @@ Use this file for agent-only context: build and test commands, conventions, and **A feature is not complete until `dotnet format` has been executed successfully.** ## Code Rules (MUST follow) -``` + +```text ✅ Guid.CreateVersion7() ❌ Guid.NewGuid() ✅ DateTimeOffset.UtcNow ❌ DateTime.Now ✅ record BookAdded(...) ❌ record AddBook(...) @@ -48,6 +54,7 @@ Use this file for agent-only context: build and test commands, conventions, and ``` ## Conventions and Style + - Use `record` for DTOs/Commands/Events; events are past tense - File-scoped namespaces only - Follow `.editorconfig` and analyzer rules in `docs/guides/analyzer-rules.md` @@ -55,17 +62,20 @@ Use this file for agent-only context: build and test commands, conventions, and - Shared build settings live in `Directory.Build.props` ## Common Mistakes + - Business logic in endpoints -> put it in aggregates/handlers - Missing SSE notification -> add to `MartenCommitListener` - Missing cache invalidation -> call `RemoveByTagAsync` after mutations ## Quick Troubleshooting + - Build failures: check BS1xxx-BS4xxx analyzer errors first - SSE not working: run `/frontend__debug_sse` - Cache issues: run `/cache__debug_cache` - Environment issues: run `/ops__doctor_check` ## Documentation Index + - Setup: `docs/getting-started.md` - Architecture: `docs/architecture.md` - Event sourcing: `docs/guides/event-sourcing-guide.md` diff --git a/tests/BookStore.AppHost.Tests/AccountIsolationTests.cs b/tests/BookStore.AppHost.Tests/AccountIsolationTests.cs index ef5db1f..9e4dfb7 100644 --- a/tests/BookStore.AppHost.Tests/AccountIsolationTests.cs +++ b/tests/BookStore.AppHost.Tests/AccountIsolationTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -35,19 +36,19 @@ public static async Task ClassSetup() opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; }); - await TestHelpers.SeedTenantAsync(store, "acme"); - await TestHelpers.SeedTenantAsync(store, "contoso"); + await DatabaseHelpers.SeedTenantAsync(store, "acme"); + await DatabaseHelpers.SeedTenantAsync(store, "contoso"); } [Test] public async Task User_RegisteredOnContoso_CannotLoginOnAcme() { // Arrange: Create a unique user email for this test - var userEmail = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); - var contosoClient = RestService.For(TestHelpers.GetUnauthenticatedClient("contoso")); - var acmeClient = RestService.For(TestHelpers.GetUnauthenticatedClient("acme")); + var contosoClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("contoso")); + var acmeClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("acme")); // Act 1: Register user on Contoso tenant _ = await contosoClient.RegisterAsync(new RegisterRequest(userEmail, password)); @@ -64,10 +65,10 @@ public async Task User_RegisteredOnContoso_CannotLoginOnAcme() public async Task User_RegisteredOnContoso_CanLoginOnContoso() { // Arrange: Create a unique user email for this test - var userEmail = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); - var contosoClient = RestService.For(TestHelpers.GetUnauthenticatedClient("contoso")); + var contosoClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("contoso")); // Act 1: Register user on Contoso tenant _ = await contosoClient.RegisterAsync(new RegisterRequest(userEmail, password)); @@ -84,11 +85,11 @@ public async Task User_RegisteredOnContoso_CanLoginOnContoso() public async Task User_RegisteredOnAcme_CannotLoginOnContoso() { // Arrange: Create a unique user email for this test - var userEmail = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); - var acmeClient = RestService.For(TestHelpers.GetUnauthenticatedClient("acme")); - var contosoClient = RestService.For(TestHelpers.GetUnauthenticatedClient("contoso")); + var acmeClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("acme")); + var contosoClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("contoso")); // Act 1: Register user on Acme tenant _ = await acmeClient.RegisterAsync(new RegisterRequest(userEmail, password)); @@ -105,11 +106,11 @@ public async Task User_RegisteredOnAcme_CannotLoginOnContoso() public async Task User_RegisteredOnDefault_CannotLoginOnAcme() { // Arrange: Create a unique user email for this test - var userEmail = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); - var defaultClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); - var acmeClient = RestService.For(TestHelpers.GetUnauthenticatedClient("acme")); + var defaultClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); + var acmeClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("acme")); // Act 1: Register user on Default tenant (no X-Tenant-ID header) _ = await defaultClient.RegisterAsync(new RegisterRequest(userEmail, password)); diff --git a/tests/BookStore.AppHost.Tests/AdminTenantTests.cs b/tests/BookStore.AppHost.Tests/AdminTenantTests.cs index 8e8bac6..2941ae7 100644 --- a/tests/BookStore.AppHost.Tests/AdminTenantTests.cs +++ b/tests/BookStore.AppHost.Tests/AdminTenantTests.cs @@ -1,5 +1,6 @@ using System.Net; using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx.Events; @@ -20,7 +21,7 @@ public async Task CreateTenant_WithInvalidPassword_ReturnsBadRequest() } var client = - RestService.For(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); // Arrange var command = new CreateTenantCommand( @@ -37,7 +38,7 @@ public async Task CreateTenant_WithInvalidPassword_ReturnsBadRequest() var exception = await Assert.That(async () => await client.CreateTenantAsync(command)).Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); - var error = await exception.GetContentAsAsync(); + var error = await exception.GetContentAsAsync(); _ = await Assert.That(error?.Error).IsEqualTo(ErrorCodes.Tenancy.InvalidAdminPassword); } @@ -50,7 +51,7 @@ public async Task CreateTenant_WithInvalidEmail_ReturnsBadRequest() } var client = - RestService.For(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); // Arrange var command = new CreateTenantCommand( @@ -60,14 +61,14 @@ public async Task CreateTenant_WithInvalidEmail_ReturnsBadRequest() ThemePrimaryColor: "#ff0000", IsEnabled: true, AdminEmail: "invalid-email", // Invalid email - AdminPassword: TestHelpers.GenerateFakePassword() + AdminPassword: FakeDataGenerators.GenerateFakePassword() ); // Act & Assert var exception = await Assert.That(async () => await client.CreateTenantAsync(command)).Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); - var error = await exception.GetContentAsAsync(); + var error = await exception.GetContentAsAsync(); _ = await Assert.That(error?.Error).IsEqualTo(ErrorCodes.Tenancy.InvalidAdminEmail); } @@ -80,7 +81,7 @@ public async Task CreateTenant_WithValidRequest_ReturnsCreated() } var client = - RestService.For(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); // Arrange var tenantId = $"valid-tenant-{Guid.NewGuid():N}"; @@ -90,8 +91,8 @@ public async Task CreateTenant_WithValidRequest_ReturnsCreated() Tagline: "Testing valid creation", ThemePrimaryColor: "#00ff00", IsEnabled: true, - AdminEmail: TestHelpers.GenerateFakeEmail(), - AdminPassword: TestHelpers.GenerateFakePassword() // Valid password + AdminEmail: FakeDataGenerators.GenerateFakeEmail(), + AdminPassword: FakeDataGenerators.GenerateFakePassword() // Valid password ); // Act @@ -107,11 +108,11 @@ public async Task CreateTenant_WithEmailVerification_CreatesUnconfirmedUser() } var client = - RestService.For(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); // Arrange var tenantId = $"verify-tenant-{Guid.NewGuid():N}"; - var adminEmail = TestHelpers.GenerateFakeEmail(); + var adminEmail = FakeDataGenerators.GenerateFakeEmail(); var command = new CreateTenantCommand( Id: tenantId, Name: "Verify Tenant", @@ -119,12 +120,12 @@ public async Task CreateTenant_WithEmailVerification_CreatesUnconfirmedUser() ThemePrimaryColor: "#0000ff", IsEnabled: true, AdminEmail: adminEmail, - AdminPassword: TestHelpers.GenerateFakePassword() + AdminPassword: FakeDataGenerators.GenerateFakePassword() ); // Act & Assert - Connect to SSE before creating, then wait for notification // Creation triggers UserUpdated via UserProfile projection - var received = await TestHelpers.ExecuteAndWaitForEventAsync( + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( Guid.Empty, "UserUpdated", async () => await client.CreateTenantAsync(command), diff --git a/tests/BookStore.AppHost.Tests/AdminUserTests.cs b/tests/BookStore.AppHost.Tests/AdminUserTests.cs index 9f62617..adb6704 100644 --- a/tests/BookStore.AppHost.Tests/AdminUserTests.cs +++ b/tests/BookStore.AppHost.Tests/AdminUserTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -21,8 +22,8 @@ public async Task Setup() public async Task GetUsers_ReturnsListOfUsers() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); // Act var result = await client.GetUsersAsync(); @@ -42,14 +43,14 @@ public async Task GetUsers_ReturnsListOfUsers() public async Task PromoteUser_SucceedsForOtherUser() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); var identityClient = - RestService.For(TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + RestService.For(HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); // Create a regular user - var userEmail = TestHelpers.GenerateFakeEmail(); - _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, TestHelpers.GenerateFakePassword())); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, FakeDataGenerators.GenerateFakePassword())); var result = await client.GetUsersAsync(search: userEmail); var users = result.Items; @@ -69,8 +70,8 @@ public async Task PromoteUser_SucceedsForOtherUser() public async Task PromoteSelf_ReturnsBadRequest() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); var result = await client.GetUsersAsync(); var users = result.Items; @@ -80,7 +81,7 @@ public async Task PromoteSelf_ReturnsBadRequest() var exception = await Assert.That(async () => await client.PromoteToAdminAsync(self.Id)).Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); - var error = await exception.GetContentAsAsync(); + var error = await exception.GetContentAsAsync(); _ = await Assert.That(error?.Error).IsEqualTo(ErrorCodes.Admin.CannotPromoteSelf); } @@ -88,8 +89,8 @@ public async Task PromoteSelf_ReturnsBadRequest() public async Task DemoteSelf_ReturnsBadRequest() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); var result = await client.GetUsersAsync(); var users = result.Items; @@ -100,7 +101,7 @@ public async Task DemoteSelf_ReturnsBadRequest() .Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); - var error = await exception.GetContentAsAsync(); + var error = await exception.GetContentAsAsync(); _ = await Assert.That(error?.Error).IsEqualTo(ErrorCodes.Admin.CannotDemoteSelf); } @@ -108,14 +109,14 @@ public async Task DemoteSelf_ReturnsBadRequest() public async Task DemoteUser_SucceedsForOtherUser() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); var identityClient = - RestService.For(TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + RestService.For(HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); // Create and promote a user - var userEmail = TestHelpers.GenerateFakeEmail(); - _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, TestHelpers.GenerateFakePassword())); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, FakeDataGenerators.GenerateFakePassword())); var result = await client.GetUsersAsync(search: userEmail); var users = result.Items; @@ -136,14 +137,14 @@ public async Task DemoteUser_SucceedsForOtherUser() public async Task PromoteUser_AlreadyAdmin_ReturnsBadRequest() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); var identityClient = - RestService.For(TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + RestService.For(HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); // Create and promote a user - var userEmail = TestHelpers.GenerateFakeEmail(); - _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, TestHelpers.GenerateFakePassword())); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, FakeDataGenerators.GenerateFakePassword())); var result = await client.GetUsersAsync(search: userEmail); var users = result.Items; @@ -159,14 +160,14 @@ public async Task PromoteUser_AlreadyAdmin_ReturnsBadRequest() public async Task DemoteUser_NotAdmin_ReturnsBadRequest() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); var identityClient = - RestService.For(TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + RestService.For(HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); // Create a regular user - var userEmail = TestHelpers.GenerateFakeEmail(); - _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, TestHelpers.GenerateFakePassword())); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, FakeDataGenerators.GenerateFakePassword())); var result = await client.GetUsersAsync(search: userEmail); var users = result.Items; @@ -182,15 +183,15 @@ public async Task DemoteUser_NotAdmin_ReturnsBadRequest() public async Task RegularUser_CannotAccessAdminUserEndpoints() { // Arrange - var userEmail = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); var identityClient = - RestService.For(TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + RestService.For(HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, password)); var loginResponse = await identityClient.LoginAsync(new LoginRequest(userEmail, password)); var userClient = - RestService.For(TestHelpers.GetAuthenticatedClient(loginResponse.AccessToken)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginResponse.AccessToken)); // Act & Assert var exception = await Assert.That(async () => await userClient.GetUsersAsync()).Throws(); @@ -201,14 +202,14 @@ public async Task RegularUser_CannotAccessAdminUserEndpoints() public async Task PromoteUser_LowercaseAdmin_IsNormalizedToPascalCase() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); var identityClient = - RestService.For(TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + RestService.For(HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); // Create a regular user - var userEmail = TestHelpers.GenerateFakeEmail(); - _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, TestHelpers.GenerateFakePassword())); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + _ = await identityClient.RegisterAsync(new RegisterRequest(userEmail, FakeDataGenerators.GenerateFakePassword())); var result = await client.GetUsersAsync(search: userEmail); var users = result.Items; @@ -230,8 +231,8 @@ public async Task PromoteUser_LowercaseAdmin_IsNormalizedToPascalCase() public async Task GetUsers_WithPagination_ReturnsCorrectPage() { // Arrange - var adminLogin = await TestHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); - var client = RestService.For(TestHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); + var adminLogin = await AuthenticationHelpers.LoginAsAdminAsync(StorageConstants.DefaultTenantId); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(adminLogin!.AccessToken)); // Act var result = await client.GetUsersAsync(page: 1, pageSize: 1); diff --git a/tests/BookStore.AppHost.Tests/ApiDocumentationTests.cs b/tests/BookStore.AppHost.Tests/ApiDocumentationTests.cs index 7df8c70..bb053c1 100644 --- a/tests/BookStore.AppHost.Tests/ApiDocumentationTests.cs +++ b/tests/BookStore.AppHost.Tests/ApiDocumentationTests.cs @@ -1,6 +1,7 @@ using System.Net; using Aspire.Hosting; using Aspire.Hosting.Testing; +using BookStore.AppHost.Tests.Helpers; using Projects; namespace BookStore.AppHost.Tests; diff --git a/tests/BookStore.AppHost.Tests/AuthTests.cs b/tests/BookStore.AppHost.Tests/AuthTests.cs index c8bfef8..373f4d8 100644 --- a/tests/BookStore.AppHost.Tests/AuthTests.cs +++ b/tests/BookStore.AppHost.Tests/AuthTests.cs @@ -1,5 +1,6 @@ using System.Net; using Bogus; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -13,7 +14,7 @@ public class AuthTests public AuthTests() { - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); _client = RestService.For(httpClient); _faker = new Faker(); } @@ -23,8 +24,8 @@ public async Task Register_WithValidData_ShouldReturnOk() { // Arrange var request = new RegisterRequest( - TestHelpers.GenerateFakeEmail(), - TestHelpers.GenerateFakePassword() + FakeDataGenerators.GenerateFakeEmail(), + FakeDataGenerators.GenerateFakePassword() ); // Act @@ -40,7 +41,7 @@ public async Task Register_WithExistingUser_ShouldReturnOk() { // Arrange var email = _faker.Internet.Email(); - var password = TestHelpers.GenerateFakePassword(); + var password = FakeDataGenerators.GenerateFakePassword(); var request = new RegisterRequest(email, password); // Register once @@ -58,7 +59,7 @@ public async Task Login_WithValidCredentials_ShouldReturnToken() { // Arrange var email = _faker.Internet.Email(); - var password = TestHelpers.GenerateFakePassword(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register first _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -102,7 +103,7 @@ public async Task RawLoginError_ShouldContainStandardizedCode() } catch (ApiException ex) { - var problem = await ex.GetContentAsAsync(); + var problem = await ex.GetContentAsAsync(); _ = await Assert.That(problem?.Error).IsEqualTo("ERR_AUTH_INVALID_CREDENTIALS"); } } @@ -115,7 +116,7 @@ public async Task Login_AsTenantAdmin_ShouldSucceed() var email = $"admin@{tenantId}.com"; var password = "Admin123!"; - var client = RestService.For(TestHelpers.GetUnauthenticatedClient(tenantId)); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); // Act var loginResult = await client.LoginAsync(new LoginRequest(email, password)); @@ -129,8 +130,8 @@ public async Task Login_AsTenantAdmin_ShouldSucceed() public async Task Refresh_WithValidToken_ShouldReturnNewToken() { // Arrange - var email = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register and Login _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -149,14 +150,14 @@ public async Task Refresh_WithValidToken_ShouldReturnNewToken() public async Task Logout_WithValidRefreshToken_ShouldInvalidateToken() { // Arrange - Register and login - var email = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); _ = await _client.RegisterAsync(new RegisterRequest(email, password)); var loginResult = await _client.LoginAsync(new LoginRequest(email, password)); // Create an authenticated client with the user's token - var authClient = RestService.For(TestHelpers.GetAuthenticatedClient(loginResult.AccessToken)); + var authClient = RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginResult.AccessToken)); // Act - Logout await authClient.LogoutAsync(new LogoutRequest(loginResult.RefreshToken)); @@ -177,8 +178,8 @@ public async Task Logout_WithValidRefreshToken_ShouldInvalidateToken() public async Task Logout_WithoutRefreshToken_ShouldInvalidateAllTokens() { // Arrange - Register, login twice to get two refresh tokens - var email = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -186,7 +187,7 @@ public async Task Logout_WithoutRefreshToken_ShouldInvalidateAllTokens() var loginResult2 = await _client.LoginAsync(new LoginRequest(email, password)); // Create an authenticated client with the user's token - var authClient = RestService.For(TestHelpers.GetAuthenticatedClient(loginResult2.AccessToken)); + var authClient = RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginResult2.AccessToken)); // Act - Logout without specifying refresh token (should clear all) await authClient.LogoutAsync(new LogoutRequest(null)); diff --git a/tests/BookStore.AppHost.Tests/AuthorCrudTests.cs b/tests/BookStore.AppHost.Tests/AuthorCrudTests.cs index 80f7a5c..988dd76 100644 --- a/tests/BookStore.AppHost.Tests/AuthorCrudTests.cs +++ b/tests/BookStore.AppHost.Tests/AuthorCrudTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -16,11 +17,11 @@ public class AuthorCrudTests public async Task CreateAuthor_EndToEndFlow_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createAuthorRequest = TestHelpers.GenerateFakeAuthorRequest(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createAuthorRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); // 1. Create Author - _ = await TestHelpers.ExecuteAndWaitForEventAsync( + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync( createAuthorRequest.Id, ["AuthorCreated", "AuthorUpdated"], async () => await client.CreateAuthorAsync(createAuthorRequest), @@ -37,7 +38,7 @@ public async Task CreateAuthor_EndToEndFlow_ShouldReturnOk() public async Task CreateAuthor_WithInvalidName_ShouldReturnBadRequest(string? invalidName) { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); var request = new CreateAuthorRequest { Id = Guid.CreateVersion7(), @@ -64,14 +65,14 @@ public async Task CreateAuthor_WithInvalidName_ShouldReturnBadRequest(string? in public async Task UpdateAuthor_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); - var updateRequest = TestHelpers.GenerateFakeUpdateAuthorRequest(); + var updateRequest = FakeDataGenerators.GenerateFakeUpdateAuthorRequest(); // Act - author = await TestHelpers.UpdateAuthorAsync(client, author!, updateRequest); + author = await AuthorHelpers.UpdateAuthorAsync(client, author!, updateRequest); // Assert var updatedAuthor = await client.GetAuthorAsync(author!.Id); @@ -82,12 +83,12 @@ public async Task UpdateAuthor_ShouldReturnOk() public async Task DeleteAuthor_ShouldReturnNoContent() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); // Act - author = await TestHelpers.DeleteAuthorAsync(client, author!); + author = await AuthorHelpers.DeleteAuthorAsync(client, author!); // Assert // Verify it is not found or soft deleted @@ -110,13 +111,13 @@ public async Task DeleteAuthor_ShouldReturnNoContent() public async Task RestoreAuthor_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); - author = await TestHelpers.DeleteAuthorAsync(client, author!); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); + author = await AuthorHelpers.DeleteAuthorAsync(client, author!); // Act - author = await TestHelpers.RestoreAuthorAsync(client, author!); + author = await AuthorHelpers.RestoreAuthorAsync(client, author!); // Assert var restored = await client.GetAuthorAsync(author!.Id); @@ -133,9 +134,9 @@ public async Task GetAuthor_WithLocalizedHeader_ShouldReturnExpectedContent(stri string expectedBiography) { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); var publicClient = - RestService.For(TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + RestService.For(HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); var createRequest = new CreateAuthorRequest { @@ -150,7 +151,7 @@ public async Task GetAuthor_WithLocalizedHeader_ShouldReturnExpectedContent(stri }; // Act - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); // Assert var authorDto = await publicClient.GetAuthorAsync(author!.Id, acceptLanguage); diff --git a/tests/BookStore.AppHost.Tests/BookConcurrencyTests.cs b/tests/BookStore.AppHost.Tests/BookConcurrencyTests.cs index 54f1d81..5a0c801 100644 --- a/tests/BookStore.AppHost.Tests/BookConcurrencyTests.cs +++ b/tests/BookStore.AppHost.Tests/BookConcurrencyTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using TUnit.Assertions.Extensions; @@ -10,18 +11,18 @@ public class BookConcurrencyTests public async Task UpdateBook_TwiceWithSameETag_ShouldFailOnSecondUpdate() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeBookRequest(); - var book = await TestHelpers.CreateBookAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeBookRequest(); + var book = await BookHelpers.CreateBookAsync(client, createRequest); // Get initial state and ETag var response = await client.GetBookWithResponseAsync(book.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest1 = TestHelpers.GenerateFakeUpdateBookRequest(book.Publisher?.Id, + var updateRequest1 = FakeDataGenerators.GenerateFakeUpdateBookRequest(book.Publisher?.Id, book.Authors.Select(a => a.Id), book.Categories.Select(c => c.Id)); - var updateRequest2 = TestHelpers.GenerateFakeUpdateBookRequest(book.Publisher?.Id, + var updateRequest2 = FakeDataGenerators.GenerateFakeUpdateBookRequest(book.Publisher?.Id, book.Authors.Select(a => a.Id), book.Categories.Select(c => c.Id)); // Act - First update succeeds @@ -38,16 +39,16 @@ public async Task UpdateBook_TwiceWithSameETag_ShouldFailOnSecondUpdate() public async Task UpdateThenDeleteBook_WithSameETag_ShouldFailOnDelete() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeBookRequest(); - var book = await TestHelpers.CreateBookAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeBookRequest(); + var book = await BookHelpers.CreateBookAsync(client, createRequest); // Get initial state and ETag var response = await client.GetBookWithResponseAsync(book.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest = TestHelpers.GenerateFakeUpdateBookRequest(book.Publisher?.Id, + var updateRequest = FakeDataGenerators.GenerateFakeUpdateBookRequest(book.Publisher?.Id, book.Authors.Select(a => a.Id), book.Categories.Select(c => c.Id)); // Act - Update succeeds @@ -64,16 +65,16 @@ public async Task UpdateThenDeleteBook_WithSameETag_ShouldFailOnDelete() public async Task DeleteThenUpdateBook_WithSameETag_ShouldFailOnUpdate() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeBookRequest(); - var book = await TestHelpers.CreateBookAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeBookRequest(); + var book = await BookHelpers.CreateBookAsync(client, createRequest); // Get initial state and ETag var response = await client.GetBookWithResponseAsync(book.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest = TestHelpers.GenerateFakeUpdateBookRequest(book.Publisher?.Id, + var updateRequest = FakeDataGenerators.GenerateFakeUpdateBookRequest(book.Publisher?.Id, book.Authors.Select(a => a.Id), book.Categories.Select(c => c.Id)); // Act - Delete succeeds diff --git a/tests/BookStore.AppHost.Tests/BookCrudTests.cs b/tests/BookStore.AppHost.Tests/BookCrudTests.cs index fe22e5b..f385747 100644 --- a/tests/BookStore.AppHost.Tests/BookCrudTests.cs +++ b/tests/BookStore.AppHost.Tests/BookCrudTests.cs @@ -1,10 +1,10 @@ using System.Net; using System.Net.Http.Headers; using Bogus; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; - // Resolve ambiguities by preferring Client types using CreateBookRequest = BookStore.Client.CreateBookRequest; using UpdateBookRequest = BookStore.Client.UpdateBookRequest; @@ -17,8 +17,8 @@ public class BookCrudTests public async Task UploadBookImage_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(client); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(client); // Get ETag for concurrency check var getResponse = await client.GetBookWithResponseAsync(createdBook.Id); @@ -43,7 +43,7 @@ public async Task UploadBookImage_ShouldReturnOk() // We can double check strict status if we change return type to Task, but Task is fine for "ShouldReturnOk". } - // I will modify ONLY the parts that DON'T need ETag for now? + // I will modify ONLY the parts that DON'T need ETag for now? // No, most tests use ETag. // I absolutely need to solve the ETag retrieval with Refit. // Standard Refit pattern: use ApiResponse. @@ -54,10 +54,10 @@ public async Task UploadBookImage_ShouldReturnOk() public async Task CreateBook_EndToEndFlow_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); // Act - var createdBook = await TestHelpers.CreateBookAsync(client); + var createdBook = await BookHelpers.CreateBookAsync(client); // Assert _ = await Assert.That(createdBook).IsNotNull(); @@ -67,8 +67,8 @@ public async Task CreateBook_EndToEndFlow_ShouldReturnOk() public async Task UpdateBook_EndToEndFlow_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(client); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(client); // Get the book to retrieve its ETag var getResponse = await client.GetBookWithResponseAsync(createdBook.Id); @@ -78,8 +78,8 @@ public async Task UpdateBook_EndToEndFlow_ShouldReturnOk() _ = await Assert.That(etag).IsNotNull(); // Act - var updateBookRequest = TestHelpers.GenerateFakeBookRequest(); - createdBook = await TestHelpers.UpdateBookAsync(client, createdBook.Id, updateBookRequest, etag!); + var updateBookRequest = FakeDataGenerators.GenerateFakeBookRequest(); + createdBook = await BookHelpers.UpdateBookAsync(client, createdBook.Id, updateBookRequest, etag!); // Assert - Success is validated inside UpdateBookAsync } @@ -88,8 +88,8 @@ public async Task UpdateBook_EndToEndFlow_ShouldReturnOk() public async Task DeleteBook_EndToEndFlow_ShouldReturnNoContent() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(client); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(client); // Get the book to retrieve its ETag var getResponse = await client.GetBookWithResponseAsync(createdBook.Id); @@ -99,7 +99,7 @@ public async Task DeleteBook_EndToEndFlow_ShouldReturnNoContent() _ = await Assert.That(etag).IsNotNull(); // Act - var deletedBook = await TestHelpers.DeleteBookAsync(client, createdBook.Id, etag!); + var deletedBook = await BookHelpers.DeleteBookAsync(client, createdBook.Id, etag!); // Assert - Success is validated inside DeleteBookAsync } @@ -108,8 +108,8 @@ public async Task DeleteBook_EndToEndFlow_ShouldReturnNoContent() public async Task RestoreBook_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(client); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(client); // Get ETag for delete var getResponse = await client.GetBookWithResponseAsync(createdBook.Id); @@ -117,10 +117,10 @@ public async Task RestoreBook_ShouldReturnOk() _ = await Assert.That(deleteEtag).IsNotNull(); // Soft delete book - _ = await TestHelpers.DeleteBookAsync(client, createdBook.Id, deleteEtag!); + _ = await BookHelpers.DeleteBookAsync(client, createdBook.Id, deleteEtag!); // Act - createdBook = await TestHelpers.RestoreBookAsync(client, createdBook.Id); + createdBook = await BookHelpers.RestoreBookAsync(client, createdBook.Id); // Assert - Success is validated inside RestoreBookAsync } @@ -129,11 +129,11 @@ public async Task RestoreBook_ShouldReturnOk() public async Task AddToFavorites_ShouldReturnNoContent() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(client); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(client); // Act - await TestHelpers.AddToFavoritesAsync(client, createdBook.Id); + await BookHelpers.AddToFavoritesAsync(client, createdBook.Id); // Assert var getResponse = await client.GetBookAsync(createdBook.Id); @@ -144,18 +144,18 @@ public async Task AddToFavorites_ShouldReturnNoContent() public async Task RemoveFromFavorites_ShouldReturnNoContent() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(client); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(client); // Add to favorites first - await TestHelpers.AddToFavoritesAsync(client, createdBook.Id); + await BookHelpers.AddToFavoritesAsync(client, createdBook.Id); // Verify it IS favorite initially var initialGet = await client.GetBookAsync(createdBook.Id); _ = await Assert.That(initialGet!.IsFavorite).IsTrue(); // Act - await TestHelpers.RemoveFromFavoritesAsync(client, createdBook.Id); + await BookHelpers.RemoveFromFavoritesAsync(client, createdBook.Id); // Assert var getResponse = await client.GetBookAsync(createdBook.Id); @@ -166,11 +166,11 @@ public async Task RemoveFromFavorites_ShouldReturnNoContent() public async Task GetBook_WhenNotAuthenticated_ShouldHaveIsFavoriteFalse() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // Act - var publicClient = TestHelpers.GetUnauthenticatedClient(); + var publicClient = HttpClientHelpers.GetUnauthenticatedClient(); var getResponse = await publicClient.GetBookAsync(createdBook.Id); // Assert @@ -180,32 +180,32 @@ public async Task GetBook_WhenNotAuthenticated_ShouldHaveIsFavoriteFalse() [Test] public async Task BookLikeCount_ShouldAggregateCorrectly_WhenMultipleUsersLikeBook() { - var anonClient = TestHelpers.GetUnauthenticatedClient(); + var anonClient = HttpClientHelpers.GetUnauthenticatedClient(); // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); var user1Client = await CreateAuthenticatedUserAsync(); var user2Client = await CreateAuthenticatedUserAsync(); // Act & Assert: User 1 likes book - await TestHelpers.AddToFavoritesAsync(user1Client, createdBook.Id, createdBook.Id, "BookStatisticsUpdate"); + await BookHelpers.AddToFavoritesAsync(user1Client, createdBook.Id, createdBook.Id, "BookStatisticsUpdate"); var bookDto1 = await anonClient.GetBookAsync(createdBook.Id); _ = await Assert.That(bookDto1!.LikeCount).IsEqualTo(1); // Act & Assert: User 2 likes book - await TestHelpers.AddToFavoritesAsync(user2Client, createdBook.Id, createdBook.Id, "BookStatisticsUpdate"); + await BookHelpers.AddToFavoritesAsync(user2Client, createdBook.Id, createdBook.Id, "BookStatisticsUpdate"); var bookDto2 = await anonClient.GetBookAsync(createdBook.Id); _ = await Assert.That(bookDto2!.LikeCount).IsEqualTo(2); // Act & Assert: User 1 unlikes book - await TestHelpers.RemoveFromFavoritesAsync(user1Client, createdBook.Id, createdBook.Id, "BookStatisticsUpdate"); + await BookHelpers.RemoveFromFavoritesAsync(user1Client, createdBook.Id, createdBook.Id, "BookStatisticsUpdate"); var bookDto3 = await anonClient.GetBookAsync(createdBook.Id); _ = await Assert.That(bookDto3!.LikeCount).IsEqualTo(1); } async Task CreateAuthenticatedUserAsync() - // Wrapper for TestHelpers.CreateUserAndGetClientAsync - => await TestHelpers.CreateUserAndGetClientAsync(); + // Wrapper for AuthenticationHelpers.CreateUserAndGetClientAsync + => await AuthenticationHelpers.CreateUserAndGetClientAsync(); } diff --git a/tests/BookStore.AppHost.Tests/BookFilterRegressionTests.cs b/tests/BookStore.AppHost.Tests/BookFilterRegressionTests.cs index 5842722..7059798 100644 --- a/tests/BookStore.AppHost.Tests/BookFilterRegressionTests.cs +++ b/tests/BookStore.AppHost.Tests/BookFilterRegressionTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Marten; @@ -27,33 +28,33 @@ public async Task SearchBooks_InNonDefaultTenant_ShouldRespectAuthorFilter() opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); })) { - await TestHelpers.SeedTenantAsync(store, tenantId); + await DatabaseHelpers.SeedTenantAsync(store, tenantId); } // Authenticate as Admin in the new tenant - var loginRes = await TestHelpers.LoginAsAdminAsync(tenantId); + var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(tenantId); var adminClient = - RestService.For(TestHelpers.GetAuthenticatedClient(loginRes!.AccessToken, tenantId)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginRes!.AccessToken, tenantId)); var adminBooksClient = - RestService.For(TestHelpers.GetAuthenticatedClient(loginRes!.AccessToken, tenantId)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginRes!.AccessToken, tenantId)); // Create Author in this tenant - var authorReq = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(adminClient, authorReq); + var authorReq = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(adminClient, authorReq); var authorId = author.Id; // Create Book linked to this Author - var bookReq = TestHelpers.GenerateFakeBookRequest(authorIds: new[] { authorId }); - var book = await TestHelpers.CreateBookAsync(adminBooksClient, bookReq); + var bookReq = FakeDataGenerators.GenerateFakeBookRequest(authorIds: new[] { authorId }); + var book = await BookHelpers.CreateBookAsync(adminBooksClient, bookReq); // Search in correct tenant - var tenantClient = RestService.For(TestHelpers.GetUnauthenticatedClient(tenantId)); + var tenantClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); var list = await tenantClient.GetBooksAsync(new BookSearchRequest { AuthorId = authorId }); _ = await Assert.That(list != null && list.Items.Any(b => b.Id == book.Id)).IsTrue(); // Search in WRONG tenant (Default) - var defaultTenantClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var defaultTenantClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // No X-Tenant-ID header implies default tenant var listDefault = await defaultTenantClient.GetBooksAsync(new BookSearchRequest { AuthorId = authorId }); @@ -77,8 +78,8 @@ public async Task SearchBooks_WithMultiCurrencyPrices_ShouldRespectCurrencyFilte double? maxPrice, bool expectedFound) { // Debugging Multi-Currency Price Filter - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); var uniqueTitle = $"MultiCurrency-{Guid.NewGuid()}"; // Create book with: USD=10, EUR=50 @@ -95,7 +96,7 @@ public async Task SearchBooks_WithMultiCurrencyPrices_ShouldRespectCurrencyFilte }; // Wait for projection - _ = await TestHelpers.CreateBookAsync(authClient, createRequest); + _ = await BookHelpers.CreateBookAsync(authClient, createRequest); var contentInitial = await publicClient.GetBooksAsync(new BookSearchRequest { Search = uniqueTitle }); _ = await Assert.That(contentInitial != null && contentInitial.Items.Any(b => b.Title == uniqueTitle)).IsTrue(); @@ -127,8 +128,8 @@ public async Task SearchBooks_WithMultiCurrencyPrices_ShouldRespectCurrencyFilte public async Task SearchBooks_WithActiveSale_ShouldFilterByDiscountedPrice() { // Debugging Price Filter taking Sale into account - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); var uniqueTitle = $"SaleBook-{Guid.NewGuid()}"; // Create book with Price=50 USD @@ -144,7 +145,7 @@ public async Task SearchBooks_WithActiveSale_ShouldFilterByDiscountedPrice() Prices = new Dictionary { ["USD"] = 50.0m } }; - var book = await TestHelpers.CreateBookAsync(authClient, createRequest); + var book = await BookHelpers.CreateBookAsync(authClient, createRequest); var bookId = book.Id; // Verify initially NOT found with MaxPrice=40 (Price is 50) @@ -161,7 +162,7 @@ public async Task SearchBooks_WithActiveSale_ShouldFilterByDiscountedPrice() var saleRequest = new ScheduleSaleRequest(50m, DateTimeOffset.UtcNow.AddSeconds(-5), DateTimeOffset.UtcNow.AddDays(1)); - var putReceived = await TestHelpers.ExecuteAndWaitForEventAsync(bookId, "BookUpdated", + var putReceived = await SseEventHelpers.ExecuteAndWaitForEventAsync(bookId, "BookUpdated", async () => await authClient.ScheduleBookSaleAsync(bookId, saleRequest, book.ETag), TimeSpan.FromSeconds(5)); diff --git a/tests/BookStore.AppHost.Tests/BookRatingTests.cs b/tests/BookStore.AppHost.Tests/BookRatingTests.cs index 3d01594..daaf7eb 100644 --- a/tests/BookStore.AppHost.Tests/BookRatingTests.cs +++ b/tests/BookStore.AppHost.Tests/BookRatingTests.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using Bogus; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -12,14 +13,14 @@ public class BookRatingTests public async Task RateBook_ShouldUpdateUserRatingAndStatistics() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create book and wait for projection - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // Act - Rate the book and wait for UserUpdated (since we assert UserRating) var rating = 4; - await TestHelpers.RateBookAsync(client, createdBook.Id, rating, createdBook.Id, "BookUpdated"); + await BookHelpers.RateBookAsync(client, createdBook.Id, rating, createdBook.Id, "BookUpdated"); // Assert - Verify statistics and user rating var bookDto = await client.GetBookAsync(createdBook.Id); @@ -32,14 +33,14 @@ public async Task RateBook_ShouldUpdateUserRatingAndStatistics() public async Task UpdateRating_ShouldChangeExistingRating() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create book and wait for projection - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // Rate the book initially and wait for update var initialRating = 3; - await TestHelpers.RateBookAsync(client, createdBook.Id, initialRating, createdBook.Id, "BookUpdated"); + await BookHelpers.RateBookAsync(client, createdBook.Id, initialRating, createdBook.Id, "BookUpdated"); // Verify initial rating var initialGet = await client.GetBookAsync(createdBook.Id); @@ -48,7 +49,7 @@ public async Task UpdateRating_ShouldChangeExistingRating() // Act - Update the rating and wait for UserUpdated var updatedRating = 5; - await TestHelpers.RateBookAsync(client, createdBook.Id, updatedRating, createdBook.Id, "BookUpdated"); + await BookHelpers.RateBookAsync(client, createdBook.Id, updatedRating, createdBook.Id, "BookUpdated"); // Assert - Verify updated statistics var bookDto = await client.GetBookAsync(createdBook.Id); @@ -61,13 +62,13 @@ public async Task UpdateRating_ShouldChangeExistingRating() public async Task RemoveRating_ShouldClearUserRatingAndUpdateStatistics() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create book and wait for projection - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // Rate the book first and wait for update - await TestHelpers.RateBookAsync(client, createdBook.Id, 4, createdBook.Id, "BookUpdated"); + await BookHelpers.RateBookAsync(client, createdBook.Id, 4, createdBook.Id, "BookUpdated"); // Verify rating is set var initialGet = await client.GetBookAsync(createdBook.Id); @@ -75,7 +76,7 @@ public async Task RemoveRating_ShouldClearUserRatingAndUpdateStatistics() _ = await Assert.That(initialGet.RatingCount).IsEqualTo(1); // Act - Remove the rating and wait for UserUpdated - await TestHelpers.RemoveRatingAsync(client, createdBook.Id, createdBook.Id, "BookUpdated"); + await BookHelpers.RemoveRatingAsync(client, createdBook.Id, createdBook.Id, "BookUpdated"); // Assert - Verify rating is removed var bookDto = await client.GetBookAsync(createdBook.Id); @@ -90,16 +91,16 @@ public async Task BookRatingStatistics_ShouldAggregateCorrectly_WhenMultipleUser var _faker = new Faker(); // 1. Arrange: Create a book as Admin and wait - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // 2. Arrange: Create User 1, User 2, and User 3 - var user1Client = await TestHelpers.CreateUserAndGetClientAsync(); - var user2Client = await TestHelpers.CreateUserAndGetClientAsync(); - var user3Client = await TestHelpers.CreateUserAndGetClientAsync(); + var user1Client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); + var user2Client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); + var user3Client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // 3. Act: User 1 Rates Book with 3 stars and wait for statistics update via SSE - await TestHelpers.RateBookAsync(user1Client, createdBook.Id, 3, createdBook.Id, "BookUpdated"); + await BookHelpers.RateBookAsync(user1Client, createdBook.Id, 3, createdBook.Id, "BookUpdated"); // Assert: Average = 3.0, Count = 1 var bookDto1 = await adminClient.GetBookAsync(createdBook.Id); @@ -107,7 +108,7 @@ public async Task BookRatingStatistics_ShouldAggregateCorrectly_WhenMultipleUser _ = await Assert.That(bookDto1.RatingCount).IsEqualTo(1); // 4. Act: User 2 Rates Book with 4 stars and wait for SSE - await TestHelpers.RateBookAsync(user2Client, createdBook.Id, 4, createdBook.Id, "BookUpdated"); + await BookHelpers.RateBookAsync(user2Client, createdBook.Id, 4, createdBook.Id, "BookUpdated"); // Assert: Average = 3.5, Count = 2 var bookDto2 = await adminClient.GetBookAsync(createdBook.Id); @@ -115,7 +116,7 @@ public async Task BookRatingStatistics_ShouldAggregateCorrectly_WhenMultipleUser _ = await Assert.That(bookDto2.RatingCount).IsEqualTo(2); // 5. Act: User 3 Rates Book with 5 stars and wait for SSE - await TestHelpers.RateBookAsync(user3Client, createdBook.Id, 5, createdBook.Id, "BookUpdated"); + await BookHelpers.RateBookAsync(user3Client, createdBook.Id, 5, createdBook.Id, "BookUpdated"); // Assert: Average = 4.0, Count = 3 var bookDto3 = await adminClient.GetBookAsync(createdBook.Id); @@ -123,7 +124,7 @@ public async Task BookRatingStatistics_ShouldAggregateCorrectly_WhenMultipleUser _ = await Assert.That(bookDto3.RatingCount).IsEqualTo(3); // 6. Act: User 1 Updates their rating to 5 stars and wait for SSE - await TestHelpers.RateBookAsync(user1Client, createdBook.Id, 5, createdBook.Id, "BookUpdated"); + await BookHelpers.RateBookAsync(user1Client, createdBook.Id, 5, createdBook.Id, "BookUpdated"); // Assert: Average = 4.67 (rounded from 14/3), Count = 3 var bookDto4 = await adminClient.GetBookAsync(createdBook.Id); @@ -131,7 +132,7 @@ public async Task BookRatingStatistics_ShouldAggregateCorrectly_WhenMultipleUser _ = await Assert.That(bookDto4.RatingCount).IsEqualTo(3); // 7. Act: User 2 Removes their rating and wait for SSE - await TestHelpers.RemoveRatingAsync(user2Client, createdBook.Id, createdBook.Id, "BookUpdated"); + await BookHelpers.RemoveRatingAsync(user2Client, createdBook.Id, createdBook.Id, "BookUpdated"); // Assert: Average = 5.0, Count = 2 var bookDto5 = await adminClient.GetBookAsync(createdBook.Id); @@ -143,10 +144,10 @@ public async Task BookRatingStatistics_ShouldAggregateCorrectly_WhenMultipleUser public async Task RateBook_WithInvalidRating_ShouldReturnBadRequest() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create book and wait for projection - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // Act & Assert - Try invalid ratings var invalidRatings = new[] { 0, 6, -1, 10 }; @@ -160,7 +161,7 @@ public async Task RateBook_WithInvalidRating_ShouldReturnBadRequest() catch (ApiException ex) { _ = await Assert.That(ex.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); - var error = await ex.GetContentAsAsync(); + var error = await ex.GetContentAsAsync(); _ = await Assert.That(error?.Detail).Contains("Rating must be between 1 and 5"); } } @@ -175,12 +176,12 @@ public async Task RateBook_WithInvalidRating_ShouldReturnBadRequest() public async Task RateBook_WhenUnauthenticated_ShouldReturnUnauthorized() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); // Create book and wait for projection - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // Act - Try to rate without authentication - var unauthenticatedClient = TestHelpers.GetUnauthenticatedClient(); + var unauthenticatedClient = HttpClientHelpers.GetUnauthenticatedClient(); var client = RestService.For(unauthenticatedClient); try diff --git a/tests/BookStore.AppHost.Tests/BookSoftDeleteTests.cs b/tests/BookStore.AppHost.Tests/BookSoftDeleteTests.cs index 5800864..cf53f16 100644 --- a/tests/BookStore.AppHost.Tests/BookSoftDeleteTests.cs +++ b/tests/BookStore.AppHost.Tests/BookSoftDeleteTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -11,13 +12,13 @@ public class BookSoftDeleteTests public async Task SoftDeleteFlow_FullLifecycle_ShouldWorkCorrectly() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); // We also need a raw client to fetch ETag, as Refit IBooksClient returns DTOs without headers - // var rawAdminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publicClient = Refit.RestService.For(TestHelpers.GetUnauthenticatedClient()); + // var rawAdminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publicClient = Refit.RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // 1. Create a book - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); var bookId = createdBook!.Id; // Verify visible in public API @@ -27,7 +28,7 @@ public async Task SoftDeleteFlow_FullLifecycle_ShouldWorkCorrectly() // 2. Soft Delete via Admin API // Perform Soft Delete - var deletedBook = await TestHelpers.DeleteBookAsync(adminClient, createdBook); + var deletedBook = await BookHelpers.DeleteBookAsync(adminClient, createdBook); // 3. Verify Public API returns 404 try @@ -48,7 +49,7 @@ public async Task SoftDeleteFlow_FullLifecycle_ShouldWorkCorrectly() _ = await Assert.That(adminBook!.IsDeleted).IsTrue(); // 5. Restore via Admin API - createdBook = await TestHelpers.RestoreBookAsync(adminClient, bookId); + createdBook = await BookHelpers.RestoreBookAsync(adminClient, bookId); var restoredGet = await publicClient.GetBookAsync(bookId); _ = await Assert.That(restoredGet).IsNotNull(); @@ -58,15 +59,15 @@ public async Task SoftDeleteFlow_FullLifecycle_ShouldWorkCorrectly() public async Task SoftDeletedBook_ShouldBeVisibleToAdmin_ButNotPublic() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - // var rawAdminClient = await TestHelpers.GetAuthenticatedClientAsync(); // For ETag - var publicClient = Refit.RestService.For(TestHelpers.GetUnauthenticatedClient()); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + // var rawAdminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); // For ETag + var publicClient = Refit.RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); var bookId = createdBook!.Id; // Soft Delete - var deletedBook = await TestHelpers.DeleteBookAsync(adminClient, createdBook); + var deletedBook = await BookHelpers.DeleteBookAsync(adminClient, createdBook); // Act & Assert diff --git a/tests/BookStore.AppHost.Tests/BookValidationTests.cs b/tests/BookStore.AppHost.Tests/BookValidationTests.cs index ba0b6b8..e3719e6 100644 --- a/tests/BookStore.AppHost.Tests/BookValidationTests.cs +++ b/tests/BookStore.AppHost.Tests/BookValidationTests.cs @@ -1,5 +1,6 @@ using System.Net; using Bogus; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -21,7 +22,7 @@ public async Task CreateBook_WithInvalidData_ShouldReturnProblemDetails_WithErro string expectedErrorCode) { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); var request = new CreateBookRequest { diff --git a/tests/BookStore.AppHost.Tests/CategoryConcurrencyTests.cs b/tests/BookStore.AppHost.Tests/CategoryConcurrencyTests.cs index 865f3ad..136ebcd 100644 --- a/tests/BookStore.AppHost.Tests/CategoryConcurrencyTests.cs +++ b/tests/BookStore.AppHost.Tests/CategoryConcurrencyTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using TUnit.Assertions.Extensions; @@ -10,17 +11,17 @@ public class CategoryConcurrencyTests public async Task UpdateCategory_TwiceWithSameETag_ShouldFailOnSecondUpdate() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeCategoryRequest(); - var category = await TestHelpers.CreateCategoryAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeCategoryRequest(); + var category = await CategoryHelpers.CreateCategoryAsync(client, createRequest); // Get initial state and ETag var response = await client.GetCategoryWithResponseAsync(category.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest1 = TestHelpers.GenerateFakeUpdateCategoryRequest(); - var updateRequest2 = TestHelpers.GenerateFakeUpdateCategoryRequest(); + var updateRequest1 = FakeDataGenerators.GenerateFakeUpdateCategoryRequest(); + var updateRequest2 = FakeDataGenerators.GenerateFakeUpdateCategoryRequest(); // Act - First update succeeds await client.UpdateCategoryAsync(category.Id, updateRequest1, etag); @@ -36,16 +37,16 @@ public async Task UpdateCategory_TwiceWithSameETag_ShouldFailOnSecondUpdate() public async Task UpdateThenDeleteCategory_WithSameETag_ShouldFailOnDelete() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeCategoryRequest(); - var category = await TestHelpers.CreateCategoryAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeCategoryRequest(); + var category = await CategoryHelpers.CreateCategoryAsync(client, createRequest); // Get initial state and ETag var response = await client.GetCategoryWithResponseAsync(category.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest = TestHelpers.GenerateFakeUpdateCategoryRequest(); + var updateRequest = FakeDataGenerators.GenerateFakeUpdateCategoryRequest(); // Act - Update succeeds await client.UpdateCategoryAsync(category.Id, updateRequest, etag); @@ -61,16 +62,16 @@ public async Task UpdateThenDeleteCategory_WithSameETag_ShouldFailOnDelete() public async Task DeleteThenUpdateCategory_WithSameETag_ShouldFailOnUpdate() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeCategoryRequest(); - var category = await TestHelpers.CreateCategoryAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeCategoryRequest(); + var category = await CategoryHelpers.CreateCategoryAsync(client, createRequest); // Get initial state and ETag var response = await client.GetCategoryWithResponseAsync(category.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest = TestHelpers.GenerateFakeUpdateCategoryRequest(); + var updateRequest = FakeDataGenerators.GenerateFakeUpdateCategoryRequest(); // Act - Delete succeeds await client.SoftDeleteCategoryAsync(category.Id, etag); diff --git a/tests/BookStore.AppHost.Tests/CategoryCrudTests.cs b/tests/BookStore.AppHost.Tests/CategoryCrudTests.cs index 2fddfb4..f667b3e 100644 --- a/tests/BookStore.AppHost.Tests/CategoryCrudTests.cs +++ b/tests/BookStore.AppHost.Tests/CategoryCrudTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -15,11 +16,11 @@ public class CategoryCrudTests public async Task CreateCategory_EndToEndFlow_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createCategoryRequest = TestHelpers.GenerateFakeCategoryRequest(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createCategoryRequest = FakeDataGenerators.GenerateFakeCategoryRequest(); // Act - var category = await TestHelpers.CreateCategoryAsync(client, createCategoryRequest); + var category = await CategoryHelpers.CreateCategoryAsync(client, createCategoryRequest); // Assert _ = await Assert.That(category).IsNotNull(); @@ -30,14 +31,14 @@ public async Task CreateCategory_EndToEndFlow_ShouldReturnOk() public async Task UpdateCategory_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeCategoryRequest(); - var createdCategory = await TestHelpers.CreateCategoryAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeCategoryRequest(); + var createdCategory = await CategoryHelpers.CreateCategoryAsync(client, createRequest); - var updateRequest = TestHelpers.GenerateFakeUpdateCategoryRequest(); // New data + var updateRequest = FakeDataGenerators.GenerateFakeUpdateCategoryRequest(); // New data // Act - createdCategory = await TestHelpers.UpdateCategoryAsync(client, createdCategory!, updateRequest); + createdCategory = await CategoryHelpers.UpdateCategoryAsync(client, createdCategory!, updateRequest); // Verify update in public API (data should be consistent now) // We use public unauthenticated client to verify @@ -45,7 +46,7 @@ public async Task UpdateCategory_ShouldReturnOk() var publicClient = RestService.For( - TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); var expectedName = updateRequest.Translations["en"].Name; var updatedCategory = await publicClient.GetCategoryAsync(createdCategory!.Id, acceptLanguage: "en"); @@ -56,18 +57,18 @@ public async Task UpdateCategory_ShouldReturnOk() public async Task DeleteCategory_ShouldReturnNoContent() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeCategoryRequest(); - var createdCategory = await TestHelpers.CreateCategoryAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeCategoryRequest(); + var createdCategory = await CategoryHelpers.CreateCategoryAsync(client, createRequest); // Act - createdCategory = await TestHelpers.DeleteCategoryAsync(client, createdCategory!); + createdCategory = await CategoryHelpers.DeleteCategoryAsync(client, createdCategory!); // Verify it's gone from public API // Verify it's gone from public API var publicClient = RestService.For( - TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); try { _ = await publicClient.GetCategoryAsync(createdCategory!.Id); @@ -85,7 +86,7 @@ public async Task DeleteCategory_ShouldReturnNoContent() public async Task CreateCategory_WithInvalidName_ShouldReturnBadRequest(string? invalidName) { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); var request = new CreateCategoryRequest { Id = Guid.CreateVersion7(), @@ -117,7 +118,7 @@ public async Task GetCategory_WithLocalizedHeader_ShouldReturnExpectedContent(st string expectedName) { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); var createRequest = new CreateCategoryRequest { @@ -130,12 +131,12 @@ public async Task GetCategory_WithLocalizedHeader_ShouldReturnExpectedContent(st } }; - var createdCategory = await TestHelpers.CreateCategoryAsync(client, createRequest); + var createdCategory = await CategoryHelpers.CreateCategoryAsync(client, createRequest); _ = await Assert.That(createdCategory).IsNotNull(); var publicClient = RestService.For( - TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); var categoryDto = await publicClient.GetCategoryAsync(createdCategory!.Id, acceptLanguage: acceptLanguage); // Assert @@ -147,17 +148,17 @@ public async Task GetCategory_WithLocalizedHeader_ShouldReturnExpectedContent(st public async Task RestoreCategory_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); // 1. Create Category - var createRequest = TestHelpers.GenerateFakeCategoryRequest(); - var createdCategory = await TestHelpers.CreateCategoryAsync(client, createRequest); + var createRequest = FakeDataGenerators.GenerateFakeCategoryRequest(); + var createdCategory = await CategoryHelpers.CreateCategoryAsync(client, createRequest); // 2. Soft Delete Category - createdCategory = await TestHelpers.DeleteCategoryAsync(client, createdCategory!); + createdCategory = await CategoryHelpers.DeleteCategoryAsync(client, createdCategory!); // Act - Restore - createdCategory = await TestHelpers.RestoreCategoryAsync(client, createdCategory!); + createdCategory = await CategoryHelpers.RestoreCategoryAsync(client, createdCategory!); // Verify // Use client to get it (should succeed now if visible to admin, which it is) diff --git a/tests/BookStore.AppHost.Tests/CategoryOrderingTests.cs b/tests/BookStore.AppHost.Tests/CategoryOrderingTests.cs index 4203908..b6aa6b5 100644 --- a/tests/BookStore.AppHost.Tests/CategoryOrderingTests.cs +++ b/tests/BookStore.AppHost.Tests/CategoryOrderingTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -18,7 +19,7 @@ public class CategoryOrderingTests public async Task GetCategories_OrderedByName_ShouldReturnInCorrectOrder() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); // Create categories with specific names to test ordering var names = (string[])["Z-Category", "A-Category", "M-Category"]; @@ -34,7 +35,7 @@ public async Task GetCategories_OrderedByName_ShouldReturnInCorrectOrder() ["en"] = new CategoryTranslationDto(name) } }; - _ = await TestHelpers.ExecuteAndWaitForEventAsync( + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync( createRequest.Id, ["CategoryCreated", "CategoryUpdated"], async () => await adminClient.CreateCategoryAsync(createRequest), @@ -44,7 +45,7 @@ public async Task GetCategories_OrderedByName_ShouldReturnInCorrectOrder() // Removed Task.Delay(TestConstants.DefaultProjectionDelay) as we now wait for each creation. // Act - Request public categories ordered by name asc - var publicHttpClient = TestHelpers.GetUnauthenticatedClient(); + var publicHttpClient = HttpClientHelpers.GetUnauthenticatedClient(); publicHttpClient.DefaultRequestHeaders.AcceptLanguage.Clear(); publicHttpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en")); var publicClient = RestService.For(publicHttpClient); @@ -71,7 +72,7 @@ public async Task GetCategories_OrderedByName_ShouldReturnInCorrectOrder() public async Task AdminGetAllCategories_OrderedByNameWithLanguage_ShouldReturnInCorrectOrder() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); // Create categories with Portuguese and English names // Cat 1: EN: "C", PT: "A" @@ -93,7 +94,7 @@ public async Task AdminGetAllCategories_OrderedByNameWithLanguage_ShouldReturnIn ["pt-PT"] = new CategoryTranslationDto(cat.PT) } }; - _ = await TestHelpers.ExecuteAndWaitForEventAsync( + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync( createRequest.Id, ["CategoryCreated", "CategoryUpdated"], async () => await adminClient.CreateCategoryAsync(createRequest), diff --git a/tests/BookStore.AppHost.Tests/ConcurrencyTests.cs b/tests/BookStore.AppHost.Tests/ConcurrencyTests.cs index f4891d4..cee29d4 100644 --- a/tests/BookStore.AppHost.Tests/ConcurrencyTests.cs +++ b/tests/BookStore.AppHost.Tests/ConcurrencyTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using TUnit.Assertions.Extensions; @@ -10,17 +11,17 @@ public class ConcurrencyTests public async Task UpdateAuthor_TwiceWithSameETag_ShouldFailOnSecondUpdate() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); // Get initial state and ETag var response = await client.GetAuthorWithResponseAsync(author.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest1 = TestHelpers.GenerateFakeUpdateAuthorRequest(); - var updateRequest2 = TestHelpers.GenerateFakeUpdateAuthorRequest(); + var updateRequest1 = FakeDataGenerators.GenerateFakeUpdateAuthorRequest(); + var updateRequest2 = FakeDataGenerators.GenerateFakeUpdateAuthorRequest(); // Act - First update succeeds await client.UpdateAuthorAsync(author.Id, updateRequest1, etag); @@ -36,16 +37,16 @@ public async Task UpdateAuthor_TwiceWithSameETag_ShouldFailOnSecondUpdate() public async Task UpdateThenDeleteAuthor_WithSameETag_ShouldFailOnDelete() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); // Get initial state and ETag var response = await client.GetAuthorWithResponseAsync(author.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest = TestHelpers.GenerateFakeUpdateAuthorRequest(); + var updateRequest = FakeDataGenerators.GenerateFakeUpdateAuthorRequest(); // Act - Update succeeds await client.UpdateAuthorAsync(author.Id, updateRequest, etag); @@ -61,16 +62,16 @@ public async Task UpdateThenDeleteAuthor_WithSameETag_ShouldFailOnDelete() public async Task DeleteThenUpdateAuthor_WithSameETag_ShouldFailOnUpdate() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); // Get initial state and ETag var response = await client.GetAuthorWithResponseAsync(author.Id); var etag = response.Headers.ETag?.Tag; _ = await Assert.That(etag).IsNotNull(); - var updateRequest = TestHelpers.GenerateFakeUpdateAuthorRequest(); + var updateRequest = FakeDataGenerators.GenerateFakeUpdateAuthorRequest(); // Act - Delete succeeds await client.SoftDeleteAuthorAsync(author.Id, etag); @@ -86,11 +87,11 @@ public async Task DeleteThenUpdateAuthor_WithSameETag_ShouldFailOnUpdate() public async Task UpdateAuthor_MissingETag_ShouldFail() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); - var updateRequest = TestHelpers.GenerateFakeUpdateAuthorRequest(); + var updateRequest = FakeDataGenerators.GenerateFakeUpdateAuthorRequest(); // Act - Update without ETag should fail (once we make it mandatory) var updateResponse = await client.UpdateAuthorWithResponseAsync(author.Id, updateRequest, null); diff --git a/tests/BookStore.AppHost.Tests/ConfigurationEndpointsTests.cs b/tests/BookStore.AppHost.Tests/ConfigurationEndpointsTests.cs index b395782..b908ee5 100644 --- a/tests/BookStore.AppHost.Tests/ConfigurationEndpointsTests.cs +++ b/tests/BookStore.AppHost.Tests/ConfigurationEndpointsTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using BookStore.AppHost.Tests.Helpers; using BookStore.Shared.Models; namespace BookStore.AppHost.Tests; @@ -10,7 +11,7 @@ public class ConfigurationEndpointsTests public async Task GetLocalizationConfig_ShouldReturnConfiguration() { // Arrange - var client = TestHelpers.GetUnauthenticatedClient(); + var client = HttpClientHelpers.GetUnauthenticatedClient(); // Act var response = await client.GetAsync("/api/config/localization"); @@ -29,7 +30,7 @@ public async Task GetLocalizationConfig_ShouldReturnConfiguration() public async Task GetCurrencyConfig_ShouldReturnConfiguration() { // Arrange - var client = TestHelpers.GetUnauthenticatedClient(); + var client = HttpClientHelpers.GetUnauthenticatedClient(); // Act var response = await client.GetAsync("/api/config/currency"); @@ -48,7 +49,7 @@ public async Task GetCurrencyConfig_ShouldReturnConfiguration() public async Task GetLocalizationConfig_ShouldBeAccessibleWithoutAuthentication() { // Arrange - var client = TestHelpers.GetUnauthenticatedClient(); + var client = HttpClientHelpers.GetUnauthenticatedClient(); // No authentication headers added // Act @@ -62,7 +63,7 @@ public async Task GetLocalizationConfig_ShouldBeAccessibleWithoutAuthentication( public async Task GetCurrencyConfig_ShouldBeAccessibleWithoutAuthentication() { // Arrange - var client = TestHelpers.GetUnauthenticatedClient(); + var client = HttpClientHelpers.GetUnauthenticatedClient(); // No authentication headers added // Act @@ -76,7 +77,7 @@ public async Task GetCurrencyConfig_ShouldBeAccessibleWithoutAuthentication() public async Task GetLocalizationConfig_ShouldMatchAppSettings() { // Arrange - var client = TestHelpers.GetUnauthenticatedClient(); + var client = HttpClientHelpers.GetUnauthenticatedClient(); // Act var response = await client.GetAsync("/api/config/localization"); @@ -98,7 +99,7 @@ public async Task GetLocalizationConfig_ShouldMatchAppSettings() public async Task GetCurrencyConfig_ShouldMatchAppSettings() { // Arrange - var client = TestHelpers.GetUnauthenticatedClient(); + var client = HttpClientHelpers.GetUnauthenticatedClient(); // Act var response = await client.GetAsync("/api/config/currency"); diff --git a/tests/BookStore.AppHost.Tests/CorrelationTests.cs b/tests/BookStore.AppHost.Tests/CorrelationTests.cs index b7de34d..d2912d2 100644 --- a/tests/BookStore.AppHost.Tests/CorrelationTests.cs +++ b/tests/BookStore.AppHost.Tests/CorrelationTests.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Net.Http.Json; +using BookStore.AppHost.Tests.Helpers; using BookStore.ServiceDefaults; using Npgsql; @@ -17,7 +18,7 @@ public async Task ShouldPropagateCorrelationIdToMartenEvents() Assert.Fail("App not initialized"); } - var httpClient = await TestHelpers.GetAuthenticatedClientAsync(); + var httpClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var correlationId = Guid.NewGuid().ToString(); var fakeBookId = @@ -43,7 +44,7 @@ public async Task ShouldPropagateCorrelationIdToMartenEvents() // Act & Assert // We use ExecuteAndWaitForEventAsync to ensure the command is processed and events are persisted // before we check the database. Rating a book triggers a UserUpdated notification in this system. - var received = await TestHelpers.ExecuteAndWaitForEventAsync( + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( Guid.Empty, "UserUpdated", async () => @@ -121,7 +122,7 @@ public async Task ShouldGenerateAndPropagateCorrelationIdWhenMissing() Assert.Fail("App not initialized"); } - var httpClient = await TestHelpers.GetAuthenticatedClientAsync(); + var httpClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var fakeBookId = Guid.NewGuid(); @@ -134,7 +135,7 @@ public async Task ShouldGenerateAndPropagateCorrelationIdWhenMissing() // Act & Assert string? responseCorrelationId = null; - var received = await TestHelpers.ExecuteAndWaitForEventAsync( + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( Guid.Empty, "UserUpdated", async () => diff --git a/tests/BookStore.AppHost.Tests/CorsTests.cs b/tests/BookStore.AppHost.Tests/CorsTests.cs index a5eac3c..d29c9ac 100644 --- a/tests/BookStore.AppHost.Tests/CorsTests.cs +++ b/tests/BookStore.AppHost.Tests/CorsTests.cs @@ -1,6 +1,7 @@ using System.Net; using Aspire.Hosting; using Aspire.Hosting.Testing; +using BookStore.AppHost.Tests.Helpers; using Projects; namespace BookStore.AppHost.Tests; diff --git a/tests/BookStore.AppHost.Tests/DatabaseTests.cs b/tests/BookStore.AppHost.Tests/DatabaseTests.cs index 2eb5d95..cdfaaa9 100644 --- a/tests/BookStore.AppHost.Tests/DatabaseTests.cs +++ b/tests/BookStore.AppHost.Tests/DatabaseTests.cs @@ -1,4 +1,5 @@ using Aspire.Hosting; +using BookStore.AppHost.Tests.Helpers; using Npgsql; namespace BookStore.AppHost.Tests; diff --git a/tests/BookStore.AppHost.Tests/EmailVerificationTests.cs b/tests/BookStore.AppHost.Tests/EmailVerificationTests.cs index 06de028..b3c37c9 100644 --- a/tests/BookStore.AppHost.Tests/EmailVerificationTests.cs +++ b/tests/BookStore.AppHost.Tests/EmailVerificationTests.cs @@ -1,6 +1,7 @@ using System.Net; using Bogus; using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -17,7 +18,7 @@ public class EmailVerificationTests public EmailVerificationTests() { - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); _client = RestService.For(httpClient); _faker = new Faker(); } @@ -44,7 +45,7 @@ public async Task EmailVerification_FullFlow_ShouldSucceed() catch (ApiException ex) { _ = await Assert.That((int)ex.StatusCode).IsEqualTo((int)HttpStatusCode.Unauthorized); - var problem = await ex.GetContentAsAsync(); + var problem = await ex.GetContentAsAsync(); _ = await Assert.That(problem?.Error).IsEqualTo(ErrorCodes.Auth.EmailUnconfirmed); } diff --git a/tests/BookStore.AppHost.Tests/ErrorScenarioTests.cs b/tests/BookStore.AppHost.Tests/ErrorScenarioTests.cs index 7878b53..612af4b 100644 --- a/tests/BookStore.AppHost.Tests/ErrorScenarioTests.cs +++ b/tests/BookStore.AppHost.Tests/ErrorScenarioTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -11,8 +12,8 @@ public class ErrorScenarioTests public async Task CreateBook_WithoutAuth_ShouldReturnUnauthorized() { // Arrange - var client = RestService.For(TestHelpers.GetUnauthenticatedClient()); - var createBookRequest = TestHelpers.GenerateFakeBookRequest(); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); + var createBookRequest = FakeDataGenerators.GenerateFakeBookRequest(); // Act & Assert var exception = await Assert.That(async () => await client.CreateBookAsync(createBookRequest)) @@ -24,7 +25,7 @@ public async Task CreateBook_WithoutAuth_ShouldReturnUnauthorized() public async Task CreateBook_WithInvalidData_ShouldReturnBadRequest() { // Arrange - var client = RestService.For(await TestHelpers.GetAuthenticatedClientAsync()); + var client = RestService.For(await HttpClientHelpers.GetAuthenticatedClientAsync()); var createBookRequest = new CreateBookRequest { @@ -50,7 +51,7 @@ public async Task CreateBook_WithInvalidData_ShouldReturnBadRequest() public async Task GetBook_NotFound_ShouldReturn404() { // Arrange - var client = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); var nonExistentId = Guid.NewGuid(); // Act & Assert diff --git a/tests/BookStore.AppHost.Tests/FavoriteBooksTests.cs b/tests/BookStore.AppHost.Tests/FavoriteBooksTests.cs index 50a96dc..937de27 100644 --- a/tests/BookStore.AppHost.Tests/FavoriteBooksTests.cs +++ b/tests/BookStore.AppHost.Tests/FavoriteBooksTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -12,17 +13,17 @@ public class FavoriteBooksTests public async Task GetFavoriteBooks_WhenAuthenticated_ShouldReturnOnlyFavorites() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var userClient = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var userClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(); var client = RestService.For(userClient.Client); // Create 2 books - var book1 = await TestHelpers.CreateBookAsync(adminClient, TestHelpers.GenerateFakeBookRequest()); - var book2 = await TestHelpers.CreateBookAsync(adminClient, TestHelpers.GenerateFakeBookRequest()); + var book1 = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + var book2 = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); // Add 2 to favorites - await TestHelpers.AddToFavoritesAsync(userClient.Client, book1.Id, userClient.UserId); - await TestHelpers.AddToFavoritesAsync(userClient.Client, book2.Id, userClient.UserId); + await BookHelpers.AddToFavoritesAsync(userClient.Client, book1.Id, userClient.UserId); + await BookHelpers.AddToFavoritesAsync(userClient.Client, book2.Id, userClient.UserId); // Act var response = await client.GetFavoriteBooksAsync(new OrderedPagedRequest()); @@ -44,7 +45,7 @@ public async Task GetFavoriteBooks_WhenAuthenticated_ShouldReturnOnlyFavorites() public async Task GetFavoriteBooks_WhenNoFavorites_ShouldReturnEmpty() { // Arrange - Create a new authenticated user with no favorites - var userClient = await TestHelpers.CreateUserAndGetClientAsync(); + var userClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(); var client = RestService.For(userClient.Client); // Act @@ -60,7 +61,7 @@ public async Task GetFavoriteBooks_WhenNoFavorites_ShouldReturnEmpty() public async Task GetFavoriteBooks_WhenUnauthenticated_ShouldReturn401() { // Arrange - var unauthenticatedClient = TestHelpers.GetUnauthenticatedClient(); + var unauthenticatedClient = HttpClientHelpers.GetUnauthenticatedClient(); var client = RestService.For(unauthenticatedClient); // Act & Assert @@ -79,15 +80,15 @@ public async Task GetFavoriteBooks_WhenUnauthenticated_ShouldReturn401() public async Task GetFavoriteBooks_WithPagination_ShouldRespectPaging() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var userClient = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var userClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(); var client = RestService.For(userClient.Client); // Create and favorite at least 5 books for (var i = 0; i < 5; i++) { - var book = await TestHelpers.CreateBookAsync(adminClient, TestHelpers.GenerateFakeBookRequest()); - await TestHelpers.AddToFavoritesAsync(userClient.Client, book.Id, userClient.UserId); + var book = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + await BookHelpers.AddToFavoritesAsync(userClient.Client, book.Id, userClient.UserId); } // Act - Request first page with 3 items @@ -105,22 +106,22 @@ public async Task GetFavoriteBooks_WithPagination_ShouldRespectPaging() public async Task GetFavoriteBooks_WithSorting_ShouldApplySort() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var userClient = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var userClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(); var client = RestService.For(userClient.Client); // Create books with specific titles for sorting - var requestA = TestHelpers.GenerateFakeBookRequest(); + var requestA = FakeDataGenerators.GenerateFakeBookRequest(); requestA.Title = $"AAA Book {Guid.NewGuid()}"; - var bookA = await TestHelpers.CreateBookAsync(adminClient, requestA); + var bookA = await BookHelpers.CreateBookAsync(adminClient, requestA); - var requestZ = TestHelpers.GenerateFakeBookRequest(); + var requestZ = FakeDataGenerators.GenerateFakeBookRequest(); requestZ.Title = $"ZZZ Book {Guid.NewGuid()}"; - var bookZ = await TestHelpers.CreateBookAsync(adminClient, requestZ); + var bookZ = await BookHelpers.CreateBookAsync(adminClient, requestZ); // Add to favorites - await TestHelpers.AddToFavoritesAsync(userClient.Client, bookA.Id, userClient.UserId); - await TestHelpers.AddToFavoritesAsync(userClient.Client, bookZ.Id, userClient.UserId); + await BookHelpers.AddToFavoritesAsync(userClient.Client, bookA.Id, userClient.UserId); + await BookHelpers.AddToFavoritesAsync(userClient.Client, bookZ.Id, userClient.UserId); // Act - Sort by title descending var response = @@ -144,13 +145,13 @@ public async Task GetFavoriteBooks_WithSorting_ShouldApplySort() public async Task GetFavoriteBooks_AfterRemovingFavorite_ShouldNotIncludeBook() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var userClient = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var userClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(); var client = RestService.For(userClient.Client); - var book = await TestHelpers.CreateBookAsync(adminClient, TestHelpers.GenerateFakeBookRequest()); + var book = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); // Add to favorites - await TestHelpers.AddToFavoritesAsync(userClient.Client, book.Id, userClient.UserId); + await BookHelpers.AddToFavoritesAsync(userClient.Client, book.Id, userClient.UserId); // Verify it appears in favorites var response1 = await client.GetFavoriteBooksAsync(new OrderedPagedRequest()); @@ -159,7 +160,7 @@ public async Task GetFavoriteBooks_AfterRemovingFavorite_ShouldNotIncludeBook() _ = await Assert.That(favoriteIds1.Contains(book.Id)).IsTrue(); // Act - Remove from favorites - await TestHelpers.RemoveFromFavoritesAsync(userClient.Client, book.Id, userClient.UserId); + await BookHelpers.RemoveFromFavoritesAsync(userClient.Client, book.Id, userClient.UserId); // Assert - Verify it no longer appears var response2 = await client.GetFavoriteBooksAsync(new OrderedPagedRequest()); diff --git a/tests/BookStore.AppHost.Tests/FrontendTests.cs b/tests/BookStore.AppHost.Tests/FrontendTests.cs index 8e977fd..d4ad225 100644 --- a/tests/BookStore.AppHost.Tests/FrontendTests.cs +++ b/tests/BookStore.AppHost.Tests/FrontendTests.cs @@ -1,6 +1,7 @@ using System.Net; using Aspire.Hosting; using Aspire.Hosting.Testing; +using BookStore.AppHost.Tests.Helpers; using BookStore.ServiceDefaults; using Projects; diff --git a/tests/BookStore.AppHost.Tests/GlobalSetup.cs b/tests/BookStore.AppHost.Tests/GlobalSetup.cs index 0ad7645..11cabd0 100644 --- a/tests/BookStore.AppHost.Tests/GlobalSetup.cs +++ b/tests/BookStore.AppHost.Tests/GlobalSetup.cs @@ -3,6 +3,7 @@ using Aspire.Hosting.Testing; using BookStore.ApiService.Aggregates; using BookStore.ApiService.Events; +using BookStore.AppHost.Tests.Helpers; using BookStore.Shared.Models; using JasperFx; using JasperFx.Core; @@ -151,7 +152,7 @@ static async Task AuthenticateAdminAsync() // Retry login mechanism (less aggressive now that we control seeding) HttpResponseMessage? loginResponse = null; - await TestHelpers.WaitForConditionAsync(async () => + await SseEventHelpers.WaitForConditionAsync(async () => { try { diff --git a/tests/BookStore.AppHost.Tests/Helpers/AuthenticationHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/AuthenticationHelpers.cs new file mode 100644 index 0000000..632c7ca --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/AuthenticationHelpers.cs @@ -0,0 +1,147 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Aspire.Hosting; +using BookStore.ApiService.Infrastructure.Tenant; +using JasperFx; +using Refit; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class AuthenticationHelpers +{ + public static async Task LoginAsAdminAsync(string tenantId) + { + var app = GlobalHooks.App!; + using var client = app.CreateHttpClient("apiservice"); + return await LoginAsAdminAsync(client, tenantId); + } + + public static async Task LoginAsAdminAsync(HttpClient client, string tenantId) + { + var email = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) + ? "admin@bookstore.com" + : $"admin@{tenantId}.com"; + + var credentials = new { email, password = "Admin123!" }; + + // Simple retry logic + for (var i = 0; i < 3; i++) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/account/login") + { + Content = JsonContent.Create(credentials) + }; + request.Headers.Add("X-Tenant-ID", tenantId); + + var response = await client.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(); + } + + if (i == 2) // Last attempt + { + return null; + } + + await Task.Delay(TestConstants.DefaultPollingInterval); // Wait before retry + } + + return null; + } + + public static async Task CreateUserAndGetClientAsync(string? tenantId = null) + { + var app = GlobalHooks.App!; + var publicClient = app.CreateHttpClient("apiservice"); + var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; + publicClient.DefaultRequestHeaders.Add("X-Tenant-ID", actualTenantId); + + var email = $"user_{Guid.NewGuid()}@example.com"; + var password = "Password123!"; + + // Register + var registerRequest = new { email, password }; + var registerResponse = await publicClient.PostAsJsonAsync("/account/register", registerRequest); + if (!registerResponse.IsSuccessStatusCode) + { + } + + _ = registerResponse.EnsureSuccessStatusCode(); + + // Login + var loginRequest = new { email, password }; + var loginResponse = await publicClient.PostAsJsonAsync("/account/login", loginRequest); + if (!loginResponse.IsSuccessStatusCode) + { + } + + _ = loginResponse.EnsureSuccessStatusCode(); + + var tokenResponse = await loginResponse.Content.ReadFromJsonAsync(); + + // Decode JWT to verify claims + var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); + _ = handler.ReadJwtToken(tokenResponse!.AccessToken); + + var userId = Guid.Parse(handler.ReadJwtToken(tokenResponse!.AccessToken).Claims.First(c => c.Type == "sub") + .Value); + + // Create authenticated client + var authenticatedClient = app.CreateHttpClient("apiservice"); + authenticatedClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); + authenticatedClient.DefaultRequestHeaders.Add("X-Tenant-ID", actualTenantId); + + return new UserClient(authenticatedClient, userId); + } + + public record UserClient(HttpClient Client, Guid UserId); + + public static async Task CreateUserAndGetClientAsync(string? tenantId = null) + { + var userClient = await CreateUserAndGetClientAsync(tenantId); + return RestService.For(userClient.Client); + } + + public static async Task<(string email, string password, LoginResponse loginResponse, string tenantId)> + RegisterAndLoginUserAsync(string? tenantId = null) + { + tenantId ??= StorageConstants.DefaultTenantId; + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + + var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); + var registerResponse = await client.PostAsJsonAsync("/account/register", new { email, password }); + _ = registerResponse.EnsureSuccessStatusCode(); + + var loginResponse = await client.PostAsJsonAsync("/account/login", new { email, password }); + _ = loginResponse.EnsureSuccessStatusCode(); + + var tokenResponse = await loginResponse.Content.ReadFromJsonAsync(); + if (tokenResponse == null) + { + throw new InvalidOperationException("Login response was null."); + } + + return (email, password, tokenResponse, tenantId); + } + + public record LoginResponse(string AccessToken, string RefreshToken); + + public record ErrorResponse( + [property: JsonPropertyName("error")] + string Error, + string Message); + + public record MessageResponse(string Message); + + public record ValidationProblemDetails( + string? Title = null, + int? Status = null, + string? Detail = null, + [property: JsonPropertyName("error")] + string? Error = null); +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/AuthorHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/AuthorHelpers.cs new file mode 100644 index 0000000..ea26784 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/AuthorHelpers.cs @@ -0,0 +1,167 @@ +using BookStore.Client; +using BookStore.Shared.Models; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class AuthorHelpers +{ + public static async Task CreateAuthorAsync(IAuthorsClient client, + CreateAuthorRequest createAuthorRequest) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + createAuthorRequest.Id, + ["AuthorCreated", "AuthorUpdated"], + async () => + { + var response = await client.CreateAuthorWithResponseAsync(createAuthorRequest); + if (response.Error != null) + { + throw response.Error; + } + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Failed to receive AuthorCreated event."); + } + + return await client.GetAuthorAsync(createAuthorRequest.Id); + } + + public static async Task UpdateAuthorAsync(IAuthorsClient client, AuthorDto author, + UpdateAuthorRequest updateRequest) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(author.ETag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + author.Id, + "AuthorUpdated", + async () => await client.UpdateAuthorAsync(author.Id, updateRequest, author.ETag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Failed to receive AuthorUpdated event after UpdateAuthor."); + } + + return await client.GetAuthorAsync(author.Id); + } + + public static async Task UpdateAuthorAsync(IAuthorsClient client, AdminAuthorDto author, + UpdateAuthorRequest updateRequest) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(author.ETag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + author.Id, + "AuthorUpdated", + async () => await client.UpdateAuthorAsync(author.Id, updateRequest, author.ETag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Failed to receive AuthorUpdated event after UpdateAuthor."); + } + + return await client.GetAuthorAdminAsync(author.Id); + } + + public static async Task DeleteAuthorAsync(IAuthorsClient client, AuthorDto author) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + author.Id, + "AuthorDeleted", + async () => + { + var etag = author.ETag; + if (string.IsNullOrEmpty(etag)) + { + var latestAuthor = await client.GetAuthorAdminAsync(author.Id); + etag = latestAuthor?.ETag; + } + + await client.SoftDeleteAuthorAsync(author.Id, etag); + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Failed to receive AuthorDeleted event after DeleteAuthor."); + } + + return await client.GetAuthorAsync(author.Id); + } + + public static async Task DeleteAuthorAsync(IAuthorsClient client, AdminAuthorDto author) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + author.Id, + "AuthorDeleted", + async () => + { + var etag = author.ETag; + if (string.IsNullOrEmpty(etag)) + { + var latestAuthor = await client.GetAuthorAdminAsync(author.Id); + etag = latestAuthor?.ETag; + } + + await client.SoftDeleteAuthorAsync(author.Id, etag); + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Failed to receive AuthorDeleted event after DeleteAuthor."); + } + + return await client.GetAuthorAdminAsync(author.Id); + } + + public static async Task RestoreAuthorAsync(IAuthorsClient client, AuthorDto author) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + author.Id, + "AuthorUpdated", + async () => + { + var latestAuthor = await client.GetAuthorAdminAsync(author.Id); + var etag = latestAuthor?.ETag; + + await client.RestoreAuthorAsync(author.Id, etag); + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Failed to receive AuthorUpdated event after RestoreAuthor."); + } + + return await client.GetAuthorAsync(author.Id); + } + + public static async Task RestoreAuthorAsync(IAuthorsClient client, AdminAuthorDto author) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + author.Id, + "AuthorUpdated", + async () => + { + var latestAuthor = await client.GetAuthorAdminAsync(author.Id); + var etag = latestAuthor?.ETag; + + await client.RestoreAuthorAsync(author.Id, etag); + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Failed to receive AuthorUpdated event after RestoreAuthor."); + } + + return await client.GetAuthorAdminAsync(author.Id); + } +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/BookHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/BookHelpers.cs new file mode 100644 index 0000000..5cc7b22 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/BookHelpers.cs @@ -0,0 +1,457 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using BookStore.Client; +using BookStore.Shared.Models; +using TUnit.Assertions.Extensions; +using CreateBookRequest = BookStore.Client.CreateBookRequest; +using UpdateBookRequest = BookStore.Client.UpdateBookRequest; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class BookHelpers +{ + public static async Task CreateBookAsync(HttpClient httpClient, object createBookRequest) + { + // Try to get Id from the request object if it's one of ours + var entityId = Guid.Empty; + if (createBookRequest is CreateBookRequest req) + { + entityId = req.Id; + } + + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + entityId, + [ + "BookCreated", "BookUpdated" + ], // Async projections may report as Update regardless of Insert/Update + async () => + { + var createResponse = await httpClient.PostAsJsonAsync("/api/admin/books", createBookRequest); + if (!createResponse.IsSuccessStatusCode) + { + } + + _ = createResponse.EnsureSuccessStatusCode(); + if (entityId == Guid.Empty) + { + var createdBook = await createResponse.Content.ReadFromJsonAsync(); + entityId = createdBook?.Id ?? Guid.Empty; + } + }, + TestConstants.DefaultEventTimeout); + + if (!received || entityId == Guid.Empty) + { + throw new Exception("Failed to create book or receive BookUpdated event."); + } + + return (await httpClient.GetFromJsonAsync($"/api/books/{entityId}"))!; + } + + public static async Task CreateBookAsync(IBooksClient client, CreateBookRequest createBookRequest) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + createBookRequest.Id, + ["BookCreated", "BookUpdated"], + async () => + { + var response = await client.CreateBookWithResponseAsync(createBookRequest); + if (response.Error != null) + { + throw response.Error; + } + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Failed to receive BookCreated event."); + } + + return await client.GetBookAsync(createBookRequest.Id); + } + + public static async Task CreateBookAsync(HttpClient httpClient, Guid? publisherId = null, + IEnumerable? authorIds = null, IEnumerable? categoryIds = null) + { + // Ensure dependencies exist + if (publisherId == null) + { + var pClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var pub = await PublisherHelpers.CreatePublisherAsync(pClient, FakeDataGenerators.GenerateFakePublisherRequest()); + publisherId = pub.Id; + } + + if (authorIds == null || !authorIds.Any()) + { + var aClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var auth = await AuthorHelpers.CreateAuthorAsync(aClient, FakeDataGenerators.GenerateFakeAuthorRequest()); + authorIds = [auth.Id]; + } + + if (categoryIds == null || !categoryIds.Any()) + { + var cClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var cat = await CategoryHelpers.CreateCategoryAsync(cClient, FakeDataGenerators.GenerateFakeCategoryRequest()); + categoryIds = [cat.Id]; + } + + var createBookRequest = FakeDataGenerators.GenerateFakeBookRequest(publisherId, authorIds, categoryIds); + return await CreateBookAsync(httpClient, createBookRequest); + } + + public static async Task CreateBookAsync(IBooksClient client, Guid? publisherId = null, + IEnumerable? authorIds = null, IEnumerable? categoryIds = null) + { + // Ensure dependencies exist + if (publisherId == null) + { + var pClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var pub = await PublisherHelpers.CreatePublisherAsync(pClient, FakeDataGenerators.GenerateFakePublisherRequest()); + publisherId = pub.Id; + } + + if (authorIds == null || !authorIds.Any()) + { + var aClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var auth = await AuthorHelpers.CreateAuthorAsync(aClient, FakeDataGenerators.GenerateFakeAuthorRequest()); + authorIds = [auth.Id]; + } + + if (categoryIds == null || !categoryIds.Any()) + { + var cClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var cat = await CategoryHelpers.CreateCategoryAsync(cClient, FakeDataGenerators.GenerateFakeCategoryRequest()); + categoryIds = [cat.Id]; + } + + var createBookRequest = FakeDataGenerators.GenerateFakeBookRequest(publisherId, authorIds, categoryIds); + return await CreateBookAsync(client, createBookRequest); + } + + public static async Task UpdateBookAsync(HttpClient client, Guid bookId, object updatePayload, string etag) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + bookId, + "BookUpdated", + async () => + { + var updateRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/admin/books/{bookId}") + { + Content = JsonContent.Create(updatePayload) + }; + updateRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(etag)); + + var updateResponse = await client.SendAsync(updateRequest); + if (!updateResponse.IsSuccessStatusCode) + { + } + + _ = await Assert.That(updateResponse.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Timed out waiting for BookUpdated event after UpdateBook."); + } + } + + public static async Task UpdateBookAsync(IBooksClient client, Guid bookId, UpdateBookRequest updatePayload, + string etag) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + bookId, + "BookUpdated", + async () => await client.UpdateBookAsync(bookId, updatePayload, etag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Timed out waiting for BookUpdated event after UpdateBook."); + } + + return await client.GetBookAsync(bookId); + } + + public static async Task UpdateBookAsync(IBooksClient client, AdminBookDto book, + UpdateBookRequest request) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(book.ETag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + book.Id, + "BookUpdated", + async () => await client.UpdateBookAsync(book.Id, request, book.ETag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Failed to receive BookUpdated event."); + } + + return await client.GetBookAdminAsync(book.Id); + } + + // Helper to accept generic object and cast if possible or use fake request + public static async Task UpdateBookAsync(IBooksClient client, Guid bookId, object updatePayload, + string etag) + { + var json = JsonSerializer.Serialize(updatePayload); + var request = JsonSerializer.Deserialize(json); + return await UpdateBookAsync(client, bookId, request!, etag); + } + + public static async Task DeleteBookAsync(HttpClient client, Guid bookId, string etag) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + bookId, + "BookDeleted", + async () => + { + var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/books/{bookId}"); + deleteRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(etag)); + + var deleteResponse = await client.SendAsync(deleteRequest); + if (!deleteResponse.IsSuccessStatusCode) + { + } + + _ = await Assert.That(deleteResponse.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Timed out waiting for BookDeleted event after DeleteBook."); + } + } + + public static async Task DeleteBookAsync(IBooksClient client, Guid bookId, string etag) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + bookId, + ["BookDeleted", "BookSoftDeleted"], + async () => await client.SoftDeleteBookAsync(bookId, etag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Timed out waiting for BookSoftDeleted event after DeleteBook."); + } + + return await client.GetBookAdminAsync(bookId); + } + + public static async Task DeleteBookAsync(IBooksClient client, BookDto book) + { + var etag = book.ETag; + if (string.IsNullOrEmpty(etag)) + { + var latest = await client.GetBookAsync(book.Id); + etag = latest.ETag; + } + + return await DeleteBookAsync(client, book.Id, etag!); + } + + public static async Task RestoreBookAsync(HttpClient client, Guid bookId, string etag) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + bookId, + ["BookUpdated", "BookRestored"], + async () => + { + var restoreResponse = await client.PostAsync($"/api/admin/books/{bookId}/restore", null); + if (!restoreResponse.IsSuccessStatusCode) + { + } + + _ = await Assert.That(restoreResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + }, + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Timed out waiting for BookUpdated event after RestoreBook."); + } + } + + public static async Task RestoreBookAsync(IBooksClient client, Guid bookId, string? etag = null) + { + var currentETag = etag; + if (string.IsNullOrEmpty(currentETag)) + { + // Use Admin endpoint to get the book, including soft-deleted ones, to get the ETag + var book = await client.GetBookAdminAsync(bookId); + currentETag = book?.ETag; + Console.WriteLine($"[TestHelpers] Fetched ETag for restore: {currentETag}"); + } + + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(currentETag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + bookId, + ["BookUpdated", "BookRestored"], + async () => await client.RestoreBookAsync(bookId, apiVersion: "1.0", etag: currentETag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Timed out waiting for BookUpdated event after RestoreBook."); + } + + return await client.GetBookAsync(bookId); + } + + public static async Task RateBookAsync(HttpClient client, Guid bookId, int rating, Guid? expectedEntityId = null, + string expectedEvent = "UserUpdated") + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + expectedEvent, + async () => + { + var response = await client.PostAsJsonAsync($"/api/books/{bookId}/rating", new { Rating = rating }); + _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception($"Timed out waiting for {expectedEvent} event after RateBook."); + } + } + + public static async Task RateBookAsync(IBooksClient client, Guid bookId, int rating, Guid expectedEntityId, + string expectedEvent) => await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId, + expectedEvent, + () => client.RateBookAsync(bookId, new RateBookRequest(rating)), + TimeSpan.FromSeconds(10), // Increased timeout + minTimestamp: DateTimeOffset.UtcNow); // Use current time to avoid stale events + + public static async Task RemoveRatingAsync(HttpClient client, Guid bookId, Guid? expectedEntityId = null, + string expectedEvent = "UserUpdated") + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + expectedEvent, + async () => + { + var response = await client.DeleteAsync($"/api/books/{bookId}/rating"); + _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception($"Timed out waiting for {expectedEvent} event after RemoveRating."); + } + } + + public static async Task RemoveRatingAsync(IBooksClient client, Guid bookId, Guid expectedEntityId, + string expectedEvent) => await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId, + expectedEvent, + () => client.RemoveBookRatingAsync(bookId), + TimeSpan.FromSeconds(10), + minTimestamp: DateTimeOffset.UtcNow); + + public static async Task AddToFavoritesAsync(HttpClient client, Guid bookId, Guid? expectedEntityId = null, + string expectedEvent = "UserUpdated") + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + expectedEvent, + async () => + { + var response = await client.PostAsync($"/api/books/{bookId}/favorites", null); + _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception($"Timed out waiting for {expectedEvent} event after AddToFavorites."); + } + } + + public static async Task AddToFavoritesAsync(IBooksClient client, Guid bookId, Guid? expectedEntityId = null, + string expectedEvent = "UserUpdated") + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + expectedEvent, + async () => await client.AddBookToFavoritesAsync(bookId, + null), + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception($"Timed out waiting for {expectedEvent} event after AddToFavorites."); + } + } + + public static async Task RemoveFromFavoritesAsync(HttpClient client, Guid bookId, Guid? expectedEntityId = null, + string expectedEvent = "UserUpdated") + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + expectedEvent, + async () => + { + var response = await client.DeleteAsync($"/api/books/{bookId}/favorites"); + _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception($"Timed out waiting for {expectedEvent} event after RemoveFromFavorites."); + } + } + + public static async Task RemoveFromFavoritesAsync(IBooksClient client, Guid bookId, Guid? expectedEntityId = null, + string expectedEvent = "UserUpdated") + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + expectedEvent, + async () => + { + var book = await client.GetBookAsync(bookId); + await client.RemoveBookFromFavoritesAsync(bookId, book?.ETag); + }, + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception($"Timed out waiting for {expectedEvent} event after RemoveFromFavorites."); + } + } +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/CategoryHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/CategoryHelpers.cs new file mode 100644 index 0000000..a65365c --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/CategoryHelpers.cs @@ -0,0 +1,115 @@ +using BookStore.Client; +using BookStore.Shared.Models; +using Refit; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class CategoryHelpers +{ + public static async Task CreateCategoryAsync(ICategoriesClient client, CreateCategoryRequest request) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + request.Id, + ["CategoryCreated", "CategoryUpdated"], + async () => + { + var response = await client.CreateCategoryWithResponseAsync(request); + if (response.Error != null) + { + throw response.Error; + } + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Failed to receive CategoryCreated event."); + } + + return await client.GetCategoryAsync(request.Id); + } + + public static async Task UpdateCategoryAsync(ICategoriesClient client, CategoryDto category, + UpdateCategoryRequest request) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(category.ETag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + category.Id, + "CategoryUpdated", + async () => await client.UpdateCategoryAsync(category.Id, request, category.ETag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Failed to receive CategoryUpdated event."); + } + + return await client.GetCategoryAsync(category.Id); + } + + public static async Task UpdateCategoryAsync(ICategoriesClient client, AdminCategoryDto category, + UpdateCategoryRequest request) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(category.ETag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + category.Id, + "CategoryUpdated", + async () => await client.UpdateCategoryAsync(category.Id, request, category.ETag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Failed to receive CategoryUpdated event."); + } + + return await client.GetCategoryAdminAsync(category.Id); + } + + public static async Task DeleteCategoryAsync(ICategoriesClient client, CategoryDto category) + { + var result = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + category.Id, + "CategoryDeleted", + async () => await client.SoftDeleteCategoryAsync(category.Id, category.ETag), + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!result.Success) + { + throw new Exception("Failed to receive CategoryDeleted event."); + } + + try + { + return await client.GetCategoryAsync(category.Id); + } + catch (Refit.ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // Soft-deleted, hidden from public API. Construct DTO with reconstructed ETag. + return category with { ETag = $"\"{result.Version}\"" }; + } + } + + public static async Task RestoreCategoryAsync(ICategoriesClient client, CategoryDto category) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(category.ETag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + category.Id, + "CategoryUpdated", + async () => await client.RestoreCategoryAsync(category.Id, category.ETag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Failed to receive CategoryUpdated event (Restore)."); + } + + return await client.GetCategoryAsync(category.Id); + } +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/DatabaseHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/DatabaseHelpers.cs new file mode 100644 index 0000000..9fc1ca4 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/DatabaseHelpers.cs @@ -0,0 +1,99 @@ +using Aspire.Hosting; +using BookStore.ApiService.Infrastructure.Tenant; +using JasperFx; +using Marten; +using Weasel.Core; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class DatabaseHelpers +{ + public static async Task SeedTenantAsync(Marten.IDocumentStore store, string tenantId) + { + // 1. Ensure Tenant document exists in Marten's native default bucket (for validation) + await using (var tenantSession = store.LightweightSession()) + { + var existingTenant = await tenantSession.LoadAsync(tenantId); + if (existingTenant == null) + { + tenantSession.Store(new BookStore.ApiService.Models.Tenant + { + Id = tenantId, + Name = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) + ? "BookStore" + : (char.ToUpper(tenantId[0]) + tenantId[1..] + " Corp"), + IsEnabled = true, + CreatedAt = DateTimeOffset.UtcNow + }); + await tenantSession.SaveChangesAsync(); + } + } + + // 2. Seed Admin User in the tenant's own bucket + await using var session = store.LightweightSession(tenantId); + + var adminEmail = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) + ? "admin@bookstore.com" + : $"admin@{tenantId}.com"; + + // We still use manual store here as TestHelpers might be used in light setup contexts + // but we fix the normalization mismatch + var existingUser = await session.Query() + .Where(u => u.Email == adminEmail) + .FirstOrDefaultAsync(); + + if (existingUser == null) + { + var adminUser = new BookStore.ApiService.Models.ApplicationUser + { + UserName = adminEmail, + NormalizedUserName = adminEmail.ToUpperInvariant(), + Email = adminEmail, + NormalizedEmail = adminEmail.ToUpperInvariant(), + EmailConfirmed = true, + Roles = ["Admin"], + SecurityStamp = Guid.CreateVersion7().ToString("D"), + ConcurrencyStamp = Guid.CreateVersion7().ToString("D") + }; + + var hasher = + new Microsoft.AspNetCore.Identity.PasswordHasher(); + adminUser.PasswordHash = hasher.HashPassword(adminUser, "Admin123!"); + + session.Store(adminUser); + await session.SaveChangesAsync(); + } + } + + /// + /// Gets a configured IDocumentStore instance for direct database access in tests. + /// + /// A configured IDocumentStore with multi-tenancy support. + public static async Task GetDocumentStoreAsync() + { + var connectionString = await GlobalHooks.App!.GetConnectionStringAsync("bookstore"); + return DocumentStore.For(opts => + { + opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); + opts.Connection(connectionString!); + _ = opts.Policies.AllDocumentsAreMultiTenanted(); + opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; + }); + } + + /// + /// Gets a user by email from the database. + /// + /// The Marten session to query from. + /// The user's email address. + /// The ApplicationUser if found, null otherwise. + public static async Task GetUserByEmailAsync( + IDocumentSession session, + string email) + { + var normalizedEmail = email.ToUpperInvariant(); + return await session.Query() + .Where(u => u.NormalizedEmail == normalizedEmail) + .FirstOrDefaultAsync(); + } +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/FakeDataGenerators.cs b/tests/BookStore.AppHost.Tests/Helpers/FakeDataGenerators.cs new file mode 100644 index 0000000..1f59396 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/FakeDataGenerators.cs @@ -0,0 +1,135 @@ +using Bogus; +using BookStore.Client; +using BookStore.Shared.Models; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class FakeDataGenerators +{ + static readonly Faker _faker = new(); + + /// + /// Generates a random password that meets common password requirements. + /// + /// A password with at least 12 characters including uppercase, lowercase, numbers, and special characters. + public static string GenerateFakePassword() => _faker.Internet.Password(12, false, "", "Aa1!"); + + /// + /// Generates a random email address for testing. + /// + /// A valid email address. + public static string GenerateFakeEmail() => _faker.Internet.Email(); + + /// + /// Generates a fake book creation request with random data using Bogus. + /// + /// Optional publisher ID. If null, the book will have no publisher. + /// Optional collection of author IDs. If null or empty, the book will have no authors. + /// Optional collection of category IDs. If null or empty, the book will have no categories. + /// A CreateBookRequest with randomized title, ISBN, translations, and prices. + public static CreateBookRequest + GenerateFakeBookRequest(Guid? publisherId = null, IEnumerable? authorIds = null, + IEnumerable? categoryIds = null) => new() + { + Id = Guid.CreateVersion7(), + Title = _faker.Commerce.ProductName(), + Isbn = _faker.Commerce.Ean13(), + Language = "en", + Translations = + new Dictionary + { + ["en"] = new(_faker.Lorem.Paragraph()), + ["es"] = new(_faker.Lorem.Paragraph()) + }, + PublicationDate = new PartialDate( + _faker.Date.Past(10).Year, + _faker.Random.Int(1, 12), + _faker.Random.Int(1, 28)), + PublisherId = publisherId, + AuthorIds = [.. (authorIds ?? [])], + CategoryIds = [.. (categoryIds ?? [])], + Prices = new Dictionary { ["USD"] = decimal.Parse(_faker.Commerce.Price(10, 100)) } + }; + + public static UpdateBookRequest + GenerateFakeUpdateBookRequest(Guid? publisherId = null, IEnumerable? authorIds = null, + IEnumerable? categoryIds = null) => new() + { + Title = _faker.Commerce.ProductName(), + Isbn = _faker.Commerce.Ean13(), + Language = "en", + Translations = + new Dictionary + { + ["en"] = new(_faker.Lorem.Paragraph()), + ["es"] = new(_faker.Lorem.Paragraph()) + }, + PublicationDate = new PartialDate( + _faker.Date.Past(10).Year, + _faker.Random.Int(1, 12), + _faker.Random.Int(1, 28)), + PublisherId = publisherId, + AuthorIds = [.. (authorIds ?? [])], + CategoryIds = [.. (categoryIds ?? [])], + Prices = new Dictionary { ["USD"] = decimal.Parse(_faker.Commerce.Price(10, 100)) } + }; + + /// + /// Generates a fake author creation request with random data using Bogus. + /// + /// A CreateAuthorRequest with randomized name and biography in English and Spanish. + public static CreateAuthorRequest GenerateFakeAuthorRequest() => new() + { + Id = Guid.CreateVersion7(), + Name = _faker.Name.FullName(), + Translations = new Dictionary + { + ["en"] = new(_faker.Lorem.Paragraphs(2)), + ["es"] = new(_faker.Lorem.Paragraphs(2)) + } + }; + + public static BookStore.Client.UpdateAuthorRequest GenerateFakeUpdateAuthorRequest() => new() + { + Name = _faker.Name.FullName(), + Translations = new Dictionary + { + ["en"] = new(_faker.Lorem.Paragraphs(2)), + ["es"] = new(_faker.Lorem.Paragraphs(2)) + } + }; + + /// + /// Generates a fake category creation request with random data using Bogus. + /// + /// A CreateCategoryRequest with randomized name and description in English and Spanish. + public static CreateCategoryRequest GenerateFakeCategoryRequest() => new() + { + Id = Guid.CreateVersion7(), + Translations = new Dictionary + { + ["en"] = new(_faker.Commerce.Department()), + ["es"] = new(_faker.Commerce.Department()) + } + }; + + /// + /// Generates a fake category update request with random data using Bogus. + /// + /// An UpdateCategoryRequest with randomized name and description in English and Spanish. + public static BookStore.Client.UpdateCategoryRequest GenerateFakeUpdateCategoryRequest() => new() + { + Translations = new Dictionary + { + ["en"] = new(_faker.Commerce.Department()), + ["es"] = new(_faker.Commerce.Department()) + } + }; + + /// + /// Generates a fake publisher creation request with random data using Bogus. + /// + /// A CreatePublisherRequest with a randomized company name. + public static CreatePublisherRequest GenerateFakePublisherRequest() + => new() { Id = Guid.CreateVersion7(), Name = _faker.Company.CompanyName() }; +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/HttpClientHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/HttpClientHelpers.cs new file mode 100644 index 0000000..6a60b89 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/HttpClientHelpers.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Headers; +using Aspire.Hosting; +using BookStore.ApiService.Infrastructure.Tenant; +using JasperFx; +using Refit; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class HttpClientHelpers +{ + public static HttpClient GetAuthenticatedClient(string accessToken) + { + var client = GetUnauthenticatedClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return client; + } + + public static HttpClient GetAuthenticatedClient(string accessToken, string tenantId) + { + var client = GetUnauthenticatedClient(tenantId); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return client; + } + + public static async Task GetAuthenticatedClientAsync() + { + var app = GlobalHooks.App!; + var client = app.CreateHttpClient("apiservice"); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", GlobalHooks.AdminAccessToken); + client.DefaultRequestHeaders.Add("X-Tenant-ID", StorageConstants.DefaultTenantId); + return await Task.FromResult(client); + } + + /// + /// Gets an authenticated HTTP client for the API service using the global admin token. + /// + /// The Refit interface type to create a client for. + /// A Refit client instance configured with admin authentication. + public static async Task GetAuthenticatedClientAsync() + { + var httpClient = await GetAuthenticatedClientAsync(); + return RestService.For(httpClient); + } + + public static HttpClient GetUnauthenticatedClient() + => GetUnauthenticatedClient(StorageConstants.DefaultTenantId); + + public static HttpClient GetUnauthenticatedClient(string tenantId) + { + var app = GlobalHooks.App!; + var client = app.CreateHttpClient("apiservice"); + client.DefaultRequestHeaders.Add("X-Tenant-ID", tenantId); + return client; + } + + public static T GetUnauthenticatedClient() + { + var httpClient = GetUnauthenticatedClient(); + return RestService.For(httpClient); + } + + public static T GetUnauthenticatedClientWithLanguage(string language) + { + var httpClient = GetUnauthenticatedClient(); + httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd(language); + return RestService.For(httpClient); + } + + public static async Task GetTenantClientAsync(string tenantId, string accessToken) + { + var app = GlobalHooks.App!; + var client = app.CreateHttpClient("apiservice"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + client.DefaultRequestHeaders.Add("X-Tenant-ID", tenantId); + return await Task.FromResult(client); + } +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/PublisherHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/PublisherHelpers.cs new file mode 100644 index 0000000..88ef18a --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/PublisherHelpers.cs @@ -0,0 +1,95 @@ +using BookStore.Client; +using BookStore.Shared.Models; +using Refit; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class PublisherHelpers +{ + public static async Task CreatePublisherAsync(IPublishersClient client, + CreatePublisherRequest request) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + request.Id, + ["PublisherCreated", "PublisherUpdated"], + async () => + { + var response = await client.CreatePublisherWithResponseAsync(request); + if (response.Error != null) + { + throw response.Error; + } + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Failed to receive PublisherCreated event."); + } + + var result = await client.GetAllPublishersAsync(new PublisherSearchRequest { Search = request.Name }); + return result!.Items.First(p => p.Id == request.Id); + } + + public static async Task UpdatePublisherAsync(IPublishersClient client, PublisherDto publisher, + UpdatePublisherRequest request) + { + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(publisher.ETag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + publisher.Id, + "PublisherUpdated", + async () => await client.UpdatePublisherAsync(publisher.Id, request, publisher.ETag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received.Success) + { + throw new Exception("Failed to receive PublisherUpdated event."); + } + + return await client.GetPublisherAsync(publisher.Id); + } + + public static async Task DeletePublisherAsync(IPublishersClient client, PublisherDto publisher) + { + var result = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( + publisher.Id, + "PublisherDeleted", + async () => await client.SoftDeletePublisherAsync(publisher.Id, publisher.ETag), + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!result.Success) + { + throw new Exception("Failed to receive PublisherDeleted event."); + } + + try + { + return await client.GetPublisherAsync(publisher.Id); + } + catch (Refit.ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // Soft-deleted, hidden from public API. Construct DTO with reconstructed ETag. + return publisher with { ETag = $"\"{result.Version}\"" }; + } + } + + public static async Task RestorePublisherAsync(IPublishersClient client, PublisherDto publisher) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + publisher.Id, + "PublisherUpdated", + async () => await client.RestorePublisherAsync(publisher.Id, publisher.ETag), + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception("Failed to receive PublisherUpdated event (Restore)."); + } + + return await client.GetPublisherAsync(publisher.Id); + } +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/ShoppingCartHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/ShoppingCartHelpers.cs new file mode 100644 index 0000000..b555430 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/ShoppingCartHelpers.cs @@ -0,0 +1,159 @@ +using System.Net.Http.Json; +using BookStore.Client; +using BookStore.Shared.Models; +using TUnit.Assertions.Extensions; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class ShoppingCartHelpers +{ + public static async Task AddToCartAsync(HttpClient client, Guid bookId, int quantity = 1, + Guid? expectedEntityId = null) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + "UserUpdated", + async () => + { + var response = + await client.PostAsJsonAsync("/api/cart/items", new AddToCartClientRequest(bookId, quantity)); + _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout); + + if (!received) + { + throw new Exception("Timed out waiting for UserUpdated event after AddToCart."); + } + } + + public static async Task AddToCartAsync(IShoppingCartClient client, Guid bookId, int quantity = 1, + Guid? expectedEntityId = null) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + "UserUpdated", + async () => await client.AddToCartAsync(new AddToCartClientRequest(bookId, quantity)), + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception("Timed out waiting for UserUpdated event after AddToCart."); + } + } + + public static async Task UpdateCartItemQuantityAsync(HttpClient client, Guid bookId, int quantity, + Guid? expectedEntityId = null) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + "UserUpdated", + async () => + { + var response = await client.PutAsJsonAsync($"/api/cart/items/{bookId}", + new UpdateCartItemClientRequest(quantity)); + _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception("Timed out waiting for UserUpdated event after UpdateCartItemQuantity."); + } + } + + public static async Task UpdateCartItemQuantityAsync(IShoppingCartClient client, Guid bookId, int quantity, + Guid? expectedEntityId = null) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + "UserUpdated", + async () => await client.UpdateCartItemAsync(bookId, new UpdateCartItemClientRequest(quantity)), + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception("Timed out waiting for UserUpdated event after UpdateCartItemQuantity."); + } + } + + public static async Task RemoveFromCartAsync(HttpClient client, Guid bookId, Guid? expectedEntityId = null) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + "UserUpdated", + async () => + { + var response = await client.DeleteAsync($"/api/cart/items/{bookId}"); + _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception("Timed out waiting for UserUpdated event after RemoveFromCart."); + } + } + + public static async Task RemoveFromCartAsync(IShoppingCartClient client, Guid bookId, Guid? expectedEntityId = null) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + "UserUpdated", + async () => await client.RemoveFromCartAsync(bookId), + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception("Timed out waiting for UserUpdated event after RemoveFromCart."); + } + } + + public static async Task ClearCartAsync(HttpClient client, Guid? expectedEntityId = null) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + "UserUpdated", + async () => + { + var response = await client.DeleteAsync("/api/cart"); + _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); + }, + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception("Timed out waiting for UserUpdated event after ClearCart."); + } + } + + public static async Task ClearCartAsync(IShoppingCartClient client, Guid? expectedEntityId = null) + { + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + expectedEntityId ?? Guid.Empty, + "UserUpdated", + async () => await client.ClearCartAsync(), + TestConstants.DefaultEventTimeout, + minTimestamp: DateTimeOffset.UtcNow); + + if (!received) + { + throw new Exception("Timed out waiting for UserUpdated event after ClearCart."); + } + } + + public static async Task EnsureCartIsEmptyAsync(HttpClient client) + { + var cart = await client.GetFromJsonAsync("/api/cart"); + if (cart != null && cart.TotalItems > 0) + { + await ClearCartAsync(client); + } + } +} diff --git a/tests/BookStore.AppHost.Tests/Helpers/SseEventHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/SseEventHelpers.cs new file mode 100644 index 0000000..d283e4e --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/SseEventHelpers.cs @@ -0,0 +1,274 @@ +using System.Net.Http.Headers; +using System.Net.ServerSentEvents; +using System.Text.Json; +using Aspire.Hosting; +using BookStore.ApiService.Infrastructure.Tenant; +using JasperFx; + +namespace BookStore.AppHost.Tests.Helpers; + +public static class SseEventHelpers +{ + /// + /// Executes an action while listening for a specific SSE event. + /// This ensures the SSE client is connected BEFORE the action is performed, + /// simulating a real client that's already listening for changes. + /// + /// The entity ID to match, or Guid.Empty to match any entity + /// The event type to listen for (e.g., "CategoryCreated") + /// The action to perform (e.g., create/update/delete) + /// How long to wait for the event + public static async Task ExecuteAndWaitForEventAsync( + Guid entityId, + string eventType, + Func action, + TimeSpan timeout, + long minVersion = 0, + DateTimeOffset? minTimestamp = null) + => (await ExecuteAndWaitForEventWithVersionAsync(entityId, eventType, action, timeout, minVersion, + minTimestamp)) + .Success; + + public static async Task ExecuteAndWaitForEventWithVersionAsync( + Guid entityId, + string eventType, + Func action, + TimeSpan timeout, + long minVersion = 0, + DateTimeOffset? minTimestamp = null) + => await ExecuteAndWaitForEventWithVersionAsync(entityId, [eventType], action, timeout, minVersion, + minTimestamp); + + public record EventResult(bool Success, long Version); + + public static async Task ExecuteAndWaitForEventAsync( + Guid entityId, + string[] eventTypes, + Func action, + TimeSpan timeout, + long minVersion = 0, + DateTimeOffset? minTimestamp = null) + => (await ExecuteAndWaitForEventWithVersionAsync(entityId, eventTypes, action, timeout, minVersion, + minTimestamp)) + .Success; + + public static async Task ExecuteAndWaitForEventWithVersionAsync( + Guid entityId, + string[] eventTypes, + Func action, + TimeSpan timeout, + long minVersion = 0, + DateTimeOffset? minTimestamp = null) + { + var matchAnyId = entityId == Guid.Empty; + var receivedEvents = new List(); + + var app = GlobalHooks.App!; + using var client = app.CreateHttpClient("apiservice"); + client.Timeout = TestConstants.DefaultStreamTimeout; // Prevent Aspire default timeout from killing the stream + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", GlobalHooks.AdminAccessToken); + client.DefaultRequestHeaders.Add("X-Tenant-ID", StorageConstants.DefaultTenantId); + + using var cts = new CancellationTokenSource(timeout); + var tcs = new TaskCompletionSource(); + var connectedTcs = new TaskCompletionSource(); + + // Start listening to SSE stream + var listenTask = Task.Run(async () => + { + try + { + using var response = await client.GetAsync("/api/notifications/stream", + HttpCompletionOption.ResponseHeadersRead, cts.Token); + _ = response.EnsureSuccessStatusCode(); + + _ = connectedTcs.TrySetResult(); + + using var stream = await response.Content.ReadAsStreamAsync(cts.Token); + + await foreach (var item in SseParser.Create(stream).EnumerateAsync(cts.Token)) + { + if (string.IsNullOrEmpty(item.Data)) + { + continue; + } + + var received = $"Type: {item.EventType}, Data: {item.Data}"; + receivedEvents.Add(received); + + if (eventTypes.Contains(item.EventType)) + { + using var doc = JsonDocument.Parse(item.Data); + if (doc.RootElement.TryGetProperty("entityId", out var idProp)) + { + var receivedId = idProp.GetGuid(); + if (matchAnyId || receivedId == entityId) + { + if (minVersion > 0) + { + if (doc.RootElement.TryGetProperty("version", out var versionProp) && + versionProp.ValueKind == JsonValueKind.Number && + versionProp.GetInt64() >= minVersion) + { + // Version match + } + else + { + continue; + } + } + + if (minTimestamp.HasValue) + { + if (doc.RootElement.TryGetProperty("timestamp", out var timestampProp) && + timestampProp.TryGetDateTimeOffset(out var timestamp) && + timestamp >= minTimestamp.Value) + { + // Timestamp match + } + else + { + continue; + } + } + + long version = 0; + if (doc.RootElement.TryGetProperty("version", out var vProp) && + vProp.ValueKind == JsonValueKind.Number) + { + version = vProp.GetInt64(); + } + + _ = tcs.TrySetResult(new EventResult(true, version)); + return; + } + } + } + } + } + catch (OperationCanceledException) + { + _ = tcs.TrySetResult(new EventResult(false, 0)); + } + catch (Exception ex) + { + _ = tcs.TrySetException(ex); + _ = connectedTcs.TrySetResult(); // Ensure we don't block + } + }, cts.Token); + + // Wait for connection to be established + if (await Task.WhenAny(connectedTcs.Task, Task.Delay(timeout)) != connectedTcs.Task) + { + // Proceed anyway? Or fail? proceeding might miss event. + } + + // Execute the action that should trigger the event + try + { + await action(); + } + catch (Exception) + { + throw; + } + + // Wait for either the event or timeout + _ = await Task.WhenAny(tcs.Task, Task.Delay(timeout)); + + var result = tcs.Task.IsCompleted && tcs.Task.Result.Success ? tcs.Task.Result : new EventResult(false, 0); + + if (!result.Success) + { + cts.Cancel(); // Stop listening + } + + try + { + await listenTask; // Ensure cleanup logic runs and we catch any final exceptions + } + catch (Exception) + { + // Valid to ignore here during cleanup + await Task.CompletedTask; + } + + if (result.Success) + { + return result; + } + + return result; + } + + /// + /// Legacy method - waits for an event AFTER it may have already been sent. + /// Prefer ExecuteAndWaitForEventAsync instead. + /// + [Obsolete("Use ExecuteAndWaitForEventAsync to avoid race conditions")] + public static async Task WaitForEventAsync(Guid entityId, string eventType, TimeSpan timeout) + { + var app = GlobalHooks.App!; + using var client = app.CreateHttpClient("apiservice"); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", GlobalHooks.AdminAccessToken); + client.DefaultRequestHeaders.Add("X-Tenant-ID", StorageConstants.DefaultTenantId); + + using var cts = new CancellationTokenSource(timeout); + try + { + using var response = await client.GetAsync("/api/notifications/stream", + HttpCompletionOption.ResponseHeadersRead, cts.Token); + _ = response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(cts.Token); + + await foreach (var item in SseParser.Create(stream).EnumerateAsync(cts.Token)) + { + if (string.IsNullOrEmpty(item.Data)) + { + continue; + } + + if (item.EventType == eventType) + { + using var doc = JsonDocument.Parse(item.Data); + if (doc.RootElement.TryGetProperty("entityId", out var idProp) && idProp.GetGuid() == entityId) + { + return true; + } + } + } + } + catch (OperationCanceledException) + { + return false; + } + + return false; + } + + public static async Task WaitForConditionAsync(Func> condition, TimeSpan timeout, string failureMessage) + { + using var cts = new CancellationTokenSource(timeout); + try + { + while (!cts.IsCancellationRequested) + { + if (await condition()) + { + return; + } + + await Task.Delay(TestConstants.DefaultPollingInterval, cts.Token); + } + } + catch (OperationCanceledException) + { + // Fall through to failure + } + + throw new Exception($"Timeout waiting for condition: {failureMessage}"); + } +} diff --git a/tests/BookStore.AppHost.Tests/InfrastructureTests.cs b/tests/BookStore.AppHost.Tests/InfrastructureTests.cs index 144c293..012b795 100644 --- a/tests/BookStore.AppHost.Tests/InfrastructureTests.cs +++ b/tests/BookStore.AppHost.Tests/InfrastructureTests.cs @@ -1,5 +1,6 @@ using Aspire.Hosting; using Aspire.Hosting.Testing; +using BookStore.AppHost.Tests.Helpers; using Projects; namespace BookStore.AppHost.Tests; diff --git a/tests/BookStore.AppHost.Tests/LocalizationTests.cs b/tests/BookStore.AppHost.Tests/LocalizationTests.cs index ce1dee2..adec5ec 100644 --- a/tests/BookStore.AppHost.Tests/LocalizationTests.cs +++ b/tests/BookStore.AppHost.Tests/LocalizationTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -18,7 +19,7 @@ public async Task GetBook_WithLocalizedHeader_ShouldReturnExpectedContent(string string expectedDescription) { // Arrange - var adminClient = RestService.For(await TestHelpers.GetAuthenticatedClientAsync()); + var adminClient = RestService.For(await HttpClientHelpers.GetAuthenticatedClientAsync()); // Create the book // Use dictionary for Translations as per contract @@ -29,13 +30,13 @@ public async Task GetBook_WithLocalizedHeader_ShouldReturnExpectedContent(string ["es"] = new BookTranslationDto("Descripción en Español") }; - var request = TestHelpers.GenerateFakeBookRequest(); + var request = FakeDataGenerators.GenerateFakeBookRequest(); request.Translations = translations; request.Title = "Localized Book"; - var createdBook = await TestHelpers.CreateBookAsync(adminClient, request); + var createdBook = await BookHelpers.CreateBookAsync(adminClient, request); - var publicClient = TestHelpers.GetUnauthenticatedClientWithLanguage(acceptLanguage); + var publicClient = HttpClientHelpers.GetUnauthenticatedClientWithLanguage(acceptLanguage); var bookDto = await publicClient.GetBookAsync(createdBook.Id); // Assert diff --git a/tests/BookStore.AppHost.Tests/ManagementIntegrationTests.cs b/tests/BookStore.AppHost.Tests/ManagementIntegrationTests.cs index 36a8c73..b391996 100644 --- a/tests/BookStore.AppHost.Tests/ManagementIntegrationTests.cs +++ b/tests/BookStore.AppHost.Tests/ManagementIntegrationTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -12,10 +13,10 @@ public class ManagementIntegrationTests public async Task GetAllData_AsAdmin_ShouldReturnAllEntities() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var authorsClient = await TestHelpers.GetAuthenticatedClientAsync(); - var categoriesClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publishersClient = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var authorsClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var categoriesClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publishersClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var suffix = Guid.NewGuid().ToString()[..8]; var authorName = $"GetAll Auth {suffix}"; @@ -24,7 +25,7 @@ public async Task GetAllData_AsAdmin_ShouldReturnAllEntities() var bookTitle = $"GetAll Book {suffix}"; // Create random entities to ensure list is non-empty - var author = await TestHelpers.CreateAuthorAsync(authorsClient, + var author = await AuthorHelpers.CreateAuthorAsync(authorsClient, new CreateAuthorRequest { Id = Guid.CreateVersion7(), @@ -32,14 +33,14 @@ public async Task GetAllData_AsAdmin_ShouldReturnAllEntities() Translations = new Dictionary { ["en"] = new("Bio") } }); - var category = await TestHelpers.CreateCategoryAsync(categoriesClient, + var category = await CategoryHelpers.CreateCategoryAsync(categoriesClient, new CreateCategoryRequest { Id = Guid.CreateVersion7(), Translations = new Dictionary { ["en"] = new(catName) } }); - var publisher = await TestHelpers.CreatePublisherAsync(publishersClient, + var publisher = await PublisherHelpers.CreatePublisherAsync(publishersClient, new CreatePublisherRequest { Id = Guid.CreateVersion7(), Name = pubName }); var createRequest = new CreateBookRequest @@ -56,7 +57,7 @@ public async Task GetAllData_AsAdmin_ShouldReturnAllEntities() CategoryIds = [category.Id], PublisherId = publisher.Id }; - var book = await TestHelpers.CreateBookAsync(client, createRequest); + var book = await BookHelpers.CreateBookAsync(client, createRequest); // Act // Use search with empty params to get all (paged/list), @@ -88,29 +89,29 @@ public async Task GetAllData_AsAdmin_ShouldReturnAllEntities() public async Task Search_WithFilter_ShouldReturnMatchedItems() { // Arrange - var authorsClient = await TestHelpers.GetAuthenticatedClientAsync(); - var categoriesClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publishersClient = await TestHelpers.GetAuthenticatedClientAsync(); + var authorsClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var categoriesClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publishersClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var suffix = Guid.NewGuid().ToString()[..8]; var authorName = $"SearchMatch Auth {suffix}"; var catName = $"SearchMatch Cat {suffix}"; var pubName = $"SearchMatch Pub {suffix}"; - _ = await TestHelpers.CreateAuthorAsync(authorsClient, + _ = await AuthorHelpers.CreateAuthorAsync(authorsClient, new CreateAuthorRequest { Id = Guid.CreateVersion7(), Name = authorName, Translations = new Dictionary { ["en"] = new("Bio") } }); - _ = await TestHelpers.CreateCategoryAsync(categoriesClient, + _ = await CategoryHelpers.CreateCategoryAsync(categoriesClient, new CreateCategoryRequest { Id = Guid.CreateVersion7(), Translations = new Dictionary { ["en"] = new(catName) } }); - _ = await TestHelpers.CreatePublisherAsync(publishersClient, + _ = await PublisherHelpers.CreatePublisherAsync(publishersClient, new CreatePublisherRequest { Id = Guid.CreateVersion7(), Name = pubName }); // Act & Assert @@ -123,11 +124,11 @@ public async Task Search_WithFilter_ShouldReturnMatchedItems() public async Task SoftDelete_ShouldHideItem_AndRestoreShouldShowIt() { // Arrange - var authorsClient = await TestHelpers.GetAuthenticatedClientAsync(); + var authorsClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var suffix = Guid.NewGuid().ToString()[..8]; var authorName = $"Delete Auth {suffix}"; - var author = await TestHelpers.CreateAuthorAsync(authorsClient, + var author = await AuthorHelpers.CreateAuthorAsync(authorsClient, new CreateAuthorRequest { Id = Guid.CreateVersion7(), diff --git a/tests/BookStore.AppHost.Tests/MultiLanguageTranslationTests.cs b/tests/BookStore.AppHost.Tests/MultiLanguageTranslationTests.cs index 6f12bb6..0b2a64b 100644 --- a/tests/BookStore.AppHost.Tests/MultiLanguageTranslationTests.cs +++ b/tests/BookStore.AppHost.Tests/MultiLanguageTranslationTests.cs @@ -1,3 +1,4 @@ +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -11,7 +12,7 @@ public class MultiLanguageTranslationTests public async Task Author_Update_ShouldPreserveAllBiographies() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); var authorName = "Translation Author " + Guid.NewGuid().ToString()[..8]; var createRequest = new CreateAuthorRequest @@ -26,7 +27,7 @@ public async Task Author_Update_ShouldPreserveAllBiographies() }; // 1. Create Author - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); _ = await Assert.That(author).IsNotNull(); // 2. Verify all translations are returned in Admin API @@ -44,7 +45,7 @@ public async Task Author_Update_ShouldPreserveAllBiographies() var updateRequest = new UpdateAuthorRequest { Name = authorName + " Updated", Translations = translations }; - authorInList = await TestHelpers.UpdateAuthorAsync(client, authorInList, updateRequest); + authorInList = await AuthorHelpers.UpdateAuthorAsync(client, authorInList, updateRequest); _ = await Assert.That(authorInList).IsNotNull(); _ = await Assert.That(authorInList.Name).IsEqualTo(authorName + " Updated"); @@ -56,7 +57,7 @@ public async Task Author_Update_ShouldPreserveAllBiographies() public async Task Category_Update_ShouldPreserveAllNames() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); var englishName = "English Cat " + Guid.NewGuid().ToString()[..8]; var createRequest = new CreateCategoryRequest @@ -69,7 +70,7 @@ public async Task Category_Update_ShouldPreserveAllNames() } }; - var category = await TestHelpers.CreateCategoryAsync(client, createRequest); + var category = await CategoryHelpers.CreateCategoryAsync(client, createRequest); _ = await Assert.That(category).IsNotNull(); var pagedCategories = @@ -87,7 +88,7 @@ public async Task Category_Update_ShouldPreserveAllNames() var updateRequest = new UpdateCategoryRequest { Translations = translations }; - categoryInList = await TestHelpers.UpdateCategoryAsync(client, categoryInList, updateRequest); + categoryInList = await CategoryHelpers.UpdateCategoryAsync(client, categoryInList, updateRequest); _ = await Assert.That(categoryInList).IsNotNull(); // Verify @@ -99,7 +100,7 @@ public async Task Category_Update_ShouldPreserveAllNames() public async Task Book_Update_ShouldPreserveAllDescriptions() { // 1. Create Book with Translations - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); var title = "TransBook " + Guid.NewGuid().ToString()[..8]; @@ -122,7 +123,7 @@ public async Task Book_Update_ShouldPreserveAllDescriptions() }; // Create - var book = await TestHelpers.CreateBookAsync(client, createRequest); + var book = await BookHelpers.CreateBookAsync(client, createRequest); _ = await Assert.That(book).IsNotNull(); // Fetch using Refit client to get ETag @@ -147,17 +148,17 @@ public async Task Book_Update_ShouldPreserveAllDescriptions() } }; - var updatedBook = await TestHelpers.UpdateBookAsync(client, book.Id, updateRequest, book.ETag); + var updatedBook = await BookHelpers.UpdateBookAsync(client, book.Id, updateRequest, book.ETag); _ = await Assert.That(updatedBook).IsNotNull(); // 4. Verify using Accept-Language // English - var publicClientEn = TestHelpers.GetUnauthenticatedClientWithLanguage("en"); + var publicClientEn = HttpClientHelpers.GetUnauthenticatedClientWithLanguage("en"); var bookEn = await publicClientEn.GetBookAsync(book.Id); _ = await Assert.That(bookEn.Description).IsEqualTo("English Updated"); // Spanish - var publicClientEs = TestHelpers.GetUnauthenticatedClientWithLanguage("es"); + var publicClientEs = HttpClientHelpers.GetUnauthenticatedClientWithLanguage("es"); var bookEs = await publicClientEs.GetBookAsync(book.Id); _ = await Assert.That(bookEs.Description).IsEqualTo("Descripción Original"); } diff --git a/tests/BookStore.AppHost.Tests/MultiTenancyTests.cs b/tests/BookStore.AppHost.Tests/MultiTenancyTests.cs index 4435607..05ec06e 100644 --- a/tests/BookStore.AppHost.Tests/MultiTenancyTests.cs +++ b/tests/BookStore.AppHost.Tests/MultiTenancyTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Marten; @@ -29,8 +30,8 @@ public static async Task ClassSetup() opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; }); - await TestHelpers.SeedTenantAsync(store, "acme"); - await TestHelpers.SeedTenantAsync(store, "contoso"); + await DatabaseHelpers.SeedTenantAsync(store, "acme"); + await DatabaseHelpers.SeedTenantAsync(store, "contoso"); } [Test] @@ -38,23 +39,23 @@ public async Task EntitiesAreIsolatedByTenant() { // 1. Setup Clients // Login as Acme Admin - var acmeLogin = await TestHelpers.LoginAsAdminAsync("acme"); + var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync("acme"); _ = await Assert.That(acmeLogin).IsNotNull(); var acmeClient = - RestService.For(TestHelpers.GetAuthenticatedClient(acmeLogin!.AccessToken, "acme")); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLogin!.AccessToken, "acme")); // Login as Contoso Admin - var contosoLogin = await TestHelpers.LoginAsAdminAsync("contoso"); + var contosoLogin = await AuthenticationHelpers.LoginAsAdminAsync("contoso"); _ = await Assert.That(contosoLogin).IsNotNull(); var contosoClient = - RestService.For(TestHelpers.GetAuthenticatedClient(contosoLogin!.AccessToken, "contoso")); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(contosoLogin!.AccessToken, "contoso")); // 2. Create Book in ACME - var createRequest = TestHelpers.GenerateFakeBookRequest(); + var createRequest = FakeDataGenerators.GenerateFakeBookRequest(); // Use CreateBookAsync helper that handles dependencies and SSE waiting - // Wait, TestHelpers.CreateBookAsync takes IBooksClient and CreateBookRequest. + // Wait, BookHelpers.CreateBookAsync takes IBooksClient and CreateBookRequest. // It should handle it. - var createdBook = await TestHelpers.CreateBookAsync(acmeClient, createRequest); + var createdBook = await BookHelpers.CreateBookAsync(acmeClient, createRequest); _ = await Assert.That(createdBook).IsNotNull(); var bookId = createdBook.Id; @@ -85,7 +86,7 @@ public async Task InvalidTenantReturns400() // But the middleware validates tenant existence or format. // If we use IBooksClient with bad tenant, standard Refit call handles it. - var client = RestService.For(TestHelpers.GetUnauthenticatedClient("garbage-tenant-id")); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("garbage-tenant-id")); var exception = await Assert.That(async () => await client.GetBooksAsync(new BookSearchRequest())) .Throws(); diff --git a/tests/BookStore.AppHost.Tests/MultiTenantAuthenticationTests.cs b/tests/BookStore.AppHost.Tests/MultiTenantAuthenticationTests.cs index 6b73939..73704ee 100644 --- a/tests/BookStore.AppHost.Tests/MultiTenantAuthenticationTests.cs +++ b/tests/BookStore.AppHost.Tests/MultiTenantAuthenticationTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -35,8 +36,8 @@ public static async Task ClassSetup() opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; }); - await TestHelpers.SeedTenantAsync(store, "acme"); - await TestHelpers.SeedTenantAsync(store, "contoso"); + await DatabaseHelpers.SeedTenantAsync(store, "acme"); + await DatabaseHelpers.SeedTenantAsync(store, "contoso"); } [Before(Test)] @@ -69,9 +70,9 @@ public void Dispose() public async Task SeedAsync_CreatesAdminForEachTenant() { // Act: Try to login as each tenant's admin - var defaultLogin = await TestHelpers.LoginAsAdminAsync(_client!, StorageConstants.DefaultTenantId); - var acmeLogin = await TestHelpers.LoginAsAdminAsync(_client!, "acme"); - var contosoLogin = await TestHelpers.LoginAsAdminAsync(_client!, "contoso"); + var defaultLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, StorageConstants.DefaultTenantId); + var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); + var contosoLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "contoso"); // Assert: All admins should exist and be able to login _ = await Assert.That(defaultLogin).IsNotNull(); @@ -107,7 +108,7 @@ public async Task Login_AdminFromAcme_CannotLoginToContoso() public async Task Login_AdminFromAcme_SucceedsInAcmeTenant() { // Act: Login with Acme credentials using helper (which has retry logic) - var response = await TestHelpers.LoginAsAdminAsync(_client!, "acme"); + var response = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); // Assert: Should succeed _ = await Assert.That(response).IsNotNull(); @@ -118,7 +119,7 @@ public async Task Login_AdminFromAcme_SucceedsInAcmeTenant() public async Task AdminToken_FromAcme_CanAccessAcmeBooks() { // Arrange: Login as Acme admin and get token - var acmeLogin = await TestHelpers.LoginAsAdminAsync(_client!, "acme"); + var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); _ = await Assert.That(acmeLogin).IsNotNull(); // Act: Try to access acme books with acme token and acme tenant header @@ -136,7 +137,7 @@ public async Task AdminToken_FromAcme_CanAccessAcmeBooks() public async Task AdminToken_FromAcme_WithContosoHeader_IsRejected() { // Arrange: Login as Acme admin - var acmeLogin = await TestHelpers.LoginAsAdminAsync(_client!, "acme"); + var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); _ = await Assert.That(acmeLogin).IsNotNull(); // Act: Try to access books with acme JWT but contoso tenant header @@ -160,7 +161,7 @@ HttpStatusCode.Forbidden or public async Task Admin_CanCreateBookInOwnTenant() { // Arrange: Login as Acme admin - var acmeLogin = await TestHelpers.LoginAsAdminAsync(_client!, "acme"); + var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); _ = await Assert.That(acmeLogin).IsNotNull(); // Create minimal book data @@ -213,7 +214,7 @@ public async Task Admin_CanCreateBookInOwnTenant() public async Task Login_ContosoAdmin_CannotAccessAcmeData() { // Arrange: Login as Contoso admin - var contosoLogin = await TestHelpers.LoginAsAdminAsync(_client!, "contoso"); + var contosoLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "contoso"); _ = await Assert.That(contosoLogin).IsNotNull(); // Act: Try to access acme books with contoso credentials diff --git a/tests/BookStore.AppHost.Tests/PasskeyDeletionTests.cs b/tests/BookStore.AppHost.Tests/PasskeyDeletionTests.cs index 4335eed..38db07e 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyDeletionTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyDeletionTests.cs @@ -1,5 +1,6 @@ using Bogus; using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -19,7 +20,7 @@ public class PasskeyDeletionTests public PasskeyDeletionTests() { - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); _client = RestService.For(httpClient); _passkeyClient = RestService.For(httpClient); _faker = new Faker(); @@ -36,7 +37,7 @@ public async Task DeletePasskey_WithUrlUnsafeId_ShouldSucceed() _ = await _client.RegisterAsync(new RegisterRequest(email, password)); var loginResult = await _client.LoginAsync(new LoginRequest(email, password)); - var authClient = TestHelpers.GetAuthenticatedClient(loginResult.AccessToken); + var authClient = HttpClientHelpers.GetAuthenticatedClient(loginResult.AccessToken); var authenticatedPasskeyClient = RestService.For(authClient); // 2. Manually seed a passkey with an ID that is NOT URL-safe in standard Base64 @@ -67,7 +68,7 @@ await PasskeyTestHelpers.AddPasskeyToUserAsync( // 5. Assert // Verify it's gone from DB - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(StorageConstants.DefaultTenantId); var user = await session.Query() .Where(u => u.NormalizedEmail == email.ToUpperInvariant()) diff --git a/tests/BookStore.AppHost.Tests/PasskeyRegistrationSecurityTests.cs b/tests/BookStore.AppHost.Tests/PasskeyRegistrationSecurityTests.cs index 653539b..2449f87 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyRegistrationSecurityTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyRegistrationSecurityTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using BookStore.AppHost.Tests.Helpers; using BookStore.Shared; using TUnit.Assertions.Extensions; using TUnit.Core; @@ -17,9 +18,9 @@ public class PasskeyRegistrationSecurityTests public async Task PasskeyRegistration_ConcurrentAttempts_OnlyOneSucceeds() { // Arrange - Get registration options to obtain a user ID - var email = TestHelpers.GenerateFakeEmail(); + var email = FakeDataGenerators.GenerateFakeEmail(); var tenantId = MultiTenancyConstants.DefaultTenantId; - var client = TestHelpers.GetUnauthenticatedClient(tenantId); + var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); // Get creation options first var optionsResponse = await client.PostAsJsonAsync("/account/attestation/options", new @@ -77,22 +78,22 @@ public async Task PasskeyRegistration_ConcurrentAttempts_OnlyOneSucceeds() public async Task PasskeyRegistration_WithExistingUserId_ReturnsGenericError() { // Arrange - Create a user first - var (email, _, _, tenantId) = await TestHelpers.RegisterAndLoginUserAsync(); + var (email, _, _, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); // Get the existing user's ID - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); - var existingUser = await TestHelpers.GetUserByEmailAsync(session, email); + var existingUser = await DatabaseHelpers.GetUserByEmailAsync(session, email); _ = await Assert.That(existingUser).IsNotNull(); var existingUserId = existingUser!.Id.ToString(); // Act - Try to register a NEW passkey with an EXISTING user ID - var client = TestHelpers.GetUnauthenticatedClient(tenantId); + var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); var response = await client.PostAsJsonAsync("/account/attestation/result", new { credentialJson = "{\"mock\":\"credential\"}", - email = TestHelpers.GenerateFakeEmail(), // different email + email = FakeDataGenerators.GenerateFakeEmail(), // different email userId = existingUserId // SAME user ID }); diff --git a/tests/BookStore.AppHost.Tests/PasskeySecurityTests.cs b/tests/BookStore.AppHost.Tests/PasskeySecurityTests.cs index 80598cb..61299a3 100644 --- a/tests/BookStore.AppHost.Tests/PasskeySecurityTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeySecurityTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -25,7 +26,7 @@ public class PasskeySecurityTests public async Task PasskeyLogin_WithClonedAuthenticator_LocksAccount() { // Arrange - Create user with a passkey that has a sign count of 5 - var (email, password, _, tenantId) = await TestHelpers.RegisterAndLoginUserAsync(); + var (email, password, _, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); var credentialId = Guid.CreateVersion7().ToByteArray(); const uint initialSignCount = 5; @@ -36,16 +37,16 @@ public async Task PasskeyLogin_WithClonedAuthenticator_LocksAccount() await PasskeyTestHelpers.UpdatePasskeySignCountAsync(tenantId, email, credentialId, signCount: 3); // Act - Try to use a passkey with a DECREASING counter (cloned authenticator) - var client = TestHelpers.GetUnauthenticatedClient(tenantId); + var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); var response = await client.PostAsJsonAsync("/account/assertion/result", new { credentialJson = "{\"mock\":\"data\"}", // Would normally be WebAuthn credential }); // Assert - The endpoint should return error, and account should be locked - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); - var user = await TestHelpers.GetUserByEmailAsync(session, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); _ = await Assert.That(user).IsNotNull(); // The actual lockout would be tested via a full WebAuthn flow which requires browser automation } @@ -54,10 +55,10 @@ public async Task PasskeyLogin_WithClonedAuthenticator_LocksAccount() public async Task Token_AfterSecurityStampChange_BecomesInvalid() { // Arrange - Register and login to get a valid token - var (email, password, loginResponse, tenantId) = await TestHelpers.RegisterAndLoginUserAsync(); + var (email, password, loginResponse, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); var authClient = RestService.For( - TestHelpers.GetAuthenticatedClient(loginResponse.AccessToken, tenantId)); + HttpClientHelpers.GetAuthenticatedClient(loginResponse.AccessToken, tenantId)); // Verify token works initially var initialStatus = await authClient.GetPasswordStatusAsync(); @@ -66,7 +67,7 @@ public async Task Token_AfterSecurityStampChange_BecomesInvalid() // Act - Change password (this updates security stamp) await authClient.ChangePasswordAsync(new ChangePasswordRequest( password, - TestHelpers.GenerateFakePassword() + FakeDataGenerators.GenerateFakePassword() )); // Assert - Old token should now be rejected due to security stamp mismatch @@ -79,10 +80,10 @@ await authClient.ChangePasswordAsync(new ChangePasswordRequest( public async Task Token_AfterAddingPasskey_BecomesInvalid() { // Arrange - var (email, password, loginResponse, tenantId) = await TestHelpers.RegisterAndLoginUserAsync(); + var (email, password, loginResponse, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); var authClient = RestService.For( - TestHelpers.GetAuthenticatedClient(loginResponse.AccessToken, tenantId)); + HttpClientHelpers.GetAuthenticatedClient(loginResponse.AccessToken, tenantId)); // Verify token works initially var initialStatus = await authClient.GetPasswordStatusAsync(); @@ -93,10 +94,10 @@ public async Task Token_AfterAddingPasskey_BecomesInvalid() await PasskeyTestHelpers.AddPasskeyToUserAsync(tenantId, email, "New Passkey", credentialId, signCount: 0); // Manually trigger security stamp update like the endpoint does - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using (var session = store.LightweightSession(tenantId)) { - var user = await TestHelpers.GetUserByEmailAsync(session, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); user!.SecurityStamp = Guid.CreateVersion7().ToString(); session.Update(user); await session.SaveChangesAsync(); @@ -113,24 +114,24 @@ public async Task RefreshToken_FromDifferentTenant_LocksAccountAndClearsTokens() { // Arrange - Create a user in tenant1 var tenant1 = "acme"; - var email = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); - var client1 = TestHelpers.GetUnauthenticatedClient(tenant1); + var client1 = HttpClientHelpers.GetUnauthenticatedClient(tenant1); var register1 = await client1.PostAsJsonAsync("/account/register", new { email, password }); _ = register1.EnsureSuccessStatusCode(); // Get refresh token for tenant1 var login1 = await client1.PostAsJsonAsync("/account/login", new { email, password }); _ = login1.EnsureSuccessStatusCode(); - var loginResponse1 = await login1.Content.ReadFromJsonAsync(); + var loginResponse1 = await login1.Content.ReadFromJsonAsync(); // Manually add the same refresh token with a DIFFERENT tenant ID to simulate cross-tenant token theft // In a real scenario, this would require a security breach or bug that allows token reuse across tenants - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using (var session = store.LightweightSession(tenant1)) { - var user = await TestHelpers.GetUserByEmailAsync(session, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); if (user != null) { @@ -158,7 +159,7 @@ public async Task RefreshToken_FromDifferentTenant_LocksAccountAndClearsTokens() // Verify tenant1 account is now locked and all tokens cleared await using (var sessionVerify = store.LightweightSession(tenant1)) { - var user = await TestHelpers.GetUserByEmailAsync(sessionVerify, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(sessionVerify, email); _ = await Assert.That(user).IsNotNull(); _ = await Assert.That(user!.LockoutEnd).IsNotNull(); @@ -171,14 +172,14 @@ public async Task RefreshToken_FromDifferentTenant_LocksAccountAndClearsTokens() public async Task PasskeyLogin_ClearsAllExistingRefreshTokens() { // Arrange - Create user and establish multiple sessions with refresh tokens - var (email, password, login1, tenantId) = await TestHelpers.RegisterAndLoginUserAsync(); + var (email, password, login1, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); // Create 2 additional sessions (with RegisterAndLoginUserAsync we already have 1) - var client = TestHelpers.GetUnauthenticatedClient(tenantId); + var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); var loginResponse2 = await client.PostAsJsonAsync("/account/login", new { email, password }); - var login2 = await loginResponse2.Content.ReadFromJsonAsync(); + var login2 = await loginResponse2.Content.ReadFromJsonAsync(); var loginResponse3 = await client.PostAsJsonAsync("/account/login", new { email, password }); - var login3 = await loginResponse3.Content.ReadFromJsonAsync(); + var login3 = await loginResponse3.Content.ReadFromJsonAsync(); // Act - Add a passkey and trigger passkey login flow // In a real passkey login, all refresh tokens are cleared for security @@ -186,10 +187,10 @@ public async Task PasskeyLogin_ClearsAllExistingRefreshTokens() await PasskeyTestHelpers.AddPasskeyToUserAsync(tenantId, email, "Login Passkey", credentialId, signCount: 0); // Simulate the token clearing that happens in passkey login - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using (var session = store.LightweightSession(tenantId)) { - var user = await TestHelpers.GetUserByEmailAsync(session, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); user!.RefreshTokens.Clear(); session.Update(user); await session.SaveChangesAsync(); @@ -206,7 +207,7 @@ public async Task PasskeyLogin_ClearsAllExistingRefreshTokens() // Verify database state await using var sessionFinal = store.LightweightSession(tenantId); - var userAfter = await TestHelpers.GetUserByEmailAsync(sessionFinal, email); + var userAfter = await DatabaseHelpers.GetUserByEmailAsync(sessionFinal, email); _ = await Assert.That(userAfter!.RefreshTokens).IsEmpty(); } @@ -215,7 +216,7 @@ public async Task PasskeyLogin_ClearsAllExistingRefreshTokens() public async Task SecurityStamp_InToken_MustMatchUserSecurityStamp() { // Arrange - Register user and get token - var (email, _, loginResponse, tenantId) = await TestHelpers.RegisterAndLoginUserAsync(); + var (email, _, loginResponse, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); // Verify token contains security_stamp claim var handler = new JwtSecurityTokenHandler(); @@ -224,9 +225,9 @@ public async Task SecurityStamp_InToken_MustMatchUserSecurityStamp() _ = await Assert.That(securityStampClaim).IsNotNull(); // Verify it matches the user's actual security stamp - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); - var user = await TestHelpers.GetUserByEmailAsync(session, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); _ = await Assert.That(user).IsNotNull(); _ = await Assert.That(securityStampClaim!.Value).IsEqualTo(user!.SecurityStamp); @@ -236,13 +237,13 @@ public async Task SecurityStamp_InToken_MustMatchUserSecurityStamp() public async Task PasskeySignCount_MustBeStoredAndIncrement() { // Arrange - var (email, _, _, tenantId) = await TestHelpers.RegisterAndLoginUserAsync(); + var (email, _, _, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); var credentialId = Guid.CreateVersion7().ToByteArray(); // Add initial passkey with sign count 0 await PasskeyTestHelpers.AddPasskeyToUserAsync(tenantId, email, "Test Device", credentialId, signCount: 0); - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); // Act - Simulate successful logins that increment the counter await PasskeyTestHelpers.UpdatePasskeySignCountAsync(tenantId, email, credentialId, signCount: 1); @@ -251,7 +252,7 @@ public async Task PasskeySignCount_MustBeStoredAndIncrement() // Assert - Verify counter is properly stored and incremented await using var session = store.LightweightSession(tenantId); - var user = await TestHelpers.GetUserByEmailAsync(session, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); _ = await Assert.That(user).IsNotNull(); var passkey = user!.Passkeys.First(p => p.CredentialId.SequenceEqual(credentialId)); diff --git a/tests/BookStore.AppHost.Tests/PasskeyTenantIsolationTests.cs b/tests/BookStore.AppHost.Tests/PasskeyTenantIsolationTests.cs index 70b6a8e..a93fac4 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyTenantIsolationTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyTenantIsolationTests.cs @@ -3,6 +3,7 @@ using System.Net; using System.Net.Http.Json; using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using Marten; using Refit; @@ -16,15 +17,15 @@ public class PasskeyTenantIsolationTests public async Task Passkeys_AreTenantScoped() { // Arrange - var (acmeEmail, _, acmeLoginResponse, _) = await TestHelpers.RegisterAndLoginUserAsync("acme"); - var (_, _, contosoLoginResponse, _) = await TestHelpers.RegisterAndLoginUserAsync("contoso"); + var (acmeEmail, _, acmeLoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("acme"); + var (_, _, contosoLoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("contoso"); var credentialId = Guid.CreateVersion7().ToByteArray(); await PasskeyTestHelpers.AddPasskeyToUserAsync("acme", acmeEmail, "Acme Passkey", credentialId); - var acmeClient = RestService.For(TestHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "acme")); + var acmeClient = RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "acme")); var contosoClient = - RestService.For(TestHelpers.GetAuthenticatedClient(contosoLoginResponse.AccessToken, "contoso")); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(contosoLoginResponse.AccessToken, "contoso")); // Act var acmePasskeys = await acmeClient.ListPasskeysAsync(); @@ -35,7 +36,7 @@ public async Task Passkeys_AreTenantScoped() _ = await Assert.That(contosoPasskeys.Any(p => p.Name == "Acme Passkey")).IsFalse(); var mismatchedClient = - RestService.For(TestHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "contoso")); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "contoso")); var mismatchException = await Assert.That(async () => await mismatchedClient.ListPasskeysAsync()).Throws(); var isRejected = mismatchException!.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized; @@ -46,16 +47,16 @@ public async Task Passkeys_AreTenantScoped() public async Task DeletePasskey_WithMismatchedTenantHeader_IsRejected() { // Arrange - var (acmeEmail, _, acmeLoginResponse, _) = await TestHelpers.RegisterAndLoginUserAsync("acme"); + var (acmeEmail, _, acmeLoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("acme"); var credentialId = Guid.CreateVersion7().ToByteArray(); await PasskeyTestHelpers.AddPasskeyToUserAsync("acme", acmeEmail, "Acme Passkey", credentialId); - var acmeClient = RestService.For(TestHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "acme")); + var acmeClient = RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "acme")); var passkeys = await acmeClient.ListPasskeysAsync(); var passkeyId = passkeys.Single(p => p.Name == "Acme Passkey").Id; var mismatchedClient = - RestService.For(TestHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "contoso")); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "contoso")); // Act var mismatchException = await Assert.That( @@ -74,8 +75,8 @@ public async Task DeletePasskey_WithMismatchedTenantHeader_IsRejected() public async Task PasskeyCreationOptions_WithEmailFromOtherTenant_ReturnsFreshUserId() { // Arrange - var (acmeEmail, _, acmeLoginResponse, _) = await TestHelpers.RegisterAndLoginUserAsync("acme"); - var contosoClient = RestService.For(TestHelpers.GetUnauthenticatedClient("contoso")); + var (acmeEmail, _, acmeLoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("acme"); + var contosoClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("contoso")); // Get acme user ID from JWT token var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); diff --git a/tests/BookStore.AppHost.Tests/PasskeyTestHelpers.cs b/tests/BookStore.AppHost.Tests/PasskeyTestHelpers.cs index a05805e..c8d8673 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyTestHelpers.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyTestHelpers.cs @@ -1,4 +1,5 @@ using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; using JasperFx; using Marten; using Microsoft.AspNetCore.Identity; @@ -45,10 +46,10 @@ public static async Task AddPasskeyToUserAsync( byte[] credentialId, uint signCount = 0) { - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); - var user = await TestHelpers.GetUserByEmailAsync(session, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); if (user == null) { throw new InvalidOperationException($"User not found: {email}"); @@ -70,10 +71,10 @@ public static async Task UpdatePasskeySignCountAsync( byte[] credentialId, uint signCount) { - var store = await TestHelpers.GetDocumentStoreAsync(); + var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); - var user = await TestHelpers.GetUserByEmailAsync(session, email); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); if (user == null) { throw new InvalidOperationException($"User not found: {email}"); @@ -107,61 +108,4 @@ public static async Task UpdatePasskeySignCountAsync( session.Update(user); await session.SaveChangesAsync(); } - - /// - /// Creates a passkey using reflection for legacy code compatibility. - /// This is a fallback for tests that need to work with different UserPasskeyInfo versions. - /// - [Obsolete("Use CreatePasskeyInfo instead. This method is only for backward compatibility.")] - public static object CreatePasskeyViaReflection(byte[] credentialId, string name) - { - var passkeyType = typeof(UserPasskeyInfo); - var constructors = passkeyType.GetConstructors(System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.NonPublic); - var constructor = constructors[0]; - var parameters = constructor.GetParameters(); - var args = new object?[parameters.Length]; - - for (var i = 0; i < parameters.Length; i++) - { - var parameter = parameters[i]; - if (parameter.ParameterType == typeof(byte[])) - { - args[i] = Array.Empty(); - } - else if (parameter.ParameterType == typeof(DateTimeOffset)) - { - args[i] = DateTimeOffset.UtcNow; - } - else if (parameter.ParameterType == typeof(uint)) - { - args[i] = 0u; - } - else if (parameter.ParameterType == typeof(bool)) - { - args[i] = false; - } - else - { - args[i] = null; - } - } - - var passkey = constructor.Invoke(args) ?? throw new InvalidOperationException("Failed to create passkey."); - - var fields = passkeyType.GetFields(System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.NonPublic | - System.Reflection.BindingFlags.Public); - - var credentialIdField = fields.FirstOrDefault(f => - f.Name.Contains("k__BackingField") || f.Name == "_credentialId" || f.Name == "credentialId"); - credentialIdField?.SetValue(passkey, credentialId); - - var nameField = fields.FirstOrDefault(f => - f.Name.Contains("k__BackingField") || f.Name == "_name" || f.Name == "name"); - nameField?.SetValue(passkey, name); - - return passkey; - } } diff --git a/tests/BookStore.AppHost.Tests/PasskeyTests.cs b/tests/BookStore.AppHost.Tests/PasskeyTests.cs index 1d44a47..ccac249 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Bogus; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -15,7 +16,7 @@ public class PasskeyTests public PasskeyTests() { - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); _identityClient = RestService.For(httpClient); _passkeyClient = RestService.For(httpClient); _faker = new Faker(); @@ -36,7 +37,7 @@ public async Task GetAssertionOptions_WithUserWithNoPasskeys_ShouldReturnOptions // Act var options = await _passkeyClient.GetPasskeyLoginOptionsAsync(request); - // Assert + // Assert - API returns options even for users without passkeys to prevent user enumeration _ = await Assert.That(options).IsNotNull(); } @@ -73,7 +74,7 @@ public async Task GetAttestationOptions_WhenAuthenticated_ShouldReturnOptions() var loginResult = await _identityClient.LoginAsync(new LoginRequest(email, password)); // Create authenticated client - var authHttpClient = TestHelpers.GetUnauthenticatedClient(); + var authHttpClient = HttpClientHelpers.GetUnauthenticatedClient(); authHttpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginResult!.AccessToken); var authPasskeyClient = RestService.For(authHttpClient); diff --git a/tests/BookStore.AppHost.Tests/PasswordManagementTests.cs b/tests/BookStore.AppHost.Tests/PasswordManagementTests.cs index 0b2efd6..6e51737 100644 --- a/tests/BookStore.AppHost.Tests/PasswordManagementTests.cs +++ b/tests/BookStore.AppHost.Tests/PasswordManagementTests.cs @@ -1,6 +1,7 @@ using System.Net; using Bogus; using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -18,7 +19,7 @@ public class PasswordManagementTests public PasswordManagementTests() { - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); _client = RestService.For(httpClient); _faker = new Faker(); } @@ -27,7 +28,7 @@ public PasswordManagementTests() public async Task GetPasswordStatus_WhenUserHasPassword_ShouldReturnTrue() { // Arrange - var identityClient = await TestHelpers.CreateUserAndGetClientAsync(); + var identityClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Act var status = await identityClient.GetPasswordStatusAsync(); @@ -41,9 +42,9 @@ public async Task GetPasswordStatus_WhenUserHasPassword_ShouldReturnTrue() public async Task ChangePassword_WithValidCredentials_ShouldSucceed() { // Arrange - var email = TestHelpers.GenerateFakeEmail(); - var oldPassword = TestHelpers.GenerateFakePassword(); - var newPassword = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var oldPassword = FakeDataGenerators.GenerateFakePassword(); + var newPassword = FakeDataGenerators.GenerateFakePassword(); // Register _ = await _client.RegisterAsync(new RegisterRequest(email, oldPassword)); @@ -52,7 +53,7 @@ public async Task ChangePassword_WithValidCredentials_ShouldSucceed() var loginResult = await _client.LoginAsync(new LoginRequest(email, oldPassword)); var authClient = RestService.For( - TestHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); // Act await authClient.ChangePasswordAsync(new ChangePasswordRequest(oldPassword, newPassword)); @@ -68,16 +69,16 @@ public async Task ChangePassword_WithValidCredentials_ShouldSucceed() public async Task AddPassword_WhenManualClearance_ShouldSucceed() { // Arrange - var email = TestHelpers.GenerateFakeEmail(); - var tempPassword = TestHelpers.GenerateFakePassword(); - var newPassword = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var tempPassword = FakeDataGenerators.GenerateFakePassword(); + var newPassword = FakeDataGenerators.GenerateFakePassword(); // Register normally _ = await _client.RegisterAsync(new RegisterRequest(email, tempPassword)); var loginResult = await _client.LoginAsync(new LoginRequest(email, tempPassword)); var authClient = RestService.For( - TestHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); // Manually clear password hash in DB using var store = await GetStoreAsync(); @@ -111,8 +112,8 @@ public async Task AddPassword_WhenManualClearance_ShouldSucceed() public async Task ChangePassword_WithSamePassword_ShouldReturnBadRequest() { // Arrange - var email = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -121,7 +122,7 @@ public async Task ChangePassword_WithSamePassword_ShouldReturnBadRequest() var loginResult = await _client.LoginAsync(new LoginRequest(email, password)); var authClient = RestService.For( - TestHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); // Act & Assert try @@ -132,7 +133,7 @@ public async Task ChangePassword_WithSamePassword_ShouldReturnBadRequest() catch (ApiException ex) { _ = await Assert.That((int)ex.StatusCode).IsEqualTo((int)HttpStatusCode.BadRequest); - var problem = await ex.GetContentAsAsync(); + var problem = await ex.GetContentAsAsync(); _ = await Assert.That(problem?.Error).IsEqualTo(ErrorCodes.Auth.PasswordReuse); } } @@ -141,8 +142,8 @@ public async Task ChangePassword_WithSamePassword_ShouldReturnBadRequest() public async Task RemovePassword_Fails_WhenUserHasNoPasskey() { // Arrange - var email = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -151,7 +152,7 @@ public async Task RemovePassword_Fails_WhenUserHasNoPasskey() var loginResult = await _client.LoginAsync(new LoginRequest(email, password)); var authClient = RestService.For( - TestHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); // Act & Assert try @@ -162,7 +163,7 @@ public async Task RemovePassword_Fails_WhenUserHasNoPasskey() catch (ApiException ex) { _ = await Assert.That((int)ex.StatusCode).IsEqualTo((int)HttpStatusCode.BadRequest); - var problem = await ex.GetContentAsAsync(); + var problem = await ex.GetContentAsAsync(); _ = await Assert.That(problem?.Error).IsEqualTo(ErrorCodes.Auth.InvalidRequest); } } @@ -171,8 +172,8 @@ public async Task RemovePassword_Fails_WhenUserHasNoPasskey() public async Task RemovePassword_Succeeds_WhenUserHasPasskey() { // Arrange - var email = TestHelpers.GenerateFakeEmail(); - var password = TestHelpers.GenerateFakePassword(); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -181,7 +182,7 @@ public async Task RemovePassword_Succeeds_WhenUserHasPasskey() var loginResult = await _client.LoginAsync(new LoginRequest(email, password)); var authClient = RestService.For( - TestHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetAuthenticatedClient(loginResult.AccessToken, StorageConstants.DefaultTenantId)); // Manually add a passkey using var store = await GetStoreAsync(); diff --git a/tests/BookStore.AppHost.Tests/PriceFilterRegressionTests.cs b/tests/BookStore.AppHost.Tests/PriceFilterRegressionTests.cs index ebc8e4b..d048f68 100644 --- a/tests/BookStore.AppHost.Tests/PriceFilterRegressionTests.cs +++ b/tests/BookStore.AppHost.Tests/PriceFilterRegressionTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using TUnit; @@ -27,9 +28,9 @@ public async Task SearchBooks_WithVariousPriceAndDiscountScenarios_ShouldFilterC double maxPrice, bool shouldMatch) { - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); // Public client via Refit - var publicHttpClient = TestHelpers.GetUnauthenticatedClient(); + var publicHttpClient = HttpClientHelpers.GetUnauthenticatedClient(); var publicClient = Refit.RestService.For(publicHttpClient); var uniqueTitle = @@ -49,7 +50,7 @@ public async Task SearchBooks_WithVariousPriceAndDiscountScenarios_ShouldFilterC }; createRequest.Prices = new Dictionary { ["USD"] = (decimal)originalPrice }; - var book = await TestHelpers.CreateBookAsync(authClient, createRequest); + var book = await BookHelpers.CreateBookAsync(authClient, createRequest); var bookId = book.Id; if (discountPercentage > 0) @@ -59,7 +60,7 @@ public async Task SearchBooks_WithVariousPriceAndDiscountScenarios_ShouldFilterC var bookResponse = await authClient.GetBookWithResponseAsync(bookId); var currentVersion = ParseETag(bookResponse.Headers.ETag?.Tag); - _ = await TestHelpers.ExecuteAndWaitForEventAsync(bookId, "BookUpdated", + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(bookId, "BookUpdated", async () => await authClient.ScheduleBookSaleAsync(bookId, saleRequest, bookResponse.Headers.ETag?.Tag), TimeSpan.FromSeconds(10), minVersion: currentVersion + 1); @@ -93,8 +94,8 @@ public async Task SearchBooks_WithVariousPriceAndDiscountScenarios_ShouldFilterC [Test] public async Task SearchBooks_WithMixedCurrency_ShouldRequireSingleCurrencyToMatchRange() { - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publicHttpClient = TestHelpers.GetUnauthenticatedClient(); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publicHttpClient = HttpClientHelpers.GetUnauthenticatedClient(); var publicClient = Refit.RestService.For(publicHttpClient); var uniqueTitle = $"Mixed-NoMatch-{Guid.NewGuid()}"; @@ -113,7 +114,7 @@ public async Task SearchBooks_WithMixedCurrency_ShouldRequireSingleCurrencyToMat CategoryIds = [] }; - var book = await TestHelpers.CreateBookAsync(authClient, createRequest); + var book = await BookHelpers.CreateBookAsync(authClient, createRequest); var contentInitial = await publicClient.GetBooksAsync(new BookSearchRequest { Search = uniqueTitle }); _ = await Assert.That(contentInitial != null && contentInitial.Items.Any(b => b.Title == uniqueTitle)).IsTrue(); @@ -133,8 +134,8 @@ public async Task SearchBooks_WithMixedCurrency_ShouldRequireSingleCurrencyToMat [Test] public async Task SearchBooks_WithDiscount_AfterBookUpdate_ShouldStillFilterByDiscountedPrice() { - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publicHttpClient = TestHelpers.GetUnauthenticatedClient(); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publicHttpClient = HttpClientHelpers.GetUnauthenticatedClient(); var publicClient = Refit.RestService.For(publicHttpClient); var uniqueTitle = $"UpdateResetsDiscount-{Guid.NewGuid()}"; @@ -152,7 +153,7 @@ public async Task SearchBooks_WithDiscount_AfterBookUpdate_ShouldStillFilterByDi CategoryIds = [] }; - var book = await TestHelpers.CreateBookAsync(authClient, createRequest); + var book = await BookHelpers.CreateBookAsync(authClient, createRequest); var bookId = book.Id; var initialVersion = ParseETag(book.ETag); @@ -164,14 +165,14 @@ public async Task SearchBooks_WithDiscount_AfterBookUpdate_ShouldStillFilterByDi $"[Test] Book {bookId} Created. InitialVersion={initialVersion}"); // Wait for ScheduleBookSale (version 2) - _ = await TestHelpers.ExecuteAndWaitForEventAsync(bookId, "BookUpdated", + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(bookId, "BookUpdated", async () => await authClient.ScheduleBookSaleAsync(bookId, saleRequest), TimeSpan.FromSeconds(10), minVersion: initialVersion + 1); // Wait for ApplyBookDiscount side effect (version 3) // This is scheduled to run at Sale.Start (which is UtcNow), so it should execute almost immediately // Instead of Delay, we should wait for the event that signals the discount was applied - _ = await TestHelpers.ExecuteAndWaitForEventAsync(bookId, "BookUpdated", + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(bookId, "BookUpdated", async () => { /* The side effect is already triggered by Marten/Wolverine */ @@ -210,7 +211,7 @@ public async Task SearchBooks_WithDiscount_AfterBookUpdate_ShouldStillFilterByDi Prices = fetchedBook.Prices?.ToDictionary(k => k.Key, v => v.Value) ?? [] }; - _ = await TestHelpers.UpdateBookAsync(authClient, bookId, updateRequest, etagValue!); + _ = await BookHelpers.UpdateBookAsync(authClient, bookId, updateRequest, etagValue!); // 4. Verify book is STILL found in the same price range var updatedTitle = uniqueTitle + " Updated"; diff --git a/tests/BookStore.AppHost.Tests/PublicApiTests.cs b/tests/BookStore.AppHost.Tests/PublicApiTests.cs index 1098118..9955c69 100644 --- a/tests/BookStore.AppHost.Tests/PublicApiTests.cs +++ b/tests/BookStore.AppHost.Tests/PublicApiTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using Refit; @@ -16,7 +17,7 @@ public async Task GetBooks_PublicEndpoint_ShouldReturnOk() _ = await notificationService.WaitForResourceHealthyAsync("apiservice", CancellationToken.None) .WaitAsync(TestConstants.DefaultTimeout); - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); var client = RestService.For(httpClient); // Act @@ -37,7 +38,7 @@ public async Task GetAuthors_PublicEndpoint_ShouldReturnOk() _ = await notificationService.WaitForResourceHealthyAsync("apiservice", CancellationToken.None) .WaitAsync(TestConstants.DefaultTimeout); - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); var client = RestService.For(httpClient); // Act @@ -58,7 +59,7 @@ public async Task GetCategories_PublicEndpoint_ShouldReturnOk() _ = await notificationService.WaitForResourceHealthyAsync("apiservice", CancellationToken.None) .WaitAsync(TestConstants.DefaultTimeout); - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); var client = RestService.For(httpClient); // Act @@ -79,7 +80,7 @@ public async Task GetPublishers_PublicEndpoint_ShouldReturnOk() _ = await notificationService.WaitForResourceHealthyAsync("apiservice", CancellationToken.None) .WaitAsync(TestConstants.DefaultTimeout); - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); var client = RestService.For(httpClient); // Act diff --git a/tests/BookStore.AppHost.Tests/PublisherCrudTests.cs b/tests/BookStore.AppHost.Tests/PublisherCrudTests.cs index cfd2c83..d705e65 100644 --- a/tests/BookStore.AppHost.Tests/PublisherCrudTests.cs +++ b/tests/BookStore.AppHost.Tests/PublisherCrudTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using JasperFx; @@ -15,11 +16,11 @@ public class PublisherCrudTests public async Task CreatePublisher_EndToEndFlow_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createPublisherRequest = TestHelpers.GenerateFakePublisherRequest(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createPublisherRequest = FakeDataGenerators.GenerateFakePublisherRequest(); // Act - var publisher = await TestHelpers.CreatePublisherAsync(client, createPublisherRequest); + var publisher = await PublisherHelpers.CreatePublisherAsync(client, createPublisherRequest); // Assert _ = await Assert.That(publisher).IsNotNull(); @@ -30,19 +31,19 @@ public async Task CreatePublisher_EndToEndFlow_ShouldReturnOk() public async Task UpdatePublisher_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakePublisherRequest(); - var createdPublisher = await TestHelpers.CreatePublisherAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakePublisherRequest(); + var createdPublisher = await PublisherHelpers.CreatePublisherAsync(client, createRequest); var updateRequest = new UpdatePublisherRequest { Name = "Updated Publisher Name" }; // Act - createdPublisher = await TestHelpers.UpdatePublisherAsync(client, createdPublisher, updateRequest); + createdPublisher = await PublisherHelpers.UpdatePublisherAsync(client, createdPublisher, updateRequest); // Verify update in public API (data should be consistent now) var publicClient = RestService.For( - TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); var updatedPublisher = await publicClient.GetPublisherAsync(createdPublisher.Id); _ = await Assert.That(updatedPublisher!.Name).IsEqualTo(updateRequest.Name); } @@ -51,18 +52,18 @@ public async Task UpdatePublisher_ShouldReturnOk() public async Task DeletePublisher_ShouldReturnNoContent() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakePublisherRequest(); - var createdPublisher = await TestHelpers.CreatePublisherAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakePublisherRequest(); + var createdPublisher = await PublisherHelpers.CreatePublisherAsync(client, createRequest); // Act - createdPublisher = await TestHelpers.DeletePublisherAsync(client, createdPublisher); + createdPublisher = await PublisherHelpers.DeletePublisherAsync(client, createdPublisher); // Verify it's gone from public API // Verify it's gone from public API var publicClient = Refit.RestService.For( - TestHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); + HttpClientHelpers.GetUnauthenticatedClient(StorageConstants.DefaultTenantId)); try { _ = await publicClient.GetPublisherAsync(createdPublisher.Id); @@ -78,17 +79,17 @@ public async Task DeletePublisher_ShouldReturnNoContent() public async Task RestorePublisher_ShouldReturnOk() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); // 1. Create Publisher - var createRequest = TestHelpers.GenerateFakePublisherRequest(); - var createdPublisher = await TestHelpers.CreatePublisherAsync(client, createRequest); + var createRequest = FakeDataGenerators.GenerateFakePublisherRequest(); + var createdPublisher = await PublisherHelpers.CreatePublisherAsync(client, createRequest); // 2. Soft Delete Publisher - createdPublisher = await TestHelpers.DeletePublisherAsync(client, createdPublisher); + createdPublisher = await PublisherHelpers.DeletePublisherAsync(client, createdPublisher); // Act - Restore - createdPublisher = await TestHelpers.RestorePublisherAsync(client, createdPublisher); + createdPublisher = await PublisherHelpers.RestorePublisherAsync(client, createdPublisher); // Verify // Use client to get it (should succeed now if visible to admin, which it is) diff --git a/tests/BookStore.AppHost.Tests/RateLimitTests.cs b/tests/BookStore.AppHost.Tests/RateLimitTests.cs index bc4501d..0ce89f8 100644 --- a/tests/BookStore.AppHost.Tests/RateLimitTests.cs +++ b/tests/BookStore.AppHost.Tests/RateLimitTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using Refit; using SharedModels = BookStore.Shared.Models; @@ -16,7 +17,7 @@ public async Task GetFromAuthEndpoint_RepeatedRequests_ShouldConsumeQuota() .WaitAsync(TestConstants.DefaultTimeout); // Use unauthenticated client for login attempt via Refit - var httpClient = TestHelpers.GetUnauthenticatedClient(); + var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); var client = RestService.For(httpClient); // Act & Assert diff --git a/tests/BookStore.AppHost.Tests/RefitMartenRegressionTests.cs b/tests/BookStore.AppHost.Tests/RefitMartenRegressionTests.cs index 608b198..eb20998 100644 --- a/tests/BookStore.AppHost.Tests/RefitMartenRegressionTests.cs +++ b/tests/BookStore.AppHost.Tests/RefitMartenRegressionTests.cs @@ -1,3 +1,4 @@ +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Marten; @@ -14,7 +15,7 @@ public class RefitMartenRegressionTests public async Task GetPublishers_ShouldReturnPagedListDto_MatchingRefitExpectation() { // Arrange - var client = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // Act // This effectively tests that the server response structure matches PagedListDto @@ -31,8 +32,8 @@ public async Task GetPublishers_ShouldReturnPagedListDto_MatchingRefitExpectatio public async Task SearchBooks_WithPriceFilter_ShouldNotThrow500() { // Arrange - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // Create a book with a specific price to ensure we have data to query against var uniqueTitle = $"PriceTest-{Guid.NewGuid()}"; @@ -47,7 +48,7 @@ public async Task SearchBooks_WithPriceFilter_ShouldNotThrow500() PublicationDate = new PartialDate(2024, 1, 1), Prices = new Dictionary { ["USD"] = 10.0m } }; - _ = await TestHelpers.CreateBookAsync(authClient, createRequest); + _ = await BookHelpers.CreateBookAsync(authClient, createRequest); // Act // This query caused Marten.Exceptions.BadLinqExpressionException before the fix @@ -68,8 +69,8 @@ public async Task SearchBooks_WithPriceFilter_ShouldNotThrow500() public async Task SearchBooks_WithPriceFilter_ShouldExcludeOutOfRange() { // Arrange - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // Create a book with price 20.0 (outside range 5-15) var uniqueTitle = $"OutOfRange-{Guid.NewGuid()}"; @@ -84,7 +85,7 @@ public async Task SearchBooks_WithPriceFilter_ShouldExcludeOutOfRange() PublicationDate = new PartialDate(2024, 1, 1), Prices = new Dictionary { ["USD"] = 20.0m } }; - _ = await TestHelpers.CreateBookAsync(authClient, createRequest); + _ = await BookHelpers.CreateBookAsync(authClient, createRequest); // Act var response = await publicClient.GetBooksAsync(new BookSearchRequest @@ -104,9 +105,9 @@ public async Task SearchBooks_WithDateSort_ShouldNotThrow500() { // Arrange // Create a book to ensure data exists with the new PublicationDateString field populated - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var uniqueTitle = $"DateSort-{Guid.NewGuid()}"; - _ = await TestHelpers.CreateBookAsync(authClient, + _ = await BookHelpers.CreateBookAsync(authClient, new CreateBookRequest { Id = Guid.CreateVersion7(), @@ -119,7 +120,7 @@ public async Task SearchBooks_WithDateSort_ShouldNotThrow500() Prices = new Dictionary { ["USD"] = 10.0m } }); - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // Act // This query caused Marten.Exceptions.BadLinqExpressionException before the fix @@ -139,8 +140,8 @@ public async Task SearchBooks_WithPriceFilter_ShouldExcludeBooksWithHighPrimaryP // it matches because 10 <= 15, even though the USD price is 100. // Arrange - var authClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); var uniqueTitle = $"CurrencyMismatch-{Guid.NewGuid()}"; var createRequest = new CreateBookRequest @@ -158,7 +159,7 @@ public async Task SearchBooks_WithPriceFilter_ShouldExcludeBooksWithHighPrimaryP ["EUR"] = 10.0m // Cheap in EUR } }; - _ = await TestHelpers.CreateBookAsync(authClient, createRequest); + _ = await BookHelpers.CreateBookAsync(authClient, createRequest); // Act // Filter: MaxPrice 15 AND Currency=USD. @@ -202,30 +203,30 @@ public async Task SearchBooks_InNonDefaultTenant_WithAuthorFilter_ShouldReturnBo opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); })) { - await TestHelpers.SeedTenantAsync(store, tenantId); + await DatabaseHelpers.SeedTenantAsync(store, tenantId); } // 1. Authenticate as Admin in the new tenant - var loginRes = await TestHelpers.LoginAsAdminAsync(tenantId); + var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(tenantId); _ = await Assert.That(loginRes).IsNotNull(); var adminClient = - RestService.For(TestHelpers.GetAuthenticatedClient(loginRes!.AccessToken, tenantId)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginRes!.AccessToken, tenantId)); var adminBooksClient = - RestService.For(TestHelpers.GetAuthenticatedClient(loginRes!.AccessToken, tenantId)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginRes!.AccessToken, tenantId)); // 2. Create an Author in this tenant - var authorReq = TestHelpers.GenerateFakeAuthorRequest(); + var authorReq = FakeDataGenerators.GenerateFakeAuthorRequest(); var authorRes = await adminClient.CreateAuthorWithResponseAsync(authorReq); _ = await Assert.That(authorRes.StatusCode).IsEqualTo(HttpStatusCode.Created); var authorId = authorRes.Content!.Id; // 3. Create a Book linked to this Author - var bookReq = TestHelpers.GenerateFakeBookRequest(authorIds: new[] { authorId }); - var book = await TestHelpers.CreateBookAsync(adminBooksClient, bookReq); + var bookReq = FakeDataGenerators.GenerateFakeBookRequest(authorIds: new[] { authorId }); + var book = await BookHelpers.CreateBookAsync(adminBooksClient, bookReq); // 4. Search for the book using the Author Filter - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient(tenantId)); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); // Act var response = await publicClient.GetBooksAsync(new BookSearchRequest { AuthorId = authorId }); @@ -256,29 +257,29 @@ public async Task GetAuthors_InDifferentTenants_ShouldNotReturnCachedResultsFrom opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); }); - await TestHelpers.SeedTenantAsync(store, tenantA); - await TestHelpers.SeedTenantAsync(store, tenantB); + await DatabaseHelpers.SeedTenantAsync(store, tenantA); + await DatabaseHelpers.SeedTenantAsync(store, tenantB); - var loginResA = await TestHelpers.LoginAsAdminAsync(tenantA); + var loginResA = await AuthenticationHelpers.LoginAsAdminAsync(tenantA); var adminClientA = - RestService.For(TestHelpers.GetAuthenticatedClient(loginResA!.AccessToken, tenantA)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginResA!.AccessToken, tenantA)); - var loginResB = await TestHelpers.LoginAsAdminAsync(tenantB); + var loginResB = await AuthenticationHelpers.LoginAsAdminAsync(tenantB); var adminClientB = - RestService.For(TestHelpers.GetAuthenticatedClient(loginResB!.AccessToken, tenantB)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(loginResB!.AccessToken, tenantB)); // Create Unique Authors and wait for projection - var authorReqA = TestHelpers.GenerateFakeAuthorRequest(); - var authorA = await TestHelpers.CreateAuthorAsync(adminClientA, authorReqA); + var authorReqA = FakeDataGenerators.GenerateFakeAuthorRequest(); + var authorA = await AuthorHelpers.CreateAuthorAsync(adminClientA, authorReqA); _ = await Assert.That(authorA).IsNotNull(); - var authorReqB = TestHelpers.GenerateFakeAuthorRequest(); - var authorB = await TestHelpers.CreateAuthorAsync(adminClientB, authorReqB); + var authorReqB = FakeDataGenerators.GenerateFakeAuthorRequest(); + var authorB = await AuthorHelpers.CreateAuthorAsync(adminClientB, authorReqB); _ = await Assert.That(authorB).IsNotNull(); // Act & Assert // 1. Get Authors from Tenant A. Should contain Author A. - var publicClientA = RestService.For(TestHelpers.GetUnauthenticatedClient(tenantA)); + var publicClientA = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantA)); var nameA = authorReqA.Name; @@ -286,7 +287,7 @@ public async Task GetAuthors_InDifferentTenants_ShouldNotReturnCachedResultsFrom _ = await Assert.That(listA.Items.Any(a => a.Name == nameA)).IsTrue(); // 2. Get Authors from Tenant B. Should contain Author B, AND NOT Author A. - var publicClientB = RestService.For(TestHelpers.GetUnauthenticatedClient(tenantB)); + var publicClientB = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantB)); var nameB = authorReqB.Name; diff --git a/tests/BookStore.AppHost.Tests/SearchTests.cs b/tests/BookStore.AppHost.Tests/SearchTests.cs index 33b6d9e..78c55a7 100644 --- a/tests/BookStore.AppHost.Tests/SearchTests.cs +++ b/tests/BookStore.AppHost.Tests/SearchTests.cs @@ -1,3 +1,4 @@ +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; @@ -9,7 +10,7 @@ public class SearchTests public async Task SearchBooks_WithValidQuery_ShouldReturnMatches() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var uniqueTitle = $"UniqueSearchTerm-{Guid.NewGuid()}"; // Create a book with a unique title using proper request model @@ -27,10 +28,10 @@ public async Task SearchBooks_WithValidQuery_ShouldReturnMatches() CategoryIds = [], Prices = new Dictionary { ["USD"] = 10.0m } }; - var createdBook = await TestHelpers.CreateBookAsync(adminClient, createRequest); + var createdBook = await BookHelpers.CreateBookAsync(adminClient, createRequest); // Act - var publicClient = TestHelpers.GetUnauthenticatedClient(); + var publicClient = HttpClientHelpers.GetUnauthenticatedClient(); var searchResult = await publicClient.GetBooksAsync(new BookSearchRequest { Search = uniqueTitle }); // Assert @@ -43,7 +44,7 @@ public async Task SearchBooks_WithValidQuery_ShouldReturnMatches() public async Task SearchBooks_WithNoMatches_ShouldReturnEmpty() { // Arrange - var publicClient = TestHelpers.GetUnauthenticatedClient(); + var publicClient = HttpClientHelpers.GetUnauthenticatedClient(); var globalHooks = GlobalHooks.NotificationService; // ensure app is ready _ = await globalHooks!.WaitForResourceHealthyAsync("apiservice", CancellationToken.None) .WaitAsync(TestConstants.DefaultTimeout); diff --git a/tests/BookStore.AppHost.Tests/ShoppingCartTests.cs b/tests/BookStore.AppHost.Tests/ShoppingCartTests.cs index a17971d..e5ca5e7 100644 --- a/tests/BookStore.AppHost.Tests/ShoppingCartTests.cs +++ b/tests/BookStore.AppHost.Tests/ShoppingCartTests.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -11,14 +12,14 @@ public class ShoppingCartTests public async Task AddToCart_ShouldAddItemToCart() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create a book first (cart needs real books to display) - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // Act - Add item to cart and wait for async projection - await TestHelpers.AddToCartAsync(client, createdBook.Id, 2); + await ShoppingCartHelpers.AddToCartAsync(client, createdBook.Id, 2); // Assert - Verify cart contains item var cart = await client.GetShoppingCartAsync(); @@ -33,16 +34,16 @@ public async Task AddToCart_ShouldAddItemToCart() public async Task AddToCart_MultipleTimes_ShouldAccumulateQuantity() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create a book first - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); // Act - Add same book twice - await TestHelpers.AddToCartAsync(client, createdBook.Id, 2); + await ShoppingCartHelpers.AddToCartAsync(client, createdBook.Id, 2); - await TestHelpers.AddToCartAsync(client, createdBook.Id, 3); + await ShoppingCartHelpers.AddToCartAsync(client, createdBook.Id, 3); // Assert - Quantity should be accumulated (2 + 3 = 5) var cart = await client.GetShoppingCartAsync(); @@ -56,19 +57,19 @@ public async Task AddToCart_MultipleTimes_ShouldAccumulateQuantity() public async Task UpdateCartItemQuantity_ShouldUpdateQuantity() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create a book first - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); var bookId = createdBook.Id; // Add item first - await TestHelpers.AddToCartAsync(client, bookId, 2); + await ShoppingCartHelpers.AddToCartAsync(client, bookId, 2); // Act - Update quantity - await TestHelpers.UpdateCartItemQuantityAsync(client, bookId, 5); + await ShoppingCartHelpers.UpdateCartItemQuantityAsync(client, bookId, 5); // Assert var cart = await client.GetShoppingCartAsync(); @@ -80,23 +81,23 @@ public async Task UpdateCartItemQuantity_ShouldUpdateQuantity() public async Task RemoveFromCart_ShouldRemoveItem() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create a book first - var createdBook = await TestHelpers.CreateBookAsync(adminClient); + var createdBook = await BookHelpers.CreateBookAsync(adminClient); var bookId = createdBook.Id; // Add item first - await TestHelpers.AddToCartAsync(client, bookId, 2); + await ShoppingCartHelpers.AddToCartAsync(client, bookId, 2); // Verify it exists var cartBefore = await client.GetShoppingCartAsync(); _ = await Assert.That(cartBefore.Items.Count).IsEqualTo(1); // Act - Remove item - await TestHelpers.RemoveFromCartAsync(client, bookId); + await ShoppingCartHelpers.RemoveFromCartAsync(client, bookId); // Assert - Cart should be empty var cart = await client.GetShoppingCartAsync(); @@ -108,25 +109,25 @@ public async Task RemoveFromCart_ShouldRemoveItem() public async Task ClearCart_ShouldRemoveAllItems() { // Arrange - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Create 3 books first - var book1 = await TestHelpers.CreateBookAsync(adminClient); - var book2 = await TestHelpers.CreateBookAsync(adminClient); - var book3 = await TestHelpers.CreateBookAsync(adminClient); + var book1 = await BookHelpers.CreateBookAsync(adminClient); + var book2 = await BookHelpers.CreateBookAsync(adminClient); + var book3 = await BookHelpers.CreateBookAsync(adminClient); // Add multiple items - await TestHelpers.AddToCartAsync(client, book1.Id, 2); - await TestHelpers.AddToCartAsync(client, book2.Id, 3); - await TestHelpers.AddToCartAsync(client, book3.Id); + await ShoppingCartHelpers.AddToCartAsync(client, book1.Id, 2); + await ShoppingCartHelpers.AddToCartAsync(client, book2.Id, 3); + await ShoppingCartHelpers.AddToCartAsync(client, book3.Id); // Verify items exist var cartBefore = await client.GetShoppingCartAsync(); _ = await Assert.That(cartBefore.Items.Count).IsEqualTo(3); //Act - Clear cart - await TestHelpers.ClearCartAsync(client); + await ShoppingCartHelpers.ClearCartAsync(client); // Assert - Cart should be empty var cart = await client.GetShoppingCartAsync(); @@ -138,7 +139,7 @@ public async Task ClearCart_ShouldRemoveAllItems() public async Task GetCart_WhenEmpty_ShouldReturnEmptyCart() { // Arrange - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Act var cart = await client.GetShoppingCartAsync(); @@ -156,14 +157,14 @@ public async Task GetCart_WhenEmpty_ShouldReturnEmptyCart() public async Task AddToCart_WithInvalidQuantity_ShouldReturnBadRequest(int quantity) { // Arrange - var client = await TestHelpers.CreateUserAndGetClientAsync(); + var client = await AuthenticationHelpers.CreateUserAndGetClientAsync(); // Act & Assert var exception = await Assert .That(() => client.AddToCartAsync(new AddToCartClientRequest(Guid.NewGuid(), quantity))) .Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); - var error = await exception.GetContentAsAsync(); + var error = await exception.GetContentAsAsync(); _ = await Assert.That(error?.Error).IsEqualTo(ErrorCodes.Cart.InvalidQuantity); } @@ -171,7 +172,7 @@ public async Task AddToCart_WithInvalidQuantity_ShouldReturnBadRequest(int quant public async Task CartOperations_WhenUnauthenticated_ShouldReturnUnauthorized() { // Arrange - var unauthenticatedHttpClient = TestHelpers.GetUnauthenticatedClient(); + var unauthenticatedHttpClient = HttpClientHelpers.GetUnauthenticatedClient(); var client = RestService.For(unauthenticatedHttpClient); // Act & Assert - Get cart diff --git a/tests/BookStore.AppHost.Tests/TenantInfoTests.cs b/tests/BookStore.AppHost.Tests/TenantInfoTests.cs index 2c76e2e..70fb8f2 100644 --- a/tests/BookStore.AppHost.Tests/TenantInfoTests.cs +++ b/tests/BookStore.AppHost.Tests/TenantInfoTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -10,7 +11,7 @@ public class TenantInfoTests [Test] public async Task GetTenantInfo_ReturnsCorrectName() { - var client = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // 1. Get info for "acme" var acmeInfo = await client.GetTenantAsync("acme"); @@ -28,7 +29,7 @@ public async Task GetTenantInfo_ReturnsCorrectName() [Test] public async Task GetTenantInfo_InvalidId_ReturnsNotFound() { - var client = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); var exception = await Assert.That(async () => await client.GetTenantAsync("invalid-tenant-id")) .Throws(); diff --git a/tests/BookStore.AppHost.Tests/TenantSecurityTests.cs b/tests/BookStore.AppHost.Tests/TenantSecurityTests.cs index 0ee42e3..5e2f377 100644 --- a/tests/BookStore.AppHost.Tests/TenantSecurityTests.cs +++ b/tests/BookStore.AppHost.Tests/TenantSecurityTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using Refit; @@ -18,7 +19,7 @@ public async Task Request_WithNoTenantIdClaim_ShouldBeForbidden() // Arrange // Test 1: Valid token (tenant=Default/BookStore), Header=acme -> Should Fail - var client = RestService.For(TestHelpers.GetAuthenticatedClient(validToken, "acme")); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(validToken, "acme")); // Act & Assert var exception = await Assert.That(async () => await client.GetShoppingCartAsync()).Throws(); @@ -34,7 +35,7 @@ public async Task Request_Anonymous_WithTenantHeader_ShouldBeForbidden() } // Test 2: Anonymous user with X-Tenant-ID="acme" -> Should be Forbidden - var client = RestService.For(TestHelpers.GetUnauthenticatedClient("acme")); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("acme")); // Act & Assert var exception = await Assert.That(async () => await client.GetShoppingCartAsync()).Throws(); @@ -51,7 +52,7 @@ public async Task Request_NoTenantClaim_ShouldBeForbidden() // Test 3: Same as Test 1 basically - Valid Token (Default), Header (acme) -> Mismatch -> Forbidden var validToken = GlobalHooks.AdminAccessToken!; - var client = RestService.For(TestHelpers.GetAuthenticatedClient(validToken, "acme")); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(validToken, "acme")); // Act & Assert var exception = await Assert.That(async () => await client.GetShoppingCartAsync()).Throws(); @@ -73,7 +74,7 @@ public async Task Admin_TenantList_RestrictedToDefaultTenant() // 1. Success path: Default Tenant Admin (GlobalHooks.AdminAccessToken) accessing Default Tenant endpoint var client = - RestService.For(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken)); + RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken)); // Act var result = await client.GetAllTenantsAdminAsync(); diff --git a/tests/BookStore.AppHost.Tests/TenantUserIsolationTests.cs b/tests/BookStore.AppHost.Tests/TenantUserIsolationTests.cs index a5f20cf..a8d5a5e 100644 --- a/tests/BookStore.AppHost.Tests/TenantUserIsolationTests.cs +++ b/tests/BookStore.AppHost.Tests/TenantUserIsolationTests.cs @@ -1,4 +1,5 @@ using System.Net; +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -17,11 +18,11 @@ public async Task RateBook_InSpecificTenant_ShouldUpdateRating() { // Arrange - Setup tenant and user var tenantId = "acme"; - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var loginRes = await TestHelpers.LoginAsAdminAsync(adminClient, tenantId); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(adminClient, tenantId); _ = await Assert.That(loginRes).IsNotNull(); - var tenantAdminClient = await TestHelpers.GetTenantClientAsync(tenantId, loginRes!.AccessToken); + var tenantAdminClient = await HttpClientHelpers.GetTenantClientAsync(tenantId, loginRes!.AccessToken); var tenantAdminBooksClient = Refit.RestService.For(tenantAdminClient); // Use Refit to create book @@ -40,18 +41,18 @@ public async Task RateBook_InSpecificTenant_ShouldUpdateRating() }; SharedModels.BookDto book = null!; - _ = await TestHelpers.ExecuteAndWaitForEventAsync(Guid.Empty, ["BookCreated", "BookUpdated"], + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(Guid.Empty, ["BookCreated", "BookUpdated"], async () => book = await tenantAdminBooksClient.CreateBookAsync(createRequest), TestConstants.DefaultEventTimeout); - var userClient = await TestHelpers.CreateUserAndGetClientAsync(tenantId); + var userClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(tenantId); var userBooksClient = Refit.RestService.For(userClient.Client); // Act - Rate the book var rating = 5; // Verify method name in IBooksClient. IRateBookEndpoint.RateBookAsync? // Using wait for event - _ = await TestHelpers.ExecuteAndWaitForEventAsync(book.Id, "BookUpdated", + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(book.Id, "BookUpdated", async () => await userBooksClient.RateBookAsync(book.Id, new RateBookRequest(rating)), TestConstants.DefaultEventTimeout); @@ -66,11 +67,11 @@ public async Task AddToFavorites_InSpecificTenant_ShouldUpdateFavorites() { // Arrange var tenantId = "contoso"; - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var loginRes = await TestHelpers.LoginAsAdminAsync(adminClient, tenantId); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(adminClient, tenantId); _ = await Assert.That(loginRes).IsNotNull(); - var tenantAdminClient = await TestHelpers.GetTenantClientAsync(tenantId, loginRes!.AccessToken); + var tenantAdminClient = await HttpClientHelpers.GetTenantClientAsync(tenantId, loginRes!.AccessToken); var tenantAdminBooksClient = Refit.RestService.For(tenantAdminClient); var createRequest = new CreateBookRequest @@ -88,17 +89,17 @@ public async Task AddToFavorites_InSpecificTenant_ShouldUpdateFavorites() }; SharedModels.BookDto book = null!; - _ = await TestHelpers.ExecuteAndWaitForEventAsync(Guid.Empty, ["BookCreated", "BookUpdated"], + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(Guid.Empty, ["BookCreated", "BookUpdated"], async () => book = await tenantAdminBooksClient.CreateBookAsync(createRequest), TestConstants.DefaultEventTimeout); - var userClient = await TestHelpers.CreateUserAndGetClientAsync(tenantId); + var userClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(tenantId); var userBooksClient = Refit.RestService.For(userClient.Client); // Act // Act // AddBookToFavoritesAsync ? - _ = await TestHelpers.ExecuteAndWaitForEventAsync(userClient.UserId, "UserUpdated", + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(userClient.UserId, "UserUpdated", async () => await userBooksClient.AddBookToFavoritesAsync(book.Id), TestConstants.DefaultEventTimeout); @@ -117,11 +118,11 @@ public async Task AddToCart_InSpecificTenant_ShouldPersistInTenant() { // Arrange - Setup tenant-specific context var tenantId = "acme"; - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); - var loginRes = await TestHelpers.LoginAsAdminAsync(adminClient, tenantId); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(adminClient, tenantId); _ = await Assert.That(loginRes).IsNotNull(); - var tenantAdminClient = await TestHelpers.GetTenantClientAsync(tenantId, loginRes!.AccessToken); + var tenantAdminClient = await HttpClientHelpers.GetTenantClientAsync(tenantId, loginRes!.AccessToken); var tenantAdminBooksClient = Refit.RestService.For(tenantAdminClient); var createRequest = new CreateBookRequest @@ -139,16 +140,16 @@ public async Task AddToCart_InSpecificTenant_ShouldPersistInTenant() }; SharedModels.BookDto book = null!; - _ = await TestHelpers.ExecuteAndWaitForEventAsync(Guid.Empty, ["BookCreated", "BookUpdated"], + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(Guid.Empty, ["BookCreated", "BookUpdated"], async () => book = await tenantAdminBooksClient.CreateBookAsync(createRequest), TestConstants.DefaultEventTimeout); - var userClient = await TestHelpers.CreateUserAndGetClientAsync(tenantId); + var userClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(tenantId); // Need Cart Client var userCartClient = Refit.RestService.For(userClient.Client); // Act - Add to cart - _ = await TestHelpers.ExecuteAndWaitForEventAsync(userClient.UserId, "UserUpdated", + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(userClient.UserId, "UserUpdated", async () => await userCartClient.AddToCartAsync(new AddToCartClientRequest(book.Id, 2)), TestConstants.DefaultEventTimeout); @@ -165,13 +166,13 @@ public async Task UserData_ShouldBeIsolatedBetweenTenants() var tenant1 = "acme"; var tenant2 = "contoso"; - var adminClient = await TestHelpers.GetAuthenticatedClientAsync(); + var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); // Helper to setup tenant and create book - async Task<(SharedModels.BookDto book, TestHelpers.UserClient userClient)> SetupTenantAsync(string tid) + async Task<(SharedModels.BookDto book, AuthenticationHelpers.UserClient userClient)> SetupTenantAsync(string tid) { - var login = await TestHelpers.LoginAsAdminAsync(adminClient, tid); - var tClient = await TestHelpers.GetTenantClientAsync(tid, login!.AccessToken); + var login = await AuthenticationHelpers.LoginAsAdminAsync(adminClient, tid); + var tClient = await HttpClientHelpers.GetTenantClientAsync(tid, login!.AccessToken); var tBooksClient = Refit.RestService.For(tClient); var createRequest = new CreateBookRequest @@ -189,11 +190,11 @@ public async Task UserData_ShouldBeIsolatedBetweenTenants() }; SharedModels.BookDto createdBook = null!; - _ = await TestHelpers.ExecuteAndWaitForEventAsync(Guid.Empty, ["BookCreated", "BookUpdated"], + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(Guid.Empty, ["BookCreated", "BookUpdated"], async () => createdBook = await tBooksClient.CreateBookAsync(createRequest), TestConstants.DefaultEventTimeout); - var uClient = await TestHelpers.CreateUserAndGetClientAsync(tid); + var uClient = await AuthenticationHelpers.CreateUserAndGetClientAsync(tid); return (createdBook, uClient); } @@ -204,11 +205,11 @@ public async Task UserData_ShouldBeIsolatedBetweenTenants() var user2Client = Refit.RestService.For(user2ClientInfo.Client); // Act - User1 rates book1, User2 rates book2 - _ = await TestHelpers.ExecuteAndWaitForEventAsync(book1.Id, "BookUpdated", + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(book1.Id, "BookUpdated", async () => await user1Client.RateBookAsync(book1.Id, new RateBookRequest(5)), TestConstants.DefaultEventTimeout); - _ = await TestHelpers.ExecuteAndWaitForEventAsync(book2.Id, "BookUpdated", + _ = await SseEventHelpers.ExecuteAndWaitForEventAsync(book2.Id, "BookUpdated", async () => await user2Client.RateBookAsync(book2.Id, new RateBookRequest(3)), TestConstants.DefaultEventTimeout); diff --git a/tests/BookStore.AppHost.Tests/TestHelpers.cs b/tests/BookStore.AppHost.Tests/TestHelpers.cs deleted file mode 100644 index 1d5f677..0000000 --- a/tests/BookStore.AppHost.Tests/TestHelpers.cs +++ /dev/null @@ -1,1649 +0,0 @@ -using System.Linq; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Net.ServerSentEvents; -using System.Text.Json; -using Aspire.Hosting; -using Bogus; -using BookStore.ApiService.Infrastructure.Tenant; -using BookStore.Client; -using BookStore.Shared.Models; -using JasperFx; -using Marten; -using Refit; -using Weasel.Core; -// Resolve ambiguities by preferring Client types -using CreateBookRequest = BookStore.Client.CreateBookRequest; -using SharedModels = BookStore.Shared.Models; -using UpdateBookRequest = BookStore.Client.UpdateBookRequest; - -namespace BookStore.AppHost.Tests; - -public static class TestHelpers -{ - static readonly Faker _faker = new(); - - /// - /// Generates a random password that meets common password requirements. - /// - /// A password with at least 12 characters including uppercase, lowercase, numbers, and special characters. - public static string GenerateFakePassword() => _faker.Internet.Password(12, false, "", "Aa1!"); - - /// - /// Generates a random email address for testing. - /// - /// A valid email address. - public static string GenerateFakeEmail() => _faker.Internet.Email(); - - /// - /// Creates a Marten DocumentStore configured for the BookStore application. - /// - /// A configured IDocumentStore instance. - public static async Task GetDocumentStoreAsync() - { - var connectionString = await GlobalHooks.App!.GetConnectionStringAsync("bookstore"); - return DocumentStore.For(opts => - { - opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); - opts.Connection(connectionString!); - _ = opts.Policies.AllDocumentsAreMultiTenanted(); - opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; - }); - } - - /// - /// Retrieves a user by email address from the given Marten session. - /// - public static async Task GetUserByEmailAsync( - Marten.IQuerySession session, - string email) => await session.Query() - .Where(u => u.NormalizedEmail == email.ToUpperInvariant()) - .FirstOrDefaultAsync(); - - /// - /// Registers a new user and logs them in, returning complete authentication details. - /// - public static async Task<(string Email, string Password, LoginResponse Login, string TenantId)> - RegisterAndLoginUserAsync(string? tenantId = null) - { - tenantId ??= StorageConstants.DefaultTenantId; - var email = GenerateFakeEmail(); - var password = GenerateFakePassword(); - - var client = GetUnauthenticatedClient(tenantId); - var registerResponse = await client.PostAsJsonAsync("/account/register", new { email, password }); - _ = registerResponse.EnsureSuccessStatusCode(); - - var loginResponse = await client.PostAsJsonAsync("/account/login", new { email, password }); - _ = loginResponse.EnsureSuccessStatusCode(); - - var tokenResponse = await loginResponse.Content.ReadFromJsonAsync(); - if (tokenResponse == null) - { - throw new InvalidOperationException("Login response was null."); - } - - return (email, password, tokenResponse, tenantId); - } - - /// - /// Generates a fake book creation request with random data using Bogus. - /// - /// Optional publisher ID. If null, the book will have no publisher. - /// Optional collection of author IDs. If null or empty, the book will have no authors. - /// Optional collection of category IDs. If null or empty, the book will have no categories. - /// A CreateBookRequest with randomized title, ISBN, translations, and prices. - public static CreateBookRequest - GenerateFakeBookRequest(Guid? publisherId = null, IEnumerable? authorIds = null, - IEnumerable? categoryIds = null) => new() - { - Id = Guid.CreateVersion7(), - Title = _faker.Commerce.ProductName(), - Isbn = _faker.Commerce.Ean13(), - Language = "en", - Translations = - new Dictionary - { - ["en"] = new(_faker.Lorem.Paragraph()), - ["es"] = new(_faker.Lorem.Paragraph()) - }, - PublicationDate = new PartialDate( - _faker.Date.Past(10).Year, - _faker.Random.Int(1, 12), - _faker.Random.Int(1, 28)), - PublisherId = publisherId, - AuthorIds = [.. (authorIds ?? [])], - CategoryIds = [.. (categoryIds ?? [])], - Prices = new Dictionary { ["USD"] = decimal.Parse(_faker.Commerce.Price(10, 100)) } - }; - - public static UpdateBookRequest - GenerateFakeUpdateBookRequest(Guid? publisherId = null, IEnumerable? authorIds = null, - IEnumerable? categoryIds = null) => new() - { - Title = _faker.Commerce.ProductName(), - Isbn = _faker.Commerce.Ean13(), - Language = "en", - Translations = - new Dictionary - { - ["en"] = new(_faker.Lorem.Paragraph()), - ["es"] = new(_faker.Lorem.Paragraph()) - }, - PublicationDate = new PartialDate( - _faker.Date.Past(10).Year, - _faker.Random.Int(1, 12), - _faker.Random.Int(1, 28)), - PublisherId = publisherId, - AuthorIds = [.. (authorIds ?? [])], - CategoryIds = [.. (categoryIds ?? [])], - Prices = new Dictionary { ["USD"] = decimal.Parse(_faker.Commerce.Price(10, 100)) } - }; - - /// - /// Generates a fake author creation request with random data using Bogus. - /// - /// A CreateAuthorRequest with randomized name and biography in English and Spanish. - public static CreateAuthorRequest GenerateFakeAuthorRequest() => new() - { - Id = Guid.CreateVersion7(), - Name = _faker.Name.FullName(), - Translations = new Dictionary - { - ["en"] = new(_faker.Lorem.Paragraphs(2)), - ["es"] = new(_faker.Lorem.Paragraphs(2)) - } - }; - - public static BookStore.Client.UpdateAuthorRequest GenerateFakeUpdateAuthorRequest() => new() - { - Name = _faker.Name.FullName(), - Translations = new Dictionary - { - ["en"] = new(_faker.Lorem.Paragraphs(2)), - ["es"] = new(_faker.Lorem.Paragraphs(2)) - } - }; - - /// - /// Generates a fake category creation request with random data using Bogus. - /// - /// A CreateCategoryRequest with randomized name and description in English and Spanish. - public static CreateCategoryRequest GenerateFakeCategoryRequest() => new() - { - Id = Guid.CreateVersion7(), - Translations = new Dictionary - { - ["en"] = new(_faker.Commerce.Department()), - ["es"] = new(_faker.Commerce.Department()) - } - }; - - /// - /// Generates a fake category update request with random data using Bogus. - /// - /// An UpdateCategoryRequest with randomized name and description in English and Spanish. - public static BookStore.Client.UpdateCategoryRequest GenerateFakeUpdateCategoryRequest() => new() - { - Translations = new Dictionary - { - ["en"] = new(_faker.Commerce.Department()), - ["es"] = new(_faker.Commerce.Department()) - } - }; - - public static async Task CreateCategoryAsync(ICategoriesClient client, CreateCategoryRequest request) - { - var received = await ExecuteAndWaitForEventAsync( - request.Id, - ["CategoryCreated", "CategoryUpdated"], - async () => - { - var response = await client.CreateCategoryWithResponseAsync(request); - if (response.Error != null) - { - throw response.Error; - } - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Failed to receive CategoryCreated event."); - } - - return await client.GetCategoryAsync(request.Id); - } - - public static async Task UpdateCategoryAsync(ICategoriesClient client, CategoryDto category, - UpdateCategoryRequest request) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(category.ETag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - category.Id, - "CategoryUpdated", - async () => await client.UpdateCategoryAsync(category.Id, request, category.ETag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Failed to receive CategoryUpdated event."); - } - - return await client.GetCategoryAsync(category.Id); - } - - public static async Task UpdateCategoryAsync(ICategoriesClient client, AdminCategoryDto category, - UpdateCategoryRequest request) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(category.ETag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - category.Id, - "CategoryUpdated", - async () => await client.UpdateCategoryAsync(category.Id, request, category.ETag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Failed to receive CategoryUpdated event."); - } - - return await client.GetCategoryAdminAsync(category.Id); - } - - public static async Task DeleteCategoryAsync(ICategoriesClient client, CategoryDto category) - { - var result = await ExecuteAndWaitForEventWithVersionAsync( - category.Id, - "CategoryDeleted", - async () => await client.SoftDeleteCategoryAsync(category.Id, category.ETag), - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!result.Success) - { - throw new Exception("Failed to receive CategoryDeleted event."); - } - - try - { - return await client.GetCategoryAsync(category.Id); - } - catch (Refit.ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Soft-deleted, hidden from public API. Construct DTO with reconstructed ETag. - return category with { ETag = $"\"{result.Version}\"" }; - } - } - - public static async Task RestoreCategoryAsync(ICategoriesClient client, CategoryDto category) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(category.ETag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - category.Id, - "CategoryUpdated", - async () => await client.RestoreCategoryAsync(category.Id, category.ETag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Failed to receive CategoryUpdated event (Restore)."); - } - - return await client.GetCategoryAsync(category.Id); - } - - /// - /// Generates a fake publisher creation request with random data using Bogus. - /// - /// A CreatePublisherRequest with a randomized company name. - public static CreatePublisherRequest GenerateFakePublisherRequest() - => new() { Id = Guid.CreateVersion7(), Name = _faker.Company.CompanyName() }; - - public static HttpClient GetAuthenticatedClient(string accessToken) - { - var client = GetUnauthenticatedClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - return client; - } - - public static HttpClient GetAuthenticatedClient(string accessToken, string tenantId) - { - var client = GetUnauthenticatedClient(tenantId); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - return client; - } - - public static async Task GetAuthenticatedClientAsync() - { - var app = GlobalHooks.App!; - var client = app.CreateHttpClient("apiservice"); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", GlobalHooks.AdminAccessToken); - client.DefaultRequestHeaders.Add("X-Tenant-ID", StorageConstants.DefaultTenantId); - return await Task.FromResult(client); - } - - /// - /// Gets an authenticated HTTP client for the API service using the global admin token. - /// - /// The Refit interface type to create a client for. - /// A Refit client instance configured with admin authentication. - public static async Task GetAuthenticatedClientAsync() - { - var httpClient = await GetAuthenticatedClientAsync(); - return RestService.For(httpClient); - } - - public static HttpClient GetUnauthenticatedClient() - => GetUnauthenticatedClient(StorageConstants.DefaultTenantId); - - public static HttpClient GetUnauthenticatedClient(string tenantId) - { - var app = GlobalHooks.App!; - var client = app.CreateHttpClient("apiservice"); - client.DefaultRequestHeaders.Add("X-Tenant-ID", tenantId); - return client; - } - - public static T GetUnauthenticatedClient() - { - var httpClient = GetUnauthenticatedClient(); - return RestService.For(httpClient); - } - - public static T GetUnauthenticatedClientWithLanguage(string language) - { - var httpClient = GetUnauthenticatedClient(); - httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd(language); - return RestService.For(httpClient); - } - - /// - /// Executes an action while listening for a specific SSE event. - /// This ensures the SSE client is connected BEFORE the action is performed, - /// simulating a real client that's already listening for changes. - /// - /// The entity ID to match, or Guid.Empty to match any entity - /// The event type to listen for (e.g., "CategoryCreated") - /// The action to perform (e.g., create/update/delete) - /// How long to wait for the event - public static async Task ExecuteAndWaitForEventAsync( - Guid entityId, - string eventType, - Func action, - TimeSpan timeout, - long minVersion = 0, - DateTimeOffset? minTimestamp = null) - => (await ExecuteAndWaitForEventWithVersionAsync(entityId, eventType, action, timeout, minVersion, - minTimestamp)) - .Success; - - public static async Task ExecuteAndWaitForEventWithVersionAsync( - Guid entityId, - string eventType, - Func action, - TimeSpan timeout, - long minVersion = 0, - DateTimeOffset? minTimestamp = null) - => await ExecuteAndWaitForEventWithVersionAsync(entityId, [eventType], action, timeout, minVersion, - minTimestamp); - - public record EventResult(bool Success, long Version); - - public static async Task ExecuteAndWaitForEventAsync( - Guid entityId, - string[] eventTypes, - Func action, - TimeSpan timeout, - long minVersion = 0, - DateTimeOffset? minTimestamp = null) - => (await ExecuteAndWaitForEventWithVersionAsync(entityId, eventTypes, action, timeout, minVersion, - minTimestamp)) - .Success; - - public static async Task ExecuteAndWaitForEventWithVersionAsync( - Guid entityId, - string[] eventTypes, - Func action, - TimeSpan timeout, - long minVersion = 0, - DateTimeOffset? minTimestamp = null) - { - var matchAnyId = entityId == Guid.Empty; - var receivedEvents = new List(); - - var app = GlobalHooks.App!; - using var client = app.CreateHttpClient("apiservice"); - client.Timeout = TestConstants.DefaultStreamTimeout; // Prevent Aspire default timeout from killing the stream - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", GlobalHooks.AdminAccessToken); - client.DefaultRequestHeaders.Add("X-Tenant-ID", StorageConstants.DefaultTenantId); - - using var cts = new CancellationTokenSource(timeout); - var tcs = new TaskCompletionSource(); - var connectedTcs = new TaskCompletionSource(); - - // Start listening to SSE stream - var listenTask = Task.Run(async () => - { - try - { - using var response = await client.GetAsync("/api/notifications/stream", - HttpCompletionOption.ResponseHeadersRead, cts.Token); - _ = response.EnsureSuccessStatusCode(); - - _ = connectedTcs.TrySetResult(); - - using var stream = await response.Content.ReadAsStreamAsync(cts.Token); - - await foreach (var item in SseParser.Create(stream).EnumerateAsync(cts.Token)) - { - if (string.IsNullOrEmpty(item.Data)) - { - continue; - } - - var received = $"Type: {item.EventType}, Data: {item.Data}"; - receivedEvents.Add(received); - - if (eventTypes.Contains(item.EventType)) - { - using var doc = JsonDocument.Parse(item.Data); - if (doc.RootElement.TryGetProperty("entityId", out var idProp)) - { - var receivedId = idProp.GetGuid(); - if (matchAnyId || receivedId == entityId) - { - if (minVersion > 0) - { - if (doc.RootElement.TryGetProperty("version", out var versionProp) && - versionProp.ValueKind == JsonValueKind.Number && - versionProp.GetInt64() >= minVersion) - { - // Version match - } - else - { - continue; - } - } - - if (minTimestamp.HasValue) - { - if (doc.RootElement.TryGetProperty("timestamp", out var timestampProp) && - timestampProp.TryGetDateTimeOffset(out var timestamp) && - timestamp >= minTimestamp.Value) - { - // Timestamp match - } - else - { - continue; - } - } - - long version = 0; - if (doc.RootElement.TryGetProperty("version", out var vProp) && - vProp.ValueKind == JsonValueKind.Number) - { - version = vProp.GetInt64(); - } - - _ = tcs.TrySetResult(new EventResult(true, version)); - return; - } - } - } - } - } - catch (OperationCanceledException) - { - _ = tcs.TrySetResult(new EventResult(false, 0)); - } - catch (Exception ex) - { - _ = tcs.TrySetException(ex); - _ = connectedTcs.TrySetResult(); // Ensure we don't block - } - }, cts.Token); - - // Wait for connection to be established - if (await Task.WhenAny(connectedTcs.Task, Task.Delay(timeout)) != connectedTcs.Task) - { - // Proceed anyway? Or fail? proceeding might miss event. - } - - // Execute the action that should trigger the event - try - { - await action(); - } - catch (Exception) - { - throw; - } - - // Wait for either the event or timeout - _ = await Task.WhenAny(tcs.Task, Task.Delay(timeout)); - - var result = tcs.Task.IsCompleted && tcs.Task.Result.Success ? tcs.Task.Result : new EventResult(false, 0); - - if (!result.Success) - { - cts.Cancel(); // Stop listening - } - - try - { - await listenTask; // Ensure cleanup logic runs and we catch any final exceptions - } - catch (Exception) - { - // Valid to ignore here during cleanup - await Task.CompletedTask; - } - - if (result.Success) - { - return result; - } - - return result; - } - - /// - /// Legacy method - waits for an event AFTER it may have already been sent. - /// Prefer ExecuteAndWaitForEventAsync instead. - /// - [Obsolete("Use ExecuteAndWaitForEventAsync to avoid race conditions")] - public static async Task WaitForEventAsync(Guid entityId, string eventType, TimeSpan timeout) - { - var app = GlobalHooks.App!; - using var client = app.CreateHttpClient("apiservice"); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", GlobalHooks.AdminAccessToken); - client.DefaultRequestHeaders.Add("X-Tenant-ID", StorageConstants.DefaultTenantId); - - using var cts = new CancellationTokenSource(timeout); - try - { - using var response = await client.GetAsync("/api/notifications/stream", - HttpCompletionOption.ResponseHeadersRead, cts.Token); - _ = response.EnsureSuccessStatusCode(); - - using var stream = await response.Content.ReadAsStreamAsync(cts.Token); - - await foreach (var item in SseParser.Create(stream).EnumerateAsync(cts.Token)) - { - if (string.IsNullOrEmpty(item.Data)) - { - continue; - } - - if (item.EventType == eventType) - { - using var doc = JsonDocument.Parse(item.Data); - if (doc.RootElement.TryGetProperty("entityId", out var idProp) && idProp.GetGuid() == entityId) - { - return true; - } - } - } - } - catch (OperationCanceledException) - { - return false; - } - - return false; - } - - public static async Task CreateBookAsync(HttpClient httpClient, object createBookRequest) - { - // Try to get Id from the request object if it's one of ours - var entityId = Guid.Empty; - if (createBookRequest is CreateBookRequest req) - { - entityId = req.Id; - } - - var received = await ExecuteAndWaitForEventAsync( - entityId, - [ - "BookCreated", "BookUpdated" - ], // Async projections may report as Update regardless of Insert/Update - async () => - { - var createResponse = await httpClient.PostAsJsonAsync("/api/admin/books", createBookRequest); - if (!createResponse.IsSuccessStatusCode) - { - } - - _ = createResponse.EnsureSuccessStatusCode(); - if (entityId == Guid.Empty) - { - var createdBook = await createResponse.Content.ReadFromJsonAsync(); - entityId = createdBook?.Id ?? Guid.Empty; - } - }, - TestConstants.DefaultEventTimeout); - - if (!received || entityId == Guid.Empty) - { - throw new Exception("Failed to create book or receive BookUpdated event."); - } - - return (await httpClient.GetFromJsonAsync($"/api/books/{entityId}"))!; - } - - public static async Task CreateAuthorAsync(IAuthorsClient client, - CreateAuthorRequest createAuthorRequest) - { - var received = await ExecuteAndWaitForEventAsync( - createAuthorRequest.Id, - ["AuthorCreated", "AuthorUpdated"], - async () => - { - var response = await client.CreateAuthorWithResponseAsync(createAuthorRequest); - if (response.Error != null) - { - throw response.Error; - } - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Failed to receive AuthorCreated event."); - } - - return await client.GetAuthorAsync(createAuthorRequest.Id); - } - - public static async Task UpdateAuthorAsync(IAuthorsClient client, AuthorDto author, - UpdateAuthorRequest updateRequest) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(author.ETag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - author.Id, - "AuthorUpdated", - async () => await client.UpdateAuthorAsync(author.Id, updateRequest, author.ETag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Failed to receive AuthorUpdated event after UpdateAuthor."); - } - - return await client.GetAuthorAsync(author.Id); - } - - public static async Task UpdateAuthorAsync(IAuthorsClient client, AdminAuthorDto author, - UpdateAuthorRequest updateRequest) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(author.ETag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - author.Id, - "AuthorUpdated", - async () => await client.UpdateAuthorAsync(author.Id, updateRequest, author.ETag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Failed to receive AuthorUpdated event after UpdateAuthor."); - } - - return await client.GetAuthorAdminAsync(author.Id); - } - - public static async Task DeleteAuthorAsync(IAuthorsClient client, AuthorDto author) - { - var received = await ExecuteAndWaitForEventAsync( - author.Id, - "AuthorDeleted", - async () => - { - var etag = author.ETag; - if (string.IsNullOrEmpty(etag)) - { - var latestAuthor = await client.GetAuthorAdminAsync(author.Id); - etag = latestAuthor?.ETag; - } - - await client.SoftDeleteAuthorAsync(author.Id, etag); - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Failed to receive AuthorDeleted event after DeleteAuthor."); - } - - return await client.GetAuthorAsync(author.Id); - } - - public static async Task DeleteAuthorAsync(IAuthorsClient client, AdminAuthorDto author) - { - var received = await ExecuteAndWaitForEventAsync( - author.Id, - "AuthorDeleted", - async () => - { - var etag = author.ETag; - if (string.IsNullOrEmpty(etag)) - { - var latestAuthor = await client.GetAuthorAdminAsync(author.Id); - etag = latestAuthor?.ETag; - } - - await client.SoftDeleteAuthorAsync(author.Id, etag); - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Failed to receive AuthorDeleted event after DeleteAuthor."); - } - - return await client.GetAuthorAdminAsync(author.Id); - } - - public static async Task RestoreAuthorAsync(IAuthorsClient client, AuthorDto author) - { - var received = await ExecuteAndWaitForEventAsync( - author.Id, - "AuthorUpdated", - async () => - { - var latestAuthor = await client.GetAuthorAdminAsync(author.Id); - var etag = latestAuthor?.ETag; - - await client.RestoreAuthorAsync(author.Id, etag); - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Failed to receive AuthorUpdated event after RestoreAuthor."); - } - - return await client.GetAuthorAsync(author.Id); - } - - public static async Task RestoreAuthorAsync(IAuthorsClient client, AdminAuthorDto author) - { - var received = await ExecuteAndWaitForEventAsync( - author.Id, - "AuthorUpdated", - async () => - { - var latestAuthor = await client.GetAuthorAdminAsync(author.Id); - var etag = latestAuthor?.ETag; - - await client.RestoreAuthorAsync(author.Id, etag); - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Failed to receive AuthorUpdated event after RestoreAuthor."); - } - - return await client.GetAuthorAdminAsync(author.Id); - } - - public static async Task CreateBookAsync(IBooksClient client, CreateBookRequest createBookRequest) - { - var received = await ExecuteAndWaitForEventAsync( - createBookRequest.Id, - ["BookCreated", "BookUpdated"], - async () => - { - var response = await client.CreateBookWithResponseAsync(createBookRequest); - if (response.Error != null) - { - throw response.Error; - } - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Failed to receive BookCreated event."); - } - - return await client.GetBookAsync(createBookRequest.Id); - } - - public static async Task CreatePublisherAsync(IPublishersClient client, - CreatePublisherRequest request) - { - var received = await ExecuteAndWaitForEventAsync( - request.Id, - ["PublisherCreated", "PublisherUpdated"], - async () => - { - var response = await client.CreatePublisherWithResponseAsync(request); - if (response.Error != null) - { - throw response.Error; - } - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Failed to receive PublisherCreated event."); - } - - var result = await client.GetAllPublishersAsync(new PublisherSearchRequest { Search = request.Name }); - return result!.Items.First(p => p.Id == request.Id); - } - - public static async Task UpdatePublisherAsync(IPublishersClient client, PublisherDto publisher, - UpdatePublisherRequest request) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(publisher.ETag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - publisher.Id, - "PublisherUpdated", - async () => await client.UpdatePublisherAsync(publisher.Id, request, publisher.ETag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Failed to receive PublisherUpdated event."); - } - - return await client.GetPublisherAsync(publisher.Id); - } - - public static async Task DeletePublisherAsync(IPublishersClient client, PublisherDto publisher) - { - var result = await ExecuteAndWaitForEventWithVersionAsync( - publisher.Id, - "PublisherDeleted", - async () => await client.SoftDeletePublisherAsync(publisher.Id, publisher.ETag), - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!result.Success) - { - throw new Exception("Failed to receive PublisherDeleted event."); - } - - try - { - return await client.GetPublisherAsync(publisher.Id); - } - catch (Refit.ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Soft-deleted, hidden from public API. Construct DTO with reconstructed ETag. - return publisher with { ETag = $"\"{result.Version}\"" }; - } - } - - public static async Task RestorePublisherAsync(IPublishersClient client, PublisherDto publisher) - { - var received = await ExecuteAndWaitForEventAsync( - publisher.Id, - "PublisherUpdated", - async () => await client.RestorePublisherAsync(publisher.Id, publisher.ETag), - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception("Failed to receive PublisherUpdated event (Restore)."); - } - - return await client.GetPublisherAsync(publisher.Id); - } - - public static async Task CreateBookAsync(HttpClient httpClient, Guid? publisherId = null, - IEnumerable? authorIds = null, IEnumerable? categoryIds = null) - { - // Ensure dependencies exist - if (publisherId == null) - { - var pClient = await GetAuthenticatedClientAsync(); - var pub = await CreatePublisherAsync(pClient, GenerateFakePublisherRequest()); - publisherId = pub.Id; - } - - if (authorIds == null || !authorIds.Any()) - { - var aClient = await GetAuthenticatedClientAsync(); - var auth = await CreateAuthorAsync(aClient, GenerateFakeAuthorRequest()); - authorIds = [auth.Id]; - } - - if (categoryIds == null || !categoryIds.Any()) - { - var cClient = await GetAuthenticatedClientAsync(); - var cat = await CreateCategoryAsync(cClient, GenerateFakeCategoryRequest()); - categoryIds = [cat.Id]; - } - - var createBookRequest = GenerateFakeBookRequest(publisherId, authorIds, categoryIds); - return await CreateBookAsync(httpClient, createBookRequest); - } - - public static async Task CreateBookAsync(IBooksClient client, Guid? publisherId = null, - IEnumerable? authorIds = null, IEnumerable? categoryIds = null) - { - // Ensure dependencies exist - if (publisherId == null) - { - var pClient = await GetAuthenticatedClientAsync(); - var pub = await CreatePublisherAsync(pClient, GenerateFakePublisherRequest()); - publisherId = pub.Id; - } - - if (authorIds == null || !authorIds.Any()) - { - var aClient = await GetAuthenticatedClientAsync(); - var auth = await CreateAuthorAsync(aClient, GenerateFakeAuthorRequest()); - authorIds = [auth.Id]; - } - - if (categoryIds == null || !categoryIds.Any()) - { - var cClient = await GetAuthenticatedClientAsync(); - var cat = await CreateCategoryAsync(cClient, GenerateFakeCategoryRequest()); - categoryIds = [cat.Id]; - } - - var createBookRequest = GenerateFakeBookRequest(publisherId, authorIds, categoryIds); - return await CreateBookAsync(client, createBookRequest); - } - - public static async Task GetTenantClientAsync(string tenantId, string accessToken) - { - var app = GlobalHooks.App!; - var client = app.CreateHttpClient("apiservice"); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - client.DefaultRequestHeaders.Add("X-Tenant-ID", tenantId); - return await Task.FromResult(client); - } - - public static async Task AddToCartAsync(HttpClient client, Guid bookId, int quantity = 1, - Guid? expectedEntityId = null) - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - "UserUpdated", - async () => - { - var response = - await client.PostAsJsonAsync("/api/cart/items", new AddToCartClientRequest(bookId, quantity)); - _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout); - - if (!received) - { - throw new Exception("Timed out waiting for UserUpdated event after AddToCart."); - } - } - - public static async Task AddToCartAsync(IShoppingCartClient client, Guid bookId, int quantity = 1, - Guid? expectedEntityId = null) - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - "UserUpdated", - async () => await client.AddToCartAsync(new AddToCartClientRequest(bookId, quantity)), - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception("Timed out waiting for UserUpdated event after AddToCart."); - } - } - - public static async Task UpdateCartItemQuantityAsync(HttpClient client, Guid bookId, int quantity, - Guid? expectedEntityId = null) - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - "UserUpdated", - async () => - { - var response = await client.PutAsJsonAsync($"/api/cart/items/{bookId}", - new UpdateCartItemClientRequest(quantity)); - _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception("Timed out waiting for UserUpdated event after UpdateCartItemQuantity."); - } - } - - public static async Task UpdateCartItemQuantityAsync(IShoppingCartClient client, Guid bookId, int quantity, - Guid? expectedEntityId = null) - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - "UserUpdated", - async () => await client.UpdateCartItemAsync(bookId, new UpdateCartItemClientRequest(quantity)), - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception("Timed out waiting for UserUpdated event after UpdateCartItemQuantity."); - } - } - - public static async Task RemoveFromCartAsync(HttpClient client, Guid bookId, Guid? expectedEntityId = null) - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - "UserUpdated", - async () => - { - var response = await client.DeleteAsync($"/api/cart/items/{bookId}"); - _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception("Timed out waiting for UserUpdated event after RemoveFromCart."); - } - } - - public static async Task RemoveFromCartAsync(IShoppingCartClient client, Guid bookId, Guid? expectedEntityId = null) - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - "UserUpdated", - async () => await client.RemoveFromCartAsync(bookId), - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception("Timed out waiting for UserUpdated event after RemoveFromCart."); - } - } - - public static async Task ClearCartAsync(HttpClient client, Guid? expectedEntityId = null) - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - "UserUpdated", - async () => - { - var response = await client.DeleteAsync("/api/cart"); - _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception("Timed out waiting for UserUpdated event after ClearCart."); - } - } - - public static async Task ClearCartAsync(IShoppingCartClient client, Guid? expectedEntityId = null) - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - "UserUpdated", - async () => await client.ClearCartAsync(), - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception("Timed out waiting for UserUpdated event after ClearCart."); - } - } - - public static async Task EnsureCartIsEmptyAsync(HttpClient client) - { - var cart = await client.GetFromJsonAsync("/api/cart"); - if (cart != null && cart.TotalItems > 0) - { - await ClearCartAsync(client); - } - } - - public static async Task RateBookAsync(HttpClient client, Guid bookId, int rating, Guid? expectedEntityId = null, - string expectedEvent = "UserUpdated") - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - expectedEvent, - async () => - { - var response = await client.PostAsJsonAsync($"/api/books/{bookId}/rating", new { Rating = rating }); - _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception($"Timed out waiting for {expectedEvent} event after RateBook."); - } - } - - public static async Task RateBookAsync(IBooksClient client, Guid bookId, int rating, Guid expectedEntityId, - string expectedEvent) => await ExecuteAndWaitForEventAsync( - expectedEntityId, - expectedEvent, - () => client.RateBookAsync(bookId, new RateBookRequest(rating)), - TimeSpan.FromSeconds(10), // Increased timeout - minTimestamp: DateTimeOffset.UtcNow); // Use current time to avoid stale events - - public static async Task RemoveRatingAsync(HttpClient client, Guid bookId, Guid? expectedEntityId = null, - string expectedEvent = "UserUpdated") - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - expectedEvent, - async () => - { - var response = await client.DeleteAsync($"/api/books/{bookId}/rating"); - _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception($"Timed out waiting for {expectedEvent} event after RemoveRating."); - } - } - - public static async Task RemoveRatingAsync(IBooksClient client, Guid bookId, Guid expectedEntityId, - string expectedEvent) => await ExecuteAndWaitForEventAsync( - expectedEntityId, - expectedEvent, - () => client.RemoveBookRatingAsync(bookId), - TimeSpan.FromSeconds(10), - minTimestamp: DateTimeOffset.UtcNow); - - public static async Task AddToFavoritesAsync(HttpClient client, Guid bookId, Guid? expectedEntityId = null, - string expectedEvent = "UserUpdated") - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - expectedEvent, - async () => - { - var response = await client.PostAsync($"/api/books/{bookId}/favorites", null); - _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception($"Timed out waiting for {expectedEvent} event after AddToFavorites."); - } - } - - public static async Task AddToFavoritesAsync(IBooksClient client, Guid bookId, Guid? expectedEntityId = null, - string expectedEvent = "UserUpdated") - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - expectedEvent, - async () => await client.AddBookToFavoritesAsync(bookId, - null), - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception($"Timed out waiting for {expectedEvent} event after AddToFavorites."); - } - } - - public static async Task RemoveFromFavoritesAsync(HttpClient client, Guid bookId, Guid? expectedEntityId = null, - string expectedEvent = "UserUpdated") - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - expectedEvent, - async () => - { - var response = await client.DeleteAsync($"/api/books/{bookId}/favorites"); - _ = await Assert.That(response.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception($"Timed out waiting for {expectedEvent} event after RemoveFromFavorites."); - } - } - - public static async Task RemoveFromFavoritesAsync(IBooksClient client, Guid bookId, Guid? expectedEntityId = null, - string expectedEvent = "UserUpdated") - { - var received = await ExecuteAndWaitForEventAsync( - expectedEntityId ?? Guid.Empty, - expectedEvent, - async () => - { - var book = await client.GetBookAsync(bookId); - await client.RemoveBookFromFavoritesAsync(bookId, book?.ETag); - }, - TestConstants.DefaultEventTimeout, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received) - { - throw new Exception($"Timed out waiting for {expectedEvent} event after RemoveFromFavorites."); - } - } - - public static async Task UpdateBookAsync(HttpClient client, Guid bookId, object updatePayload, string etag) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - bookId, - "BookUpdated", - async () => - { - var updateRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/admin/books/{bookId}") - { - Content = JsonContent.Create(updatePayload) - }; - updateRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(etag)); - - var updateResponse = await client.SendAsync(updateRequest); - if (!updateResponse.IsSuccessStatusCode) - { - } - - _ = await Assert.That(updateResponse.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Timed out waiting for BookUpdated event after UpdateBook."); - } - } - - public static async Task UpdateBookAsync(IBooksClient client, Guid bookId, UpdateBookRequest updatePayload, - string etag) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - bookId, - "BookUpdated", - async () => await client.UpdateBookAsync(bookId, updatePayload, etag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Timed out waiting for BookUpdated event after UpdateBook."); - } - - return await client.GetBookAsync(bookId); - } - - public static async Task UpdateBookAsync(IBooksClient client, AdminBookDto book, - UpdateBookRequest request) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(book.ETag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - book.Id, - "BookUpdated", - async () => await client.UpdateBookAsync(book.Id, request, book.ETag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Failed to receive BookUpdated event."); - } - - return await client.GetBookAdminAsync(book.Id); - } - - // Helper to accept generic object and cast if possible or use fake request - public static async Task UpdateBookAsync(IBooksClient client, Guid bookId, object updatePayload, - string etag) - { - var json = JsonSerializer.Serialize(updatePayload); - var request = JsonSerializer.Deserialize(json); - return await UpdateBookAsync(client, bookId, request!, etag); - } - - public static async Task DeleteBookAsync(HttpClient client, Guid bookId, string etag) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - bookId, - "BookDeleted", - async () => - { - var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/books/{bookId}"); - deleteRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(etag)); - - var deleteResponse = await client.SendAsync(deleteRequest); - if (!deleteResponse.IsSuccessStatusCode) - { - } - - _ = await Assert.That(deleteResponse.IsSuccessStatusCode).IsTrue(); - }, - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Timed out waiting for BookDeleted event after DeleteBook."); - } - } - - public static async Task DeleteBookAsync(IBooksClient client, Guid bookId, string etag) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - bookId, - ["BookDeleted", "BookSoftDeleted"], - async () => await client.SoftDeleteBookAsync(bookId, etag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Timed out waiting for BookSoftDeleted event after DeleteBook."); - } - - return await client.GetBookAdminAsync(bookId); - } - - public static async Task DeleteBookAsync(IBooksClient client, BookDto book) - { - var etag = book.ETag; - if (string.IsNullOrEmpty(etag)) - { - var latest = await client.GetBookAsync(book.Id); - etag = latest.ETag; - } - - return await DeleteBookAsync(client, book.Id, etag!); - } - - public static async Task RestoreBookAsync(HttpClient client, Guid bookId, string etag) - { - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - bookId, - ["BookUpdated", "BookRestored"], - async () => - { - var restoreResponse = await client.PostAsync($"/api/admin/books/{bookId}/restore", null); - if (!restoreResponse.IsSuccessStatusCode) - { - } - - _ = await Assert.That(restoreResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent); - }, - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Timed out waiting for BookUpdated event after RestoreBook."); - } - } - - public static async Task RestoreBookAsync(IBooksClient client, Guid bookId, string? etag = null) - { - var currentETag = etag; - if (string.IsNullOrEmpty(currentETag)) - { - // Use Admin endpoint to get the book, including soft-deleted ones, to get the ETag - var book = await client.GetBookAdminAsync(bookId); - currentETag = book?.ETag; - Console.WriteLine($"[TestHelpers] Fetched ETag for restore: {currentETag}"); - } - - var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(currentETag) ?? 0; - var received = await ExecuteAndWaitForEventWithVersionAsync( - bookId, - ["BookUpdated", "BookRestored"], - async () => await client.RestoreBookAsync(bookId, apiVersion: "1.0", etag: currentETag), - TestConstants.DefaultEventTimeout, - minVersion: version + 1, - minTimestamp: DateTimeOffset.UtcNow); - - if (!received.Success) - { - throw new Exception("Timed out waiting for BookUpdated event after RestoreBook."); - } - - return await client.GetBookAsync(bookId); - } - - public static async Task WaitForConditionAsync(Func> condition, TimeSpan timeout, string failureMessage) - { - using var cts = new CancellationTokenSource(timeout); - try - { - while (!cts.IsCancellationRequested) - { - if (await condition()) - { - return; - } - - await Task.Delay(TestConstants.DefaultPollingInterval, cts.Token); - } - } - catch (OperationCanceledException) - { - // Fall through to failure - } - - throw new Exception($"Timeout waiting for condition: {failureMessage}"); - } - - public static async Task SeedTenantAsync(Marten.IDocumentStore store, string tenantId) - { - // 1. Ensure Tenant document exists in Marten's native default bucket (for validation) - await using (var tenantSession = store.LightweightSession()) - { - var existingTenant = await tenantSession.LoadAsync(tenantId); - if (existingTenant == null) - { - tenantSession.Store(new BookStore.ApiService.Models.Tenant - { - Id = tenantId, - Name = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) - ? "BookStore" - : (char.ToUpper(tenantId[0]) + tenantId[1..] + " Corp"), - IsEnabled = true, - CreatedAt = DateTimeOffset.UtcNow - }); - await tenantSession.SaveChangesAsync(); - } - } - - // 2. Seed Admin User in the tenant's own bucket - await using var session = store.LightweightSession(tenantId); - - var adminEmail = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) - ? "admin@bookstore.com" - : $"admin@{tenantId}.com"; - - // We still use manual store here as TestHelpers might be used in light setup contexts - // but we fix the normalization mismatch - var existingUser = await session.Query() - .Where(u => u.Email == adminEmail) - .FirstOrDefaultAsync(); - - if (existingUser == null) - { - var adminUser = new BookStore.ApiService.Models.ApplicationUser - { - UserName = adminEmail, - NormalizedUserName = adminEmail.ToUpperInvariant(), - Email = adminEmail, - NormalizedEmail = adminEmail.ToUpperInvariant(), - EmailConfirmed = true, - Roles = ["Admin"], - SecurityStamp = Guid.CreateVersion7().ToString("D"), - ConcurrencyStamp = Guid.CreateVersion7().ToString("D") - }; - - var hasher = - new Microsoft.AspNetCore.Identity.PasswordHasher(); - adminUser.PasswordHash = hasher.HashPassword(adminUser, "Admin123!"); - - session.Store(adminUser); - await session.SaveChangesAsync(); - } - } - - public static async Task LoginAsAdminAsync(string tenantId) - { - var app = GlobalHooks.App!; - using var client = app.CreateHttpClient("apiservice"); - return await LoginAsAdminAsync(client, tenantId); - } - - public static async Task LoginAsAdminAsync(HttpClient client, string tenantId) - { - var email = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) - ? "admin@bookstore.com" - : $"admin@{tenantId}.com"; - - var credentials = new { email, password = "Admin123!" }; - - // Simple retry logic - for (var i = 0; i < 3; i++) - { - var request = new HttpRequestMessage(HttpMethod.Post, "/account/login") - { - Content = JsonContent.Create(credentials) - }; - request.Headers.Add("X-Tenant-ID", tenantId); - - var response = await client.SendAsync(request); - - if (response.IsSuccessStatusCode) - { - return await response.Content.ReadFromJsonAsync(); - } - - if (i == 2) // Last attempt - { - return null; - } - - await Task.Delay(TestConstants.DefaultPollingInterval); // Wait before retry - } - - return null; - } - - public static async Task CreateUserAndGetClientAsync(string? tenantId = null) - { - var app = GlobalHooks.App!; - var publicClient = app.CreateHttpClient("apiservice"); - var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; - publicClient.DefaultRequestHeaders.Add("X-Tenant-ID", actualTenantId); - - var email = $"user_{Guid.NewGuid()}@example.com"; - var password = "Password123!"; - - // Register - var registerRequest = new { email, password }; - var registerResponse = await publicClient.PostAsJsonAsync("/account/register", registerRequest); - if (!registerResponse.IsSuccessStatusCode) - { - } - - _ = registerResponse.EnsureSuccessStatusCode(); - - // Login - var loginRequest = new { email, password }; - var loginResponse = await publicClient.PostAsJsonAsync("/account/login", loginRequest); - if (!loginResponse.IsSuccessStatusCode) - { - } - - _ = loginResponse.EnsureSuccessStatusCode(); - - var tokenResponse = await loginResponse.Content.ReadFromJsonAsync(); - - // Decode JWT to verify claims - var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); - _ = handler.ReadJwtToken(tokenResponse!.AccessToken); - - var userId = Guid.Parse(handler.ReadJwtToken(tokenResponse!.AccessToken).Claims.First(c => c.Type == "sub") - .Value); - - // Create authenticated client - var authenticatedClient = app.CreateHttpClient("apiservice"); - authenticatedClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); - authenticatedClient.DefaultRequestHeaders.Add("X-Tenant-ID", actualTenantId); - - return new UserClient(authenticatedClient, userId); - } - - public record UserClient(HttpClient Client, Guid UserId); - - public static async Task CreateUserAndGetClientAsync(string? tenantId = null) - { - var userClient = await CreateUserAndGetClientAsync(tenantId); - return RestService.For(userClient.Client); - } - - public record LoginResponse(string AccessToken, string RefreshToken); - - public record ErrorResponse( - [property: System.Text.Json.Serialization.JsonPropertyName("error")] - string Error, - string Message); - - public record MessageResponse(string Message); - - public record ValidationProblemDetails( - string? Title = null, - int? Status = null, - string? Detail = null, - [property: System.Text.Json.Serialization.JsonPropertyName("error")] - string? Error = null); -} diff --git a/tests/BookStore.AppHost.Tests/UnverifiedAccountCleanupTests.cs b/tests/BookStore.AppHost.Tests/UnverifiedAccountCleanupTests.cs index 0484307..6d9363b 100644 --- a/tests/BookStore.AppHost.Tests/UnverifiedAccountCleanupTests.cs +++ b/tests/BookStore.AppHost.Tests/UnverifiedAccountCleanupTests.cs @@ -1,6 +1,7 @@ using BookStore.ApiService.Handlers.Maintenance; using BookStore.ApiService.Infrastructure.Identity; using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; using JasperFx.Core; using Marten; using Microsoft.Extensions.Logging.Abstractions; diff --git a/tests/BookStore.AppHost.Tests/UpdateTests.cs b/tests/BookStore.AppHost.Tests/UpdateTests.cs index d386140..b63f395 100644 --- a/tests/BookStore.AppHost.Tests/UpdateTests.cs +++ b/tests/BookStore.AppHost.Tests/UpdateTests.cs @@ -1,3 +1,4 @@ +using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; @@ -14,9 +15,9 @@ public class UpdateTests public async Task UpdateAuthor_FullUpdate_ShouldReflectChanges() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeAuthorRequest(); - var author = await TestHelpers.CreateAuthorAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeAuthorRequest(); + var author = await AuthorHelpers.CreateAuthorAsync(client, createRequest); var updateRequest = new UpdateAuthorRequest { @@ -29,14 +30,14 @@ public async Task UpdateAuthor_FullUpdate_ShouldReflectChanges() }; // Act - author = await TestHelpers.UpdateAuthorAsync(client, author, updateRequest); + author = await AuthorHelpers.UpdateAuthorAsync(client, author, updateRequest); // Assert var updatedAuthorAdmin = await client.GetAuthorAsync(author.Id); _ = await Assert.That(updatedAuthorAdmin.Name).IsEqualTo(updateRequest.Name); // Verify translations via public API - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); var enAuthor = await publicClient.GetAuthorAsync(author.Id, "en"); var ptAuthor = await publicClient.GetAuthorAsync(author.Id, "pt-PT"); @@ -49,9 +50,9 @@ public async Task UpdateAuthor_FullUpdate_ShouldReflectChanges() public async Task UpdateCategory_FullUpdate_ShouldReflectChanges() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakeCategoryRequest(); - var category = await TestHelpers.CreateCategoryAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeCategoryRequest(); + var category = await CategoryHelpers.CreateCategoryAsync(client, createRequest); var updateRequest = new UpdateCategoryRequest { @@ -63,10 +64,10 @@ public async Task UpdateCategory_FullUpdate_ShouldReflectChanges() }; // Act - category = await TestHelpers.UpdateCategoryAsync(client, category, updateRequest); + category = await CategoryHelpers.UpdateCategoryAsync(client, category, updateRequest); // Assert - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); var enCat = await publicClient.GetCategoryAsync(category.Id, null, "en"); var esCat = await publicClient.GetCategoryAsync(category.Id, null, "es"); @@ -79,17 +80,17 @@ public async Task UpdateCategory_FullUpdate_ShouldReflectChanges() public async Task UpdatePublisher_FullUpdate_ShouldReflectChanges() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var createRequest = TestHelpers.GenerateFakePublisherRequest(); - var publisher = await TestHelpers.CreatePublisherAsync(client, createRequest); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakePublisherRequest(); + var publisher = await PublisherHelpers.CreatePublisherAsync(client, createRequest); var updateRequest = new UpdatePublisherRequest { Name = "Updated Publisher Name" }; // Act - publisher = await TestHelpers.UpdatePublisherAsync(client, publisher, updateRequest); + publisher = await PublisherHelpers.UpdatePublisherAsync(client, publisher, updateRequest); // Assert - var publicClient = RestService.For(TestHelpers.GetUnauthenticatedClient()); + var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); var updatedPub = await publicClient.GetPublisherAsync(publisher.Id); _ = await Assert.That(updatedPub.Name).IsEqualTo(updateRequest.Name); @@ -99,21 +100,21 @@ public async Task UpdatePublisher_FullUpdate_ShouldReflectChanges() public async Task UpdateBook_FullUpdate_ShouldReflectChanges() { // Arrange - var client = await TestHelpers.GetAuthenticatedClientAsync(); - var authorsClient = await TestHelpers.GetAuthenticatedClientAsync(); - var categoriesClient = await TestHelpers.GetAuthenticatedClientAsync(); - var publishersClient = await TestHelpers.GetAuthenticatedClientAsync(); + var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var authorsClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var categoriesClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var publishersClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var originalAuthor = - await TestHelpers.CreateAuthorAsync(authorsClient, TestHelpers.GenerateFakeAuthorRequest()); + await AuthorHelpers.CreateAuthorAsync(authorsClient, FakeDataGenerators.GenerateFakeAuthorRequest()); var originalCategory = - await TestHelpers.CreateCategoryAsync(categoriesClient, TestHelpers.GenerateFakeCategoryRequest()); + await CategoryHelpers.CreateCategoryAsync(categoriesClient, FakeDataGenerators.GenerateFakeCategoryRequest()); var originalPublisher = - await TestHelpers.CreatePublisherAsync(publishersClient, TestHelpers.GenerateFakePublisherRequest()); + await PublisherHelpers.CreatePublisherAsync(publishersClient, FakeDataGenerators.GenerateFakePublisherRequest()); var createRequest = - TestHelpers.GenerateFakeBookRequest(originalPublisher.Id, [originalAuthor.Id], [originalCategory.Id]); - var book = await TestHelpers.CreateBookAsync(client, createRequest); + FakeDataGenerators.GenerateFakeBookRequest(originalPublisher.Id, [originalAuthor.Id], [originalCategory.Id]); + var book = await BookHelpers.CreateBookAsync(client, createRequest); // Retrieve ETag var getResponse = await client.GetBookWithResponseAsync(book.Id); @@ -121,11 +122,11 @@ public async Task UpdateBook_FullUpdate_ShouldReflectChanges() _ = await Assert.That(etag).IsNotNull(); // New dependencies - var newAuthor = await TestHelpers.CreateAuthorAsync(authorsClient, TestHelpers.GenerateFakeAuthorRequest()); + var newAuthor = await AuthorHelpers.CreateAuthorAsync(authorsClient, FakeDataGenerators.GenerateFakeAuthorRequest()); var newCategory = - await TestHelpers.CreateCategoryAsync(categoriesClient, TestHelpers.GenerateFakeCategoryRequest()); + await CategoryHelpers.CreateCategoryAsync(categoriesClient, FakeDataGenerators.GenerateFakeCategoryRequest()); var newPublisher = - await TestHelpers.CreatePublisherAsync(publishersClient, TestHelpers.GenerateFakePublisherRequest()); + await PublisherHelpers.CreatePublisherAsync(publishersClient, FakeDataGenerators.GenerateFakePublisherRequest()); var updateRequest = new UpdateBookRequest { @@ -144,11 +145,11 @@ public async Task UpdateBook_FullUpdate_ShouldReflectChanges() } }; - book = await TestHelpers.UpdateBookAsync(client, book.Id, updateRequest, etag!); + book = await BookHelpers.UpdateBookAsync(client, book.Id, updateRequest, etag!); // Assert - var enClient = TestHelpers.GetUnauthenticatedClientWithLanguage("en"); - var ptClient = TestHelpers.GetUnauthenticatedClientWithLanguage("pt-PT"); + var enClient = HttpClientHelpers.GetUnauthenticatedClientWithLanguage("en"); + var ptClient = HttpClientHelpers.GetUnauthenticatedClientWithLanguage("pt-PT"); var enBook = await enClient.GetBookAsync(book.Id); var ptBook = await ptClient.GetBookAsync(book.Id); diff --git a/tests/BookStore.AppHost.Tests/WebTests.cs b/tests/BookStore.AppHost.Tests/WebTests.cs index f17bc4e..9994f8a 100644 --- a/tests/BookStore.AppHost.Tests/WebTests.cs +++ b/tests/BookStore.AppHost.Tests/WebTests.cs @@ -1,6 +1,7 @@ using System.Net; using Aspire.Hosting; using Aspire.Hosting.Testing; +using BookStore.AppHost.Tests.Helpers; using Projects; namespace BookStore.AppHost.Tests;