fix(worker): route done-toggle and dequeue through guarded TaskStateService transitions
The task-list done toggle (both islands) and RemoveFromQueue wrote TaskEntity.Status directly via EF, bypassing TaskStateService: no TaskUpdated broadcast, no guard against a concurrent picker claim (lost update), and no status-based filter. Added guarded MarkDoneAsync/UnmarkDoneAsync/DequeueToIdleAsync transitions plus matching hub methods (SetTaskDone/UnsetTaskDone/DequeueTask) and IWorkerClient wrappers; the three UI call sites now route through the hub with optimistic-then-revert row updates and ErrorReported on failure. RemoveFromQueueAsync dequeues each queued child individually through the same guarded path instead of cascading via a raw EF update. Also closes two hub guard gaps: UpdateListConfig's delete branch now preserves a list's SerializeOnFileOverlap flag instead of dropping it, and SubmitTaskForReview's Idle/Failed status gate now runs before either mutation branch so a Done/Cancelled task can't get committed or stamped and then rejected.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
/// UpdateListConfig's "all fields blank -> delete the row" branch used to delete unconditionally,
|
||||
/// silently dropping SerializeOnFileOverlap -- a flag with no UI/hub affordance of its own (set
|
||||
/// only via set_list_config or directly against ListConfigEntity).
|
||||
public sealed class ListConfigHubTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private WorkerHub CreateHub()
|
||||
{
|
||||
var factory = _db.CreateFactory();
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, broadcaster, factory,
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
|
||||
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
}
|
||||
|
||||
// Each helper opens (and disposes) its own short-lived context/repository -- ListRepository's
|
||||
// GetConfigAsync doesn't AsNoTracking(), so reusing one long-lived instance across a hub call
|
||||
// that writes via a *different* context would return a stale, identity-mapped entity.
|
||||
|
||||
private async Task<string> SeedListAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await using var ctx = _db.CreateContext();
|
||||
await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
return listId;
|
||||
}
|
||||
|
||||
private async Task SeedConfigAsync(string listId, string? model = null, bool serializeOnFileOverlap = false)
|
||||
{
|
||||
await using var ctx = _db.CreateContext();
|
||||
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity
|
||||
{
|
||||
ListId = listId, Model = model, SerializeOnFileOverlap = serializeOnFileOverlap,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ListConfigEntity?> GetConfigAsync(string listId)
|
||||
{
|
||||
await using var ctx = _db.CreateContext();
|
||||
return await new ListRepository(ctx).GetConfigAsync(listId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateListConfig_AllBlank_NoUnrelatedSettings_DeletesRow()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
var listId = await SeedListAsync();
|
||||
await SeedConfigAsync(listId, model: "opus");
|
||||
|
||||
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null));
|
||||
|
||||
Assert.Null(await GetConfigAsync(listId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateListConfig_AllBlank_WithSerializeOnFileOverlap_KeepsFlag_RowSurvives()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
var listId = await SeedListAsync();
|
||||
await SeedConfigAsync(listId, model: "opus", serializeOnFileOverlap: true);
|
||||
|
||||
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null));
|
||||
|
||||
var config = await GetConfigAsync(listId);
|
||||
Assert.NotNull(config);
|
||||
Assert.True(config!.SerializeOnFileOverlap);
|
||||
Assert.Null(config.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateListConfig_WithModel_UpsertsNormally_PreservesSerializeOnFileOverlap()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
var listId = await SeedListAsync();
|
||||
await SeedConfigAsync(listId, serializeOnFileOverlap: true);
|
||||
|
||||
await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null));
|
||||
|
||||
var config = await GetConfigAsync(listId);
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("opus", config!.Model);
|
||||
Assert.True(config.SerializeOnFileOverlap);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user