fix: resolve DEBT-017 duplicate ApprovalWorkflow implementation
Adopt Features/ApprovalWorkflow/ (wired into Program.cs, reachable over
HTTP) as the sole VS-26 (formerly VS-03) maker-checker approval slice.
Delete the dead, [DontRegister]'d duplicate under
ApprovalWorkflow/ (Workstream H) and its dedicated test file, which had
been misleadingly credited with "20/20 tests PASS" while being
unreachable at runtime.
- Sql.cs: fix the same Dapper DateOnly-parameter-binding bug that was
already found and fixed in the now-deleted implementation
(commit 2ccf74c) but had not been ported to this one; InsertProposalAsync
would have failed 100% of the time against a real database.
- tests/.../ApprovalWorkflow/ApprovalWorkflowTests.cs: new Handler+Sql+
real-Postgres integration coverage (create/approve/activate role
gating, maker!=checker separation of duties, evidence attachment,
DateOnly round-trip, list filtering) replacing the deleted dead-code
suite at the same path.
- ApprovalWorkflowPolicyTests.cs: extended (5->10 cases) rather than
replaced, since it already tested the kept implementation's Policy.
- Program.cs: drop the reference comment to the deleted namespace.
- TECH_DEBT_REGISTER.md: DEBT-017 marked Completed (DB verification
pending); corrected stale DEBT-023 to point at this resolution;
registered two residual gaps discovered (not introduced) by this
cleanup as DEBT-025 (no GET /approvals/{id}, evidence unreachable via
HTTP) and DEBT-026 (no wired Draft->Proposed transition, so the
approve/activate path is currently unreachable end-to-end via HTTP).
- WBS_PROGRESS_TRACKER.csv / CURRENT_ROADMAP.md: AEG-VS-26-01 kept
BLOCKED, not COMPLETED — no PostgreSQL was reachable in this session
(127.0.0.1:5432 connection refused), so the 8 new integration tests
are unverified; only the 10 pure-Policy tests were confirmed passing.
Cherry-picked cedc8d7/8c777df from docs/wbs-tracker-current-state onto
this worktree branch first, to bring in the VS-26 renumbering and
ADR-WBS-001 that this task's brief assumed already existed.
dotnet build -c Release: 0 errors/0 warnings.
dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release:
10 passed (Policy, no DB), 15 failed (DB connection refused - includes
6 unrelated pre-existing tests matched by the filter substring).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,209 +2,231 @@ namespace KArtSell.Integration.Tests.ApprovalWorkflow;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Xunit;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
using KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
||||
using KArtSell.Modules.ModelOperations.Domain.ApprovalWorkflow;
|
||||
using KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// Integration coverage for the CANONICAL maker-checker approval slice
|
||||
/// (<c>KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow</c> — the one wired into
|
||||
/// <c>Program.cs</c> 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 <c>KArtSell.Modules.ModelOperations.ApprovalWorkflow</c> namespace's test
|
||||
/// coverage, deleted as part of resolving TECH_DEBT_REGISTER.md DEBT-017: that implementation
|
||||
/// was dead code (all 4 endpoints <c>[DontRegister]</c>'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().
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class ApprovalWorkflowTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ApprovalSql _sql;
|
||||
private readonly ApprovalPolicy _policy;
|
||||
private readonly IOutbox _outbox;
|
||||
private readonly ApprovalWorkflowSql _sql;
|
||||
private readonly IClock _clock = new SystemClock();
|
||||
|
||||
public ApprovalWorkflowTests()
|
||||
{
|
||||
_connectionString = "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
|
||||
_sql = new ApprovalSql(_connectionString, new SystemClock());
|
||||
_policy = new ApprovalPolicy(new SystemClock());
|
||||
_outbox = new InMemoryOutbox();
|
||||
_connectionString = TestDatabaseConnection.GetConnectionString();
|
||||
_sql = new ApprovalWorkflowSql(_connectionString);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Ensure database is ready
|
||||
await Task.CompletedTask;
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync("SELECT 1");
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task<Guid> SeedModelAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanCreateProposal_WithMakerRole_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var makerEmail = "maker@company.com";
|
||||
var makerRole = "Maker";
|
||||
|
||||
// Act
|
||||
var result = _policy.CanCreateProposal(makerEmail, makerRole);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanCreateProposal_WithoutMakerRole_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var email = "user@company.com";
|
||||
var role = "Viewer";
|
||||
|
||||
// Act
|
||||
var result = _policy.CanCreateProposal(email, role);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanApproveApproval_WithDifferentChecker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var maker = "maker@company.com";
|
||||
var checker = "checker@company.com";
|
||||
var proposal = new ApprovalProposal { CreatedBy = maker, Status = ApprovalStatus.Proposed };
|
||||
|
||||
// Act
|
||||
var result = _policy.CanApproveApproval(proposal, checker, maker);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanApproveApproval_WithSameMaker_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var maker = "maker@company.com";
|
||||
var proposal = new ApprovalProposal { CreatedBy = maker, Status = ApprovalStatus.Proposed };
|
||||
|
||||
// Act
|
||||
var result = _policy.CanApproveApproval(proposal, maker, maker);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProposal_SetsCorrectDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var modelId = Guid.NewGuid();
|
||||
var maker = "maker@company.com";
|
||||
var justification = "Model passed OOS testing";
|
||||
var effectiveAt = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7));
|
||||
|
||||
// Act
|
||||
var proposal = _policy.CreateProposal(modelId, maker, justification, effectiveAt);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(modelId, proposal.ModelId);
|
||||
Assert.Equal(maker, proposal.CreatedBy);
|
||||
Assert.Equal(ApprovalStatus.Draft, proposal.Status);
|
||||
Assert.Equal(justification, proposal.Justification);
|
||||
Assert.Equal(effectiveAt, proposal.EffectiveAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProposeApproval_TransitionsToProposed()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = new ApprovalProposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "maker@company.com",
|
||||
Status = ApprovalStatus.Draft,
|
||||
Justification = "Test",
|
||||
EffectiveAt = DateOnly.FromDateTime(DateTime.UtcNow)
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = _policy.ProposeApproval(proposal, "maker@company.com");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApprovalStatus.Proposed, updated.Status);
|
||||
Assert.NotNull(updated.ProposedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApproveApproval_AddsEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = new ApprovalProposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "maker@company.com",
|
||||
Status = ApprovalStatus.Proposed,
|
||||
Evidence = [],
|
||||
CorrelationId = Guid.NewGuid()
|
||||
};
|
||||
var evidence = new List<EvidenceItem>
|
||||
{
|
||||
new() { Type = "PBO_SCORE", Url = "s3://pbo-0.95.json", Comment = "Verified" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = _policy.ApproveApproval(proposal, "checker@company.com", "Looks good", evidence);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApprovalStatus.Approved, updated.Status);
|
||||
Assert.Equal("checker@company.com", updated.ApprovedBy);
|
||||
Assert.Single(updated.Evidence);
|
||||
Assert.Equal("PBO_SCORE", updated.Evidence[0].EvidenceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsertAndRetrieveProposal_RoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
var modelId = Guid.NewGuid();
|
||||
var correlationId = Guid.NewGuid();
|
||||
await SeedModelAsync(modelId);
|
||||
|
||||
// Act
|
||||
await _sql.InsertProposalAsync(
|
||||
id,
|
||||
modelId,
|
||||
"Draft",
|
||||
"maker@company.com",
|
||||
"Test justification",
|
||||
DateOnly.FromDateTime(DateTime.UtcNow),
|
||||
DateTimeOffset.UtcNow,
|
||||
correlationId);
|
||||
|
||||
var retrieved = await _sql.GetProposalByIdAsync(id, DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(id, retrieved.Id);
|
||||
Assert.Equal(modelId, retrieved.ModelId);
|
||||
Assert.Equal(correlationId, retrieved.CorrelationId);
|
||||
}
|
||||
|
||||
private async Task SeedModelAsync(Guid modelId)
|
||||
{
|
||||
await using var conn = new Npgsql.NpgsqlConnection(_connectionString);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public class InMemoryOutbox : IOutbox
|
||||
{
|
||||
public List<(string EventType, Guid CorrelationId, object Data)> Events { get; } = [];
|
||||
|
||||
public Task PublishAsync(string eventType, Guid correlationId, object data)
|
||||
[Fact]
|
||||
public async Task CreateProposal_WithMakerRole_InsertsDraftProposal_AndEffectiveAtRoundTrips()
|
||||
{
|
||||
Events.Add((eventType, correlationId, data));
|
||||
return Task.CompletedTask;
|
||||
// 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<UnauthorizedAccessException>(() =>
|
||||
handler.Handle("viewer@company.com", "Viewer", modelId, DateOnly.FromDateTime(DateTime.UtcNow), "no role", Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[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<int>(
|
||||
"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<UnauthorizedAccessException>(() =>
|
||||
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<UnauthorizedAccessException>(() =>
|
||||
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<UnauthorizedAccessException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user