using System.Data.Common;
using System.Text.Json;
using Dapr.Client;
using EntitlementBLL.Common;
using EntitlementBLL.Exceptions;
using EntitlementBLL.Interfaces;
using EntitlementBLL.Legal;
using EntitlementBLL.Onboarding;
using EntitlementBLL.Options;
using EntitlementDAL.DTOs;
using EntitlementDAL.Interfaces;
using GB5Shared.DTO.Framework.Login;
using GB5Shared.DTO.Framework.CommonConfig;
using GB5Shared.EventLogPublish;
using GB5Shared.GenerateAutoNumber;
using GB5Shared.QueryExecutor;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace EntitlementTests;
///
/// Covers ClientOnboardingOrchestratorBLL (Thread 6/§31 Phase 3): the
/// DefaultDbServerId/DbModelId/UpgradePackageId-not-configured guard; the happy path in both
/// HumanApprove (Approve/Execute never called) and AutoApprove (Approve+Execute called) modes;
/// ResumeAfterProvisioningExecutedAsync's three branches (Executed -> subscription created +
/// job Completed, Rejected -> job Failed, still-pending -> no-op); and that the reference-data
/// sync trigger is skipped (not thrown) when DefaultOnboardingSyncJobId is unset.
///
public class ClientOnboardingOrchestratorBLLTests
{
private sealed class Mocks
{
public Mock ClientProvisioningBLL = new();
public Mock SubscriptionService = new();
public Mock ProvisioningJobDAL = new();
public Mock SqlWorkbenchClient = new();
public Mock ReferenceDataSyncClient = new();
public Mock KeycloakProvisioningClient = new();
public Mock HubContext = new();
public Mock AgreementConsentProvider = new();
}
private static (ClientOnboardingOrchestratorBLL Svc, Mocks M) BuildService(
OnboardingApprovalMode approvalMode = OnboardingApprovalMode.HumanApprove,
int defaultDbServerId = 1, int defaultDbModelId = 1, int defaultUpgradePackageId = 1,
int? defaultOnboardingSyncJobId = null, int defaultTrialPlanId = -1,
bool wireHubContext = false)
{
var m = new Mocks();
m.ClientProvisioningBLL
.Setup(c => c.CreateClientAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new CreateClientResultDTO { ClientId = 100, UserId = 200, TemporaryPassword = "Temp#123" });
// Empty chain by default — every pre-existing test exercises the DefaultUpgradePackageId
// fallback path unchanged (no DbModel packages registered yet is the real starting state
// this whole engagement has been in). Chain-specific tests override this explicitly.
m.SqlWorkbenchClient
.Setup(s => s.GetProvisioningChainAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(Array.Empty());
m.SqlWorkbenchClient
.Setup(s => s.SaveClientDatabaseAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(11);
m.SqlWorkbenchClient
.Setup(s => s.SaveChangeRequestAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(22);
m.SqlWorkbenchClient
.Setup(s => s.SubmitChangeRequestAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
m.SqlWorkbenchClient
.Setup(s => s.ApproveChangeRequestAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
m.SqlWorkbenchClient
.Setup(s => s.ExecuteChangeRequestAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
m.SqlWorkbenchClient
.Setup(s => s.QueueChangeRequestExecutionAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
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 options = new Mock>();
options.Setup(o => o.Value).Returns(new OnboardingOptions
{
ApprovalMode = approvalMode,
DefaultDbServerId = defaultDbServerId,
DefaultDbModelId = defaultDbModelId,
DefaultUpgradePackageId = defaultUpgradePackageId,
DefaultOnboardingSyncJobId = defaultOnboardingSyncJobId,
DefaultTrialPlanId = defaultTrialPlanId,
});
var svc = new ClientOnboardingOrchestratorBLL(
m.ClientProvisioningBLL.Object, m.SubscriptionService.Object, m.ProvisioningJobDAL.Object,
m.SqlWorkbenchClient.Object, m.ReferenceDataSyncClient.Object, m.KeycloakProvisioningClient.Object,
queryExecutor.Object, autoNumber, loginFactory.Object, eventLog, options.Object,
m.AgreementConsentProvider.Object,
NullLogger.Instance,
wireHubContext ? m.HubContext.Object : null);
return (svc, m);
}
private static OnboardClientRequestDTO ValidRequest() => new()
{
ClientCode = "ACME",
ClientName = "Acme Corp",
AdminUserCode = "acme_admin",
AdminUserName = "Acme Admin",
AdminEmail = "admin@acme.test",
AdminMobile = "9999999999",
PlanId = 3,
};
// ── StartOnboardingAsync ────────────────────────────────────────────────
[Theory]
[InlineData(-1, 1, 1)]
[InlineData(1, -1, 1)]
[InlineData(1, 1, -1)]
public async Task Test_StartOnboardingAsync_OptionsNotConfigured_ThrowsAndNeverCreatesClient(
int dbServerId, int dbModelId, int upgradePackageId)
{
var (svc, m) = BuildService(defaultDbServerId: dbServerId, defaultDbModelId: dbModelId, defaultUpgradePackageId: upgradePackageId);
await Assert.ThrowsAsync(() => svc.StartOnboardingAsync(ValidRequest(), CancellationToken.None));
m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_StartOnboardingAsync_DeploymentTypeAndLeadId_ThreadedThroughToClientAndJob()
{
// Tracker §52 — the entdeploy authorization check itself lives in OnboardClient.cs (SL
// layer, needs the caller's real MenuRights); this BLL just needs to faithfully persist
// whatever DeploymentType the (already authorized) caller supplied, not silently default
// it back to SaaS. Tracker §52.3 — LeadId now has a real, dedicated MPROVISIONINGJOB.LEADID
// column (FK -> TLEAD, mirroring TDEMOSESSION.LeadId's own design) rather than only
// reaching the job's own serialized RequestPayload audit trail.
var (svc, m) = BuildService(approvalMode: OnboardingApprovalMode.HumanApprove);
var req = ValidRequest();
req.DeploymentType = 1; // OnPrem
req.LeadId = 777;
await svc.StartOnboardingAsync(req, CancellationToken.None);
m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(
It.Is(r => r.DeploymentType == 1), It.IsAny()), Times.Once);
m.ProvisioningJobDAL.Verify(j => j.SaveAsync(
It.Is(p => p.DeploymentType == 1 && p.LeadId == 777), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
}
[Fact]
public async Task Test_StartOnboardingAsync_HumanApprove_SubmitsButNeverApprovesOrExecutes()
{
var (svc, m) = BuildService(approvalMode: OnboardingApprovalMode.HumanApprove);
var result = await svc.StartOnboardingAsync(ValidRequest(), CancellationToken.None);
Assert.Equal(100, result.ClientId);
Assert.Equal(22, result.SwChangeRequestId);
Assert.Equal(11, result.SwClientDatabaseId);
m.SqlWorkbenchClient.Verify(s => s.SaveClientDatabaseAsync(
It.Is(r => r.DbServerId == 1 && r.DbModelId == 1 && r.TenantId == 100),
It.IsAny(), It.IsAny()), Times.Once);
m.SqlWorkbenchClient.Verify(s => s.SaveChangeRequestAsync(
It.Is(r => r.ClientDatabaseId == 11 && r.BaselineUpgradePackageId == 1),
It.IsAny(), It.IsAny()), Times.Once);
m.SqlWorkbenchClient.Verify(s => s.SubmitChangeRequestAsync(22, It.IsAny(), It.IsAny()), Times.Once);
m.SqlWorkbenchClient.Verify(s => s.ApproveChangeRequestAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
m.SqlWorkbenchClient.Verify(s => s.ExecuteChangeRequestAsync(
It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
m.ProvisioningJobDAL.Verify(j => j.SetSwLinkageAsync(
It.IsAny(), 22, 11, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
}
[Fact]
public async Task Test_StartOnboardingAsync_AutoApprove_ApprovesAndExecutes()
{
var (svc, m) = BuildService(approvalMode: OnboardingApprovalMode.AutoApprove);
await svc.StartOnboardingAsync(ValidRequest(), CancellationToken.None);
m.SqlWorkbenchClient.Verify(s => s.ApproveChangeRequestAsync(
22, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
// Queued for background execution (tracker §37/§43), not executed inline — a large
// baseline-package batch must never be tied to this request's own CancellationToken.
m.SqlWorkbenchClient.Verify(s => s.QueueChangeRequestExecutionAsync(
22, It.IsAny(), It.IsAny()), Times.Once);
m.SqlWorkbenchClient.Verify(s => s.ExecuteChangeRequestAsync(
It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
// ── StartOnboardingAsync — package-chain resolution ─────────────────────
[Fact]
public async Task Test_StartOnboardingAsync_ChainResolvedWithMultiplePackages_SubmitsOnlyTheFirstAndQueuesTheRest()
{
var (svc, m) = BuildService(approvalMode: OnboardingApprovalMode.HumanApprove);
m.SqlWorkbenchClient
.Setup(s => s.GetProvisioningChainAsync(1, It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new[]
{
new SwPackageChainItemDTO { PackageId = 5, PackageCode = "FULL_BASE_SCHEMA_V1", SequenceInChain = 1 },
new SwPackageChainItemDTO { PackageId = 6, PackageCode = "ENTITLEMENT_BASELINE_V1_PKG", SequenceInChain = 2 },
});
string? capturedPendingChain = null;
m.ProvisioningJobDAL
.Setup(j => j.SetSwLinkageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Callback(
(_, _, _, pending, _, _, _, _) => capturedPendingChain = pending)
.Returns(Task.CompletedTask);
var result = await svc.StartOnboardingAsync(ValidRequest(), CancellationToken.None);
// Only the FIRST package's CR gets submitted during StartOnboardingAsync itself.
m.SqlWorkbenchClient.Verify(s => s.SaveChangeRequestAsync(
It.Is(r => r.BaselineUpgradePackageId == 5),
It.IsAny(), It.IsAny()), Times.Once);
m.SqlWorkbenchClient.Verify(s => s.SaveChangeRequestAsync(
It.Is(r => r.BaselineUpgradePackageId == 6),
It.IsAny(), It.IsAny()), Times.Never);
Assert.NotNull(capturedPendingChain);
Assert.Contains("6", capturedPendingChain);
Assert.Contains("1 of 2", result.Message);
}
[Fact]
public async Task Test_StartOnboardingAsync_ChainResolvedWithOnePackage_NoPendingChainStored()
{
var (svc, m) = BuildService(approvalMode: OnboardingApprovalMode.HumanApprove);
m.SqlWorkbenchClient
.Setup(s => s.GetProvisioningChainAsync(1, It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { new SwPackageChainItemDTO { PackageId = 9, PackageCode = "SOLO_PKG", SequenceInChain = 1 } });
string? capturedPendingChain = "not-null-sentinel";
m.ProvisioningJobDAL
.Setup(j => j.SetSwLinkageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Callback(
(_, _, _, pending, _, _, _, _) => capturedPendingChain = pending)
.Returns(Task.CompletedTask);
await svc.StartOnboardingAsync(ValidRequest(), CancellationToken.None);
m.SqlWorkbenchClient.Verify(s => s.SaveChangeRequestAsync(
It.Is(r => r.BaselineUpgradePackageId == 9),
It.IsAny(), It.IsAny()), Times.Once);
Assert.Null(capturedPendingChain);
}
[Fact]
public async Task Test_StartOnboardingAsync_EmptyChainAndNoFallbackPackageId_Throws()
{
var (svc, m) = BuildService(defaultUpgradePackageId: -1);
await Assert.ThrowsAsync(() => svc.StartOnboardingAsync(ValidRequest(), CancellationToken.None));
m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(It.IsAny(), It.IsAny()), Times.Never);
}
// ── ResumeAfterProvisioningExecutedAsync — chain advance ────────────────
[Fact]
public async Task Test_Resume_CrExecuted_WithPendingChain_SubmitsNextPackage_DoesNotCreateSubscriptionYet()
{
var (svc, m) = BuildService();
var job = AwaitingJob();
job.PendingPackageChain = "[33]";
job.RequestPayload = JsonSerializer.Serialize(new OnboardClientRequestDTO { ClientName = "Acme Corp" });
m.ProvisioningJobDAL
.Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { job });
m.SqlWorkbenchClient
.Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny(), It.IsAny()))
.ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 22, CrStatus = 5, ClientDatabaseId = 11 });
m.SqlWorkbenchClient
.Setup(s => s.SaveChangeRequestAsync(It.Is(r => r.BaselineUpgradePackageId == 33),
It.IsAny(), It.IsAny()))
.ReturnsAsync(44);
await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None);
m.SqlWorkbenchClient.Verify(s => s.SaveChangeRequestAsync(
It.Is(r => r.BaselineUpgradePackageId == 33),
It.IsAny(), It.IsAny()), Times.Once);
m.ProvisioningJobDAL.Verify(j => j.SetSwLinkageAsync(
500, 44, 11, null, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
// Subscription is NOT created yet — still waiting on the second package's own CR.
m.SubscriptionService.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never);
m.ProvisioningJobDAL.Verify(j => j.MarkCompletedAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
// ── StartSelfServiceTrialAsync ──────────────────────────────────────────
private static SelfProvisionTrialRequestDTO ValidTrialRequest() => new()
{
CompanyName = "Acme Corp",
ContactName = "Jane Visitor",
Email = "jane@acme.test",
Mobile = "9999999999",
};
[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_StartSelfServiceTrialAsync_MissingField_ThrowsAndNeverCreatesClient(
string companyName, string contactName, string email, string mobile)
{
var (svc, m) = BuildService(defaultTrialPlanId: 42);
var req = new SelfProvisionTrialRequestDTO
{ CompanyName = companyName, ContactName = contactName, Email = email, Mobile = mobile };
await Assert.ThrowsAsync(() => svc.StartSelfServiceTrialAsync(req, CancellationToken.None));
m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_StartSelfServiceTrialAsync_InvalidEmail_ThrowsAndNeverCreatesClient()
{
var (svc, m) = BuildService(defaultTrialPlanId: 42);
var req = ValidTrialRequest();
req.Email = "not-an-email";
await Assert.ThrowsAsync(() => svc.StartSelfServiceTrialAsync(req, CancellationToken.None));
m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_StartSelfServiceTrialAsync_TrialPlanNotConfigured_ThrowsAndNeverCreatesClient()
{
var (svc, m) = BuildService(defaultTrialPlanId: -1);
await Assert.ThrowsAsync(() => svc.StartSelfServiceTrialAsync(ValidTrialRequest(), CancellationToken.None));
m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_StartSelfServiceTrialAsync_HappyPath_DerivesCodesAndFixesPlanAndTrialMode()
{
var (svc, m) = BuildService(defaultTrialPlanId: 42);
ProvisioningJobDTO? capturedJob = null;
m.ProvisioningJobDAL
.Setup(j => j.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Callback((dto, _, _, _) => capturedJob = dto)
.Returns(Task.CompletedTask);
var result = await svc.StartSelfServiceTrialAsync(ValidTrialRequest(), CancellationToken.None);
Assert.Equal(100, result.ClientId);
Assert.Equal("Temp#123", result.TemporaryPassword);
Assert.NotEqual(0, result.ProvisioningJobId);
m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(
It.Is(r =>
r.AdminEmail == "jane@acme.test" &&
r.AdminUserName == "Jane Visitor" &&
r.ClientName == "Acme Corp" &&
!string.IsNullOrWhiteSpace(r.ClientCode) &&
!string.IsNullOrWhiteSpace(r.AdminUserCode)),
It.IsAny()), Times.Once);
Assert.NotNull(capturedJob);
Assert.Equal(42, capturedJob!.PlanId);
Assert.Equal((byte)2, capturedJob.TrialMode); // Blank
// No AcceptedAgreementVersionIds supplied (ValidTrialRequest()'s default, an empty
// array) — Legal/Contract Agreement Consent (tracker §50/§51) must be fully skipped,
// not blocked or defaulted.
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_StartSelfServiceTrialAsync_WithAcceptedAgreements_RecordsConsentAgainstNewClientId()
{
var (svc, m) = BuildService(defaultTrialPlanId: 42);
var req = ValidTrialRequest();
req.AcceptedAgreementVersionIds = new[] { -1370000201, -1370000202 }; // the placeholder TOS+PrivacyPolicy versions
var result = await svc.StartSelfServiceTrialAsync(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);
}
[Fact]
public async Task Test_StartSelfServiceTrialAsync_ClientCodeCollision_RetriesThenSucceeds()
{
var (svc, m) = BuildService(defaultTrialPlanId: 42);
var attempt = 0;
m.ClientProvisioningBLL
.Setup(c => c.CreateClientAsync(It.IsAny(), It.IsAny()))
.Returns(() =>
{
attempt++;
if (attempt < 3) throw new ClientCodeAlreadyExistsException("COLLIDE");
return Task.FromResult(new CreateClientResultDTO { ClientId = 100, UserId = 200, TemporaryPassword = "Temp#123" });
});
var result = await svc.StartSelfServiceTrialAsync(ValidTrialRequest(), CancellationToken.None);
Assert.Equal(100, result.ClientId);
m.ClientProvisioningBLL.Verify(c => c.CreateClientAsync(It.IsAny(), It.IsAny()), Times.Exactly(3));
}
[Fact]
public async Task Test_StartSelfServiceTrialAsync_ClientCodeExhausted_ThrowsInvalidOperationException()
{
var (svc, m) = BuildService(defaultTrialPlanId: 42);
m.ClientProvisioningBLL
.Setup(c => c.CreateClientAsync(It.IsAny(), It.IsAny()))
.ThrowsAsync(new ClientCodeAlreadyExistsException("COLLIDE"));
await Assert.ThrowsAsync(() => svc.StartSelfServiceTrialAsync(ValidTrialRequest(), CancellationToken.None));
}
// ── GetTrialProvisioningStatusAsync ──────────────────────────────────────
private static ProvisioningJobDTO StatusJob(string adminEmail) => new()
{
ProvisioningJobId = 900,
ClientId = 100,
JobStatus = 1,
CurrentStep = "DB_PROVISIONING_CR_SUBMITTED",
RequestPayload = JsonSerializer.Serialize(new OnboardClientRequestDTO { AdminEmail = adminEmail }),
};
[Fact]
public async Task Test_GetTrialProvisioningStatusAsync_EmailMatches_ReturnsStatus()
{
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetByIdAsync(900, It.IsAny(), It.IsAny()))
.ReturnsAsync(StatusJob("jane@acme.test"));
var status = await svc.GetTrialProvisioningStatusAsync(900, "JANE@ACME.TEST", CancellationToken.None);
Assert.NotNull(status);
Assert.Equal((byte)1, status!.JobStatus);
Assert.Equal("DB_PROVISIONING_CR_SUBMITTED", status.CurrentStep);
Assert.False(status.HasError);
}
[Fact]
public async Task Test_GetTrialProvisioningStatusAsync_EmailMismatch_ReturnsNull()
{
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetByIdAsync(900, It.IsAny(), It.IsAny()))
.ReturnsAsync(StatusJob("jane@acme.test"));
var status = await svc.GetTrialProvisioningStatusAsync(900, "someone-else@example.com", CancellationToken.None);
Assert.Null(status);
}
[Fact]
public async Task Test_GetTrialProvisioningStatusAsync_JobNotFound_ReturnsNull()
{
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync((ProvisioningJobDTO?)null);
var status = await svc.GetTrialProvisioningStatusAsync(900, "jane@acme.test", CancellationToken.None);
Assert.Null(status);
}
// ── GetProvisioningJobByIdAsync (tracker §52.2) ─────────────────────────
[Fact]
public async Task Test_GetProvisioningJobByIdAsync_JobExists_ReturnsFullDto_NoEmailCheckNeeded()
{
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetByIdAsync(900, It.IsAny(), It.IsAny()))
.ReturnsAsync(StatusJob("jane@acme.test"));
// Unlike GetTrialProvisioningStatusAsync, no email is supplied at all — this is the
// internal-staff path, already [MenuRights]-authenticated, no shared-secret needed.
var job = await svc.GetProvisioningJobByIdAsync(900, CancellationToken.None);
Assert.NotNull(job);
Assert.Equal(900, job!.ProvisioningJobId);
Assert.Equal("DB_PROVISIONING_CR_SUBMITTED", job.CurrentStep);
}
[Fact]
public async Task Test_GetProvisioningJobByIdAsync_JobNotFound_ReturnsNull()
{
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync((ProvisioningJobDTO?)null);
var job = await svc.GetProvisioningJobByIdAsync(900, CancellationToken.None);
Assert.Null(job);
}
[Fact]
public async Task Test_GetProvisioningJobByIdAsync_InvalidId_ReturnsNullWithoutQuerying()
{
var (svc, m) = BuildService();
var job = await svc.GetProvisioningJobByIdAsync(0, CancellationToken.None);
Assert.Null(job);
m.ProvisioningJobDAL.Verify(j => j.GetByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
// ── ResumeAfterProvisioningExecutedAsync ────────────────────────────────
private static ProvisioningJobDTO AwaitingJob(int changeRequestId = 22) => new()
{
ProvisioningJobId = 500,
ClientId = 100,
PlanId = 3,
JobStatus = 1,
CurrentStep = "DB_PROVISIONING_CR_SUBMITTED",
CompletedSteps = "[\"CLIENT_IDENTITY_CREATED\",\"DB_PROVISIONING_CR_SUBMITTED\"]",
SwChangeRequestId = changeRequestId,
SwClientDatabaseId = 11,
};
[Fact]
public async Task Test_Resume_CrExecuted_CreatesSubscriptionAndMarksCompleted()
{
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { AwaitingJob() });
m.SqlWorkbenchClient
.Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny(), It.IsAny()))
.ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 22, CrStatus = 5, ClientDatabaseId = 11 });
await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None);
m.SubscriptionService.Verify(s => s.SaveAsync(
It.Is(r => r.ClientId == 100 && r.PlanId == 3), It.IsAny()), Times.Once);
m.ProvisioningJobDAL.Verify(j => j.MarkCompletedAsync(
500, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
m.ProvisioningJobDAL.Verify(j => j.MarkFailedAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
[Fact]
public async Task Test_Resume_CrExecuted_NoSyncJobConfigured_SkipsTriggerButStillCompletes()
{
var (svc, m) = BuildService(defaultOnboardingSyncJobId: null);
m.ProvisioningJobDAL
.Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { AwaitingJob() });
m.SqlWorkbenchClient
.Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny(), It.IsAny()))
.ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 22, CrStatus = 5, ClientDatabaseId = 11 });
await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None);
m.ReferenceDataSyncClient.Verify(r => r.TriggerSyncJobAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
m.ProvisioningJobDAL.Verify(j => j.MarkCompletedAsync(
500, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
}
[Fact]
public async Task Test_Resume_CrExecuted_SyncJobConfigured_TriggersIt()
{
var (svc, m) = BuildService(defaultOnboardingSyncJobId: 77);
m.ProvisioningJobDAL
.Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { AwaitingJob() });
m.SqlWorkbenchClient
.Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny(), It.IsAny()))
.ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 22, CrStatus = 5, ClientDatabaseId = 11 });
await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None);
m.ReferenceDataSyncClient.Verify(r => r.TriggerSyncJobAsync(77, It.IsAny(), It.IsAny()), Times.Once);
}
[Fact]
public async Task Test_Resume_CrRejected_MarksJobFailed()
{
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { AwaitingJob() });
m.SqlWorkbenchClient
.Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny(), It.IsAny()))
.ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 22, CrStatus = 4, ClientDatabaseId = 11 });
await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None);
m.ProvisioningJobDAL.Verify(j => j.MarkFailedAsync(
500, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
m.SubscriptionService.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_Resume_BackgroundExecutionFailed_MarksJobFailed_EvenThoughCrStatusStillApproved()
{
// Tracker §37/§43 — a CR queued via QueueChangeRequestExecutionAsync that fails inside
// SqlWorkbench's ChangeRequestExecutionWorkerJob never reaches CrStatus=Executed at all
// (ExecuteCoreAsync throws before that update), so CrStatus alone (still 3=Approved here)
// can't signal failure — only ExecutionStatus=4/Failed can.
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { AwaitingJob() });
m.SqlWorkbenchClient
.Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny(), It.IsAny()))
.ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 22, CrStatus = 3, ExecutionStatus = 4, ClientDatabaseId = 11 });
await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None);
m.ProvisioningJobDAL.Verify(j => j.MarkFailedAsync(
500, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
m.SubscriptionService.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_Resume_BackgroundExecutionStillRunning_DoesNotMarkFailed_KeepsWaiting()
{
var (svc, m) = BuildService();
m.ProvisioningJobDAL
.Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { AwaitingJob() });
m.SqlWorkbenchClient
.Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny