Files
ERP-core/Backend/ERPCore.Tests/GlAccountConfigServiceTests.cs
T
ImanThiyanga c41aed9a80 Move the GL account mapping out of appsettings into validated master data
The per-module Gl*Code appsettings keys pinned GL's account *codes* in
ERPCore's config, while GL generates codes positionally and refuses a
requested one. Nothing connected the two, so "1100 means Inventory" held
only because someone once created accounts in a particular order - and on
2026-10-07 eleven of the twelve configured codes turned out never to have
existed in GL. Every GRN, adjustment, day-end, invoice and FIFO posting had
been failing with "Account <code> not found".

The mapping is now master data in gl_account_config, keyed by accounting
role rather than by module, and GlAccountConfigService validates every
submitted code against GL's live chart of accounts before storing it. A
code GL does not have, or cannot post to, is refused with the role named -
so the mismatch is unrepresentable rather than merely detected. Correcting
a mapping no longer needs a redeploy.

Notes:
- Fails closed on save (503 if GL is unreachable), deliberately opposite to
  the startup validator, which must still let the app serve.
- Non-postable accounts are rejected too: GL rejects a journal line against
  one, so storing it only defers the failure. The auto-created 1000
  Bank/Cash header is exactly that trap.
- The settings endpoint also serves the selectable accounts, because
  GeneralLedgerController is gated on `ledgers` while this is on `settings`
  - and it keeps the picker and the validation list the same data.
- Reading an unmapped role throws naming the role, instead of posting ""
  and letting GL return an opaque 404 from inside a transaction.
- The appsettings keys are now seed-only: DataSeeder copies them in once so
  existing deployments keep their current mapping. No runtime fallback, or
  there would be two sources of truth again.
- The 32 read sites across 11 services collapsed to 12 roles, since every
  module carried the same value for a given role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 13:04:49 +05:30

235 lines
9.9 KiB
C#

using ERPCore.Domain.Entities;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
namespace ERPCore.Tests;
/// <summary>
/// The point of this service is that a mapping GL cannot post to never reaches the database.
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
/// existed in GL at all.
/// </summary>
public class GlAccountConfigServiceTests
{
[Fact]
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
{
await using var db = await CreateSeededContextAsync();
var gl = new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100", "Inventory"),
StubGeneralLedgerService.Postable("2000", "GRNI")
]);
var service = CreateService(db, gl);
var before = (await service.GetAsync()).RowVersion;
var result = await service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
Assert.True(result.RowVersion > before);
var codes = await CreateService(db, gl).GetCodesAsync();
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
}
[Fact]
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Equal(422, ex.StatusCode);
Assert.Contains("9999", ex.Message);
Assert.Contains("Clearing", ex.Message);
await AssertNothingStoredAsync(db);
}
/// <summary>
/// GL rejects a journal line against a non-postable account, so storing one only defers the
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
/// </summary>
[Fact]
public async Task UpdateAsync_RejectsANonPostableAccount()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Contains("not postable", ex.Message);
await AssertNothingStoredAsync(db);
}
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
[Fact]
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
await AssertNothingStoredAsync(db);
}
[Fact]
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Equal(503, ex.StatusCode);
}
[Fact]
public async Task UpdateAsync_RejectsAStaleIfMatch()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
[Fact]
public async Task UpdateAsync_TreatsBlankAsCleared()
{
await using var db = await CreateSeededContextAsync();
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var service = CreateService(db, gl);
var rowVersion = (await service.GetAsync()).RowVersion;
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
var codes = await CreateService(db, gl).GetCodesAsync();
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
}
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
[Fact]
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var codes = await service.GetCodesAsync();
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
Assert.Contains("Cogs", ex.Message);
}
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
[Fact]
public async Task GetCodesAsync_ReadsOncePerScope()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var first = await service.GetCodesAsync();
var second = await service.GetCodesAsync();
Assert.Same(first, second);
}
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
[Fact]
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
{
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
var result = await service.GetAsync();
Assert.True(result.Value.GlUnavailable);
Assert.Empty(result.Value.Options);
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
Assert.Equal("1100", inventory.AccountCode);
}
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
[Fact]
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
{
await using var db = await CreateSeededContextAsync(c =>
{
c.InventoryAccountCode = "1100";
c.CashAccountCode = "1000-01";
});
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
]));
var result = await service.GetAsync();
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
}
/// <summary>The picker must only offer what validation will accept.</summary>
[Fact]
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100", "Inventory"),
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
new GlAccount("1200", "Retired", 1, true, false)
]));
var options = (await service.GetAsync()).Value.Options;
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
}
private static async Task AssertNothingStoredAsync(ErpDbContext db)
{
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
Assert.Null(row!.InventoryAccountCode);
Assert.Null(row.ClearingAccountCode);
Assert.Null(row.CashAccountCode);
}
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
{
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
configure?.Invoke(config);
db.GlAccountConfig.Add(config);
await db.SaveChangesAsync();
return db;
}
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
}