diff --git a/src/FluentCMS.Core/ChangeSets/ChangeOperation.cs b/src/FluentCMS.Core/ChangeSets/ChangeOperation.cs new file mode 100644 index 000000000..56d664d33 --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeOperation.cs @@ -0,0 +1,65 @@ +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.ChangeSets; + +// One create, update or delete in a change set. It names the item and the version the change was based on, so publish can +// tell whether the item moved on in the meantime and revert knows what to restore (CHG-05, CHG-06). +public sealed record ChangeOperation +{ + private ChangeOperation(OperationKind kind, ItemKind itemKind, Guid itemId, int? baseVersion) + { + Kind = kind; + ItemKind = itemKind; + ItemId = itemId; + BaseVersion = baseVersion; + } + + public OperationKind Kind { get; } + + public ItemKind ItemKind { get; } + + public Guid ItemId { get; } + + // Null for a create, which has no earlier version. + public int? BaseVersion { get; } + + public static Result Create(OperationKind kind, ItemKind itemKind, Guid itemId, int? baseVersion) + { + var errors = new List(); + + if (!Enum.IsDefined(kind)) + { + errors.Add(new Error(ChangeSetErrorCodes.OperationKindInvalid, $"The operation '{kind}' is not one of create, update or delete.")); + } + + if (!Enum.IsDefined(itemKind)) + { + errors.Add(new Error( + ChangeSetErrorCodes.ItemKindInvalid, + $"The item kind '{itemKind}' is not one of entry, entity, media, schema or rule.")); + } + + if (itemId == Guid.Empty) + { + errors.Add(new Error(ChangeSetErrorCodes.ItemMissing, "An operation names its item, and the item id is empty.")); + } + + if (kind == OperationKind.Create && baseVersion is not null) + { + errors.Add(new Error( + ChangeSetErrorCodes.BaseVersionInvalid, + $"A create has no base version because the item does not exist yet, but version {baseVersion} was given.")); + } + + if (kind is OperationKind.Update or OperationKind.Delete && baseVersion is not >= 1) + { + errors.Add(new Error( + ChangeSetErrorCodes.BaseVersionInvalid, + $"An {kind.ToString().ToLowerInvariant()} names the version it was based on, which is 1 or later.")); + } + + return errors.Count == 0 + ? Result.Success(new ChangeOperation(kind, itemKind, itemId, baseVersion)) + : Result.Failure(errors); + } +} diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs new file mode 100644 index 000000000..425c2540c --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -0,0 +1,175 @@ +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.ChangeSets; + +// The unit of every write and every review: operations grouped under one rationale, with the sources they came from and +// who started them (CHG-01, D15). It moves only along ChangeSetTransitions, and a refused call leaves it as it was. +public sealed class ChangeSet +{ + private readonly List _operations = []; + + private ChangeSet(Guid id, Guid siteId, string rationale, IReadOnlyList sources, Initiator initiator) + { + Id = id; + SiteId = siteId; + Rationale = rationale; + Sources = sources; + Initiator = initiator; + } + + public Guid Id { get; } + + public Guid SiteId { get; } + + public string Rationale { get; } + + public IReadOnlyList Sources { get; } + + public Initiator Initiator { get; } + + public IReadOnlyList Operations => _operations.AsReadOnly(); + + public ChangeSetState State { get; private set; } = ChangeSetState.Open; + + // Kept so the desk can offer it as rule feedback once the rule book exists (CHG-08, RUL-08). + public string? RejectionReason { get; private set; } + + // Operations come later, while the set is open, so an agent can open a set before its first write (CHG-02). An empty + // list of sources is allowed here: requiring one is a check (CHK-01), not a rule of the model. + public static Result Open(Guid siteId, string rationale, IEnumerable sources, Initiator initiator) + { + ArgumentNullException.ThrowIfNull(sources); + ArgumentNullException.ThrowIfNull(initiator); + + var errors = new List(); + var trimmedRationale = rationale?.Trim() ?? string.Empty; + var trimmedSources = sources.Select(source => source?.Trim() ?? string.Empty).ToList(); + + if (siteId == Guid.Empty) + { + errors.Add(new Error(ChangeSetErrorCodes.SiteMissing, "A change set belongs to a site, and the site id is empty.")); + } + + if (trimmedRationale.Length == 0) + { + errors.Add(new Error(ChangeSetErrorCodes.RationaleMissing, "A change set needs a rationale that says why the changes are made.")); + } + + for (var index = 0; index < trimmedSources.Count; index++) + { + if (trimmedSources[index].Length == 0) + { + errors.Add(new Error( + ChangeSetErrorCodes.SourceBlank, + $"Source {index + 1} is blank. A source names where a change came from, such as a URL, a file, an export or a person.")); + } + } + + return errors.Count == 0 + ? Result.Success(new ChangeSet(Guid.CreateVersion7(), siteId, trimmedRationale, trimmedSources, initiator)) + : Result.Failure(errors); + } + + public Result AddOperation(ChangeOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + if (State != ChangeSetState.Open) + { + return Result.Failure( + ChangeSetErrorCodes.NotOpen, + $"Operations are added while a change set is open, and this one is {Describe(State)}."); + } + + // One operation per item keeps one diff per item and one version to restore on revert (CHG-03, CHG-06). + if (_operations.Any(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId)) + { + return Result.Failure( + ChangeSetErrorCodes.ItemDuplicate, + $"The {Describe(operation.ItemKind)} {operation.ItemId} already has an operation in this change set."); + } + + _operations.Add(operation); + return Result.Success(); + } + + public Result TransitionTo(ChangeSetState target) + { + var allowed = CanMoveTo(target); + if (!allowed.IsSuccess) + { + return allowed; + } + + if (target == ChangeSetState.Rejected) + { + return Result.Failure( + ChangeSetErrorCodes.RejectionReasonMissing, + "A rejection needs a reason, which the change set stores; reject it with one."); + } + + State = target; + return Result.Success(); + } + + public Result Reject(string reason) + { + var allowed = CanMoveTo(ChangeSetState.Rejected); + if (!allowed.IsSuccess) + { + return allowed; + } + + var trimmedReason = reason?.Trim() ?? string.Empty; + if (trimmedReason.Length == 0) + { + return Result.Failure( + ChangeSetErrorCodes.RejectionReasonMissing, + "A rejection needs a reason that says what is wrong, and the reason is blank."); + } + + State = ChangeSetState.Rejected; + RejectionReason = trimmedReason; + return Result.Success(); + } + + private Result CanMoveTo(ChangeSetState target) + { + if (!ChangeSetTransitions.IsAllowed(State, target)) + { + var next = ChangeSetTransitions.From(State); + var options = next.Count == 0 + ? $"{Describe(State)} is final" + : "it can move to " + string.Join(" or ", next.Select(Describe)); + + return Result.Failure( + ChangeSetErrorCodes.TransitionNotAllowed, + $"A change set cannot move from {Describe(State)} to {Describe(target)}; {options}."); + } + + // A change set contains one or more operations (CHG-01); while open it may still be empty. + if (State == ChangeSetState.Open && _operations.Count == 0) + { + return Result.Failure(ChangeSetErrorCodes.OperationsMissing, "A change set needs at least one operation before it leaves open."); + } + + return Result.Success(); + } + + private static string Describe(ChangeSetState state) + { + return state switch + { + ChangeSetState.ChecksRunning => "checks running", + ChangeSetState.ChecksFailed => "checks failed", + ChangeSetState.AwaitingReview => "awaiting review", + _ => state.ToString().ToLowerInvariant(), + }; + } + + private static string Describe(ItemKind kind) + { + return kind.ToString().ToLowerInvariant(); + } +} diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs new file mode 100644 index 000000000..92a7a6fae --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs @@ -0,0 +1,17 @@ +namespace FluentCMS.Core.ChangeSets; + +public static class ChangeSetErrorCodes +{ + public const string OperationKindInvalid = "operation_kind_invalid"; + public const string ItemKindInvalid = "item_kind_invalid"; + public const string ItemMissing = "item_missing"; + public const string BaseVersionInvalid = "base_version_invalid"; + public const string ItemDuplicate = "item_duplicate"; + public const string SiteMissing = "site_missing"; + public const string RationaleMissing = "rationale_missing"; + public const string SourceBlank = "source_blank"; + public const string NotOpen = "change_set_not_open"; + public const string OperationsMissing = "operations_missing"; + public const string TransitionNotAllowed = "transition_not_allowed"; + public const string RejectionReasonMissing = "rejection_reason_missing"; +} diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetState.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetState.cs new file mode 100644 index 000000000..75a5576a8 --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetState.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Core.ChangeSets; + +// The CHG-04 states; ChangeSetTransitions says which moves between them exist. +public enum ChangeSetState +{ + Open, + ChecksRunning, + ChecksFailed, + AwaitingReview, + Approved, + Published, + Rejected, + Reverted, +} diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetTransitions.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetTransitions.cs new file mode 100644 index 000000000..faa544cd5 --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetTransitions.cs @@ -0,0 +1,32 @@ +namespace FluentCMS.Core.ChangeSets; + +// The CHG-04 state machine. A change set moves only along these pairs and every other move is refused, so a new transition +// is a change to this table and nowhere else. +public static class ChangeSetTransitions +{ + public static IReadOnlyList<(ChangeSetState From, ChangeSetState To)> All { get; } = + [ + (ChangeSetState.Open, ChangeSetState.ChecksRunning), + (ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed), + (ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview), + + // Routing auto-publishes: nobody approves, but publish keeps a single way in (RTE-01, MCP-06). + (ChangeSetState.ChecksRunning, ChangeSetState.Approved), + (ChangeSetState.ChecksFailed, ChangeSetState.Open), + (ChangeSetState.ChecksFailed, ChangeSetState.Rejected), + (ChangeSetState.AwaitingReview, ChangeSetState.Approved), + (ChangeSetState.AwaitingReview, ChangeSetState.Rejected), + (ChangeSetState.Approved, ChangeSetState.Published), + (ChangeSetState.Published, ChangeSetState.Reverted), + ]; + + public static bool IsAllowed(ChangeSetState from, ChangeSetState to) + { + return All.Contains((from, to)); + } + + public static IReadOnlyList From(ChangeSetState state) + { + return All.Where(transition => transition.From == state).Select(transition => transition.To).ToList(); + } +} diff --git a/src/FluentCMS.Core/ChangeSets/ItemKind.cs b/src/FluentCMS.Core/ChangeSets/ItemKind.cs new file mode 100644 index 000000000..e6c0f30d7 --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ItemKind.cs @@ -0,0 +1,11 @@ +namespace FluentCMS.Core.ChangeSets; + +// What a change set operation can touch (CHG-01). Schema means a content type; the other kinds arrive with their epics. +public enum ItemKind +{ + Entry, + Entity, + Media, + Schema, + Rule, +} diff --git a/src/FluentCMS.Core/ChangeSets/OperationKind.cs b/src/FluentCMS.Core/ChangeSets/OperationKind.cs new file mode 100644 index 000000000..fe113172a --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/OperationKind.cs @@ -0,0 +1,8 @@ +namespace FluentCMS.Core.ChangeSets; + +public enum OperationKind +{ + Create, + Update, + Delete, +} diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeOperationTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeOperationTests.cs new file mode 100644 index 000000000..07ef3590f --- /dev/null +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeOperationTests.cs @@ -0,0 +1,84 @@ +using FluentCMS.Core; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Schema; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.ChangeSets; + +public sealed class ChangeOperationTests +{ + private static readonly Guid Item = Guid.NewGuid(); + + [Theory] + [InlineData(OperationKind.Create, ItemKind.Entry, null)] + [InlineData(OperationKind.Update, ItemKind.Entity, 1)] + [InlineData(OperationKind.Update, ItemKind.Media, 7)] + [InlineData(OperationKind.Delete, ItemKind.Rule, 2)] + [InlineData(OperationKind.Delete, ItemKind.Schema, 1)] + public void An_operation_names_its_item_and_the_version_it_was_based_on(OperationKind kind, ItemKind itemKind, int? baseVersion) + { + var operation = ChangeOperation.Create(kind, itemKind, Item, baseVersion).Value; + + Assert.Equal(kind, operation.Kind); + Assert.Equal(itemKind, operation.ItemKind); + Assert.Equal(Item, operation.ItemId); + Assert.Equal(baseVersion, operation.BaseVersion); + } + + [Fact] + public void An_operation_on_a_content_type_points_at_its_id_and_version() + { + using var provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); + var fieldTypes = provider.GetRequiredService(); + var created = ContentType.Create(Guid.NewGuid(), "Product page", "product-page", new ContentTypeDefinition([]), fieldTypes).Value; + var revised = created.Revise(new ContentTypeDefinition([new FieldDefinition("title", "text")]), fieldTypes).Value; + + var create = ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, created.Id, null).Value; + var update = ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, revised.Id, revised.Version).Value; + + Assert.Equal(created.Id, create.ItemId); + Assert.Null(create.BaseVersion); + Assert.Equal(created.Id, update.ItemId); + Assert.Equal(2, update.BaseVersion); + } + + [Fact] + public void An_operation_without_an_item_is_rejected() + { + var result = ChangeOperation.Create(OperationKind.Update, ItemKind.Entry, Guid.Empty, 1); + + Assert.Equal(ChangeSetErrorCodes.ItemMissing, Assert.Single(result.Errors).Code); + } + + [Fact] + public void A_create_with_a_base_version_is_rejected() + { + var result = ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, Item, 1); + + Assert.Equal(ChangeSetErrorCodes.BaseVersionInvalid, Assert.Single(result.Errors).Code); + } + + [Theory] + [InlineData(OperationKind.Update, null)] + [InlineData(OperationKind.Update, 0)] + [InlineData(OperationKind.Delete, null)] + [InlineData(OperationKind.Delete, -1)] + public void An_update_or_delete_without_a_base_version_of_one_or_later_is_rejected(OperationKind kind, int? baseVersion) + { + var result = ChangeOperation.Create(kind, ItemKind.Entity, Item, baseVersion); + + var error = Assert.Single(result.Errors); + Assert.Equal(ChangeSetErrorCodes.BaseVersionInvalid, error.Code); + Assert.Contains(kind.ToString().ToLowerInvariant(), error.Message); + } + + [Fact] + public void Every_problem_with_an_operation_is_reported_at_once() + { + var result = ChangeOperation.Create((OperationKind)9, (ItemKind)9, Guid.Empty, null); + + Assert.Equal( + [ChangeSetErrorCodes.OperationKindInvalid, ChangeSetErrorCodes.ItemKindInvalid, ChangeSetErrorCodes.ItemMissing], + result.Errors.Select(error => error.Code)); + } +} diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs new file mode 100644 index 000000000..9568a780f --- /dev/null +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -0,0 +1,132 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; + +namespace FluentCMS.Tests.ChangeSets; + +public sealed class ChangeSetTests +{ + private static readonly Guid Site = TestChangeSets.Site; + + [Fact] + public void A_new_set_is_open_and_keeps_its_site_rationale_sources_and_initiator() + { + var user = new UserInitiator(Guid.NewGuid(), "Example Editor"); + + var changeSet = ChangeSet.Open(Site, " Correct the launch date. ", [" https://example.com/press-release ", "Product team"], user).Value; + + Assert.NotEqual(Guid.Empty, changeSet.Id); + Assert.Equal(Site, changeSet.SiteId); + Assert.Equal("Correct the launch date.", changeSet.Rationale); + Assert.Equal(["https://example.com/press-release", "Product team"], changeSet.Sources); + Assert.Same(user, changeSet.Initiator); + Assert.Equal(ChangeSetState.Open, changeSet.State); + Assert.Empty(changeSet.Operations); + Assert.Null(changeSet.RejectionReason); + } + + [Fact] + public void An_agent_stays_the_initiator_of_the_sets_it_opens() + { + var changeSet = ChangeSet.Open(Site, "Sync prices.", [], TestChangeSets.Agent).Value; + + Assert.IsType(changeSet.Initiator); + } + + [Fact] + public void A_set_without_sources_opens() + { + var result = ChangeSet.Open(Site, "Fix a typo.", [], TestChangeSets.Agent); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value.Sources); + } + + [Fact] + public void Every_problem_with_a_new_set_is_reported_at_once() + { + var result = ChangeSet.Open(Guid.Empty, " ", ["exports/catalog.csv", " ", ""], TestChangeSets.Agent); + + Assert.Equal( + [ChangeSetErrorCodes.SiteMissing, ChangeSetErrorCodes.RationaleMissing, ChangeSetErrorCodes.SourceBlank, ChangeSetErrorCodes.SourceBlank], + result.Errors.Select(error => error.Code)); + Assert.Contains("Source 2", result.Errors[2].Message); + Assert.Contains("Source 3", result.Errors[3].Message); + } + + [Fact] + public void A_set_needs_an_initiator() + { + Assert.Throws(() => ChangeSet.Open(Site, "Sync prices.", [], null!)); + } + + [Fact] + public void Entity_entry_and_schema_operations_can_share_a_set() + { + var changeSet = TestChangeSets.Open(); + var operations = new[] + { + TestChangeSets.Update(ItemKind.Entity), + TestChangeSets.Update(ItemKind.Entry), + ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, Guid.NewGuid(), null).Value, + }; + + foreach (var operation in operations) + { + Assert.True(changeSet.AddOperation(operation).IsSuccess); + } + + Assert.True(changeSet.TransitionTo(ChangeSetState.ChecksRunning).IsSuccess); + Assert.Equal(operations, changeSet.Operations); + } + + [Fact] + public void An_item_appears_at_most_once_in_a_set() + { + var changeSet = TestChangeSets.Open(); + var item = Guid.NewGuid(); + changeSet.AddOperation(TestChangeSets.Update(ItemKind.Entry, item)); + + var result = changeSet.AddOperation(ChangeOperation.Create(OperationKind.Delete, ItemKind.Entry, item, 1).Value); + + var error = Assert.Single(result.Errors); + Assert.Equal(ChangeSetErrorCodes.ItemDuplicate, error.Code); + Assert.Contains(item.ToString(), error.Message); + Assert.Single(changeSet.Operations); + } + + [Fact] + public void A_set_with_no_operations_cannot_leave_open() + { + var changeSet = TestChangeSets.Open(); + + var result = changeSet.TransitionTo(ChangeSetState.ChecksRunning); + + Assert.Equal(ChangeSetErrorCodes.OperationsMissing, Assert.Single(result.Errors).Code); + Assert.Equal(ChangeSetState.Open, changeSet.State); + } + + [Theory] + [InlineData(ChangeSetState.ChecksRunning)] + [InlineData(ChangeSetState.AwaitingReview)] + [InlineData(ChangeSetState.Published)] + [InlineData(ChangeSetState.Rejected)] + public void Operations_are_added_only_while_the_set_is_open(ChangeSetState state) + { + var changeSet = TestChangeSets.InState(state); + + var result = changeSet.AddOperation(TestChangeSets.Update(ItemKind.Media)); + + Assert.Equal(ChangeSetErrorCodes.NotOpen, Assert.Single(result.Errors).Code); + Assert.Single(changeSet.Operations); + } + + [Fact] + public void A_set_whose_checks_failed_takes_operations_again_once_reopened() + { + var changeSet = TestChangeSets.InState(ChangeSetState.ChecksFailed); + + Assert.True(changeSet.TransitionTo(ChangeSetState.Open).IsSuccess); + Assert.True(changeSet.AddOperation(TestChangeSets.Update(ItemKind.Entity)).IsSuccess); + Assert.Equal(2, changeSet.Operations.Count); + } +} diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTransitionTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTransitionTests.cs new file mode 100644 index 000000000..937482ea0 --- /dev/null +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTransitionTests.cs @@ -0,0 +1,161 @@ +using FluentCMS.Core.ChangeSets; + +namespace FluentCMS.Tests.ChangeSets; + +public sealed class ChangeSetTransitionTests +{ + public static TheoryData Allowed() + { + var data = new TheoryData(); + foreach (var (from, to) in ChangeSetTransitions.All) + { + data.Add(from, to); + } + + return data; + } + + public static TheoryData NotAllowed() + { + var data = new TheoryData(); + foreach (var from in Enum.GetValues()) + { + foreach (var to in Enum.GetValues().Where(to => !ChangeSetTransitions.IsAllowed(from, to))) + { + data.Add(from, to); + } + } + + return data; + } + + [Fact] + public void The_table_holds_exactly_the_chg_04_transitions() + { + Assert.Equal( + [ + (ChangeSetState.Open, ChangeSetState.ChecksRunning), + (ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed), + (ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview), + (ChangeSetState.ChecksRunning, ChangeSetState.Approved), + (ChangeSetState.ChecksFailed, ChangeSetState.Open), + (ChangeSetState.ChecksFailed, ChangeSetState.Rejected), + (ChangeSetState.AwaitingReview, ChangeSetState.Approved), + (ChangeSetState.AwaitingReview, ChangeSetState.Rejected), + (ChangeSetState.Approved, ChangeSetState.Published), + (ChangeSetState.Published, ChangeSetState.Reverted), + ], + ChangeSetTransitions.All); + } + + [Fact] + public void Every_state_can_be_reached_from_open() + { + foreach (var state in Enum.GetValues()) + { + Assert.Equal(state, TestChangeSets.InState(state).State); + } + } + + [Theory] + [InlineData(ChangeSetState.Rejected)] + [InlineData(ChangeSetState.Reverted)] + public void Rejected_and_reverted_are_final(ChangeSetState state) + { + Assert.Empty(ChangeSetTransitions.From(state)); + } + + [Fact] + public void Publish_has_one_way_in_from_approved() + { + var ways = ChangeSetTransitions.All.Where(transition => transition.To == ChangeSetState.Published); + + Assert.Equal([ChangeSetState.Approved], ways.Select(transition => transition.From)); + } + + [Theory] + [MemberData(nameof(Allowed))] + public void Every_transition_in_the_table_succeeds(ChangeSetState from, ChangeSetState to) + { + var changeSet = TestChangeSets.InState(from); + + var result = to == ChangeSetState.Rejected ? changeSet.Reject("The price has no source.") : changeSet.TransitionTo(to); + + Assert.True(result.IsSuccess); + Assert.Equal(to, changeSet.State); + } + + [Theory] + [MemberData(nameof(NotAllowed))] + public void Every_transition_outside_the_table_is_refused_and_changes_nothing(ChangeSetState from, ChangeSetState to) + { + var changeSet = TestChangeSets.InState(from); + var reason = changeSet.RejectionReason; + + var result = changeSet.TransitionTo(to); + + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(result.Errors).Code); + Assert.Equal(from, changeSet.State); + Assert.Equal(reason, changeSet.RejectionReason); + } + + [Theory] + [InlineData(ChangeSetState.Open)] + [InlineData(ChangeSetState.ChecksRunning)] + [InlineData(ChangeSetState.Approved)] + [InlineData(ChangeSetState.Published)] + [InlineData(ChangeSetState.Rejected)] + [InlineData(ChangeSetState.Reverted)] + public void A_rejection_with_a_reason_is_still_refused_where_the_table_has_none(ChangeSetState from) + { + var changeSet = TestChangeSets.InState(from); + var reason = changeSet.RejectionReason; + + var result = changeSet.Reject("The price has no source."); + + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(result.Errors).Code); + Assert.Equal(from, changeSet.State); + Assert.Equal(reason, changeSet.RejectionReason); + } + + [Fact] + public void A_refusal_names_the_states_the_set_can_move_to() + { + var running = TestChangeSets.InState(ChangeSetState.ChecksRunning); + var rejected = TestChangeSets.InState(ChangeSetState.Rejected); + + var fromRunning = Assert.Single(running.TransitionTo(ChangeSetState.Published).Errors).Message; + var fromRejected = Assert.Single(rejected.TransitionTo(ChangeSetState.Open).Errors).Message; + + Assert.Equal( + "A change set cannot move from checks running to published; it can move to checks failed or awaiting review or approved.", + fromRunning); + Assert.Equal("A change set cannot move from rejected to open; rejected is final.", fromRejected); + } + + [Theory] + [InlineData(ChangeSetState.ChecksFailed)] + [InlineData(ChangeSetState.AwaitingReview)] + public void A_rejection_without_a_reason_is_refused(ChangeSetState from) + { + var changeSet = TestChangeSets.InState(from); + + var errors = new[] { changeSet.TransitionTo(ChangeSetState.Rejected), changeSet.Reject(" "), changeSet.Reject(null!) } + .Select(result => Assert.Single(result.Errors).Code); + + Assert.All(errors, code => Assert.Equal(ChangeSetErrorCodes.RejectionReasonMissing, code)); + Assert.Equal(from, changeSet.State); + Assert.Null(changeSet.RejectionReason); + } + + [Fact] + public void A_rejection_stores_its_reason() + { + var changeSet = TestChangeSets.InState(ChangeSetState.AwaitingReview); + + Assert.True(changeSet.Reject(" The price has no source. ").IsSuccess); + + Assert.Equal(ChangeSetState.Rejected, changeSet.State); + Assert.Equal("The price has no source.", changeSet.RejectionReason); + } +} diff --git a/tests/FluentCMS.Tests/ChangeSets/TestChangeSets.cs b/tests/FluentCMS.Tests/ChangeSets/TestChangeSets.cs new file mode 100644 index 000000000..3d06d82c4 --- /dev/null +++ b/tests/FluentCMS.Tests/ChangeSets/TestChangeSets.cs @@ -0,0 +1,62 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; + +namespace FluentCMS.Tests.ChangeSets; + +internal static class TestChangeSets +{ + public static readonly Guid Site = Guid.NewGuid(); + + public static readonly Initiator Agent = new AgentInitiator(Guid.NewGuid(), "example-agent"); + + public static ChangeSet Open() + { + return ChangeSet.Open(Site, "Sync prices from the catalog export.", ["exports/catalog-2026-10-18.csv"], Agent).Value; + } + + public static ChangeOperation Update(ItemKind itemKind, Guid? itemId = null) + { + return ChangeOperation.Create(OperationKind.Update, itemKind, itemId ?? Guid.NewGuid(), 1).Value; + } + + // A set with one operation, moved along the shortest path of the transition table to the given state. + public static ChangeSet InState(ChangeSetState state) + { + var changeSet = Open(); + Assert.True(changeSet.AddOperation(Update(ItemKind.Entry)).IsSuccess); + + foreach (var next in PathFromOpen(state)) + { + var moved = next == ChangeSetState.Rejected ? changeSet.Reject("Example reason.") : changeSet.TransitionTo(next); + Assert.True(moved.IsSuccess); + } + + Assert.Equal(state, changeSet.State); + return changeSet; + } + + private static List PathFromOpen(ChangeSetState target) + { + var previous = new Dictionary(); + var queue = new Queue([ChangeSetState.Open]); + + while (queue.TryDequeue(out var state) && state != target) + { + foreach (var next in ChangeSetTransitions.From(state)) + { + if (next != ChangeSetState.Open && previous.TryAdd(next, state)) + { + queue.Enqueue(next); + } + } + } + + var path = new List(); + for (var state = target; state != ChangeSetState.Open; state = previous[state]) + { + path.Insert(0, state); + } + + return path; + } +}