using Dapper; using KArtSell.BuildingBlocks.Data; using KArtSell.BuildingBlocks.Time; using KArtSell.Modules.ModelOperations.Features.ApproveModel; using KArtSell.Modules.ModelOperations.Features.RejectModel; using Npgsql; using Xunit; using ApproveHandler = KArtSell.Modules.ModelOperations.Features.ApproveModel.Handler; using RejectHandler = KArtSell.Modules.ModelOperations.Features.RejectModel.Handler; using ApproveRequest = KArtSell.Modules.ModelOperations.Features.ApproveModel.Request; using RejectRequest = KArtSell.Modules.ModelOperations.Features.RejectModel.Request; namespace KArtSell.Integration.Tests; /// /// Approval Workflow Tests /// Covers: Approval queue, maker-checker pattern, status transitions, validation /// Following AGENTS.md v16.0: Workflow correctness, constraint enforcement, audit trails /// public sealed class ApprovalWorkflowTests : IAsyncLifetime { private readonly string _connectionString; private readonly NpgsqlDataSource _dataSource; private readonly IDbConnectionFactory _connectionFactory; private readonly IClock _clock = new SystemClock(); public ApprovalWorkflowTests() { _connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"; _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(); } /// /// Gate 4.1: Approval Queue List - Retrieve pending and completed approvals /// [Fact] public async Task ApprovalQueue_RetrievePending_ByStatus() { // Arrange: Create shadow_run and approval_queue records var runId1 = Guid.NewGuid(); var runId2 = Guid.NewGuid(); var modelId = Guid.NewGuid(); var approverId = Guid.NewGuid(); var now = _clock.UtcNow.DateTime; await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None); // Create shadow runs await connection.ExecuteAsync(""" INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, new[] { new { RunId = runId1, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }, new { RunId = runId2, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) } }); // Create approvals: one Pending, one Approved await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) VALUES (@RunId, @ModelId, 'Pending') """, new { RunId = runId1, ModelId = modelId }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status, approved_by, approval_reason, requested_at) VALUES (@RunId, @ModelId, 'Approved', @ApproverId, 'Validation gates passed', @Now) """, new { RunId = runId2, ModelId = modelId, ApproverId = approverId, Now = now }); // Act: Query approval queue var queue = await connection.QueryAsync<(Guid RunId, string Status)>(""" SELECT run_id AS RunId, status AS Status FROM model_operations.approval_queue ORDER BY status, requested_at DESC """); // Assert: Both records present, ordered by status (Approved after Pending) Assert.NotEmpty(queue); Assert.Contains(queue, r => r.Status == "Pending"); Assert.Contains(queue, r => r.Status == "Approved"); } /// /// Gate 4.2: Approve Model - Maker-checker approval with validation /// [Fact] public async Task ApprovalWorkflow_Approve_UpdatesStatusAndApprover() { // Arrange: Create shadow_run and pending approval var runId = Guid.NewGuid(); var modelId = Guid.NewGuid(); var approverId = Guid.NewGuid(); var approvalReason = "Model passes all validation gates"; 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) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) VALUES (@RunId, @ModelId, 'Pending') """, new { RunId = runId, ModelId = modelId }); // Act: Approve model var handler = new ApproveHandler(_connectionFactory, _clock); var request = new ApproveRequest(runId, approvalReason); var response = await handler.HandleAsync(request, approverId, CancellationToken.None); // Assert: Approval status changed to Approved Assert.Equal("Approved", response.Status); Assert.Equal(runId, response.RunId); // Verify database state var approval = await connection.QuerySingleAsync<(string Status, Guid ApprovedBy, string ApprovalReason, DateTime ApprovedAt)>(""" SELECT status, approved_by, approval_reason, approved_at FROM model_operations.approval_queue WHERE run_id = @RunId """, new { RunId = runId }); Assert.Equal("Approved", approval.Status); Assert.Equal(approverId, approval.ApprovedBy); Assert.Equal(approvalReason, approval.ApprovalReason); Assert.True(approval.ApprovedAt > DateTime.MinValue); } /// /// Gate 4.3: Approval Constraint - Cannot approve non-Pending approvals /// [Fact] public async Task ApprovalWorkflow_Approve_RejectsNonPendingApprovals() { // Arrange: Create already-approved approval var runId = Guid.NewGuid(); var modelId = Guid.NewGuid(); var approverId = Guid.NewGuid(); 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) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status, approved_by, approval_reason) VALUES (@RunId, @ModelId, 'Approved', @ApproverId, 'Already approved') """, new { RunId = runId, ModelId = modelId, ApproverId = approverId }); // Act: Try to approve already-approved approval var handler = new ApproveHandler(_connectionFactory, _clock); var request = new ApproveRequest(runId, "Trying to re-approve"); // Assert: Handler rejects the operation var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(request, Guid.NewGuid(), CancellationToken.None)); Assert.Contains("not Pending", ex.Message); } /// /// Gate 4.4: Reject Model - Alternative workflow for rejected approvals /// [Fact] public async Task ApprovalWorkflow_Reject_UpdatesStatusAndReason() { // Arrange: Create pending approval var runId = Guid.NewGuid(); var modelId = Guid.NewGuid(); var rejectionReason = "Model drift detected in OOS period"; 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) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) VALUES (@RunId, @ModelId, 'Pending') """, new { RunId = runId, ModelId = modelId }); // Act: Reject model var handler = new RejectHandler(_connectionFactory, _clock); var request = new RejectRequest(runId, rejectionReason); var response = await handler.HandleAsync(request, Guid.NewGuid(), CancellationToken.None); // Assert: Approval status changed to Rejected Assert.Equal("Rejected", response.Status); Assert.Equal(runId, response.RunId); // Verify database state var approval = await connection.QuerySingleAsync<(string Status, string RejectionReason, DateTime RejectedAt)>(""" SELECT status, rejection_reason, rejected_at FROM model_operations.approval_queue WHERE run_id = @RunId """, new { RunId = runId }); Assert.Equal("Rejected", approval.Status); Assert.Equal(rejectionReason, approval.RejectionReason); Assert.True(approval.RejectedAt > DateTime.MinValue); } /// /// Gate 4.5: Approval Audit Trail - All state transitions tracked /// [Fact] public async Task ApprovalWorkflow_AuditTrail_TimestampsRecorded() { // Arrange: Create approval and track timestamps var runId = Guid.NewGuid(); var modelId = Guid.NewGuid(); var approverId = Guid.NewGuid(); var beforeApproval = _clock.UtcNow.DateTime; 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) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) VALUES (@RunId, @ModelId, 'Pending') """, new { RunId = runId, ModelId = modelId }); // Act: Approve model var handler = new ApproveHandler(_connectionFactory, _clock); var request = new ApproveRequest(runId, "Approved"); var response = await handler.HandleAsync(request, approverId, CancellationToken.None); var afterApproval = _clock.UtcNow.DateTime; // Assert: Timestamps are within expected range var approval = await connection.QuerySingleAsync<(DateTime RequestedAt, DateTime ApprovedAt)>(""" SELECT requested_at, approved_at FROM model_operations.approval_queue WHERE run_id = @RunId """, new { RunId = runId }); Assert.True(approval.RequestedAt < approval.ApprovedAt); Assert.True(approval.ApprovedAt >= beforeApproval && approval.ApprovedAt <= afterApproval.AddSeconds(1)); } /// /// Gate 4.6: Approval Idempotency - Cannot create duplicate approval for same run /// [Fact] public async Task ApprovalWorkflow_UniqueConstraint_PreventsDuplicateApprovals() { // Arrange: Create shadow_run var runId = Guid.NewGuid(); var modelId = Guid.NewGuid(); 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) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); // Insert first approval await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) VALUES (@RunId, @ModelId, 'Pending') """, new { RunId = runId, ModelId = modelId }); // Act: Try to insert duplicate approval for same run var exception = await Assert.ThrowsAsync(async () => { await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) VALUES (@RunId, @ModelId, 'Pending') """, new { RunId = runId, ModelId = modelId }); }); // Assert: UNIQUE constraint violation (run_id is unique) Assert.Contains("23505", exception.SqlState); // unique_violation error code } // ========== Helper Class ========== private sealed class NpgsqlConnectionFactory : IDbConnectionFactory { private readonly NpgsqlDataSource _dataSource; public NpgsqlConnectionFactory(NpgsqlDataSource dataSource) { _dataSource = dataSource; } public async ValueTask OpenAsync(CancellationToken cancellationToken) => await _dataSource.OpenConnectionAsync(cancellationToken); } }