Files
PnvPanel/backend/tests/PnvPanel.Application.Tests/Plans/ChangePlanCommandHandlerTests.cs
T
Leonid Pershin fad03c2834
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Enhance user plan management and update related endpoints
- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`.
- Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes.
- Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users.
- Removed deprecated role request approval endpoints from `AdminSupportEndpoints`.
- Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications.
- Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings.
- Enhanced billing request handling to accommodate plan changes instead of role changes.
- Updated various interfaces and command handlers to support new plan management features.
2026-07-23 22:52:20 +03:00

299 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<IIdentityService>();
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
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<ChangePlanCommandHandler>.Instance
);
[Fact]
public async Task Handle_WhenPlanNotFound_ReturnsNotFound()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).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<CancellationToken>()).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<CancellationToken>())
.Returns(Profile(userId, configQuota: 3, billingEnabled: false));
_identityService
.SetConfigQuotaAsync(userId, 8, null, Arg.Any<CancellationToken>())
.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<CancellationToken>());
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<CancellationToken>())
.Returns(Profile(userId, configQuota: 3, billingEnabled: true, billingPaidUntil: paidUntil));
_identityService
.SetConfigQuotaAsync(userId, 10, plan.Id, Arg.Any<CancellationToken>())
.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<CancellationToken>())
.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<CancellationToken>())
.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<CancellationToken>())
.Returns(Profile(userId, configQuota: 3, billingEnabled: true));
_identityService
.SetConfigQuotaAsync(userId, 1, null, Arg.Any<CancellationToken>())
.Returns(Result.Success());
_gateway
.RemoveClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
toRevoke.ClientExternalId,
toRevoke.Protocol,
Arg.Any<CancellationToken>()
)
.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<CancellationToken>())
.Returns(Profile(userId, configQuota: 3));
_identityService
.SetConfigQuotaAsync(userId, 1, null, Arg.Any<CancellationToken>())
.Returns(Result.Success());
_gateway
.RemoveClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
toRevoke.ClientExternalId,
toRevoke.Protocol,
Arg.Any<CancellationToken>()
)
.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<CancellationToken>());
await _notifier
.Received(1)
.NotifyConfigStatusChangedAsync(
userId,
toRevoke.Id,
ConfigStatus.Revoked,
Arg.Any<CancellationToken>()
);
}
}