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
327 lines
14 KiB
C#
327 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Approval Workflow Tests
|
|
/// Covers: Approval queue, maker-checker pattern, status transitions, validation
|
|
/// Following AGENTS.md v16.0: Workflow correctness, constraint enforcement, audit trails
|
|
/// </summary>
|
|
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 = 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 4.1: Approval Queue List - Retrieve pending and completed approvals
|
|
/// </summary>
|
|
[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 DateTime(2024, 1, 2), End = new DateTime(2024, 8, 31) },
|
|
new { RunId = runId2, ModelId = modelId, Start = new DateTime(2024, 1, 2), End = new DateTime(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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate 4.2: Approve Model - Maker-checker approval with validation
|
|
/// </summary>
|
|
[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 DateTime(2024, 1, 2), End = new DateTime(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate 4.3: Approval Constraint - Cannot approve non-Pending approvals
|
|
/// </summary>
|
|
[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 DateTime(2024, 1, 2), End = new DateTime(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<InvalidOperationException>(
|
|
() => handler.HandleAsync(request, Guid.NewGuid(), CancellationToken.None));
|
|
|
|
Assert.Contains("not Pending", ex.Message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate 4.4: Reject Model - Alternative workflow for rejected approvals
|
|
/// </summary>
|
|
[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 DateTime(2024, 1, 2), End = new DateTime(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate 4.5: Approval Audit Trail - All state transitions tracked
|
|
/// </summary>
|
|
[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 = MarketTime.SeoulDateTime(_clock.UtcNow);
|
|
|
|
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 DateTime(2024, 1, 2), End = new DateTime(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 = MarketTime.SeoulDateTime(_clock.UtcNow);
|
|
|
|
// 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate 4.6: Approval Idempotency - Cannot create duplicate approval for same run
|
|
/// </summary>
|
|
[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 DateTime(2024, 1, 2), End = new DateTime(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<PostgresException>(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<System.Data.Common.DbConnection> OpenAsync(CancellationToken cancellationToken)
|
|
=> await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
}
|
|
}
|