Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions tests/Trax.Api.Tests/Audit/TraxAuditWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TraxAuditEntry> 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()
{
Expand Down
97 changes: 97 additions & 0 deletions tests/Trax.Api.Tests/CoverageEdgeTests.cs
Original file line number Diff line number Diff line change
@@ -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<JwtBearerOptions> 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<ArgumentNullException>();
}

#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<string, object?>)result!;

dict["obj"].Should().BeOfType<System.Collections.Generic.Dictionary<string, object?>>();
dict["arr"].Should().BeOfType<System.Collections.Generic.List<object?>>();
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
}
Loading