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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user