From f868613a084131422f62d4f42052009ea11fd8d1 Mon Sep 17 00:00:00 2001 From: AhmadAbuhussein Date: Thu, 29 Oct 2026 13:59:10 +0300 Subject: [PATCH] test: critical-journey tests (verify v2) --- AGENTS.md | 15 +++ README.md | 14 +++ .../ElasticSearchLoggerJourneyTests.cs | 103 ++++++++++++++++++ .../ElasticsearchFixture.cs | 58 ++++++++++ .../SW.Logger.IntegrationTests.csproj | 21 ++++ SW.Logger.sln | 14 +++ 6 files changed, 225 insertions(+) create mode 100644 AGENTS.md create mode 100644 SW.Logger.IntegrationTests/ElasticSearchLoggerJourneyTests.cs create mode 100644 SW.Logger.IntegrationTests/ElasticsearchFixture.cs create mode 100644 SW.Logger.IntegrationTests/SW.Logger.IntegrationTests.csproj diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d9d28de --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# AGENTS.md + +## Critical-journey tests (verify) + +This repository's critical journeys are tested in `SW.Logger.IntegrationTests/`; run them with `dotnet test SW.Logger.IntegrationTests`. +They gate releases through the `verify / result` check. + +When you change this repository: +- If you change something a journey uses (a route, screen, form, API call, auth or data shape), + update the affected tests and their mocks or recordings in the same PR, and run them. +- If you add a feature that becomes a core flow, add a journey for it. Keep 3–5 journeys in all: + replace the least important one instead of growing the list. +- If you remove a flow, remove its test. Never skip, delete or loosen a test just to get green. +- Same rules as when they were written: never a real environment (dev, staging or production), + and never real credentials or personal data in tests, mocks or recordings. diff --git a/README.md b/README.md index 5922a7f..c81f7ac 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ SW-Logger/ │ ├── IHostBuilderExtensions.cs │ ├── LoggerOptions.cs │ └── StringExtensions.cs +├── SW.Logger.IntegrationTests/ # Critical journeys against a throwaway Elasticsearch (Testcontainers) +├── SW.Logger.UnitTests/ # Unit tests └── SW.Logger.SampleWeb/ # Sample web application ├── Program.cs ├── Startup.cs @@ -253,6 +255,18 @@ Supports both basic authentication and certificate-based authentication for Elas - .NET 8.0 or higher - For ElasticSearch logging: ElasticSearch cluster (7.x or 8.x) +## Tests + +```sh +dotnet test SW.Logger.UnitTests +# Against a throwaway Elasticsearch 8.18 container (needs Docker): logs reach the data stream, +# and the retention policy is saved and applied. +dotnet test SW.Logger.IntegrationTests +``` + +On Docker Desktop for macOS, if containers fail with `invalid mount config ... docker.raw.sock`, +run with `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock`. + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/SW.Logger.IntegrationTests/ElasticSearchLoggerJourneyTests.cs b/SW.Logger.IntegrationTests/ElasticSearchLoggerJourneyTests.cs new file mode 100644 index 0000000..a99a721 --- /dev/null +++ b/SW.Logger.IntegrationTests/ElasticSearchLoggerJourneyTests.cs @@ -0,0 +1,103 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SW.Logger.ElasticSerach; +using Xunit; + +namespace SW.Logger.IntegrationTests; + +/// +/// What a service relies on SimplyWorks.Logger.ElasticSearch for, against a real +/// (throwaway) Elasticsearch: its logs land in its data stream, and the retention +/// policy it is configured with is saved and applied to that stream. +/// +[Trait("Category", "CriticalJourney")] +public class ElasticSearchLoggerJourneyTests : IClassFixture +{ + private readonly ElasticsearchFixture _elastic; + + public ElasticSearchLoggerJourneyTests(ElasticsearchFixture elastic) => _elastic = elastic; + + [Fact] + public async Task Logs_ReachTheDataStream_AtTheConfiguredLevel() + { + const string app = "journeylogs"; + var marker = Guid.NewGuid().ToString("N"); + + using (var host = BuildHost(app, deleteAfterDays: 30)) + { + await host.StartAsync(); + var logger = host.Services.GetRequiredService().CreateLogger("Journey"); + logger.LogInformation("Order {Marker} shipped", marker); + logger.LogDebug("Debug {Marker} below the configured level", marker); + await host.StopAsync(); + } // Disposing the host flushes the sink's buffer. + + var stream = $"logs-{app}-development"; + JsonElement hits = default; + await ElasticsearchFixture.Eventually(async () => + { + await _elastic.Client.PostAsync($"{stream}/_refresh", null); + var search = await _elastic.Client.GetAsync($"{stream}/_search?q={marker}&size=10"); + if (!search.IsSuccessStatusCode) + return false; + hits = (await search.Content.ReadFromJsonAsync()).GetProperty("hits").GetProperty("hits"); + return hits.GetArrayLength() > 0; + }, TimeSpan.FromSeconds(60), $"The log never reached {stream}."); + + // Only the Information entry: the Debug one is below the configured level. + var log = Assert.Single(hits.EnumerateArray()).GetProperty("_source"); + Assert.Equal($"Order \"{marker}\" shipped", log.GetProperty("message").GetString()); + Assert.Equal("Information", log.GetProperty("log.level").GetString()); + Assert.Equal("Development", log.GetProperty("labels").GetProperty("Environment").GetString()); + Assert.Equal(marker, log.GetProperty("labels").GetProperty("Marker").GetString()); + } + + [Fact] + public async Task RetentionPolicy_IsSavedAndAppliedToTheDataStream() + { + const string app = "journeyretention"; + var stream = $"logs-{app}-development"; + var policy = $"{stream}-retention"; + + // The policy can only be applied to a stream that exists (it is retried daily + // otherwise); create it the way the built-in logs-*-* template would. + var created = await _elastic.Client.PutAsync($"_data_stream/{stream}", null); + Assert.True(created.IsSuccessStatusCode, await created.Content.ReadAsStringAsync()); + + using var host = BuildHost(app, deleteAfterDays: 14, replicasAfterRollover: 0); + await host.StartAsync(); + + await ElasticsearchFixture.Eventually(async () => + { + var settings = await _elastic.Client.GetAsync($"{stream}/_settings/index.lifecycle.name"); + return settings.IsSuccessStatusCode && + (await settings.Content.ReadAsStringAsync()).Contains($"\"{policy}\""); + }, TimeSpan.FromSeconds(60), $"{policy} was never applied to {stream}."); + + var saved = await _elastic.Get($"_ilm/policy/{policy}"); + var phases = saved.GetProperty(policy).GetProperty("policy").GetProperty("phases"); + Assert.Equal("14d", phases.GetProperty("delete").GetProperty("min_age").GetString()); + Assert.Equal(0, phases.GetProperty("warm").GetProperty("actions").GetProperty("allocate") + .GetProperty("number_of_replicas").GetInt32()); + + await host.StopAsync(); + } + + private IHost BuildHost(string app, int deleteAfterDays, int? replicasAfterRollover = null) => + Host.CreateDefaultBuilder() + .UseEnvironment("Development") + .UseSwElasticSearchLogger(o => + { + o.ApplicationName = app; + o.LoggingLevel = 2; // Information + o.ElasticsearchUrl = _elastic.Url; + o.ElasticsearchUser = "test-user"; + o.ElasticsearchPassword = "not-a-real-password"; + o.ElasticsearchDeleteIndexAfterDays = deleteAfterDays; + o.ElasticsearchReplicasAfterRollover = replicasAfterRollover; + }) + .Build(); +} diff --git a/SW.Logger.IntegrationTests/ElasticsearchFixture.cs b/SW.Logger.IntegrationTests/ElasticsearchFixture.cs new file mode 100644 index 0000000..4459e1d --- /dev/null +++ b/SW.Logger.IntegrationTests/ElasticsearchFixture.cs @@ -0,0 +1,58 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Xunit; + +namespace SW.Logger.IntegrationTests; + +/// +/// A throwaway single-node Elasticsearch, pinned to the 8.x line the sink targets +/// (Elastic.Serilog.Sinks 8.18). Security is off so the tests can read it back over +/// plain HTTP; nothing here ever talks to a real cluster. +/// +public sealed class ElasticsearchFixture : IAsyncLifetime +{ + private const int Port = 9200; + + private readonly IContainer _container = new ContainerBuilder("elasticsearch:8.18.2") + .WithEnvironment("discovery.type", "single-node") + .WithEnvironment("xpack.security.enabled", "false") + .WithEnvironment("ES_JAVA_OPTS", "-Xms512m -Xmx512m") + .WithPortBinding(Port, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r + .ForPort(Port) + .ForPath("/_cluster/health"))) + .Build(); + + public string Url => $"http://{_container.Hostname}:{_container.GetMappedPublicPort(Port)}"; + + public HttpClient Client { get; private set; } + + public async Task InitializeAsync() + { + await _container.StartAsync(); + Client = new HttpClient { BaseAddress = new Uri(Url) }; + } + + public async Task DisposeAsync() + { + Client?.Dispose(); + await _container.DisposeAsync(); + } + + public async Task Get(string path) => + await Client.GetFromJsonAsync(path); + + /// Polls until holds, or fails after . + public static async Task Eventually(Func> condition, TimeSpan timeout, string because) + { + var deadline = DateTime.UtcNow + timeout; + while (!await condition()) + { + if (DateTime.UtcNow > deadline) + Assert.Fail(because); + await Task.Delay(500); + } + } +} diff --git a/SW.Logger.IntegrationTests/SW.Logger.IntegrationTests.csproj b/SW.Logger.IntegrationTests/SW.Logger.IntegrationTests.csproj new file mode 100644 index 0000000..7e11926 --- /dev/null +++ b/SW.Logger.IntegrationTests/SW.Logger.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + disable + enable + false + + + + + + + + + + + + + + diff --git a/SW.Logger.sln b/SW.Logger.sln index 2d7a829..abb24a1 100644 --- a/SW.Logger.sln +++ b/SW.Logger.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.Logger.Console", "SW.Log EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.Logger.UnitTests", "SW.Logger.UnitTests\SW.Logger.UnitTests.csproj", "{A2CEEFC3-1E8E-40A7-A224-64DF8697713E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.Logger.IntegrationTests", "SW.Logger.IntegrationTests\SW.Logger.IntegrationTests.csproj", "{60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,18 @@ Global {A2CEEFC3-1E8E-40A7-A224-64DF8697713E}.Release|x64.Build.0 = Release|Any CPU {A2CEEFC3-1E8E-40A7-A224-64DF8697713E}.Release|x86.ActiveCfg = Release|Any CPU {A2CEEFC3-1E8E-40A7-A224-64DF8697713E}.Release|x86.Build.0 = Release|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|x64.ActiveCfg = Debug|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|x64.Build.0 = Debug|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|x86.ActiveCfg = Debug|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|x86.Build.0 = Debug|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|Any CPU.Build.0 = Release|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|x64.ActiveCfg = Release|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|x64.Build.0 = Release|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|x86.ActiveCfg = Release|Any CPU + {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE