355 lines
16 KiB
C#
355 lines
16 KiB
C#
using ItWorx.KnowledgeHub.Domain.Ingestion;
|
|
|
|
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
|
|
|
public sealed class IngestionProposalTests
|
|
{
|
|
[Fact]
|
|
public void Manual_submission_requires_original_source_publication_and_submitter()
|
|
{
|
|
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "manual", "manual upload");
|
|
Assert.Throws<DomainException>(() => proposal.SubmitManualReview(ActorId, Now));
|
|
|
|
proposal.SetPublication(IngestionPublication.Create(IngestionMutationKind.CreatePage,
|
|
"Document", "Inhoud", "Reference", "Draft"), ActorId, Now);
|
|
Assert.Throws<DomainException>(() => proposal.SubmitManualReview(ActorId, Now));
|
|
proposal.AddSource("document.pdf", "application/pdf", 10, new string('a', 64), "accepted", Guid.NewGuid());
|
|
Assert.Throws<DomainException>(() => proposal.SubmitManualReview(ReviewerId, Now));
|
|
|
|
proposal.SubmitManualReview(ActorId, Now);
|
|
Assert.Equal(IngestionProposalStatus.ReadyForReview, proposal.Status);
|
|
Assert.Null(proposal.Analysis);
|
|
Assert.Equal("manual-review-submitted", proposal.Revisions[^1].Operation);
|
|
Assert.Throws<DomainException>(() => proposal.SubmitManualReview(ActorId, Now));
|
|
Assert.Throws<DomainException>(() => proposal.AddSource("late.pdf", "application/pdf", 10, new string('b', 64), "accepted", Guid.NewGuid()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Editor_can_update_a_received_manifest_and_preserves_append_only_history()
|
|
{
|
|
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "edit-correlation", "initial manifest");
|
|
|
|
proposal.Edit("changed manifest", "clarified", ActorId, DateTimeOffset.UtcNow);
|
|
|
|
Assert.Equal(2, proposal.RevisionNumber);
|
|
Assert.Equal("changed manifest", proposal.Manifest);
|
|
Assert.Equal("clarified", proposal.ReviewerComment);
|
|
Assert.Equal("edited", proposal.Revisions[^1].Operation);
|
|
}
|
|
private static readonly Guid WorkspaceId = Guid.NewGuid();
|
|
private static readonly Guid ActorId = Guid.NewGuid();
|
|
private static readonly Guid ReviewerId = Guid.NewGuid();
|
|
private static readonly DateTimeOffset Now = new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public void Approval_captures_current_revision_and_reject_is_terminal()
|
|
{
|
|
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "correlation", "manifest");
|
|
|
|
proposal.BeginAnalysis(ActorId, Now);
|
|
proposal.ApplyAnalysis("analysis", ActorId, Now);
|
|
proposal.Approve(ReviewerId, Now);
|
|
|
|
Assert.Equal(proposal.RevisionNumber, proposal.ApprovedRevisionNumber);
|
|
Assert.Throws<DomainException>(() => proposal.Reject(ReviewerId, "late", Now));
|
|
}
|
|
|
|
[Fact]
|
|
public void Failed_publishing_can_be_retried_only_up_to_the_configured_limit()
|
|
{
|
|
var proposal = ReadyAndApproved(maxPublishRetries: 1);
|
|
proposal.BeginPublishing(ReviewerId, Now);
|
|
proposal.MarkFailed(ReviewerId, IngestionProcessingStage.Publish, "publish-error", "Publication could not complete.", Now);
|
|
|
|
proposal.Retry(ReviewerId, Now);
|
|
Assert.Equal(IngestionProposalStatus.Approved, proposal.Status);
|
|
Assert.Equal(1, proposal.RetryCount);
|
|
|
|
proposal.BeginPublishing(ReviewerId, Now);
|
|
proposal.MarkFailed(ReviewerId, IngestionProcessingStage.Publish, "publish-error", "Publication could not complete.", Now);
|
|
Assert.Throws<DomainException>(() => proposal.Retry(ReviewerId, Now));
|
|
}
|
|
|
|
[Fact]
|
|
public void Reviewer_changes_after_approval_append_an_immutable_revision()
|
|
{
|
|
var proposal = ReadyAndApproved();
|
|
var approvedRevision = Assert.Single(proposal.Revisions, revision => revision.Operation == "approved");
|
|
|
|
proposal.RequestChanges(ReviewerId, "Please correct the classification.", Now.AddMinutes(1));
|
|
|
|
Assert.Equal(IngestionProposalStatus.ChangesRequested, proposal.Status);
|
|
Assert.Null(proposal.ApprovedRevisionNumber);
|
|
Assert.Equal(approvedRevision, proposal.Revisions.Single(revision => revision.Operation == "approved"));
|
|
Assert.Equal(proposal.RevisionNumber, proposal.Revisions.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Editor_changes_after_approval_return_the_proposal_to_review_and_clear_approval()
|
|
{
|
|
var proposal = ReadyAndApproved();
|
|
var approvedRevision = proposal.ApprovedRevisionNumber;
|
|
|
|
proposal.Edit("corrected manifest", "Corrected after approval.", ReviewerId, Now.AddMinutes(1));
|
|
|
|
Assert.Equal(IngestionProposalStatus.ReadyForReview, proposal.Status);
|
|
Assert.Null(proposal.ApprovedRevisionNumber);
|
|
Assert.Null(proposal.ApprovedByUserId);
|
|
Assert.Equal("corrected manifest", proposal.Manifest);
|
|
Assert.Equal("edited", proposal.Revisions[^1].Operation);
|
|
Assert.Equal(approvedRevision + 1, proposal.RevisionNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void Published_and_rejected_are_terminal()
|
|
{
|
|
var published = ReadyAndApproved();
|
|
published.BeginPublishing(ReviewerId, Now);
|
|
published.MarkPublished(ReviewerId, Guid.NewGuid(), 1, "published-test", Now);
|
|
|
|
Assert.Throws<DomainException>(() => published.ReturnToReviewForStaleTarget(ReviewerId, Now));
|
|
|
|
var rejected = IngestionProposal.Create(WorkspaceId, ActorId, "other-correlation", "manifest");
|
|
rejected.BeginAnalysis(ActorId, Now);
|
|
rejected.ApplyAnalysis("analysis", ActorId, Now);
|
|
rejected.Reject(ReviewerId, "Not suitable.", Now);
|
|
|
|
Assert.Throws<DomainException>(() => rejected.ApplyAnalysis("late analysis", ActorId, Now));
|
|
}
|
|
|
|
private static IngestionProposal ReadyAndApproved(int maxPublishRetries = IngestionProposal.DefaultMaxPublishRetries)
|
|
{
|
|
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, Guid.NewGuid().ToString("N"), "manifest", maxPublishRetries);
|
|
proposal.BeginAnalysis(ActorId, Now);
|
|
proposal.ApplyAnalysis("analysis", ActorId, Now);
|
|
proposal.Approve(ReviewerId, Now);
|
|
return proposal;
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(IngestionProposalStatus.Received, IngestionProposalStatus.Received)]
|
|
[InlineData(IngestionProposalStatus.Analyzing, IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProposalStatus.Publishing, IngestionProposalStatus.Approved)]
|
|
public void Retry_returns_to_the_stage_specific_safe_state(
|
|
IngestionProposalStatus failedStage, IngestionProposalStatus expectedStatus)
|
|
{
|
|
var proposal = ProposalAt(failedStage);
|
|
proposal.MarkFailed(ActorId, ProcessingStageFor(failedStage), "transient-error", "Processing could not complete.", Now);
|
|
|
|
proposal.Retry(ActorId, Now);
|
|
|
|
Assert.Equal(expectedStatus, proposal.Status);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(IngestionProposalStatus.Received)]
|
|
[InlineData(IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProposalStatus.Publishing)]
|
|
public void Failure_from_a_processing_stage_records_safe_metadata_and_a_revision(IngestionProposalStatus stage)
|
|
{
|
|
var proposal = ProposalAt(stage);
|
|
var priorRevision = proposal.RevisionNumber;
|
|
|
|
proposal.MarkFailed(ActorId, ProcessingStageFor(stage), "transient-error", "Processing could not complete.", Now);
|
|
|
|
Assert.Equal(IngestionProposalStatus.Failed, proposal.Status);
|
|
Assert.Equal(ProcessingStageFor(stage), proposal.FailedProcessingStage);
|
|
Assert.Equal("transient-error", proposal.FailureErrorType);
|
|
Assert.Equal("Processing could not complete.", proposal.FailureMessage);
|
|
Assert.True(proposal.IsRetryEligible);
|
|
var revision = Assert.Single(proposal.Revisions, item => item.Operation == "processing-failed");
|
|
Assert.Equal(priorRevision + 1, revision.RevisionNumber);
|
|
Assert.Equal(ProcessingStageFor(stage), revision.FailedProcessingStage);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(IngestionProposalStatus.ReadyForReview)]
|
|
[InlineData(IngestionProposalStatus.Approved)]
|
|
public void Reviewer_changes_follow_the_explicit_changes_requested_and_analysis_path(IngestionProposalStatus stage)
|
|
{
|
|
var proposal = ProposalAt(stage);
|
|
|
|
proposal.RequestChanges(ReviewerId, "Please correct the classification.", Now);
|
|
Assert.Equal(IngestionProposalStatus.ChangesRequested, proposal.Status);
|
|
proposal.BeginAnalysis(ActorId, Now);
|
|
proposal.ApplyAnalysis("updated analysis", ActorId, Now);
|
|
|
|
Assert.Equal(IngestionProposalStatus.ReadyForReview, proposal.Status);
|
|
Assert.Null(proposal.ApprovedRevisionNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejection_is_allowed_only_from_ready_for_review()
|
|
{
|
|
var received = IngestionProposal.Create(WorkspaceId, ActorId, "received", "manifest");
|
|
Assert.Throws<DomainException>(() => received.Reject(ReviewerId, "No.", Now));
|
|
|
|
var approved = ReadyAndApproved();
|
|
Assert.Throws<DomainException>(() => approved.Reject(ReviewerId, "No.", Now));
|
|
}
|
|
|
|
[Fact]
|
|
public void Stale_target_during_publishing_returns_to_review_and_clears_approval()
|
|
{
|
|
var proposal = ReadyAndApproved();
|
|
proposal.BeginPublishing(ReviewerId, Now);
|
|
|
|
proposal.ReturnToReviewForStaleTarget(ReviewerId, Now);
|
|
|
|
Assert.Equal(IngestionProposalStatus.ReadyForReview, proposal.Status);
|
|
Assert.Null(proposal.ApprovedRevisionNumber);
|
|
Assert.Null(proposal.ApprovedByUserId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("C:\\evidence\\proposal.pdf")]
|
|
[InlineData("/var/tmp/proposal.pdf")]
|
|
[InlineData("../proposal.pdf")]
|
|
[InlineData("https://example.test/proposal.pdf")]
|
|
public void Source_rejects_path_and_url_like_filenames(string unsafeFileName)
|
|
{
|
|
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "source-safety", "manifest");
|
|
|
|
Assert.Throws<DomainException>(() => proposal.AddSource(
|
|
unsafeFileName,
|
|
"application/pdf",
|
|
1,
|
|
new string('a', 64),
|
|
"clean",
|
|
Guid.NewGuid()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Source_permits_a_safe_basename()
|
|
{
|
|
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "source-basename", "manifest");
|
|
|
|
proposal.AddSource("proposal.pdf", "application/pdf", 1, new string('b', 64), "clean", Guid.NewGuid());
|
|
|
|
Assert.Equal("proposal.pdf", Assert.Single(proposal.Sources).FileName);
|
|
}
|
|
|
|
private static IngestionProposal ProposalAt(
|
|
IngestionProposalStatus stage,
|
|
int maxPublishRetries = IngestionProposal.DefaultMaxPublishRetries)
|
|
{
|
|
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, Guid.NewGuid().ToString("N"), "manifest", maxPublishRetries);
|
|
if (stage == IngestionProposalStatus.Received)
|
|
{
|
|
return proposal;
|
|
}
|
|
|
|
proposal.BeginAnalysis(ActorId, Now);
|
|
if (stage == IngestionProposalStatus.Analyzing)
|
|
{
|
|
return proposal;
|
|
}
|
|
|
|
proposal.ApplyAnalysis("analysis", ActorId, Now);
|
|
if (stage == IngestionProposalStatus.ReadyForReview)
|
|
{
|
|
return proposal;
|
|
}
|
|
|
|
proposal.Approve(ReviewerId, Now);
|
|
if (stage == IngestionProposalStatus.Approved)
|
|
{
|
|
return proposal;
|
|
}
|
|
|
|
proposal.BeginPublishing(ReviewerId, Now);
|
|
return proposal;
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(IngestionProcessingStage.Receive, IngestionProposalStatus.Received)]
|
|
[InlineData(IngestionProcessingStage.Scan, IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProcessingStage.Extract, IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProcessingStage.Normalize, IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProcessingStage.Retrieve, IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProcessingStage.Compare, IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProcessingStage.Compose, IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProcessingStage.Publish, IngestionProposalStatus.Publishing)]
|
|
public void Failure_records_an_approved_processing_stage_at_its_matching_lifecycle_stage(
|
|
IngestionProcessingStage processingStage, IngestionProposalStatus lifecycleStage)
|
|
{
|
|
var proposal = ProposalAt(lifecycleStage);
|
|
|
|
proposal.MarkFailed(ActorId, processingStage, "transient-error", "Processing could not complete.", Now);
|
|
|
|
Assert.Equal(processingStage, proposal.FailedProcessingStage);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(IngestionProposalStatus.Received, IngestionProcessingStage.Scan)]
|
|
[InlineData(IngestionProposalStatus.Analyzing, IngestionProcessingStage.Receive)]
|
|
[InlineData(IngestionProposalStatus.Analyzing, IngestionProcessingStage.Publish)]
|
|
[InlineData(IngestionProposalStatus.Publishing, IngestionProcessingStage.Compose)]
|
|
public void Failure_rejects_an_invalid_processing_stage_for_its_lifecycle_stage(
|
|
IngestionProposalStatus lifecycleStage, IngestionProcessingStage processingStage)
|
|
{
|
|
var proposal = ProposalAt(lifecycleStage);
|
|
var revisionNumber = proposal.RevisionNumber;
|
|
|
|
Assert.Throws<DomainException>(() => proposal.MarkFailed(ActorId, processingStage, "transient-error", "Processing could not complete.", Now));
|
|
|
|
Assert.Equal(lifecycleStage, proposal.Status);
|
|
Assert.Equal(revisionNumber, proposal.RevisionNumber);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(IngestionProposalStatus.Received)]
|
|
[InlineData(IngestionProposalStatus.Analyzing)]
|
|
[InlineData(IngestionProposalStatus.Publishing)]
|
|
public void Retry_exhaustion_is_enforced_for_each_retryable_lifecycle_stage(IngestionProposalStatus stage)
|
|
{
|
|
var proposal = ProposalAt(stage, maxPublishRetries: 1);
|
|
proposal.MarkFailed(ActorId, ProcessingStageFor(stage), "transient-error", "Processing could not complete.", Now);
|
|
proposal.Retry(ActorId, Now);
|
|
|
|
if (stage == IngestionProposalStatus.Received)
|
|
{
|
|
proposal.MarkFailed(ActorId, IngestionProcessingStage.Receive, "transient-error", "Processing could not complete.", Now);
|
|
}
|
|
else if (stage == IngestionProposalStatus.Analyzing)
|
|
{
|
|
proposal.MarkFailed(ActorId, IngestionProcessingStage.Scan, "transient-error", "Processing could not complete.", Now);
|
|
}
|
|
else
|
|
{
|
|
proposal.BeginPublishing(ActorId, Now);
|
|
proposal.MarkFailed(ActorId, IngestionProcessingStage.Publish, "transient-error", "Processing could not complete.", Now);
|
|
}
|
|
|
|
Assert.Throws<DomainException>(() => proposal.Retry(ActorId, Now));
|
|
}
|
|
|
|
[Fact]
|
|
public void Retry_clears_live_failure_metadata_but_preserves_the_immutable_failure_revision()
|
|
{
|
|
var proposal = ProposalAt(IngestionProposalStatus.Analyzing);
|
|
proposal.MarkFailed(ActorId, IngestionProcessingStage.Extract, "transient-error", "Processing could not complete.", Now);
|
|
var failureRevision = Assert.Single(proposal.Revisions, revision => revision.Operation == "processing-failed");
|
|
|
|
proposal.Retry(ActorId, Now);
|
|
|
|
Assert.Null(proposal.FailedProcessingStage);
|
|
Assert.Null(proposal.FailureErrorType);
|
|
Assert.Null(proposal.FailureMessage);
|
|
Assert.False(proposal.IsRetryEligible);
|
|
Assert.Equal(IngestionProcessingStage.Extract, failureRevision.FailedProcessingStage);
|
|
var retryRevision = Assert.Single(proposal.Revisions, revision => revision.Operation == "retry-requested");
|
|
Assert.Null(retryRevision.FailedProcessingStage);
|
|
Assert.Null(retryRevision.FailureErrorType);
|
|
Assert.Null(retryRevision.FailureMessage);
|
|
}
|
|
|
|
private static IngestionProcessingStage ProcessingStageFor(IngestionProposalStatus status) => status switch
|
|
{
|
|
IngestionProposalStatus.Received => IngestionProcessingStage.Receive,
|
|
IngestionProposalStatus.Analyzing => IngestionProcessingStage.Scan,
|
|
IngestionProposalStatus.Publishing => IngestionProcessingStage.Publish,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
|
};
|
|
}
|