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 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> InstallAsync(string url, CancellationToken ct) { InstallUrl = url; if (ThrowOnInstall is not null) throw ThrowOnInstall; return Task.FromResult>(new List { "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> ListAsync(CancellationToken ct) => Task.FromResult>(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(), 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 { "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( () => 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 { "ponytail", "ponytail-help" } }); var reloaded = await hub.GetAppSettings(); Assert.Equal(new List { "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 { "ponytail" } }); await hub.UpdateAppSettings(current with { SessionSkills = new List() }); 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 { "ponytail" })); var config = await hub.GetListConfig(listId); Assert.NotNull(config); Assert.Equal(new List { "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 { "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); } }