feat: complete task board workflow gates
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class TaskWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
|
||||
|
||||
var child = await fixture.TaskService.CreateAgentTaskAsync(
|
||||
"PO spec",
|
||||
"Prepare specification",
|
||||
"iris",
|
||||
"Medium",
|
||||
"product-owner",
|
||||
"programmer-fast",
|
||||
parent.Id,
|
||||
startsInProgress: false,
|
||||
initialState: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.Equal("Backlog", child.State);
|
||||
Assert.Equal("product-owner", child.AssignedTo);
|
||||
Assert.Equal("programmer-fast", child.ExpectedFrom);
|
||||
Assert.True(child.IsAgentTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDashboardTaskByIdAsync_MapsChildDelegationAndActivity()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
|
||||
|
||||
var child = await fixture.TaskService.CreateAgentTaskAsync(
|
||||
"Implement",
|
||||
"Code changes",
|
||||
"iris",
|
||||
"High",
|
||||
"programmer-fast",
|
||||
"programmer-fast",
|
||||
parent.Id,
|
||||
startsInProgress: false,
|
||||
initialState: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
var dto = await fixture.TaskService.GetDashboardTaskByIdAsync(child.Id, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.True(dto!.HasVisibleDelegation);
|
||||
Assert.NotNull(dto.LastActivityMessage);
|
||||
Assert.Equal("programmer-fast", dto.AssignedTo);
|
||||
Assert.Equal("programmer-fast", dto.ExpectedFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BridgeGetChildTasksAsync_ReturnsMappedActivityAndVisibleDelegation()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
|
||||
|
||||
await fixture.TaskBridgeService.CreateChildTaskAsync(
|
||||
parent.Id,
|
||||
"Review",
|
||||
"Review implementation",
|
||||
"iris",
|
||||
"Medium",
|
||||
"reviewer",
|
||||
"reviewer",
|
||||
startsInProgress: false,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
var children = await fixture.TaskBridgeService.GetChildTasksAsync(parent.Id, CancellationToken.None);
|
||||
var child = Assert.Single(children);
|
||||
|
||||
Assert.True(child.HasVisibleDelegation);
|
||||
Assert.NotNull(child.LastActivityMessage);
|
||||
Assert.Equal("reviewer", child.AssignedTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "programmer-fast"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_AcceptsServiceKeyWithoutConfiguredNexusSystemAgent()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DashboardController_GetBoard_AcceptsServiceKeyWithoutJwt()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new DashboardController(
|
||||
new FakeDashboardService(),
|
||||
fixture.TaskService,
|
||||
fixture.ActivityRepository,
|
||||
new HttpContextAccessor(),
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
fixture.NotificationService,
|
||||
fixture.LiveUpdateService)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "programmer-fast"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_Anonymous_IsUnauthorized()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext()
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "unknown-agent"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_OrdinaryJwtUser_IsForbidden()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_ServiceKey_IsAllowed()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_IrisHeader_IsAllowed()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(agentId: "iris")
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_AdminJwt_IsAllowed()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChildTaskAsync_TransitionsBacklogParent_WhenCallerIsProgrammerFast()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("programmer-fast");
|
||||
|
||||
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
|
||||
|
||||
var result = await fixture.TaskBridgeService.CreateChildTaskAsync(
|
||||
parent.Id,
|
||||
"Implement",
|
||||
"Ship the change",
|
||||
"programmer-fast",
|
||||
"Medium",
|
||||
"programmer-fast",
|
||||
"programmer-fast",
|
||||
startsInProgress: false,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
var updatedParent = await fixture.TaskService.GetByIdAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskBridgeOutcome.Success, result.Outcome);
|
||||
Assert.NotNull(updatedParent);
|
||||
Assert.Equal("In progress", updatedParent!.State);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NexusDbContext _db;
|
||||
|
||||
private TaskWorkflowFixture(
|
||||
NexusDbContext db,
|
||||
IConfiguration configuration,
|
||||
ITaskRepository taskRepository,
|
||||
IActivityRepository activityRepository,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService,
|
||||
ITaskService taskService,
|
||||
ITaskBridgeService taskBridgeService,
|
||||
IAgentService agentService,
|
||||
HttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_db = db;
|
||||
Configuration = configuration;
|
||||
TaskRepository = taskRepository;
|
||||
ActivityRepository = activityRepository;
|
||||
NotificationService = notificationService;
|
||||
LiveUpdateService = liveUpdateService;
|
||||
TaskService = taskService;
|
||||
TaskBridgeService = taskBridgeService;
|
||||
AgentService = agentService;
|
||||
HttpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
public ITaskRepository TaskRepository { get; }
|
||||
public IActivityRepository ActivityRepository { get; }
|
||||
public INotificationService NotificationService { get; }
|
||||
public ILiveUpdateService LiveUpdateService { get; }
|
||||
public ITaskService TaskService { get; }
|
||||
public ITaskBridgeService TaskBridgeService { get; }
|
||||
public IAgentService AgentService { get; }
|
||||
public HttpContextAccessor HttpContextAccessor { get; }
|
||||
|
||||
public static async Task<TaskWorkflowFixture> CreateAsync()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var db = new NexusDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = configPath,
|
||||
["NexusApiKey"] = "test-service-key"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var agentService = new AgentService(configuration, new FakeRuntime());
|
||||
var liveUpdateService = new LiveUpdateService();
|
||||
var activityRepository = new ActivityRepository(db);
|
||||
var taskRepository = new TaskRepository(db);
|
||||
var notificationService = new NotificationService(db, liveUpdateService);
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
|
||||
|
||||
var taskService = new TaskService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
notificationService,
|
||||
agentService,
|
||||
httpContextAccessor,
|
||||
liveUpdateService);
|
||||
|
||||
var taskBridgeService = new TaskBridgeService(
|
||||
taskService,
|
||||
agentService,
|
||||
activityRepository,
|
||||
notificationService,
|
||||
liveUpdateService);
|
||||
|
||||
return new TaskWorkflowFixture(
|
||||
db,
|
||||
configuration,
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
notificationService,
|
||||
liveUpdateService,
|
||||
taskService,
|
||||
taskBridgeService,
|
||||
agentService,
|
||||
httpContextAccessor);
|
||||
}
|
||||
|
||||
public static DefaultHttpContext CreateHttpContext(
|
||||
string? agentId = null,
|
||||
Dictionary<string, string>? headers = null,
|
||||
ClaimsPrincipal? user = null)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
if (!string.IsNullOrWhiteSpace(agentId))
|
||||
httpContext.Request.Headers["X-Agent-Id"] = agentId;
|
||||
|
||||
if (headers is not null)
|
||||
{
|
||||
foreach (var (key, value) in headers)
|
||||
httpContext.Request.Headers[key] = value;
|
||||
}
|
||||
|
||||
httpContext.User = user ?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||
return httpContext;
|
||||
}
|
||||
|
||||
public static ClaimsPrincipal CreateUser(string userId, string role)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim(ClaimTypes.Role, role)
|
||||
};
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
public void SetCallerAgent(string agentId)
|
||||
{
|
||||
HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _db.DisposeAsync();
|
||||
}
|
||||
|
||||
private static string CreateAgentConfigFile()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path,
|
||||
"""
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/workspace/default",
|
||||
"model": {
|
||||
"primary": "deepseek/deepseek-v4-flash"
|
||||
}
|
||||
},
|
||||
"list": [
|
||||
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
|
||||
{ "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } },
|
||||
{ "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } },
|
||||
{ "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } },
|
||||
{ "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeDashboardService : IDashboardService
|
||||
{
|
||||
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
|
||||
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
|
||||
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
|
||||
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
|
||||
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
|
||||
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
|
||||
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
|
||||
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
|
||||
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
|
||||
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
|
||||
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
|
||||
public List<ModelOption> GetAvailableModels() => [];
|
||||
}
|
||||
Reference in New Issue
Block a user