using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Models; using PnvPanel.Application.Plans; using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Domain.Billing; using PnvPanel.Domain.Configs; using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Plans; using PnvPanel.Domain.Pricing; using Xunit; namespace PnvPanel.Application.Tests.Plans; public class ChangePlanCommandHandlerTests { private readonly IIdentityService _identityService = Substitute.For(); private readonly IXuiPanelGateway _gateway = Substitute.For(); private readonly IRealtimeNotifier _notifier = Substitute.For(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); private static CurrentUserProfile Profile( Guid userId, int configQuota = 3, bool billingEnabled = false, DateTimeOffset? billingPaidUntil = null ) => new( userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, ConfigQuota: configQuota, PlanId: null, MaxIpLimit: 2, SubscriptionToken: "sub-token", BillingEnabled: billingEnabled, BillingPaidUntil: billingPaidUntil, BillingSuspended: false ); private ChangePlanCommandHandler CreateHandler(IAppDbContext dbContext, Guid userId) => new( dbContext, _identityService, _gateway, _notifier, _telegramNotifier, FakeCurrentUser.Authenticated(userId), NullLogger.Instance ); [Fact] public async Task Handle_WhenPlanNotFound_ReturnsNotFound() { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); _identityService.GetProfileAsync(userId, Arg.Any()).Returns(Profile(userId)); var result = await CreateHandler(dbContext, userId) .Handle(new ChangePlanCommand(Guid.NewGuid(), null, []), CancellationToken.None); Assert.False(result.IsSuccess); Assert.Equal(PlanErrors.NotFound, result.Error); } [Fact] public async Task Handle_WhenPlanDisabled_ReturnsDisabled() { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); var plan = Plan.Create("Скрытый", 6, 0); plan.Update(plan.Name, plan.ConfigCount, plan.SortOrder, isEnabled: false); dbContext.Plans.Add(plan); await dbContext.SaveChangesAsync(CancellationToken.None); _identityService.GetProfileAsync(userId, Arg.Any()).Returns(Profile(userId)); var result = await CreateHandler(dbContext, userId) .Handle(new ChangePlanCommand(plan.Id, null, []), CancellationToken.None); Assert.False(result.IsSuccess); Assert.Equal(PlanErrors.Disabled, result.Error); } [Fact] public async Task Handle_WithCustomConfigCount_UpdatesQuotaImmediatelyWithoutBilling() { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); _identityService .GetProfileAsync(userId, Arg.Any()) .Returns(Profile(userId, configQuota: 3, billingEnabled: false)); _identityService .SetConfigQuotaAsync(userId, 8, null, Arg.Any()) .Returns(Result.Success()); var result = await CreateHandler(dbContext, userId) .Handle(new ChangePlanCommand(null, 8, []), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(8, result.Value.ConfigQuota); Assert.Null(result.Value.PlanId); Assert.Null(result.Value.TopUpAmount); await _identityService.Received(1).SetConfigQuotaAsync(userId, 8, null, Arg.Any()); Assert.Empty(dbContext.PaymentRequests); } [Fact] public async Task Handle_WhenUpgradingWithBillingAndActivePaidPeriod_CreatesTopUp() { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); var plan = Plan.Create("Плюс", 10, 0); dbContext.Plans.Add(plan); var pricing = PricingSettings.CreateDefault(); pricing.Update(200, 180, 160); dbContext.PricingSettings.Add(pricing); await dbContext.SaveChangesAsync(CancellationToken.None); var paidUntil = DateTimeOffset.UtcNow.AddDays(30); _identityService .GetProfileAsync(userId, Arg.Any()) .Returns(Profile(userId, configQuota: 3, billingEnabled: true, billingPaidUntil: paidUntil)); _identityService .SetConfigQuotaAsync(userId, 10, plan.Id, Arg.Any()) .Returns(Result.Success()); var result = await CreateHandler(dbContext, userId) .Handle(new ChangePlanCommand(plan.Id, null, []), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(10, result.Value.ConfigQuota); Assert.Equal(plan.Id, result.Value.PlanId); Assert.NotNull(result.Value.TopUpAmount); var topUp = Assert.Single(dbContext.PaymentRequests); Assert.Equal(PaymentRequestKind.PlanChangeTopUp, topUp.Kind); Assert.Equal(result.Value.TopUpAmount, topUp.AmountSnapshot); } [Fact] public async Task Handle_WhenDowngradeWithoutEnoughConfigIdsToRevoke_ReturnsMustSelectConfigsToRevoke() { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); dbContext.Inbounds.Add(inbound); dbContext.VpnConfigs.AddRange( VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "a"), VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "b"), VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "c") ); await dbContext.SaveChangesAsync(CancellationToken.None); _identityService .GetProfileAsync(userId, Arg.Any()) .Returns(Profile(userId, configQuota: 3)); var result = await CreateHandler(dbContext, userId) .Handle(new ChangePlanCommand(null, 1, []), CancellationToken.None); Assert.False(result.IsSuccess); Assert.Equal(PlanErrors.MustSelectConfigsToRevoke(2), result.Error); } [Fact] public async Task Handle_WhenDowngradeWithOnlyExpiredConfigs_StillRequiresSelectingThem() { // Все конфиги приостановлены за неуплату (Expired) — ни одного Active. Если считать // excess только по Active, форма подумает, что отзывать нечего, а BillingConfigResumer // потом молча вернёт все три в Active при следующей оплате — мимо новой квоты. using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); dbContext.Inbounds.Add(inbound); var configs = new[] { VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "a"), VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "b"), VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "c"), }; foreach (var c in configs) c.Suspend(); dbContext.VpnConfigs.AddRange(configs); await dbContext.SaveChangesAsync(CancellationToken.None); _identityService .GetProfileAsync(userId, Arg.Any()) .Returns(Profile(userId, configQuota: 3, billingEnabled: true)); var result = await CreateHandler(dbContext, userId) .Handle(new ChangePlanCommand(null, 1, []), CancellationToken.None); Assert.False(result.IsSuccess); Assert.Equal(PlanErrors.MustSelectConfigsToRevoke(2), result.Error); } [Fact] public async Task Handle_WhenDowngradeSelectingExpiredConfig_RevokesIt() { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); var node = Node.Register( "node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null ); var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); var toKeep = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "keep"); var toRevoke = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "revoke"); toRevoke.Suspend(); dbContext.Nodes.Add(node); dbContext.Inbounds.Add(inbound); dbContext.VpnConfigs.AddRange(toKeep, toRevoke); await dbContext.SaveChangesAsync(CancellationToken.None); _identityService .GetProfileAsync(userId, Arg.Any()) .Returns(Profile(userId, configQuota: 3, billingEnabled: true)); _identityService .SetConfigQuotaAsync(userId, 1, null, Arg.Any()) .Returns(Result.Success()); _gateway .RemoveClientAsync( Arg.Any(), inbound.RemoteInboundId, toRevoke.ClientExternalId, toRevoke.Protocol, Arg.Any() ) .Returns(Result.Success()); var result = await CreateHandler(dbContext, userId) .Handle(new ChangePlanCommand(null, 1, [toRevoke.Id]), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(ConfigStatus.Revoked, toRevoke.Status); Assert.Equal(ConfigStatus.Active, toKeep.Status); } [Fact] public async Task Handle_WhenDowngradeWithCorrectConfigIds_RevokesThemAndUpdatesQuota() { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); var node = Node.Register( "node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null ); var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); var toKeep = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "keep"); var toRevoke = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "revoke"); dbContext.Nodes.Add(node); dbContext.Inbounds.Add(inbound); dbContext.VpnConfigs.AddRange(toKeep, toRevoke); await dbContext.SaveChangesAsync(CancellationToken.None); _identityService .GetProfileAsync(userId, Arg.Any()) .Returns(Profile(userId, configQuota: 3)); _identityService .SetConfigQuotaAsync(userId, 1, null, Arg.Any()) .Returns(Result.Success()); _gateway .RemoveClientAsync( Arg.Any(), inbound.RemoteInboundId, toRevoke.ClientExternalId, toRevoke.Protocol, Arg.Any() ) .Returns(Result.Success()); var result = await CreateHandler(dbContext, userId) .Handle(new ChangePlanCommand(null, 1, [toRevoke.Id]), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(ConfigStatus.Revoked, toRevoke.Status); Assert.Equal(ConfigStatus.Active, toKeep.Status); await _identityService.Received(1).SetConfigQuotaAsync(userId, 1, null, Arg.Any()); await _notifier .Received(1) .NotifyConfigStatusChangedAsync( userId, toRevoke.Id, ConfigStatus.Revoked, Arg.Any() ); } }