Merge RB-30 — extract BriefStore's guards into Domain/Letters/BriefRules.cs
TE-008: five guard decisions in BriefStore (Save, Submit, Send, the shared Approve/Reject review path) were pure functions of status tag, actor role and entity completeness, but each sat inside a lock-held, DB-opening method, so a spec could not exercise a decision without a booted host and a real SQLite file. BriefRules.cs holds the five pure statics; BriefStore keeps its lock, its Db.Create(), its static shape and every method signature. 29 new free-running unit assertions in BriefRuleTests.cs; the existing host-booting brief endpoint tests are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md # libs/shared/docs/behaviour-spec.mdx
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Letters;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class BriefRuleTests
|
||||
{
|
||||
private static BriefStatusDto Status(string tag) => new(tag);
|
||||
|
||||
private static readonly Principal Drafter = new(PrincipalRole.Drafter);
|
||||
private static readonly Principal Approver = new(PrincipalRole.Approver);
|
||||
|
||||
// --- CanSave -----------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("draft")]
|
||||
[InlineData("rejected")]
|
||||
public void A_drafter_may_save_a_draft_or_rejected_letter(string tag) =>
|
||||
Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSave(Status(tag), isDrafter: true));
|
||||
|
||||
[Theory]
|
||||
[InlineData("submitted")]
|
||||
[InlineData("approved")]
|
||||
[InlineData("sent")]
|
||||
public void A_drafter_may_not_save_a_non_editable_letter(string tag) =>
|
||||
Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSave(Status(tag), isDrafter: true));
|
||||
|
||||
[Theory]
|
||||
[InlineData("draft")]
|
||||
[InlineData("submitted")]
|
||||
public void A_non_drafter_is_forbidden_to_save_regardless_of_status(string tag) =>
|
||||
// Role is checked before status: Forbidden wins even against an otherwise-open status.
|
||||
Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSave(Status(tag), isDrafter: false));
|
||||
|
||||
// --- StatusAfterSave -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Saving_a_rejected_letter_reopens_it_to_draft() =>
|
||||
Assert.Equal("draft", BriefRules.StatusAfterSave(Status("rejected")).Tag);
|
||||
|
||||
[Fact]
|
||||
public void Saving_a_draft_letter_leaves_its_status_unchanged() =>
|
||||
Assert.Equal("draft", BriefRules.StatusAfterSave(Status("draft")).Tag);
|
||||
|
||||
// --- RequiredFilled --------------------------------------------------------------
|
||||
|
||||
private static LetterSectionDto Section(string key, bool required, int blockCount) =>
|
||||
new(key, key, required, Enumerable.Range(0, blockCount)
|
||||
.Select(i => new LetterBlockDto("freeText", $"{key}-{i}", new RichTextBlockDto(Array.Empty<ParagraphDto>())))
|
||||
.ToList());
|
||||
|
||||
[Fact]
|
||||
public void No_required_sections_means_nothing_to_fill() =>
|
||||
Assert.True(BriefRules.RequiredFilled(Array.Empty<LetterSectionDto>()));
|
||||
|
||||
[Fact]
|
||||
public void An_optional_empty_section_does_not_block_submission() =>
|
||||
Assert.True(BriefRules.RequiredFilled(new[] { Section("slot", required: false, blockCount: 0) }));
|
||||
|
||||
[Fact]
|
||||
public void A_required_section_with_a_block_is_filled() =>
|
||||
Assert.True(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 1) }));
|
||||
|
||||
[Fact]
|
||||
public void A_required_section_with_no_blocks_is_not_filled() =>
|
||||
Assert.False(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 0) }));
|
||||
|
||||
[Fact]
|
||||
public void One_unfilled_required_section_blocks_submission_even_if_others_are_filled() =>
|
||||
Assert.False(BriefRules.RequiredFilled(new[]
|
||||
{
|
||||
Section("kern", required: true, blockCount: 1),
|
||||
Section("bijlage", required: true, blockCount: 0),
|
||||
}));
|
||||
|
||||
// --- CanSubmit -----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void A_drafter_may_submit_a_filled_draft() =>
|
||||
Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: true));
|
||||
|
||||
[Fact]
|
||||
public void A_drafter_may_not_submit_an_unfilled_draft() =>
|
||||
Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: false));
|
||||
|
||||
[Fact]
|
||||
public void A_drafter_may_not_submit_a_letter_that_is_not_a_draft() =>
|
||||
Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("submitted"), isDrafter: true, requiredFilled: true));
|
||||
|
||||
[Fact]
|
||||
public void A_non_drafter_is_forbidden_to_submit_even_a_filled_draft() =>
|
||||
// Role is checked before status/completeness: Forbidden wins over Conflict.
|
||||
Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSubmit(Status("draft"), isDrafter: false, requiredFilled: true));
|
||||
|
||||
// --- CanSend ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void An_approved_letter_may_be_sent() =>
|
||||
Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSend(Status("approved")));
|
||||
|
||||
[Theory]
|
||||
[InlineData("draft")]
|
||||
[InlineData("submitted")]
|
||||
[InlineData("rejected")]
|
||||
[InlineData("sent")]
|
||||
public void Only_an_approved_letter_may_be_sent(string tag) =>
|
||||
Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSend(Status(tag)));
|
||||
|
||||
// --- CanDecide (Approve/Reject shared guard) --------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(BriefAction.Approve)]
|
||||
[InlineData(BriefAction.Reject)]
|
||||
public void An_approver_may_decide_a_submitted_letter_drafted_by_someone_else(BriefAction action) =>
|
||||
Assert.Equal(
|
||||
BriefStore.Outcome.Ok,
|
||||
BriefRules.CanDecide(action, Status("submitted"), Approver, drafterId: BriefStore.DrafterId));
|
||||
|
||||
[Fact]
|
||||
public void A_drafter_may_not_approve_or_reject() =>
|
||||
Assert.Equal(
|
||||
BriefStore.Outcome.Forbidden,
|
||||
BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Drafter, drafterId: BriefStore.DrafterId));
|
||||
|
||||
[Fact]
|
||||
public void An_approver_may_not_decide_a_letter_they_drafted_themselves() =>
|
||||
// Four-eyes / SoD: the acting approver id happens to equal the letter's drafterId.
|
||||
Assert.Equal(
|
||||
BriefStore.Outcome.Forbidden,
|
||||
BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Approver, drafterId: BriefStore.ApproverId));
|
||||
|
||||
[Fact]
|
||||
public void An_approver_may_not_decide_a_letter_that_is_not_submitted() =>
|
||||
Assert.Equal(
|
||||
BriefStore.Outcome.Conflict,
|
||||
BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.DrafterId));
|
||||
|
||||
[Fact]
|
||||
public void Entitlement_is_checked_before_status_forbidden_wins_over_conflict() =>
|
||||
// Same actor as drafter AND a non-submitted status: still Forbidden, not Conflict —
|
||||
// matches the store's original check order (Authz.CanActOn before the status guard).
|
||||
Assert.Equal(
|
||||
BriefStore.Outcome.Forbidden,
|
||||
BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.ApproverId));
|
||||
}
|
||||
Reference in New Issue
Block a user