Each batch tool now sends an i/n progress ping via ProgressReporter.ReportItem after processing every item, so a waiting agent doesn't see the MCP client's 300s idle-silence abort while the worker keeps looping. Covers BatchGetTasks, BatchAddTasks, BatchUpdateTaskStatus, BatchCancelTasks, BatchDeleteTasks, BatchSetMyDay, and BatchCleanupTaskWorktrees (the slowest of the seven, since it does git work per task).
625 lines
24 KiB
C#
625 lines
24 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ModelContextProtocol;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
// A synchronously-collecting IProgress<T>: Progress<T> marshals through the SynchronizationContext
|
|
// captured at construction, which is unreliable to assert on immediately in a test.
|
|
file sealed class SyncProgress<T> : IProgress<T>
|
|
{
|
|
public readonly List<T> Reports = new();
|
|
public void Report(T value) => Reports.Add(value);
|
|
}
|
|
|
|
public sealed class BatchMcpToolsTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly List<GitRepoFixture> _repos = new();
|
|
|
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
|
|
|
public BatchMcpToolsTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_broadcaster = new HubBroadcaster(new CapturingHubContext());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var r in _repos) r.Dispose();
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
}
|
|
|
|
private async Task<string> SeedListAsync(string? workingDir = null)
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow, WorkingDir = workingDir });
|
|
return id;
|
|
}
|
|
|
|
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle)
|
|
{
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = title,
|
|
Status = status,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private BatchMcpTools BuildSut()
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
var svc = new ExternalMcpService(
|
|
_tasks, _lists, CreateQueue(), _broadcaster,
|
|
state,
|
|
git, factory, maintenance, merge, planningMerge,
|
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
|
return new BatchMcpTools(svc);
|
|
}
|
|
|
|
private QueueService CreateQueue()
|
|
{
|
|
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_batch_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(tempDir);
|
|
var cfg = new WorkerConfig
|
|
{
|
|
SandboxRoot = Path.Combine(tempDir, "sandbox"),
|
|
LogRoot = Path.Combine(tempDir, "logs"),
|
|
QueueBackstopIntervalMs = 50,
|
|
};
|
|
var dbFactory = _db.CreateFactory();
|
|
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
|
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
|
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg,
|
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
|
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
|
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
|
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance,
|
|
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels,
|
|
new FakeUsageGate(), new UsageState(), broadcaster);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchAddTasks_CreatesAll_AndReportsOkPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchAddTasks(listId, new[]
|
|
{
|
|
new BatchAddTaskInput("a"),
|
|
new BatchAddTaskInput("b"),
|
|
new BatchAddTaskInput("c"),
|
|
}, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(3, results.Count);
|
|
Assert.All(results, r => Assert.True(r.Ok));
|
|
Assert.Equal(new[] { 0, 1, 2 }, results.Select(r => r.Index).ToArray());
|
|
var inList = await _tasks.GetByListIdAsync(listId);
|
|
Assert.Equal(3, inList.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchAddTasks_FailingItem_DoesNotAbortTheRest()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchAddTasks(listId, new[]
|
|
{
|
|
new BatchAddTaskInput("ok-1"),
|
|
new BatchAddTaskInput(" "), // blank title → AddTask throws
|
|
new BatchAddTaskInput("ok-2"),
|
|
}, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(results[0].Ok);
|
|
Assert.False(results[1].Ok);
|
|
Assert.NotNull(results[1].Error);
|
|
Assert.True(results[2].Ok);
|
|
var inList = await _tasks.GetByListIdAsync(listId);
|
|
Assert.Equal(2, inList.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchAddTasks_SimilarTitleInSameList_ReportsPossibleDuplicate()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchAddTasks(listId, new[]
|
|
{
|
|
new BatchAddTaskInput("Settings: MaxTurnsCeiling editierbar machen"),
|
|
}, cancellationToken: CancellationToken.None);
|
|
|
|
var result = Assert.Single(results);
|
|
Assert.True(result.Ok);
|
|
Assert.NotNull(result.PossibleDuplicates);
|
|
Assert.Single(result.PossibleDuplicates!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_MissingId_IsFoundFalseNotError()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(new[] { task.Id, "nope" }, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id);
|
|
var missing = results.Single(r => r.Id == "nope");
|
|
Assert.True(found.Found);
|
|
Assert.NotNull(found.Task);
|
|
Assert.Null(found.TaskFull);
|
|
Assert.False(missing.Found);
|
|
Assert.Null(missing.Task);
|
|
Assert.Null(missing.TaskFull);
|
|
Assert.Null(missing.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_Default_ReturnsLeanTask_NoTaskFull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.Description = "a description that should not come back by default";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(new[] { task.Id }, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id);
|
|
Assert.True(found.Found);
|
|
Assert.NotNull(found.Task);
|
|
Assert.Equal(task.Id, found.Task!.Id);
|
|
Assert.Equal(task.Number, found.Task!.Number);
|
|
Assert.Null(found.TaskFull);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_MixedNumberAndGuidIds_ResolvesBoth()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var a = await SeedTaskAsync(listId);
|
|
var b = await SeedTaskAsync(listId);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(new[] { $"#{a.Number}", b.Id }, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(results[0].Found);
|
|
Assert.Equal(a.Id, results[0].Task!.Id);
|
|
Assert.True(results[1].Found);
|
|
Assert.Equal(b.Id, results[1].Task!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_IncludeDescriptionTrue_ReturnsTaskFull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.Description = "the full description";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(new[] { task.Id }, includeDescription: true, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id);
|
|
Assert.True(found.Found);
|
|
Assert.Null(found.Task);
|
|
Assert.NotNull(found.TaskFull);
|
|
Assert.Equal("the full description", found.TaskFull!.Description);
|
|
Assert.Equal(task.Number, found.TaskFull!.Number);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_DescriptionLongerThanCap_IsTruncated_WithFlagAndFullLength()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var full = new string('x', 50);
|
|
task.Description = full;
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(
|
|
new[] { task.Id }, includeDescription: true, descriptionMaxChars: 10, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id).TaskFull!;
|
|
Assert.Equal(new string('x', 10), found.Description);
|
|
Assert.True(found.DescriptionTruncated);
|
|
Assert.Equal(50, found.DescriptionFullLength);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_DescriptionUnderCap_IsNotTruncated()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.Description = "short";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(
|
|
new[] { task.Id }, includeDescription: true, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id).TaskFull!;
|
|
Assert.Equal("short", found.Description);
|
|
Assert.False(found.DescriptionTruncated);
|
|
Assert.Null(found.DescriptionFullLength);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_FieldsRestrictsPayload_ToRequestedFieldsOnly()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.Description = "a description that should not come back when fields excludes it";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(
|
|
new[] { task.Id }, includeDescription: true, fields: new[] { "title", "status" },
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id).TaskFull!;
|
|
Assert.Equal(task.Title, found.Title);
|
|
Assert.NotNull(found.Status);
|
|
Assert.Null(found.Description);
|
|
Assert.Null(found.ListId);
|
|
Assert.Null(found.RoadblockCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_UnknownField_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut();
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.BatchGetTasks(
|
|
new[] { task.Id }, includeDescription: true, fields: new[] { "notAField" },
|
|
cancellationToken: CancellationToken.None));
|
|
Assert.Contains("notAField", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_RoadblockTextField_ExtractsTailOfResult_WithoutFullResult()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.Result = "the task's own result\n\n⚠ Roadblocks reported during the run:\n- missing credentials";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(
|
|
new[] { task.Id }, includeDescription: true, fields: new[] { "roadblockText" },
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id).TaskFull!;
|
|
Assert.Equal("- missing credentials", found.RoadblockText);
|
|
Assert.Null(found.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_NoRoadblockMarker_RoadblockTextIsNull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.Result = "plain result, no roadblocks";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(
|
|
new[] { task.Id }, includeDescription: true, fields: new[] { "roadblockText" },
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Null(results.Single(r => r.Id == task.Id).TaskFull!.RoadblockText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_OversizedResponse_ThrowsNamingTheFixingParameters()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var ids = new List<string>();
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
var task = await SeedTaskAsync(listId, $"t{i}");
|
|
task.Description = new string('d', 6000);
|
|
await _tasks.UpdateAsync(task);
|
|
ids.Add(task.Id);
|
|
}
|
|
var sut = BuildSut();
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.BatchGetTasks(
|
|
ids.ToArray(), includeDescription: true, descriptionMaxChars: 6000, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("descriptionMaxChars", ex.Message);
|
|
Assert.Contains("fields", ex.Message);
|
|
Assert.Contains("taskIds", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_TenTasksWithDescriptions_StaysWithinTokenBudget()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var ids = new List<string>();
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
var task = await SeedTaskAsync(listId, $"t{i}");
|
|
task.Description = new string('d', 5000);
|
|
await _tasks.UpdateAsync(task);
|
|
ids.Add(task.Id);
|
|
}
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(ids.ToArray(), includeDescription: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(10, results.Count);
|
|
Assert.All(results, r => Assert.True(r.TaskFull!.DescriptionTruncated));
|
|
var json = System.Text.Json.JsonSerializer.Serialize(results);
|
|
Assert.True(json.Length < 25_000, $"response was {json.Length} chars, expected < 25000");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_QueuedWithUnmetDependsOn_ReportsBlocked()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
|
task.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(new[] { task.Id }, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id);
|
|
Assert.True(found.Task!.Blocked);
|
|
Assert.Contains(predecessor.Id, found.Task!.BlockedReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var deletable = await SeedTaskAsync(listId, "del", TaskStatus.Idle);
|
|
var running = await SeedTaskAsync(listId, "run", TaskStatus.Running);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchDeleteTasks(new[] { deletable.Id, running.Id }, CancellationToken.None);
|
|
|
|
Assert.True(results.Single(r => r.TaskId == deletable.Id).Ok);
|
|
Assert.False(results.Single(r => r.TaskId == running.Id).Ok);
|
|
Assert.Null(await _tasks.GetByIdAsync(deletable.Id));
|
|
Assert.NotNull(await _tasks.GetByIdAsync(running.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchUpdateTaskStatus_QueuesIdleTasks()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None);
|
|
|
|
Assert.All(results, r => Assert.True(r.Ok));
|
|
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t1.Id))!.Status);
|
|
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchUpdateTaskStatus_DirtyBaseRepo_ReportsWarningPerItem_OneListOneRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
|
|
|
|
var listId = await SeedListAsync(repo.RepoDir);
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
|
|
// Two tasks queued from the same list -- BaseDirtyChecker's TTL cache means only one
|
|
// `git status` actually runs underneath, but both items still see the warning.
|
|
var results = await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None);
|
|
|
|
Assert.All(results, r => Assert.True(r.Ok));
|
|
Assert.All(results, r =>
|
|
{
|
|
Assert.NotNull(r.BaseDirty);
|
|
Assert.Equal(1, r.BaseDirty!.UntrackedCount);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchUpdateTaskStatus_Done_MixedWorktreeState_ReportsPerItemAndDoesNotAbort()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var noWorktree = await SeedTaskAsync(listId, "no-wt", TaskStatus.Idle);
|
|
var missing = "does-not-exist";
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchUpdateTaskStatus(
|
|
new[] { noWorktree.Id, missing }, "Done", CancellationToken.None);
|
|
|
|
Assert.True(results.Single(r => r.TaskId == noWorktree.Id).Ok);
|
|
Assert.False(results.Single(r => r.TaskId == missing).Ok);
|
|
Assert.Equal(TaskStatus.Done, (await _tasks.GetByIdAsync(noWorktree.Id))!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchTools_RejectEmptyBatch()
|
|
{
|
|
var sut = BuildSut();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.BatchGetTasks(Array.Empty<string>(), cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchTools_RejectOversizedBatch()
|
|
{
|
|
var sut = BuildSut();
|
|
var ids = Enumerable.Range(0, 101).Select(i => i.ToString()).ToArray();
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.BatchGetTasks(ids, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("max", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_ReportsProgressPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var a = await SeedTaskAsync(listId);
|
|
var b = await SeedTaskAsync(listId);
|
|
var c = await SeedTaskAsync(listId);
|
|
var sut = BuildSut();
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
await sut.BatchGetTasks(new[] { a.Id, b.Id, c.Id }, cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(3, progress.Reports.Count);
|
|
Assert.Equal(3, progress.Reports[2].Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchAddTasks_ReportsProgressPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = BuildSut();
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
await sut.BatchAddTasks(listId, new[]
|
|
{
|
|
new BatchAddTaskInput("a"),
|
|
new BatchAddTaskInput("b"),
|
|
}, cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(2, progress.Reports.Count);
|
|
Assert.Equal(2, progress.Reports[1].Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchUpdateTaskStatus_ReportsProgressPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None, progress);
|
|
|
|
Assert.Equal(2, progress.Reports.Count);
|
|
Assert.Equal(2, progress.Reports[1].Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchCancelTasks_ReportsProgressPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
await sut.BatchCancelTasks(new[] { t1.Id, t2.Id }, CancellationToken.None, progress);
|
|
|
|
Assert.Equal(2, progress.Reports.Count);
|
|
Assert.Equal(2, progress.Reports[1].Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchDeleteTasks_ReportsProgressPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
await sut.BatchDeleteTasks(new[] { t1.Id, t2.Id }, CancellationToken.None, progress);
|
|
|
|
Assert.Equal(2, progress.Reports.Count);
|
|
Assert.Equal(2, progress.Reports[1].Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchSetMyDay_ReportsProgressPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
await sut.BatchSetMyDay(new[]
|
|
{
|
|
new BatchSetMyDayInput(t1.Id, true),
|
|
new BatchSetMyDayInput(t2.Id, true),
|
|
}, CancellationToken.None, progress);
|
|
|
|
Assert.Equal(2, progress.Reports.Count);
|
|
Assert.Equal(2, progress.Reports[1].Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchCleanupTaskWorktrees_ReportsProgressPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
// Neither task has a worktree -- each item fails individually (ok=false), but the batch
|
|
// still reports progress per item since the loop doesn't abort on a per-item error.
|
|
await sut.BatchCleanupTaskWorktrees(new[] { t1.Id, t2.Id }, cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(2, progress.Reports.Count);
|
|
Assert.Equal(2, progress.Reports[1].Total);
|
|
}
|
|
}
|