Enhance user plan management and update related endpoints
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- 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.
This commit is contained in:
Leonid Pershin
2026-07-23 22:52:20 +03:00
parent 2c5b730500
commit fad03c2834
152 changed files with 4060 additions and 2240 deletions
@@ -23,7 +23,8 @@ public class AddTicketCommentCommandHandlerTests
role,
IsActivated: true,
IsBlocked: false,
MaxConfigs: 3,
ConfigQuota: 3,
PlanId: null,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "token",
BillingEnabled: false,
@@ -23,7 +23,8 @@ public class CreateExtensionRequestTicketCommandHandlerTests
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: billingEnabled,
@@ -1,133 +0,0 @@
using NSubstitute;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Support;
using PnvPanel.Application.Support.CreateRoleRequest;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Support;
using Xunit;
namespace PnvPanel.Application.Tests.Support;
public class CreateRoleRequestTicketCommandHandlerTests
{
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
[Fact]
public async Task Handle_ForExistingRole_CreatesTicket()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 5, 2, false, false) });
var handler = new CreateRoleRequestTicketCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle(
new CreateRoleRequestTicketCommand(roleId, null, null, null, "нужно больше конфигов"),
CancellationToken.None
);
await dbContext.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(roleId, result.Value.RequestedRoleId);
Assert.Equal("premium", result.Value.RequestedRoleName);
await _telegramNotifier
.Received(1)
.NotifyAdminsRoleRequestCreatedAsync(
Arg.Any<Guid>(),
"alice",
"premium",
"нужно больше конфигов",
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_ForAdminRole_ReturnsForbidden()
{
using var dbContext = InMemoryDbContextFactory.Create();
var roleId = Guid.NewGuid();
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "admin", -1, -1, true, false) });
var handler = new CreateRoleRequestTicketCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle(
new CreateRoleRequestTicketCommand(roleId, null, null, null, "хочу быть админом"),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.CannotRequestAdminRole, result.Error);
}
[Fact]
public async Task Handle_ForNewRole_SetsProposedFields()
{
using var dbContext = InMemoryDbContextFactory.Create();
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "bob");
var handler = new CreateRoleRequestTicketCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle(
new CreateRoleRequestTicketCommand(null, "custom", 10, 4, "нужна кастомная роль"),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal("custom", result.Value.ProposedRoleName);
Assert.Equal(10, result.Value.ProposedMaxConfigs);
Assert.Equal(4, result.Value.ProposedMaxIpLimit);
}
[Fact]
public async Task Handle_WhenPendingRoleRequestExists_ReturnsConflict()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
dbContext.SupportTickets.Add(SupportTicket.CreateRoleRequestForNewRole(userId, "x", 1, 1));
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(userId);
var handler = new CreateRoleRequestTicketCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle(
new CreateRoleRequestTicketCommand(null, "y", 2, 2, "ещё заявка"),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.RoleRequestAlreadyPending, result.Error);
}
}
@@ -1,97 +0,0 @@
using NSubstitute;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Support.ListSelectableRoles;
using PnvPanel.Application.Tests.TestSupport;
using Xunit;
namespace PnvPanel.Application.Tests.Support.ListSelectableRoles;
public class ListSelectableRolesQueryHandlerTests
{
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private static CurrentUserProfile Profile(Guid userId, Guid roleId, string role) =>
new(
userId,
"user",
roleId,
role,
IsActivated: true,
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: 1,
SubscriptionToken: "token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
[Fact]
public async Task Handle_ExcludesAdminAndCurrentUserRole()
{
var userId = Guid.NewGuid();
var currentRoleId = Guid.NewGuid();
var extendedRoleId = Guid.NewGuid();
var adminRoleId = Guid.NewGuid();
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(currentRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false),
new(extendedRoleId, "extended", 10, 5, IsSystem: false, BillingEnabled: false),
}
);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, currentRoleId, "user"));
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
var handler = new ListSelectableRolesQueryHandler(
_roleService,
_identityService,
currentUser
);
var result = await handler.Handle(new ListSelectableRolesQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(["extended"], result.Value.Select(r => r.Name));
}
[Fact]
public async Task Handle_WhenProfileMissing_StillExcludesAdmin()
{
var userId = Guid.NewGuid();
var userRoleId = Guid.NewGuid();
var adminRoleId = Guid.NewGuid();
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(userRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false),
}
);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns((CurrentUserProfile?)null);
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
var handler = new ListSelectableRolesQueryHandler(
_roleService,
_identityService,
currentUser
);
var result = await handler.Handle(new ListSelectableRolesQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(["user"], result.Value.Select(r => r.Name));
}
}
@@ -85,13 +85,13 @@ public class ReopenTicketCommandHandlerTests
}
[Fact]
public async Task Handle_WhenRoleRequestResolved_ReturnsOnlyBugReportCanBeReopened()
public async Task Handle_WhenExtensionRequestResolved_ReturnsOnlyBugReportCanBeReopened()
{
// approve/reject — единственный способ решить заявку на роль/продление (см. CLAUDE.md);
// Reopen для них запрещён даже если тикет каким-то образом оказался Resolved.
// approve/reject — единственный способ решить заявку на продление (см. CLAUDE.md);
// Reopen для неё запрещён даже если тикет каким-то образом оказался Resolved.
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, Guid.NewGuid());
var ticket = SupportTicket.CreateExtensionRequest(userId, 14);
ticket.Resolve();
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);