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:
@@ -477,6 +477,84 @@ public sealed class TaskStateServiceTests : IDisposable
|
||||
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
|
||||
}
|
||||
|
||||
// ─── MarkDoneAsync / UnmarkDoneAsync ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MarkDoneAsync_FromIdle_TransitionsToDone_AndStampsFinishedAt()
|
||||
{
|
||||
var id = await SeedTaskAsync(TaskStatus.Idle);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var result = await _sut.MarkDoneAsync(id, now, default);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var t = await GetTaskAsync(id);
|
||||
Assert.Equal(TaskStatus.Done, t.Status);
|
||||
Assert.Equal(now, t.FinishedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkDoneAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
|
||||
{
|
||||
var id = await SeedTaskAsync(TaskStatus.Running);
|
||||
|
||||
var result = await _sut.MarkDoneAsync(id, DateTime.UtcNow, default);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmarkDoneAsync_FromDone_TransitionsToIdle()
|
||||
{
|
||||
var id = await SeedTaskAsync(TaskStatus.Done);
|
||||
|
||||
var result = await _sut.UnmarkDoneAsync(id, default);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmarkDoneAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
|
||||
{
|
||||
var id = await SeedTaskAsync(TaskStatus.Running);
|
||||
|
||||
var result = await _sut.UnmarkDoneAsync(id, default);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
|
||||
}
|
||||
|
||||
// ─── DequeueToIdleAsync ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DequeueToIdleAsync_FromQueued_TransitionsToIdle_AndClearsBlockedByTaskId()
|
||||
{
|
||||
var pred = await SeedTaskAsync(TaskStatus.Queued);
|
||||
var id = await SeedTaskAsync(TaskStatus.Queued, blockedBy: pred);
|
||||
|
||||
var result = await _sut.DequeueToIdleAsync(id, default);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var t = await GetTaskAsync(id);
|
||||
Assert.Equal(TaskStatus.Idle, t.Status);
|
||||
Assert.Null(t.BlockedByTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DequeueToIdleAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
|
||||
{
|
||||
// Simulates the picker having already claimed the task between the UI reading
|
||||
// its row and the dequeue call landing.
|
||||
var id = await SeedTaskAsync(TaskStatus.Running);
|
||||
|
||||
var result = await _sut.DequeueToIdleAsync(id, default);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
|
||||
}
|
||||
|
||||
// ─── StartPlanningAsync ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user