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; /// /// 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. /// 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(() => 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); } /// /// GL rejects a journal line against a non-postable account, so storing one only defers the /// failure to posting time. The auto-created 1000 Bank/Cash header is exactly this trap. /// [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(() => service.UpdateAsync( new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion)); Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code); Assert.Contains("not postable", ex.Message); await AssertNothingStoredAsync(db); } /// Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one. [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(() => 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(() => 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(() => service.UpdateAsync( new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999)); Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code); Assert.Equal(412, ex.StatusCode); } /// An empty string clears a role rather than storing "" and posting it to GL. [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)); } /// Reading an unmapped role names the role, instead of posting "" and letting GL 404. [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(() => codes[GlAccountRole.Cogs]); Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code); Assert.Contains("Cogs", ex.Message); } /// One request can post through several services; they should not each re-read the row. [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); } /// GL being down must not stop the saved mapping from rendering on the settings screen. [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); } /// The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank. [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); } /// The picker must only offer what validation will accept. [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(db).GetByIdAsync(GlAccountConfig.SingletonId); Assert.Null(row!.InventoryAccountCode); Assert.Null(row.ClearingAccountCode); Assert.Null(row.CashAccountCode); } private static async Task CreateSeededContextAsync(Action? 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(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db)); }