using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Signals.Models; using StellaOps.Signals.Options; using StellaOps.Signals.Services; using Xunit; namespace StellaOps.Signals.Tests; public class RouterEventsPublisherTests { [Fact] public async Task PublishFactUpdatedAsync_SendsEnvelopeToRouter() { var options = CreateOptions(); var handler = new StubHandler(HttpStatusCode.Accepted); using var httpClient = new HttpClient(handler) { BaseAddress = new Uri(options.Events.Router.BaseUrl) }; var logger = new ListLogger(); var builder = new ReachabilityFactEventBuilder(options, TimeProvider.System); var publisher = new RouterEventsPublisher(builder, options, httpClient, logger); await publisher.PublishFactUpdatedAsync(CreateFact(), CancellationToken.None); var request = Assert.Single(handler.Requests); Assert.Equal(options.Events.Router.Path, request.Uri!.PathAndQuery); Assert.Equal("application/json", request.ContentType); Assert.Contains(options.Events.Router.ApiKeyHeader, request.Headers.Keys); using var doc = JsonDocument.Parse(request.Body ?? "{}"); Assert.Equal(options.Events.Stream, doc.RootElement.GetProperty("topic").GetString()); Assert.Equal("signals.fact.updated@v1", doc.RootElement.GetProperty("version").GetString()); Assert.Contains(logger.Messages, m => m.Contains("Router publish succeeded")); } [Fact] public async Task PublishFactUpdatedAsync_LogsFailure() { var options = CreateOptions(); var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom"); using var httpClient = new HttpClient(handler) { BaseAddress = new Uri(options.Events.Router.BaseUrl) }; var logger = new ListLogger(); var builder = new ReachabilityFactEventBuilder(options, TimeProvider.System); var publisher = new RouterEventsPublisher(builder, options, httpClient, logger); await publisher.PublishFactUpdatedAsync(CreateFact(), CancellationToken.None); Assert.Contains(logger.Messages, m => m.Contains("Router publish failed")); } private static SignalsOptions CreateOptions() { var options = new SignalsOptions(); options.Events.Driver = "router"; options.Events.Stream = "signals.fact.updated.v1"; options.Events.Router.BaseUrl = "https://router.test"; options.Events.Router.Path = "/router/events/signals.fact.updated"; options.Events.Router.ApiKeyHeader = "X-Test-Key"; options.Events.Router.ApiKey = "secret"; return options; } private static ReachabilityFactDocument CreateFact() { return new ReachabilityFactDocument { SubjectKey = "tenant:image@sha256:abc", CallgraphId = "cg-123", ComputedAt = DateTimeOffset.Parse("2025-12-10T00:00:00Z"), States = new List { new() { Target = "pkg:pypi/django", Reachable = true, Confidence = 0.9, Bucket = "runtime", Weight = 0.45 } } }; } private sealed class StubHandler : HttpMessageHandler { private readonly HttpStatusCode statusCode; private readonly string? responseBody; public List Requests { get; } = new(); public StubHandler(HttpStatusCode statusCode, string? responseBody = null) { this.statusCode = statusCode; this.responseBody = responseBody; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); Requests.Add(new CapturedRequest { Uri = request.RequestUri, ContentType = request.Content?.Headers.ContentType?.MediaType, Headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToArray()), Body = body }); var response = new HttpResponseMessage(statusCode); if (!string.IsNullOrEmpty(responseBody)) { response.Content = new StringContent(responseBody); } return response; } } private sealed record CapturedRequest { public Uri? Uri { get; init; } public string? ContentType { get; init; } public Dictionary Headers { get; init; } = new(StringComparer.OrdinalIgnoreCase); public string? Body { get; init; } } private sealed class ListLogger : ILogger { public List Messages { get; } = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Messages.Add(formatter(state, exception)); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } }