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(() => proposal.SubmitManualReview(ActorId, Now)); proposal.SetPublication(IngestionPublication.Create(IngestionMutationKind.CreatePage, "Document", "Inhoud", "Reference", "Draft"), ActorId, Now); Assert.Throws(() => proposal.SubmitManualReview(ActorId, Now)); proposal.AddSource("document.pdf", "application/pdf", 10, new string('a', 64), "accepted", Guid.NewGuid()); Assert.Throws(() => 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(() => proposal.SubmitManualReview(ActorId, Now)); Assert.Throws(() => 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(() => 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(() => 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(() => 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(() => 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(() => received.Reject(ReviewerId, "No.", Now)); var approved = ReadyAndApproved(); Assert.Throws(() => 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(() => 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(() => 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(() => 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)) }; }