diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab9fe1be8e..07e0ba7969 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,10 @@ jobs: integration: runs-on: docs-builder-latest-16 + environment: integration-tests + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v6 @@ -195,5 +199,22 @@ jobs: - name: Install Aspire workload run: dotnet workload install aspire + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::197730964718:role/elastic-docs-v3-integration-tests + aws-region: us-east-1 + + - name: Fetch ES credentials + run: | + ES_URL=$(aws ssm get-parameter --name /elastic-docs-v3/prod/docs-elasticsearch-github-actions-readonly-url --with-decryption --query Parameter.Value --output text) + ES_KEY=$(aws ssm get-parameter --name /elastic-docs-v3/prod/docs-elasticsearch-github-actions-readonly-api-key --with-decryption --query Parameter.Value --output text) + echo "::add-mask::$ES_URL" + echo "::add-mask::$ES_KEY" + dotnet user-secrets set "DocumentationElasticUrl" "$ES_URL" --project aspire + dotnet user-secrets set "DocumentationElasticApiKey" "$ES_KEY" --project aspire + - name: Integration Tests + env: + ENVIRONMENT: prod run: dotnet run --project build -c release -- integrate diff --git a/aspire/AppHost.cs b/aspire/AppHost.cs index e89cdc29f1..3cbf54287b 100644 --- a/aspire/AppHost.cs +++ b/aspire/AppHost.cs @@ -54,50 +54,57 @@ internal static async Task Run( .WaitForCompletion(cloneAll) .WithParentRelationship(cloneAll); - var elasticsearchLocal = builder.AddElasticsearch(ElasticsearchLocal) - .WithEnvironment("LICENSE", "trial"); - if (!startElasticsearch) - elasticsearchLocal = elasticsearchLocal.WithExplicitStart(); + IResourceBuilder? elasticsearchLocal = null; + if (startElasticsearch) + elasticsearchLocal = builder.AddElasticsearch(ElasticsearchLocal) + .WithEnvironment("LICENSE", "trial"); var elasticsearchRemote = builder.AddExternalService(ElasticsearchRemote, elasticsearchUrl); - var api = builder.AddProject(Api) + // Read ENVIRONMENT and DOCS_BUILD_TYPE from the host process (injected by CI or set locally). + // Index name pattern: docs-{type}.semantic-{env}-latest + var rawEnvironment = Environment.GetEnvironmentVariable("ENVIRONMENT"); + var serviceEnvironment = string.IsNullOrWhiteSpace(rawEnvironment) ? "prod" : rawEnvironment; + var rawBuildType = Environment.GetEnvironmentVariable("DOCS_BUILD_TYPE"); + var buildType = string.IsNullOrWhiteSpace(rawBuildType) ? "assembler" : rawBuildType; + + var api = builder.AddProject(Api, launchProfileName: "http") .WithArgs(GlobalArguments) - .WithEnvironment("ENVIRONMENT", "dev") + .WithEnvironment("ENVIRONMENT", serviceEnvironment) + .WithEnvironment("DOCS_BUILD_TYPE", buildType) .WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl) - .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath); + .WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath) + .WithHttpHealthCheck("/docs/_api/health"); // ReSharper disable once RedundantAssignment api = startElasticsearch ? api - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) - .WaitFor(elasticsearchLocal) - .WithExplicitStart() + .WithReference(elasticsearchLocal!) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal!) + .WaitFor(elasticsearchLocal!) : api.WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) - .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) - .WithExplicitStart(); + .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); var mcp = builder.AddProject(RemoteMcp) .WithArgs(GlobalArguments) - .WithEnvironment("ENVIRONMENT", "dev"); + .WithEnvironment("ENVIRONMENT", serviceEnvironment) + .WithEnvironment("DOCS_BUILD_TYPE", buildType) + .WithHttpHealthCheck("/docs/_mcp/health"); // ReSharper disable once RedundantAssignment mcp = startElasticsearch ? mcp - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) - .WaitFor(elasticsearchLocal) - .WithExplicitStart() + .WithReference(elasticsearchLocal!) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal!) + .WaitFor(elasticsearchLocal!) : mcp.WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) - .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) - .WithExplicitStart(); + .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); var indexElasticsearch = builder.AddProject(ElasticsearchIngest) .WithArgs(["assembler", "index", .. GlobalArguments]) @@ -107,11 +114,11 @@ internal static async Task Run( // ReSharper disable once RedundantAssignment indexElasticsearch = startElasticsearch ? indexElasticsearch - .WaitFor(elasticsearchLocal) - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) - .WithParentRelationship(elasticsearchLocal) + .WaitFor(elasticsearchLocal!) + .WithReference(elasticsearchLocal!) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithParentRelationship(elasticsearchLocal!) : indexElasticsearch .WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) @@ -129,16 +136,16 @@ internal static async Task Run( serveStatic = startElasticsearch ? serveStatic - .WithReference(elasticsearchLocal) - .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal.Resource.PasswordParameter) + .WithReference(elasticsearchLocal!) + .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) + .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) : serveStatic .WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); // ReSharper disable once RedundantAssignment - serveStatic = startElasticsearch ? serveStatic.WaitFor(elasticsearchLocal) : serveStatic.WaitFor(buildAll); + serveStatic = startElasticsearch ? serveStatic.WaitFor(elasticsearchLocal!) : serveStatic.WaitFor(buildAll); await builder.Build().RunAsync(ct); } diff --git a/docs-builder.slnx b/docs-builder.slnx index 63f92e7635..48103b2bf2 100644 --- a/docs-builder.slnx +++ b/docs-builder.slnx @@ -84,8 +84,7 @@ - - + @@ -95,6 +94,7 @@ + diff --git a/src/api/Elastic.Documentation.Api/Program.cs b/src/api/Elastic.Documentation.Api/Program.cs index bbee8a9201..ea521b9377 100644 --- a/src/api/Elastic.Documentation.Api/Program.cs +++ b/src/api/Elastic.Documentation.Api/Program.cs @@ -22,11 +22,17 @@ _ = builder.AddDefaultHealthChecks(); _ = builder.AddDocsApiOpenTelemetry(); - // Configure Kestrel to listen on port 8080 (standard container port) - _ = builder.WebHost.ConfigureKestrel(serverOptions => + // Only hardcode port 8080 when not running under Aspire/orchestration. + // Use builder.Configuration so both ASPNETCORE_* and DOTNET_* prefix variants are covered. + if (string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) + && string.IsNullOrEmpty(builder.Configuration["HTTPS_PORTS"]) + && string.IsNullOrEmpty(builder.Configuration["URLS"])) { - serverOptions.ListenAnyIP(8080); - }); + _ = builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.ListenAnyIP(8080); + }); + } var environment = Environment.GetEnvironmentVariable("ENVIRONMENT"); Console.WriteLine($"Docs Environment: {environment}"); diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index 291c61f9fc..5b5c70e22f 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -30,11 +30,17 @@ _ = builder.Services.ConfigureOpenTelemetryTracerProvider(t => t.AddSource(McpToolTelemetry.McpToolSourceName)); - // Configure Kestrel to listen on port 8080 (standard container port) - _ = builder.WebHost.ConfigureKestrel(serverOptions => + // Only hardcode port 8080 when not running under Aspire/orchestration. + // Use builder.Configuration so both ASPNETCORE_* and DOTNET_* prefix variants are covered. + if (string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) + && string.IsNullOrEmpty(builder.Configuration["HTTPS_PORTS"]) + && string.IsNullOrEmpty(builder.Configuration["URLS"])) { - serverOptions.ListenAnyIP(8080); - }); + _ = builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.ListenAnyIP(8080); + }); + } var environment = Environment.GetEnvironmentVariable("ENVIRONMENT"); Console.WriteLine($"Docs Environment: {environment}"); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs similarity index 84% rename from tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs index 02e073d530..a47b11b601 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs @@ -5,6 +5,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; +using Elastic.Documentation.Aspire; using Elastic.Documentation.ServiceDefaults; using InMemLogger; using Microsoft.Extensions.Configuration; @@ -12,9 +13,9 @@ using Microsoft.Extensions.Logging; using static Elastic.Documentation.Aspire.ResourceNames; -[assembly: CaptureConsole, AssemblyFixture(typeof(Elastic.Assembler.IntegrationTests.DocumentationFixture))] +[assembly: CaptureConsole, AssemblyFixture(typeof(Elastic.Documentation.IntegrationTests.DocumentationFixture))] -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public static class DistributedApplicationExtensions { @@ -90,6 +91,14 @@ public async ValueTask InitializeAsync() _ = await DistributedApplication.ResourceNotifications .WaitForResourceHealthyAsync(AssemblerServe, cancellationToken: TestContext.Current.CancellationToken) .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); + + _ = await DistributedApplication.ResourceNotifications + .WaitForResourceHealthyAsync(ResourceNames.Api, cancellationToken: TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); + + _ = await DistributedApplication.ResourceNotifications + .WaitForResourceHealthyAsync(RemoteMcp, cancellationToken: TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); } catch (Exception e) { @@ -99,6 +108,10 @@ public async ValueTask InitializeAsync() } } + public HttpClient CreateApiClient() => DistributedApplication.CreateHttpClient(ResourceNames.Api); + + public HttpClient CreateMcpClient() => DistributedApplication.CreateHttpClient(RemoteMcp); + private async ValueTask ValidateExitCode(string resourceName) { var eventResource = await DistributedApplication.ResourceNotifications.WaitForResourceAsync(resourceName, _ => true); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs similarity index 97% rename from tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 61775c3e74..6008c930fa 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Nullean.ScopedFileSystem; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class PublicOnlyAssemblerConfigurationTests { @@ -130,7 +130,7 @@ public void ReadsVersions() public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in _fixture.InMemoryLogger.RecordedLogs) _output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs index 06beb9ccf3..d88c7e6b53 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs @@ -21,7 +21,7 @@ using OpenTelemetry; using OpenTelemetry.Trace; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class DocsSyncTests { diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj b/tests-integration/Elastic.Documentation.IntegrationTests/Elastic.Documentation.IntegrationTests.csproj similarity index 86% rename from tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj rename to tests-integration/Elastic.Documentation.IntegrationTests/Elastic.Documentation.IntegrationTests.csproj index e5b507a5fd..16ba31e332 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Elastic.Documentation.IntegrationTests.csproj @@ -12,6 +12,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs similarity index 98% rename from tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index cb02a3879f..d9e501d140 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -24,7 +24,7 @@ using Nullean.ScopedFileSystem; using RazorSlices; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class NavigationBuildingTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime { @@ -169,7 +169,7 @@ private static IEnumerable GetAllNavigationUrls(INavigationItem item) public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in fixture.InMemoryLogger.RecordedLogs) output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs similarity index 97% rename from tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs index 38aa6e64f9..00e9624f6a 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs @@ -24,7 +24,7 @@ using Nullean.ScopedFileSystem; using RazorSlices; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class NavigationRootTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime { @@ -78,7 +78,7 @@ public async Task AssertRealNavigation() public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in fixture.InMemoryLogger.RecordedLogs) output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchBootstrapFixture.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchBootstrapFixture.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs index 4685443f18..a29c95d7b5 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchBootstrapFixture.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs @@ -19,7 +19,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Elastic.Assembler.IntegrationTests.Search; +namespace Elastic.Documentation.IntegrationTests.Search; [CollectionDefinition(Collection)] public class SearchBootstrapFixture(DocumentationFixture fixture) : IAsyncLifetime diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs similarity index 99% rename from tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs index 4f351d3927..a114f798c9 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs @@ -6,7 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation.Search; -namespace Elastic.Assembler.IntegrationTests.Search; +namespace Elastic.Documentation.IntegrationTests.Search; /// /// Integration tests for the search endpoint exposed through MapSearchEndpoint. diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchTestBase.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchTestBase.cs similarity index 88% rename from tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchTestBase.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchTestBase.cs index 2b45d165db..92c0535b25 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchTestBase.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchTestBase.cs @@ -2,7 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -namespace Elastic.Assembler.IntegrationTests.Search; +namespace Elastic.Documentation.IntegrationTests.Search; /// /// Base class for search integration tests that handles initialization diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs similarity index 89% rename from tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs index e3f1ccaee6..cebeeb3afc 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs @@ -6,7 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation.Aspire; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class ServeStaticTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime { @@ -23,7 +23,7 @@ public async Task AssertRequestToRootReturnsData() public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in fixture.InMemoryLogger.RecordedLogs) output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs similarity index 98% rename from tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs index d89cd503a7..f3cb9a098c 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs @@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Nullean.ScopedFileSystem; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class SiteNavigationTests : IAsyncLifetime { @@ -225,7 +225,7 @@ public async Task UriResolving() public ValueTask DisposeAsync() { GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) + if (TestContext.Current.TestState?.Result is not TestResult.Failed) return default; foreach (var resource in _fixture.InMemoryLogger.RecordedLogs.ToList()) _output.WriteLine(resource.Message); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs new file mode 100644 index 0000000000..bd6741bde0 --- /dev/null +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -0,0 +1,86 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using System.Net.Http.Json; +using AwesomeAssertions; +using Elastic.Documentation.Search; + +namespace Elastic.Documentation.IntegrationTests.Smoke; + +public class ApiSmokeTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime +{ + /// + public ValueTask InitializeAsync() => default; + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + if (TestContext.Current.TestState?.Result is not TestResult.Failed) + return default; + foreach (var resource in fixture.InMemoryLogger.RecordedLogs.ToList()) + output.WriteLine(resource.Message); + return default; + } + + [Fact] + public async Task HealthEndpoint_Returns200() + { + using var client = fixture.CreateApiClient(); + var response = await client.GetAsync("/docs/_api/health", TestContext.Current.CancellationToken); + _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task AliveEndpoint_Returns200() + { + using var client = fixture.CreateApiClient(); + var response = await client.GetAsync("/docs/_api/alive", TestContext.Current.CancellationToken); + _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task SearchEndpoint_ReturnsResults() + { + using var client = fixture.CreateApiClient(); + var response = await client.GetAsync("/docs/_api/v1/search?q=elasticsearch", TestContext.Current.CancellationToken); + if (!response.IsSuccessStatusCode) + { + var diagnostics = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Fail($"Search endpoint returned {(int)response.StatusCode} {response.StatusCode}:\n{diagnostics}"); + } + + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.SkipUnless(body.TotalResults > 0 && body.Results.Count > 0, "search index has no data, skipping result assertions"); + _ = body.Results.Should().NotBeEmpty("search for 'elasticsearch' should return results when the index is populated"); + body.Results.Should().AllSatisfy(r => + { + _ = r.Url.Should().NotBeNullOrEmpty(); + _ = r.Title.Should().NotBeNullOrEmpty(); + }); + } + + [Fact] + public async Task ChangesEndpoint_ReturnsResponse() + { + using var client = fixture.CreateApiClient(); + var since = Uri.EscapeDataString("2020-01-01T00:00:00Z"); + // The changes endpoint requires open_point_in_time privilege. CI uses a read-only API key that lacks it. + Assert.SkipUnless(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")), + "Skipping: CI read-only API key lacks open_point_in_time privilege required by the changes endpoint"); + + var response = await client.GetAsync($"/docs/_api/v1/changes?since={since}", TestContext.Current.CancellationToken); + if (!response.IsSuccessStatusCode) + { + var diagnostics = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Fail($"Changes endpoint returned {(int)response.StatusCode} {response.StatusCode}:\n{diagnostics}"); + } + + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + _ = body.Should().NotBeNull(); + body.Pages.Should().NotBeNull("pages collection should always be present even when empty"); + } +} diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs new file mode 100644 index 0000000000..2a33f373ae --- /dev/null +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs @@ -0,0 +1,52 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using AwesomeAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; + +namespace Elastic.Documentation.IntegrationTests.Smoke; + +public class McpSmokeTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime +{ + /// + public ValueTask InitializeAsync() => default; + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + if (TestContext.Current.TestState?.Result is not TestResult.Failed) + return default; + foreach (var resource in fixture.InMemoryLogger.RecordedLogs.ToList()) + output.WriteLine(resource.Message); + return default; + } + + [Fact] + public async Task AliveEndpoint_Returns200() + { + using var client = fixture.CreateMcpClient(); + var response = await client.GetAsync("/docs/_mcp/alive", TestContext.Current.CancellationToken); + _ = response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ListTools_ReturnsAtLeastOneTool() + { + using var httpClient = fixture.CreateMcpClient(); + var mcpEndpoint = new Uri(httpClient.BaseAddress!, "/docs/_mcp"); + var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = mcpEndpoint }, + httpClient, + NullLoggerFactory.Instance, + ownsHttpClient: false); + await using var mcpClient = await McpClient.CreateAsync( + transport, + cancellationToken: TestContext.Current.CancellationToken); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + _ = tools.Should().NotBeEmpty("the MCP server should expose at least one tool"); + } +} diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/TestHelpers.cs b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs similarity index 98% rename from tests-integration/Elastic.Assembler.IntegrationTests/TestHelpers.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs index 01bb997ef0..7098a012de 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/TestHelpers.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs @@ -13,7 +13,7 @@ using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging.Abstractions; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public static class TestHelpers { diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs b/tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs similarity index 97% rename from tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs rename to tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs index 89b5a4d529..c82e6fb711 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs @@ -5,7 +5,7 @@ using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging; -namespace Elastic.Assembler.IntegrationTests; +namespace Elastic.Documentation.IntegrationTests; public class TestLogger(ITestOutputHelper? output) : ILogger { diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/AskAiGatewayStreamingTests.cs b/tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs similarity index 99% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/AskAiGatewayStreamingTests.cs rename to tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs index 2b2fc35712..96ec8ce3d0 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/AskAiGatewayStreamingTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging; using Xunit; -namespace Elastic.Documentation.Api.IntegrationTests; +namespace Elastic.Documentation.Api.Tests; /// /// Unit tests for AskAI gateway implementations that verify streaming behavior. diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj b/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj similarity index 100% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj rename to tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs b/tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs similarity index 96% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs rename to tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs index c555951748..6192e81620 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs @@ -7,17 +7,17 @@ using AwesomeAssertions; using Elastic.Documentation.Api; using Elastic.Documentation.Api.AskAi; -using Elastic.Documentation.Api.IntegrationTests.Fixtures; +using Elastic.Documentation.Api.Tests.Fixtures; using FakeItEasy; using Microsoft.Extensions.DependencyInjection; -namespace Elastic.Documentation.Api.IntegrationTests; +namespace Elastic.Documentation.Api.Tests; /// /// Integration tests for euid cookie enrichment in OpenTelemetry traces and logging. /// Uses WebApplicationFactory to test the real API configuration with mocked AskAi services. /// -public class EuidEnrichmentIntegrationTests : IAsyncLifetime +public class EuidEnrichmentTests : IAsyncLifetime { private const string OtlpEndpoint = "http://localhost:4318"; diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs b/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs similarity index 86% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs rename to tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs index d7d5bf88ed..3cf684f429 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs +++ b/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs @@ -16,7 +16,7 @@ using OpenTelemetry.Logs; using OpenTelemetry.Trace; -namespace Elastic.Documentation.Api.IntegrationTests.Fixtures; +namespace Elastic.Documentation.Api.Tests.Fixtures; /// /// Custom WebApplicationFactory for testing the API with mocked services. @@ -36,32 +36,40 @@ public class ApiWebApplicationFactory : WebApplicationFactory public List ExportedLogRecords { get; } = []; private readonly Action? _configureServices; - public ApiWebApplicationFactory() : this(null) + private readonly string? _otlpEndpoint; + + public ApiWebApplicationFactory() : this(null, null) { } - internal ApiWebApplicationFactory(Action? configureServices) => _configureServices = configureServices; + internal ApiWebApplicationFactory(Action? configureServices, string? otlpEndpoint = null) + { + _configureServices = configureServices; + _otlpEndpoint = otlpEndpoint; + } /// /// Creates a factory with specific services replaced by mocks. /// This allows tests to inject fake implementations for testing specific scenarios. /// /// Action to configure service replacements + /// Optional OTLP endpoint to enable the OTLP proxy routes /// New factory instance with replaced services - public static ApiWebApplicationFactory WithMockedServices(Action serviceReplacements) + public static ApiWebApplicationFactory WithMockedServices(Action serviceReplacements, string? otlpEndpoint = null) { var builder = new ServiceReplacementBuilder(); serviceReplacements(builder); - return new ApiWebApplicationFactory(builder.Build()); + return new ApiWebApplicationFactory(builder.Build(), otlpEndpoint); } /// /// Creates a factory with custom service configuration. /// /// Action to configure services directly + /// Optional OTLP endpoint to enable the OTLP proxy routes /// New factory instance with custom service configuration - public static ApiWebApplicationFactory WithMockedServices(Action configureServices) - => new(configureServices); + public static ApiWebApplicationFactory WithMockedServices(Action configureServices, string? otlpEndpoint = null) + => new(configureServices, otlpEndpoint); protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -69,6 +77,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // This prevents WebApplicationFactory from caching and reusing servers across different factory instances builder.UseEnvironment($"Testing-{_instanceId}"); + // Configure OTLP endpoint per-instance to avoid process-global env var races in parallel tests + if (_otlpEndpoint is not null) + builder.UseSetting("OTEL_EXPORTER_OTLP_ENDPOINT", _otlpEndpoint); + builder.ConfigureServices(services => { // Configure OpenTelemetry with in-memory exporters for all tests diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs b/tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs similarity index 93% rename from tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs rename to tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs index 99db7df7da..b1cdd736ba 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/OtlpProxyTests.cs @@ -5,31 +5,18 @@ using System.Net; using System.Text; using AwesomeAssertions; -using Elastic.Documentation.Api.IntegrationTests.Fixtures; using Elastic.Documentation.Api.Telemetry; +using Elastic.Documentation.Api.Tests.Fixtures; using FakeItEasy; using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace Elastic.Documentation.Api.IntegrationTests; +namespace Elastic.Documentation.Api.Tests; -public class OtlpProxyIntegrationTests : IAsyncLifetime +public class OtlpProxyTests { private const string OtlpEndpoint = "http://localhost:4318"; - public ValueTask InitializeAsync() - { - Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT", OtlpEndpoint); - return ValueTask.CompletedTask; - } - - public ValueTask DisposeAsync() - { - GC.SuppressFinalize(this); - Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT", null); - return ValueTask.CompletedTask; - } - [Fact] public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() { @@ -54,7 +41,7 @@ public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() // Replace the named HttpClient with our mock _ = services.AddHttpClient(AdotOtlpService.HttpClientName) .ConfigurePrimaryHttpMessageHandler(() => mockHandler); - }); + }, otlpEndpoint: OtlpEndpoint); var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ @@ -118,7 +105,7 @@ public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() { _ = services.AddHttpClient(AdotOtlpService.HttpClientName) .ConfigurePrimaryHttpMessageHandler(() => mockHandler); - }); + }, otlpEndpoint: OtlpEndpoint); var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ @@ -175,7 +162,7 @@ public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() { _ = services.AddHttpClient(AdotOtlpService.HttpClientName) .ConfigurePrimaryHttpMessageHandler(() => mockHandler); - }); + }, otlpEndpoint: OtlpEndpoint); var client = factory.CreateClient(); var otlpPayload = /*lang=json,strict*/ """ @@ -229,7 +216,7 @@ public async Task OtlpProxyReturnsCollectorErrorStatusCode() .ConfigurePrimaryHttpMessageHandler(() => mockHandler) .RemoveAllResilienceHandlers(); #pragma warning restore EXTEXP0001 - }); + }, otlpEndpoint: OtlpEndpoint); var client = factory.CreateClient(); using var content = new StringContent("{}", Encoding.UTF8, "application/json");