feat(data,worker): add repositories, stale-task recovery, and test foundation
Data: TagRepository, ListRepository, TaskRepository (incl. queue selection via effective agent tag + scheduled_for filter), WorktreeRepository. All CRUD via parameterized SqliteCommand; enums roundtrip as lowercase strings matching the schema CHECK constraints. Worker: StaleTaskRecovery IHostedService flips running -> failed on startup and marks the result column with a [stale] reason. All four repositories registered as singletons. Tests: DbFixture with temp-file SQLite + schema bootstrap, covering TaskRepository (queue pick via list-tag and task-tag, schedule filter, transitions, stale flip), ListRepository CRUD + junctions, and StaleTaskRecovery. 14 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
107
tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryTests.cs
Normal file
107
tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryTests.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||
|
||||
public sealed class ListRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
|
||||
public ListRepositoryTests()
|
||||
{
|
||||
_lists = new ListRepository(_db.Factory);
|
||||
_tags = new TagRepository(_db.Factory);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_And_GetByIdAsync_Roundtrips()
|
||||
{
|
||||
var entity = new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = "Shopping",
|
||||
CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
WorkingDir = @"C:\Repos\Test",
|
||||
DefaultCommitType = "feat",
|
||||
};
|
||||
|
||||
await _lists.AddAsync(entity);
|
||||
var loaded = await _lists.GetByIdAsync(entity.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(entity.Id, loaded.Id);
|
||||
Assert.Equal(entity.Name, loaded.Name);
|
||||
Assert.Equal(entity.WorkingDir, loaded.WorkingDir);
|
||||
Assert.Equal(entity.DefaultCommitType, loaded.DefaultCommitType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_Changes_Fields()
|
||||
{
|
||||
var entity = new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = "Original",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _lists.AddAsync(entity);
|
||||
|
||||
entity.Name = "Updated";
|
||||
entity.WorkingDir = @"C:\New";
|
||||
await _lists.UpdateAsync(entity);
|
||||
|
||||
var loaded = await _lists.GetByIdAsync(entity.Id);
|
||||
Assert.Equal("Updated", loaded!.Name);
|
||||
Assert.Equal(@"C:\New", loaded.WorkingDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_Removes_List()
|
||||
{
|
||||
var entity = new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = "ToDelete",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _lists.AddAsync(entity);
|
||||
await _lists.DeleteAsync(entity.Id);
|
||||
|
||||
var loaded = await _lists.GetByIdAsync(entity.Id);
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_Returns_All_Lists()
|
||||
{
|
||||
var a = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "A", CreatedAt = DateTime.UtcNow.AddMinutes(-1) };
|
||||
var b = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "B", CreatedAt = DateTime.UtcNow };
|
||||
await _lists.AddAsync(a);
|
||||
await _lists.AddAsync(b);
|
||||
|
||||
var all = await _lists.GetAllAsync();
|
||||
Assert.True(all.Count >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagJunction_AddAndRemove()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "Tagged", CreatedAt = DateTime.UtcNow });
|
||||
var tagId = await _tags.GetOrCreateAsync("agent");
|
||||
|
||||
await _lists.AddTagAsync(listId, tagId);
|
||||
var tags = await _lists.GetTagsAsync(listId);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("agent", tags[0].Name);
|
||||
|
||||
await _lists.RemoveTagAsync(listId, tagId);
|
||||
tags = await _lists.GetTagsAsync(listId);
|
||||
Assert.Empty(tags);
|
||||
}
|
||||
}
|
||||
217
tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
Normal file
217
tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||
|
||||
public sealed class TaskRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
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);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private async Task<string> CreateListAsync(string? id = null)
|
||||
{
|
||||
var listId = id ?? Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = listId,
|
||||
Name = "Test List",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
return listId;
|
||||
}
|
||||
|
||||
private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Queued, DateTime? createdAt = null, DateTime? scheduledFor = null) => new()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "Test Task",
|
||||
Description = "A description",
|
||||
Status = status,
|
||||
ScheduledFor = scheduledFor,
|
||||
CreatedAt = createdAt ?? DateTime.UtcNow,
|
||||
CommitType = "feat",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_Roundtrips_AllFields()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "My Task",
|
||||
Description = "Desc",
|
||||
Status = TaskStatus.Queued,
|
||||
ScheduledFor = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc),
|
||||
Result = "some result",
|
||||
LogPath = "/tmp/log.ndjson",
|
||||
CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StartedAt = new DateTime(2026, 1, 1, 1, 0, 0, DateTimeKind.Utc),
|
||||
FinishedAt = new DateTime(2026, 1, 1, 2, 0, 0, DateTimeKind.Utc),
|
||||
CommitType = "feat",
|
||||
};
|
||||
|
||||
await _tasks.AddAsync(entity);
|
||||
var loaded = await _tasks.GetByIdAsync(entity.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(entity.Id, loaded.Id);
|
||||
Assert.Equal(entity.ListId, loaded.ListId);
|
||||
Assert.Equal(entity.Title, loaded.Title);
|
||||
Assert.Equal(entity.Description, loaded.Description);
|
||||
Assert.Equal(entity.Status, loaded.Status);
|
||||
Assert.Equal(entity.ScheduledFor!.Value.Date, loaded.ScheduledFor!.Value.Date);
|
||||
Assert.Equal(entity.Result, loaded.Result);
|
||||
Assert.Equal(entity.LogPath, loaded.LogPath);
|
||||
Assert.Equal(entity.CommitType, loaded.CommitType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextQueuedAgentTaskAsync_Returns_OldestWithAgentTag_ViaTaskTag()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||
|
||||
var older = MakeTask(listId, createdAt: DateTime.UtcNow.AddMinutes(-10));
|
||||
var newer = MakeTask(listId, createdAt: DateTime.UtcNow);
|
||||
await _tasks.AddAsync(older);
|
||||
await _tasks.AddAsync(newer);
|
||||
await _tasks.AddTagAsync(older.Id, agentTagId);
|
||||
await _tasks.AddTagAsync(newer.Id, agentTagId);
|
||||
|
||||
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
||||
Assert.NotNull(picked);
|
||||
Assert.Equal(older.Id, picked.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextQueuedAgentTaskAsync_Returns_TaskWithAgentTag_ViaListTag()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||
await _lists.AddTagAsync(listId, agentTagId);
|
||||
|
||||
var task = MakeTask(listId);
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
||||
Assert.NotNull(picked);
|
||||
Assert.Equal(task.Id, picked.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextQueuedAgentTaskAsync_ReturnsNull_WhenNoAgentTag()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var task = MakeTask(listId);
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
||||
Assert.Null(picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextQueuedAgentTaskAsync_Skips_FutureScheduledFor()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||
|
||||
var task = MakeTask(listId, scheduledFor: DateTime.UtcNow.AddHours(1));
|
||||
await _tasks.AddAsync(task);
|
||||
await _tasks.AddTagAsync(task.Id, agentTagId);
|
||||
|
||||
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
||||
Assert.Null(picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transitions_MarkRunning_ThenMarkDone()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var task = MakeTask(listId);
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
var startedAt = DateTime.UtcNow;
|
||||
await _tasks.MarkRunningAsync(task.Id, startedAt);
|
||||
var running = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Running, running!.Status);
|
||||
Assert.NotNull(running.StartedAt);
|
||||
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _tasks.MarkDoneAsync(task.Id, finishedAt, "All good");
|
||||
var done = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Done, done!.Status);
|
||||
Assert.Equal("All good", done.Result);
|
||||
Assert.NotNull(done.FinishedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlipAllRunningToFailedAsync_FlipsOnlyRunningRows()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var running1 = MakeTask(listId, status: TaskStatus.Running);
|
||||
var running2 = MakeTask(listId, status: TaskStatus.Running);
|
||||
var queued = MakeTask(listId, status: TaskStatus.Queued);
|
||||
var done = MakeTask(listId, status: TaskStatus.Done);
|
||||
|
||||
await _tasks.AddAsync(running1);
|
||||
await _tasks.AddAsync(running2);
|
||||
await _tasks.AddAsync(queued);
|
||||
await _tasks.AddAsync(done);
|
||||
|
||||
var flipped = await _tasks.FlipAllRunningToFailedAsync("worker restart");
|
||||
|
||||
Assert.Equal(2, flipped);
|
||||
|
||||
var r1 = await _tasks.GetByIdAsync(running1.Id);
|
||||
Assert.Equal(TaskStatus.Failed, r1!.Status);
|
||||
Assert.StartsWith("[stale] ", r1.Result);
|
||||
|
||||
var r2 = await _tasks.GetByIdAsync(running2.Id);
|
||||
Assert.Equal(TaskStatus.Failed, r2!.Status);
|
||||
|
||||
var q = await _tasks.GetByIdAsync(queued.Id);
|
||||
Assert.Equal(TaskStatus.Queued, q!.Status);
|
||||
|
||||
var d = await _tasks.GetByIdAsync(done.Id);
|
||||
Assert.Equal(TaskStatus.Done, d!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
|
||||
{
|
||||
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");
|
||||
|
||||
await _lists.AddTagAsync(listId, agentTagId);
|
||||
|
||||
var task = MakeTask(listId);
|
||||
await _tasks.AddAsync(task);
|
||||
await _tasks.AddTagAsync(task.Id, manualTagId);
|
||||
await _tasks.AddTagAsync(task.Id, codeTagId);
|
||||
|
||||
var effective = await _tasks.GetEffectiveTagsAsync(task.Id);
|
||||
var names = effective.Select(t => t.Name).OrderBy(n => n).ToList();
|
||||
|
||||
Assert.Equal(3, names.Count);
|
||||
Assert.Contains("agent", names);
|
||||
Assert.Contains("code", names);
|
||||
Assert.Contains("manual", names);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user