Files
KArtSell.Aegis/tests/KArtSell.Integration.Tests/ShadowRunGate3Tests.cs
T
kjh2064 74ddd95a05
ci / backend (push) Failing after 0s
ci / static (push) Failing after 6s
ci / backend (pull_request) Failing after 1s
ci / static (pull_request) Failing after 7s
Build & Test with Secrets / build (pull_request) Failing after 1s
ci / frontend (push) Failing after 48s
Build & Test with Secrets / security-scan (pull_request) Successful in 5s
Build & Test with Secrets / frontend (pull_request) Failing after 1m23s
ci / frontend (pull_request) Failing after 1m32s
Build & Test with Secrets / notification (pull_request) Failing after 2s
테스트 DB 계약과 실행 안전성 정렬
2026-08-02 17:37:12 +09:00

429 lines
16 KiB
C#

using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Time;
using Npgsql;
using Xunit;
namespace KArtSell.Integration.Tests;
/// <summary>
/// Gate 3: Shadow Run Validation Tests (252+ trading-day execution)
/// Covers: End-to-end shadow run workflow, validation gates, approval auto-population
/// Following AGENTS.md v16.0: Complete validation pipeline, evidence preservation
/// </summary>
public sealed class ShadowRunGate3Tests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly NpgsqlDataSource _dataSource;
private readonly IDbConnectionFactory _connectionFactory;
private readonly IClock _clock = new SystemClock();
public ShadowRunGate3Tests()
{
_connectionString = TestDatabaseConnection.GetConnectionString();
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
}
public async Task InitializeAsync()
{
await using var connection = await _dataSource.OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync();
}
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
}
/// <summary>
/// Gate 3.1: Shadow Run Completion - EvaluationComplete status recorded
/// </summary>
[Fact]
public async Task ShadowRun_ExecutionComplete_RecordsMetricsAndValidationGates()
{
// Arrange: Create test shadow run with complete validation gates
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var now = _clock.UtcNow.DateTime;
var validationGates = """
{
"pbo": 0.15,
"pbo_under_20": true,
"dsr": 0.96,
"dsr_above_95": true,
"cost_2x_positive": true,
"all_gates_passed": true,
"sharpe": 1.45,
"calmar": 0.82,
"max_drawdown": 0.18,
"returns": 0.28
}
""";
var phaseAnalysis = """
{
"bull": {"sharpe": 1.8, "return": 0.35, "days": 85},
"bear": {"sharpe": 0.9, "return": 0.15, "days": 45},
"sideways": {"sharpe": 1.2, "return": 0.22, "days": 122}
}
""";
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run
(run_id, model_id, window_start, window_end, status, published_at,
validation_gates_json, phase_analysis_json)
VALUES (@RunId, @ModelId, @Start, @End, @Status, @Now,
CAST(@Gates AS jsonb), CAST(@Phase AS jsonb))
""",
new
{
RunId = runId,
ModelId = modelId,
Start = new DateTime(2024, 1, 2),
End = new DateTime(2024, 8, 31),
Status = "EvaluationComplete",
Now = now,
Gates = validationGates,
Phase = phaseAnalysis
});
// Act: Query validation gates
var gates = await connection.QuerySingleAsync<(bool AllPassed, decimal Pbo, decimal Dsr, bool Cost2xPositive)>("""
SELECT
CAST(validation_gates_json->>'all_gates_passed' AS bool) as AllPassed,
CAST(validation_gates_json->>'pbo' AS decimal) as Pbo,
CAST(validation_gates_json->>'dsr' AS decimal) as Dsr,
CAST(validation_gates_json->>'cost_2x_positive' AS bool) as Cost2xPositive
FROM model_operations.shadow_run
WHERE run_id = @RunId
""",
new { RunId = runId });
// Assert: All validation gates passed
Assert.True(gates.AllPassed);
Assert.True(gates.Pbo <= 0.20m, "PBO should be ≤ 20%");
Assert.True(gates.Dsr >= 0.95m, "DSR should be ≥ 95th percentile");
Assert.True(gates.Cost2xPositive);
}
/// <summary>
/// Gate 3.2: Validation Gates - PBO ≤ 20% check
/// </summary>
[Fact]
public async Task ShadowRun_ValidationGate_PboUnder20Percent()
{
// Arrange: Create shadow run with PBO near threshold
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var now = _clock.UtcNow.DateTime;
var validationGates = """{"pbo": 0.18, "pbo_under_20": true}""";
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run
(run_id, model_id, window_start, window_end, status, published_at, validation_gates_json)
VALUES (@RunId, @ModelId, @Start, @End, @Status, @Now, CAST(@Gates AS jsonb))
""",
new
{
RunId = runId,
ModelId = modelId,
Start = new DateTime(2024, 1, 2),
End = new DateTime(2024, 8, 31),
Status = "EvaluationComplete",
Now = now,
Gates = validationGates
});
// Act: Check PBO gate
var pboGate = await connection.QuerySingleAsync<(decimal Pbo, bool Pass)>("""
SELECT
CAST(validation_gates_json->>'pbo' AS decimal) as Pbo,
CAST(validation_gates_json->>'pbo_under_20' AS bool) as Pass
FROM model_operations.shadow_run
WHERE run_id = @RunId
""",
new { RunId = runId });
// Assert: PBO under 20% threshold
Assert.True(pboGate.Pass);
Assert.True(pboGate.Pbo <= 0.20m);
}
/// <summary>
/// Gate 3.3: Approval Queue Auto-Population - Shadow run completion triggers approval
/// </summary>
[Fact]
public async Task ShadowRun_CompleteWithAllGatesPassed_AutoPopulatesApprovalQueue()
{
// Arrange: Create shadow run that passes all gates
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var now = MarketTime.SeoulDateTime(_clock.UtcNow);
var validationGates = """
{
"all_gates_passed": true,
"pbo_under_20": true,
"dsr_above_95": true,
"cost_2x_positive": true
}
""";
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run
(run_id, model_id, window_start, window_end, status, published_at, validation_gates_json)
VALUES (@RunId, @ModelId, @Start, @End, @Status, @Now, CAST(@Gates AS jsonb))
""",
new
{
RunId = runId,
ModelId = modelId,
Start = new DateTime(2024, 1, 2),
End = new DateTime(2024, 8, 31),
Status = "EvaluationComplete",
Now = now,
Gates = validationGates
});
// Simulate approval queue auto-population (what downstream job would do)
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
VALUES (@RunId, @ModelId, 'Pending')
""",
new { RunId = runId, ModelId = modelId });
// Act: Query approval status
var approval = await connection.QuerySingleAsync<(Guid RunId, string Status, DateTime RequestedAt)>("""
SELECT run_id as RunId, status as Status, requested_at as RequestedAt
FROM model_operations.approval_queue
WHERE run_id = @RunId
""",
new { RunId = runId });
// Assert: Approval auto-created in Pending status
Assert.Equal("Pending", approval.Status);
Assert.True(approval.RequestedAt <= now.AddSeconds(1));
}
/// <summary>
/// Gate 3.4: Audit Trail - CorrelationId present in all events
/// </summary>
[Fact]
public async Task ShadowRun_AuditTrail_CorrelationIdPreservedInOutbox()
{
// Arrange: Create shadow run with correlation tracking
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var correlationId = Guid.NewGuid().ToString();
var now = MarketTime.SeoulDateTime(_clock.UtcNow);
var validationGates = """{"all_gates_passed": true}""";
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
// Insert shadow run
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run
(run_id, model_id, window_start, window_end, status, published_at, validation_gates_json)
VALUES (@RunId, @ModelId, @Start, @End, @Status, @Now, CAST(@Gates AS jsonb))
""",
new
{
RunId = runId,
ModelId = modelId,
Start = new DateTime(2024, 1, 2),
End = new DateTime(2024, 8, 31),
Status = "EvaluationComplete",
Now = now,
Gates = validationGates
});
// Emit event to outbox with correlation ID
var eventId = Guid.NewGuid();
await connection.ExecuteAsync("""
INSERT INTO building_blocks.outbox_message
(message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash, published_at)
VALUES (@EventId, @EventType, 1, CAST(@Payload AS jsonb), @CorrelationId, @Now, @Hash, @Now)
""",
new
{
EventId = eventId,
EventType = "ShadowRunCompleted",
Payload = $"{{\"runId\":\"{runId}\",\"modelId\":\"{modelId}\"}}",
CorrelationId = correlationId,
Now = now,
Hash = "hash123"
});
// Act: Verify correlation ID is preserved
var audit = await connection.QuerySingleAsync<(string CorrelationId, string EventType)>("""
SELECT correlation_id as CorrelationId, event_type as EventType
FROM building_blocks.outbox_message
WHERE message_id = @EventId
""",
new { EventId = eventId });
// Assert: Correlation ID tracked end-to-end
Assert.Equal(correlationId, audit.CorrelationId);
Assert.Equal("ShadowRunCompleted", audit.EventType);
}
/// <summary>
/// Gate 3.5: Phase Analysis - Bull/Bear/Sideways metrics non-zero
/// </summary>
[Fact]
public async Task ShadowRun_PhaseSegmentation_AllPhaseMetricsNonZero()
{
// Arrange: Create shadow run with phase analysis
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var now = _clock.UtcNow.DateTime;
var phaseAnalysis = """
{
"bull": {"sharpe": 1.8, "return": 0.35, "days": 85},
"bear": {"sharpe": 0.9, "return": 0.15, "days": 45},
"sideways": {"sharpe": 1.2, "return": 0.22, "days": 122}
}
""";
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run
(run_id, model_id, window_start, window_end, status, phase_analysis_json)
VALUES (@RunId, @ModelId, @Start, @End, @Status, CAST(@Phase AS jsonb))
""",
new
{
RunId = runId,
ModelId = modelId,
Start = new DateTime(2024, 1, 2),
End = new DateTime(2024, 8, 31),
Status = "EvaluationComplete",
Phase = phaseAnalysis
});
// Act: Query phase metrics
var phaseJson = await connection.QuerySingleAsync<string>("""
SELECT phase_analysis_json::text
FROM model_operations.shadow_run
WHERE run_id = @RunId
""",
new { RunId = runId });
// Assert: Phase analysis captured (structure validation in real execution)
Assert.NotNull(phaseJson);
Assert.Contains("bull", phaseJson);
Assert.Contains("bear", phaseJson);
Assert.Contains("sideways", phaseJson);
}
/// <summary>
/// Gate 3.6: End-to-End Flow - Shadow run → Approval → Activation
/// </summary>
[Fact]
public async Task ShadowRun_EndToEndFlow_CompletionTriggersApprovalWorkflow()
{
// Arrange: Complete shadow run flow
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var approverId = Guid.NewGuid();
var now = _clock.UtcNow.DateTime;
var validationGates = """
{
"all_gates_passed": true,
"pbo": 0.15,
"dsr": 0.96,
"cost_2x_positive": true
}
""";
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
// Step 1: Shadow run completes
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run
(run_id, model_id, window_start, window_end, status, published_at, validation_gates_json)
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete', @Now, CAST(@Gates AS jsonb))
""",
new
{
RunId = runId,
ModelId = modelId,
Start = new DateTime(2024, 1, 2),
End = new DateTime(2024, 8, 31),
Now = now,
Gates = validationGates
});
// Step 2: Approval auto-populated
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
VALUES (@RunId, @ModelId, 'Pending')
""",
new { RunId = runId, ModelId = modelId });
// Step 3: Maker-checker approves
await connection.ExecuteAsync("""
UPDATE model_operations.approval_queue
SET status = 'Approved', approved_by = @ApproverId, approval_reason = @Reason
WHERE run_id = @RunId
""",
new
{
RunId = runId,
ApproverId = approverId,
Reason = "All gates passed. Ready for activation."
});
// Act: Verify complete flow
var shadowRun = await connection.QuerySingleAsync<(string Status, bool AllGatesPassed)>("""
SELECT status as Status, CAST(validation_gates_json->>'all_gates_passed' AS bool) as AllGatesPassed
FROM model_operations.shadow_run
WHERE run_id = @RunId
""",
new { RunId = runId });
var approval = await connection.QuerySingleAsync<(string Status, Guid ApprovedBy)>("""
SELECT status as Status, approved_by as ApprovedBy
FROM model_operations.approval_queue
WHERE run_id = @RunId
""",
new { RunId = runId });
// Assert: Complete flow successful
Assert.Equal("EvaluationComplete", shadowRun.Status);
Assert.True(shadowRun.AllGatesPassed);
Assert.Equal("Approved", approval.Status);
Assert.Equal(approverId, approval.ApprovedBy);
}
// ========== Helper Class ==========
private sealed class NpgsqlConnectionFactory : IDbConnectionFactory
{
private readonly NpgsqlDataSource _dataSource;
public NpgsqlConnectionFactory(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public async ValueTask<System.Data.Common.DbConnection> OpenAsync(CancellationToken cancellationToken)
=> await _dataSource.OpenConnectionAsync(cancellationToken);
}
}