using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Tests; public sealed class ListFolderTests : IDisposable { private readonly string _dbPath; private readonly DbContextOptions _options; public ListFolderTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_folders_{Guid.NewGuid():N}.db"); _options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; using var ctx = new ClaudeDoDbContext(_options); ctx.Database.EnsureCreated(); } public void Dispose() { foreach (var suffix in new[] { "", "-wal", "-shm" }) try { File.Delete(_dbPath + suffix); } catch { } } private ClaudeDoDbContext Open() => new(_options); private static ListFolderEntity NewFolder(string id, string name = "Group") => new() { Id = id, Name = name, CreatedAt = DateTime.UtcNow }; [Fact] public async Task Deleting_a_folder_keeps_its_lists_and_moves_them_to_the_top_level() { await using (var ctx = Open()) { var repo = new ListRepository(ctx); await repo.AddFolderAsync(NewFolder("f1")); await repo.AddAsync(new ListEntity { Id = "l1", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" }); } await using (var ctx = Open()) await new ListRepository(ctx).DeleteFolderAsync("f1"); await using (var ctx = Open()) { var list = await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1"); Assert.Null(list.FolderId); Assert.Empty(await ctx.ListFolders.AsNoTracking().ToListAsync()); } } [Fact] public async Task RenameFolderAsync_keeps_sort_order_and_collapse_state() { // The whole reason it patches instead of Update()-ing a rebuilt entity: a rename must not // silently unfold the folder or shuffle it to the top. await using (var ctx = Open()) { var repo = new ListRepository(ctx); await repo.AddFolderAsync(NewFolder("f1", "First")); await repo.AddFolderAsync(NewFolder("f2", "Second")); await repo.SetFolderCollapsedAsync("f2", true); } await using (var ctx = Open()) await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star"); await using (var ctx = Open()) { var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2"); Assert.Equal("Renamed", folder.Name); Assert.Equal("Star", folder.Icon); Assert.True(folder.IsCollapsed); Assert.Equal(1, folder.SortOrder); } } [Fact] public async Task SetListFolderAsync_moves_a_list_in_and_back_out() { await using (var ctx = Open()) { var repo = new ListRepository(ctx); await repo.AddFolderAsync(NewFolder("f1")); await repo.AddAsync(new ListEntity { Id = "l1", Name = "Roaming", CreatedAt = DateTime.UtcNow }); } await using (var ctx = Open()) await new ListRepository(ctx).SetListFolderAsync("l1", "f1"); await using (var ctx = Open()) Assert.Equal("f1", (await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId); await using (var ctx = Open()) await new ListRepository(ctx).SetListFolderAsync("l1", null); await using (var ctx = Open()) Assert.Null((await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId); } [Fact] public async Task ReorderSidebarAsync_numbers_folders_and_lists_in_one_sequence() { await using var ctx = Open(); var repo = new ListRepository(ctx); await repo.AddFolderAsync(NewFolder("f1")); await repo.AddAsync(new ListEntity { Id = "inside", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" }); await repo.AddAsync(new ListEntity { Id = "top", Name = "Top", CreatedAt = DateTime.UtcNow }); // Sidebar: the top-level list above the folder, whose one child follows the folder. await repo.ReorderSidebarAsync([("top", false), ("f1", true), ("inside", false)]); Assert.Equal(0, (await repo.GetByIdAsync("top"))!.SortOrder); Assert.Equal(1, (await repo.GetFoldersAsync()).Single().SortOrder); Assert.Equal(2, (await repo.GetByIdAsync("inside"))!.SortOrder); } [Fact] public async Task AddAsync_appends_past_the_folders_too() { await using var ctx = Open(); var repo = new ListRepository(ctx); await repo.AddFolderAsync(NewFolder("f1")); await repo.AddFolderAsync(NewFolder("f2")); await repo.AddAsync(new ListEntity { Id = "l1", Name = "Fresh", CreatedAt = DateTime.UtcNow }); // 2, not 0 — a new list must not jump above the existing folders on the next load. Assert.Equal(2, (await repo.GetByIdAsync("l1"))!.SortOrder); } [Fact] public async Task AddFolderAsync_appends_in_creation_order() { await using var ctx = Open(); var repo = new ListRepository(ctx); await repo.AddFolderAsync(NewFolder("f1", "One")); await repo.AddFolderAsync(NewFolder("f2", "Two")); var folders = await repo.GetFoldersAsync(); Assert.Equal(["One", "Two"], folders.Select(f => f.Name)); Assert.Equal([0, 1], folders.Select(f => f.SortOrder)); } [Fact] public async Task SetFolderParentAsync_nests_a_folder_and_moves_it_back_out() { await using (var ctx = Open()) { var repo = new ListRepository(ctx); await repo.AddFolderAsync(NewFolder("parent")); await repo.AddFolderAsync(NewFolder("child")); } await using (var ctx = Open()) await new ListRepository(ctx).SetFolderParentAsync("child", "parent"); await using (var ctx = Open()) Assert.Equal("parent", (await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId); await using (var ctx = Open()) await new ListRepository(ctx).SetFolderParentAsync("child", null); await using (var ctx = Open()) Assert.Null((await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId); } [Fact] public async Task Deleting_a_folder_keeps_its_subfolder_and_moves_it_to_the_top_level() { await using (var ctx = Open()) { var repo = new ListRepository(ctx); await repo.AddFolderAsync(NewFolder("parent")); await repo.AddFolderAsync(NewFolder("child")); await repo.SetFolderParentAsync("child", "parent"); } await using (var ctx = Open()) await new ListRepository(ctx).DeleteFolderAsync("parent"); await using (var ctx = Open()) { var child = await ctx.ListFolders.AsNoTracking().SingleAsync(); Assert.Equal("child", child.Id); Assert.Null(child.ParentFolderId); } } }