namespace KArtSell.Integration.Tests.ApprovalWorkflow; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Dapper; using KArtSell.BuildingBlocks.Time; using KArtSell.Modules.ModelOperations.Domain.ApprovalWorkflow; using KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow; using Npgsql; using Xunit; /// /// Integration coverage for the CANONICAL maker-checker approval slice /// (KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow — the one wired into /// Program.cs and reachable over HTTP). Exercises Handler + Sql + a real PostgreSQL /// database, per CLAUDE.md's Vertical Slice testing rules (no mocks for this layer). /// /// Replaces the old KArtSell.Modules.ModelOperations.ApprovalWorkflow namespace's test /// coverage, deleted as part of resolving TECH_DEBT_REGISTER.md DEBT-017: that implementation /// was dead code (all 4 endpoints [DontRegister]'d) even though it had 20/20 passing /// tests, while this one — the implementation actually reachable at runtime — had none. /// /// Requires KARTSELL_POSTGRES (or appsettings.Development.json ConnectionStrings:Postgres) to /// point at a reachable PostgreSQL instance with migrations 0035/0036 applied. See /// TestDatabaseConnection.GetConnectionString(). /// [Collection("Database")] public class ApprovalWorkflowTests : IAsyncLifetime { private readonly string _connectionString; private readonly ApprovalWorkflowSql _sql; private readonly IClock _clock = new SystemClock(); public ApprovalWorkflowTests() { _connectionString = TestDatabaseConnection.GetConnectionString(); _sql = new ApprovalWorkflowSql(_connectionString); } public async Task InitializeAsync() { await using var conn = new NpgsqlConnection(_connectionString); await conn.OpenAsync(); await conn.ExecuteAsync("SELECT 1"); } public Task DisposeAsync() => Task.CompletedTask; private async Task SeedModelAsync() { var modelId = Guid.NewGuid(); await using var conn = new NpgsqlConnection(_connectionString); await conn.OpenAsync(); await conn.ExecuteAsync( "INSERT INTO model_operations.models (id, ticker, correlation_id) VALUES (@Id, @Ticker, @CorrelationId)", new { Id = modelId, Ticker = "TEST", CorrelationId = Guid.NewGuid() }); return modelId; } [Fact] public async Task CreateProposal_WithMakerRole_InsertsDraftProposal_AndEffectiveAtRoundTrips() { // Arrange: DateOnly binding is the specific regression this test guards against — the // previously-deleted ApprovalSql.cs could not INSERT a DateOnly parameter through Dapper // at all (fixed in commit 2ccf74c). Use a date with no "today" coincidence risk. var modelId = await SeedModelAsync(); var handler = new CreateApprovalProposalHandler(_sql, _clock); var effectiveAt = new DateOnly(2028, 2, 29); // leap day: exercises DateOnly edge case too // Act var proposalId = await handler.Handle( "maker@company.com", "Maker", modelId, effectiveAt, "Model passed OOS testing", Guid.NewGuid()); // Assert var proposal = await _sql.GetProposalAsync(proposalId); Assert.NotNull(proposal); Assert.Equal(ApprovalStatus.Draft, proposal!.Status); Assert.Equal(modelId, proposal.ModelId); Assert.Equal("maker@company.com", proposal.CreatedBy); Assert.Equal(effectiveAt, proposal.EffectiveAt); } [Fact] public async Task CreateProposal_WithNonMakerRole_ThrowsUnauthorized() { // Arrange var modelId = await SeedModelAsync(); var handler = new CreateApprovalProposalHandler(_sql, _clock); // Act & Assert await Assert.ThrowsAsync(() => handler.Handle("viewer@company.com", "Viewer", modelId, DateOnly.FromDateTime(DateTime.UtcNow), "no role", Guid.NewGuid())); } [Fact] public async Task ProposeForReview_ByCreatingMaker_TransitionsDraftToProposed() { // Arrange: this handler didn't exist until DEBT-026 was fixed — before that, a proposal // created via POST /approvals could never reach Proposed/Approved/Active through the // running application at all (only test code bypassing the handler via // _sql.UpdateProposalStatusAsync directly could move it, as the other tests in this file // still do to set up their own preconditions). var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var proposalId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Ready to send for review", Guid.NewGuid()); var proposeHandler = new ProposeForReviewHandler(_sql, _clock); // Act await proposeHandler.Handle(proposalId, "maker@company.com", Guid.NewGuid()); // Assert var proposal = await _sql.GetProposalAsync(proposalId); Assert.NotNull(proposal); Assert.Equal(ApprovalStatus.Proposed, proposal!.Status); } [Fact] public async Task ProposeForReview_ByDifferentUserThanCreator_ThrowsUnauthorized() { // Arrange var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var proposalId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Only the creator may propose it", Guid.NewGuid()); var proposeHandler = new ProposeForReviewHandler(_sql, _clock); // Act & Assert await Assert.ThrowsAsync(() => proposeHandler.Handle(proposalId, "someone-else@company.com", Guid.NewGuid())); } [Fact] public async Task GetEvidenceForProposalAsync_ReturnsEvidenceAttachedDuringApproval() { // Arrange: this query didn't exist until DEBT-025 was fixed — evidence attached during // approval (the whole point of this slice per CLAUDE.md's "Activation gating") was // otherwise only readable by querying model_operations.approval_evidence directly. var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var proposalId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Needs evidence readback", Guid.NewGuid()); await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); var approveHandler = new ApproveApprovalHandler(_sql, _clock); var evidence = new List<(string Type, string Url, string? Comment)> { ("PBO_SCORE", "s3://evidence/pbo-0.95.json", "Verified"), }; await approveHandler.Handle(proposalId, "checker@company.com", "Checker", "Approved", evidence, Guid.NewGuid()); // Act var retrieved = await _sql.GetEvidenceForProposalAsync(proposalId); // Assert Assert.Single(retrieved); Assert.Equal("PBO_SCORE", retrieved[0].EvidenceType); Assert.Equal("s3://evidence/pbo-0.95.json", retrieved[0].EvidenceUrl); } [Fact] public async Task Approve_WithDifferentChecker_TransitionsToApproved_AndAttachesEvidence() { // Arrange var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var proposalId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Model passed OOS testing; PBO score 0.95", Guid.NewGuid()); await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); var approveHandler = new ApproveApprovalHandler(_sql, _clock); var evidence = new List<(string Type, string Url, string? Comment)> { ("PBO_SCORE", "s3://evidence/pbo-0.95.json", "Verified"), ("OOS_RETURN", "s3://evidence/oos-returns.csv", "Acceptable"), }; // Act await approveHandler.Handle(proposalId, "checker@company.com", "Checker", "PBO verified, OOS metrics acceptable", evidence, Guid.NewGuid()); // Assert var proposal = await _sql.GetProposalAsync(proposalId); Assert.NotNull(proposal); Assert.Equal(ApprovalStatus.Approved, proposal!.Status); Assert.Equal("checker@company.com", proposal.ApprovedBy); Assert.NotNull(proposal.ApprovedAt); await using var conn = new NpgsqlConnection(_connectionString); await conn.OpenAsync(); var evidenceCount = await conn.QuerySingleAsync( "SELECT COUNT(*) FROM model_operations.approval_evidence WHERE approval_proposal_id = @ProposalId", new { ProposalId = proposalId }); Assert.Equal(2, evidenceCount); } [Fact] public async Task Approve_BySameMakerAsChecker_ThrowsUnauthorized_SeparationOfDuties() { // Arrange var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var proposalId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Self-approval attempt", Guid.NewGuid()); await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); var approveHandler = new ApproveApprovalHandler(_sql, _clock); // Act & Assert: same user, even with Checker role, cannot approve their own proposal await Assert.ThrowsAsync(() => approveHandler.Handle(proposalId, "maker@company.com", "Checker", "self-approved", [], Guid.NewGuid())); } [Fact] public async Task Approve_WithNonCheckerRole_ThrowsUnauthorized() { // Arrange var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var proposalId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Needs a Checker role, not just a different user", Guid.NewGuid()); await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); var approveHandler = new ApproveApprovalHandler(_sql, _clock); // Act & Assert: different user, but wrong role await Assert.ThrowsAsync(() => approveHandler.Handle(proposalId, "other-maker@company.com", "Maker", "wrong role", [], Guid.NewGuid())); } [Fact] public async Task Activate_BySreAfterApproval_TransitionsToActive() { // Arrange var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var proposalId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Ready for activation", Guid.NewGuid()); await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Approved, "checker@company.com", "Approved"); var activateHandler = new ActivateModelHandler(_sql, _clock); // Act await activateHandler.Handle(proposalId, "sre@company.com", "SRE", Guid.NewGuid()); // Assert var proposal = await _sql.GetProposalAsync(proposalId); Assert.NotNull(proposal); Assert.Equal(ApprovalStatus.Active, proposal!.Status); } [Fact] public async Task Activate_WithNonSreRole_ThrowsUnauthorized() { // Arrange var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var proposalId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Should not activate without SRE", Guid.NewGuid()); await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Approved, "checker@company.com", "Approved"); var activateHandler = new ActivateModelHandler(_sql, _clock); // Act & Assert await Assert.ThrowsAsync(() => activateHandler.Handle(proposalId, "maker@company.com", "Maker", Guid.NewGuid())); } [Fact] public async Task ListProposalsAsync_FiltersByStatusAndModelId() { // Arrange var modelId = await SeedModelAsync(); var createHandler = new CreateApprovalProposalHandler(_sql, _clock); var draftId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Stays in Draft", Guid.NewGuid()); var proposedId = await createHandler.Handle( "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), "Moves to Proposed", Guid.NewGuid()); await _sql.UpdateProposalStatusAsync(proposedId, ApprovalStatus.Proposed); // Act var proposedOnly = await _sql.ListProposalsAsync(ApprovalStatus.Proposed, modelId); // Assert Assert.Contains(proposedOnly, p => p.Id == proposedId); Assert.DoesNotContain(proposedOnly, p => p.Id == draftId); } }