using System.Collections.Generic; using System.Globalization; using System.Net; using System.Net.Http; using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using MongoDB.Bson; using StellaOps.Concelier.Connector.Acsc; using StellaOps.Concelier.Connector.Acsc.Configuration; using StellaOps.Concelier.Connector.Common; using StellaOps.Concelier.Connector.Common.Http; using StellaOps.Concelier.Connector.Common.Testing; using StellaOps.Concelier.Storage.Mongo; using StellaOps.Concelier.Storage.Mongo.Documents; using StellaOps.Concelier.Testing; using Xunit; namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc; [Collection("mongo-fixture")] public sealed class AcscConnectorFetchTests : IAsyncLifetime { private static readonly Uri BaseEndpoint = new("https://origin.example/"); private static readonly Uri RelayEndpoint = new("https://relay.example/"); private static readonly Uri AlertsDirectUri = new(BaseEndpoint, "/feeds/alerts/rss"); private static readonly Uri AlertsRelayUri = new(RelayEndpoint, "/feeds/alerts/rss"); private readonly MongoIntegrationFixture _fixture; private readonly FakeTimeProvider _timeProvider; private readonly CannedHttpMessageHandler _handler; public AcscConnectorFetchTests(MongoIntegrationFixture fixture) { _fixture = fixture; _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero)); _handler = new CannedHttpMessageHandler(); } [Fact] public async Task FetchAsync_DirectSuccessAdvancesCursor() { await using var provider = await BuildProviderAsync(preferRelay: false); var connector = provider.GetRequiredService(); SeedRssResponse(AlertsDirectUri, "direct", DateTimeOffset.Parse("2025-10-10T02:15:00Z"), DateTimeOffset.Parse("2025-10-11T05:30:00Z")); await connector.FetchAsync(provider, CancellationToken.None); _handler.AssertNoPendingResponses(); var stateRepository = provider.GetRequiredService(); var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None); Assert.NotNull(state); Assert.Equal("Direct", state!.Cursor.GetValue("preferredEndpoint").AsString); var feeds = state.Cursor.GetValue("feeds").AsBsonDocument; Assert.True(feeds.TryGetValue("alerts", out var published)); Assert.Equal(DateTime.Parse("2025-10-11T05:30:00Z").ToUniversalTime(), published.ToUniversalTime()); var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsBsonArray; Assert.Single(pendingDocuments); var documentStore = provider.GetRequiredService(); var documentId = Guid.Parse(pendingDocuments[0]!.AsString); var document = await documentStore.FindAsync(documentId, CancellationToken.None); Assert.NotNull(document); Assert.Equal(DocumentStatuses.PendingParse, document!.Status); var directMetadata = document.Metadata ?? new Dictionary(StringComparer.Ordinal); Assert.True(directMetadata.TryGetValue("acsc.fetch.mode", out var mode)); Assert.Equal("direct", mode); } [Fact] public async Task FetchAsync_DirectFailureFallsBackToRelay() { await using var provider = await BuildProviderAsync(preferRelay: false); var connector = provider.GetRequiredService(); _handler.AddException(HttpMethod.Get, AlertsDirectUri, new HttpRequestException("HTTP/2 reset")); SeedRssResponse(AlertsRelayUri, "relay", DateTimeOffset.Parse("2025-10-09T10:00:00Z"), DateTimeOffset.Parse("2025-10-11T00:00:00Z")); await connector.FetchAsync(provider, CancellationToken.None); _handler.AssertNoPendingResponses(); var stateRepository = provider.GetRequiredService(); var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None); Assert.NotNull(state); Assert.Equal("Relay", state!.Cursor.GetValue("preferredEndpoint").AsString); var feeds = state.Cursor.GetValue("feeds").AsBsonDocument; Assert.True(feeds.TryGetValue("alerts", out var published)); Assert.Equal(DateTime.Parse("2025-10-11T00:00:00Z").ToUniversalTime(), published.ToUniversalTime()); var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsBsonArray; Assert.Single(pendingDocuments); var documentStore = provider.GetRequiredService(); var documentId = Guid.Parse(pendingDocuments[0]!.AsString); var document = await documentStore.FindAsync(documentId, CancellationToken.None); Assert.NotNull(document); Assert.Equal(DocumentStatuses.PendingParse, document!.Status); var metadata = document.Metadata ?? new Dictionary(StringComparer.Ordinal); Assert.True(metadata.TryGetValue("acsc.fetch.mode", out var mode)); Assert.Equal("relay", mode); Assert.Collection(_handler.Requests, request => { Assert.Equal(HttpMethod.Get, request.Method); Assert.Equal(AlertsDirectUri, request.Uri); }, request => { Assert.Equal(HttpMethod.Get, request.Method); Assert.Equal(AlertsRelayUri, request.Uri); }); } public async Task InitializeAsync() => await Task.CompletedTask; public Task DisposeAsync() => Task.CompletedTask; private async Task BuildProviderAsync(bool preferRelay) { await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName); _handler.Clear(); var services = new ServiceCollection(); services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); services.AddSingleton(_timeProvider); services.AddMongoStorage(options => { options.ConnectionString = _fixture.Runner.ConnectionString; options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName; options.CommandTimeout = TimeSpan.FromSeconds(5); }); services.AddSourceCommon(); services.AddAcscConnector(options => { options.BaseEndpoint = BaseEndpoint; options.RelayEndpoint = RelayEndpoint; options.EnableRelayFallback = true; options.PreferRelayByDefault = preferRelay; options.ForceRelay = false; options.RequestTimeout = TimeSpan.FromSeconds(10); options.Feeds.Clear(); options.Feeds.Add(new AcscFeedOptions { Slug = "alerts", RelativePath = "/feeds/alerts/rss", Enabled = true, }); }); services.Configure(AcscOptions.HttpClientName, builderOptions => { builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler); }); services.Configure(AcscOptions.HttpClientName, options => { options.MaxAttempts = 1; options.BaseDelay = TimeSpan.Zero; }); var provider = services.BuildServiceProvider(); var bootstrapper = provider.GetRequiredService(); await bootstrapper.InitializeAsync(CancellationToken.None); return provider; } private void SeedRssResponse(Uri uri, string mode, DateTimeOffset first, DateTimeOffset second) { var payload = CreateRssPayload(first, second); _handler.AddResponse(HttpMethod.Get, uri, _ => { var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"), }; response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue($"\"{mode}-etag\""); response.Content.Headers.LastModified = second; return response; }); } private static string CreateRssPayload(DateTimeOffset first, DateTimeOffset second) { return $$""" Alerts https://origin.example/feeds/alerts First https://origin.example/alerts/first {{first.ToString("r", CultureInfo.InvariantCulture)}} Second https://origin.example/alerts/second {{second.ToString("r", CultureInfo.InvariantCulture)}} """; } }