TaskIdResolver resolves a #123/bare-123 taskId parameter to its GUID before any lookup, across every External/ MCP tool that takes a task id, including the batch tools' id arrays (via delegation to the already-resolving single-entity methods) and update_task's dependsOnTaskId (empty string still passes through unchanged as the clear-link sentinel). An unknown number throws a clear error instead of a silent null. McpToolDocs.TaskNumberHint tells the agent to refer to tasks as #<number> when reporting to the user, added to the description of get_task, list_tasks, add_task, update_task_status and review_task.
105 lines
3.1 KiB
C#
105 lines
3.1 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
public sealed class TaskIdResolverTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
|
|
public TaskIdResolverTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
}
|
|
|
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
|
|
|
private async Task<TaskEntity> SeedTaskAsync()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
|
|
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAsync_HashNumber_ResolvesToGuid()
|
|
{
|
|
var task = await SeedTaskAsync();
|
|
|
|
var resolved = await TaskIdResolver.ResolveAsync(_tasks, $"#{task.Number}", CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, resolved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAsync_BareNumber_ResolvesToGuid()
|
|
{
|
|
var task = await SeedTaskAsync();
|
|
|
|
var resolved = await TaskIdResolver.ResolveAsync(_tasks, task.Number.ToString(), CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, resolved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAsync_Guid_PassesThroughUnchanged()
|
|
{
|
|
var task = await SeedTaskAsync();
|
|
|
|
var resolved = await TaskIdResolver.ResolveAsync(_tasks, task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, resolved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAsync_UnknownNumber_ThrowsWithNumberInMessage()
|
|
{
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
TaskIdResolver.ResolveAsync(_tasks, "#999999", CancellationToken.None));
|
|
|
|
Assert.Contains("999999", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveOptionalAsync_EmptyString_PassesThroughUnchanged()
|
|
{
|
|
var resolved = await TaskIdResolver.ResolveOptionalAsync(_tasks, "", CancellationToken.None);
|
|
|
|
Assert.Equal("", resolved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveOptionalAsync_Null_ReturnsNull()
|
|
{
|
|
var resolved = await TaskIdResolver.ResolveOptionalAsync(_tasks, null, CancellationToken.None);
|
|
|
|
Assert.Null(resolved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveManyAsync_MixedArray_ResolvesBoth()
|
|
{
|
|
var a = await SeedTaskAsync();
|
|
var b = await SeedTaskAsync();
|
|
|
|
var resolved = await TaskIdResolver.ResolveManyAsync(_tasks, [$"#{a.Number}", b.Id], CancellationToken.None);
|
|
|
|
Assert.Equal(new[] { a.Id, b.Id }, resolved);
|
|
}
|
|
}
|