199 lines
7.5 KiB
C#
199 lines
7.5 KiB
C#
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)
|
|
});
|
|
}
|
|
}
|