Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,16 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Attachments;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class AttachmentUsageTests
|
||||
{
|
||||
[Fact]
|
||||
public void Proposal_owned_usage_has_no_page_or_staging_owner()
|
||||
{
|
||||
var usage = AttachmentUsage.ForIngestionProposal(Guid.NewGuid(), Guid.NewGuid(), DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.NotNull(usage.IngestionProposalId);
|
||||
Assert.Null(usage.PageId);
|
||||
Assert.Null(usage.StagingSessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Decisions;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class DecisionRecordTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateOnly Today = DateOnly.FromDateTime(Now.UtcDateTime);
|
||||
|
||||
private static DecisionRecord CreateProposed(Guid? supersedes = null) =>
|
||||
DecisionRecord.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 1, Today, "Samenvatting", supersedes, Guid.NewGuid(), Now);
|
||||
|
||||
[Fact]
|
||||
public void Create_starts_proposed()
|
||||
{
|
||||
var decision = CreateProposed();
|
||||
|
||||
Assert.Equal(DecisionStatus.Proposed, decision.Status);
|
||||
Assert.Null(decision.SupersededByDecisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accept_transitions_from_proposed_to_accepted()
|
||||
{
|
||||
var decision = CreateProposed();
|
||||
|
||||
decision.Accept(Now);
|
||||
|
||||
Assert.Equal(DecisionStatus.Accepted, decision.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accept_a_non_proposed_decision_throws()
|
||||
{
|
||||
var decision = CreateProposed();
|
||||
decision.Accept(Now);
|
||||
|
||||
var exception = Record.Exception(() => decision.Accept(Now));
|
||||
|
||||
Assert.IsType<InvalidOperationException>(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reject_transitions_from_proposed_to_rejected()
|
||||
{
|
||||
var decision = CreateProposed();
|
||||
|
||||
decision.Reject(Now);
|
||||
|
||||
Assert.Equal(DecisionStatus.Rejected, decision.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DecisionStatus.Proposed)]
|
||||
[InlineData(DecisionStatus.Accepted)]
|
||||
public void Withdraw_is_allowed_from_proposed_or_accepted(DecisionStatus initial)
|
||||
{
|
||||
var decision = CreateProposed();
|
||||
if (initial == DecisionStatus.Accepted)
|
||||
{
|
||||
decision.Accept(Now);
|
||||
}
|
||||
|
||||
decision.Withdraw(Now);
|
||||
|
||||
Assert.Equal(DecisionStatus.Withdrawn, decision.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Withdraw_a_rejected_decision_throws()
|
||||
{
|
||||
var decision = CreateProposed();
|
||||
decision.Reject(Now);
|
||||
|
||||
var exception = Record.Exception(() => decision.Withdraw(Now));
|
||||
|
||||
Assert.IsType<InvalidOperationException>(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkSupersededBy_requires_the_decision_to_currently_be_accepted()
|
||||
{
|
||||
var decision = CreateProposed();
|
||||
|
||||
var exception = Record.Exception(() => decision.MarkSupersededBy(Guid.NewGuid(), Now));
|
||||
|
||||
Assert.IsType<InvalidOperationException>(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkSupersededBy_records_the_replacement_and_flips_status()
|
||||
{
|
||||
var decision = CreateProposed();
|
||||
decision.Accept(Now);
|
||||
var replacementId = Guid.NewGuid();
|
||||
|
||||
decision.MarkSupersededBy(replacementId, Now);
|
||||
|
||||
Assert.Equal(DecisionStatus.Superseded, decision.Status);
|
||||
Assert.Equal(replacementId, decision.SupersededByDecisionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
|
||||
/// <summary>V2-R1 (audit finding M3): a draft must capture every field the editor
|
||||
/// actually lets a user change, not just title/Markdown, so a metadata-only edit is
|
||||
/// recoverable exactly like a content edit is.</summary>
|
||||
public sealed class DraftTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 3, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Create_captures_tags_document_type_status_and_change_summary()
|
||||
{
|
||||
var draft = Draft.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "draft-key", null, null,
|
||||
"Titel", "Markdown", ["netwerk", "referentie"], "Runbook", "Active", "Kleine wijziging",
|
||||
Now, TimeSpan.FromDays(14));
|
||||
|
||||
Assert.Equal(["netwerk", "referentie"], draft.Tags);
|
||||
Assert.Equal("Runbook", draft.DocumentType);
|
||||
Assert.Equal("Active", draft.DocumentStatus);
|
||||
Assert.Equal("Kleine wijziging", draft.ChangeSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_with_no_metadata_defaults_to_an_empty_tag_list_and_null_metadata_never_null_reference()
|
||||
{
|
||||
var draft = Draft.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "draft-key", null, null,
|
||||
"Titel", "Markdown", null, null, null, null, Now, TimeSpan.FromDays(14));
|
||||
|
||||
Assert.Empty(draft.Tags);
|
||||
Assert.Null(draft.DocumentType);
|
||||
Assert.Null(draft.DocumentStatus);
|
||||
Assert.Null(draft.ChangeSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_replaces_every_field_including_a_metadata_only_change()
|
||||
{
|
||||
var draft = Draft.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "draft-key", null, null,
|
||||
"Titel", "Markdown", ["oud"], "WikiPage", "Draft", null, Now, TimeSpan.FromDays(14));
|
||||
|
||||
draft.Update("Titel", "Markdown", ["nieuw"], "Runbook", "Active", "Bijgewerkt", Now.AddMinutes(1), TimeSpan.FromDays(14));
|
||||
|
||||
Assert.Equal(["nieuw"], draft.Tags);
|
||||
Assert.Equal("Runbook", draft.DocumentType);
|
||||
Assert.Equal("Active", draft.DocumentStatus);
|
||||
Assert.Equal("Bijgewerkt", draft.ChangeSummary);
|
||||
Assert.Equal(Now.AddMinutes(1), draft.UpdatedAt);
|
||||
Assert.Equal(Now.AddMinutes(1).AddDays(14), draft.ExpiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class EmailAddressTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_normalizes_case_and_whitespace()
|
||||
{
|
||||
var email = EmailAddress.Create(" Owner@ItWorx.Example ");
|
||||
|
||||
Assert.Equal("owner@itworx.example", email.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("missing-domain@")]
|
||||
[InlineData("@missing-local.example")]
|
||||
public void Create_rejects_invalid_shapes(string value)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => EmailAddress.Create(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreate_returns_false_instead_of_throwing()
|
||||
{
|
||||
var succeeded = EmailAddress.TryCreate("not-an-email", out var emailAddress);
|
||||
|
||||
Assert.False(succeeded);
|
||||
Assert.Null(emailAddress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class FileSystemPathBoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Contains_accepts_the_root_and_descendants()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "knowledge-imports");
|
||||
|
||||
Assert.True(FileSystemPathBoundary.Contains(root, root));
|
||||
Assert.True(FileSystemPathBoundary.Contains(root, Path.Combine(root, "nested", "source")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contains_rejects_parent_and_prefix_sibling_paths()
|
||||
{
|
||||
var parent = Path.Combine(Path.GetTempPath(), $"knowledge-parent-{Guid.NewGuid():N}");
|
||||
var root = Path.Combine(parent, "imports");
|
||||
|
||||
Assert.False(FileSystemPathBoundary.Contains(root, parent));
|
||||
Assert.False(FileSystemPathBoundary.Contains(root, Path.Combine(parent, "imports-private")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,58 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class InfrastructureKnowledgeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Editorial_profile_normalizes_tags_without_touching_source_facts()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var profile = EditorialProfile.Create(
|
||||
Guid.NewGuid(), InfrastructureObjectKind.Service, Guid.NewGuid(), Guid.NewGuid(), now);
|
||||
|
||||
profile.Update(
|
||||
" Knowledge Hub ", "Beschrijving", "Productiviteit", "Doel", null, null,
|
||||
null, null, "Gedekt", null, "Notities", "Risico's", "Update", "Rollback",
|
||||
["docker", "Docker", " kennis "], true, false, false, Guid.NewGuid(), now.AddMinutes(1));
|
||||
|
||||
Assert.Equal("Knowledge Hub", profile.DisplayName);
|
||||
Assert.Equal(["docker", "kennis"], profile.Tags);
|
||||
Assert.True(profile.IsFavorite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_service_is_never_deleted_and_can_be_observed_again()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var service = InfrastructureService.Create(
|
||||
Guid.NewGuid(), "docker:knowledge-hub", "knowledge-hub", now, Guid.NewGuid());
|
||||
service.Confirm();
|
||||
|
||||
service.MarkMissing(now.AddMinutes(1));
|
||||
Assert.Equal(InventoryPresence.Missing, service.Presence);
|
||||
|
||||
service.Refresh(
|
||||
null, "knowledge-hub", "container", "running", HealthState.Healthy,
|
||||
"itworx/knowledge-hub:v2", "sha256:test", "10001:10001", true, true, false,
|
||||
FactConfidence.High, Guid.NewGuid(), now.AddMinutes(2));
|
||||
|
||||
Assert.Equal(InventoryPresence.Present, service.Presence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rejected_proposal_stays_archived_when_seen_in_a_later_snapshot()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var service = InfrastructureService.Create(
|
||||
Guid.NewGuid(), "docker:temporary", "temporary", now, Guid.NewGuid());
|
||||
|
||||
service.Reject();
|
||||
service.Refresh(
|
||||
null, "temporary", "container", "running", HealthState.Healthy,
|
||||
"example/temporary:latest", "sha256:test", "1000:1000", false, true, false,
|
||||
FactConfidence.High, Guid.NewGuid(), now.AddMinutes(1));
|
||||
|
||||
Assert.Equal(InventoryPresence.Archived, service.Presence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class InstallationTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateUninitialized_has_no_completion_timestamp()
|
||||
{
|
||||
var installation = Installation.CreateUninitialized("1.0.0", "m1", "nl-BE", "Europe/Brussels");
|
||||
|
||||
Assert.False(installation.IsSetupComplete);
|
||||
Assert.Null(installation.SetupCompletedAt);
|
||||
Assert.Equal(Installation.SingletonId, installation.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteSetup_can_only_happen_once()
|
||||
{
|
||||
var installation = Installation.CreateUninitialized("1.0.0", "m1", "nl-BE", "Europe/Brussels");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
installation.CompleteSetup(now);
|
||||
|
||||
Assert.True(installation.IsSetupComplete);
|
||||
Assert.Equal(now, installation.SetupCompletedAt);
|
||||
Assert.Throws<InvalidOperationException>(() => installation.CompleteSetup(now));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Domain/ItWorx.KnowledgeHub.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
|
||||
public sealed class PageCreationSessionTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 3, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Open_starts_in_the_Open_status_with_an_expiry_derived_from_the_lifespan()
|
||||
{
|
||||
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
|
||||
|
||||
Assert.True(session.IsOpen);
|
||||
Assert.Equal(PageCreationSessionStatus.Open, session.Status);
|
||||
Assert.Equal(Now.AddHours(24), session.ExpiresAt);
|
||||
Assert.Null(session.CommittedPageId);
|
||||
Assert.Null(session.ClosedAt);
|
||||
Assert.False(session.IsExpired(Now));
|
||||
Assert.True(session.IsExpired(Now.AddHours(25)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkCommitted_transitions_to_Committed_and_records_the_page_id()
|
||||
{
|
||||
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
|
||||
var pageId = Guid.NewGuid();
|
||||
|
||||
session.MarkCommitted(pageId, Now.AddMinutes(5));
|
||||
|
||||
Assert.False(session.IsOpen);
|
||||
Assert.Equal(PageCreationSessionStatus.Committed, session.Status);
|
||||
Assert.Equal(pageId, session.CommittedPageId);
|
||||
Assert.Equal(Now.AddMinutes(5), session.ClosedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkCommitted_a_second_time_throws_never_silently_double_commits()
|
||||
{
|
||||
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
|
||||
session.MarkCommitted(Guid.NewGuid(), Now);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => session.MarkCommitted(Guid.NewGuid(), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDiscarded_transitions_to_Discarded()
|
||||
{
|
||||
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
|
||||
|
||||
session.MarkDiscarded(Now.AddMinutes(1));
|
||||
|
||||
Assert.Equal(PageCreationSessionStatus.Discarded, session.Status);
|
||||
Assert.Equal(Now.AddMinutes(1), session.ClosedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDiscarded_is_idempotent_when_already_discarded()
|
||||
{
|
||||
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
|
||||
session.MarkDiscarded(Now);
|
||||
|
||||
var exception = Record.Exception(() => session.MarkDiscarded(Now.AddMinutes(1)));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Equal(PageCreationSessionStatus.Discarded, session.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDiscarded_after_MarkCommitted_throws_a_committed_page_can_never_be_discarded()
|
||||
{
|
||||
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
|
||||
session.MarkCommitted(Guid.NewGuid(), Now);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => session.MarkDiscarded(Now));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class PageTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 31, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Create_sets_the_initial_revision_number_and_publishes_immediately()
|
||||
{
|
||||
var page = Page.Create(
|
||||
Guid.NewGuid(),
|
||||
null,
|
||||
SortKey.First(),
|
||||
PageTitle.Create("Voorbeeldpagina"),
|
||||
DocumentType.WikiPage,
|
||||
"# Hallo",
|
||||
Guid.NewGuid(),
|
||||
Now);
|
||||
|
||||
Assert.Equal(1, page.CurrentRevisionNumber);
|
||||
Assert.Equal(Now, page.PublishedAt);
|
||||
Assert.False(page.IsTrashed);
|
||||
Assert.Equal("voorbeeldpagina", page.Slug.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_increments_the_revision_number_and_updates_the_slug()
|
||||
{
|
||||
var editor = Guid.NewGuid();
|
||||
var page = Page.Create(
|
||||
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Oude titel"),
|
||||
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
|
||||
|
||||
page.Publish(PageTitle.Create("Nieuwe titel"), "Bijgewerkte inhoud", DocumentType.WikiPage, DocumentStatus.Active, editor, Now.AddMinutes(5));
|
||||
|
||||
Assert.Equal(2, page.CurrentRevisionNumber);
|
||||
Assert.Equal("nieuwe-titel", page.Slug.Value);
|
||||
Assert.Equal(DocumentStatus.Active, page.DocumentStatus);
|
||||
Assert.Equal(editor, page.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveTo_rejects_a_page_becoming_its_own_parent()
|
||||
{
|
||||
var page = Page.Create(
|
||||
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
|
||||
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => page.MoveTo(page.Id, SortKey.First(), Guid.NewGuid(), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_trashed_page_cannot_be_edited_or_moved_until_restored()
|
||||
{
|
||||
var actor = Guid.NewGuid();
|
||||
var page = Page.Create(
|
||||
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
|
||||
DocumentType.WikiPage, "Inhoud", actor, Now);
|
||||
|
||||
page.Trash(actor, Now, TimeSpan.FromDays(30));
|
||||
|
||||
Assert.True(page.IsTrashed);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => page.Publish(PageTitle.Create("Pagina"), "Nieuw", DocumentType.WikiPage, DocumentStatus.Active, actor, Now));
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => page.MoveTo(null, SortKey.First(), actor, Now));
|
||||
|
||||
page.Restore(actor, Now.AddMinutes(1));
|
||||
Assert.False(page.IsTrashed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trashing_twice_is_a_harmless_no_op()
|
||||
{
|
||||
var actor = Guid.NewGuid();
|
||||
var page = Page.Create(
|
||||
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
|
||||
DocumentType.WikiPage, "Inhoud", actor, Now);
|
||||
|
||||
page.Trash(actor, Now, TimeSpan.FromDays(30));
|
||||
var purgeAfterFirst = page.PurgeAfter;
|
||||
page.Trash(actor, Now.AddDays(1), TimeSpan.FromDays(30));
|
||||
|
||||
Assert.Equal(purgeAfterFirst, page.PurgeAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceTags_trims_deduplicates_case_insensitively_and_keeps_first_seen_casing()
|
||||
{
|
||||
var page = Page.Create(
|
||||
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
|
||||
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
|
||||
|
||||
page.ReplaceTags([" Netwerk ", "netwerk", "Archief", "", " "]);
|
||||
|
||||
Assert.Equal(["Netwerk", "Archief"], page.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceTags_bounds_the_number_of_tags_and_the_length_of_each_tag()
|
||||
{
|
||||
var page = Page.Create(
|
||||
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
|
||||
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
|
||||
|
||||
var tooMany = Enumerable.Range(0, Page.MaxTagCount + 10).Select(i => $"tag{i}");
|
||||
page.ReplaceTags(tooMany);
|
||||
Assert.Equal(Page.MaxTagCount, page.Tags.Count);
|
||||
|
||||
var tooLong = new string('a', Page.MaxTagLength + 20);
|
||||
page.ReplaceTags([tooLong]);
|
||||
Assert.Equal(Page.MaxTagLength, page.Tags[0].Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceTags_does_not_advance_the_revision_number_or_change_UpdatedAt()
|
||||
{
|
||||
var page = Page.Create(
|
||||
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
|
||||
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
|
||||
|
||||
page.ReplaceTags(["Runbook"]);
|
||||
|
||||
Assert.Equal(1, page.CurrentRevisionNumber);
|
||||
Assert.Equal(Now, page.UpdatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class PageTitleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_trims_surrounding_whitespace()
|
||||
{
|
||||
var title = PageTitle.Create(" PostgreSQL back-up ");
|
||||
|
||||
Assert.Equal("PostgreSQL back-up", title.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public void Create_rejects_empty_values(string value)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => PageTitle.Create(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_values_above_the_limit()
|
||||
{
|
||||
var value = new string('x', PageTitle.MaximumLength + 1);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => PageTitle.Create(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Projects;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class ProjectTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static Project CreateProject() =>
|
||||
Project.Create(Guid.NewGuid(), "Kennisbank vernieuwing", "Samenvatting", ProjectPriority.Medium, "Huidige stand.", Guid.NewGuid(), Guid.NewGuid(), Now);
|
||||
|
||||
[Fact]
|
||||
public void Create_starts_in_concept_status_with_a_derived_slug()
|
||||
{
|
||||
var project = CreateProject();
|
||||
|
||||
Assert.Equal(ProjectLifecycleStatus.Concept, project.Status);
|
||||
Assert.Equal("kennisbank-vernieuwing", project.Slug);
|
||||
Assert.Empty(project.Technologies);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ProjectLifecycleStatus.Concept, ProjectLifecycleStatus.Planned, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Planned, ProjectLifecycleStatus.Active, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Active, ProjectLifecycleStatus.Blocked, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Blocked, ProjectLifecycleStatus.Active, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Active, ProjectLifecycleStatus.Maintenance, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Active, ProjectLifecycleStatus.Completed, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Completed, ProjectLifecycleStatus.Paused, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Paused, ProjectLifecycleStatus.Active, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Concept, ProjectLifecycleStatus.Archived, true)]
|
||||
[InlineData(ProjectLifecycleStatus.Concept, ProjectLifecycleStatus.Active, false)]
|
||||
[InlineData(ProjectLifecycleStatus.Archived, ProjectLifecycleStatus.Active, false)]
|
||||
[InlineData(ProjectLifecycleStatus.Completed, ProjectLifecycleStatus.Active, false)]
|
||||
public void ChangeStatus_only_allows_the_documented_lifecycle_transitions(
|
||||
ProjectLifecycleStatus from, ProjectLifecycleStatus to, bool expectedAllowed)
|
||||
{
|
||||
Assert.Equal(expectedAllowed, Project.CanTransition(from, to));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeStatus_throws_for_a_disallowed_transition()
|
||||
{
|
||||
var project = CreateProject();
|
||||
|
||||
var exception = Record.Exception(() => project.ChangeStatus(ProjectLifecycleStatus.Active, Guid.NewGuid(), Now));
|
||||
|
||||
Assert.IsType<InvalidOperationException>(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeStatus_out_of_archived_is_always_rejected()
|
||||
{
|
||||
var project = CreateProject();
|
||||
project.ChangeStatus(ProjectLifecycleStatus.Archived, Guid.NewGuid(), Now);
|
||||
|
||||
var exception = Record.Exception(() => project.ChangeStatus(ProjectLifecycleStatus.Active, Guid.NewGuid(), Now));
|
||||
|
||||
Assert.IsType<InvalidOperationException>(exception);
|
||||
Assert.True(project.IsArchived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceTechnologies_normalizes_trims_deduplicates_and_bounds_the_list()
|
||||
{
|
||||
var project = CreateProject();
|
||||
|
||||
project.ReplaceTechnologies([" .NET ", ".net", "PostgreSQL", "", "React"]);
|
||||
|
||||
Assert.Equal([".NET", "PostgreSQL", "React"], project.Technologies);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Runbooks;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class RunbookProfileTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void A_never_validated_runbook_is_stale_against_any_revision()
|
||||
{
|
||||
var runbook = RunbookProfile.Create(Guid.NewGuid(), Guid.NewGuid(), RunbookRiskLevel.Medium, false, null, null, Now);
|
||||
|
||||
Assert.True(runbook.IsStaleAgainst(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_records_the_reviewer_and_the_current_revision()
|
||||
{
|
||||
var runbook = RunbookProfile.Create(Guid.NewGuid(), Guid.NewGuid(), RunbookRiskLevel.High, true, 30, null, Now);
|
||||
var validator = Guid.NewGuid();
|
||||
|
||||
runbook.Validate(validator, 3, Now);
|
||||
|
||||
Assert.Equal(validator, runbook.ValidatedBy);
|
||||
Assert.Equal(Now, runbook.LastValidatedAt);
|
||||
Assert.False(runbook.IsStaleAgainst(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_content_revision_after_validation_makes_the_runbook_stale_again()
|
||||
{
|
||||
var runbook = RunbookProfile.Create(Guid.NewGuid(), Guid.NewGuid(), RunbookRiskLevel.Low, false, null, null, Now);
|
||||
runbook.Validate(Guid.NewGuid(), 2, Now);
|
||||
|
||||
Assert.True(runbook.IsStaleAgainst(3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Attachments;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class SafeFilenameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("../../../../etc/passwd", "passwd")]
|
||||
[InlineData("..\\..\\windows\\system32\\config", "config")]
|
||||
[InlineData("subdir/verslag.pdf", "verslag.pdf")]
|
||||
public void Create_strips_any_directory_component_and_keeps_only_the_leaf_name(string raw, string expected)
|
||||
{
|
||||
var filename = SafeFilename.Create(raw);
|
||||
|
||||
Assert.Equal(expected, filename.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_a_filename_containing_a_null_byte()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SafeFilename.Create("bestand\0.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_a_filename_containing_a_control_character()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SafeFilename.Create("bestand.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_an_empty_or_dot_only_filename()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SafeFilename.Create(""));
|
||||
Assert.Throws<ArgumentException>(() => SafeFilename.Create("."));
|
||||
Assert.Throws<ArgumentException>(() => SafeFilename.Create(".."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_truncates_an_excessively_long_filename()
|
||||
{
|
||||
var raw = new string('a', 400) + ".txt";
|
||||
|
||||
var filename = SafeFilename.Create(raw);
|
||||
|
||||
Assert.True(filename.Value.Length <= SafeFilename.MaximumLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllExtensions_detects_a_double_extension()
|
||||
{
|
||||
var filename = SafeFilename.Create("factuur.pdf.exe");
|
||||
|
||||
Assert.Equal([".pdf", ".exe"], filename.AllExtensions());
|
||||
Assert.Equal(".exe", filename.LastExtension());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastExtension_is_null_when_there_is_no_extension()
|
||||
{
|
||||
var filename = SafeFilename.Create("readme");
|
||||
|
||||
Assert.Null(filename.LastExtension());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class ServiceAccountTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Create_starts_enabled()
|
||||
{
|
||||
var account = ServiceAccount.Create(Guid.NewGuid(), "CI-bot", "Beschrijving.", Guid.NewGuid(), Now);
|
||||
|
||||
Assert.True(account.Enabled);
|
||||
Assert.Null(account.DisabledAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disable_is_idempotent_and_records_the_first_disable_time_only()
|
||||
{
|
||||
var account = ServiceAccount.Create(Guid.NewGuid(), "CI-bot", "B.", Guid.NewGuid(), Now);
|
||||
|
||||
account.Disable(Now);
|
||||
account.Disable(Now.AddHours(1));
|
||||
|
||||
Assert.False(account.Enabled);
|
||||
Assert.Equal(Now, account.DisabledAt);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ServiceAccountTokenTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_a_token_with_no_valid_scopes()
|
||||
{
|
||||
var exception = Record.Exception(() =>
|
||||
ServiceAccountToken.Create(Guid.NewGuid(), "pub123", "hash", ["not-a-real-scope"], null, Now));
|
||||
|
||||
Assert.IsType<ArgumentException>(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_normalizes_and_deduplicates_scopes()
|
||||
{
|
||||
var token = ServiceAccountToken.Create(
|
||||
Guid.NewGuid(), "pub123", "hash", [Scopes.PagesRead, Scopes.PagesRead, "bogus"], null, Now);
|
||||
|
||||
Assert.Equal([Scopes.PagesRead], token.Scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_never_expiring_never_revoked_token_is_active()
|
||||
{
|
||||
var token = ServiceAccountToken.Create(Guid.NewGuid(), "pub", "hash", [Scopes.PagesRead], null, Now);
|
||||
|
||||
Assert.True(token.IsActive(Now.AddYears(10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_expired_token_is_not_active()
|
||||
{
|
||||
var token = ServiceAccountToken.Create(Guid.NewGuid(), "pub", "hash", [Scopes.PagesRead], Now.AddDays(1), Now);
|
||||
|
||||
Assert.False(token.IsActive(Now.AddDays(2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_revoked_token_is_not_active_even_before_its_expiry()
|
||||
{
|
||||
var token = ServiceAccountToken.Create(Guid.NewGuid(), "pub", "hash", [Scopes.PagesRead], Now.AddYears(1), Now);
|
||||
|
||||
token.Revoke(Now.AddHours(1));
|
||||
|
||||
Assert.False(token.IsActive(Now.AddHours(2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasScope_is_true_only_for_a_granted_scope()
|
||||
{
|
||||
var token = ServiceAccountToken.Create(Guid.NewGuid(), "pub", "hash", [Scopes.PagesRead], null, Now);
|
||||
|
||||
Assert.True(token.HasScope(Scopes.PagesRead));
|
||||
Assert.False(token.HasScope(Scopes.PagesWrite));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OutboxMessageTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Create_starts_unprocessed_and_immediately_available()
|
||||
{
|
||||
var message = OutboxMessage.Create("page.created.v1", "page", Guid.NewGuid().ToString(), "{}", Now);
|
||||
|
||||
Assert.False(message.IsProcessed);
|
||||
Assert.Equal(Now, message.AvailableAt);
|
||||
Assert.Equal(0, message.Attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkFailed_increments_attempts_and_delays_the_next_availability()
|
||||
{
|
||||
var message = OutboxMessage.Create("page.created.v1", "page", Guid.NewGuid().ToString(), "{}", Now);
|
||||
|
||||
message.MarkFailed("transient error", TimeSpan.FromMinutes(5), Now);
|
||||
|
||||
Assert.Equal(1, message.Attempts);
|
||||
Assert.Equal(Now.AddMinutes(5), message.AvailableAt);
|
||||
Assert.False(message.IsProcessed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkProcessed_sets_the_processed_timestamp()
|
||||
{
|
||||
var message = OutboxMessage.Create("page.created.v1", "page", Guid.NewGuid().ToString(), "{}", Now);
|
||||
|
||||
message.MarkProcessed(Now.AddSeconds(1));
|
||||
|
||||
Assert.True(message.IsProcessed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class SortKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Next_produces_a_key_that_sorts_after_the_previous_one()
|
||||
{
|
||||
var first = SortKey.First();
|
||||
var second = SortKey.Next(first);
|
||||
var third = SortKey.Next(second);
|
||||
|
||||
Assert.True(string.CompareOrdinal(first.Value, second.Value) < 0);
|
||||
Assert.True(string.CompareOrdinal(second.Value, third.Value) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_produces_a_key_strictly_between_two_others()
|
||||
{
|
||||
var lower = SortKey.First();
|
||||
var upper = SortKey.Next(lower);
|
||||
var middle = SortKey.Between(lower, upper);
|
||||
|
||||
Assert.True(string.CompareOrdinal(lower.Value, middle.Value) < 0);
|
||||
Assert.True(string.CompareOrdinal(middle.Value, upper.Value) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_keys_share_the_same_fixed_width_for_stable_ordinal_comparison()
|
||||
{
|
||||
var keys = new[] { SortKey.First(), SortKey.Next(SortKey.First()) };
|
||||
|
||||
Assert.All(keys, key => Assert.Equal(keys[0].Value.Length, key.Value.Length));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class WorkspaceNameTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_trims_and_normalizes_the_name()
|
||||
{
|
||||
var name = WorkspaceName.Create(" ItWorx Kennisbank ");
|
||||
|
||||
Assert.Equal("ItWorx Kennisbank", name.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ItWorx Kennisbank", "itworx-kennisbank")]
|
||||
[InlineData("École & Café", "ecole-cafe")]
|
||||
public void Create_derives_a_url_safe_slug(string input, string expectedSlug)
|
||||
{
|
||||
var name = WorkspaceName.Create(input);
|
||||
|
||||
Assert.Equal(expectedSlug, name.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_an_empty_name()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => WorkspaceName.Create(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_a_name_above_the_limit()
|
||||
{
|
||||
var tooLong = new string('a', WorkspaceName.MaximumLength + 1);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => WorkspaceName.Create(tooLong));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user