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.Instance) { ControllerContext = new ControllerContext { HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary { ["X-Agent-Id"] = "programmer-fast" }) } }; var result = await controller.GetBoard(CancellationToken.None); Assert.IsType(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.Instance) { ControllerContext = new ControllerContext { HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary { ["X-Nexus-Api-Key"] = "test-service-key" }) } }; var result = await controller.GetBoard(CancellationToken.None); Assert.IsType(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 { ["X-Nexus-Api-Key"] = "test-service-key" }) } }; var result = await controller.GetBoard(CancellationToken.None); Assert.IsType(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 { ["X-Agent-Id"] = "programmer-fast" }) } }; var result = await controller.GetBoard(CancellationToken.None); AssertStatusCode(result, StatusCodes.Status200OK); } [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); AssertStatusCode(result, StatusCodes.Status401Unauthorized); } [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 { ["X-Agent-Id"] = "unknown-agent" }) } }; var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None); AssertStatusCode(result, StatusCodes.Status403Forbidden); } [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); AssertStatusCode(result, StatusCodes.Status403Forbidden); } [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 { ["X-Nexus-Api-Key"] = "test-service-key" }) } }; var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None); AssertStatusCode(result, StatusCodes.Status200OK); } [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); AssertStatusCode(result, StatusCodes.Status200OK); } [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.Instance) { ControllerContext = new ControllerContext { HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user")) } }; var result = await controller.GetBoard(CancellationToken.None); Assert.IsType(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.Instance) { ControllerContext = new ControllerContext { HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin")) } }; var result = await controller.GetBoard(CancellationToken.None); Assert.IsType(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); } private static void AssertStatusCode(IResult result, int expectedStatusCode) { var statusResult = Assert.IsAssignableFrom(result); Assert.Equal(expectedStatusCode, statusResult.StatusCode); } } 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 CreateAsync() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var db = new NexusDbContext(options); await db.Database.EnsureCreatedAsync(); var configPath = CreateAgentConfigFile(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["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? 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 GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0)); public Task> GetAgentsAsync() => Task.FromResult(new List()); public Task> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List()); public Task SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null)); public Task> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List()); public Task> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List()); public Task DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored)); public Task CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored)); public Task GetAgentModelAsync(string agentId) => Task.FromResult(null); public Task SetAgentModelAsync(string agentId, string model) => Task.FromResult(false); public Task> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List()); public List GetAvailableModels() => []; }