using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Data.Tests; /// /// Proves that FK enforcement (PRAGMA foreign_keys=ON) is active on every /// IDbContextFactory-created context, not just the MigrateAndConfigure context. /// public sealed class ForeignKeyTests : IDisposable { private readonly string _dbPath; private readonly DbContextOptions _options; public ForeignKeyTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_fktest_{Guid.NewGuid():N}.db"); _options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; // EnsureCreated creates the schema including FK constraints in DDL. 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); // ---- FK interceptor: ON DELETE SET NULL ---- [Fact] public async Task BlockedByTaskId_is_nulled_when_predecessor_deleted_on_fresh_context() { var listId = Guid.NewGuid().ToString(); var parentId = Guid.NewGuid().ToString(); var childId = Guid.NewGuid().ToString(); await using (var ctx = Open()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "Predecessor", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "Blocked", Status = TaskStatus.Idle, BlockedByTaskId = parentId, CreatedAt = DateTime.UtcNow }); await ctx.SaveChangesAsync(); } // Delete predecessor in a brand-new context (simulates factory-created context). // Without the FK interceptor, PRAGMA foreign_keys is OFF and SQLite silently // leaves blocked_by_task_id pointing at the deleted row. await using (var ctx = Open()) { var predecessor = await ctx.Tasks.FindAsync(parentId); ctx.Tasks.Remove(predecessor!); await ctx.SaveChangesAsync(); } await using (var ctx = Open()) { var child = await ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == childId); Assert.Null(child.BlockedByTaskId); } } // ---- AppSettingsRepository: get-or-create resilience ---- [Fact] public async Task GetAsync_returns_existing_row_when_present() { await using var ctx = Open(); var repo = new AppSettingsRepository(ctx); var first = await repo.GetAsync(); var second = await repo.GetAsync(); Assert.Equal(AppSettingsEntity.SingletonId, first.Id); Assert.Equal(AppSettingsEntity.SingletonId, second.Id); } [Fact] public async Task GetAsync_creates_row_when_absent() { // Use a fresh DB with no HasData seed (EnsureCreated skips HasData seeding). var path = Path.Combine(Path.GetTempPath(), $"claudedo_fktest_empty_{Guid.NewGuid():N}.db"); try { var opts = new DbContextOptionsBuilder() .UseSqlite($"Data Source={path}") .Options; await using var ctx = new ClaudeDoDbContext(opts); ctx.Database.EnsureCreated(); var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); Assert.Equal(AppSettingsEntity.SingletonId, settings.Id); // A second call should find the row and not re-insert. var settings2 = await repo.GetAsync(); Assert.Equal(AppSettingsEntity.SingletonId, settings2.Id); } finally { foreach (var suffix in new[] { "", "-wal", "-shm" }) try { File.Delete(path + suffix); } catch { } } } }