using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.Metrics; using System.Net; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Xunit; [assembly: CollectionBehavior(DisableTestParallelization = true)] public sealed class ServiceFactory : WebApplicationFactory { public Dictionary Settings { get; } = new() { ["Service:Name"] = "lesson-service", ["Dependency:TimeoutMs"] = "100", ["Dependency:DelayMs"] = "20", ["Dependency:Available"] = "true", ["DiagnosticCanary"] = "SYNTHETIC-DO-NOT-LOG-7F2A" }; public CaptureLogger Logs { get; } = new(); public IHost? CapturedHost { get; private set; } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); // Options bind when consumed; do not read Settings in top-level Program before Build. builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(Settings)); builder.ConfigureLogging(logging => { logging.ClearProviders(); logging.AddProvider(Logs); }); } protected override IHost CreateHost(IHostBuilder builder) { CapturedHost = base.CreateHost(builder); return CapturedHost; } } public sealed class CaptureLogger : ILoggerProvider { public ConcurrentQueue<(string Category, int EventId, string Message)> Entries { get; } = new(); public ILogger CreateLogger(string categoryName) => new Sink(this, categoryName); public void Dispose() { } private sealed class Sink(CaptureLogger owner, string category) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel level) => true; public void Log(LogLevel level, EventId eventId, TState state, Exception? error, Func formatter) => owner.Entries.Enqueue((category, eventId.Id, formatter(state, error))); } } public sealed class ServiceTests { [Fact] public async Task Normal_request_traverses_the_application() { using var factory = new ServiceFactory(); using var client = factory.CreateClient(); using var response = await client.GetAsync("/sample"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal("synthetic-ok", payload.RootElement.GetProperty("value").GetString()); } [Theory] [InlineData("Service:Name", "")] [InlineData("Dependency:TimeoutMs", "0")] public void Invalid_options_reject_startup(string key, string value) { using var factory = new ServiceFactory(); factory.Settings[key] = value; var error = Record.Exception(() => { using var client = factory.CreateClient(); }); Assert.NotNull(error); Assert.True(ContainsOptionsFailure(error!), "Failure must come from options validation."); } private static bool ContainsOptionsFailure(Exception error) => error is OptionsValidationException || (error is AggregateException aggregate && aggregate.InnerExceptions.Any(ContainsOptionsFailure)) || (error.InnerException is not null && ContainsOptionsFailure(error.InnerException)); [Theory] [InlineData(true, HttpStatusCode.OK)] [InlineData(false, HttpStatusCode.ServiceUnavailable)] public async Task Readiness_and_liveness_have_distinct_contracts(bool available, HttpStatusCode ready) { using var factory = new ServiceFactory(); factory.Settings["Dependency:Available"] = available.ToString(); using var client = factory.CreateClient(); using var readiness = await client.GetAsync("/ready"); using var liveness = await client.GetAsync("/live"); Assert.Equal(ready, readiness.StatusCode); Assert.Equal(HttpStatusCode.OK, liveness.StatusCode); } [Fact] public async Task Injected_timeout_is_correlated_counted_and_does_not_log_the_canary() { using var factory = new ServiceFactory(); factory.Settings["Dependency:DelayMs"] = "250"; using var client = factory.CreateClient(); var telemetry = factory.Services.GetRequiredService(); var stopped = new ConcurrentQueue(); using var activities = new ActivityListener { ShouldListenTo = source => ReferenceEquals(source, telemetry.Activities), Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, ActivityStopped = activity => stopped.Enqueue(activity) }; ActivitySource.AddActivityListener(activities); long observed = 0; var observedTags = new ConcurrentQueue>(); using var metrics = new MeterListener(); metrics.InstrumentPublished = (instrument, listener) => { if (ReferenceEquals(instrument.Meter, telemetry.Meter)) listener.EnableMeasurementEvents(instrument); }; metrics.SetMeasurementEventCallback((instrument, value, tags, _) => { if (instrument.Name == "lesson.dependency.timeouts") { Interlocked.Add(ref observed, value); foreach (var tag in tags) observedTags.Enqueue(tag); } }); metrics.Start(); using var request = new HttpRequestMessage(HttpMethod.Get, "/sample"); request.Headers.TryAddWithoutValidation("traceparent", "00-11111111111111111111111111111111-2222222222222222-01"); using var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); using var payload = JsonDocument.Parse(body); var traceId = payload.RootElement.GetProperty("trace_id").GetString(); var span = Assert.Single(stopped.Where(x => x.OperationName == "dependency.call")); Assert.Equal(ActivityStatusCode.Error, span.Status); Assert.Equal(span.TraceId.ToString(), traceId); Assert.NotEqual("00000000000000000000000000000000", traceId); var log = Assert.Single(factory.Logs.Entries.Where(x => x.EventId == 1002)); Assert.Contains(traceId!, log.Message); Assert.Equal(1L, observed); var tag = Assert.Single(observedTags); Assert.Equal("operation", tag.Key); Assert.Equal("dependency", tag.Value); Assert.DoesNotContain("SYNTHETIC-DO-NOT-LOG-7F2A", body); Assert.All(factory.Logs.Entries, entry => Assert.DoesNotContain("SYNTHETIC-DO-NOT-LOG-7F2A", entry.Message)); } [Fact] public async Task Host_start_and_stop_are_observed() { using var factory = new ServiceFactory(); using var client = factory.CreateClient(); var record = factory.Services.GetRequiredService(); Assert.True(record.Started); using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await factory.CapturedHost!.StopAsync(deadline.Token); Assert.True(record.Stopped); Assert.True(factory.Services.GetRequiredService().ApplicationStopped.IsCancellationRequested); } }