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);
}
}