using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Tests.Infrastructure; using Xunit; namespace ClaudeDo.Worker.Tests.Hub; /// UpdateListConfig's "all fields blank -> delete the row" branch used to delete unconditionally, /// silently dropping SerializeOnFileOverlap. The DTO field is tri-state: the list-settings modal /// sends an explicit true/false, every other caller sends null and must not clear the stored flag. public sealed class ListConfigHubTests : 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.Online.OnlineTokenStore(), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); return hub; } // Each helper opens (and disposes) its own short-lived context/repository -- ListRepository's // GetConfigAsync doesn't AsNoTracking(), so reusing one long-lived instance across a hub call // that writes via a *different* context would return a stale, identity-mapped entity. 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, string? model = null, bool serializeOnFileOverlap = false) { await using var ctx = _db.CreateContext(); await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, Model = model, SerializeOnFileOverlap = serializeOnFileOverlap, }); } private async Task GetConfigAsync(string listId) { await using var ctx = _db.CreateContext(); return await new ListRepository(ctx).GetConfigAsync(listId); } [Fact] public async Task UpdateListConfig_AllBlank_NoUnrelatedSettings_DeletesRow() { var hub = CreateHub(); var listId = await SeedListAsync(); await SeedConfigAsync(listId, model: "opus"); await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null)); Assert.Null(await GetConfigAsync(listId)); } [Fact] public async Task UpdateListConfig_AllBlank_WithSerializeOnFileOverlap_KeepsFlag_RowSurvives() { var hub = CreateHub(); var listId = await SeedListAsync(); await SeedConfigAsync(listId, model: "opus", serializeOnFileOverlap: true); await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null)); var config = await GetConfigAsync(listId); Assert.NotNull(config); Assert.True(config!.SerializeOnFileOverlap); Assert.Null(config.Model); } [Fact] public async Task UpdateListConfig_WithModel_UpsertsNormally_PreservesSerializeOnFileOverlap() { var hub = CreateHub(); var listId = await SeedListAsync(); await SeedConfigAsync(listId, serializeOnFileOverlap: true); await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null)); var config = await GetConfigAsync(listId); Assert.NotNull(config); Assert.Equal("opus", config!.Model); Assert.True(config.SerializeOnFileOverlap); } [Fact] public async Task UpdateListConfig_ExplicitFalse_ClearsFlagAndDeletesOtherwiseEmptyRow() { var hub = CreateHub(); var listId = await SeedListAsync(); await SeedConfigAsync(listId, serializeOnFileOverlap: true); await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, SerializeOnFileOverlap: false)); Assert.Null(await GetConfigAsync(listId)); } [Fact] public async Task UpdateListConfig_ExplicitTrue_SetsFlagOnOtherwiseEmptyRow() { var hub = CreateHub(); var listId = await SeedListAsync(); await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, SerializeOnFileOverlap: true)); var config = await GetConfigAsync(listId); Assert.NotNull(config); Assert.True(config!.SerializeOnFileOverlap); } }