330 lines
12 KiB
C#
330 lines
12 KiB
C#
using ClaudeDo.Data;
|
|
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 TaskRepositoryPlanningTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly TagRepository _tags;
|
|
|
|
public TaskRepositoryPlanningTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_tags = new TagRepository(_ctx);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ctx.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.Manual, string? parentId = null) => new()
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "t",
|
|
Status = status,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "feat",
|
|
ParentTaskId = parentId,
|
|
};
|
|
|
|
[Fact]
|
|
public async Task GetChildrenAsync_ReturnsOnlyDirectChildren_Sorted()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
|
parent.Title = "parent";
|
|
await _tasks.AddAsync(parent);
|
|
|
|
var childA = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
|
|
childA.Title = "a";
|
|
await _tasks.AddAsync(childA);
|
|
childA.SortOrder = 1;
|
|
await _tasks.UpdateAsync(childA);
|
|
|
|
var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
|
|
childB.Title = "b";
|
|
await _tasks.AddAsync(childB);
|
|
childB.SortOrder = 0;
|
|
await _tasks.UpdateAsync(childB);
|
|
|
|
var unrelated = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(unrelated);
|
|
|
|
var children = await _tasks.GetChildrenAsync(parent.Id);
|
|
|
|
Assert.Equal(2, children.Count);
|
|
Assert.Equal("b", children[0].Title);
|
|
Assert.Equal("a", children[1].Title);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateChildAsync_CreatesDraftUnderParent()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
|
await _tasks.AddAsync(parent);
|
|
|
|
var child = await _tasks.CreateChildAsync(
|
|
parent.Id,
|
|
title: "child title",
|
|
description: "child desc",
|
|
tagNames: new[] { "agent" },
|
|
commitType: "feat");
|
|
|
|
Assert.Equal(TaskStatus.Draft, child.Status);
|
|
Assert.Equal(parent.Id, child.ParentTaskId);
|
|
Assert.Equal(listId, child.ListId);
|
|
Assert.Equal("child title", child.Title);
|
|
Assert.Equal("child desc", child.Description);
|
|
Assert.Equal("feat", child.CommitType);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(child.Id);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(TaskStatus.Draft, loaded!.Status);
|
|
|
|
var tags = await _tasks.GetTagsAsync(child.Id);
|
|
Assert.Contains(tags, t => t.Name == "agent");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateChildAsync_ThrowsIfParentNotFound()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
_ = listId; // just to create the DB
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetPlanningStartedAsync_ManualTask_TransitionsToPlanning()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(task);
|
|
|
|
var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-abc");
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(TaskStatus.Planning, result!.Status);
|
|
Assert.Equal("tok-abc", result.PlanningSessionToken);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
|
Assert.Equal("tok-abc", loaded.PlanningSessionToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetPlanningStartedAsync_NonManualTask_ReturnsNull()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Queued);
|
|
await _tasks.AddAsync(task);
|
|
|
|
var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-xyz");
|
|
|
|
Assert.Null(result);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Queued, loaded!.Status);
|
|
Assert.Null(loaded.PlanningSessionToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdatePlanningSessionIdAsync_StoresClaudeSessionId()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(task);
|
|
await _tasks.SetPlanningStartedAsync(task.Id, "tok");
|
|
|
|
await _tasks.UpdatePlanningSessionIdAsync(task.Id, "claude-session-42");
|
|
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal("claude-session-42", loaded!.PlanningSessionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindByPlanningTokenAsync_ReturnsTask_WhenTokenMatches()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(task);
|
|
await _tasks.SetPlanningStartedAsync(task.Id, "unique-token-123");
|
|
|
|
var found = await _tasks.FindByPlanningTokenAsync("unique-token-123");
|
|
|
|
Assert.NotNull(found);
|
|
Assert.Equal(task.Id, found!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindByPlanningTokenAsync_ReturnsNull_WhenTokenUnknown()
|
|
{
|
|
var found = await _tasks.FindByPlanningTokenAsync("no-such-token");
|
|
Assert.Null(found);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FinalizePlanningAsync_TransitionsDraftsAndParent()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
|
|
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, tagNames: new[] { "agent" }, commitType: null);
|
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, tagNames: null, commitType: null);
|
|
|
|
var count = await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
|
|
|
|
Assert.Equal(2, count);
|
|
|
|
var c1Loaded = await _tasks.GetByIdAsync(c1.Id);
|
|
var c2Loaded = await _tasks.GetByIdAsync(c2.Id);
|
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
|
|
|
Assert.Equal(TaskStatus.Queued, c1Loaded!.Status);
|
|
Assert.Equal(TaskStatus.Manual, c2Loaded!.Status);
|
|
Assert.Equal(TaskStatus.Planned, parentLoaded!.Status);
|
|
Assert.NotNull(parentLoaded.PlanningFinalizedAt);
|
|
Assert.Null(parentLoaded.PlanningSessionToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FinalizePlanningAsync_QueueAgentTasksFalse_AllToManual()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: new[] { "agent" }, commitType: null);
|
|
|
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
|
|
|
|
var cLoaded = await _tasks.GetByIdAsync(c.Id);
|
|
Assert.Equal(TaskStatus.Manual, cLoaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FinalizePlanningAsync_ParentWithAgentListTag_ChildIsQueued()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
|
await _lists.AddTagAsync(listId, agentTagId);
|
|
|
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: null, commitType: null);
|
|
|
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
|
|
|
|
var cLoaded = await _tasks.GetByIdAsync(c.Id);
|
|
Assert.Equal(TaskStatus.Queued, cLoaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42");
|
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
|
|
|
var ok = await _tasks.DiscardPlanningAsync(parent.Id);
|
|
|
|
Assert.True(ok);
|
|
Assert.Null(await _tasks.GetByIdAsync(c1.Id));
|
|
Assert.Null(await _tasks.GetByIdAsync(c2.Id));
|
|
|
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Manual, parentLoaded!.Status);
|
|
Assert.Null(parentLoaded.PlanningSessionId);
|
|
Assert.Null(parentLoaded.PlanningSessionToken);
|
|
Assert.Null(parentLoaded.PlanningFinalizedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardPlanningAsync_OnNonPlanningTask_ReturnsFalse()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(task);
|
|
|
|
var ok = await _tasks.DiscardPlanningAsync(task.Id);
|
|
|
|
Assert.False(ok);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteAsync_ParentWithChildren_ThrowsOrDoesNotDelete()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
|
|
|
// ExecuteDelete bypasses EF change tracking, so SQLite's FK enforcement
|
|
// (foreign_keys = ON, set by ClaudeDoDbContext) throws SqliteException directly.
|
|
await Assert.ThrowsAsync<Microsoft.Data.Sqlite.SqliteException>(async () =>
|
|
{
|
|
await _tasks.DeleteAsync(parent.Id);
|
|
});
|
|
|
|
var stillThere = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.NotNull(stillThere);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
|
|
|
async Task<TaskEntity> T(TaskStatus s, bool withTag, string? parent = null)
|
|
{
|
|
var t = MakeTask(listId, s, parentId: parent);
|
|
await _tasks.AddAsync(t);
|
|
if (withTag) await _tasks.AddTagAsync(t.Id, agentTagId);
|
|
return t;
|
|
}
|
|
|
|
var planning = await T(TaskStatus.Planning, withTag: true);
|
|
var planned = await T(TaskStatus.Planned, withTag: true);
|
|
var draft = await T(TaskStatus.Draft, withTag: true, parent: planning.Id);
|
|
var queued = await T(TaskStatus.Queued, withTag: true);
|
|
|
|
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
|
|
|
Assert.NotNull(picked);
|
|
Assert.Equal(queued.Id, picked!.Id);
|
|
}
|
|
}
|