feat(worker): broadcast child TaskUpdated events on planning CRUD

So the UI refreshes individual child rows alongside the parent during
create/update/delete/finalize from the planning MCP service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-24 14:54:46 +02:00
parent e62485db3b
commit 5a03dc8430
2 changed files with 95 additions and 15 deletions

View File

@@ -16,29 +16,34 @@ file sealed class FakeHttpContextAccessor : IHttpContextAccessor
public HttpContext? HttpContext { get; set; }
}
file sealed class NullHubClients : IHubClients
file sealed class RecordingHubClients : IHubClients
{
public IClientProxy All => NullClientProxy.Instance;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
public IClientProxy Client(string connectionId) => NullClientProxy.Instance;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NullClientProxy.Instance;
public IClientProxy Group(string groupName) => NullClientProxy.Instance;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NullClientProxy.Instance;
public IClientProxy User(string userId) => NullClientProxy.Instance;
public IClientProxy Users(IReadOnlyList<string> userIds) => NullClientProxy.Instance;
public RecordingClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Client(string connectionId) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
public IClientProxy Group(string groupName) => Proxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
public IClientProxy User(string userId) => Proxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
}
file sealed class NullClientProxy : IClientProxy
file sealed class RecordingClientProxy : IClientProxy
{
public static readonly NullClientProxy Instance = new();
public List<(string Method, object?[] Args)> Calls { get; } = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
{
Calls.Add((method, args));
return Task.CompletedTask;
}
}
file sealed class FakeHubContext : IHubContext<WorkerHub>
{
public IHubClients Clients { get; } = new NullHubClients();
public RecordingHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
@@ -58,15 +63,25 @@ public sealed class PlanningMcpServiceTests : IDisposable
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private List<(string Method, object?[] Args)> _hubCalls = new();
private PlanningMcpService BuildSut(string parentTaskId)
{
var httpContext = new DefaultHttpContext();
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext });
var broadcaster = new HubBroadcaster(new FakeHubContext());
var hub = new FakeHubContext();
_hubCalls = hub.RecordingClients.Proxy.Calls;
var broadcaster = new HubBroadcaster(hub);
return new PlanningMcpService(_tasks, accessor, broadcaster);
}
private IReadOnlyList<string> TaskUpdatedIds() =>
_hubCalls
.Where(c => c.Method == "TaskUpdated")
.Select(c => (string)c.Args[0]!)
.ToList();
private async Task<TaskEntity> SeedPlanningParentAsync()
{
var listId = Guid.NewGuid().ToString();
@@ -178,4 +193,62 @@ public sealed class PlanningMcpServiceTests : IDisposable
Assert.Equal(TaskStatus.Planned, loaded!.Status);
Assert.Null(loaded.PlanningSessionToken);
}
[Fact]
public async Task CreateChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id);
var result = await sut.CreateChildTask("c", null, null, null, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(result.TaskId, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task UpdateChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await sut.UpdateChildTask(c.Id, "new title", null, null, null, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c.Id, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task DeleteChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
var sut = BuildSut(parent.Id);
await sut.DeleteChildTask(c.Id, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c.Id, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task Finalize_BroadcastsEachChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
var sut = BuildSut(parent.Id);
await sut.Finalize(true, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c1.Id, ids);
Assert.Contains(c2.Id, ids);
Assert.Contains(parent.Id, ids);
}
}