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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user