books/backend/Books.Api.Tests/GraphQL/FiscalYearGraphQLTests.cs

1103 lines
41 KiB
C#
Raw Normal View History

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; }
}