Files
PnvPanel/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs
T
Leonid Pershin 29291b5dec
CI / Backend (build + test) (push) Failing after 1m8s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Refactor billing configuration management to centralize expiration handling
- Updated `BillingConfigResumer` to manage billing expiration entirely on the application side, using `enable` as the sole control mechanism for client access.
- Changed all relevant methods to pass `DateTimeOffset.UnixEpoch` for `expiresAt`, ensuring the panel does not enforce expiration independently of our application logic.
- Modified `IXuiPanelGateway` interface to reflect the new expiration handling approach, clarifying the role of `expiresAt` in client management.
- Adjusted command handlers for creating and rotating VPN configurations to set `expiresAt` to `null`, preventing unintended expiration enforcement by the panel.
- Enhanced documentation to explain the new billing expiration management strategy and its implications for client configurations.
2026-07-22 23:43:45 +03:00

205 lines
7.3 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;
using NSubstitute;
using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Support;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Support;
public class ApproveExtensionRequestCommandHandlerTests
{
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 readonly ILogger<ApproveExtensionRequestCommandHandler> _logger = Substitute.For<
ILogger<ApproveExtensionRequestCommandHandler>
>();
private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
new(
userId,
"alice",
Guid.NewGuid(),
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: true,
BillingPaidUntil: paidUntil,
BillingSuspended: paidUntil is null
);
private ApproveExtensionRequestCommandHandler CreateHandler(
PnvPanel.Infrastructure.Persistence.AppDbContext dbContext,
Guid adminId
) =>
new(
dbContext,
_identityService,
_gateway,
_notifier,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin"),
_logger
);
[Fact]
public async Task Handle_WhenNoPriorPayment_ExtendsFromNowAndResolves()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var ticket = SupportTicket.CreateExtensionRequest(userId, 10);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, paidUntil: null));
_identityService
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
var handler = CreateHandler(dbContext, adminId);
var before = DateTimeOffset.UtcNow;
var result = await handler.Handle(
new ApproveExtensionRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(TicketStatus.Resolved, ticket.Status);
await _identityService
.Received(1)
.ExtendBillingPaidUntilAsync(
userId,
Arg.Is<DateTimeOffset>(d => d >= before.AddDays(10).AddMinutes(-1)),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_ResumesExpiredConfigs()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
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 config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
config.AssignRemoteClient("ext-1");
config.Suspend();
dbContext.Nodes.Add(node);
dbContext.Inbounds.Add(inbound);
dbContext.VpnConfigs.Add(config);
var ticket = SupportTicket.CreateExtensionRequest(userId, 5);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, paidUntil: null));
_identityService
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
_gateway
.UpdateClientAsync(
Arg.Any<Node>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<VpnProtocol>(),
Arg.Any<string>(),
Arg.Any<bool?>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<CancellationToken>()
)
.Returns(Result.Success());
var handler = CreateHandler(dbContext, adminId);
var result = await handler.Handle(
new ApproveExtensionRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(ConfigStatus.Active, config.Status);
Assert.NotNull(config.ExpiresAt);
// enable:true — истечение управляется полностью на нашей стороне, панельный expiresAt
// сбрасывается в "без срока" (см. BillingConfigResumer.NoExpiry), а не в реальный новый срок.
await _gateway
.Received(1)
.UpdateClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
"ext-1",
VpnProtocol.Vless,
Arg.Any<string>(),
true,
Arg.Is<DateTimeOffset?>(d => d == DateTimeOffset.UnixEpoch),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenTicketNotOpen_ReturnsNotOpen()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var ticket = SupportTicket.CreateExtensionRequest(userId, 5);
ticket.Close();
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = CreateHandler(dbContext, adminId);
var result = await handler.Handle(
new ApproveExtensionRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.NotOpen, result.Error);
}
[Fact]
public async Task Handle_WhenNotExtensionRequest_ReturnsNotExtensionRequest()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = CreateHandler(dbContext, adminId);
var result = await handler.Handle(
new ApproveExtensionRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.NotExtensionRequest, result.Error);
}
}