feat(worker): session skills SignalR surface + per-level persistence
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
public sealed class SessionSkillsHubTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
|
||||
{
|
||||
public List<SessionSkillEntity> Skills { get; } = new();
|
||||
public string? InstallUrl { get; private set; }
|
||||
public string? UpdateSourceUrl { get; private set; }
|
||||
public string? RemoveSourceUrl { get; private set; }
|
||||
public Exception? ThrowOnInstall { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct)
|
||||
{
|
||||
InstallUrl = url;
|
||||
if (ThrowOnInstall is not null) throw ThrowOnInstall;
|
||||
return Task.FromResult<IReadOnlyList<string>>(new List<string> { "ponytail", "ponytail-help" });
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string sourceUrl, CancellationToken ct)
|
||||
{
|
||||
UpdateSourceUrl = sourceUrl;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string sourceUrl, CancellationToken ct)
|
||||
{
|
||||
RemoveSourceUrl = sourceUrl;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Skills);
|
||||
}
|
||||
|
||||
private (WorkerHub hub, FakeSessionSkillRegistry registry) CreateHub()
|
||||
{
|
||||
var registry = new FakeSessionSkillRegistry();
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
|
||||
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!, registry);
|
||||
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return (hub, registry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionSkills_maps_registry_rows_to_dtos()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
registry.Skills.Add(new SessionSkillEntity
|
||||
{
|
||||
Name = "ponytail",
|
||||
SourceUrl = "https://example.com/skills.git",
|
||||
PinnedRef = "abc123",
|
||||
Subpath = "skills/ponytail",
|
||||
Description = "A skill",
|
||||
AddedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
var result = await hub.GetSessionSkills();
|
||||
|
||||
var dto = Assert.Single(result);
|
||||
Assert.Equal("ponytail", dto.Name);
|
||||
Assert.Equal("A skill", dto.Description);
|
||||
Assert.Equal("https://example.com/skills.git", dto.SourceUrl);
|
||||
Assert.Equal("abc123", dto.PinnedRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallSessionSkill_returns_installed_names_and_forwards_url()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
|
||||
var installed = await hub.InstallSessionSkill("https://example.com/skills.git");
|
||||
|
||||
Assert.Equal(new List<string> { "ponytail", "ponytail-help" }, installed);
|
||||
Assert.Equal("https://example.com/skills.git", registry.InstallUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallSessionSkill_wraps_InvalidOperationException_as_HubException()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
registry.ThrowOnInstall = new InvalidOperationException("boom");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Microsoft.AspNetCore.SignalR.HubException>(
|
||||
() => hub.InstallSessionSkill("https://example.com/skills.git"));
|
||||
Assert.Equal("boom", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionSkill_forwards_source_url()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
|
||||
await hub.UpdateSessionSkill("https://example.com/skills.git");
|
||||
|
||||
Assert.Equal("https://example.com/skills.git", registry.UpdateSourceUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveSessionSkill_forwards_source_url()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
|
||||
await hub.RemoveSessionSkill("https://example.com/skills.git");
|
||||
|
||||
Assert.Equal("https://example.com/skills.git", registry.RemoveSourceUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAppSettings_then_GetAppSettings_RoundTrips_SessionSkills()
|
||||
{
|
||||
var (hub, _) = CreateHub();
|
||||
var current = await hub.GetAppSettings();
|
||||
|
||||
await hub.UpdateAppSettings(current with { SessionSkills = new List<string> { "ponytail", "ponytail-help" } });
|
||||
var reloaded = await hub.GetAppSettings();
|
||||
|
||||
Assert.Equal(new List<string> { "ponytail", "ponytail-help" }, reloaded.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAppSettings_EmptySessionSkills_PersistsAsNull()
|
||||
{
|
||||
var (hub, _) = CreateHub();
|
||||
var current = await hub.GetAppSettings();
|
||||
|
||||
await hub.UpdateAppSettings(current with { SessionSkills = new List<string> { "ponytail" } });
|
||||
await hub.UpdateAppSettings(current with { SessionSkills = new List<string>() });
|
||||
var reloaded = await hub.GetAppSettings();
|
||||
|
||||
Assert.Null(reloaded.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateListConfig_then_GetListConfig_RoundTrips_SessionSkills()
|
||||
{
|
||||
var (hub, _) = CreateHub();
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, null, new List<string> { "ponytail" }));
|
||||
var config = await hub.GetListConfig(listId);
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(new List<string> { "ponytail" }, config!.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTaskAgentSettings_Persists_SessionSkills()
|
||||
{
|
||||
var (hub, _) = CreateHub();
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
await new TaskRepository(ctx).AddAsync(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
await hub.UpdateTaskAgentSettings(new UpdateTaskAgentSettingsDto(
|
||||
taskId, null, null, null, null, new List<string> { "ponytail", "ponytail-help" }));
|
||||
|
||||
using var readCtx = _db.CreateContext();
|
||||
var entity = await new TaskRepository(readCtx).GetByIdAsync(taskId);
|
||||
Assert.NotNull(entity);
|
||||
Assert.Equal("[\"ponytail\",\"ponytail-help\"]", entity!.SessionSkills);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user