namespace Northstar.ProtoTest;
using System.Data.Common;
using global::NUnit.Framework;
using global::ProtoTest.AspNetCore;
using global::ProtoTest.Core;
using global::ProtoTest.Data;
using global::ProtoTest.GraphQL;
using global::ProtoTest.Messaging;
using global::ProtoTest.Messaging.RabbitMq;
using global::ProtoTest.Messaging.RabbitMq.Testcontainers;
using global::ProtoTest.NUnit;
using global::ProtoTest.Reporting;
using global::ProtoTest.Rest;
using global::ProtoTest.SampleApp.Domain;
using global::ProtoTest.Sheets;
using global::ProtoTest.Sql;
using global::ProtoTest.Sql.Testcontainers;
using global::ProtoTest.Testcontainers;
using global::ProtoTest.Web;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Npgsql;
using NorthstarProgram = global::ProtoTest.SampleApp.Program;
///
/// Composes the Learning demo once for the whole run: the application in-process for the journeys that
/// control the clock and on a loopback listener for the browser journey, the run-owned store and
/// broker, a trace and the reports. The store and broker choices read as a composition: configured
/// address first, then a container this run owns, then the run-owned SQLite file and no broker.
///
[SetUpFixture]
public sealed class Setup : ProtoTestAssembly
{
protected override void Configure(IProtoHostBuilder builder)
{
var configuration = LoadConfiguration();
var run = NorthstarRun.From(configuration);
run.PrepareOwnedStore();
ConfigureInfrastructure(builder, run);
ConfigureApplications(builder, run);
ConfigureDomain(builder, run);
builder
.ConfigureTracing(trace =>
{
trace.ActivitySources.Add("Northstar.Domain");
})
.ConfigureRedaction(redaction => redaction.AddSensitiveName("OwnerToken"))
.ConfigureAppConfiguration(settings => ConfigureTests(settings, run))
.AddSheets()
.AddNorthstarTestSupport()
.AddNorthstarData(data => data.UseDomainProvisioners = run.CanComposeDomain)
.AddCapabilityReason(
ProtoCapabilityKinds.Broker,
"No broker is configured; set ProtoTest:Messaging:Broker=container.")
.AddSink(sink => sink.OutputPath = Path.Combine(
"TestResults", "Northstar.ProtoTest", "report.json"))
.AddSink(sink =>
{
sink.OutputPath = Path.Combine("TestResults", "Northstar.ProtoTest", "report.html");
sink.Title = "Northstar Learning demo";
})
.AddRunGate("no error findings", context => context
.ItemsOfKind(ProtoReportItemKinds.Finding)
.Any(item => item.Status == ProtoReportStatus.Error)
? ProtoRunGateResult.Failed("The run recorded error findings.")
: ProtoRunGateResult.Passed("No error findings were recorded."));
ConfigureMessaging(builder, run);
}
private static IConfiguration LoadConfiguration()
=> new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.AddEnvironmentVariables()
.Build();
private static void ConfigureInfrastructure(IProtoHostBuilder builder, NorthstarRun run)
{
if (run.OwnsPostgres)
{
// UseConfigured wins when the environment provides the address, so a container never starts.
builder.AddInfrastructure(
"NorthstarDatabase",
chain => chain
.UseConfigured()
.UseContainer(PostgresDatabase.Container()),
"ConnectionStrings:Northstar");
}
if (run.OwnsMessagingBroker)
{
// The integration's option key and the application's own key are filled by the same piece.
builder.AddInfrastructure(
"MessagingBroker",
chain => chain
.UseConfigured()
.UseContainer(RabbitMqBroker.Container()),
RabbitMqOptions.ConnectionStringSetting,
"Messaging:RabbitMq:ConnectionString");
}
}
private static void ConfigureApplications(IProtoHostBuilder builder, NorthstarRun run)
{
builder.AddApplication(NorthstarTargets.Api, app =>
{
if (run.RunsLocalApplications)
{
// The in-process server is what carries the test clock into the application.
app.AddAspNetCoreServer(configureWebHost: webHost =>
ConfigureHostedApplication(webHost, run));
}
app.AddRest(rest => rest
.CaptureAttachments()
.AddClient(NorthstarTargets.Api)
.AddCollector()
.AddCollector())
.AddGraphQL(graphQL => graphQL
.CaptureAttachments()
.AddClient("GraphQL", endpoint: "GraphQL"));
});
if (run.RunsLocalApplications)
{
// The browser needs a real listener; the page journey follows this instance's address.
builder.AddLoopbackApplication(NorthstarTargets.Web, NorthstarProgram.CreateApp);
builder.AddHttpReadiness(NorthstarTargets.Web, "/health");
}
builder.AddApplication(NorthstarTargets.Web, app => app
.AddRest(rest => rest
.CaptureAttachments()
.AddClient(NorthstarTargets.Web))
.AddWeb(options => options.Headless = true));
}
private static void ConfigureHostedApplication(IWebHostBuilder webHost, NorthstarRun run)
{
webHost.UseSetting("Database:Provider", run.DatabaseProvider);
webHost.UseSetting("ProtoTest:TestSupport", "true");
if (run.DatabaseConnection is { } store)
{
webHost.UseSetting("ConnectionStrings:Northstar", store);
}
if (!string.IsNullOrWhiteSpace(run.ConfiguredMessaging))
{
webHost.UseSetting("Messaging:RabbitMq:ConnectionString", run.ConfiguredMessaging);
}
}
private static void ConfigureTests(IConfigurationBuilder settings, NorthstarRun run)
{
settings.AddConfiguration(run.Configuration);
var values = new Dictionary
{
["Database:Provider"] = run.DatabaseProvider,
// The loopback instance serves the page while the in-process instance owns webhook dispatch.
["Northstar:DisableWebhookDispatcher"] = "true",
[$"ProtoTest:Applications:{NorthstarTargets.Api}:Endpoints:GraphQL"] = "/graphql",
[$"ProtoTest:Applications:{NorthstarTargets.Web}:Endpoints:GraphQL"] = "/graphql"
};
if (run.RunsLocalApplications)
{
if (run.DatabaseConnection is { } store)
{
values["ConnectionStrings:Northstar"] = store;
}
}
else
{
values[$"{ProtoApplication.SectionPath}:{NorthstarTargets.Api}:BaseUrl"] = run.TargetUrl;
values[$"{ProtoApplication.SectionPath}:{NorthstarTargets.Web}:BaseUrl"] = run.TargetUrl;
}
if (!string.IsNullOrWhiteSpace(run.ConfiguredMessaging))
{
// The application reads its own key; the suite keeps the ProtoTest one.
values["Messaging:RabbitMq:ConnectionString"] = run.ConfiguredMessaging;
}
settings.AddInMemoryCollection(values);
}
private static void ConfigureDomain(IProtoHostBuilder builder, NorthstarRun run)
{
if (!run.CanComposeDomain)
{
return;
}
// The application has its own connection, so a test transaction would hide fixture writes.
builder
.AddSql(
provider => CreateDatabaseConnection(
ResolveDatabase(provider, run.DatabaseConnection ?? string.Empty),
run.UsesPostgres),
sql => sql.Isolation = SqlIsolation.None)
.ConfigureServices(services =>
{
services.AddNorthstarDomain(
(provider, options) =>
{
var connection = provider.GetRequiredService();
if (run.UsesPostgres)
{
options.UseNpgsql(connection);
}
else
{
options.UseSqlite(connection);
}
},
ServiceLifetime.Scoped);
// The host owns the test clock bridge; the domain's own TimeProvider would shadow it,
// so the suite's composition keeps the bridge and the application keeps the test clock.
services.RemoveAll();
});
}
private static void ConfigureMessaging(IProtoHostBuilder builder, NorthstarRun run)
{
// Registered last so the application exists before messaging binds its taps. The tap is declared
// in code so it is bound during setup, not at the first await, and cannot miss the publish.
if (run.UsesMessaging)
{
builder.AddMessaging(messaging => messaging
.CaptureAttachments()
.UseRabbitMq()
.Declare("invoice.paid")
.Tap("invoice.paid"));
}
else
{
builder.AddMessaging(messaging => messaging.CaptureAttachments());
}
}
private static string ResolveDatabase(IServiceProvider provider, string fallback)
=> ProtoApplication.ResolveSetting(
provider.GetRequiredService(),
provider.GetService(),
"ConnectionStrings:Northstar")
?? fallback;
private static DbConnection CreateDatabaseConnection(string connectionString, bool postgres)
=> postgres
? new NpgsqlConnection(connectionString)
: new SqliteConnection(connectionString);
}