books/backend/Books.Api.Tests/GraphQL/JournalEntryDraftGraphQLTests.cs
Nicolaj Hartmann 1f75c5d791 Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:

Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess

Commands & Handlers:
- Full CQRS command structure for all domains

Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories

GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries

Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)

Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00

787 lines
28 KiB
C#

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;
/// <summary>
/// Integration tests for JournalEntryDraft (Kassekladde) GraphQL operations.
/// </summary>
[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<CreateDraftResponse>("""
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<CreateDraftResponse>("""
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<CreateDraftResponse>("""
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Act
var response = await graphqlClient.MutateAsync<UpdateDraftResponse>("""
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<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) {
id
}
}
""",
new
{
input = new
{
id = nonExistentId,
name = "Test",
lines = Array.Empty<object>()
}
});
// 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<IJournalEntryDraftRepository>();
return await repo.GetActiveByCompanyIdAsync(companyId);
}, 3, timeout: TimeSpan.FromSeconds(10));
// Act
var response = await graphqlClient.QueryAsync<DraftsResponse>("""
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Act
var response = await graphqlClient.QueryAsync<SingleDraftResponse>("""
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<DraftsResponse>("""
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Act
var response = await graphqlClient.MutateAsync<DiscardDraftResponse>("""
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<IJournalEntryDraftRepository>();
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Discard once
await graphqlClient.MutateAsync<DiscardDraftResponse>("""
mutation DiscardDraft($id: ID!) {
discardJournalEntryDraft(id: $id) { id status }
}
""",
new { id = draftId });
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
var draft = await repo.GetByIdAsync(draftId);
return draft?.Status == "discarded" ? draft : null;
});
// Act - Try to discard again
var response = await graphqlClient.MutateAsync<DiscardDraftResponse>("""
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<IAccountRepository>();
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Create fiscal year for posting
var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId);
// Update with unbalanced lines
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
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<PostDraftResponse>("""
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<IAccountRepository>();
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Update with balanced lines but NO fiscal year
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
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<PostDraftResponse>("""
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId);
// Update with balanced lines but missing account IDs
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
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<PostDraftResponse>("""
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<IAccountRepository>();
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId);
// Update draft with valid data for posting
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
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<PostDraftResponse>("""
mutation PostDraft($id: ID!) {
postJournalEntryDraft(id: $id) { id status }
}
""",
new { id = draftId });
postResponse.EnsureNoErrors();
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
var draft = await repo.GetByIdAsync(draftId);
return draft?.Status == "posted" ? draft : null;
});
// Act - Try to update the posted draft
var response = await graphqlClient.MutateAsync<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
id = draftId,
name = "Should Fail",
lines = Array.Empty<object>()
}
});
// 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<string> CreateCompanyAsync(GraphQLTestClient client, string name)
{
var response = await client.MutateAsync<CreateCompanyResponse>("""
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<string> CreateDraftAsync(GraphQLTestClient client, string companyId, string name)
{
var response = await client.MutateAsync<CreateDraftResponse>("""
mutation CreateDraft($input: CreateJournalEntryDraftInput!) {
createJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
companyId,
name
}
});
response.EnsureNoErrors();
return response.Data!.CreateJournalEntryDraft!.Id;
}
private async Task<string> CreateFiscalYearAsync(GraphQLTestClient client, string companyId)
{
var response = await client.MutateAsync<CreateFiscalYearResponse>("""
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<IFiscalYearRepository>();
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<DraftDto> 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<DraftLineDto>? 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
}