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 JournalEntryDraft (Kassekladde) GraphQL operations. /// [Trait("Category", "Integration")] public class JournalEntryDraftGraphQLTests(TestWebApplicationFactory factory) : IntegrationTestBase(factory) { #region Create Draft Tests [Fact] public async Task Mutation_CreateJournalEntryDraft_CreatesSuccessfully() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Draft Test Company"); // Act var response = await graphqlClient.MutateAsync(""" mutation CreateDraft($input: CreateJournalEntryDraftInput!) { createJournalEntryDraft(input: $input) { id companyId name status createdBy } } """, new { input = new { companyId, name = "Januar Udgifter" } }); // Assert response.EnsureNoErrors(); response.Data.Should().NotBeNull(); response.Data!.CreateJournalEntryDraft.Should().NotBeNull(); response.Data.CreateJournalEntryDraft!.Name.Should().Be("Januar Udgifter"); response.Data.CreateJournalEntryDraft.Status.Should().Be("active"); response.Data.CreateJournalEntryDraft.CompanyId.Should().Be(companyId); response.Data.CreateJournalEntryDraft.Id.Should().StartWith("journalentrydraft-"); } [Fact] public async Task Mutation_CreateJournalEntryDraft_FailsWithoutCompanyHeader() { // Arrange var graphqlClient = new GraphQLTestClient(Client); // Note: We're NOT setting X-Company-Id header // Act var response = await graphqlClient.MutateAsync(""" mutation CreateDraft($input: CreateJournalEntryDraftInput!) { createJournalEntryDraft(input: $input) { id } } """, new { input = new { companyId = "some-company-id", name = "Test Draft" } }); // Assert response.HasErrors.Should().BeTrue(); } [Fact] public async Task Mutation_CreateJournalEntryDraft_FailsWithEmptyName() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Empty Name Test Company"); // Act var response = await graphqlClient.MutateAsync(""" mutation CreateDraft($input: CreateJournalEntryDraftInput!) { createJournalEntryDraft(input: $input) { id } } """, new { input = new { companyId, name = " " // Whitespace only } }); // Assert response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); errorInfo.Should().Contain("Draft name is required"); } #endregion #region Update Draft Tests [Fact] public async Task Mutation_UpdateJournalEntryDraft_UpdatesSuccessfully() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Update Draft Test Company"); var draftId = await CreateDraftAsync(graphqlClient, companyId, "Original Name"); // Wait for eventual consistency await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(draftId); }); // Act var response = await graphqlClient.MutateAsync(""" mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { updateJournalEntryDraft(input: $input) { id name documentDate description lines { lineNumber accountId debitAmount creditAmount description } } } """, new { input = new { id = draftId, name = "Updated Name", documentDate = "2025-01-15", description = "Test beskrivelse", lines = new[] { new { lineNumber = 1, accountId = (string?)null, debitAmount = 1000m, creditAmount = 0m, description = "Debet linje" }, new { lineNumber = 2, accountId = (string?)null, debitAmount = 0m, creditAmount = 1000m, description = "Kredit linje" } } } }); // Assert response.EnsureNoErrors(); response.Data!.UpdateJournalEntryDraft!.Name.Should().Be("Updated Name"); response.Data.UpdateJournalEntryDraft.Description.Should().Be("Test beskrivelse"); response.Data.UpdateJournalEntryDraft.Lines.Should().HaveCount(2); response.Data.UpdateJournalEntryDraft.Lines![0].DebitAmount.Should().Be(1000m); response.Data.UpdateJournalEntryDraft.Lines[1].CreditAmount.Should().Be(1000m); } [Fact] public async Task Mutation_UpdateJournalEntryDraft_FailsForNonExistentDraft() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Non-existent Draft Company"); var nonExistentId = $"journalentrydraft-{Guid.NewGuid():D}"; // Act var response = await graphqlClient.MutateAsync(""" mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { updateJournalEntryDraft(input: $input) { id } } """, new { input = new { id = nonExistentId, name = "Test", lines = Array.Empty() } }); // Assert response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); errorInfo.Should().Contain("not found"); } #endregion #region Query Tests [Fact] public async Task Query_JournalEntryDrafts_ReturnsActiveDrafts() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Query Drafts Test Company"); // Create multiple drafts await CreateDraftAsync(graphqlClient, companyId, "Draft 1"); await CreateDraftAsync(graphqlClient, companyId, "Draft 2"); await CreateDraftAsync(graphqlClient, companyId, "Draft 3"); // Wait for eventual consistency var drafts = await Eventually.GetListAsync(async () => { var repo = GetService(); return await repo.GetActiveByCompanyIdAsync(companyId); }, 3, timeout: TimeSpan.FromSeconds(10)); // Act var response = await graphqlClient.QueryAsync(""" query Drafts($companyId: ID!) { journalEntryDrafts(companyId: $companyId) { id name status } } """, new { companyId }); // Assert response.EnsureNoErrors(); response.Data!.JournalEntryDrafts.Should().HaveCount(3); response.Data.JournalEntryDrafts.Should().AllSatisfy(d => d.Status.Should().Be("active")); } [Fact] public async Task Query_JournalEntryDraft_ReturnsSingleDraft() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Single Draft Query Company"); var draftId = await CreateDraftAsync(graphqlClient, companyId, "Query Test Draft"); await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(draftId); }); // Act var response = await graphqlClient.QueryAsync(""" query Draft($id: ID!) { journalEntryDraft(id: $id) { id name companyId status lines { lineNumber } } } """, new { id = draftId }); // Assert response.EnsureNoErrors(); response.Data!.JournalEntryDraft.Should().NotBeNull(); response.Data.JournalEntryDraft!.Name.Should().Be("Query Test Draft"); response.Data.JournalEntryDraft.CompanyId.Should().Be(companyId); } [Fact] public async Task Query_JournalEntryDrafts_FailsWithoutCompanyHeader() { // Arrange var graphqlClient = new GraphQLTestClient(Client); // Not setting company header // Act var response = await graphqlClient.QueryAsync(""" query Drafts($companyId: ID!) { journalEntryDrafts(companyId: $companyId) { id } } """, new { companyId = "some-id" }); // Assert response.HasErrors.Should().BeTrue(); } #endregion #region Discard Draft Tests [Fact] public async Task Mutation_DiscardJournalEntryDraft_DiscardsSuccessfully() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Discard Draft Company"); var draftId = await CreateDraftAsync(graphqlClient, companyId, "To Be Discarded"); await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(draftId); }); // Act var response = await graphqlClient.MutateAsync(""" mutation DiscardDraft($id: ID!) { discardJournalEntryDraft(id: $id) { id status } } """, new { id = draftId }); // Assert response.EnsureNoErrors(); response.Data!.DiscardJournalEntryDraft!.Status.Should().Be("discarded"); // Verify it's no longer in active drafts var activeDrafts = await Eventually.GetAsync(async () => { var repo = GetService(); var drafts = await repo.GetActiveByCompanyIdAsync(companyId); return drafts.Any(d => d.Id == draftId) ? null : drafts; }); activeDrafts.Should().NotContain(d => d.Id == draftId); } [Fact] public async Task Mutation_DiscardJournalEntryDraft_FailsForAlreadyDiscarded() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Double Discard Company"); var draftId = await CreateDraftAsync(graphqlClient, companyId, "Discard Twice"); await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(draftId); }); // Discard once await graphqlClient.MutateAsync(""" mutation DiscardDraft($id: ID!) { discardJournalEntryDraft(id: $id) { id status } } """, new { id = draftId }); await Eventually.GetAsync(async () => { var repo = GetService(); var draft = await repo.GetByIdAsync(draftId); return draft?.Status == "discarded" ? draft : null; }); // Act - Try to discard again var response = await graphqlClient.MutateAsync(""" mutation DiscardDraft($id: ID!) { discardJournalEntryDraft(id: $id) { id status } } """, new { id = draftId }); // Assert response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); errorInfo.Should().Contain("has been discarded"); } #endregion #region Post Draft Tests [Fact] public async Task Mutation_PostJournalEntryDraft_FailsWithUnbalancedLines() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Unbalanced Post Company"); var draftId = await CreateDraftAsync(graphqlClient, companyId, "Unbalanced Draft"); // Wait for standard accounts var accounts = await Eventually.GetListAsync(async () => { var repo = GetService(); return await repo.GetActiveByCompanyIdAsync(companyId); }, 10, timeout: TimeSpan.FromSeconds(30)); var accountId1 = accounts.First().Id; var accountId2 = accounts.Skip(1).First().Id; await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(draftId); }); // Create fiscal year for posting var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId); // Update with unbalanced lines await graphqlClient.MutateAsync(""" mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { updateJournalEntryDraft(input: $input) { id } } """, new { input = new { id = draftId, fiscalYearId, lines = new[] { new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m }, new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 500m } // Unbalanced! } } }); await Task.Delay(500); // Wait for update // Act - Try to post unbalanced draft var response = await graphqlClient.MutateAsync(""" mutation PostDraft($id: ID!) { postJournalEntryDraft(id: $id) { id status transactionId } } """, new { id = draftId }); // Assert response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); errorInfo.Should().Contain("must equal credits"); } [Fact] public async Task Mutation_PostJournalEntryDraft_FailsWithoutFiscalYear() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "No Fiscal Year Company"); var draftId = await CreateDraftAsync(graphqlClient, companyId, "No Fiscal Year Draft"); // Wait for standard accounts var accounts = await Eventually.GetListAsync(async () => { var repo = GetService(); return await repo.GetActiveByCompanyIdAsync(companyId); }, 10, timeout: TimeSpan.FromSeconds(30)); var accountId1 = accounts.First().Id; var accountId2 = accounts.Skip(1).First().Id; await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(draftId); }); // Update with balanced lines but NO fiscal year await graphqlClient.MutateAsync(""" mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { updateJournalEntryDraft(input: $input) { id } } """, new { input = new { id = draftId, // No fiscalYearId! lines = new[] { new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m }, new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 1000m } } } }); await Task.Delay(500); // Act var response = await graphqlClient.MutateAsync(""" mutation PostDraft($id: ID!) { postJournalEntryDraft(id: $id) { id status } } """, new { id = draftId }); // Assert response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); errorInfo.Should().Contain("Fiscal year is required"); } [Fact] public async Task Mutation_PostJournalEntryDraft_FailsWithMissingAccounts() { // Arrange var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Missing Account Company"); var draftId = await CreateDraftAsync(graphqlClient, companyId, "Missing Account Draft"); await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(draftId); }); var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId); // Update with balanced lines but missing account IDs await graphqlClient.MutateAsync(""" mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { updateJournalEntryDraft(input: $input) { id } } """, new { input = new { id = draftId, fiscalYearId, lines = new[] { new { lineNumber = 1, accountId = (string?)null, debitAmount = 1000m, creditAmount = 0m }, new { lineNumber = 2, accountId = (string?)null, debitAmount = 0m, creditAmount = 1000m } } } }); await Task.Delay(500); // Act var response = await graphqlClient.MutateAsync(""" mutation PostDraft($id: ID!) { postJournalEntryDraft(id: $id) { id } } """, new { id = draftId }); // Assert response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); errorInfo.Should().Contain("must have an account"); } [Fact(Skip = "Requires Ledger period setup which is complex; domain logic is tested in JournalEntryDraftAggregateTests")] public async Task Mutation_UpdateJournalEntryDraft_FailsForPostedDraft() { // Arrange - Create and post a draft first var graphqlClient = new GraphQLTestClient(Client); var companyId = await CreateCompanyAsync(graphqlClient, "Posted Update Company"); var draftId = await CreateDraftAsync(graphqlClient, companyId, "To Be Posted Then Updated"); // Wait for accounts var accounts = await Eventually.GetListAsync(async () => { var repo = GetService(); return await repo.GetActiveByCompanyIdAsync(companyId); }, 10, timeout: TimeSpan.FromSeconds(30)); var accountId1 = accounts.First().Id; var accountId2 = accounts.Skip(1).First().Id; await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(draftId); }); var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId); // Update draft with valid data for posting await graphqlClient.MutateAsync(""" mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { updateJournalEntryDraft(input: $input) { id } } """, new { input = new { id = draftId, fiscalYearId, lines = new[] { new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m }, new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 1000m } } } }); await Task.Delay(500); // Post the draft var postResponse = await graphqlClient.MutateAsync(""" mutation PostDraft($id: ID!) { postJournalEntryDraft(id: $id) { id status } } """, new { id = draftId }); postResponse.EnsureNoErrors(); await Eventually.GetAsync(async () => { var repo = GetService(); var draft = await repo.GetByIdAsync(draftId); return draft?.Status == "posted" ? draft : null; }); // Act - Try to update the posted draft var response = await graphqlClient.MutateAsync(""" mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { updateJournalEntryDraft(input: $input) { id } } """, new { input = new { id = draftId, name = "Should Fail", lines = Array.Empty() } }); // Assert response.HasErrors.Should().BeTrue(); var errorInfo = string.Join(" ", response.Errors!.Select(e => e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); errorInfo.Should().Contain("already been posted"); } #endregion #region Helper Methods 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; client.SetCompanyId(companyId); return companyId; } private async Task CreateDraftAsync(GraphQLTestClient client, string companyId, string name) { var response = await client.MutateAsync(""" mutation CreateDraft($input: CreateJournalEntryDraftInput!) { createJournalEntryDraft(input: $input) { id } } """, new { input = new { companyId, name } }); response.EnsureNoErrors(); return response.Data!.CreateJournalEntryDraft!.Id; } private async Task CreateFiscalYearAsync(GraphQLTestClient client, string companyId) { var response = await client.MutateAsync(""" mutation CreateFiscalYear($input: CreateFiscalYearInput!) { createFiscalYear(input: $input) { id } } """, new { input = new { companyId, name = "2025", startDate = "2025-01-01T00:00:00", endDate = "2025-12-31T00:00:00", isFirstFiscalYear = true } }); response.EnsureNoErrors(); var fiscalYearId = response.Data!.CreateFiscalYear!.Id; // Wait for fiscal year to be created await Eventually.GetAsync(async () => { var repo = GetService(); return await repo.GetByIdAsync(fiscalYearId); }); return fiscalYearId; } #endregion #region Response DTOs private class CreateDraftResponse { public DraftDto? CreateJournalEntryDraft { get; set; } } private class UpdateDraftResponse { public DraftDto? UpdateJournalEntryDraft { get; set; } } private class PostDraftResponse { public DraftDto? PostJournalEntryDraft { get; set; } } private class DiscardDraftResponse { public DraftDto? DiscardJournalEntryDraft { get; set; } } private class DraftsResponse { public List JournalEntryDrafts { get; set; } = []; } private class SingleDraftResponse { public DraftDto? JournalEntryDraft { get; set; } } private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } } private class CreateFiscalYearResponse { public FiscalYearDto? CreateFiscalYear { get; set; } } private class DraftDto { public string Id { get; set; } = string.Empty; public string CompanyId { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string? DocumentDate { get; set; } public string? Description { get; set; } public string? FiscalYearId { get; set; } public string Status { get; set; } = string.Empty; public string? TransactionId { get; set; } public string CreatedBy { get; set; } = string.Empty; public List? Lines { get; set; } } private class DraftLineDto { public int LineNumber { get; set; } public string? AccountId { get; set; } public decimal DebitAmount { get; set; } public decimal CreditAmount { get; set; } public string? Description { get; set; } } private class CompanyDto { public string Id { get; set; } = string.Empty; } private class FiscalYearDto { public string Id { get; set; } = string.Empty; } #endregion }