TaskEntity.Number is a global, monotonically increasing, never-reused integer (displayed as #123), allocated from AppSettingsEntity.NextTaskNumber via a single UPDATE...RETURNING statement rather than MAX(number)+1, which would reissue a deleted task's number. Both insert paths (TaskRepository. AddAsync and CreateChildAsync) route through the new TaskNumberAllocator, with a bounded retry on a unique-index collision. One migration adds the columns, backfills existing rows in creation order, and creates the unique index afterwards. Data-layer only; MCP/UI wiring is later slices.
144 lines
5.1 KiB
C#
144 lines
5.1 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Data.Tests;
|
|
|
|
public sealed class MoveToListTests : IDisposable
|
|
{
|
|
private readonly string _dbPath;
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
|
|
public MoveToListTests()
|
|
{
|
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_movelist_{Guid.NewGuid():N}.db");
|
|
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
_ctx = new ClaudeDoDbContext(options);
|
|
_ctx.Database.EnsureCreated();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ctx.Dispose();
|
|
try { File.Delete(_dbPath); } catch { }
|
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
|
}
|
|
|
|
private async Task SeedListsAsync(params string[] ids)
|
|
{
|
|
foreach (var id in ids)
|
|
_ctx.Lists.Add(new ListEntity { Id = id, Name = id, CreatedAt = DateTime.UtcNow });
|
|
await _ctx.SaveChangesAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToList_changes_ListId_and_appends_at_end_of_target()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "t1", Number = 1, ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
|
});
|
|
// Existing tasks already in the target list, so we can assert the moved task lands after them.
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "existing1", Number = 2, ListId = "target", Title = "Existing 1", CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
|
});
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "existing2", Number = 3, ListId = "target", Title = "Existing 2", CreatedAt = DateTime.UtcNow, SortOrder = 1,
|
|
});
|
|
await _ctx.SaveChangesAsync();
|
|
_ctx.ChangeTracker.Clear();
|
|
|
|
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
|
|
|
|
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
|
|
Assert.Equal("target", moved.ListId);
|
|
Assert.Equal(2, moved.SortOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToList_moves_all_descendants_recursively()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "parent", Number = 1, ListId = "source", Title = "Parent", CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
|
});
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "child", Number = 2, ListId = "source", Title = "Child", CreatedAt = DateTime.UtcNow,
|
|
SortOrder = 1, ParentTaskId = "parent",
|
|
});
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "grandchild", Number = 3, ListId = "source", Title = "Grandchild", CreatedAt = DateTime.UtcNow,
|
|
SortOrder = 2, ParentTaskId = "child",
|
|
});
|
|
await _ctx.SaveChangesAsync();
|
|
_ctx.ChangeTracker.Clear();
|
|
|
|
await new TaskRepository(_ctx).MoveToListAsync("parent", "target");
|
|
|
|
var all = await _ctx.Tasks.AsNoTracking()
|
|
.Where(t => t.Id == "parent" || t.Id == "child" || t.Id == "grandchild")
|
|
.ToListAsync();
|
|
|
|
Assert.All(all, t => Assert.Equal("target", t.ListId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToList_first_task_in_empty_target_gets_SortOrder_zero()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 3,
|
|
});
|
|
await _ctx.SaveChangesAsync();
|
|
_ctx.ChangeTracker.Clear();
|
|
|
|
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
|
|
|
|
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
|
|
Assert.Equal("target", moved.ListId);
|
|
Assert.Equal(0, moved.SortOrder);
|
|
}
|
|
|
|
// A corrupt parent chain must not hang the descendant walk.
|
|
[Fact(Timeout = 15000)]
|
|
public async Task MoveToList_survives_a_self_parenting_task()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
|
});
|
|
await _ctx.SaveChangesAsync();
|
|
// Set the cycle after insert — the FK tolerates it, the walk must too.
|
|
await _ctx.Tasks.Where(t => t.Id == "t1")
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, "t1"));
|
|
_ctx.ChangeTracker.Clear();
|
|
|
|
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
|
|
|
|
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
|
|
Assert.Equal("target", moved.ListId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToList_throws_when_task_missing()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
new TaskRepository(_ctx).MoveToListAsync("nope", "target"));
|
|
}
|
|
}
|