feat(worker,ui): wire EF Core into DI and update all consumers to IDbContextFactory

Worker and App Program.cs: replace SqliteConnectionFactory+SchemaInitializer
with AddDbContextFactory<ClaudeDoDbContext> + Database.Migrate(). Repos
changed from AddSingleton to AddScoped.

All singleton services (QueueService, StaleTaskRecovery, WorktreeManager,
TaskRunner) and singleton ViewModels (MainWindowViewModel, TaskDetailViewModel,
TaskListViewModel, TaskEditorViewModel) now take IDbContextFactory<ClaudeDoDbContext>
and create short-lived contexts per operation.

Test infrastructure: DbFixture now uses EF migrations instead of SchemaInitializer;
all test classes create contexts via DbFixture.CreateContext().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-16 08:59:24 +02:00
parent b7be52a623
commit 36484ed45a
18 changed files with 479 additions and 232 deletions

View File

@@ -1,19 +1,30 @@
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Tests.Infrastructure;
public sealed class DbFixture : IDisposable
{
public string DbPath { get; }
public SqliteConnectionFactory Factory { get; }
public DbFixture()
{
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
Factory = new SqliteConnectionFactory(DbPath);
SchemaInitializer.Apply(Factory);
// Apply migrations so the schema is created.
using var ctx = CreateContext();
ctx.Database.Migrate();
}
public ClaudeDoDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={DbPath}")
.Options;
return new ClaudeDoDbContext(options);
}
public TestDbContextFactory CreateFactory() => new(this);
public void Dispose()
{
try { File.Delete(DbPath); } catch { /* best effort */ }
@@ -21,3 +32,10 @@ public sealed class DbFixture : IDisposable
try { File.Delete(DbPath + "-shm"); } catch { }
}
}
public sealed class TestDbContextFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly DbFixture _fixture;
public TestDbContextFactory(DbFixture fixture) => _fixture = fixture;
public ClaudeDoDbContext CreateDbContext() => _fixture.CreateContext();
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -7,12 +8,14 @@ namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class ListRepositoryConfigTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _repo;
private readonly string _listId;
public ListRepositoryConfigTests()
{
_repo = new ListRepository(_db.Factory);
_ctx = _db.CreateContext();
_repo = new ListRepository(_ctx);
_listId = Guid.NewGuid().ToString();
_repo.AddAsync(new ListEntity
{
@@ -57,5 +60,9 @@ public sealed class ListRepositoryConfigTests : IDisposable
Assert.Equal("haiku-4-5", fetched.Model);
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -7,16 +8,22 @@ namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class ListRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly TagRepository _tags;
public ListRepositoryTests()
{
_lists = new ListRepository(_db.Factory);
_tags = new TagRepository(_db.Factory);
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
[Fact]
public async Task AddAsync_And_GetByIdAsync_Roundtrips()

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -8,18 +9,24 @@ namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class TaskRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly TagRepository _tags;
public TaskRepositoryTests()
{
_tasks = new TaskRepository(_db.Factory);
_lists = new ListRepository(_db.Factory);
_tags = new TagRepository(_db.Factory);
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> CreateListAsync(string? id = null)
{
@@ -197,7 +204,7 @@ public sealed class TaskRepositoryTests : IDisposable
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
var manualTagId = await _tags.GetOrCreateAsync("manual");
var codeTagId = await TagRepository.GetOrCreateAsync(_db.Factory.Open(), "code");
var codeTagId = await _tags.GetOrCreateAsync("code");
await _lists.AddTagAsync(listId, agentTagId);

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -7,16 +8,18 @@ namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class TaskRunRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRunRepository _runs;
private readonly string _taskId;
public TaskRunRepositoryTests()
{
_runs = new TaskRunRepository(_db.Factory);
_ctx = _db.CreateContext();
_runs = new TaskRunRepository(_ctx);
// Seed a list and task for all tests
var lists = new ListRepository(_db.Factory);
var tasks = new TaskRepository(_db.Factory);
var lists = new ListRepository(_ctx);
var tasks = new TaskRepository(_ctx);
var listId = Guid.NewGuid().ToString();
lists.AddAsync(new ListEntity
{
@@ -37,7 +40,11 @@ public sealed class TaskRunRepositoryTests : IDisposable
}).GetAwaiter().GetResult();
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
{

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -24,19 +25,19 @@ public class WorktreeManagerTests : IDisposable
return f;
}
private async Task<(WorktreeManager mgr, WorktreeRepository wtRepo)> CreateManagerAsync(
private async Task<(WorktreeManager mgr, DbFixture db)> CreateManagerAsync(
TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null)
{
var db = new DbFixture();
_dbFixtures.Add(db);
// Seed the DB with list and task so FK constraints pass.
var listRepo = new ListRepository(db.Factory);
var taskRepo = new TaskRepository(db.Factory);
using var seedCtx = db.CreateContext();
var listRepo = new ListRepository(seedCtx);
var taskRepo = new TaskRepository(seedCtx);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
var wtRepo = new WorktreeRepository(db.Factory);
var cfg = new WorkerConfig
{
WorktreeRootStrategy = strategy,
@@ -45,8 +46,8 @@ public class WorktreeManagerTests : IDisposable
cfg.CentralWorktreeRoot = centralRoot;
var mgr = new WorktreeManager(
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
return (mgr, wtRepo);
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
return (mgr, db);
}
[Fact]
@@ -56,7 +57,7 @@ public class WorktreeManagerTests : IDisposable
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
@@ -66,6 +67,8 @@ public class WorktreeManagerTests : IDisposable
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.NotNull(row);
Assert.Equal(WorktreeState.Active, row!.State);
@@ -80,7 +83,7 @@ public class WorktreeManagerTests : IDisposable
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
@@ -88,6 +91,8 @@ public class WorktreeManagerTests : IDisposable
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
Assert.False(committed);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.Null(row!.HeadCommit);
}
@@ -99,7 +104,7 @@ public class WorktreeManagerTests : IDisposable
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
@@ -109,6 +114,8 @@ public class WorktreeManagerTests : IDisposable
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
Assert.True(committed);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.NotNull(row!.HeadCommit);
Assert.NotEqual(ctx.BaseCommit, row.HeadCommit);
@@ -129,20 +136,24 @@ public class WorktreeManagerTests : IDisposable
var db = new DbFixture();
_dbFixtures.Add(db);
var listRepo = new ListRepository(db.Factory);
var taskRepo = new TaskRepository(db.Factory);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
using (var seedCtx = db.CreateContext())
{
var listRepo = new ListRepository(seedCtx);
var taskRepo = new TaskRepository(seedCtx);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
}
var wtRepo = new WorktreeRepository(db.Factory);
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => mgr.CreateAsync(task, list, CancellationToken.None));
Assert.Contains("not a git repository", ex.Message);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.Null(row);
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -15,6 +16,7 @@ namespace ClaudeDo.Worker.Tests.Services;
public sealed class QueueServiceTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _taskRepo;
private readonly ListRepository _listRepo;
private readonly TagRepository _tagRepo;
@@ -23,9 +25,10 @@ public sealed class QueueServiceTests : IDisposable
public QueueServiceTests()
{
_taskRepo = new TaskRepository(_db.Factory);
_listRepo = new ListRepository(_db.Factory);
_tagRepo = new TagRepository(_db.Factory);
_ctx = _db.CreateContext();
_taskRepo = new TaskRepository(_ctx);
_listRepo = new ListRepository(_ctx);
_tagRepo = new TagRepository(_ctx);
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig
@@ -38,6 +41,7 @@ public sealed class QueueServiceTests : IDisposable
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
try { Directory.Delete(_tempDir, true); } catch { }
}
@@ -47,14 +51,12 @@ public sealed class QueueServiceTests : IDisposable
{
var fake = new FakeClaudeProcess(handler);
var broadcaster = new HubBroadcaster(new FakeHubContext());
var wtRepo = new WorktreeRepository(_db.Factory);
var runRepo = new TaskRunRepository(_db.Factory);
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var subtaskRepo = new SubtaskRepository(_db.Factory);
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg,
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance);
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance);
return (service, fake);
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Services;
@@ -10,16 +11,22 @@ namespace ClaudeDo.Worker.Tests.Services;
public sealed class StaleTaskRecoveryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public StaleTaskRecoveryTests()
{
_tasks = new TaskRepository(_db.Factory);
_lists = new ListRepository(_db.Factory);
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
[Fact]
public async Task StartAsync_Flips_Running_Tasks_To_Failed()
@@ -47,7 +54,7 @@ public sealed class StaleTaskRecoveryTests : IDisposable
await _tasks.AddAsync(running);
await _tasks.AddAsync(queued);
var recovery = new StaleTaskRecovery(_tasks, NullLogger<StaleTaskRecovery>.Instance);
var recovery = new StaleTaskRecovery(_db.CreateFactory(), NullLogger<StaleTaskRecovery>.Instance);
await recovery.StartAsync(CancellationToken.None);
var r = await _tasks.GetByIdAsync(running.Id);