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(), It.IsAny())) .ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 22, CrStatus = 3, ExecutionStatus = 2 /* Running */, ClientDatabaseId = 11 }); await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None); m.ProvisioningJobDAL.Verify(j => j.MarkFailedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData((byte)1)] // Submitted [InlineData((byte)2)] // UnderReview [InlineData((byte)3)] // Approved public async Task Test_Resume_CrStillPending_NoOp(byte crStatus) { 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 = crStatus, ClientDatabaseId = 11 }); await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None); 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); m.ProvisioningJobDAL.Verify(j => j.MarkFailedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_Resume_OneJobThrows_OthersStillProcessed() { var (svc, m) = BuildService(); var failingJob = AwaitingJob(changeRequestId: 22); failingJob.ProvisioningJobId = 501; var okJob = AwaitingJob(changeRequestId: 33); okJob.ProvisioningJobId = 502; m.ProvisioningJobDAL .Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { failingJob, okJob }); m.SqlWorkbenchClient .Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SqlWorkbench unreachable")); m.SqlWorkbenchClient .Setup(s => s.GetChangeRequestStatusAsync(33, It.IsAny(), It.IsAny())) .ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 33, CrStatus = 5, ClientDatabaseId = 11 }); await svc.ResumeAfterProvisioningExecutedAsync(CancellationToken.None); m.ProvisioningJobDAL.Verify(j => j.MarkCompletedAsync( 502, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } // ── Tracker §37 Decision 2 — ProvisioningHub push wiring ────────────────── [Fact] public async Task Test_StartOnboarding_PushesStageChangedTwice_ClientIdentityThenDbProvisioningSubmitted() { var (svc, m) = BuildService(wireHubContext: true); await svc.StartOnboardingAsync(ValidRequest(), CancellationToken.None); m.HubContext.Verify(h => h.PushStageChangedAsync( 100, It.IsAny(), "CLIENT_IDENTITY_CREATED", It.IsAny(), It.IsAny()), Times.Once); m.HubContext.Verify(h => h.PushStageChangedAsync( 100, It.IsAny(), "DB_PROVISIONING_CR_SUBMITTED", It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Test_StartOnboarding_NoHubContextWired_DoesNotThrow() { // The default BuildService() (wireHubContext: false) already exercises this for every // other test in this file — this test just makes the guard's intent explicit. var (svc, _) = BuildService(); var result = await svc.StartOnboardingAsync(ValidRequest(), CancellationToken.None); Assert.Equal(100, result.ClientId); } [Fact] public async Task Test_PushDdlProgress_NoHubContextWired_IsNoOpDoesNotCallSqlWorkbench() { var (svc, m) = BuildService(); await svc.PushDdlProgressForAwaitingJobsAsync(CancellationToken.None); m.SqlWorkbenchClient.Verify(s => s.GetChangeRequestStatusAsync( It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_PushDdlProgress_RealScriptsTotal_PushesDdlProgressForThatJob() { var (svc, m) = BuildService(wireHubContext: true); var job = AwaitingJob(changeRequestId: 22); 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 = 3, ClientDatabaseId = 11, ScriptsDone = 25, ScriptsTotal = 100, }); await svc.PushDdlProgressForAwaitingJobsAsync(CancellationToken.None); m.HubContext.Verify(h => h.PushDdlProgressAsync( job.ClientId, job.ProvisioningJobId, 22, 25, 100, It.IsAny()), Times.Once); } [Fact] public async Task Test_PushDdlProgress_NoScriptsTotal_SkipsThatJob_NoPush() { var (svc, m) = BuildService(wireHubContext: true); var job = AwaitingJob(changeRequestId: 22); 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 = 3, ClientDatabaseId = 11 }); await svc.PushDdlProgressForAwaitingJobsAsync(CancellationToken.None); m.HubContext.Verify(h => h.PushDdlProgressAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_PushDdlProgress_OneJobThrows_OthersStillProcessed() { var (svc, m) = BuildService(wireHubContext: true); var failingJob = AwaitingJob(changeRequestId: 22); failingJob.ProvisioningJobId = 501; var okJob = AwaitingJob(changeRequestId: 33); okJob.ProvisioningJobId = 502; m.ProvisioningJobDAL .Setup(j => j.GetAwaitingDbProvisioningAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { failingJob, okJob }); m.SqlWorkbenchClient .Setup(s => s.GetChangeRequestStatusAsync(22, It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SqlWorkbench unreachable")); m.SqlWorkbenchClient .Setup(s => s.GetChangeRequestStatusAsync(33, It.IsAny(), It.IsAny())) .ReturnsAsync(new SwChangeRequestStatusDTO { ChangeRequestId = 33, CrStatus = 3, ClientDatabaseId = 11, ScriptsDone = 5, ScriptsTotal = 10, }); await svc.PushDdlProgressForAwaitingJobsAsync(CancellationToken.None); m.HubContext.Verify(h => h.PushDdlProgressAsync( okJob.ClientId, 502, 33, 5, 10, It.IsAny()), Times.Once); } }