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 FiscalYear GraphQL operations. /// [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(""" 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(""" 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Act var closeResponse = await graphqlClient.MutateAsync(""" 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(); 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Close first await graphqlClient.MutateAsync( $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "closed" ? fy : null; }); // Act var reopenResponse = await graphqlClient.MutateAsync(""" mutation ReopenFiscalYear($id: ID!) { reopenFiscalYear(id: $id) { id status } } """, new { id = fiscalYearId }); // Assert reopenResponse.EnsureNoErrors(); var reopenedFiscalYear = await Eventually.GetAsync(async () => { var repo = GetService(); 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Must close before locking await graphqlClient.MutateAsync( $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "closed" ? fy : null; }); // Act var lockResponse = await graphqlClient.MutateAsync(""" mutation LockFiscalYear($id: ID!) { lockFiscalYear(id: $id) { id status } } """, new { id = fiscalYearId }); // Assert lockResponse.EnsureNoErrors(); var lockedFiscalYear = await Eventually.GetAsync(async () => { var repo = GetService(); 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(""" 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Close and lock await graphqlClient.MutateAsync( $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "closed" ? fy : null; }); await graphqlClient.MutateAsync( $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "locked" ? fy : null; }); // Act - Try to reopen locked fiscal year var reopenResponse = await graphqlClient.MutateAsync(""" 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(""" 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(); return await repo.GetByIdAsync(firstResponse.Data!.CreateFiscalYear!.Id); }); // Act - Try to create overlapping fiscal year (also 12 months) var overlapResponse = await graphqlClient.MutateAsync(""" 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(""" 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(""" 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Act - Try to lock an open fiscal year (not closed) var lockResponse = await graphqlClient.MutateAsync(""" 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Close and lock first await graphqlClient.MutateAsync( $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "closed" ? fy : null; }); await graphqlClient.MutateAsync( $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "locked" ? fy : null; }); // Act - Try to lock again var lockResponse = await graphqlClient.MutateAsync(""" 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Close first await graphqlClient.MutateAsync( $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "closed" ? fy : null; }); // Act - Try to close again var closeResponse = await graphqlClient.MutateAsync(""" 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(""" 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(""" 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(""" 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(""" 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(""" 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(""" 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Act - Try to reopen an already open fiscal year var reopenResponse = await graphqlClient.MutateAsync(""" 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Close first await graphqlClient.MutateAsync( $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "closed" ? fy : null; }); // Reopen await graphqlClient.MutateAsync( $"mutation {{ reopenFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); // Act - Query to check audit fields var queryResponse = await Eventually.GetAsync(async () => { var response = await graphqlClient.QueryAsync(""" 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(""" 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(); return await repo.GetByIdAsync(fiscalYearId); }); // Close and lock await graphqlClient.MutateAsync( $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); await Eventually.GetAsync(async () => { var repo = GetService(); var fy = await repo.GetByIdAsync(fiscalYearId); return fy?.Status == "closed" ? fy : null; }); await graphqlClient.MutateAsync( $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); // Act - Query to check audit fields var queryResponse = await Eventually.GetAsync(async () => { var response = await graphqlClient.QueryAsync(""" 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 CreateCompanyAsync(GraphQLTestClient client, string name) { var response = await client.MutateAsync(""" mutation CreateCompany($input: CreateCompanyInput!) { createCompany(input: $input) { id } } """, new { input = new { name } }); response.EnsureNoErrors(); var companyId = response.Data!.CreateCompany!.Id; // Set the company ID header for subsequent requests client.SetCompanyId(companyId); return companyId; } // Response DTOs private class FiscalYearsResponse { public List 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; } }