Enhance admin endpoints and queries for improved filtering and management
- 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:
@@ -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
|
||||
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
+84
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user