Files
ClaudeDo/tests/ClaudeDo.Data.Tests/MoveToListTests.cs
T
mika kuns 9e46c96b24 feat(data): add task numbers schema, allocator, and backfill migration
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.
2026-08-11 10:49:06 +02:00

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"));
}
}