feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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<IStatusCodeHttpResult>(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<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoneKeysetPredicate_IsTranslatableByNpgsql()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.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)
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user