using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Tests.Infrastructure; namespace ClaudeDo.Worker.Tests.External; public sealed class ConfigMcpToolsTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly ListRepository _lists; private readonly TaskRepository _tasks; private readonly ConfigMcpTools _sut; public ConfigMcpToolsTests() { _ctx = _db.CreateContext(); _lists = new ListRepository(_ctx); _tasks = new TaskRepository(_ctx); _sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new CapturingHubContext()), _db.CreateFactory()); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task SeedListAsync() { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); return id; } [Fact] public async Task SetAndGetListConfig_RoundTrips() { var listId = await SeedListAsync(); var setResult = await _sut.SetListConfig(listId, "sonnet", "be terse", null, 42, cancellationToken: CancellationToken.None); Assert.True(setResult.Ok); Assert.Equal(listId, setResult.ListId); Assert.NotNull(setResult.Config); Assert.Equal("sonnet", setResult.Config!.Model); var cfg = await _sut.GetListConfig(listId, CancellationToken.None); Assert.True(cfg.Found); Assert.NotNull(cfg.Config); Assert.Equal("sonnet", cfg.Config!.Model); Assert.Equal("be terse", cfg.Config.SystemPrompt); Assert.Null(cfg.Config.AgentPath); Assert.Equal(42, cfg.Config.MaxTurns); } [Fact] public async Task GetListConfig_NoConfigSet_ReturnsNotFound() { var listId = await SeedListAsync(); var cfg = await _sut.GetListConfig(listId, CancellationToken.None); Assert.False(cfg.Found); Assert.Null(cfg.Config); } [Fact] public async Task SetListConfig_PartialUpdate_LeavesOtherFieldsUntouched() { var listId = await SeedListAsync(); await _sut.SetListConfig(listId, "opus", "be terse", "agent.md", 30, cancellationToken: CancellationToken.None); var result = await _sut.SetListConfig(listId, maxTurns: 40, cancellationToken: CancellationToken.None); Assert.Equal("opus", result.Config!.Model); Assert.Equal("be terse", result.Config.SystemPrompt); Assert.Equal("agent.md", result.Config.AgentPath); Assert.Equal(40, result.Config.MaxTurns); } [Fact] public async Task SetListConfig_ClearFields_ClearsOnlyNamedField() { var listId = await SeedListAsync(); await _sut.SetListConfig(listId, "opus", "be terse", "agent.md", 30, cancellationToken: CancellationToken.None); var result = await _sut.SetListConfig(listId, clearFields: new[] { "model" }, cancellationToken: CancellationToken.None); Assert.Null(result.Config!.Model); Assert.Equal("be terse", result.Config.SystemPrompt); Assert.Equal("agent.md", result.Config.AgentPath); Assert.Equal(30, result.Config.MaxTurns); } [Fact] public async Task SetListConfig_AllFieldsCleared_DeletesConfig() { var listId = await SeedListAsync(); await _sut.SetListConfig(listId, "sonnet", null, null, null, cancellationToken: CancellationToken.None); var clearResult = await _sut.SetListConfig( listId, clearFields: new[] { "model", "systemPrompt", "agentPath", "maxTurns" }, cancellationToken: CancellationToken.None); Assert.True(clearResult.Ok); Assert.Null(clearResult.Config); var cfg = await _sut.GetListConfig(listId, CancellationToken.None); Assert.False(cfg.Found); } [Fact] public async Task SetListConfig_UnknownClearField_Throws() { var listId = await SeedListAsync(); await Assert.ThrowsAsync(() => _sut.SetListConfig(listId, clearFields: new[] { "bogus" }, cancellationToken: CancellationToken.None)); } [Fact] public async Task SetTaskConfig_PersistsOverrides() { var listId = await SeedListAsync(); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = ClaudeDo.Data.Models.TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); var result = await _sut.SetTaskConfig(task.Id, "opus", null, null, 15, cancellationToken: CancellationToken.None); Assert.True(result.Ok); Assert.Equal(task.Id, result.TaskId); Assert.Equal("opus", result.Config!.Model); Assert.Equal(15, result.Config.MaxTurns); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("opus", loaded!.Model); Assert.Equal(15, loaded.MaxTurns); } [Fact] public async Task SetTaskConfig_PartialUpdate_LeavesOtherOverridesUntouched() { var listId = await SeedListAsync(); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = ClaudeDo.Data.Models.TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); await _sut.SetTaskConfig(task.Id, "opus", "be terse", "agent.md", 15, cancellationToken: CancellationToken.None); var result = await _sut.SetTaskConfig(task.Id, maxTurns: 40, cancellationToken: CancellationToken.None); Assert.True(result.Ok); Assert.Equal("opus", result.Config!.Model); Assert.Equal("be terse", result.Config.SystemPrompt); Assert.Equal("agent.md", result.Config.AgentPath); Assert.Equal(40, result.Config.MaxTurns); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("opus", loaded!.Model); Assert.Equal("be terse", loaded.SystemPrompt); Assert.Equal(40, loaded.MaxTurns); } [Fact] public async Task SetTaskConfig_ClearFields_ClearsOnlyNamedOverride() { var listId = await SeedListAsync(); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = ClaudeDo.Data.Models.TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); await _sut.SetTaskConfig(task.Id, "opus", "be terse", "agent.md", 15, cancellationToken: CancellationToken.None); var result = await _sut.SetTaskConfig(task.Id, clearFields: new[] { "systemPrompt" }, cancellationToken: CancellationToken.None); Assert.True(result.Ok); Assert.Equal("opus", result.Config!.Model); Assert.Null(result.Config.SystemPrompt); Assert.Equal("agent.md", result.Config.AgentPath); Assert.Equal(15, result.Config.MaxTurns); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("opus", loaded!.Model); Assert.Null(loaded.SystemPrompt); Assert.Equal("agent.md", loaded.AgentPath); Assert.Equal(15, loaded.MaxTurns); } [Fact] public async Task SetTaskConfig_UnknownClearField_Throws() { var listId = await SeedListAsync(); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = ClaudeDo.Data.Models.TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); await Assert.ThrowsAsync(() => _sut.SetTaskConfig(task.Id, clearFields: new[] { "bogus" }, cancellationToken: CancellationToken.None)); } [Fact] public async Task SetListConfig_PreservesSettingsItDoesNotExpose() { var listId = await SeedListAsync(); await _lists.SetConfigAsync(new ListConfigEntity { ListId = listId, Model = "sonnet", SessionSkills = "[\"superpowers\"]", VerifyCommand = "dotnet build", SerializeOnFileOverlap = true, }); await _sut.SetListConfig(listId, model: "opus", cancellationToken: CancellationToken.None); var cfg = await _lists.GetConfigAsync(listId); Assert.NotNull(cfg); Assert.Equal("opus", cfg!.Model); Assert.Equal("[\"superpowers\"]", cfg.SessionSkills); Assert.Equal("dotnet build", cfg.VerifyCommand); // SetConfigAsync copies the entity verbatim, so a field this tool doesn't expose would // reset to its default unless it is carried over explicitly. Assert.True(cfg.SerializeOnFileOverlap); } [Fact] public async Task SetListConfig_ClearingEveryExposedField_KeepsRowWhenOtherSettingsRemain() { var listId = await SeedListAsync(); await _lists.SetConfigAsync(new ListConfigEntity { ListId = listId, Model = "sonnet", VerifyCommand = "dotnet build", SerializeOnFileOverlap = true, }); var result = await _sut.SetListConfig( listId, clearFields: new[] { "model", "systemPrompt", "agentPath", "maxTurns" }, cancellationToken: CancellationToken.None); Assert.True(result.Ok); Assert.Null(result.Config); var cfg = await _lists.GetConfigAsync(listId); Assert.NotNull(cfg); Assert.Null(cfg!.Model); Assert.Equal("dotnet build", cfg.VerifyCommand); Assert.True(cfg.SerializeOnFileOverlap); } [Fact] public async Task SetListConfig_ClearingEveryField_DeletesRowWhenNothingElseIsSet() { var listId = await SeedListAsync(); await _sut.SetListConfig(listId, "sonnet", cancellationToken: CancellationToken.None); await _sut.SetListConfig( listId, clearFields: new[] { "model", "systemPrompt", "agentPath", "maxTurns" }, cancellationToken: CancellationToken.None); Assert.Null(await _lists.GetConfigAsync(listId)); } [Fact] public async Task GetTaskConfig_NoOverrideSet_ReturnsNotFound() { var listId = await SeedListAsync(); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = ClaudeDo.Data.Models.TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); var cfg = await _sut.GetTaskConfig(task.Id, CancellationToken.None); Assert.False(cfg.Found); Assert.Null(cfg.Config); } }