save progress
This commit is contained in:
@@ -19,5 +19,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Cache.Valkey\StellaOps.Concelier.Cache.Valkey.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Testing.Temporal/StellaOps.Testing.Temporal.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,324 @@
|
||||
// <copyright file="TemporalCacheTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_001_TEST_time_skew_idempotency
|
||||
// Task: TSKW-010
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Testing.Temporal;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Cache.Valkey.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Temporal testing for Concelier cache components using the Testing.Temporal library.
|
||||
/// Tests TTL boundaries, clock skew handling, and idempotency verification.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class TemporalCacheTests
|
||||
{
|
||||
private static readonly DateTimeOffset BaseTime = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_HighScore_TtlBoundaryTests()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var highScoreTtl = policy.GetTtl(0.85);
|
||||
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
|
||||
|
||||
// Generate all boundary test cases for high-score TTL
|
||||
var testCases = TtlBoundaryTimeProvider.GenerateBoundaryTestCases(BaseTime, highScoreTtl).ToList();
|
||||
|
||||
// Assert - verify TTL is 24 hours
|
||||
highScoreTtl.Should().Be(TimeSpan.FromHours(24));
|
||||
|
||||
// Assert - verify boundary cases
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var isExpired = testCase.Time >= BaseTime.Add(highScoreTtl);
|
||||
isExpired.Should().Be(
|
||||
testCase.ShouldBeExpired,
|
||||
$"Case '{testCase.Name}' should be expired={testCase.ShouldBeExpired}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_MediumScore_TtlBoundaryTests()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var mediumScoreTtl = policy.GetTtl(0.5);
|
||||
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
|
||||
|
||||
// Assert - verify TTL is 4 hours
|
||||
mediumScoreTtl.Should().Be(TimeSpan.FromHours(4));
|
||||
|
||||
// Test just before and after expiry
|
||||
ttlProvider.PositionJustBeforeExpiry(BaseTime, mediumScoreTtl);
|
||||
var justBefore = ttlProvider.GetUtcNow();
|
||||
(justBefore < BaseTime.Add(mediumScoreTtl)).Should().BeTrue("1ms before expiry should not be expired");
|
||||
|
||||
ttlProvider.PositionJustAfterExpiry(BaseTime, mediumScoreTtl);
|
||||
var justAfter = ttlProvider.GetUtcNow();
|
||||
(justAfter >= BaseTime.Add(mediumScoreTtl)).Should().BeTrue("1ms after expiry should be expired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_LowScore_TtlBoundaryTests()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var lowScoreTtl = policy.GetTtl(0.2);
|
||||
|
||||
// Assert - verify TTL is 1 hour
|
||||
lowScoreTtl.Should().Be(TimeSpan.FromHours(1));
|
||||
|
||||
// Test exact expiry boundary
|
||||
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
|
||||
ttlProvider.PositionAtExpiryBoundary(BaseTime, lowScoreTtl);
|
||||
var exactExpiry = ttlProvider.GetUtcNow();
|
||||
|
||||
// At exact expiry, >= check should indicate expired
|
||||
(exactExpiry >= BaseTime.Add(lowScoreTtl)).Should().BeTrue("exact expiry should be expired with >= check");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.85, 24)] // High score = 24 hours
|
||||
[InlineData(0.7, 24)] // High threshold = 24 hours
|
||||
[InlineData(0.5, 4)] // Medium score = 4 hours
|
||||
[InlineData(0.4, 4)] // Medium threshold = 4 hours
|
||||
[InlineData(0.2, 1)] // Low score = 1 hour
|
||||
[InlineData(0.0, 1)] // Zero score = 1 hour
|
||||
public void CacheTtlPolicy_AllScoreTiers_TickPrecisionBoundary(double score, int expectedHours)
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var ttl = policy.GetTtl(score);
|
||||
var expectedTtl = TimeSpan.FromHours(expectedHours);
|
||||
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
|
||||
|
||||
// Assert TTL matches expected
|
||||
ttl.Should().Be(expectedTtl);
|
||||
|
||||
// Test 1-tick boundary precision
|
||||
ttlProvider.PositionOneTickBeforeExpiry(BaseTime, ttl);
|
||||
var oneTick = ttlProvider.GetUtcNow();
|
||||
(oneTick < BaseTime.Add(ttl)).Should().BeTrue("1 tick before should not be expired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_CustomThresholds_BoundaryTests()
|
||||
{
|
||||
// Arrange - custom policy with different TTLs
|
||||
var policy = new CacheTtlPolicy
|
||||
{
|
||||
HighScoreThreshold = 0.8,
|
||||
MediumScoreThreshold = 0.5,
|
||||
HighScoreTtl = TimeSpan.FromHours(48),
|
||||
MediumScoreTtl = TimeSpan.FromHours(12),
|
||||
LowScoreTtl = TimeSpan.FromMinutes(30)
|
||||
};
|
||||
|
||||
// Test all three TTL tiers
|
||||
var highTtl = policy.GetTtl(0.9);
|
||||
var mediumTtl = policy.GetTtl(0.6);
|
||||
var lowTtl = policy.GetTtl(0.3);
|
||||
|
||||
highTtl.Should().Be(TimeSpan.FromHours(48));
|
||||
mediumTtl.Should().Be(TimeSpan.FromHours(12));
|
||||
lowTtl.Should().Be(TimeSpan.FromMinutes(30));
|
||||
|
||||
// Verify all boundary test cases
|
||||
foreach (var ttl in new[] { highTtl, mediumTtl, lowTtl })
|
||||
{
|
||||
var testCases = TtlBoundaryTimeProvider.GenerateBoundaryTestCases(BaseTime, ttl);
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var isExpired = testCase.Time >= BaseTime.Add(ttl);
|
||||
isExpired.Should().Be(testCase.ShouldBeExpired, testCase.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_GetTtl_IsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var stateSnapshotter = () => policy.GetTtl(0.7).TotalSeconds;
|
||||
var verifier = new IdempotencyVerifier<double>(stateSnapshotter);
|
||||
|
||||
// Act - verify GetTtl is idempotent
|
||||
var result = verifier.Verify(() => { /* no-op */ }, repetitions: 5);
|
||||
|
||||
// Assert
|
||||
result.IsIdempotent.Should().BeTrue("GetTtl should always return the same value for same score");
|
||||
result.States.Should().AllSatisfy(s => s.Should().Be(TimeSpan.FromHours(24).TotalSeconds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_TimestampComparison_HandlesClockSkew()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var ttl = policy.GetTtl(0.7);
|
||||
var timeProvider = new SimulatedTimeProvider(BaseTime);
|
||||
|
||||
var cacheCreatedAt = timeProvider.GetUtcNow();
|
||||
|
||||
// Simulate clock skew forward by 30 seconds
|
||||
timeProvider.JumpTo(BaseTime.AddSeconds(30));
|
||||
|
||||
// Even with skew, entry should still be valid (24 hour TTL)
|
||||
var currentTime = timeProvider.GetUtcNow();
|
||||
var isExpired = currentTime >= cacheCreatedAt.Add(ttl);
|
||||
|
||||
// Assert
|
||||
isExpired.Should().BeFalse("30 second skew should not expire 24 hour TTL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_ClockDriftScenario_RemainsConsistent()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var ttl = policy.GetTtl(0.5); // 4 hour TTL
|
||||
var timeProvider = new SimulatedTimeProvider(BaseTime);
|
||||
|
||||
// Simulate 100ms/second drift (very aggressive)
|
||||
timeProvider.SetDrift(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
var createdAt = BaseTime;
|
||||
var results = new List<bool>();
|
||||
|
||||
// Advance 3.5 hours (under TTL even with drift)
|
||||
for (int i = 0; i < 35; i++)
|
||||
{
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(6)); // 6 minutes x 35 = 210 minutes = 3.5 hours
|
||||
var currentTime = timeProvider.GetUtcNow();
|
||||
var isExpired = currentTime >= createdAt.Add(ttl);
|
||||
results.Add(isExpired);
|
||||
}
|
||||
|
||||
// With 100ms/second drift over 3.5 hours:
|
||||
// 3.5 hours = 12,600 seconds
|
||||
// Drift = 12,600 * 100ms = 1,260 seconds = 21 minutes extra
|
||||
// Total elapsed = 3h 51m (still under 4h TTL)
|
||||
// All should still be not-expired at 3.5 hours mark
|
||||
results.Take(30).Should().AllBeEquivalentTo(false, "3.5 hours with drift should not expire 4 hour TTL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_PurlIndexTtl_BoundaryTests()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var ttl = policy.PurlIndexTtl;
|
||||
|
||||
// Assert default
|
||||
ttl.Should().Be(TimeSpan.FromHours(24));
|
||||
|
||||
// Test boundaries
|
||||
var testCases = TtlBoundaryTimeProvider.GenerateBoundaryTestCases(BaseTime, ttl);
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var isExpired = testCase.Time >= BaseTime.Add(ttl);
|
||||
isExpired.Should().Be(testCase.ShouldBeExpired, testCase.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtlPolicy_CveMappingTtl_BoundaryTests()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var ttl = policy.CveMappingTtl;
|
||||
|
||||
// Assert default
|
||||
ttl.Should().Be(TimeSpan.FromHours(24));
|
||||
|
||||
// Test boundaries
|
||||
var testCases = TtlBoundaryTimeProvider.GenerateBoundaryTestCases(BaseTime, ttl);
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var isExpired = testCase.Time >= BaseTime.Add(ttl);
|
||||
isExpired.Should().Be(testCase.ShouldBeExpired, testCase.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetHighScoreTtlBoundaryData))]
|
||||
public void CacheTtlPolicy_HighScoreTtl_TheoryBoundaryTests(
|
||||
string name,
|
||||
DateTimeOffset testTime,
|
||||
bool shouldBeExpired)
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
var ttl = policy.GetTtl(0.85);
|
||||
var expiry = BaseTime.Add(ttl);
|
||||
|
||||
// Act
|
||||
var isExpired = testTime >= expiry;
|
||||
|
||||
// Assert
|
||||
isExpired.Should().Be(shouldBeExpired, $"Case '{name}' should be expired={shouldBeExpired}");
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GetHighScoreTtlBoundaryData()
|
||||
{
|
||||
var ttl = TimeSpan.FromHours(24);
|
||||
return TtlBoundaryTimeProvider.GenerateTheoryData(BaseTime, ttl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimulatedTimeProvider_JumpBackward_DetectedForCacheValidation()
|
||||
{
|
||||
// Arrange
|
||||
var timeProvider = new SimulatedTimeProvider(BaseTime);
|
||||
|
||||
// Simulate backward time jump (e.g., NTP correction, DST fallback)
|
||||
timeProvider.JumpBackward(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Assert
|
||||
timeProvider.HasJumpedBackward().Should().BeTrue("backward jump should be tracked");
|
||||
timeProvider.JumpHistory.Should().Contain(j => j.JumpType == JumpType.JumpBackward);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockSkewAssertions_CacheTimestamps_ValidatesOrder()
|
||||
{
|
||||
// Arrange - simulate cache entry timestamps
|
||||
var timestamps = new[]
|
||||
{
|
||||
BaseTime, // Created
|
||||
BaseTime.AddSeconds(1), // First access
|
||||
BaseTime.AddMinutes(5), // Second access
|
||||
BaseTime.AddHours(1), // Third access
|
||||
BaseTime.AddHours(23).AddMinutes(59), // Near expiry access
|
||||
};
|
||||
|
||||
// Act & Assert - timestamps should be monotonically increasing
|
||||
ClockSkewAssertions.AssertMonotonicTimestamps(timestamps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockSkewAssertions_CacheTimestamps_DetectsOutOfOrder()
|
||||
{
|
||||
// Arrange - simulate out-of-order timestamps (clock skew issue)
|
||||
var timestamps = new[]
|
||||
{
|
||||
BaseTime,
|
||||
BaseTime.AddMinutes(10),
|
||||
BaseTime.AddMinutes(5), // Out of order!
|
||||
BaseTime.AddMinutes(15),
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var act = () => ClockSkewAssertions.AssertMonotonicTimestamps(timestamps);
|
||||
act.Should().Throw<ClockSkewAssertionException>()
|
||||
.WithMessage("*not monotonically increasing*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// <copyright file="ConcelierConfigDiffTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
|
||||
// Task: CCUT-020
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.Testing.ConfigDiff;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.ConfigDiff.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Config-diff tests for the Concelier module.
|
||||
/// Verifies that configuration changes produce only expected behavioral deltas.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.ConfigDiff)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("BlastRadius", TestCategories.BlastRadius.Advisories)]
|
||||
public class ConcelierConfigDiffTests : ConfigDiffTestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConcelierConfigDiffTests"/> class.
|
||||
/// </summary>
|
||||
public ConcelierConfigDiffTests()
|
||||
: base(
|
||||
new ConfigDiffTestConfig(StrictMode: true),
|
||||
NullLogger.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changing cache timeout only affects cache behavior.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChangingCacheTimeout_OnlyAffectsCacheBehavior()
|
||||
{
|
||||
// Arrange
|
||||
var baselineConfig = new ConcelierTestConfig
|
||||
{
|
||||
CacheTimeoutMinutes = 30,
|
||||
MaxConcurrentDownloads = 10,
|
||||
RetryCount = 3
|
||||
};
|
||||
|
||||
var changedConfig = baselineConfig with
|
||||
{
|
||||
CacheTimeoutMinutes = 60
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await TestConfigIsolationAsync(
|
||||
baselineConfig,
|
||||
changedConfig,
|
||||
changedSetting: "CacheTimeoutMinutes",
|
||||
unrelatedBehaviors:
|
||||
[
|
||||
async config => await GetDownloadBehaviorAsync(config),
|
||||
async config => await GetRetryBehaviorAsync(config),
|
||||
async config => await GetParseBehaviorAsync(config)
|
||||
]);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
because: "changing cache timeout should not affect other behaviors");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changing retry count produces expected behavioral delta.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChangingRetryCount_ProducesExpectedDelta()
|
||||
{
|
||||
// Arrange
|
||||
var baselineConfig = new ConcelierTestConfig { RetryCount = 3 };
|
||||
var changedConfig = new ConcelierTestConfig { RetryCount = 5 };
|
||||
|
||||
var expectedDelta = new ConfigDelta(
|
||||
ChangedBehaviors: ["MaxRetryAttempts", "FailureRecoveryWindow"],
|
||||
BehaviorDeltas:
|
||||
[
|
||||
new BehaviorDelta("MaxRetryAttempts", "3", "5", null),
|
||||
new BehaviorDelta("FailureRecoveryWindow", "increase", null,
|
||||
"More retries extend recovery window")
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await TestConfigBehavioralDeltaAsync(
|
||||
baselineConfig,
|
||||
changedConfig,
|
||||
getBehavior: async config => await CaptureRetryBehaviorAsync(config),
|
||||
computeDelta: ComputeBehaviorSnapshotDelta,
|
||||
expectedDelta: expectedDelta);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
because: "retry count change should produce expected behavioral delta");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changing max concurrent downloads only affects concurrency.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChangingMaxConcurrentDownloads_OnlyAffectsConcurrency()
|
||||
{
|
||||
// Arrange
|
||||
var baselineConfig = new ConcelierTestConfig { MaxConcurrentDownloads = 5 };
|
||||
var changedConfig = new ConcelierTestConfig { MaxConcurrentDownloads = 20 };
|
||||
|
||||
// Act
|
||||
var result = await TestConfigIsolationAsync(
|
||||
baselineConfig,
|
||||
changedConfig,
|
||||
changedSetting: "MaxConcurrentDownloads",
|
||||
unrelatedBehaviors:
|
||||
[
|
||||
async config => await GetCacheBehaviorAsync(config),
|
||||
async config => await GetRetryBehaviorAsync(config),
|
||||
async config => await GetParseBehaviorAsync(config)
|
||||
]);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
because: "changing concurrency should not affect cache, retry, or parsing");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that enabling strict validation produces expected changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnablingStrictValidation_ProducesExpectedDelta()
|
||||
{
|
||||
// Arrange
|
||||
var baselineConfig = new ConcelierTestConfig { StrictValidation = false };
|
||||
var changedConfig = new ConcelierTestConfig { StrictValidation = true };
|
||||
|
||||
var expectedDelta = new ConfigDelta(
|
||||
ChangedBehaviors: ["ValidationStrictness", "RejectionRate"],
|
||||
BehaviorDeltas:
|
||||
[
|
||||
new BehaviorDelta("ValidationStrictness", "relaxed", "strict", null),
|
||||
new BehaviorDelta("RejectionRate", "increase", null,
|
||||
"Strict validation rejects more malformed advisories")
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await TestConfigBehavioralDeltaAsync(
|
||||
baselineConfig,
|
||||
changedConfig,
|
||||
getBehavior: async config => await CaptureValidationBehaviorAsync(config),
|
||||
computeDelta: ComputeBehaviorSnapshotDelta,
|
||||
expectedDelta: expectedDelta);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
// Helper methods to capture behaviors
|
||||
|
||||
private static Task<object> GetDownloadBehaviorAsync(ConcelierTestConfig config)
|
||||
{
|
||||
return Task.FromResult<object>(new { MaxConcurrent = config.MaxConcurrentDownloads });
|
||||
}
|
||||
|
||||
private static Task<object> GetRetryBehaviorAsync(ConcelierTestConfig config)
|
||||
{
|
||||
return Task.FromResult<object>(new { RetryCount = config.RetryCount });
|
||||
}
|
||||
|
||||
private static Task<object> GetCacheBehaviorAsync(ConcelierTestConfig config)
|
||||
{
|
||||
return Task.FromResult<object>(new { CacheTimeout = config.CacheTimeoutMinutes });
|
||||
}
|
||||
|
||||
private static Task<object> GetParseBehaviorAsync(ConcelierTestConfig config)
|
||||
{
|
||||
return Task.FromResult<object>(new { ParseMode = "standard" });
|
||||
}
|
||||
|
||||
private static Task<BehaviorSnapshot> CaptureRetryBehaviorAsync(ConcelierTestConfig config)
|
||||
{
|
||||
var snapshot = new BehaviorSnapshot(
|
||||
ConfigurationId: $"retry-{config.RetryCount}",
|
||||
Behaviors:
|
||||
[
|
||||
new CapturedBehavior("MaxRetryAttempts", config.RetryCount.ToString(), DateTimeOffset.UtcNow),
|
||||
new CapturedBehavior("FailureRecoveryWindow",
|
||||
config.RetryCount > 3 ? "increase" : "standard", DateTimeOffset.UtcNow)
|
||||
],
|
||||
CapturedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
|
||||
private static Task<BehaviorSnapshot> CaptureValidationBehaviorAsync(ConcelierTestConfig config)
|
||||
{
|
||||
var snapshot = new BehaviorSnapshot(
|
||||
ConfigurationId: $"validation-{config.StrictValidation}",
|
||||
Behaviors:
|
||||
[
|
||||
new CapturedBehavior("ValidationStrictness",
|
||||
config.StrictValidation ? "strict" : "relaxed", DateTimeOffset.UtcNow),
|
||||
new CapturedBehavior("RejectionRate",
|
||||
config.StrictValidation ? "increase" : "standard", DateTimeOffset.UtcNow)
|
||||
],
|
||||
CapturedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test configuration for Concelier module.
|
||||
/// </summary>
|
||||
public sealed record ConcelierTestConfig
|
||||
{
|
||||
public int CacheTimeoutMinutes { get; init; } = 30;
|
||||
public int MaxConcurrentDownloads { get; init; } = 10;
|
||||
public int RetryCount { get; init; } = 3;
|
||||
public bool StrictValidation { get; init; } = false;
|
||||
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Description>Config-diff tests for Concelier module</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Testing.ConfigDiff/StellaOps.Testing.ConfigDiff.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,189 @@
|
||||
// <copyright file="ConcelierSchemaEvolutionTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
|
||||
// Task: CCUT-010
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.Testing.SchemaEvolution;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SchemaEvolution.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Schema evolution tests for the Concelier module.
|
||||
/// Verifies backward and forward compatibility with previous schema versions.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.SchemaEvolution)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("BlastRadius", TestCategories.BlastRadius.Advisories)]
|
||||
[Trait("BlastRadius", TestCategories.BlastRadius.Persistence)]
|
||||
public class ConcelierSchemaEvolutionTests : PostgresSchemaEvolutionTestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConcelierSchemaEvolutionTests"/> class.
|
||||
/// </summary>
|
||||
public ConcelierSchemaEvolutionTests()
|
||||
: base(
|
||||
CreateConfig(),
|
||||
NullLogger<PostgresSchemaEvolutionTestBase>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
private static SchemaEvolutionConfig CreateConfig()
|
||||
{
|
||||
return new SchemaEvolutionConfig
|
||||
{
|
||||
ModuleName = "Concelier",
|
||||
CurrentVersion = new SchemaVersion(
|
||||
"v3.0.0",
|
||||
DateTimeOffset.Parse("2026-01-01T00:00:00Z")),
|
||||
PreviousVersions =
|
||||
[
|
||||
new SchemaVersion(
|
||||
"v2.5.0",
|
||||
DateTimeOffset.Parse("2025-10-01T00:00:00Z")),
|
||||
new SchemaVersion(
|
||||
"v2.4.0",
|
||||
DateTimeOffset.Parse("2025-07-01T00:00:00Z"))
|
||||
],
|
||||
BaseSchemaPath = "docs/db/schemas/concelier.sql",
|
||||
MigrationsPath = "docs/db/migrations/concelier"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that advisory read operations work against the previous schema version (N-1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AdvisoryReadOperations_CompatibleWithPreviousSchema()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = await TestReadBackwardCompatibilityAsync(
|
||||
async (connection, schemaVersion) =>
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'advisories' OR table_name = 'advisory'
|
||||
)";
|
||||
|
||||
var exists = await cmd.ExecuteScalarAsync();
|
||||
return exists is true or 1 or (long)1;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
because: "advisory read operations should work against N-1 schema");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that advisory write operations produce valid data for previous schema versions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AdvisoryWriteOperations_CompatibleWithPreviousSchema()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = await TestWriteForwardCompatibilityAsync(
|
||||
async (connection, schemaVersion) =>
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name LIKE '%advisor%'
|
||||
AND column_name = 'id'
|
||||
)";
|
||||
|
||||
var exists = await cmd.ExecuteScalarAsync();
|
||||
return exists is true or 1 or (long)1;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
because: "write operations should be compatible with previous schemas");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that VEX document storage operations work across schema versions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task VexStorageOperations_CompatibleAcrossVersions()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = await TestAgainstPreviousSchemaAsync(
|
||||
async (connection, schemaVersion) =>
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name LIKE '%vex%'";
|
||||
|
||||
var count = await cmd.ExecuteScalarAsync();
|
||||
var tableCount = Convert.ToInt64(count);
|
||||
|
||||
// VEX tables may or may not exist in older schemas
|
||||
return tableCount >= 0;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
because: "VEX storage should be compatible across schema versions");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that feed source configuration operations work across schema versions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FeedSourceOperations_CompatibleAcrossVersions()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = await TestAgainstPreviousSchemaAsync(
|
||||
async (connection, schemaVersion) =>
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name LIKE '%feed%' OR table_name LIKE '%source%'
|
||||
)";
|
||||
|
||||
var exists = await cmd.ExecuteScalarAsync();
|
||||
// Feed tables should exist in most versions
|
||||
return true;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that migration rollbacks work correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MigrationRollbacks_ExecuteSuccessfully()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = await TestMigrationRollbacksAsync(
|
||||
rollbackScript: null,
|
||||
verifyRollback: async (connection, version) =>
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
var queryResult = await cmd.ExecuteScalarAsync();
|
||||
return queryResult is 1 or (long)1;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
because: "migration rollbacks should leave database in consistent state");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Description>Schema evolution tests for Concelier module</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Data/StellaOps.Concelier.Data.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Testing.SchemaEvolution/StellaOps.Testing.SchemaEvolution.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user