using System.Data.Common; using Dapr.Client; using EntitlementBLL.Common; using EntitlementBLL.Demo; using EntitlementBLL.Interfaces; using EntitlementBLL.Legal; using EntitlementBLL.Onboarding; using EntitlementDAL.DTO.User; using EntitlementDAL.DTOs; using EntitlementDAL.Interfaces; using GB5Shared.ActionProcessor; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.User; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Xunit; namespace EntitlementTests; /// /// Covers tracker §49 Decision 9's correction: registration reuses Entitlement's own central /// account model (IClientProvisioningBLL.CreateClientAsync — identity only, no DB provisioning) /// rather than a demo-only identity concept, and both CheckOutPooledSessionAsync (Pooled) and /// StartDedicatedSessionAsync (Dedicated) now require a real, pre-registered ClientId instead of /// a bare ProspectOrgName string. /// public class DemoSessionBLLTests { private sealed class Mocks { public Mock DemoDAL = new(); public Mock ClientProvisioningBLL = new(); public Mock ClientProvisioningDAL = new(); public Mock EntitlementUserDAL = new(); public Mock ClientUserProvisioner = new(); public Mock EventActionRunDAL = new(); public Mock ActionOutboxDAL = new(); public Mock Orchestrator = new(); public Mock SqlWorkbenchClient = new(); public Mock AgreementConsentProvider = new(); } private static (DemoSessionBLL Svc, Mocks M) BuildService() { var m = new Mocks(); m.ClientProvisioningBLL .Setup(c => c.CreateClientAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new CreateClientResultDTO { ClientId = 100, UserId = 200, TemporaryPassword = "Temp#123" }); var queryExecutor = new Mock(); queryExecutor.Setup(q => q.BeginTransactionAsync(It.IsAny())).ReturnsAsync((DbTransaction)null!); queryExecutor .Setup(q => q.QueryAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { 9001 }); var dbConfig = new Mock>(); dbConfig.Setup(o => o.Value).Returns(new Gb5SystemDTO { DataBaseType = 0 }); var autoNumber = new AutoNumber(queryExecutor.Object, dbConfig.Object); var loginFactory = new Mock(); loginFactory.Setup(f => f.Create(It.IsAny(), It.IsAny())) .Returns((int clientId, int userId) => new LoginDTO { ClientId = clientId, UserId = userId }); var eventLog = new EventLogPublish(new DaprClientBuilder().Build(), NullLogger.Instance); var svc = new DemoSessionBLL( m.DemoDAL.Object, m.ClientProvisioningBLL.Object, m.ClientProvisioningDAL.Object, m.EntitlementUserDAL.Object, m.ClientUserProvisioner.Object, m.EventActionRunDAL.Object, m.ActionOutboxDAL.Object, m.Orchestrator.Object, m.SqlWorkbenchClient.Object, queryExecutor.Object, autoNumber, loginFactory.Object, eventLog, m.AgreementConsentProvider.Object, NullLogger.Instance); return (svc, m); } private static RegisterDemoProspectRequest ValidRegistration() => new() { CompanyName = "Acme Manufacturing", ContactName = "Jane Prospect", Email = "jane@acme-prospect.test", Mobile = "9999999999", PreferredIndustryCode = "MFG", PreferredGeographyCode = "IN", }; // ── RegisterProspectAsync ─────────────────────────────────────────────── [Theory] [InlineData("", "Jane", "jane@acme.test", "999")] [InlineData("Acme", "", "jane@acme.test", "999")] [InlineData("Acme", "Jane", "", "999")] [InlineData("Acme", "Jane", "jane@acme.test", "")] public async Task Test_RegisterProspectAsync_MissingRequiredField_Throws( string companyName, string contactName, string email, string mobile) { var (svc, m) = BuildService(); var req = new RegisterDemoProspectRequest { CompanyName = companyName, ContactName = contactName, Email = email, Mobile = mobile, }; await Assert.ThrowsAsync(() => svc.RegisterProspectAsync(req, CancellationToken.None)); m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_RegisterProspectAsync_InvalidEmail_Throws() { var (svc, _) = BuildService(); var req = ValidRegistration(); req.Email = "not-an-email"; await Assert.ThrowsAsync(() => svc.RegisterProspectAsync(req, CancellationToken.None)); } [Fact] public async Task Test_RegisterProspectAsync_HappyPath_CreatesCentralAccountAndSavesPreference() { var (svc, m) = BuildService(); DemoPreferenceDTO? savedPreference = null; m.DemoDAL .Setup(d => d.SavePreferenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((dto, _, _, _) => savedPreference = dto) .Returns(Task.CompletedTask); var result = await svc.RegisterProspectAsync(ValidRegistration(), CancellationToken.None); Assert.Equal(100, result.ClientId); Assert.Equal(200, result.UserId); Assert.Equal("Temp#123", result.TemporaryPassword); m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync( It.Is(r => r.ClientName == "Acme Manufacturing" && r.AdminEmail == "jane@acme-prospect.test"), It.IsAny()), Times.Once); Assert.NotNull(savedPreference); Assert.Equal(100, savedPreference!.ClientId); Assert.Equal("MFG", savedPreference.PreferredIndustryCode); Assert.Equal("IN", savedPreference.PreferredGeographyCode); // Legal/Contract Agreement Consent (tracker §50/§51) — ValidRegistration()'s default // (an empty AcceptedAgreementVersionIds array) must fully skip acceptance recording, // not block or default it. m.AgreementConsentProvider.Verify(a => a.RecordAcceptanceAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_RegisterProspectAsync_WithAcceptedAgreements_RecordsConsentAsProspectAgainstNewClientId() { var (svc, m) = BuildService(); m.DemoDAL .Setup(d => d.SavePreferenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var req = ValidRegistration(); req.AcceptedAgreementVersionIds = new[] { -1370000201, -1370000202 }; var result = await svc.RegisterProspectAsync(req, CancellationToken.None); m.AgreementConsentProvider.Verify(a => a.RecordAcceptanceAsync( AgreementSubjectType.Prospect, result.ClientId, It.Is(ids => ids.Length == 2 && ids.Contains(-1370000201) && ids.Contains(-1370000202)), null, null, AgreementAcceptanceMethod.Clickwrap, It.Is(k => k.Contains(result.ClientId.ToString())), It.IsAny(), It.IsAny()), Times.Once); } // ── CheckOutPooledSessionAsync ────────────────────────────────────────── [Fact] public async Task Test_CheckOutPooledSessionAsync_MissingClientId_Throws() { var (svc, m) = BuildService(); var req = new CheckOutPooledSessionRequest { ClientId = 0, DemoVariantId = 1, OwnerUserId = 5 }; await Assert.ThrowsAsync(() => svc.CheckOutPooledSessionAsync(req, CancellationToken.None)); m.DemoDAL.Verify(d => d.GetOneAvailablePoolInstanceAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_CheckOutPooledSessionAsync_HappyPath_SessionCarriesClientIdLeadIdAndRoleCode() { var (svc, m) = BuildService(); var poolInstance = new DemoPoolInstanceDTO { DemoPoolInstanceId = 55, DemoVariantId = 1, SwClientDatabaseId = 900, PoolState = 0 }; m.DemoDAL.Setup(d => d.GetOneAvailablePoolInstanceAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(poolInstance); m.DemoDAL.Setup(d => d.TryClaimPoolInstanceAsync(55, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); DemoSessionDTO? saved = null; m.DemoDAL.Setup(d => d.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((dto, _, _, _) => saved = dto) .Returns(Task.CompletedTask); var req = new CheckOutPooledSessionRequest { ClientId = 100, DemoVariantId = 1, OwnerUserId = 5, LeadId = 777, DemoRoleCode = "TMS_TRAINER", }; var session = await svc.CheckOutPooledSessionAsync(req, CancellationToken.None); Assert.Equal(100, session.ClientId); Assert.Equal(777, session.LeadId); Assert.Equal("TMS_TRAINER", session.DemoRoleCode); Assert.NotNull(saved); Assert.Equal(100, saved!.ClientId); } [Fact] public async Task Test_CheckOutPooledSessionAsync_ProvisionsRealMuserInsidePoolDatabase() { var (svc, m) = BuildService(); var poolInstance = new DemoPoolInstanceDTO { DemoPoolInstanceId = 55, DemoVariantId = 1, SwClientDatabaseId = 900, LocalClientId = 1, PoolState = 0, }; m.DemoDAL.Setup(d => d.GetOneAvailablePoolInstanceAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(poolInstance); m.DemoDAL.Setup(d => d.TryClaimPoolInstanceAsync(55, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); m.DemoDAL.Setup(d => d.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); m.EntitlementUserDAL.Setup(u => u.GetUserByIdAsync(200, It.IsAny(), It.IsAny())) .ReturnsAsync(new EntitlementUserDTO { UserId = 200, UserCode = "JPROSPECT", UserName = "Jane Prospect", UserPrimaryMail = "jane@acme-prospect.test", UserPrimaryMobile = "9999999999", }); m.SqlWorkbenchClient.Setup(s => s.GetClientDatabaseAsync(900, It.IsAny(), It.IsAny())) .ReturnsAsync(new SwClientDatabaseInfoDTO { ClientDbId = 900, ClientDbCode = "DEMOMFG01", DatabaseName = "DemoMfg01Db", ServerConfigId = 42 }); LoginDTO? capturedRoleLookupLogin = null; m.ClientProvisioningDAL .Setup(d => d.GetRoleIdByCodeAsync("TMS_TRAINER", It.IsAny(), It.IsAny())) .Callback((_, login, _) => capturedRoleLookupLogin = login) .ReturnsAsync(9); ProvisionClientUserRequest? capturedProvisionRequest = null; LoginDTO? capturedProvisionLogin = null; m.ClientUserProvisioner .Setup(p => p.ProvisionAsync(It.IsAny(), It.IsAny())) .Callback((r, login) => { capturedProvisionRequest = r; capturedProvisionLogin = login; }) .ReturnsAsync(new ProvisionClientUserResult { UserId = 5001, UserGeneratedPassword = "Gen#Pwd1" }); var req = new CheckOutPooledSessionRequest { ClientId = 100, UserId = 200, DemoVariantId = 1, OwnerUserId = 5, DemoRoleCode = "TMS_TRAINER", }; var session = await svc.CheckOutPooledSessionAsync(req, CancellationToken.None); Assert.Equal(5001, session.PooledDemoUserId); Assert.NotNull(capturedProvisionRequest); Assert.Equal("JPROSPECT", capturedProvisionRequest!.UserCode); Assert.Equal("jane@acme-prospect.test", capturedProvisionRequest.UserPrimaryMail); Assert.Equal(9, capturedProvisionRequest.RoleId); // The MUSER must be created against the POOL instance's own database, not Entitlement's // own shared platform DB — confirmed via the exact connection-name convention // ClientDatabaseProvisioner.RegisterAndLinkServerConfigAsync registers (§49 Decision 8/9). Assert.Equal("DEMOMFG01-MAIN", capturedProvisionLogin!.DatabaseName); Assert.Equal(1, capturedProvisionLogin.ClientId); Assert.Equal("DEMOMFG01-MAIN", capturedRoleLookupLogin!.DatabaseName); } [Fact] public async Task Test_CheckOutPooledSessionAsync_MuserProvisioningFails_SessionStillCreatedWithNullPooledUserId() { var (svc, m) = BuildService(); var poolInstance = new DemoPoolInstanceDTO { DemoPoolInstanceId = 55, DemoVariantId = 1, SwClientDatabaseId = 900, LocalClientId = 1, PoolState = 0, }; m.DemoDAL.Setup(d => d.GetOneAvailablePoolInstanceAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(poolInstance); m.DemoDAL.Setup(d => d.TryClaimPoolInstanceAsync(55, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); m.DemoDAL.Setup(d => d.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); // No SqlWorkbenchClient/EntitlementUserDAL setup — GetClientDatabaseAsync returns null by // default (unmocked Moq call), so ProvisionPooledDemoUserAsync throws — the checkout // itself must still succeed, with PooledDemoUserId left null for manual follow-up. var req = new CheckOutPooledSessionRequest { ClientId = 100, UserId = 200, DemoVariantId = 1, OwnerUserId = 5 }; var session = await svc.CheckOutPooledSessionAsync(req, CancellationToken.None); Assert.Null(session.PooledDemoUserId); Assert.Equal(100, session.ClientId); } // ── StartDedicatedSessionAsync ────────────────────────────────────────── [Fact] public async Task Test_StartDedicatedSessionAsync_MissingClientOrUserId_Throws() { var (svc, m) = BuildService(); var req = new StartDedicatedSessionRequest { ClientId = 0, UserId = 0, OwnerUserId = 5 }; await Assert.ThrowsAsync(() => svc.StartDedicatedSessionAsync(req, CancellationToken.None)); m.Orchestrator.Verify(o => o.StartOnboardingAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_StartDedicatedSessionAsync_ReusesRegisteredIdentity_NeverCreatesSecondAccount() { var (svc, m) = BuildService(); m.Orchestrator .Setup(o => o.StartOnboardingAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new OnboardClientResultDTO { ClientId = 100, UserId = 200, TemporaryPassword = "" }); var req = new StartDedicatedSessionRequest { ClientId = 100, UserId = 200, OwnerUserId = 5, OnboardRequest = new OnboardClientRequestDTO { PlanId = 3 }, }; var session = await svc.StartDedicatedSessionAsync(req, CancellationToken.None); Assert.Equal(100, session.ClientId); // The whole point of Decision 9 — StartOnboardingAsync must be told to reuse the // already-registered identity, never create a second, duplicate MCLIENT/MUSER pair. m.Orchestrator.Verify(o => o.StartOnboardingAsync( It.Is(r => r.ExistingClientId == 100 && r.ExistingUserId == 200), It.IsAny()), Times.Once); // Never re-creates identity via ClientProvisioningBLL directly either. m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(It.IsAny(), It.IsAny()), Times.Never); } // ── ProcessExpiringAndExpiredSessionsAsync ─────────────────────────────── [Fact] public async Task Test_ProcessExpiring_ApproachingExpiry_MarksWarningAndQueuesEmail() { var (svc, m) = BuildService(); var session = new DemoSessionDTO { DemoSessionId = 700, ClientId = 100, SessionStatus = (byte)DemoSessionStatus.Active, ExpiresOn = DateTime.UtcNow.AddHours(5), }; m.DemoDAL.Setup(d => d.GetSessionsApproachingExpiryAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { session }); m.DemoDAL.Setup(d => d.GetExpiredSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); m.DemoDAL.Setup(d => d.MarkWarningIssuedAsync(700, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(1); m.EntitlementUserDAL.Setup(u => u.GetUserByClientIdAsync(100, It.IsAny(), It.IsAny())) .ReturnsAsync(new EntitlementUserDTO { UserId = 200, UserName = "Jane Prospect", UserPrimaryMail = "jane@acme-prospect.test" }); m.EventActionRunDAL.Setup(e => e.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(9001); string? capturedSendTo = null; m.ActionOutboxDAL .Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Callback((dto, _, _) => { var evt = System.Text.Json.JsonSerializer.Deserialize(dto.Payload); capturedSendTo = evt?.SendTo; }) .Returns(Task.CompletedTask); var processed = await svc.ProcessExpiringAndExpiredSessionsAsync(CancellationToken.None); Assert.Equal(1, processed); m.DemoDAL.Verify(d => d.MarkWarningIssuedAsync(700, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); Assert.Equal("jane@acme-prospect.test", capturedSendTo); } [Fact] public async Task Test_ProcessExpiring_WarningEmailQueueFails_StatusStillCommitted() { var (svc, m) = BuildService(); var session = new DemoSessionDTO { DemoSessionId = 701, ClientId = 100, SessionStatus = (byte)DemoSessionStatus.Active, ExpiresOn = DateTime.UtcNow.AddHours(5), }; m.DemoDAL.Setup(d => d.GetSessionsApproachingExpiryAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { session }); m.DemoDAL.Setup(d => d.GetExpiredSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); m.DemoDAL.Setup(d => d.MarkWarningIssuedAsync(701, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(1); // No EntitlementUserDAL setup — GetUserByClientIdAsync returns null, QueueWarningEmailAsync // returns early — the warning itself must still count as processed. var processed = await svc.ProcessExpiringAndExpiredSessionsAsync(CancellationToken.None); Assert.Equal(1, processed); } }