diff --git a/tests/Trax.Api.Tests/Audit/TraxAuditWriterTests.cs b/tests/Trax.Api.Tests/Audit/TraxAuditWriterTests.cs index 0817517..014c30a 100644 --- a/tests/Trax.Api.Tests/Audit/TraxAuditWriterTests.cs +++ b/tests/Trax.Api.Tests/Audit/TraxAuditWriterTests.cs @@ -121,6 +121,102 @@ public async Task Drains_PartialBatch_FlushesOnInterval() } } + private sealed class AlwaysFailingSink : ITraxAuditSink + { + public int Attempts { get; private set; } + + public Task WriteAsync(IReadOnlyList batch, CancellationToken ct) + { + Attempts++; + throw new InvalidOperationException("sink permanently down"); + } + } + + [Test] + public async Task SinkThrowsBeyondMaxRetries_DropsBatch() + { + var sink = new AlwaysFailingSink(); + var (channel, writer, sp) = Build( + sink, + new TraxAuditOptions + { + BatchSize = 1, + FlushInterval = TimeSpan.FromMilliseconds(100), + MaxRetries = 2, + RetryBackoff = TimeSpan.FromMilliseconds(5), + ChannelCapacity = 100, + } + ); + using (sp) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await writer.StartAsync(cts.Token); + channel.TryEnqueue(SampleEntry("a")); + + // Wait for the writer to exhaust retries and drop the batch. + // (MaxRetries=2 means 3 total attempts before dropping.) + await Task.Delay(800, cts.Token); + + sink.Attempts.Should().BeGreaterThanOrEqualTo(3); + + await writer.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task Stop_DuringRetryBackoff_PropagatesCancellation() + { + var sink = new AlwaysFailingSink(); + var (channel, writer, sp) = Build( + sink, + new TraxAuditOptions + { + BatchSize = 1, + FlushInterval = TimeSpan.FromMilliseconds(100), + MaxRetries = 10, + // Long backoff so the writer is sleeping when we stop it. + RetryBackoff = TimeSpan.FromSeconds(5), + ChannelCapacity = 100, + } + ); + using (sp) + { + await writer.StartAsync(CancellationToken.None); + channel.TryEnqueue(SampleEntry("a")); + await Task.Delay(150); + + // Stop while the writer is in Task.Delay backoff — the cancellation + // path inside the catch should propagate cleanly. + await writer.StopAsync(CancellationToken.None); + sink.Attempts.Should().BeGreaterThan(0); + } + } + + [Test] + public async Task Drains_QuietChannel_Stops_WithoutFlushing() + { + var sink = new RecordingSink(); + var (channel, writer, sp) = Build( + sink, + new TraxAuditOptions + { + BatchSize = 50, + FlushInterval = TimeSpan.FromSeconds(60), + ChannelCapacity = 100, + } + ); + using (sp) + { + await writer.StartAsync(CancellationToken.None); + // Don't enqueue anything. Stop the writer; the empty-batch waiting + // path inside DrainBatchAsync should yield without error. + await Task.Delay(150); + await writer.StopAsync(CancellationToken.None); + + sink.Batches.Should().BeEmpty(); + } + } + [Test] public async Task SinkThrows_Retries_ThenSucceeds() { diff --git a/tests/Trax.Api.Tests/CoverageEdgeTests.cs b/tests/Trax.Api.Tests/CoverageEdgeTests.cs new file mode 100644 index 0000000..487ec89 --- /dev/null +++ b/tests/Trax.Api.Tests/CoverageEdgeTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Trax.Api.Auth.Jwt; +using Trax.Api.GraphQL.Audit; +using Trax.Api.GraphQL.Types; + +namespace Trax.Api.Tests; + +[TestFixture] +public class CoverageEdgeTests +{ + #region JwtBuilder.CustomizeBearerOptions + + [Test] + public void JwtBuilder_CustomizeBearerOptions_Stores_Customizer() + { + var builder = new JwtBuilder(); + builder.UseSymmetricKey("issuer", "audience", new byte[32]); + + Action hook = _ => { }; + var returned = builder.CustomizeBearerOptions(hook); + + returned.Should().BeSameAs(builder); + builder.BearerOptionsCustomizer.Should().BeSameAs(hook); + } + + [Test] + public void JwtBuilder_CustomizeBearerOptions_Null_Throws() + { + var builder = new JwtBuilder(); + + Action act = () => builder.CustomizeBearerOptions(null!); + + act.Should().Throw(); + } + + #endregion + + #region TraxAuditDisclaimerHostedService + + [Test] + public async Task TraxAuditDisclaimerHostedService_Start_LogsAndReturns() + { + // The class is internal — instantiate via reflection. + var t = typeof(TraxAuditChannel).Assembly.GetType( + "Trax.Api.GraphQL.Audit.TraxAuditDisclaimerHostedService" + ); + t.Should().NotBeNull(); + + var loggerFactory = NullLoggerFactory.Instance; + var instance = (Microsoft.Extensions.Hosting.IHostedService) + Activator.CreateInstance(t!, loggerFactory)!; + + await instance.StartAsync(CancellationToken.None); + await instance.StopAsync(CancellationToken.None); + } + + #endregion + + #region JsonElementConverter — exercise every value kind + + [Test] + public void JsonElementConverter_ToObject_Covers_AllValueKinds() + { + var json = """ + { + "obj": {"x": 1}, + "arr": [1, 2, 3], + "s": "hello", + "i": 42, + "d": 3.14, + "longBig": 9007199254740993, + "t": true, + "f": false, + "n": null + } + """; + + var result = JsonElementConverter.ToObject(json); + var dict = (System.Collections.Generic.Dictionary)result!; + + dict["obj"].Should().BeOfType>(); + dict["arr"].Should().BeOfType>(); + dict["s"].Should().Be("hello"); + dict["i"].Should().Be(42L); + dict["d"].Should().Be(3.14); + dict["longBig"].Should().Be(9007199254740993L); + dict["t"].Should().Be(true); + dict["f"].Should().Be(false); + dict["n"].Should().BeNull(); + } + + #endregion +}