Enhance admin endpoints and queries for improved filtering and management
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- Updated `ListPaymentRequestsQuery` to include `Kind` and `Search` parameters for better filtering of payment requests.
- Enhanced `ListAuditLogsQuery` to support additional filters: `Source`, `TargetType`, and `Action`, improving audit log retrieval.
- Modified `ListUsersQuery` to accept new filters: `RoleId`, `IsActivated`, `IsBlocked`, and `BillingExpired`, allowing for more granular user management.
- Introduced `DeleteInbound` endpoint to allow deletion of inbounds that are not currently available, enhancing inbound management capabilities.
- Updated frontend API calls to reflect new query parameters and support for additional filtering options in the admin interface.
- Revised API documentation to include new parameters and endpoint functionalities for better clarity and usage guidance.
This commit is contained in:
Leonid Pershin
2026-07-20 10:35:48 +03:00
parent e19860ba46
commit 33ad98cf62
37 changed files with 1115 additions and 124 deletions
@@ -53,7 +53,13 @@ public static class AdminBillingEndpoints
CancellationToken cancellationToken
)
{
var query = new ListPaymentRequestsQuery(request.Status, request.Page, request.PageSize);
var query = new ListPaymentRequestsQuery(
request.Status,
request.Kind,
request.Search,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
@@ -98,6 +104,8 @@ public static class AdminBillingEndpoints
public sealed record ListPaymentRequestsRequest(
PaymentRequestStatus? Status,
PaymentRequestKind? Kind,
string? Search,
int Page = 1,
int PageSize = 20
);
@@ -3,6 +3,7 @@ using PnvPanel.Application.Admin.Audit;
using PnvPanel.Application.Admin.Stats;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
@@ -30,11 +31,20 @@ public static class AdminStatsEndpoints
private static async Task<IResult> GetAudit(
int page,
int pageSize,
AuditSource? source,
string? targetType,
string? action,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize);
var query = new ListAuditLogsQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 50 : pageSize,
source,
targetType,
action
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
@@ -6,6 +6,7 @@ using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Configs;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
@@ -44,11 +45,23 @@ public static class AdminUserEndpoints
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isActivated,
bool? isBlocked,
bool? billingExpired,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search);
var query = new ListUsersQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
search,
roleId,
isActivated,
isBlocked,
billingExpired
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
@@ -112,6 +125,8 @@ public static class AdminUserEndpoints
int pageSize,
string? search,
ConfigStatus? status,
VpnProtocol? protocol,
Guid? nodeId,
ISender sender,
CancellationToken cancellationToken
)
@@ -120,7 +135,9 @@ public static class AdminUserEndpoints
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
search,
status
status,
protocol,
nodeId
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
@@ -15,6 +15,7 @@ public static class InboundEndpoints
admin.MapGet("", ListInbounds).Produces<IReadOnlyList<InboundDto>>();
admin.MapPut("/{id:guid}/publish", PublishInbound).Produces<InboundDto>();
admin.MapDelete("/{id:guid}", DeleteInbound);
return app;
}
@@ -45,6 +46,16 @@ public static class InboundEndpoints
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteInbound(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteInboundCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record PublishInboundBody(
@@ -4,8 +4,13 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Audit;
public sealed record ListAuditLogsQuery(int Page, int PageSize)
: IQuery<Result<PagedList<AuditLogDto>>>;
public sealed record ListAuditLogsQuery(
int Page,
int PageSize,
AuditSource? Source,
string? TargetType,
string? Action
) : IQuery<Result<PagedList<AuditLogDto>>>;
public sealed record AuditLogDto(
long Id,
@@ -16,8 +16,16 @@ public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
var result = await dbContext
.AuditLogs.AsNoTracking()
var logsQuery = dbContext.AuditLogs.AsNoTracking();
if (query.Source is { } source)
logsQuery = logsQuery.Where(a => a.Source == source);
if (!string.IsNullOrWhiteSpace(query.TargetType))
logsQuery = logsQuery.Where(a => a.TargetType == query.TargetType);
if (!string.IsNullOrWhiteSpace(query.Action))
logsQuery = logsQuery.Where(a => a.Action.Contains(query.Action));
var result = await logsQuery
.OrderByDescending(a => a.CreatedAt)
.Select(a => new AuditLogDto(
a.Id,
@@ -4,5 +4,10 @@ using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed record ListPaymentRequestsQuery(PaymentRequestStatus? StatusFilter, int Page, int PageSize)
: IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
public sealed record ListPaymentRequestsQuery(
PaymentRequestStatus? StatusFilter,
PaymentRequestKind? KindFilter,
string? Search,
int Page,
int PageSize
) : IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
@@ -21,6 +21,18 @@ public sealed class ListPaymentRequestsQueryHandler(
var requestsQuery = dbContext.PaymentRequests.AsNoTracking();
if (query.StatusFilter is { } status)
requestsQuery = requestsQuery.Where(r => r.Status == status);
if (query.KindFilter is { } kind)
requestsQuery = requestsQuery.Where(r => r.Kind == kind);
if (!string.IsNullOrWhiteSpace(query.Search))
{
// PaymentRequest хранит только UserId — резолвим совпадающих пользователей ДО пагинации
// (иначе "поиск по имени" фильтровал бы уже отобранную страницу, а не весь набор).
var matchingUserIds = await identityService.FindUserIdsByUserNameAsync(
query.Search.Trim(),
cancellationToken
);
requestsQuery = requestsQuery.Where(r => matchingUserIds.Contains(r.UserId));
}
var page1 = await requestsQuery
.OrderByDescending(r => r.CreatedAt)
@@ -2,14 +2,18 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Admin.Configs;
/// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).</summary>
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).
/// <paramref name="NodeId"/> фильтрует по ноде инбаунда конфига (JOIN Inbounds).</summary>
public sealed record ListAllConfigsQuery(
int Page,
int PageSize,
string? Search,
ConfigStatus? Status
ConfigStatus? Status,
VpnProtocol? Protocol,
Guid? NodeId
) : IQuery<Result<PagedList<AdminVpnConfigDto>>>;
@@ -23,6 +23,19 @@ public sealed class ListAllConfigsQueryHandler(
if (query.Status is { } status)
configsQuery = configsQuery.Where(c => c.Status == status);
if (query.Protocol is { } protocol)
configsQuery = configsQuery.Where(c => c.Protocol == protocol);
if (query.NodeId is { } nodeId)
{
var nodeInboundIds = await dbContext
.Inbounds.AsNoTracking()
.Where(i => i.NodeId == nodeId)
.Select(i => i.Id)
.ToListAsync(cancellationToken);
configsQuery = configsQuery.Where(c => nodeInboundIds.Contains(c.InboundId));
}
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
/// <summary>Force-удаление недоступного на панели инбаунда вместе с каскадным отзывом ещё живых
/// конфигов на нём — см. DeleteInboundCommandHandler.</summary>
public sealed record DeleteInboundCommand(Guid InboundId) : ICommand<Result>;
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Admin.Inbounds;
/// <summary>Удаляет инбаунд, которого больше нет на панели (см. SyncNodeCommandHandler —
/// автоматически он не удаляется, пока по нему есть VpnConfig, чтобы не потерять ссылку на историю).
/// Разрешено только для `IsAvailable == false` — живой, всё ещё синхронизируемый инбаунд через этот
/// путь не удалить. Перед удалением каскадно отзывает все ещё не-Revoked конфиги на нём: панельный
/// клиент всё равно недостижим (инбаунда для него на 3x-ui уже нет), поэтому Revoke — чисто локальная
/// операция, без вызова гейтвея (см. RevokeVpnConfigCommandHandler для того же паттерна). Уже
/// Revoked-конфиги при этом остаются в БД с InboundId, указывающим на удалённую запись — это
/// осознанный компромисс (см. domain-model.md): нигде в пользовательских списках Revoked-конфиги не
/// показываются, а в админском списке отсутствующий инбаунд отображается как "?".</summary>
public sealed class DeleteInboundCommandHandler(
IAppDbContext dbContext,
IRealtimeNotifier notifier,
ICurrentUser currentUser
) : ICommandHandler<DeleteInboundCommand, Result>
{
public async Task<Result> Handle(DeleteInboundCommand command, CancellationToken cancellationToken)
{
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
i => i.Id == command.InboundId,
cancellationToken
);
if (inbound is null)
return Result.Failure(InboundErrors.NotFound);
if (inbound.IsAvailable)
return Result.Failure(InboundErrors.StillAvailable);
var configs = await dbContext
.VpnConfigs.Where(c => c.InboundId == inbound.Id && c.Status != ConfigStatus.Revoked)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
config.Revoke();
await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
}
dbContext.Inbounds.Remove(inbound);
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"InboundDeleted",
"Inbound",
inbound.Id.ToString(),
metadata: configs.Count > 0 ? $"{{\"revokedConfigs\":{configs.Count}}}" : null,
AuditSource.Web
)
);
return Result.Success();
}
}
@@ -8,4 +8,9 @@ public static class InboundErrors
"Inbounds.NotFound",
"Inbound не найден."
);
public static readonly Error StillAvailable = Error.Validation(
"Inbounds.StillAvailable",
"Inbound всё ещё существует на панели — удалить можно только недоступные (IsAvailable=false)."
);
}
@@ -4,5 +4,12 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Users;
public sealed record ListUsersQuery(int Page, int PageSize, string? Search)
: IQuery<Result<PagedList<UserSummaryDto>>>;
public sealed record ListUsersQuery(
int Page,
int PageSize,
string? Search,
Guid? RoleId,
bool? IsActivated,
bool? IsBlocked,
bool? BillingExpired
) : IQuery<Result<PagedList<UserSummaryDto>>>;
@@ -21,6 +21,10 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService, IApp
page,
pageSize,
query.Search,
query.RoleId,
query.IsActivated,
query.IsBlocked,
query.BillingExpired,
cancellationToken
);
@@ -92,6 +92,14 @@ public interface IIdentityService
CancellationToken cancellationToken
);
/// <summary>Для фильтрации списков по владельцу там, где сущность хранит только UserId (см.
/// ListPaymentRequestsQueryHandler) — резолвится ДО пагинации, а не после (в отличие от
/// GetUserNamesAsync, который просто подставляет имена в уже отобранную страницу).</summary>
Task<IReadOnlyList<Guid>> FindUserIdsByUserNameAsync(
string search,
CancellationToken cancellationToken
);
/// <summary>Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной.</summary>
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
@@ -117,6 +125,10 @@ public interface IIdentityService
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isActivated,
bool? isBlocked,
bool? billingExpired,
CancellationToken cancellationToken
);
@@ -4,13 +4,15 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.Identity;
internal sealed class IdentityService(
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
RoleManager<AppRole> roleManager
RoleManager<AppRole> roleManager,
AppDbContext dbContext
) : IIdentityService
{
public async Task<Result<Guid>> CreateUserAsync(
@@ -176,6 +178,17 @@ internal sealed class IdentityService(
.ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken);
}
public async Task<IReadOnlyList<Guid>> FindUserIdsByUserNameAsync(
string search,
CancellationToken cancellationToken
)
{
return await userManager
.Users.Where(u => u.UserName!.Contains(search))
.Select(u => u.Id)
.ToListAsync(cancellationToken);
}
public async Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(userId.ToString());
@@ -252,12 +265,36 @@ internal sealed class IdentityService(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isActivated,
bool? isBlocked,
bool? billingExpired,
CancellationToken cancellationToken
)
{
var query = userManager.Users.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(u => u.UserName!.Contains(search));
if (isActivated is { } activated)
query = query.Where(u => u.IsActivated == activated);
if (isBlocked is { } blocked)
query = query.Where(u => u.IsBlocked == blocked);
if (roleId is { } roleIdFilter)
query = query.Where(u => dbContext.UserRoles.Any(ur => ur.UserId == u.Id && ur.RoleId == roleIdFilter));
if (billingExpired is { } expired)
{
// Только среди пользователей с billing-ролью — иначе сюда попали бы и те, у кого
// биллинг вообще не включён (BillingPaidUntil у них тоже null, но это не "просрочено").
var now = DateTimeOffset.UtcNow;
query = query.Where(u =>
dbContext.UserRoles.Any(ur =>
ur.UserId == u.Id && dbContext.Roles.Any(r => r.Id == ur.RoleId && r.BillingEnabled)
)
);
query = expired
? query.Where(u => u.BillingPaidUntil == null || u.BillingPaidUntil < now)
: query.Where(u => u.BillingPaidUntil != null && u.BillingPaidUntil >= now);
}
var total = await query.CountAsync(cancellationToken);
var users = await query
@@ -0,0 +1,75 @@
using PnvPanel.Application.Admin.Audit;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Audit;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Audit;
public class ListAuditLogsQueryHandlerTests
{
[Fact]
public async Task Handle_FiltersBySource()
{
using var dbContext = InMemoryDbContextFactory.Create();
dbContext.AuditLogs.AddRange(
AuditLog.Create(null, "BillingSuspended", "User", Guid.NewGuid().ToString(), null, AuditSource.System),
AuditLog.Create(Guid.NewGuid(), "InboundDeleted", "Inbound", Guid.NewGuid().ToString(), null, AuditSource.Web)
);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new ListAuditLogsQueryHandler(dbContext);
var result = await handler.Handle(
new ListAuditLogsQuery(1, 50, AuditSource.System, TargetType: null, Action: null),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var item = Assert.Single(result.Value.Items);
Assert.Equal("BillingSuspended", item.Action);
}
[Fact]
public async Task Handle_FiltersByTargetType()
{
using var dbContext = InMemoryDbContextFactory.Create();
dbContext.AuditLogs.AddRange(
AuditLog.Create(Guid.NewGuid(), "UserBlocked", "User", Guid.NewGuid().ToString(), null, AuditSource.Web),
AuditLog.Create(Guid.NewGuid(), "InboundDeleted", "Inbound", Guid.NewGuid().ToString(), null, AuditSource.Web)
);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new ListAuditLogsQueryHandler(dbContext);
var result = await handler.Handle(
new ListAuditLogsQuery(1, 50, Source: null, TargetType: "Inbound", Action: null),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var item = Assert.Single(result.Value.Items);
Assert.Equal("InboundDeleted", item.Action);
}
[Fact]
public async Task Handle_FiltersByActionSubstring()
{
using var dbContext = InMemoryDbContextFactory.Create();
dbContext.AuditLogs.AddRange(
AuditLog.Create(Guid.NewGuid(), "TicketResolved", "SupportTicket", Guid.NewGuid().ToString(), null, AuditSource.Web),
AuditLog.Create(Guid.NewGuid(), "TicketClosed", "SupportTicket", Guid.NewGuid().ToString(), null, AuditSource.Web)
);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new ListAuditLogsQueryHandler(dbContext);
var result = await handler.Handle(
new ListAuditLogsQuery(1, 50, Source: null, TargetType: null, Action: "Resolved"),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var item = Assert.Single(result.Value.Items);
Assert.Equal("TicketResolved", item.Action);
}
}
@@ -0,0 +1,81 @@
using NSubstitute;
using PnvPanel.Application.Admin.Billing;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Billing;
public class ListPaymentRequestsQueryHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
[Fact]
public async Task Handle_FiltersByKind()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var subscription = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
var topUp = PaymentRequest.CreateRoleChangeTopUp(userId, 500);
dbContext.PaymentRequests.AddRange(subscription, topUp);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetUserNamesAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, string> { [userId] = "alice" });
var handler = new ListPaymentRequestsQueryHandler(dbContext, _identityService);
var result = await handler.Handle(
new ListPaymentRequestsQuery(
StatusFilter: null,
KindFilter: PaymentRequestKind.RoleChangeTopUp,
Search: null,
Page: 1,
PageSize: 20
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var item = Assert.Single(result.Value.Items);
Assert.Equal(topUp.Id, item.Id);
}
[Fact]
public async Task Handle_FiltersBySearch_ResolvesUserIdsBeforePagination()
{
using var dbContext = InMemoryDbContextFactory.Create();
var aliceId = Guid.NewGuid();
var bobId = Guid.NewGuid();
var aliceRequest = PaymentRequest.Create(aliceId, PaymentPeriod.Quarter, 1500);
var bobRequest = PaymentRequest.Create(bobId, PaymentPeriod.Quarter, 1500);
dbContext.PaymentRequests.AddRange(aliceRequest, bobRequest);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.FindUserIdsByUserNameAsync("alice", Arg.Any<CancellationToken>())
.Returns([aliceId]);
_identityService
.GetUserNamesAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, string> { [aliceId] = "alice" });
var handler = new ListPaymentRequestsQueryHandler(dbContext, _identityService);
var result = await handler.Handle(
new ListPaymentRequestsQuery(
StatusFilter: null,
KindFilter: null,
Search: "alice",
Page: 1,
PageSize: 20
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var item = Assert.Single(result.Value.Items);
Assert.Equal(aliceRequest.Id, item.Id);
}
}
@@ -41,7 +41,7 @@ public class ListAllConfigsQueryHandlerTests
var handler = new ListAllConfigsQueryHandler(dbContext, _identityService);
var result = await handler.Handle(
new ListAllConfigsQuery(1, 20, Search: null, Status: null),
new ListAllConfigsQuery(1, 20, Search: null, Status: null, Protocol: null, NodeId: null),
CancellationToken.None
);
@@ -83,7 +83,7 @@ public class ListAllConfigsQueryHandlerTests
var handler = new ListAllConfigsQueryHandler(dbContext, _identityService);
var result = await handler.Handle(
new ListAllConfigsQuery(1, 20, Search: "phone", Status: null),
new ListAllConfigsQuery(1, 20, Search: "phone", Status: null, Protocol: null, NodeId: null),
CancellationToken.None
);
@@ -121,7 +121,7 @@ public class ListAllConfigsQueryHandlerTests
var handler = new ListAllConfigsQueryHandler(dbContext, _identityService);
var result = await handler.Handle(
new ListAllConfigsQuery(1, 20, Search: null, Status: ConfigStatus.Revoked),
new ListAllConfigsQuery(1, 20, Search: null, Status: ConfigStatus.Revoked, Protocol: null, NodeId: null),
CancellationToken.None
);
@@ -129,4 +129,85 @@ public class ListAllConfigsQueryHandlerTests
var item = Assert.Single(result.Value.Items);
Assert.Equal(revoked.Id, item.Id);
}
[Fact]
public async Task Handle_FiltersByProtocol()
{
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 vless = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "vless-config");
var trojan = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Trojan, "trojan-config");
dbContext.Nodes.Add(node);
dbContext.Inbounds.Add(inbound);
dbContext.VpnConfigs.AddRange(vless, trojan);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetUserNamesAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, string> { [userId] = "alice" });
var handler = new ListAllConfigsQueryHandler(dbContext, _identityService);
var result = await handler.Handle(
new ListAllConfigsQuery(1, 20, Search: null, Status: null, Protocol: VpnProtocol.Trojan, NodeId: null),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var item = Assert.Single(result.Value.Items);
Assert.Equal(trojan.Id, item.Id);
}
[Fact]
public async Task Handle_FiltersByNodeId()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var nodeA = Node.Register(
"node-a",
new Uri("https://node-a.example.com"),
new NodeCredentials("admin", "protected"),
null
);
var nodeB = Node.Register(
"node-b",
new Uri("https://node-b.example.com"),
new NodeCredentials("admin", "protected"),
null
);
var inboundA = Inbound.FromRemote(nodeA.Id, "1", VpnProtocol.Vless, "remark", 443);
var inboundB = Inbound.FromRemote(nodeB.Id, "1", VpnProtocol.Vless, "remark", 443);
var configA = VpnConfig.Create(userId, inboundA.Id, VpnProtocol.Vless, "config-a");
var configB = VpnConfig.Create(userId, inboundB.Id, VpnProtocol.Vless, "config-b");
dbContext.Nodes.AddRange(nodeA, nodeB);
dbContext.Inbounds.AddRange(inboundA, inboundB);
dbContext.VpnConfigs.AddRange(configA, configB);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetUserNamesAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, string> { [userId] = "alice" });
var handler = new ListAllConfigsQueryHandler(dbContext, _identityService);
var result = await handler.Handle(
new ListAllConfigsQuery(1, 20, Search: null, Status: null, Protocol: null, NodeId: nodeB.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var item = Assert.Single(result.Value.Items);
Assert.Equal(configB.Id, item.Id);
}
}
@@ -0,0 +1,88 @@
using NSubstitute;
using PnvPanel.Application.Admin.Inbounds;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Inbounds;
public class DeleteInboundCommandHandlerTests
{
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private DeleteInboundCommandHandler CreateHandler(IAppDbContext dbContext) =>
new(dbContext, _notifier, _currentUser);
[Fact]
public async Task Handle_WhenInboundNotFound_ReturnsNotFound()
{
using var dbContext = InMemoryDbContextFactory.Create();
var result = await CreateHandler(dbContext)
.Handle(new DeleteInboundCommand(Guid.NewGuid()), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(InboundErrors.NotFound, result.Error);
}
[Fact]
public async Task Handle_WhenInboundStillAvailable_ReturnsStillAvailable()
{
using var dbContext = InMemoryDbContextFactory.Create();
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
dbContext.Inbounds.Add(inbound);
await dbContext.SaveChangesAsync(CancellationToken.None);
var result = await CreateHandler(dbContext)
.Handle(new DeleteInboundCommand(inbound.Id), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(InboundErrors.StillAvailable, result.Error);
Assert.NotNull(await dbContext.Inbounds.FindAsync([inbound.Id], CancellationToken.None));
}
[Fact]
public async Task Handle_WhenUnavailable_RevokesActiveConfigsAndDeletesInbound()
{
using var dbContext = InMemoryDbContextFactory.Create();
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
inbound.MarkUnavailable();
var userId = Guid.NewGuid();
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
var alreadyRevoked = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
alreadyRevoked.Revoke();
dbContext.Inbounds.Add(inbound);
dbContext.VpnConfigs.AddRange(activeConfig, alreadyRevoked);
await dbContext.SaveChangesAsync(CancellationToken.None);
var result = await CreateHandler(dbContext)
.Handle(new DeleteInboundCommand(inbound.Id), CancellationToken.None);
// UnitOfWorkBehavior делает это в реальном пайплайне — здесь хендлер вызывается напрямую.
await dbContext.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(ConfigStatus.Revoked, activeConfig.Status);
Assert.Null(await dbContext.Inbounds.FindAsync([inbound.Id], CancellationToken.None));
await _notifier
.Received(1)
.NotifyConfigStatusChangedAsync(
userId,
activeConfig.Id,
ConfigStatus.Revoked,
Arg.Any<CancellationToken>()
);
await _notifier
.DidNotReceive()
.NotifyConfigStatusChangedAsync(
userId,
alreadyRevoked.Id,
Arg.Any<ConfigStatus>(),
Arg.Any<CancellationToken>()
);
}
}
@@ -26,12 +26,12 @@ public class ListUsersQueryHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>())
.ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext);
var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None);
var result = await handler.Handle(new ListUsersQuery(1, 20, null, null, null, null, null), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Value.Items.Single().BillingPendingReview);
@@ -44,12 +44,12 @@ public class ListUsersQueryHandlerTests
var userId = Guid.NewGuid();
_identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>())
.ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext);
var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None);
var result = await handler.Handle(new ListUsersQuery(1, 20, null, null, null, null, null), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Value.Items.Single().BillingPendingReview);
@@ -68,12 +68,12 @@ public class ListUsersQueryHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>())
.ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext);
var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None);
var result = await handler.Handle(new ListUsersQuery(1, 20, null, null, null, null, null), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Value.Items.Single().BillingPendingReview);