refactor(backend): extract brief guards into Domain/Letters/BriefRules.cs (RB-30)

BriefStore's five guard decisions (Save, Submit, Send, and 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. A spec could not exercise the decision without a booted host and
a real SQLite file.

Extract the guards into a pure Domain/Letters/BriefRules.cs. BriefStore
keeps its lock, its Db.Create(), its static shape, and every method
signature — only the if cascades move. Add BriefRuleTests.cs (29
assertions, ~120 ms, no host boot) covering every branch, including the
rejected-to-draft reopen on save, the required-filled gate on submit,
and the non-drafter and self-review denials. The existing host-booting
brief endpoint tests are unchanged and still pass, proving the
extraction preserved behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 20:42:00 +02:00
co-authored by Claude Opus 5
parent 424ceb604b
commit 07bb6277c0
6 changed files with 456 additions and 13 deletions
@@ -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));
}