From 9d569fdeb8d07a1cfd828aba28570b4501970d96 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 19 Apr 2026 14:39:09 +0300 Subject: [PATCH] feat(replay): truthful snapshot index runtime Sprint SPRINT_20260416_004_Replay_truthful_snapshot_index_cutover. Replay WebService program wiring; runtime startup contract tests, point-in-time query API integration tests, test environment variable scope helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../StellaOps.Replay.WebService/Program.cs | 28 ++--- .../StellaOps.Replay.WebService/TASKS.md | 1 + .../PointInTimeQueryApiIntegrationTests.cs | 102 ++++++++++++------ .../ReplayRuntimeStartupContractTests.cs | 97 +++++++++++++++++ .../StellaOps.Replay.Core.Tests/TASKS.md | 1 + .../TestEnvironmentVariableScope.cs | 37 +++++++ 6 files changed, 223 insertions(+), 43 deletions(-) create mode 100644 src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/ReplayRuntimeStartupContractTests.cs create mode 100644 src/Replay/__Tests/StellaOps.Replay.Core.Tests/TestEnvironmentVariableScope.cs diff --git a/src/Replay/StellaOps.Replay.WebService/Program.cs b/src/Replay/StellaOps.Replay.WebService/Program.cs index 035b48000..d6c0d17a0 100644 --- a/src/Replay/StellaOps.Replay.WebService/Program.cs +++ b/src/Replay/StellaOps.Replay.WebService/Program.cs @@ -59,7 +59,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); var replayStorageDriver = ResolveStorageDriver(builder.Configuration, "Replay"); -RegisterSnapshotIndexStore(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), replayStorageDriver); +RegisterSnapshotIndexStore(builder.Services, builder.Configuration, builder.Environment, replayStorageDriver); RegisterSnapshotBlobStore(builder.Services, builder.Configuration, "Replay"); builder.Services.AddSingleton(new FeedSnapshotServiceOptions()); builder.Services.AddSingleton(); @@ -436,22 +436,17 @@ await app.LoadTranslationsAsync(); app.TryRefreshStellaRouterEndpoints(routerEnabled); await app.RunAsync().ConfigureAwait(false); -static void RegisterSnapshotIndexStore(IServiceCollection services, IConfiguration configuration, bool isDevelopment, string storageDriver) +static void RegisterSnapshotIndexStore(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment, string storageDriver) { if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase)) { var connectionString = ResolvePostgresConnectionString(configuration, "Replay"); if (string.IsNullOrWhiteSpace(connectionString)) { - if (!isDevelopment) - { - throw new InvalidOperationException( - "Replay requires PostgreSQL connection settings in non-development mode. " + - "Set ConnectionStrings:Default or Replay:Storage:Postgres:ConnectionString."); - } - - services.AddSingleton(); - return; + throw new InvalidOperationException( + "Replay requires PostgreSQL connection settings when Storage:Driver=postgres. " + + "Set ConnectionStrings:Default or Replay:Storage:Postgres:ConnectionString. " + + "Live runtime no longer falls back to in-memory snapshot indexing; tests may opt into Replay:Storage:Driver=inmemory only in the Testing environment."); } services.AddSingleton(_ => new PostgresFeedSnapshotIndexStore(connectionString)); @@ -460,6 +455,13 @@ static void RegisterSnapshotIndexStore(IServiceCollection services, IConfigurati if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase)) { + if (!environment.IsEnvironment("Testing")) + { + throw new InvalidOperationException( + "Replay storage driver 'inmemory' is supported only in the Testing environment. " + + "Use PostgreSQL-backed snapshot indexing for Development and live runtime."); + } + services.AddSingleton(); return; } @@ -492,8 +494,8 @@ static void RegisterSnapshotBlobStore(IServiceCollection services, IConfiguratio static string ResolveStorageDriver(IConfiguration configuration, string serviceName) { return FirstNonEmpty( - configuration["Storage:Driver"], - configuration[$"{serviceName}:Storage:Driver"]) + configuration[$"{serviceName}:Storage:Driver"], + configuration["Storage:Driver"]) ?? "postgres"; } diff --git a/src/Replay/StellaOps.Replay.WebService/TASKS.md b/src/Replay/StellaOps.Replay.WebService/TASKS.md index 30e232289..22890c210 100644 --- a/src/Replay/StellaOps.Replay.WebService/TASKS.md +++ b/src/Replay/StellaOps.Replay.WebService/TASKS.md @@ -9,3 +9,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-006 | DONE | Added Postgres snapshot index + seed-fs snapshot blob stores and wired storage-driver registration in webservice startup. | | SPRINT-20260305-003 | DONE | Replay storage contract closed: object-store narrowed to seed-fs only with deterministic rustfs/unknown-driver startup rejection and synced docs. | +| REPLAY-REAL-001 | DONE | 2026-04-16: Removed the implicit Development fallback to `InMemoryFeedSnapshotIndexStore`; `Replay:Storage:Driver=inmemory` is now `Testing`-only and covered by startup-contract tests. | diff --git a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/PointInTimeQueryApiIntegrationTests.cs b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/PointInTimeQueryApiIntegrationTests.cs index 2df61f85b..c11db9f77 100644 --- a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/PointInTimeQueryApiIntegrationTests.cs +++ b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/PointInTimeQueryApiIntegrationTests.cs @@ -144,37 +144,13 @@ public sealed class PointInTimeQueryApiIntegrationTests private static WebApplicationFactory CreateFactory() { - return new WebApplicationFactory() - .WithWebHostBuilder(builder => - { - builder.UseEnvironment("Development"); - builder.ConfigureAppConfiguration((_, configurationBuilder) => - { - configurationBuilder.AddInMemoryCollection(new Dictionary - { - ["Replay:Authority:Issuer"] = "https://authority.stella-ops.local", - ["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration", - ["Replay:Authority:RequireHttpsMetadata"] = "false", - }); - }); - builder.ConfigureTestServices(services => - { - services.AddAuthentication(TestReplayAuthHandler.SchemeName) - .AddScheme( - TestReplayAuthHandler.SchemeName, - _ => { }); + var environmentScope = new TestEnvironmentVariableScope(new Dictionary + { + ["REPLAY_Replay__Storage__Driver"] = "inmemory", + ["REPLAY_Storage__Driver"] = "inmemory" + }); - services.PostConfigureAll(options => - { - options.DefaultAuthenticateScheme = TestReplayAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestReplayAuthHandler.SchemeName; - options.DefaultScheme = TestReplayAuthHandler.SchemeName; - }); - - services.RemoveAll(); - services.AddSingleton(); - }); - }); + return new ScopedReplayWebApplicationFactory(environmentScope); } private static void ConfigureClient(HttpClient client) @@ -220,4 +196,70 @@ public sealed class PointInTimeQueryApiIntegrationTests return Task.CompletedTask; } } + + private sealed class ScopedReplayWebApplicationFactory : WebApplicationFactory + { + private readonly IDisposable _environmentScope; + private bool _disposed; + + public ScopedReplayWebApplicationFactory(IDisposable environmentScope) + { + _environmentScope = environmentScope; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing && !_disposed) + { + _environmentScope.Dispose(); + _disposed = true; + } + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + + if (_disposed) + { + return; + } + + _environmentScope.Dispose(); + _disposed = true; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((_, configurationBuilder) => + { + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["Replay:Authority:Issuer"] = "https://authority.stella-ops.local", + ["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration", + ["Replay:Authority:RequireHttpsMetadata"] = "false" + }); + }); + builder.ConfigureTestServices(services => + { + services.AddAuthentication(TestReplayAuthHandler.SchemeName) + .AddScheme( + TestReplayAuthHandler.SchemeName, + _ => { }); + + services.PostConfigureAll(options => + { + options.DefaultAuthenticateScheme = TestReplayAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestReplayAuthHandler.SchemeName; + options.DefaultScheme = TestReplayAuthHandler.SchemeName; + }); + + services.RemoveAll(); + services.AddSingleton(); + }); + } + } } diff --git a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/ReplayRuntimeStartupContractTests.cs b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/ReplayRuntimeStartupContractTests.cs new file mode 100644 index 000000000..508e030b0 --- /dev/null +++ b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/ReplayRuntimeStartupContractTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Replay.Core.FeedSnapshots; +using StellaOps.Replay.WebService; +using Xunit; + +namespace StellaOps.Replay.Core.Tests.FeedSnapshots; + +[Trait("Category", "Integration")] +[Trait("Intent", "Operational")] +public sealed class ReplayRuntimeStartupContractTests +{ + [Fact] + public void DevelopmentHost_RejectsMissingPostgresSnapshotIndexConfiguration() + { + using var factory = CreateFactory("Development"); + + var act = () => factory.CreateClient(); + + act.Should() + .Throw() + .WithMessage("*Replay requires PostgreSQL connection settings when Storage:Driver=postgres*"); + } + + [Fact] + public void DevelopmentHost_RejectsExplicitInMemorySnapshotIndexDriver() + { + using var environmentScope = CreateReplayEnvironmentScope("inmemory"); + using var factory = CreateFactory( + "Development"); + + var act = () => factory.CreateClient(); + + act.Should() + .Throw() + .WithMessage("*Replay storage driver 'inmemory' is supported only in the Testing environment*"); + } + + [Fact] + public async Task TestingHost_AllowsExplicitInMemorySnapshotIndexDriver() + { + using var environmentScope = CreateReplayEnvironmentScope("inmemory"); + using var factory = CreateFactory( + "Testing"); + + using var client = factory.CreateClient(); + var health = await client.GetAsync("/healthz"); + + health.StatusCode.Should().Be(HttpStatusCode.OK); + + var store = factory.Services.GetRequiredService(); + store.GetType().Name.Should().Be("InMemoryFeedSnapshotIndexStore"); + } + + private static WebApplicationFactory CreateFactory( + string environment, + IReadOnlyDictionary? overrides = null) + { + return new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseEnvironment(environment); + builder.ConfigureAppConfiguration((_, configurationBuilder) => + { + var values = new Dictionary + { + ["Replay:Authority:Issuer"] = "https://authority.stella-ops.local", + ["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration", + ["Replay:Authority:RequireHttpsMetadata"] = "false", + }; + + if (overrides is not null) + { + foreach (var pair in overrides) + { + values[pair.Key] = pair.Value; + } + } + + configurationBuilder.AddInMemoryCollection(values); + }); + }); + } + + private static TestEnvironmentVariableScope CreateReplayEnvironmentScope(string storageDriver) + { + return new TestEnvironmentVariableScope(new Dictionary + { + ["REPLAY_Replay__Storage__Driver"] = storageDriver, + ["REPLAY_Storage__Driver"] = storageDriver + }); + } +} diff --git a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TASKS.md b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TASKS.md index e13055f25..9664f11ab 100644 --- a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TASKS.md +++ b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TASKS.md @@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-006 | DONE | Added `ReplayFeedSnapshotStoresTests` and validated Postgres index + seed-fs blob stores via class-targeted xUnit execution (3/3 pass). | | SPRINT-20260305-003 | DONE | Added authenticated replay API integration test harness and revalidated Replay core suite (`dotnet test ...` passed 99/99). | +| REPLAY-REAL-001 | DONE | 2026-04-16: Added Replay startup-contract tests proving missing Postgres config and explicit `inmemory` driver both fail outside `Testing`, while `Testing` may opt in explicitly. | diff --git a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TestEnvironmentVariableScope.cs b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TestEnvironmentVariableScope.cs new file mode 100644 index 000000000..14c87a578 --- /dev/null +++ b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TestEnvironmentVariableScope.cs @@ -0,0 +1,37 @@ +using System.Collections; + +namespace StellaOps.Replay.Core.Tests; + +internal sealed class TestEnvironmentVariableScope : IDisposable +{ + private readonly Dictionary _originalValues = new(StringComparer.OrdinalIgnoreCase); + private bool _disposed; + + public TestEnvironmentVariableScope(IReadOnlyDictionary variables) + { + foreach (var key in variables.Keys) + { + _originalValues[key] = Environment.GetEnvironmentVariable(key); + } + + foreach (var pair in variables) + { + Environment.SetEnvironmentVariable(pair.Key, pair.Value); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + foreach (var pair in _originalValues) + { + Environment.SetEnvironmentVariable(pair.Key, pair.Value); + } + + _disposed = true; + } +}