Refactor GL period and entry date handling to use BusinessClock for accurate date resolution; add GlAccountCodeValidator for startup validation of configured account codes.

This commit is contained in:
2026-10-07 11:27:12 +05:30
parent ea8c8210e5
commit f753cae82d
17 changed files with 389 additions and 25 deletions
@@ -0,0 +1,158 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.System.Time;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace ERPCore.Tests;
/// <summary>
/// The validator's whole value is in *which* failures it treats as fatal: a missing account code
/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host
/// and may still be starting — hard-failing there turns that into a deploy crash-loop).
/// </summary>
public class GlAccountCodeValidatorTests
{
[Fact]
public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
new StubGl(["1100", "2000", "4000"]));
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task StartAsync_Throws_AndNamesEveryMissingCode()
{
var validator = CreateValidator(
new()
{
["Grn:GlInventoryAccountCode"] = "1100",
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
["Adjustment:GlLossAccountCode"] = "5100"
},
new StubGl(["1100"]));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => validator.StartAsync(CancellationToken.None));
Assert.Contains("1000-01", ex.Message);
Assert.Contains("5100", ex.Message);
Assert.DoesNotContain("1100,", ex.Message);
}
/// <summary>
/// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive
/// validator would call every configured code missing and refuse to start.
/// </summary>
[Fact]
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
new StubGl([]));
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task StartAsync_DoesNotThrow_WhenGlIsUnreachable()
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
new StubGl(new DomainException(ErrorCodes.GlServiceUnavailable, "unreachable", 503)));
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
new StubGl(new OperationCanceledException()));
await validator.StartAsync(CancellationToken.None);
}
/// <summary>Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL.</summary>
[Fact]
public async Task StartAsync_IgnoresSettingsThatAreNotAccountCodes()
{
var gl = new StubGl(["1100"]);
var validator = CreateValidator(
new()
{
["Grn:GlInventoryAccountCode"] = "1100",
["Grn:GlPostingEnabled"] = "true",
["Grn:SomeOtherCode"] = "not-an-account"
},
gl);
await validator.StartAsync(CancellationToken.None);
Assert.Equal(1, gl.Calls);
}
[Fact]
public async Task StartAsync_SkipsTheGlCall_WhenNoCodesAreConfigured()
{
var gl = new StubGl(["1100"]);
var validator = CreateValidator(new(), gl);
await validator.StartAsync(CancellationToken.None);
Assert.Equal(0, gl.Calls);
}
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
[Fact]
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
{
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 20, 0, 0, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 0, 0, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 29, 59, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
}
private static GlAccountCodeValidator CreateValidator(Dictionary<string, string?> settings, IGeneralLedgerService gl)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider();
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
}
private sealed class StubGl(HashSet<string>? codes = null, Exception? throws = null) : IGeneralLedgerService
{
private readonly HashSet<string> _codes = codes ?? [];
private readonly Exception? _throws = throws;
public StubGl(Exception throws) : this(null, throws) { }
public int Calls { get; private set; }
public Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
{
Calls++;
return _throws is not null ? Task.FromException<HashSet<string>>(_throws) : Task.FromResult(_codes);
}
public Task<ERPCore.Infra.Gl.GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
=> throw new NotSupportedException();
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
=> throw new NotSupportedException();
}
}