using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Nexus.Api.Controllers; using Nexus.Api.Data; using Nexus.Api.Models; using Xunit; namespace Nexus.Api.Tests; public sealed class TaskBoardV2Tests { [Fact] public async Task GetBoardPage_ReturnsAllActiveGroups_AndPaginatesDoneByStableKeyset() { await using var fixture = await TaskWorkflowFixture.CreateAsync(); var timestamp = new DateTimeOffset(2026, 7, 30, 12, 0, 0, TimeSpan.Zero); var parentId = Guid.Parse("00000000-0000-0000-0000-000000000100"); await fixture.TaskRepository.AddAsync(new WorkTask { Id = parentId, Title = "Parent", State = "Backlog", Priority = "High", UpdatedAt = timestamp.AddMinutes(-10), CreatedAt = timestamp.AddHours(-1) }); await fixture.TaskRepository.AddAsync(new WorkTask { Id = Guid.Parse("00000000-0000-0000-0000-000000000101"), Title = "Child", State = "In progress", Priority = "Medium", ParentTaskId = parentId, UpdatedAt = timestamp.AddMinutes(-9), CreatedAt = timestamp.AddMinutes(-50) }); await fixture.TaskRepository.AddAsync(new WorkTask { Id = Guid.Parse("00000000-0000-0000-0000-000000000102"), Title = "Review", State = "Review", UpdatedAt = timestamp.AddMinutes(-8) }); await fixture.TaskRepository.AddAsync(new WorkTask { Id = Guid.Parse("00000000-0000-0000-0000-000000000103"), Title = "Blocked", State = "Blocked", UpdatedAt = timestamp.AddMinutes(-7) }); var newestDoneId = Guid.Parse("00000000-0000-0000-0000-000000000203"); var middleDoneId = Guid.Parse("00000000-0000-0000-0000-000000000202"); var oldestDoneId = Guid.Parse("00000000-0000-0000-0000-000000000201"); await AddDoneAsync(fixture, oldestDoneId, "Done 1", timestamp.AddMinutes(-3)); await AddDoneAsync(fixture, middleDoneId, "Done 2", timestamp.AddMinutes(-2)); await AddDoneAsync(fixture, newestDoneId, "Done 3", timestamp.AddMinutes(-1)); await fixture.ActivityRepository.AddAsync(new ActivityEvent { Type = "task", Message = "Parent updated", TaskId = parentId, CreatedAt = timestamp }); var first = await fixture.TaskService.GetBoardPageAsync(2); Assert.Single(first.Offen); Assert.Single(first.InProgress); Assert.Single(first.Review); Assert.Single(first.Blocked); Assert.Equal([newestDoneId, middleDoneId], first.Done.Select(task => task.Id)); Assert.True(first.HasMoreDone); Assert.NotNull(first.NextDoneCursor); Assert.Equal(1, first.Offen[0].ChildTaskCount); Assert.Equal(1, first.Offen[0].OpenChildTaskCount); Assert.Equal("Parent updated", first.Offen[0].LastActivityMessage); var second = await fixture.TaskService.GetBoardPageAsync(2, first.NextDoneCursor); Assert.Equal(first.Revision, second.Revision); Assert.Empty(second.Offen); Assert.Empty(second.InProgress); Assert.Empty(second.Review); Assert.Empty(second.Blocked); Assert.Equal([oldestDoneId], second.Done.Select(task => task.Id)); Assert.False(second.HasMoreDone); Assert.Null(second.NextDoneCursor); Assert.DoesNotContain(second.Done, task => first.Done.Any(firstTask => firstTask.Id == task.Id)); } [Fact] public async Task GetBoardPage_UsesIdAsTieBreaker_WhenDoneTimestampsMatch() { await using var fixture = await TaskWorkflowFixture.CreateAsync(); var timestamp = new DateTimeOffset(2026, 7, 30, 12, 0, 0, TimeSpan.Zero); var firstId = Guid.Parse("00000000-0000-0000-0000-000000000003"); var secondId = Guid.Parse("00000000-0000-0000-0000-000000000002"); var thirdId = Guid.Parse("00000000-0000-0000-0000-000000000001"); await AddDoneAsync(fixture, thirdId, "Done 1", timestamp); await AddDoneAsync(fixture, firstId, "Done 3", timestamp); await AddDoneAsync(fixture, secondId, "Done 2", timestamp); var first = await fixture.TaskService.GetBoardPageAsync(2); var second = await fixture.TaskService.GetBoardPageAsync(2, first.NextDoneCursor); Assert.Equal([firstId, secondId], first.Done.Select(task => task.Id)); Assert.Equal([thirdId], second.Done.Select(task => task.Id)); } [Fact] public async Task GetBoardPage_RejectsMalformedCursor_AsValidationProblem() { await using var fixture = await TaskWorkflowFixture.CreateAsync(); var controller = CreateController(fixture); var result = await controller.GetBoard( CancellationToken.None, doneLimit: 50, doneCursor: "not-a-valid-cursor"); var statusResult = Assert.IsAssignableFrom(result); Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode); } [Theory] [InlineData(0)] [InlineData(101)] public async Task GetBoardPage_RejectsOutOfRangeDoneLimit(int doneLimit) { await using var fixture = await TaskWorkflowFixture.CreateAsync(); var controller = CreateController(fixture); var result = await controller.GetBoard(CancellationToken.None, doneLimit); var statusResult = Assert.IsAssignableFrom(result); Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode); } [Fact] public void DoneKeysetPredicate_IsTranslatableByNpgsql() { var options = new DbContextOptionsBuilder() .UseNpgsql("Host=unused;Database=unused;Username=unused;Password=unused") .Options; using var db = new NexusDbContext(options); var cursorUpdatedAt = new DateTimeOffset(2026, 7, 30, 12, 0, 0, TimeSpan.Zero); var cursorId = Guid.Parse("00000000-0000-0000-0000-000000000002"); var sql = db.Tasks .AsNoTracking() .Where(task => task.State == "Done") .Where(task => task.UpdatedAt < cursorUpdatedAt || (task.UpdatedAt == cursorUpdatedAt && task.Id.CompareTo(cursorId) < 0)) .OrderByDescending(task => task.UpdatedAt) .ThenByDescending(task => task.Id) .Take(51) .ToQueryString(); Assert.Contains("\"UpdatedAt\"", sql, StringComparison.Ordinal); Assert.Contains("\"Id\"", sql, StringComparison.Ordinal); } private static TasksController CreateController(TaskWorkflowFixture fixture) => new( fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository) { ControllerContext = new ControllerContext { HttpContext = TaskWorkflowFixture.CreateHttpContext( user: TaskWorkflowFixture.CreateUser("bao", "owner")) } }; private static async Task AddDoneAsync( TaskWorkflowFixture fixture, Guid id, string title, DateTimeOffset updatedAt) { await fixture.TaskRepository.AddAsync(new WorkTask { Id = id, Title = title, State = "Done", UpdatedAt = updatedAt, CreatedAt = updatedAt.AddHours(-1) }); } }