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>
1102 lines
41 KiB
C#
1102 lines
41 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 FiscalYear GraphQL operations.
|
|
/// </summary>
|
|
[Trait("Category", "Integration")]
|
|
public class FiscalYearGraphQLTests(TestWebApplicationFactory factory)
|
|
: IntegrationTestBase(factory)
|
|
{
|
|
[Fact]
|
|
public async Task Query_FiscalYears_ReturnsEmptyList_WhenNoFiscalYearsExist()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Empty Test");
|
|
|
|
// Act
|
|
var response = await graphqlClient.QueryAsync<FiscalYearsResponse>("""
|
|
query FiscalYears($companyId: ID!) {
|
|
fiscalYears(companyId: $companyId) {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
""",
|
|
new { companyId });
|
|
|
|
// Assert
|
|
response.EnsureNoErrors();
|
|
response.Data.Should().NotBeNull();
|
|
response.Data!.FiscalYears.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_CreatesFiscalYearSuccessfully()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Create Test");
|
|
|
|
// Act
|
|
var response = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) {
|
|
id
|
|
companyId
|
|
name
|
|
startDate
|
|
endDate
|
|
status
|
|
openingBalancePosted
|
|
}
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2024",
|
|
startDate = "2024-01-01T00:00:00",
|
|
endDate = "2025-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
// Assert
|
|
response.EnsureNoErrors();
|
|
response.Data.Should().NotBeNull();
|
|
response.Data!.CreateFiscalYear.Should().NotBeNull();
|
|
response.Data.CreateFiscalYear!.Name.Should().Be("2024");
|
|
response.Data.CreateFiscalYear.Status.Should().Be("open");
|
|
response.Data.CreateFiscalYear.OpeningBalancePosted.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CloseFiscalYear_ClosesFiscalYearSuccessfully()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Close Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id status }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2023",
|
|
startDate = "2023-01-01T00:00:00",
|
|
endDate = "2024-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
createResponse.EnsureNoErrors();
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Act
|
|
var closeResponse = await graphqlClient.MutateAsync<CloseFiscalYearResponse>("""
|
|
mutation CloseFiscalYear($id: ID!) {
|
|
closeFiscalYear(id: $id) {
|
|
id
|
|
status
|
|
closingDate
|
|
closedBy
|
|
}
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
// Assert
|
|
closeResponse.EnsureNoErrors();
|
|
|
|
var closedFiscalYear = await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "closed" ? fy : null;
|
|
});
|
|
|
|
closedFiscalYear.Should().NotBeNull();
|
|
closedFiscalYear!.Status.Should().Be("closed");
|
|
closedFiscalYear.ClosedBy.Should().NotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_ReopenFiscalYear_ReopensFiscalYearSuccessfully()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Reopen Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2022",
|
|
startDate = "2022-01-01T00:00:00",
|
|
endDate = "2023-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Close first
|
|
await graphqlClient.MutateAsync<CloseFiscalYearResponse>(
|
|
$"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "closed" ? fy : null;
|
|
});
|
|
|
|
// Act
|
|
var reopenResponse = await graphqlClient.MutateAsync<ReopenFiscalYearResponse>("""
|
|
mutation ReopenFiscalYear($id: ID!) {
|
|
reopenFiscalYear(id: $id) {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
// Assert
|
|
reopenResponse.EnsureNoErrors();
|
|
|
|
var reopenedFiscalYear = await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "open" ? fy : null;
|
|
});
|
|
|
|
reopenedFiscalYear.Should().NotBeNull();
|
|
reopenedFiscalYear!.Status.Should().Be("open");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_LockFiscalYear_LocksFiscalYearSuccessfully()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Lock Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2021",
|
|
startDate = "2021-01-01T00:00:00",
|
|
endDate = "2022-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Must close before locking
|
|
await graphqlClient.MutateAsync<CloseFiscalYearResponse>(
|
|
$"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "closed" ? fy : null;
|
|
});
|
|
|
|
// Act
|
|
var lockResponse = await graphqlClient.MutateAsync<LockFiscalYearResponse>("""
|
|
mutation LockFiscalYear($id: ID!) {
|
|
lockFiscalYear(id: $id) {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
// Assert
|
|
lockResponse.EnsureNoErrors();
|
|
|
|
var lockedFiscalYear = await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "locked" ? fy : null;
|
|
});
|
|
|
|
lockedFiscalYear.Should().NotBeNull();
|
|
lockedFiscalYear!.Status.Should().Be("locked");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_FailsWithInvalidDuration()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Invalid Duration Test");
|
|
|
|
// Act - Try to create fiscal year with only 3 months (must be 12 for standard, 6-18 for first/reorg)
|
|
var response = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "Q1 2024",
|
|
startDate = "2024-01-01T00:00:00",
|
|
endDate = "2024-03-31T00:00:00" // Only 3 months - invalid
|
|
}
|
|
});
|
|
|
|
// Assert - Domain validation error is wrapped by GraphQL
|
|
response.HasErrors.Should().BeTrue();
|
|
var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
var hasValidationError = response.Errors!.Any(e =>
|
|
e.Message.Contains("INVALID_DURATION") ||
|
|
e.Message.Contains("INVALID_STANDARD_DURATION") ||
|
|
errorDetails.Contains("INVALID_DURATION") ||
|
|
errorDetails.Contains("INVALID_STANDARD_DURATION") ||
|
|
errorDetails.Contains("12 months"));
|
|
hasValidationError.Should().BeTrue("Expected an error related to invalid fiscal year duration");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_ReopenFiscalYear_FailsWhenLocked()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Cannot Reopen Locked Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2020",
|
|
startDate = "2020-01-01T00:00:00",
|
|
endDate = "2021-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Close and lock
|
|
await graphqlClient.MutateAsync<CloseFiscalYearResponse>(
|
|
$"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "closed" ? fy : null;
|
|
});
|
|
|
|
await graphqlClient.MutateAsync<LockFiscalYearResponse>(
|
|
$"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "locked" ? fy : null;
|
|
});
|
|
|
|
// Act - Try to reopen locked fiscal year
|
|
var reopenResponse = await graphqlClient.MutateAsync<ReopenFiscalYearResponse>("""
|
|
mutation ReopenFiscalYear($id: ID!) {
|
|
reopenFiscalYear(id: $id) { id status }
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
// Assert - Domain validation error is wrapped by GraphQL
|
|
reopenResponse.HasErrors.Should().BeTrue();
|
|
var errorDetails = reopenResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
var hasLockError = reopenResponse.Errors!.Any(e =>
|
|
e.Message.Contains("FISCAL_YEAR_LOCKED") ||
|
|
errorDetails.Contains("FISCAL_YEAR_LOCKED") ||
|
|
errorDetails.Contains("Locked fiscal years cannot be reopened"));
|
|
hasLockError.Should().BeTrue("Expected an error related to locked fiscal year");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_FailsWithOverlappingDates()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Overlap Test");
|
|
|
|
// Create first fiscal year (12 months as required for standard)
|
|
var firstResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2024",
|
|
startDate = "2024-01-01T00:00:00",
|
|
endDate = "2025-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
firstResponse.EnsureNoErrors();
|
|
|
|
// Wait for first fiscal year to be created
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(firstResponse.Data!.CreateFiscalYear!.Id);
|
|
});
|
|
|
|
// Act - Try to create overlapping fiscal year (also 12 months)
|
|
var overlapResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2024 Overlap",
|
|
startDate = "2024-06-01T00:00:00",
|
|
endDate = "2025-06-01T00:00:00" // Overlaps with first
|
|
}
|
|
});
|
|
|
|
// Assert
|
|
overlapResponse.HasErrors.Should().BeTrue();
|
|
var errorDetails = overlapResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
var hasOverlapError = overlapResponse.Errors!.Any(e =>
|
|
e.Message.Contains("OVERLAPPING_FISCAL_YEAR") ||
|
|
errorDetails.Contains("OVERLAPPING_FISCAL_YEAR") ||
|
|
errorDetails.Contains("overlaps"));
|
|
hasOverlapError.Should().BeTrue("Expected an error related to overlapping fiscal years");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_IsDeterministic()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Deterministic Test");
|
|
|
|
// Act - Create same fiscal year twice
|
|
var firstResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2025",
|
|
startDate = "2025-01-01T00:00:00",
|
|
endDate = "2026-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
firstResponse.EnsureNoErrors();
|
|
var firstId = firstResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
// Second attempt should fail (due to existing fiscal year with same ID)
|
|
var secondResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2025 Second Attempt",
|
|
startDate = "2025-01-01T00:00:00",
|
|
endDate = "2025-12-31T00:00:00"
|
|
}
|
|
});
|
|
|
|
// Assert - Second attempt should fail with overlap error
|
|
// (because same dates = same deterministic ID AND overlap detection)
|
|
secondResponse.HasErrors.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_LockFiscalYear_FailsWhenNotClosed()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Lock Open Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id status }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2019",
|
|
startDate = "2019-01-01T00:00:00",
|
|
endDate = "2020-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
createResponse.EnsureNoErrors();
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Act - Try to lock an open fiscal year (not closed)
|
|
var lockResponse = await graphqlClient.MutateAsync<LockFiscalYearResponse>("""
|
|
mutation LockFiscalYear($id: ID!) {
|
|
lockFiscalYear(id: $id) { id status }
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
// Assert
|
|
lockResponse.HasErrors.Should().BeTrue();
|
|
var errorDetails = lockResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
(lockResponse.Errors!.Any(e => e.Message.Contains("MUST_BE_CLOSED")) ||
|
|
errorDetails.Contains("MUST_BE_CLOSED") ||
|
|
errorDetails.Contains("must be closed")).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_LockFiscalYear_FailsWhenAlreadyLocked()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Double Lock Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2018",
|
|
startDate = "2018-01-01T00:00:00",
|
|
endDate = "2019-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Close and lock first
|
|
await graphqlClient.MutateAsync<CloseFiscalYearResponse>(
|
|
$"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "closed" ? fy : null;
|
|
});
|
|
|
|
await graphqlClient.MutateAsync<LockFiscalYearResponse>(
|
|
$"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "locked" ? fy : null;
|
|
});
|
|
|
|
// Act - Try to lock again
|
|
var lockResponse = await graphqlClient.MutateAsync<LockFiscalYearResponse>("""
|
|
mutation LockFiscalYear($id: ID!) {
|
|
lockFiscalYear(id: $id) { id status }
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
// Assert
|
|
lockResponse.HasErrors.Should().BeTrue();
|
|
var errorDetails = lockResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
(lockResponse.Errors!.Any(e => e.Message.Contains("ALREADY_LOCKED")) ||
|
|
errorDetails.Contains("ALREADY_LOCKED") ||
|
|
errorDetails.Contains("already locked")).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CloseFiscalYear_FailsWhenAlreadyClosed()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Double Close Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2017",
|
|
startDate = "2017-01-01T00:00:00",
|
|
endDate = "2018-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Close first
|
|
await graphqlClient.MutateAsync<CloseFiscalYearResponse>(
|
|
$"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "closed" ? fy : null;
|
|
});
|
|
|
|
// Act - Try to close again
|
|
var closeResponse = await graphqlClient.MutateAsync<CloseFiscalYearResponse>("""
|
|
mutation CloseFiscalYear($id: ID!) {
|
|
closeFiscalYear(id: $id) { id status }
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
// Assert
|
|
closeResponse.HasErrors.Should().BeTrue();
|
|
var errorDetails = closeResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
(closeResponse.Errors!.Any(e => e.Message.Contains("ALREADY_CLOSED")) ||
|
|
errorDetails.Contains("ALREADY_CLOSED") ||
|
|
errorDetails.Contains("already closed")).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_FailsWhenEndDateBeforeStartDate()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Invalid Date Range Test");
|
|
|
|
// Act - Try to create fiscal year with end date before start date
|
|
var response = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "Invalid Dates",
|
|
startDate = "2024-12-31T00:00:00",
|
|
endDate = "2024-01-01T00:00:00" // End before start
|
|
}
|
|
});
|
|
|
|
// Assert
|
|
response.HasErrors.Should().BeTrue();
|
|
var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
(response.Errors!.Any(e => e.Message.Contains("INVALID_DATE_RANGE")) ||
|
|
errorDetails.Contains("INVALID_DATE_RANGE") ||
|
|
errorDetails.Contains("End date must be after start date")).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_AcceptsExactly6Months_WhenFirstFiscalYear()
|
|
{
|
|
// Arrange - Per Årsregnskabsloven §15, first fiscal year can be 6-18 months
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY 6 Months First Test");
|
|
|
|
// Act - Create fiscal year with exactly 6 months (minimum allowed for first year)
|
|
// Domain calculates: (endYear - startYear) * 12 + (endMonth - startMonth)
|
|
// Jan to Jul = (0 * 12) + (7 - 1) = 6
|
|
var response = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) {
|
|
id
|
|
name
|
|
startDate
|
|
endDate
|
|
}
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "First Year H1 2024",
|
|
startDate = "2024-01-01T00:00:00",
|
|
endDate = "2024-07-01T00:00:00", // 6 months by domain calculation
|
|
isFirstFiscalYear = true
|
|
}
|
|
});
|
|
|
|
// Assert - Should succeed because isFirstFiscalYear allows 6-18 months
|
|
response.EnsureNoErrors();
|
|
response.Data!.CreateFiscalYear!.Name.Should().Be("First Year H1 2024");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_AcceptsExactly18Months_WhenReorganization()
|
|
{
|
|
// Arrange - Per Årsregnskabsloven §15, reorganization allows up to 18 months
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY 18 Months Reorg Test");
|
|
|
|
// Act - Create fiscal year with exactly 18 months (maximum allowed for reorganization)
|
|
// Domain calculates: (endYear - startYear) * 12 + (endMonth - startMonth)
|
|
// Jan 2024 to Jul 2025 = (1 * 12) + (7 - 1) = 18
|
|
var response = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "Reorganization 2024-2025",
|
|
startDate = "2024-01-01T00:00:00",
|
|
endDate = "2025-07-01T00:00:00", // 18 months by domain calculation
|
|
isReorganization = true
|
|
}
|
|
});
|
|
|
|
// Assert - Should succeed because isReorganization allows 6-18 months
|
|
response.EnsureNoErrors();
|
|
response.Data!.CreateFiscalYear!.Name.Should().Be("Reorganization 2024-2025");
|
|
}
|
|
|
|
#region Danish Compliance: Fiscal Year Duration Tests (Årsregnskabsloven §15)
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_RequiresExactly12MonthsForStandardYear()
|
|
{
|
|
// Arrange - Per Årsregnskabsloven §15, standard fiscal years must be 12 months
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Standard Duration Test");
|
|
|
|
// Act - Try to create 6-month fiscal year WITHOUT isFirstFiscalYear flag
|
|
var response = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "Invalid Standard 6 Months",
|
|
startDate = "2024-01-01T00:00:00",
|
|
endDate = "2024-07-01T00:00:00" // 6 months - invalid for standard year
|
|
// NOT setting isFirstFiscalYear or isReorganization
|
|
}
|
|
});
|
|
|
|
// Assert - Should fail because standard years must be 12 months
|
|
response.HasErrors.Should().BeTrue();
|
|
var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
(response.Errors!.Any(e => e.Message.Contains("INVALID_STANDARD_DURATION")) ||
|
|
errorDetails.Contains("INVALID_STANDARD_DURATION") ||
|
|
errorDetails.Contains("12 months")).Should().BeTrue(
|
|
"Standard fiscal year must be exactly 12 months per Årsregnskabsloven §15");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_AllowsShorterForFirstYear()
|
|
{
|
|
// Arrange - Per Årsregnskabsloven §15, first fiscal year can be shorter than 12 months
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY First Year Shorter Test");
|
|
|
|
// Act - Create 9-month fiscal year WITH isFirstFiscalYear flag
|
|
var response = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "First Year 9 Months",
|
|
startDate = "2024-04-01T00:00:00",
|
|
endDate = "2025-01-01T00:00:00", // 9 months
|
|
isFirstFiscalYear = true
|
|
}
|
|
});
|
|
|
|
// Assert - Should succeed because first year allows 6-18 months
|
|
response.EnsureNoErrors();
|
|
response.Data!.CreateFiscalYear!.Name.Should().Be("First Year 9 Months");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutation_CreateFiscalYear_Allows18MonthsOnlyForReorganization()
|
|
{
|
|
// Arrange - Per Årsregnskabsloven §15, only reorganization allows up to 18 months
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY 18 Months Standard Test");
|
|
|
|
// Act - Try to create 18-month fiscal year WITHOUT flags
|
|
var response = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "Invalid Standard 18 Months",
|
|
startDate = "2024-01-01T00:00:00",
|
|
endDate = "2025-07-01T00:00:00" // 18 months - invalid for standard year
|
|
// NOT setting isFirstFiscalYear or isReorganization
|
|
}
|
|
});
|
|
|
|
// Assert - Should fail because standard years must be 12 months
|
|
response.HasErrors.Should().BeTrue();
|
|
var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
(response.Errors!.Any(e => e.Message.Contains("INVALID_STANDARD_DURATION")) ||
|
|
errorDetails.Contains("INVALID_STANDARD_DURATION") ||
|
|
errorDetails.Contains("12 months")).Should().BeTrue(
|
|
"Only reorganization allows 18 months per Årsregnskabsloven §15");
|
|
}
|
|
|
|
#endregion
|
|
|
|
[Fact]
|
|
public async Task Mutation_ReopenFiscalYear_FailsWhenAlreadyOpen()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Reopen Already Open Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id status }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2014",
|
|
startDate = "2014-01-01T00:00:00",
|
|
endDate = "2015-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
createResponse.EnsureNoErrors();
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
createResponse.Data.CreateFiscalYear.Status.Should().Be("open");
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Act - Try to reopen an already open fiscal year
|
|
var reopenResponse = await graphqlClient.MutateAsync<ReopenFiscalYearResponse>("""
|
|
mutation ReopenFiscalYear($id: ID!) {
|
|
reopenFiscalYear(id: $id) { id status }
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
// Assert
|
|
reopenResponse.HasErrors.Should().BeTrue();
|
|
var errorDetails = reopenResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
|
|
(reopenResponse.Errors!.Any(e => e.Message.Contains("ALREADY_OPEN")) ||
|
|
errorDetails.Contains("ALREADY_OPEN") ||
|
|
errorDetails.Contains("already open")).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Query_FiscalYear_ReturnsAuditFieldsAfterReopen()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Audit Reopen Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2016",
|
|
startDate = "2016-01-01T00:00:00",
|
|
endDate = "2017-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Close first
|
|
await graphqlClient.MutateAsync<CloseFiscalYearResponse>(
|
|
$"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "closed" ? fy : null;
|
|
});
|
|
|
|
// Reopen
|
|
await graphqlClient.MutateAsync<ReopenFiscalYearResponse>(
|
|
$"mutation {{ reopenFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
|
|
// Act - Query to check audit fields
|
|
var queryResponse = await Eventually.GetAsync(async () =>
|
|
{
|
|
var response = await graphqlClient.QueryAsync<FiscalYearQueryResponse>("""
|
|
query FiscalYear($id: ID!) {
|
|
fiscalYear(id: $id) {
|
|
id
|
|
status
|
|
reopenedDate
|
|
reopenedBy
|
|
}
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
return response.Data?.FiscalYear?.ReopenedBy != null ? response : null;
|
|
});
|
|
|
|
// Assert
|
|
queryResponse.Should().NotBeNull();
|
|
queryResponse!.Data!.FiscalYear!.Status.Should().Be("open");
|
|
queryResponse.Data.FiscalYear.ReopenedBy.Should().NotBeNullOrEmpty();
|
|
queryResponse.Data.FiscalYear.ReopenedDate.Should().NotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Query_FiscalYear_ReturnsAuditFieldsAfterLock()
|
|
{
|
|
// Arrange
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "FY Audit Lock Test");
|
|
|
|
var createResponse = await graphqlClient.MutateAsync<CreateFiscalYearResponse>("""
|
|
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
|
|
createFiscalYear(input: $input) { id }
|
|
}
|
|
""",
|
|
new
|
|
{
|
|
input = new
|
|
{
|
|
companyId,
|
|
name = "2015",
|
|
startDate = "2015-01-01T00:00:00",
|
|
endDate = "2016-01-01T00:00:00" // Exactly 12 months
|
|
}
|
|
});
|
|
|
|
var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
|
|
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
return await repo.GetByIdAsync(fiscalYearId);
|
|
});
|
|
|
|
// Close and lock
|
|
await graphqlClient.MutateAsync<CloseFiscalYearResponse>(
|
|
$"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
await Eventually.GetAsync(async () =>
|
|
{
|
|
var repo = GetService<IFiscalYearRepository>();
|
|
var fy = await repo.GetByIdAsync(fiscalYearId);
|
|
return fy?.Status == "closed" ? fy : null;
|
|
});
|
|
|
|
await graphqlClient.MutateAsync<LockFiscalYearResponse>(
|
|
$"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
|
|
|
|
// Act - Query to check audit fields
|
|
var queryResponse = await Eventually.GetAsync(async () =>
|
|
{
|
|
var response = await graphqlClient.QueryAsync<FiscalYearQueryResponse>("""
|
|
query FiscalYear($id: ID!) {
|
|
fiscalYear(id: $id) {
|
|
id
|
|
status
|
|
lockedDate
|
|
lockedBy
|
|
}
|
|
}
|
|
""",
|
|
new { id = fiscalYearId });
|
|
|
|
return response.Data?.FiscalYear?.LockedBy != null ? response : null;
|
|
});
|
|
|
|
// Assert
|
|
queryResponse.Should().NotBeNull();
|
|
queryResponse!.Data!.FiscalYear!.Status.Should().Be("locked");
|
|
queryResponse.Data.FiscalYear.LockedBy.Should().NotBeNullOrEmpty();
|
|
queryResponse.Data.FiscalYear.LockedDate.Should().NotBeNullOrEmpty();
|
|
}
|
|
|
|
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;
|
|
|
|
// Set the company ID header for subsequent requests
|
|
client.SetCompanyId(companyId);
|
|
|
|
return companyId;
|
|
}
|
|
|
|
// Response DTOs
|
|
private class FiscalYearsResponse { public List<FiscalYearDto> FiscalYears { get; set; } = []; }
|
|
private class FiscalYearResponse { public FiscalYearDto? FiscalYear { get; set; } }
|
|
private class FiscalYearQueryResponse { public FiscalYearDto? FiscalYear { get; set; } }
|
|
private class CreateFiscalYearResponse { public FiscalYearDto? CreateFiscalYear { get; set; } }
|
|
private class CloseFiscalYearResponse { public FiscalYearDto? CloseFiscalYear { get; set; } }
|
|
private class ReopenFiscalYearResponse { public FiscalYearDto? ReopenFiscalYear { get; set; } }
|
|
private class LockFiscalYearResponse { public FiscalYearDto? LockFiscalYear { get; set; } }
|
|
private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } }
|
|
|
|
private class FiscalYearDto
|
|
{
|
|
public string Id { get; set; } = string.Empty;
|
|
public string CompanyId { get; set; } = string.Empty;
|
|
public string Name { get; set; } = string.Empty;
|
|
public string StartDate { get; set; } = string.Empty;
|
|
public string EndDate { get; set; } = string.Empty;
|
|
public string Status { get; set; } = string.Empty;
|
|
public bool OpeningBalancePosted { get; set; }
|
|
public string? ClosingDate { get; set; }
|
|
public string? ClosedBy { get; set; }
|
|
public string? ReopenedDate { get; set; }
|
|
public string? ReopenedBy { get; set; }
|
|
public string? LockedDate { get; set; }
|
|
public string? LockedBy { get; set; }
|
|
}
|
|
|
|
private class CompanyDto { public string Id { get; set; } = string.Empty; }
|
|
}
|