using System.Data.Common; using Dapr.Client; using EntitlementBLL.Common; using EntitlementBLL.Exceptions; using EntitlementBLL.Implementations; using EntitlementBLL.Legal; using EntitlementBLL.Payment; using EntitlementDAL.DTOs; using EntitlementDAL.Interfaces; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.DTO.Framework.Login; 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 the Thread 8 fixes to SubscriptionService.ChangePlanAsync/RenewAsync: ChangePlanAsync now /// validates the target plan exists and is active (previously only the subscription itself was /// checked); RenewAsync now checks the subscription exists, captures a real userId, and writes an /// audit row (previously it silently no-op'd on a bad SubscriptionId and never audited). Both /// methods' event-publish is verified as a genuine IEventLogPublish call, not just a GB5Trace span. /// public class SubscriptionServiceTests { private static (SubscriptionService Svc, Mock SubscriptionDal, Mock PlanDal, Mock AuditDal, Mock PayClient, Mock AgreementConsentProvider, Mock ClientProvisioningDal) BuildService() { // Unconfigured GetClientJurisdictionCodeAsync => Moq returns Task.FromResult(null), // matching the "no jurisdiction captured yet" fallback-to-universal default every // pre-existing test implicitly relies on. var clientProvisioningDal = new Mock(); var subscriptionDal = new Mock(); var planDal = new Mock(); var auditDal = new Mock(); subscriptionDal .Setup(d => d.UpdatePlanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); subscriptionDal .Setup(d => d.UpdateRenewalAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); auditDal .Setup(d => d.InsertEntitlementAuditAsync(It.IsAny(), 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.CommitAsync(It.IsAny())).Returns(Task.CompletedTask); queryExecutor.Setup(q => q.RollbackAsync(It.IsAny())).Returns(Task.CompletedTask); 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 }); // EventLogPublish.PublishEventLogAsync is not virtual, so Moq can't intercept it — a real // instance is constructed instead, mirroring ClientAuthBLLTests' established pattern. var eventLog = new EventLogPublish(new DaprClientBuilder().Build(), NullLogger.Instance); var payClient = new Mock(); // Default: no SUBSCRIPTIONTERMS version currently configured — the gate must be a no-op // for every pre-existing test that never supplies AcceptedAgreementVersionIds. var agreementConsentProvider = new Mock(); agreementConsentProvider .Setup(a => a.GetApplicableAgreementsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AgreementBundleDTO()); var svc = new SubscriptionService( subscriptionDal.Object, planDal.Object, auditDal.Object, autoNumber, queryExecutor.Object, loginFactory.Object, eventLog, payClient.Object, agreementConsentProvider.Object, clientProvisioningDal.Object); return (svc, subscriptionDal, planDal, auditDal, payClient, agreementConsentProvider, clientProvisioningDal); } [Fact] public async Task Test_ChangePlanAsync_SubscriptionNotFound_Throws() { var (svc, subscriptionDal, _, _, _, _, _) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(999, It.IsAny(), It.IsAny())) .ReturnsAsync((SubscriptionDTO?)null); await Assert.ThrowsAsync( () => svc.ChangePlanAsync(999, 5, 1, CancellationToken.None)); } [Fact] public async Task Test_ChangePlanAsync_NewPlanNotFoundOrInactive_Throws_NoMutationApplied() { var (svc, subscriptionDal, planDal, _, _, _, _) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PlanId = 1 }); // Plan exists but is inactive (Status != 1) — this is the actual fix under test: previously // no plan-existence/active check existed at all. planDal.Setup(d => d.GetByIdAsync(5, It.IsAny(), It.IsAny())) .ReturnsAsync(new PlanDTO { PlanId = 5, Status = 0 }); await Assert.ThrowsAsync( () => svc.ChangePlanAsync(10, 5, 1, CancellationToken.None)); subscriptionDal.Verify(d => d.UpdatePlanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ChangePlanAsync_ValidNewPlan_UpdatesPlanAndWritesAudit() { var (svc, subscriptionDal, planDal, auditDal, _, _, _) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PlanId = 1 }); planDal.Setup(d => d.GetByIdAsync(5, It.IsAny(), It.IsAny())) .ReturnsAsync(new PlanDTO { PlanId = 5, Status = 1 }); await svc.ChangePlanAsync(10, 5, 77, CancellationToken.None); subscriptionDal.Verify(d => d.UpdatePlanAsync(10, 5, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); auditDal.Verify(d => d.InsertEntitlementAuditAsync( It.Is(a => a.SubscriptionId == 10 && a.Action == "PLAN_CHANGED" && a.CreatedById == 77), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Test_RenewAsync_SubscriptionNotFound_Throws() { var (svc, subscriptionDal, _, _, _, _, _) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(999, It.IsAny(), It.IsAny())) .ReturnsAsync((SubscriptionDTO?)null); await Assert.ThrowsAsync( () => svc.RenewAsync(999, DateTime.UtcNow.AddYears(1), 1, CancellationToken.None)); } [Fact] public async Task Test_RenewAsync_ValidSubscription_UpdatesRenewalAndWritesAudit() { var (svc, subscriptionDal, _, auditDal, _, _, _) = BuildService(); var newValidTill = DateTime.UtcNow.AddYears(1); subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, LicenseValidTill = DateTime.UtcNow }); await svc.RenewAsync(10, newValidTill, 77, CancellationToken.None); subscriptionDal.Verify(d => d.UpdateRenewalAsync(10, newValidTill, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); // Previously RenewAsync never wrote an audit row at all — this is the actual fix under test. auditDal.Verify(d => d.InsertEntitlementAuditAsync( It.Is(a => a.SubscriptionId == 10 && a.Action == "SUBSCRIPTION_RENEWED" && a.CreatedById == 77), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } // ── Entitlement↔Payment loose coupling (§9.1) ──────────────────────────── [Fact] public async Task Test_InitiatePaymentAsync_PlanHasNoPrice_ThrowsWithoutCallingPay() { var (svc, subscriptionDal, planDal, _, payClient, agreementConsentProvider, _) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PlanId = 5 }); planDal.Setup(d => d.GetByIdAsync(5, It.IsAny(), It.IsAny())) .ReturnsAsync(new PlanDTO { PlanId = 5, PlanCode = "ENT", PlanPrice = null }); await Assert.ThrowsAsync(() => svc.InitiatePaymentAsync( new EntitlementBLL.Interfaces.InitiatePaymentRequest { SubscriptionId = 10, GatewayCode = "razorpay" }, userId: 1, CancellationToken.None)); payClient.Verify(p => p.InitiatePaymentAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_InitiatePaymentAsync_ValidPlan_CallsPayAndRecordsPayOrderId() { var (svc, subscriptionDal, planDal, auditDal, payClient, agreementConsentProvider, _) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PlanId = 5 }); planDal.Setup(d => d.GetByIdAsync(5, It.IsAny(), It.IsAny())) .ReturnsAsync(new PlanDTO { PlanId = 5, PlanCode = "ENT", PlanPrice = 999m, PlanCurrency = "INR" }); payClient.Setup(p => p.InitiatePaymentAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PayInitiateResultDTO { PayOrderId = 555, CheckoutUrl = "https://pay.example/checkout/555" }); subscriptionDal.Setup(d => d.UpdatePayOrderIdAsync(10, 555, It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var result = await svc.InitiatePaymentAsync( new EntitlementBLL.Interfaces.InitiatePaymentRequest { SubscriptionId = 10, GatewayCode = "razorpay" }, userId: 77, CancellationToken.None); Assert.Equal(555, result.PayOrderId); payClient.Verify(p => p.InitiatePaymentAsync( It.Is(r => r.SubscriptionId == 10 && r.OrderAmt == 999m && r.OrderCurrency == "INR"), It.IsAny(), It.IsAny()), Times.Once); subscriptionDal.Verify(d => d.UpdatePayOrderIdAsync(10, 555, It.IsAny(), It.IsAny()), Times.Once); auditDal.Verify(d => d.InsertEntitlementAuditAsync( It.Is(a => a.SubscriptionId == 10 && a.Action == "PAYMENT_INITIATED"), It.IsAny(), null, It.IsAny()), Times.Once); } // ── Legal/Contract Agreement Consent — the "buying" gate (tracker §51.10) ─────────────── [Fact] public async Task Test_InitiatePaymentAsync_ResolvesRealJurisdictionCode_PassesItToBundleLookup() { var (svc, subscriptionDal, planDal, _, payClient, agreementConsentProvider, clientProvisioningDal) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PlanId = 5 }); planDal.Setup(d => d.GetByIdAsync(5, It.IsAny(), It.IsAny())) .ReturnsAsync(new PlanDTO { PlanId = 5, PlanCode = "ENT", PlanPrice = 999m, PlanCurrency = "INR" }); clientProvisioningDal .Setup(d => d.GetClientJurisdictionCodeAsync(42, It.IsAny(), It.IsAny())) .ReturnsAsync("IN"); agreementConsentProvider .Setup(a => a.GetApplicableAgreementsAsync("IN", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AgreementBundleDTO()); payClient.Setup(p => p.InitiatePaymentAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PayInitiateResultDTO { PayOrderId = 555, CheckoutUrl = "https://pay.example/checkout/555" }); await svc.InitiatePaymentAsync( new EntitlementBLL.Interfaces.InitiatePaymentRequest { SubscriptionId = 10, GatewayCode = "razorpay" }, userId: 1, CancellationToken.None); agreementConsentProvider.Verify(a => a.GetApplicableAgreementsAsync( "IN", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Test_InitiatePaymentAsync_RequiredSubscriptionTermsNotAccepted_ThrowsWithoutCallingPay() { var (svc, subscriptionDal, planDal, _, payClient, agreementConsentProvider, _) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PlanId = 5 }); planDal.Setup(d => d.GetByIdAsync(5, It.IsAny(), It.IsAny())) .ReturnsAsync(new PlanDTO { PlanId = 5, PlanCode = "ENT", PlanPrice = 999m, PlanCurrency = "INR" }); agreementConsentProvider .Setup(a => a.GetApplicableAgreementsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AgreementBundleDTO { Versions = { new AgreementVersionDTO { AgreementVersionId = 900 } } }); await Assert.ThrowsAsync(() => svc.InitiatePaymentAsync( new EntitlementBLL.Interfaces.InitiatePaymentRequest { SubscriptionId = 10, GatewayCode = "razorpay" }, userId: 1, CancellationToken.None)); payClient.Verify(p => p.InitiatePaymentAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); 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_InitiatePaymentAsync_RequiredSubscriptionTermsAccepted_RecordsConsentAndCallsPay() { var (svc, subscriptionDal, planDal, _, payClient, agreementConsentProvider, _) = BuildService(); subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PlanId = 5 }); planDal.Setup(d => d.GetByIdAsync(5, It.IsAny(), It.IsAny())) .ReturnsAsync(new PlanDTO { PlanId = 5, PlanCode = "ENT", PlanPrice = 999m, PlanCurrency = "INR" }); agreementConsentProvider .Setup(a => a.GetApplicableAgreementsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AgreementBundleDTO { Versions = { new AgreementVersionDTO { AgreementVersionId = 900 } } }); payClient.Setup(p => p.InitiatePaymentAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PayInitiateResultDTO { PayOrderId = 555, CheckoutUrl = "https://pay.example/checkout/555" }); var result = await svc.InitiatePaymentAsync( new EntitlementBLL.Interfaces.InitiatePaymentRequest { SubscriptionId = 10, GatewayCode = "razorpay", AcceptedAgreementVersionIds = new[] { 900 } }, userId: 1, CancellationToken.None); Assert.Equal(555, result.PayOrderId); agreementConsentProvider.Verify(a => a.RecordAcceptanceAsync( AgreementSubjectType.Customer, 42, It.Is(ids => ids.Length == 1 && ids[0] == 900), null, null, AgreementAcceptanceMethod.Clickwrap, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); payClient.Verify(p => p.InitiatePaymentAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Theory] [InlineData(1)] // Created [InlineData(2)] // Pending public async Task Test_HandlePaymentOutcomeAsync_NonTerminalStatus_IsNoOp(int orderStatus) { var (svc, subscriptionDal, _, _, _, _, _) = BuildService(); await svc.HandlePaymentOutcomeAsync(555, orderStatus, CancellationToken.None); subscriptionDal.Verify(d => d.GetByPayOrderIdAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_HandlePaymentOutcomeAsync_NoMatchingSubscription_IsNoOp() { var (svc, subscriptionDal, _, _, _, _, _) = BuildService(); subscriptionDal.Setup(d => d.GetByPayOrderIdAsync(555, It.IsAny(), It.IsAny())) .ReturnsAsync((SubscriptionDTO?)null); await svc.HandlePaymentOutcomeAsync(555, orderStatus: 3, CancellationToken.None); subscriptionDal.Verify(d => d.UpdateStatusAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_HandlePaymentOutcomeAsync_Paid_ActivatesSubscriptionAndAudits() { var (svc, subscriptionDal, _, auditDal, _, _, _) = BuildService(); subscriptionDal.Setup(d => d.GetByPayOrderIdAsync(555, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PayOrderId = 555, SubscriptionStatus = 0 }); // SetStatusAsync (called internally to activate) re-fetches the subscription by id itself. subscriptionDal.Setup(d => d.GetByIdAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PayOrderId = 555, SubscriptionStatus = 0 }); subscriptionDal.Setup(d => d.UpdateStatusAsync(10, 1, It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); subscriptionDal.Setup(d => d.UpdateClientSubscriptionStatusAsync(42, 1, It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); await svc.HandlePaymentOutcomeAsync(555, orderStatus: 3, CancellationToken.None); // 3 = Paid subscriptionDal.Verify(d => d.UpdateStatusAsync(10, 1, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); auditDal.Verify(d => d.InsertEntitlementAuditAsync( It.Is(a => a.SubscriptionId == 10 && a.Action == "PAYMENT_CONFIRMED"), It.IsAny(), null, It.IsAny()), Times.Once); } [Fact] public async Task Test_HandlePaymentOutcomeAsync_AlreadyActive_DoesNotCallSetStatusAgain() { var (svc, subscriptionDal, _, auditDal, _, _, _) = BuildService(); subscriptionDal.Setup(d => d.GetByPayOrderIdAsync(555, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PayOrderId = 555, SubscriptionStatus = 1 }); await svc.HandlePaymentOutcomeAsync(555, orderStatus: 3, CancellationToken.None); subscriptionDal.Verify(d => d.UpdateStatusAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); auditDal.Verify(d => d.InsertEntitlementAuditAsync( It.Is(a => a.Action == "PAYMENT_CONFIRMED"), It.IsAny(), null, It.IsAny()), Times.Once); // still audited, just no redundant status flip } [Fact] public async Task Test_HandlePaymentOutcomeAsync_Failed_DoesNotChangeStatus_ButAudits() { var (svc, subscriptionDal, _, auditDal, _, _, _) = BuildService(); subscriptionDal.Setup(d => d.GetByPayOrderIdAsync(555, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 10, ClientId = 42, PayOrderId = 555, SubscriptionStatus = 0 }); await svc.HandlePaymentOutcomeAsync(555, orderStatus: 5, CancellationToken.None); // 5 = Failed subscriptionDal.Verify(d => d.UpdateStatusAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); auditDal.Verify(d => d.InsertEntitlementAuditAsync( It.Is(a => a.SubscriptionId == 10 && a.Action == "PAYMENT_FAILED"), It.IsAny(), null, It.IsAny()), Times.Once); } }