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:
eho
2026-08-28 08:08:00 +02:00
6 changed files with 456 additions and 13 deletions
+10 -11
View File
@@ -68,10 +68,10 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
var outcome = BriefRules.CanSave(e.Status, isDrafter);
if (outcome != Outcome.Ok) return (outcome, null);
e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
e.Status = BriefRules.StatusAfterSave(e.Status);
db.SaveChanges();
return (Outcome.Ok, e);
}
@@ -84,8 +84,8 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
var outcome = BriefRules.CanSubmit(e.Status, isDrafter, BriefRules.RequiredFilled(e.Sections));
if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
db.SaveChanges();
return (Outcome.Ok, e);
@@ -107,7 +107,8 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
var outcome = BriefRules.CanSend(e.Status);
if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
// Pin the org-template version the letter was sent with (WP-23): from here on
// its appearance is frozen — republishing the template touches unsent briefs only.
@@ -150,7 +151,7 @@ public static class BriefStore
// from the drafter (a drafter cannot approve their own letter). The SoD check is
// Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked
// BEFORE the status guard so Forbidden vs Conflict ordering matches the old
// inline check exactly.
// inline check exactly (BriefRules.CanDecide preserves that order).
private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func<BriefStatusDto> next)
{
lock (_gate)
@@ -158,15 +159,13 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
var outcome = BriefRules.CanDecide(action, e.Status, principal, e.DrafterId);
if (outcome != Outcome.Ok) return (outcome, null);
e.Status = next();
db.SaveChanges();
return (Outcome.Ok, e);
}
}
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
}
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
@@ -0,0 +1,63 @@
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
namespace BigRegister.Domain.Letters;
/// <summary>
/// SERVER-OWNED brief state-transition and authorization rules (RB-30, TE-008). Each
/// method is a pure decision over (status tag, actor role, entity completeness) —
/// extracted out of <see cref="BriefStore"/>'s lock-held, DB-opening methods so the
/// decision can be unit-tested without a booted host or a real SQLite file. Callers
/// pass the values the rule needs, never the entity, so this stays pure.
///
/// Returns <see cref="BriefStore.Outcome"/> — that type already exists as the domain
/// concept the whole brief flow reports through (`BriefResult` in Program.cs switches
/// on it directly), so this reuses it rather than inventing a second result shape.
/// </summary>
public static class BriefRules
{
/// Save is drafter-only, and only while the letter is editable (draft/rejected).
/// Order matches the store's original inline check: role before status, so a
/// non-drafter always sees Forbidden even against a non-editable status.
public static BriefStore.Outcome CanSave(BriefStatusDto status, bool isDrafter)
{
if (!isDrafter) return BriefStore.Outcome.Forbidden;
if (status.Tag is not ("draft" or "rejected")) return BriefStore.Outcome.Conflict;
return BriefStore.Outcome.Ok;
}
/// A save on a rejected letter reopens it to draft (mirrors the FE reducer); a save
/// on a draft leaves the status untouched.
public static BriefStatusDto StatusAfterSave(BriefStatusDto status) =>
status.Tag == "rejected" ? new BriefStatusDto("draft") : status;
/// Every required section needs at least one block before a letter is submittable.
public static bool RequiredFilled(IReadOnlyList<LetterSectionDto> sections) =>
sections.All(s => !s.Required || s.Blocks.Count > 0);
/// Submit is drafter-only, only from draft, and only once every required section
/// is filled.
public static BriefStore.Outcome CanSubmit(BriefStatusDto status, bool isDrafter, bool requiredFilled)
{
if (!isDrafter) return BriefStore.Outcome.Forbidden;
if (status.Tag != "draft" || !requiredFilled) return BriefStore.Outcome.Conflict;
return BriefStore.Outcome.Ok;
}
/// Send only from approved — sending is a mechanical dispatch step, not role-gated
/// (Authz.CanActOn already returns true unconditionally for BriefAction.Send).
public static BriefStore.Outcome CanSend(BriefStatusDto status) =>
status.Tag == "approved" ? BriefStore.Outcome.Ok : BriefStore.Outcome.Conflict;
/// Approve/Reject share this guard: the caller must be entitled to act on the letter
/// (four-eyes/SoD, via the existing <see cref="Authz.CanActOn"/>), and the letter must
/// be submitted. The entitlement check runs BEFORE the status check — Forbidden takes
/// priority over Conflict, matching the store's original order exactly.
public static BriefStore.Outcome CanDecide(BriefAction action, BriefStatusDto status, Principal principal, string drafterId)
{
if (!Authz.CanActOn(action, principal, drafterId)) return BriefStore.Outcome.Forbidden;
if (status.Tag != "submitted") return BriefStore.Outcome.Conflict;
return BriefStore.Outcome.Ok;
}
}
@@ -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));
}