using Books.Api.EventFlow.Repositories; using Books.Api.Tests.Helpers; using Books.Api.Tests.Infrastructure; using AwesomeAssertions; using Microsoft.Extensions.DependencyInjection; namespace Books.Api.Tests.GraphQL; /// /// Integration tests for Account GraphQL operations. /// Each test class runs with its own isolated database. /// [Trait("Category", "Integration")] public class AccountGraphQLTests(TestWebApplicationFactory factory) : IntegrationTestBase(factory) { [Fact] public async Task Query_Accounts_FailsWithoutCompanyHeader() { // Arrange var graphqlClient = new GraphQLTestClient(Client); // Note: We're NOT setting X-Company-Id header // Act - query accounts without X-Company-Id header var response = await graphqlClient.QueryAsync(""" query { accounts(companyId: "nonexistent") { id name } } """); // Assert - Should fail with authorization error response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); errorInfo.Should().Contain("No company selected"); } [Fact] public async Task Mutation_CreateAccount_CreatesAccountSuccessfully() { // Arrange var graphqlClient = new GraphQLTestClient(Client); // First create a company (which doesn't auto-create accounts in this test) var companyId = await CreateCompanyAsync(graphqlClient, "Account Test Company"); // Act - Create an account var response = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id companyId accountNumber name accountType isActive isSystemAccount } } """, new { input = new { companyId, accountNumber = "9999", name = "Test Konto", accountType = "EXPENSE", description = "En testkonto" } }); // Assert response.EnsureNoErrors(); response.Data.Should().NotBeNull(); response.Data!.CreateAccount.Should().NotBeNull(); response.Data.CreateAccount!.AccountNumber.Should().Be("9999"); response.Data.CreateAccount.Name.Should().Be("Test Konto"); response.Data.CreateAccount.AccountType.Should().Be("expense"); response.Data.CreateAccount.IsActive.Should().BeTrue(); response.Data.CreateAccount.IsSystemAccount.Should().BeFalse(); } [Fact] public async Task Mutation_UpdateAccount_UpdatesAccountSuccessfully() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Update Account Test"); // Create an account var createResponse = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id name } } """, new { input = new { companyId, accountNumber = "8888", name = "Original Name", accountType = "EXPENSE" } }); createResponse.EnsureNoErrors(); var accountId = createResponse.Data!.CreateAccount!.Id; // Wait for eventual consistency await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(accountId); }); // Act - Update the account var updateResponse = await graphqlClient.MutateAsync(""" mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) { updateAccount(id: $id, input: $input) { id name description } } """, new { id = accountId, input = new { name = "Updated Name", description = "Updated description" } }); // Assert updateResponse.EnsureNoErrors(); var updatedAccount = await Eventually.GetAsync(async () => { var repo = GetService(); var account = await repo.GetByIdAsync(accountId); return account?.Name == "Updated Name" ? account : null; }); updatedAccount.Should().NotBeNull(); updatedAccount!.Name.Should().Be("Updated Name"); updatedAccount.Description.Should().Be("Updated description"); } [Fact] public async Task Mutation_DeactivateAccount_DeactivatesAccountSuccessfully() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Deactivate Account Test"); var createResponse = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id isActive } } """, new { input = new { companyId, accountNumber = "7777", name = "Deactivate Test", accountType = "EXPENSE" } }); createResponse.EnsureNoErrors(); var accountId = createResponse.Data!.CreateAccount!.Id; await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(accountId); }); // Act var deactivateResponse = await graphqlClient.MutateAsync(""" mutation DeactivateAccount($id: ID!) { deactivateAccount(id: $id) { id isActive } } """, new { id = accountId }); // Assert deactivateResponse.EnsureNoErrors(); var deactivatedAccount = await Eventually.GetAsync(async () => { var repo = GetService(); var account = await repo.GetByIdAsync(accountId); return account?.IsActive == false ? account : null; }); deactivatedAccount.Should().NotBeNull(); deactivatedAccount!.IsActive.Should().BeFalse(); } [Fact] public async Task Mutation_ReactivateAccount_ReactivatesAccountSuccessfully() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Reactivate Account Test"); var createResponse = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id } } """, new { input = new { companyId, accountNumber = "6666", name = "Reactivate Test", accountType = "EXPENSE" } }); createResponse.EnsureNoErrors(); var accountId = createResponse.Data!.CreateAccount!.Id; await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(accountId); }); // Deactivate first await graphqlClient.MutateAsync(""" mutation { deactivateAccount(id: $id) { id } } """.Replace("$id", $"\"{accountId}\"")); await Eventually.GetAsync(async () => { var repo = GetService(); var account = await repo.GetByIdAsync(accountId); return account?.IsActive == false ? account : null; }); // Act var reactivateResponse = await graphqlClient.MutateAsync(""" mutation ReactivateAccount($id: ID!) { reactivateAccount(id: $id) { id isActive } } """, new { id = accountId }); // Assert reactivateResponse.EnsureNoErrors(); var reactivatedAccount = await Eventually.GetAsync(async () => { var repo = GetService(); var account = await repo.GetByIdAsync(accountId); return account?.IsActive == true ? account : null; }); reactivatedAccount.Should().NotBeNull(); reactivatedAccount!.IsActive.Should().BeTrue(); } [Fact] public async Task Query_ActiveAccounts_ExcludesDeactivatedAccounts() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Active Accounts Test"); // Wait for standard accounts to be created var initialAccounts = await Eventually.GetListAsync(async () => { var repo = GetService(); return await repo.GetByCompanyIdAsync(companyId); }, 50, timeout: TimeSpan.FromSeconds(30)); // At least 50 standard accounts var initialActiveCount = initialAccounts.Count(a => a.IsActive); // Create a custom account var customAccountId = await CreateAccountAsync(graphqlClient, companyId, "5553", "Custom To Deactivate"); await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(customAccountId); }); // Deactivate the custom account await graphqlClient.MutateAsync( $"mutation {{ deactivateAccount(id: \"{customAccountId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var account = await repo.GetByIdAsync(customAccountId); return account?.IsActive == false ? account : null; }); // Act var response = await graphqlClient.QueryAsync(""" query ActiveAccounts($companyId: ID!) { activeAccounts(companyId: $companyId) { id name isActive } } """, new { companyId }); // Assert response.EnsureNoErrors(); // Should have initial active accounts (deactivated one is excluded) response.Data!.ActiveAccounts.Should().HaveCount(initialActiveCount); response.Data.ActiveAccounts.Should().AllSatisfy(a => a.IsActive.Should().BeTrue()); response.Data.ActiveAccounts.Should().NotContain(a => a.Id == customAccountId); } [Fact] public async Task Mutation_CreateAccount_FailsWithInvalidAccountNumber() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Invalid Number Test"); // Act - Try to create account with invalid number (too short) var response = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id } } """, new { input = new { companyId, accountNumber = "12", // Invalid - must be 4-10 digits name = "Invalid Account", accountType = "EXPENSE" } }); // Assert - Domain validation error is wrapped by GraphQL response.HasErrors.Should().BeTrue(); // Check that the error is related to account validation (could be in message or extensions) var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; var hasAccountError = response.Errors!.Any(e => e.Message.Contains("INVALID_ACCOUNT_NUMBER") || e.Message.Contains("createAccount") || errorDetails.Contains("INVALID_ACCOUNT_NUMBER") || errorDetails.Contains("4-10 digits")); hasAccountError.Should().BeTrue("Expected an error related to invalid account number"); } [Fact] public async Task Mutation_UpdateAccount_FailsForSystemAccount() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "System Account Update Test"); // Wait for chart of accounts to be initialized var systemAccount = await Eventually.GetAsync(async () => { var repo = GetService(); var accounts = await repo.GetByCompanyIdAsync(companyId); return accounts.FirstOrDefault(a => a.IsSystemAccount); }, timeout: TimeSpan.FromSeconds(30)); systemAccount.Should().NotBeNull("Expected at least one system account from chart initialization"); // Act - Try to update a system account var response = await graphqlClient.MutateAsync(""" mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) { updateAccount(id: $id, input: $input) { id name } } """, new { id = systemAccount!.Id, input = new { name = "Attempting to rename system account" } }); // Assert response.HasErrors.Should().BeTrue(); var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; (response.Errors!.Any(e => e.Message.Contains("SYSTEM_ACCOUNT_READONLY")) || errorDetails.Contains("SYSTEM_ACCOUNT_READONLY") || errorDetails.Contains("System accounts cannot be modified")).Should().BeTrue(); } [Fact] public async Task Mutation_DeactivateAccount_FailsForSystemAccount() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "System Account Deactivate Test"); // Wait for chart of accounts to be initialized var systemAccount = await Eventually.GetAsync(async () => { var repo = GetService(); var accounts = await repo.GetByCompanyIdAsync(companyId); return accounts.FirstOrDefault(a => a.IsSystemAccount); }, timeout: TimeSpan.FromSeconds(30)); systemAccount.Should().NotBeNull("Expected at least one system account from chart initialization"); // Act - Try to deactivate a system account var response = await graphqlClient.MutateAsync(""" mutation DeactivateAccount($id: ID!) { deactivateAccount(id: $id) { id isActive } } """, new { id = systemAccount!.Id }); // Assert response.HasErrors.Should().BeTrue(); var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; (response.Errors!.Any(e => e.Message.Contains("SYSTEM_ACCOUNT_READONLY")) || errorDetails.Contains("SYSTEM_ACCOUNT_READONLY") || errorDetails.Contains("System accounts cannot be deactivated")).Should().BeTrue(); } [Fact] public async Task Mutation_DeactivateAccount_FailsWhenAlreadyInactive() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Double Deactivate Test"); var accountId = await CreateAccountAsync(graphqlClient, companyId, "4444", "Double Deactivate"); await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(accountId); }); // First deactivation - should succeed await graphqlClient.MutateAsync(""" mutation DeactivateAccount($id: ID!) { deactivateAccount(id: $id) { id } } """, new { id = accountId }); await Eventually.GetAsync(async () => { var repo = GetService(); var account = await repo.GetByIdAsync(accountId); return account?.IsActive == false ? account : null; }); // Act - Try to deactivate again var response = await graphqlClient.MutateAsync(""" mutation DeactivateAccount($id: ID!) { deactivateAccount(id: $id) { id isActive } } """, new { id = accountId }); // Assert response.HasErrors.Should().BeTrue(); var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; (response.Errors!.Any(e => e.Message.Contains("ACCOUNT_ALREADY_INACTIVE")) || errorDetails.Contains("ACCOUNT_ALREADY_INACTIVE") || errorDetails.Contains("already inactive")).Should().BeTrue(); } [Fact] public async Task Mutation_ReactivateAccount_FailsWhenAlreadyActive() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Reactivate Active Test"); var accountId = await CreateAccountAsync(graphqlClient, companyId, "3333", "Already Active"); await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(accountId); }); // Act - Try to reactivate an already active account var response = await graphqlClient.MutateAsync(""" mutation ReactivateAccount($id: ID!) { reactivateAccount(id: $id) { id isActive } } """, new { id = accountId }); // Assert response.HasErrors.Should().BeTrue(); var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; (response.Errors!.Any(e => e.Message.Contains("ACCOUNT_ALREADY_ACTIVE")) || errorDetails.Contains("ACCOUNT_ALREADY_ACTIVE") || errorDetails.Contains("already active")).Should().BeTrue(); } [Fact] public async Task Mutation_CreateAccount_AcceptsMinimumDigits() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Min Digits Test"); // Act - Create account with exactly 4 digits (minimum allowed) // Use a number not in the standard chart of accounts var response = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id accountNumber } } """, new { input = new { companyId, accountNumber = "9990", // Exactly 4 digits, not in standard chart name = "Minimum Digits Account", accountType = "ASSET" } }); // Assert - Should succeed response.EnsureNoErrors(); response.Data!.CreateAccount!.AccountNumber.Should().Be("9990"); } [Fact] public async Task Mutation_CreateAccount_FailsWithTooFewDigits() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Too Few Digits Test"); // Act - Create account with 3 digits (below minimum) var response = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id } } """, new { input = new { companyId, accountNumber = "999", // Only 3 digits name = "Too Few Digits", accountType = "EXPENSE" } }); // Assert response.HasErrors.Should().BeTrue(); var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; (response.Errors!.Any(e => e.Message.Contains("INVALID_ACCOUNT_NUMBER")) || errorDetails.Contains("INVALID_ACCOUNT_NUMBER") || errorDetails.Contains("4-10 digits")).Should().BeTrue(); } [Fact] public async Task Mutation_CreateAccount_IsDeterministic() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Deterministic ID Test"); // Act - Create same account twice (use number not in standard chart) var response1 = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id accountNumber } } """, new { input = new { companyId, accountNumber = "9991", name = "Deterministic Test", accountType = "EXPENSE" } }); response1.EnsureNoErrors(); var firstId = response1.Data!.CreateAccount!.Id; // Wait for eventual consistency await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(firstId); }); // Second attempt with same input var response2 = await graphqlClient.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id accountNumber } } """, new { input = new { companyId, accountNumber = "9991", name = "Deterministic Test", accountType = "EXPENSE" } }); // Assert - Second call should fail with ACCOUNT_EXISTS error (idempotency) response2.HasErrors.Should().BeTrue(); var errorDetails = response2.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; (response2.Errors!.Any(e => e.Message.Contains("ACCOUNT_EXISTS")) || errorDetails.Contains("ACCOUNT_EXISTS") || errorDetails.Contains("already exists")).Should().BeTrue(); } [Fact] public async Task Mutation_UpdateAccount_FailsForNonExistentAccount() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var nonExistentAccountId = $"account-{Guid.NewGuid():N}"; // Act - Try to update a non-existent account var response = await graphqlClient.MutateAsync(""" mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) { updateAccount(id: $id, input: $input) { id name } } """, new { id = nonExistentAccountId, input = new { name = "New Name" } }); // Assert - Domain exception is wrapped by GraphQL/EventFlow response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); (errorInfo.Contains("ACCOUNT_NOT_FOUND") || errorInfo.Contains("Account does not exist") || errorInfo.Contains("does not exist") || errorInfo.Contains("updateAccount")).Should().BeTrue( $"Expected account not found error, got: {errorInfo}"); } [Fact] public async Task Mutation_DeactivateAccount_FailsForNonExistentAccount() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var nonExistentAccountId = $"account-{Guid.NewGuid():N}"; // Act - Try to deactivate a non-existent account var response = await graphqlClient.MutateAsync(""" mutation DeactivateAccount($id: ID!) { deactivateAccount(id: $id) { id isActive } } """, new { id = nonExistentAccountId }); // Assert - Domain exception is wrapped by GraphQL/EventFlow response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); (errorInfo.Contains("ACCOUNT_NOT_FOUND") || errorInfo.Contains("Account does not exist") || errorInfo.Contains("does not exist") || errorInfo.Contains("deactivateAccount")).Should().BeTrue( $"Expected account not found error, got: {errorInfo}"); } [Fact] public async Task Mutation_ReactivateAccount_FailsForNonExistentAccount() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var nonExistentAccountId = $"account-{Guid.NewGuid():N}"; // Act - Try to reactivate a non-existent account var response = await graphqlClient.MutateAsync(""" mutation ReactivateAccount($id: ID!) { reactivateAccount(id: $id) { id isActive } } """, new { id = nonExistentAccountId }); // Assert - Domain exception is wrapped by GraphQL/EventFlow response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); (errorInfo.Contains("ACCOUNT_NOT_FOUND") || errorInfo.Contains("Account does not exist") || errorInfo.Contains("does not exist") || errorInfo.Contains("reactivateAccount")).Should().BeTrue( $"Expected account not found error, got: {errorInfo}"); } private async Task CreateCompanyAsync(GraphQLTestClient client, string name) { var response = await client.MutateAsync(""" mutation CreateCompany($input: CreateCompanyInput!) { createCompany(input: $input) { id } } """, new { input = new { name } }); response.EnsureNoErrors(); var companyId = response.Data!.CreateCompany!.Id; // Set the company ID header for subsequent requests client.SetCompanyId(companyId); return companyId; } private async Task CreateAccountAsync(GraphQLTestClient client, string companyId, string number, string name) { var response = await client.MutateAsync(""" mutation CreateAccount($input: CreateAccountInput!) { createAccount(input: $input) { id } } """, new { input = new { companyId, accountNumber = number, name, accountType = "EXPENSE" } }); response.EnsureNoErrors(); return response.Data!.CreateAccount!.Id; } // Response DTOs private class AccountsResponse { public List Accounts { get; set; } = []; } private class ActiveAccountsResponse { public List ActiveAccounts { get; set; } = []; } private class AccountResponse { public AccountDto? Account { get; set; } } private class CreateAccountResponse { public AccountDto? CreateAccount { get; set; } } private class UpdateAccountResponse { public AccountDto? UpdateAccount { get; set; } } private class DeactivateAccountResponse { public AccountDto? DeactivateAccount { get; set; } } private class ReactivateAccountResponse { public AccountDto? ReactivateAccount { get; set; } } private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } } private class AccountDto { public string Id { get; set; } = string.Empty; public string CompanyId { get; set; } = string.Empty; public string AccountNumber { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string AccountType { get; set; } = string.Empty; public string? Description { get; set; } public bool IsActive { get; set; } public bool IsSystemAccount { get; set; } } private class CompanyDto { public string Id { get; set; } = string.Empty; } }