Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
T
mika kuns 095d216bda feat(external-mcp): let set_list_config write the verify command
SetListConfig previously could only pass VerifyCommand through unchanged;
only the UI hub could set it. Add an optional verifyCommand parameter with
matching clear/merge/create-vs-delete handling, and split the list result
into ListConfigDto so the task-only SetTaskConfig/GetTaskConfig DTOs stay
untouched. Also corrects Worker/CLAUDE.md's claim that tasks can override
verify_command — it's list-only.
2026-08-11 08:52:03 +02:00

390 lines
14 KiB
C#

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<string> 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<InvalidOperationException>(() =>
_sut.SetListConfig(listId, clearFields: new[] { "bogus" }, cancellationToken: CancellationToken.None));
}
[Fact]
public async Task SetListConfig_SetsVerifyCommand()
{
var listId = await SeedListAsync();
var result = await _sut.SetListConfig(listId, verifyCommand: "dotnet test", cancellationToken: CancellationToken.None);
Assert.Equal("dotnet test", result.Config!.VerifyCommand);
var cfg = await _sut.GetListConfig(listId, CancellationToken.None);
Assert.Equal("dotnet test", cfg.Config!.VerifyCommand);
}
[Fact]
public async Task SetListConfig_OverwritesVerifyCommand()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, verifyCommand: "dotnet test", cancellationToken: CancellationToken.None);
var result = await _sut.SetListConfig(listId, verifyCommand: "dotnet build", cancellationToken: CancellationToken.None);
Assert.Equal("dotnet build", result.Config!.VerifyCommand);
}
[Fact]
public async Task SetListConfig_ClearVerifyCommand_ClearsOnlyThatField()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "opus", verifyCommand: "dotnet test", cancellationToken: CancellationToken.None);
var result = await _sut.SetListConfig(listId, clearFields: new[] { "verifyCommand" }, cancellationToken: CancellationToken.None);
Assert.Equal("opus", result.Config!.Model);
Assert.Null(result.Config.VerifyCommand);
}
[Fact]
public async Task SetListConfig_ConfigWithOnlyVerifyCommand_IsCreated()
{
var listId = await SeedListAsync();
var result = await _sut.SetListConfig(listId, verifyCommand: "dotnet test", cancellationToken: CancellationToken.None);
Assert.True(result.Ok);
Assert.NotNull(result.Config);
Assert.Equal("dotnet test", result.Config!.VerifyCommand);
Assert.Null(result.Config.Model);
}
[Fact]
public async Task SetListConfig_ClearingEveryOtherField_KeepsConfigWhenVerifyCommandRemains()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "sonnet", "be terse", "agent.md", 30, "dotnet test", cancellationToken: CancellationToken.None);
var result = await _sut.SetListConfig(
listId, clearFields: new[] { "model", "systemPrompt", "agentPath", "maxTurns" }, cancellationToken: CancellationToken.None);
Assert.True(result.Ok);
Assert.NotNull(result.Config);
Assert.Equal("dotnet test", result.Config!.VerifyCommand);
Assert.Null(result.Config.Model);
}
[Fact]
public async Task SetListConfig_ClearingEveryFieldIncludingVerifyCommand_DeletesConfig()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "sonnet", verifyCommand: "dotnet test", cancellationToken: CancellationToken.None);
var result = await _sut.SetListConfig(
listId,
clearFields: new[] { "model", "systemPrompt", "agentPath", "maxTurns", "verifyCommand" },
cancellationToken: CancellationToken.None);
Assert.True(result.Ok);
Assert.Null(result.Config);
Assert.Null(await _lists.GetConfigAsync(listId));
}
[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<InvalidOperationException>(() =>
_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",
SerializeOnFileOverlap = true,
});
var result = await _sut.SetListConfig(
listId,
clearFields: new[] { "model", "systemPrompt", "agentPath", "maxTurns", "verifyCommand" },
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.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);
}
}