using ClaudeDo.Data.Models; using ClaudeDo.Worker.Logging; using Serilog.Events; using Serilog.Parsing; namespace ClaudeDo.Worker.Tests.Logging; public class BroadcastLogSinkTests { private static readonly DateTimeOffset EvtTime = new(2026, 6, 23, 8, 0, 0, TimeSpan.Zero); private static readonly MessageTemplateParser Parser = new(); private static LogEvent Evt(LogEventLevel level, string template, Exception? ex = null, string? sourceContext = null) { var props = new List(); if (sourceContext is not null) props.Add(new LogEventProperty("SourceContext", new ScalarValue(sourceContext))); return new LogEvent(EvtTime, level, ex, Parser.Parse(template), props); } private static (BroadcastLogSink sink, LogRingBuffer buffer, List<(string msg, WorkerLogLevel lvl)> calls) NewSink(Func clock) { var buffer = new LogRingBuffer(TimeSpan.FromHours(1), utcNow: () => new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc)); var sink = new BroadcastLogSink(buffer, clock); var calls = new List<(string, WorkerLogLevel)>(); sink.Attach((m, l, _) => { calls.Add((m, l)); return Task.CompletedTask; }); return (sink, buffer, calls); } [Fact] public void Buffers_all_levels() { var (sink, buffer, _) = NewSink(() => EvtTime.UtcDateTime); sink.Emit(Evt(LogEventLevel.Information, "info")); sink.Emit(Evt(LogEventLevel.Warning, "warn")); sink.Emit(Evt(LogEventLevel.Error, "err")); Assert.Equal(3, buffer.Snapshot().Count); } [Fact] public void Broadcasts_only_warn_and_error() { var (sink, _, calls) = NewSink(() => EvtTime.UtcDateTime); sink.Emit(Evt(LogEventLevel.Information, "info")); sink.Emit(Evt(LogEventLevel.Warning, "warn")); sink.Emit(Evt(LogEventLevel.Error, "err")); Assert.Equal(new[] { WorkerLogLevel.Warn, WorkerLogLevel.Error }, calls.Select(c => c.lvl)); } [Fact] public void Dedupes_repeat_within_window_but_still_buffers() { var now = EvtTime.UtcDateTime; var (sink, buffer, calls) = NewSink(() => now); sink.Emit(Evt(LogEventLevel.Warning, "same")); now = now.AddSeconds(30); sink.Emit(Evt(LogEventLevel.Warning, "same")); Assert.Single(calls); // second broadcast suppressed Assert.Equal(2, buffer.Snapshot().Count); // both buffered } [Fact] public void Allows_repeat_after_window() { var now = EvtTime.UtcDateTime; var (sink, _, calls) = NewSink(() => now); sink.Emit(Evt(LogEventLevel.Warning, "same")); now = now.AddSeconds(121); sink.Emit(Evt(LogEventLevel.Warning, "same")); Assert.Equal(2, calls.Count); } [Fact] public void Appends_exception_to_message() { var (sink, _, calls) = NewSink(() => EvtTime.UtcDateTime); sink.Emit(Evt(LogEventLevel.Error, "boom", new InvalidOperationException("bad dir"))); Assert.Single(calls); Assert.Equal("boom: InvalidOperationException: bad dir", calls[0].msg); } [Fact] public void Plumbing_source_buffered_but_not_broadcast() { var (sink, buffer, calls) = NewSink(() => EvtTime.UtcDateTime); sink.Emit(Evt(LogEventLevel.Error, "transport hiccup", sourceContext: "Microsoft.AspNetCore.SignalR.HubConnectionHandler")); Assert.Empty(calls); Assert.Single(buffer.Snapshot()); } [Fact] public void Does_not_throw_when_detached() { var buffer = new LogRingBuffer(TimeSpan.FromHours(1)); var sink = new BroadcastLogSink(buffer); sink.Emit(Evt(LogEventLevel.Error, "no subscriber")); Assert.Single(buffer.Snapshot()); } }