feat(hub): Ticket-Settings, Projektliste und Import ueber den Hub

This commit is contained in:
mika kuns
2026-08-27 13:22:54 +02:00
parent 2e236736a7
commit f0a3a186dd
9 changed files with 255 additions and 10 deletions
@@ -200,6 +200,14 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(true, "http://127.0.0.1:7681/", null));
public virtual Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult<TicketSettingsDto?>(null);
public virtual Task SetTicketApiBaseUrlAsync(string? baseUrl) => Task.CompletedTask;
public virtual Task SetTicketTokenAsync(string token) => Task.CompletedTask;
public virtual Task ClearTicketTokenAsync() => Task.CompletedTask;
public virtual Task<TicketConnectionDto?> TestTicketConnectionAsync() => Task.FromResult<TicketConnectionDto?>(null);
public virtual Task<List<TicketProjectDto>> GetTicketProjectsAsync() => Task.FromResult(new List<TicketProjectDto>());
public virtual Task<TicketImportResultDto> ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0));
public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot);
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
@@ -0,0 +1,107 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
namespace ClaudeDo.Worker.Tests.Tickets;
/// TicketProjectId shares the exact SetConfigAsync-copies-verbatim trap that SerializeOnFileOverlap
/// already hit once: any writer that doesn't carry the field forward silently clears the
/// list<->ticket-project link. UpdateListConfigDto's TicketProjectId is tri-state for the same
/// reason SerializeOnFileOverlap is: null = keep stored, <=0 = clear, >0 = set. The MCP
/// set_list_config tool doesn't expose the field at all (UI-only), so it must preserve whatever is
/// already stored.
public sealed class ListConfigTicketProjectTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, factory,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;
}
private async Task<string> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
await using var ctx = _db.CreateContext();
await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task SeedConfigAsync(string listId, int? ticketProjectId = null, string? model = null)
{
await using var ctx = _db.CreateContext();
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = model, TicketProjectId = ticketProjectId,
});
}
private async Task<ListConfigEntity?> ReadConfigAsync(string listId)
{
await using var ctx = _db.CreateContext();
return await new ListRepository(ctx).GetConfigAsync(listId);
}
private async Task CallSetListConfigAsync(string listId, string model)
{
var factory = _db.CreateFactory();
await using var ctx = factory.CreateDbContext();
var tool = new ConfigMcpTools(
new ListRepository(ctx), new TaskRepository(ctx),
new HubBroadcaster(new CapturingHubContext()), factory);
await tool.SetListConfig(listId, model: model, cancellationToken: CancellationToken.None);
}
[Fact]
public async Task set_list_config_preserves_TicketProjectId()
{
var listId = await SeedListAsync();
await SeedConfigAsync(listId, ticketProjectId: 393);
await CallSetListConfigAsync(listId, model: "opus");
var config = await ReadConfigAsync(listId);
Assert.Equal(393, config!.TicketProjectId);
Assert.Equal("opus", config.Model);
}
[Fact]
public async Task UpdateListConfig_with_null_TicketProjectId_keeps_the_stored_value()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, ticketProjectId: 393);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null));
Assert.Equal(393, (await ReadConfigAsync(listId))!.TicketProjectId);
}
[Fact]
public async Task UpdateListConfig_can_clear_the_link_explicitly()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, ticketProjectId: 393);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null, TicketProjectId: 0));
Assert.Null((await ReadConfigAsync(listId))!.TicketProjectId);
}
}
@@ -185,6 +185,14 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(false, null, "not configured"));
public Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult<TicketSettingsDto?>(null);
public Task SetTicketApiBaseUrlAsync(string? baseUrl) => Task.CompletedTask;
public Task SetTicketTokenAsync(string token) => Task.CompletedTask;
public Task ClearTicketTokenAsync() => Task.CompletedTask;
public Task<TicketConnectionDto?> TestTicketConnectionAsync() => Task.FromResult<TicketConnectionDto?>(null);
public Task<List<TicketProjectDto>> GetTicketProjectsAsync() => Task.FromResult(new List<TicketProjectDto>());
public Task<TicketImportResultDto> ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0));
}
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────