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 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 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); } }