216 lines
9.2 KiB
C#
216 lines
9.2 KiB
C#
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<AcscConnector>();
|
|
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<ISourceStateRepository>();
|
|
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<IDocumentStore>();
|
|
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<string, string>(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<AcscConnector>();
|
|
_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<ISourceStateRepository>();
|
|
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<IDocumentStore>();
|
|
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<string, string>(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<ServiceProvider> 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>(_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<HttpClientFactoryOptions>(AcscOptions.HttpClientName, builderOptions =>
|
|
{
|
|
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
|
|
});
|
|
services.Configure<SourceHttpClientOptions>(AcscOptions.HttpClientName, options =>
|
|
{
|
|
options.MaxAttempts = 1;
|
|
options.BaseDelay = TimeSpan.Zero;
|
|
});
|
|
|
|
var provider = services.BuildServiceProvider();
|
|
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
|
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 $$"""
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0">
|
|
<channel>
|
|
<title>Alerts</title>
|
|
<link>https://origin.example/feeds/alerts</link>
|
|
<item>
|
|
<title>First</title>
|
|
<link>https://origin.example/alerts/first</link>
|
|
<pubDate>{{first.ToString("r", CultureInfo.InvariantCulture)}}</pubDate>
|
|
</item>
|
|
<item>
|
|
<title>Second</title>
|
|
<link>https://origin.example/alerts/second</link>
|
|
<pubDate>{{second.ToString("r", CultureInfo.InvariantCulture)}}</pubDate>
|
|
</item>
|
|
</channel>
|
|
</rss>
|
|
""";
|
|
}
|
|
}
|