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