From 53a891aec47da6aa1918f6baa1fb003635d2d44a Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 10:07:56 -0500 Subject: [PATCH 1/2] Add the hook contract and the hook pipeline --- .../Content/Hooks/ContentChanges.cs | 78 +++ src/FluentCMS.Core/Content/Hooks/HookKind.cs | 18 + .../Content/Hooks/HookPipeline.cs | 186 ++++++++ .../Content/Hooks/IHookEntryMapper.cs | 13 + .../Content/Hooks/IHookTiebreaker.cs | 8 + src/FluentCMS.Sdk/Content/ContentDocument.cs | 147 ++++++ src/FluentCMS.Sdk/Hooks/HookContext.cs | 67 +++ src/FluentCMS.Sdk/Hooks/HookOrderAttribute.cs | 13 + .../Hooks/HookRejectedException.cs | 22 + src/FluentCMS.Sdk/Hooks/IAfterCreateHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IAfterDeleteHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IAfterPublishHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IAfterRestoreHook.cs | 14 + .../Hooks/IAfterUnpublishHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IAfterUpdateHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IBeforeCreateHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IBeforeDeleteHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IBeforePublishHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IBeforeRestoreHook.cs | 14 + .../Hooks/IBeforeUnpublishHook.cs | 14 + src/FluentCMS.Sdk/Hooks/IBeforeUpdateHook.cs | 14 + .../Hooks/IBeforeValidateHook.cs | 14 + src/FluentCMS.Sdk/PublicAPI.Unshipped.txt | 58 +++ .../Content/Hooks/ContentChangesTests.cs | 96 ++++ .../Content/Hooks/HookFixtures.cs | 48 ++ .../Content/Hooks/HookPipelineTests.cs | 449 ++++++++++++++++++ .../Content/Hooks/ValidationReportTests.cs | 66 +++ .../Content/HookTransactionTests.cs | 179 +++++++ 28 files changed, 1630 insertions(+) create mode 100644 src/FluentCMS.Core/Content/Hooks/ContentChanges.cs create mode 100644 src/FluentCMS.Core/Content/Hooks/HookKind.cs create mode 100644 src/FluentCMS.Core/Content/Hooks/HookPipeline.cs create mode 100644 src/FluentCMS.Core/Content/Hooks/IHookEntryMapper.cs create mode 100644 src/FluentCMS.Core/Content/Hooks/IHookTiebreaker.cs create mode 100644 src/FluentCMS.Sdk/Content/ContentDocument.cs create mode 100644 src/FluentCMS.Sdk/Hooks/HookContext.cs create mode 100644 src/FluentCMS.Sdk/Hooks/HookOrderAttribute.cs create mode 100644 src/FluentCMS.Sdk/Hooks/HookRejectedException.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IAfterCreateHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IAfterDeleteHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IAfterPublishHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IAfterRestoreHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IAfterUnpublishHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IAfterUpdateHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IBeforeCreateHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IBeforeDeleteHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IBeforePublishHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IBeforeRestoreHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IBeforeUnpublishHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IBeforeUpdateHook.cs create mode 100644 src/FluentCMS.Sdk/Hooks/IBeforeValidateHook.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Hooks/ContentChangesTests.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Hooks/HookFixtures.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Hooks/HookPipelineTests.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Hooks/ValidationReportTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/HookTransactionTests.cs diff --git a/src/FluentCMS.Core/Content/Hooks/ContentChanges.cs b/src/FluentCMS.Core/Content/Hooks/ContentChanges.cs new file mode 100644 index 000000000..12fba151a --- /dev/null +++ b/src/FluentCMS.Core/Content/Hooks/ContentChanges.cs @@ -0,0 +1,78 @@ +using System.Collections; +using FluentCMS.Core.Content.Validation; + +namespace FluentCMS.Core.Content.Hooks; + +public static class ContentChanges +{ + private static readonly IReadOnlyDictionary NoValues = new Dictionary(); + + // The paths of the fields whose values differ, in the form validation reports: seo.metaTitle, sections[2].heading, tags[1]. + // A missing field counts as null, so a create (no previous state) lists the fields that hold a value. Lists of the same + // length are compared by index; a list whose length changed is reported as the list itself. Values are API-shaped: + // dictionaries for components and blocks, and scalars compared as a schema stores them. + public static IReadOnlyList Between(IReadOnlyDictionary? previous, IReadOnlyDictionary current) + { + ArgumentNullException.ThrowIfNull(current); + + var paths = new List(); + CompareMaps(previous ?? NoValues, current, "", paths); + return paths; + } + + private static void CompareMaps(IReadOnlyDictionary previous, IReadOnlyDictionary current, string prefix, List paths) + { + foreach (var (name, value) in current) + { + previous.TryGetValue(name, out var before); + CompareValues(before, value, Join(prefix, name), paths); + } + + foreach (var (name, value) in previous) + { + if (!current.ContainsKey(name)) + { + CompareValues(value, null, Join(prefix, name), paths); + } + } + } + + private static void CompareValues(object? before, object? after, string path, List paths) + { + if (AsMap(before) is { } beforeMap && AsMap(after) is { } afterMap) + { + CompareMaps(beforeMap, afterMap, path, paths); + } + else if (AsList(before) is { } beforeList && AsList(after) is { } afterList) + { + if (beforeList.Count != afterList.Count) + { + paths.Add(path); + return; + } + + for (var index = 0; index < afterList.Count; index++) + { + CompareValues(beforeList[index], afterList[index], $"{path}[{index}]", paths); + } + } + else if (!Equals(FieldValue.Normalize(before), FieldValue.Normalize(after))) + { + paths.Add(path); + } + } + + private static string Join(string prefix, string name) => prefix.Length == 0 ? name : $"{prefix}.{name}"; + + private static IReadOnlyDictionary? AsMap(object? value) => value switch + { + IReadOnlyDictionary map => map, + IDictionary map => new Dictionary(map), + _ => null, + }; + + private static List? AsList(object? value) => + value is IEnumerable and not string and not IReadOnlyDictionary and not IDictionary + ? [.. ((IEnumerable)value).Cast()] + : null; +} diff --git a/src/FluentCMS.Core/Content/Hooks/HookKind.cs b/src/FluentCMS.Core/Content/Hooks/HookKind.cs new file mode 100644 index 000000000..9f8c2d3a8 --- /dev/null +++ b/src/FluentCMS.Core/Content/Hooks/HookKind.cs @@ -0,0 +1,18 @@ +namespace FluentCMS.Core.Content.Hooks; + +public enum HookKind +{ + BeforeValidate, + BeforeCreate, + AfterCreate, + BeforeUpdate, + AfterUpdate, + BeforeDelete, + AfterDelete, + BeforePublish, + AfterPublish, + BeforeUnpublish, + AfterUnpublish, + BeforeRestore, + AfterRestore, +} diff --git a/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs b/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs new file mode 100644 index 000000000..175d7709e --- /dev/null +++ b/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs @@ -0,0 +1,186 @@ +using System.Collections.Concurrent; +using System.Reflection; +using FluentCMS.Content; +using FluentCMS.Hooks; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content.Hooks; + +// Runs the hooks of one kind for one entry. Hooks for ContentDocument (every type) and for the content class of the type, +// and for its base classes, form one sequence ordered by [HookOrder], then by IHookTiebreaker rank. Hooks that still tie keep +// the order of resolution: all-types hooks first, then base classes before derived ones, each in registration order. +// The first hook that cancels throws HookRejectedException and the hooks after it do not run. +public sealed class HookPipeline(IServiceProvider services) +{ + private static readonly ConcurrentDictionary Orders = new(); + + public async ValueTask RunAsync( + HookKind kind, + SchemaType type, + OperationContext operation, + ContentDocument entry, + ContentDocument? previous, + IReadOnlyList changedPaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(changedPaths); + + var hooks = Resolve(kind, type); + if (hooks.Count == 0) + { + return; + } + + var session = new Session(type.ClrType, operation, entry, previous, changedPaths, services.GetService(typeof(IHookEntryMapper)) as IHookEntryMapper); + var mutable = kind is HookKind.BeforeValidate or HookKind.BeforeCreate or HookKind.BeforeUpdate + or HookKind.BeforeDelete or HookKind.BeforePublish or HookKind.BeforeUnpublish or HookKind.BeforeRestore; + + foreach (var hook in hooks) + { + cancellationToken.ThrowIfCancellationRequested(); + await hook.InvokeAsync(session, mutable, cancellationToken); + } + } + + private List Resolve(HookKind kind, SchemaType type) + { + var hooks = new List(); + HookBinder.For(typeof(ContentDocument)).Collect(services, kind, hooks); + + if (type.ClrType is { } clrType) + { + var chain = new List(); + for (var current = clrType; current is not null && current != typeof(object); current = current.BaseType) + { + chain.Add(current); + } + + for (var index = chain.Count - 1; index >= 0; index--) + { + HookBinder.For(chain[index]).Collect(services, kind, hooks); + } + } + + if (hooks.Count < 2) + { + return hooks; + } + + var tiebreaker = services.GetService(typeof(IHookTiebreaker)) as IHookTiebreaker; + return [.. hooks + .OrderBy(hook => Orders.GetOrAdd(hook.ImplementationType, static type => type.GetCustomAttribute()?.Order ?? 0)) + .ThenBy(hook => tiebreaker?.Rank(hook.ImplementationType) ?? 0)]; + } + + // The entry as the hooks see it: the document, or the content class mapped from it. + private sealed class Session( + Type? clrType, + OperationContext operation, + ContentDocument entry, + ContentDocument? previous, + IReadOnlyList changedPaths, + IHookEntryMapper? mapper) + { + private object? _previousEntry; + + public HookContext CreateContext(out object? mapped) + { + if (typeof(T) == typeof(ContentDocument)) + { + mapped = null; + return new HookContext(operation, (T)(object)entry, (T?)(object?)previous, changedPaths); + } + + var map = RequireMapper(); + mapped = map.ToEntry(clrType!, entry); + if (previous is not null) + { + _previousEntry ??= map.ToEntry(clrType!, previous); + } + + return new HookContext(operation, (T)mapped, (T?)_previousEntry, changedPaths); + } + + public void WriteBack(object mapped) => RequireMapper().CopyTo(mapped, entry); + + private IHookEntryMapper RequireMapper() => + mapper ?? throw new InvalidOperationException("A hook is registered for a content class, but no IHookEntryMapper is registered to map the entry to it."); + } + + private abstract class BoundHook(Type implementationType) + { + public Type ImplementationType { get; } = implementationType; + + public abstract ValueTask InvokeAsync(Session session, bool mutable, CancellationToken cancellationToken); + } + + private sealed class BoundHook(Type implementationType, Func, CancellationToken, ValueTask> call) : BoundHook(implementationType) + { + public override async ValueTask InvokeAsync(Session session, bool mutable, CancellationToken cancellationToken) + { + var context = session.CreateContext(out var mapped); + await call(context, cancellationToken); + + if (context.IsCancelled) + { + throw new HookRejectedException(context.CancelCode!, context.CancelMessage!); + } + + if (mutable && mapped is not null) + { + session.WriteBack(mapped); + } + } + } + + private abstract class HookBinder + { + private static readonly ConcurrentDictionary Cache = new(); + + public static HookBinder For(Type entryType) => + Cache.GetOrAdd(entryType, static type => (HookBinder)Activator.CreateInstance(typeof(HookBinder<>).MakeGenericType(type))!); + + public abstract void Collect(IServiceProvider services, HookKind kind, List into); + } + + private sealed class HookBinder : HookBinder + { + public override void Collect(IServiceProvider services, HookKind kind, List into) + { + switch (kind) + { + case HookKind.BeforeValidate: Add>(services, into, hook => hook.BeforeValidateAsync); break; + case HookKind.BeforeCreate: Add>(services, into, hook => hook.BeforeCreateAsync); break; + case HookKind.AfterCreate: Add>(services, into, hook => hook.AfterCreateAsync); break; + case HookKind.BeforeUpdate: Add>(services, into, hook => hook.BeforeUpdateAsync); break; + case HookKind.AfterUpdate: Add>(services, into, hook => hook.AfterUpdateAsync); break; + case HookKind.BeforeDelete: Add>(services, into, hook => hook.BeforeDeleteAsync); break; + case HookKind.AfterDelete: Add>(services, into, hook => hook.AfterDeleteAsync); break; + case HookKind.BeforePublish: Add>(services, into, hook => hook.BeforePublishAsync); break; + case HookKind.AfterPublish: Add>(services, into, hook => hook.AfterPublishAsync); break; + case HookKind.BeforeUnpublish: Add>(services, into, hook => hook.BeforeUnpublishAsync); break; + case HookKind.AfterUnpublish: Add>(services, into, hook => hook.AfterUnpublishAsync); break; + case HookKind.BeforeRestore: Add>(services, into, hook => hook.BeforeRestoreAsync); break; + case HookKind.AfterRestore: Add>(services, into, hook => hook.AfterRestoreAsync); break; + default: throw new ArgumentOutOfRangeException(nameof(kind), kind, null); + } + } + + private static void Add(IServiceProvider services, List into, Func, CancellationToken, ValueTask>> bind) + where THook : notnull + { + if (services.GetService(typeof(IEnumerable)) is not IEnumerable hooks) + { + return; + } + + foreach (var hook in hooks) + { + into.Add(new BoundHook(hook.GetType(), bind(hook))); + } + } + } +} diff --git a/src/FluentCMS.Core/Content/Hooks/IHookEntryMapper.cs b/src/FluentCMS.Core/Content/Hooks/IHookEntryMapper.cs new file mode 100644 index 000000000..97d2c9ca6 --- /dev/null +++ b/src/FluentCMS.Core/Content/Hooks/IHookEntryMapper.cs @@ -0,0 +1,13 @@ +using FluentCMS.Content; + +namespace FluentCMS.Core.Content.Hooks; + +// Converts between the ContentDocument the content service works on and the content class a typed hook receives. +// The content service provides it; the pipeline only needs it when a hook is registered for a content class. +public interface IHookEntryMapper +{ + object ToEntry(Type clrType, ContentDocument document); + + // Writes the values of the entry back into the document and leaves the keys the class does not map alone. + void CopyTo(object entry, ContentDocument document); +} diff --git a/src/FluentCMS.Core/Content/Hooks/IHookTiebreaker.cs b/src/FluentCMS.Core/Content/Hooks/IHookTiebreaker.cs new file mode 100644 index 000000000..850a1aa31 --- /dev/null +++ b/src/FluentCMS.Core/Content/Hooks/IHookTiebreaker.cs @@ -0,0 +1,8 @@ +namespace FluentCMS.Core.Content.Hooks; + +// Orders hooks whose [HookOrder] is equal. The plugin host replaces the default (no tiebreaker registered, so +// registration order decides) with the dependency order of the plugins: a plugin that another one depends on ranks lower. +public interface IHookTiebreaker +{ + int Rank(Type hookType); +} diff --git a/src/FluentCMS.Sdk/Content/ContentDocument.cs b/src/FluentCMS.Sdk/Content/ContentDocument.cs new file mode 100644 index 000000000..5a70f33f4 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/ContentDocument.cs @@ -0,0 +1,147 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace FluentCMS.Content; + +/// +/// An entry of any content type as an ordered dictionary of values by API field name, with typed accessors. +/// The Management API, MCP and import work with it, and a hook for sees every type. +/// Names are case-sensitive and keep the order in which they were added. +/// +[SuppressMessage("Naming", "CA1710:Identifier should have correct suffix", Justification = "ContentDocument is the name the specification gives the type.")] +public sealed class ContentDocument : IDictionary, IReadOnlyDictionary +{ + private readonly OrderedDictionary _values = new(StringComparer.Ordinal); + + /// Creates an empty document. + public ContentDocument() + { + } + + /// Creates a document with values. + /// The values, in order. + public ContentDocument(IEnumerable> values) + { + ArgumentNullException.ThrowIfNull(values); + foreach (var (name, value) in values) + { + _values[name] = value; + } + } + + /// The number of values. + public int Count => _values.Count; + + /// The field names, in order. + public ICollection Keys => _values.Keys; + + /// The values, in order. + public ICollection Values => _values.Values; + + bool ICollection>.IsReadOnly => false; + + IEnumerable IReadOnlyDictionary.Keys => _values.Keys; + + IEnumerable IReadOnlyDictionary.Values => _values.Values; + + /// Gets or sets a value. Setting a new name appends it; setting an existing name keeps its place. + /// The API field name. + /// The value. + /// There is no such field when reading. + public object? this[string name] + { + get => _values[name]; + set => _values[name] = value; + } + + /// Gets a value as a type. + /// The type to read the value as. A number converts to another numeric type. + /// The API field name. + /// The value, or the default of when the field is missing or null. + /// The value does not convert to . + public T? Get(string name) => TryGet(name, out var value) ? value : default; + + /// Tries to get a value as a type. + /// The type to read the value as. A number converts to another numeric type. + /// The API field name. + /// The value, or the default of when the field is missing or null. + /// when the field exists and holds a non-null value. + /// The value does not convert to . + public bool TryGet(string name, out T? value) + { + if (!_values.TryGetValue(name, out var raw) || raw is null) + { + value = default; + return false; + } + + value = Convert(raw); + return true; + } + + /// Adds a value. + /// The API field name. + /// The value. + /// The field already exists. + public void Add(string name, object? value) => _values.Add(name, value); + + /// Whether the document has a field. + /// The API field name. + /// when the field exists, even with a null value. + public bool ContainsKey(string name) => _values.ContainsKey(name); + + /// Gets a value. + /// The API field name. + /// The value, when the field exists. + /// when the field exists. + public bool TryGetValue(string name, [MaybeNullWhen(false)] out object? value) => _values.TryGetValue(name, out value); + + /// Removes a field. + /// The API field name. + /// when the field existed. + public bool Remove(string name) => _values.Remove(name); + + /// Removes every field. + public void Clear() => _values.Clear(); + + /// Gets the values in order. + /// An enumerator over the name and value pairs. + public IEnumerator> GetEnumerator() => _values.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + void ICollection>.Add(KeyValuePair item) => _values.Add(item.Key, item.Value); + + bool ICollection>.Contains(KeyValuePair item) => + _values.TryGetValue(item.Key, out var value) && Equals(value, item.Value); + + void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => + ((ICollection>)_values).CopyTo(array, arrayIndex); + + bool ICollection>.Remove(KeyValuePair item) => + ((ICollection>)this).Contains(item) && _values.Remove(item.Key); + + private static T? Convert(object raw) + { + if (raw is T typed) + { + return typed; + } + + var target = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + if (raw is IConvertible && typeof(IConvertible).IsAssignableFrom(target) && !target.IsEnum) + { + try + { + return (T)System.Convert.ChangeType(raw, target, CultureInfo.InvariantCulture); + } + catch (Exception exception) when (exception is FormatException or OverflowException) + { + throw new InvalidCastException($"The value {raw} does not convert to {typeof(T)}.", exception); + } + } + + throw new InvalidCastException($"A value of type {raw.GetType()} does not convert to {typeof(T)}."); + } +} diff --git a/src/FluentCMS.Sdk/Hooks/HookContext.cs b/src/FluentCMS.Sdk/Hooks/HookContext.cs new file mode 100644 index 000000000..804fd573b --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/HookContext.cs @@ -0,0 +1,67 @@ +using FluentCMS.Content; + +namespace FluentCMS.Hooks; + +/// What a hook knows about the operation, and the way to cancel it. +/// The type of the entry: a content class or . +public sealed class HookContext +{ + /// Creates a context, for the hook pipeline or for unit tests of a hook. + /// The operation the hook runs in. + /// The entry the operation works on. + /// The stored state before the operation, or when there is none. + /// The paths of the fields that differ between and . + public HookContext(OperationContext operation, T entry, T? previous, IReadOnlyList changedPaths) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(changedPaths); + + Operation = operation; + Entry = entry; + Previous = previous; + ChangedPaths = changedPaths; + } + + /// Who performs the operation, from where, on which site and in which locale. + public OperationContext Operation { get; } + + /// The entry the operation works on. A before hook may change it, and the change is what gets stored. + public T Entry { get; } + + /// The stored state before the operation; when an entry is created. + public T? Previous { get; } + + /// The paths of the fields that differ between and , such as title, seo.metaTitle or sections[2].heading. A list whose length changed is reported as the list. + public IReadOnlyList ChangedPaths { get; } + + /// Whether was called. + public bool IsCancelled { get; private set; } + + /// The code given to the first call of , or when the operation is not cancelled. + public string? CancelCode { get; private set; } + + /// The message given to the first call of , or when the operation is not cancelled. + public string? CancelMessage { get; private set; } + + /// + /// Rejects the operation. The pipeline stops after the hook returns, skips the hooks that follow, throws a + /// and the transaction rolls back. Only the first call counts. + /// + /// A stable code; plugins should use their own prefix. + /// A message for people. + public void Cancel(string code, string message) + { + ArgumentException.ThrowIfNullOrEmpty(code); + ArgumentException.ThrowIfNullOrEmpty(message); + + if (IsCancelled) + { + return; + } + + IsCancelled = true; + CancelCode = code; + CancelMessage = message; + } +} diff --git a/src/FluentCMS.Sdk/Hooks/HookOrderAttribute.cs b/src/FluentCMS.Sdk/Hooks/HookOrderAttribute.cs new file mode 100644 index 000000000..aae2039d3 --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/HookOrderAttribute.cs @@ -0,0 +1,13 @@ +namespace FluentCMS.Hooks; + +/// +/// Sets where a hook runs among the hooks of the same operation: the lower the order, the earlier. A hook without +/// the attribute has order 0. Hooks of equal order run in plugin dependency order. +/// +/// The order. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class HookOrderAttribute(int order) : Attribute +{ + /// The order. + public int Order { get; } = order; +} diff --git a/src/FluentCMS.Sdk/Hooks/HookRejectedException.cs b/src/FluentCMS.Sdk/Hooks/HookRejectedException.cs new file mode 100644 index 000000000..9082cbaae --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/HookRejectedException.cs @@ -0,0 +1,22 @@ +namespace FluentCMS.Hooks; + +/// +/// Thrown when a hook cancels an operation, or by a hook that rejects it directly. The transaction rolls back and +/// the HTTP layer maps the exception to a 422 problem details response with the code and the message. +/// +public sealed class HookRejectedException : Exception +{ + /// Creates the exception. + /// A stable code; plugins should use their own prefix. + /// A message for people. + public HookRejectedException(string code, string message) + : base(message) + { + ArgumentException.ThrowIfNullOrEmpty(code); + ArgumentException.ThrowIfNullOrEmpty(message); + Code = code; + } + + /// The code the hook gave. + public string Code { get; } +} diff --git a/src/FluentCMS.Sdk/Hooks/IAfterCreateHook.cs b/src/FluentCMS.Sdk/Hooks/IAfterCreateHook.cs new file mode 100644 index 000000000..d98b39514 --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IAfterCreateHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into creating an entry, after it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IAfterCreateHook +{ + /// Runs after the write, still inside the transaction, so a failure or a cancellation rolls the write back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IAfterDeleteHook.cs b/src/FluentCMS.Sdk/Hooks/IAfterDeleteHook.cs new file mode 100644 index 000000000..47ffebbdd --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IAfterDeleteHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into moving an entry to the trash, after it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IAfterDeleteHook +{ + /// Runs after the write, still inside the transaction, so a failure or a cancellation rolls the write back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask AfterDeleteAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IAfterPublishHook.cs b/src/FluentCMS.Sdk/Hooks/IAfterPublishHook.cs new file mode 100644 index 000000000..8645441aa --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IAfterPublishHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into publishing an entry, after it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IAfterPublishHook +{ + /// Runs after the write, still inside the transaction, so a failure or a cancellation rolls the write back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask AfterPublishAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IAfterRestoreHook.cs b/src/FluentCMS.Sdk/Hooks/IAfterRestoreHook.cs new file mode 100644 index 000000000..b75ebd97a --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IAfterRestoreHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into restoring an entry from the trash, after it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IAfterRestoreHook +{ + /// Runs after the write, still inside the transaction, so a failure or a cancellation rolls the write back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask AfterRestoreAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IAfterUnpublishHook.cs b/src/FluentCMS.Sdk/Hooks/IAfterUnpublishHook.cs new file mode 100644 index 000000000..9797d15d8 --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IAfterUnpublishHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into unpublishing an entry, after it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IAfterUnpublishHook +{ + /// Runs after the write, still inside the transaction, so a failure or a cancellation rolls the write back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask AfterUnpublishAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IAfterUpdateHook.cs b/src/FluentCMS.Sdk/Hooks/IAfterUpdateHook.cs new file mode 100644 index 000000000..adca628c9 --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IAfterUpdateHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into updating the draft of an entry, after it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IAfterUpdateHook +{ + /// Runs after the write, still inside the transaction, so a failure or a cancellation rolls the write back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask AfterUpdateAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IBeforeCreateHook.cs b/src/FluentCMS.Sdk/Hooks/IBeforeCreateHook.cs new file mode 100644 index 000000000..35fc27b76 --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IBeforeCreateHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into creating an entry, before it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IBeforeCreateHook +{ + /// Runs before the write. The hook may change , and the change is what gets stored, or call to reject the operation and roll the transaction back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IBeforeDeleteHook.cs b/src/FluentCMS.Sdk/Hooks/IBeforeDeleteHook.cs new file mode 100644 index 000000000..f1728c234 --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IBeforeDeleteHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into moving an entry to the trash, before it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IBeforeDeleteHook +{ + /// Runs before the write. The hook may change , and the change is what gets stored, or call to reject the operation and roll the transaction back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask BeforeDeleteAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IBeforePublishHook.cs b/src/FluentCMS.Sdk/Hooks/IBeforePublishHook.cs new file mode 100644 index 000000000..d0f4a98bb --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IBeforePublishHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into publishing an entry, before it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IBeforePublishHook +{ + /// Runs before the write. The hook may change , and the change is what gets stored, or call to reject the operation and roll the transaction back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask BeforePublishAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IBeforeRestoreHook.cs b/src/FluentCMS.Sdk/Hooks/IBeforeRestoreHook.cs new file mode 100644 index 000000000..59726d484 --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IBeforeRestoreHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into restoring an entry from the trash, before it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IBeforeRestoreHook +{ + /// Runs before the write. The hook may change , and the change is what gets stored, or call to reject the operation and roll the transaction back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask BeforeRestoreAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IBeforeUnpublishHook.cs b/src/FluentCMS.Sdk/Hooks/IBeforeUnpublishHook.cs new file mode 100644 index 000000000..680cd216c --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IBeforeUnpublishHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into unpublishing an entry, before it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IBeforeUnpublishHook +{ + /// Runs before the write. The hook may change , and the change is what gets stored, or call to reject the operation and roll the transaction back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask BeforeUnpublishAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IBeforeUpdateHook.cs b/src/FluentCMS.Sdk/Hooks/IBeforeUpdateHook.cs new file mode 100644 index 000000000..8c4289b9b --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IBeforeUpdateHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Hooks into updating the draft of an entry, before it is written. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IBeforeUpdateHook +{ + /// Runs before the write. The hook may change , and the change is what gets stored, or call to reject the operation and roll the transaction back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Hooks/IBeforeValidateHook.cs b/src/FluentCMS.Sdk/Hooks/IBeforeValidateHook.cs new file mode 100644 index 000000000..e82be5046 --- /dev/null +++ b/src/FluentCMS.Sdk/Hooks/IBeforeValidateHook.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Hooks; + +/// +/// Runs before an entry is validated, on a draft save or a publish, so it can fill in or normalise values that validation then checks. Register it in the service collection; it runs inside the operation's transaction, in order. +/// +/// A content class, to see entries of that type, or , to see entries of every type. +public interface IBeforeValidateHook +{ + /// Runs before the write. The hook may change , and the change is what gets stored, or call to reject the operation and roll the transaction back. + /// The operation, the entry and the way to cancel. + /// Cancels the hook. + /// A task that completes when the hook is done. + ValueTask BeforeValidateAsync(HookContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt index c47066a02..662a19455 100644 --- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt +++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt @@ -1,4 +1,20 @@ #nullable enable +FluentCMS.Content.ContentDocument +FluentCMS.Content.ContentDocument.Add(string! name, object? value) -> void +FluentCMS.Content.ContentDocument.Clear() -> void +FluentCMS.Content.ContentDocument.ContainsKey(string! name) -> bool +FluentCMS.Content.ContentDocument.ContentDocument() -> void +FluentCMS.Content.ContentDocument.ContentDocument(System.Collections.Generic.IEnumerable>! values) -> void +FluentCMS.Content.ContentDocument.Count.get -> int +FluentCMS.Content.ContentDocument.Get(string! name) -> T? +FluentCMS.Content.ContentDocument.GetEnumerator() -> System.Collections.Generic.IEnumerator>! +FluentCMS.Content.ContentDocument.Keys.get -> System.Collections.Generic.ICollection! +FluentCMS.Content.ContentDocument.Remove(string! name) -> bool +FluentCMS.Content.ContentDocument.TryGet(string! name, out T? value) -> bool +FluentCMS.Content.ContentDocument.TryGetValue(string! name, out object? value) -> bool +FluentCMS.Content.ContentDocument.Values.get -> System.Collections.Generic.ICollection! +FluentCMS.Content.ContentDocument.this[string! name].get -> object? +FluentCMS.Content.ContentDocument.this[string! name].set -> void FluentCMS.Content.FilterOperator FluentCMS.Content.FilterOperator.Between = 8 -> FluentCMS.Content.FilterOperator FluentCMS.Content.FilterOperator.Contains = 9 -> FluentCMS.Content.FilterOperator @@ -97,6 +113,48 @@ FluentCMS.Content.ValidationResult FluentCMS.Content.ValidationResult.Errors.get -> System.Collections.Generic.IReadOnlyList! FluentCMS.Content.ValidationResult.IsValid.get -> bool FluentCMS.Content.ValidationResult.ValidationResult(System.Collections.Generic.IEnumerable! errors) -> void +FluentCMS.Hooks.HookContext +FluentCMS.Hooks.HookContext.Cancel(string! code, string! message) -> void +FluentCMS.Hooks.HookContext.CancelCode.get -> string? +FluentCMS.Hooks.HookContext.CancelMessage.get -> string? +FluentCMS.Hooks.HookContext.ChangedPaths.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Hooks.HookContext.Entry.get -> T +FluentCMS.Hooks.HookContext.HookContext(FluentCMS.Content.OperationContext! operation, T entry, T? previous, System.Collections.Generic.IReadOnlyList! changedPaths) -> void +FluentCMS.Hooks.HookContext.IsCancelled.get -> bool +FluentCMS.Hooks.HookContext.Operation.get -> FluentCMS.Content.OperationContext! +FluentCMS.Hooks.HookContext.Previous.get -> T? +FluentCMS.Hooks.HookOrderAttribute +FluentCMS.Hooks.HookOrderAttribute.HookOrderAttribute(int order) -> void +FluentCMS.Hooks.HookOrderAttribute.Order.get -> int +FluentCMS.Hooks.HookRejectedException +FluentCMS.Hooks.HookRejectedException.Code.get -> string! +FluentCMS.Hooks.HookRejectedException.HookRejectedException(string! code, string! message) -> void +FluentCMS.Hooks.IAfterCreateHook +FluentCMS.Hooks.IAfterCreateHook.AfterCreateAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IAfterDeleteHook +FluentCMS.Hooks.IAfterDeleteHook.AfterDeleteAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IAfterPublishHook +FluentCMS.Hooks.IAfterPublishHook.AfterPublishAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IAfterRestoreHook +FluentCMS.Hooks.IAfterRestoreHook.AfterRestoreAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IAfterUnpublishHook +FluentCMS.Hooks.IAfterUnpublishHook.AfterUnpublishAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IAfterUpdateHook +FluentCMS.Hooks.IAfterUpdateHook.AfterUpdateAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IBeforeCreateHook +FluentCMS.Hooks.IBeforeCreateHook.BeforeCreateAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IBeforeDeleteHook +FluentCMS.Hooks.IBeforeDeleteHook.BeforeDeleteAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IBeforePublishHook +FluentCMS.Hooks.IBeforePublishHook.BeforePublishAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IBeforeRestoreHook +FluentCMS.Hooks.IBeforeRestoreHook.BeforeRestoreAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IBeforeUnpublishHook +FluentCMS.Hooks.IBeforeUnpublishHook.BeforeUnpublishAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IBeforeUpdateHook +FluentCMS.Hooks.IBeforeUpdateHook.BeforeUpdateAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Hooks.IBeforeValidateHook +FluentCMS.Hooks.IBeforeValidateHook.BeforeValidateAsync(FluentCMS.Hooks.HookContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask FluentCMS.Media.MediaRef FluentCMS.Media.MediaRef.Deconstruct(out System.Guid Id) -> void FluentCMS.Media.MediaRef.Equals(FluentCMS.Media.MediaRef other) -> bool diff --git a/tests/FluentCMS.Core.Tests/Content/Hooks/ContentChangesTests.cs b/tests/FluentCMS.Core.Tests/Content/Hooks/ContentChangesTests.cs new file mode 100644 index 000000000..adae3c6de --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Hooks/ContentChangesTests.cs @@ -0,0 +1,96 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Hooks; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Content.Hooks; + +public class ContentChangesTests +{ + private static Dictionary Map(params (string Key, object? Value)[] values) => + values.ToDictionary(item => item.Key, item => item.Value); + + [Fact] + public void ChangedScalarsAreListedInTheOrderOfTheEntry() + { + var previous = Map(("title", "a"), ("slug", "a"), ("count", 1L)); + var current = Map(("title", "b"), ("slug", "a"), ("count", 2L)); + + ContentChanges.Between(previous, current).ShouldBe(["title", "count"]); + } + + [Fact] + public void EqualEntriesHaveNoChanges() + { + var previous = Map(("title", "a"), ("seo", Map(("metaTitle", "m"))), ("tags", new object[] { "x", "y" })); + var current = Map(("title", "a"), ("seo", Map(("metaTitle", "m"))), ("tags", new object[] { "x", "y" })); + + ContentChanges.Between(previous, current).ShouldBeEmpty(); + } + + [Fact] + public void NestedPathsUseTheDottedFormOfValidation() + { + var previous = Map(("seo", Map(("metaTitle", "m"), ("noIndex", false)))); + var current = Map(("seo", Map(("metaTitle", "n"), ("noIndex", false)))); + + ContentChanges.Between(previous, current).ShouldBe(["seo.metaTitle"]); + } + + [Fact] + public void ListsOfTheSameLengthAreComparedByIndex() + { + var previous = Map( + ("tags", new object?[] { "a", "b" }), + ("sections", new object?[] { Map(("heading", "h1")), Map(("heading", "h2")) })); + var current = Map( + ("tags", new object?[] { "a", "c" }), + ("sections", new object?[] { Map(("heading", "h1")), Map(("heading", "changed")) })); + + ContentChanges.Between(previous, current).ShouldBe(["tags[1]", "sections[1].heading"]); + } + + [Fact] + public void AListWhoseLengthChangedIsReportedAsTheList() + { + var previous = Map(("tags", new object?[] { "a" })); + var current = Map(("tags", new object?[] { "a", "b" })); + + ContentChanges.Between(previous, current).ShouldBe(["tags"]); + } + + [Fact] + public void AMissingFieldCountsAsNullAndARemovedValueIsAChange() + { + var previous = Map(("title", "a"), ("note", null), ("seo", Map(("metaTitle", "m")))); + var current = Map(("other", null)); + + ContentChanges.Between(previous, current).ShouldBe(["title", "seo"]); + } + + [Fact] + public void CreatingListsTheFieldsThatHoldAValue() + { + var current = new ContentDocument { ["title"] = "a", ["note"] = null, ["seo"] = Map(("metaTitle", "m")) }; + + ContentChanges.Between(null, current).ShouldBe(["title", "seo"]); + } + + [Fact] + public void NumbersCompareByValueWhateverTheirClrType() + { + var previous = Map(("count", 3), ("price", 1.5m)); + var current = Map(("count", 3L), ("price", 1.5m)); + + ContentChanges.Between(previous, current).ShouldBeEmpty(); + } + + [Fact] + public void ADocumentIsComparedLikeAnyDictionary() + { + var previous = new ContentDocument { ["seo"] = new ContentDocument { ["metaTitle"] = "m" } }; + var current = new ContentDocument { ["seo"] = new ContentDocument { ["metaTitle"] = "n" } }; + + ContentChanges.Between(previous, current).ShouldBe(["seo.metaTitle"]); + } +} diff --git a/tests/FluentCMS.Core.Tests/Content/Hooks/HookFixtures.cs b/tests/FluentCMS.Core.Tests/Content/Hooks/HookFixtures.cs new file mode 100644 index 000000000..033b95b17 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Hooks/HookFixtures.cs @@ -0,0 +1,48 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.Content.Validation; +using FluentCMS.Core.Tests.Content.Validation; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Tests.Content.Hooks; + +internal static class HookFixtures +{ + public static OperationContext Operation => ValidationFixtures.Operation; + + public static SchemaType ArticleType => ValidationFixtures.Article; + + public static HookPipeline Pipeline(Action register) + { + var services = new ServiceCollection(); + register(services); + return new HookPipeline(services.BuildServiceProvider()); + } + + public static ValueTask RunAsync( + HookPipeline pipeline, + HookKind kind, + ContentDocument entry, + ContentDocument? previous = null, + SchemaType? type = null) => + pipeline.RunAsync(kind, type ?? ArticleType, Operation, entry, previous, ContentChanges.Between(previous, entry), CancellationToken.None); + + // Maps the title and slug of an article, which is all the tests change. + public sealed class ArticleMapper : IHookEntryMapper + { + public object ToEntry(Type clrType, ContentDocument document) => new Article + { + Title = document.Get("title") ?? "", + Slug = document.Get("slug") ?? "", + }; + + public void CopyTo(object entry, ContentDocument document) + { + var article = (Article)entry; + document["title"] = article.Title; + document["slug"] = article.Slug; + } + } +} diff --git a/tests/FluentCMS.Core.Tests/Content/Hooks/HookPipelineTests.cs b/tests/FluentCMS.Core.Tests/Content/Hooks/HookPipelineTests.cs new file mode 100644 index 000000000..864807916 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Hooks/HookPipelineTests.cs @@ -0,0 +1,449 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Hooks; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Content.Hooks; + +public class HookPipelineTests +{ + private sealed class Log + { + public List Entries { get; } = []; + + public List> Contexts { get; } = []; + } + + private abstract class RecordingHook(Log log, string name) + { + protected ValueTask Record(HookContext context) + { + log.Entries.Add(name); + log.Contexts.Add(context); + return ValueTask.CompletedTask; + } + } + + [HookOrder(10)] + private sealed class Late(Log log) : RecordingHook(log, "late"), IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => Record(context); + } + + private sealed class Default(Log log) : RecordingHook(log, "default"), IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => Record(context); + } + + [HookOrder(-5)] + private sealed class Early(Log log) : RecordingHook(log, "early"), IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => Record(context); + } + + private sealed class PluginA(Log log) : RecordingHook(log, "A"), IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => Record(context); + } + + private sealed class PluginB(Log log) : RecordingHook(log, "B"), IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => Record(context); + } + + private sealed class RankByType(Dictionary ranks) : IHookTiebreaker + { + public int Rank(Type hookType) => ranks.GetValueOrDefault(hookType); + } + + private sealed class Rejecting(Log log) : RecordingHook(log, "rejecting"), IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Cancel("ACME0100", "Not today."); + return Record(context); + } + } + + private sealed class Throwing : IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => + throw new HookRejectedException("ACME0101", "Rejected directly."); + } + + private sealed class RejectingAfter : IAfterCreateHook + { + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Cancel("ACME0102", "Too late."); + return ValueTask.CompletedTask; + } + } + + private sealed class Mutating : IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["title"] = context.Entry.Get("title")!.ToUpperInvariant(); + context.Entry["addedByHook"] = true; + return ValueTask.CompletedTask; + } + } + + private sealed class SeesTitle(Log log) : IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) + { + log.Entries.Add(context.Entry.Get("title")!); + return ValueTask.CompletedTask; + } + } + + private sealed class CaptureUpdate(Log log) : IBeforeUpdateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + log.Contexts.Add(context); + return ValueTask.CompletedTask; + } + } + + private sealed class TypedRetitle(Log log) : IBeforeUpdateHook
+ { + public ValueTask BeforeUpdateAsync(HookContext
context, CancellationToken cancellationToken) + { + log.Entries.Add($"typed:{context.Previous?.Title}->{context.Entry.Title}"); + context.Entry.Title += " (typed)"; + context.Entry.Slug = "set-by-typed-hook"; + return ValueTask.CompletedTask; + } + } + + [HookOrder(-1)] + private sealed class EarlyTyped(Log log) : IBeforeUpdateHook
+ { + public ValueTask BeforeUpdateAsync(HookContext
context, CancellationToken cancellationToken) + { + log.Entries.Add("typed-early"); + return ValueTask.CompletedTask; + } + } + + private sealed class BaseClassHook(Log log) : IBeforeUpdateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + log.Entries.Add($"base:{context.Entry.GetType().Name}"); + return ValueTask.CompletedTask; + } + } + + private sealed class AllTypes(Log log) : IBeforeUpdateHook, IBeforeCreateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + log.Entries.Add($"doc:{context.Entry.Get("title")}"); + return ValueTask.CompletedTask; + } + + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) + { + log.Entries.Add("doc"); + return ValueTask.CompletedTask; + } + } + + private sealed class EveryKind(Log log) : + IBeforeValidateHook, + IBeforeCreateHook, IAfterCreateHook, + IBeforeUpdateHook, IAfterUpdateHook, + IBeforeDeleteHook, IAfterDeleteHook, + IBeforePublishHook, IAfterPublishHook, + IBeforeUnpublishHook, IAfterUnpublishHook, + IBeforeRestoreHook, IAfterRestoreHook + { + private ValueTask Note(HookKind kind) + { + log.Entries.Add(kind.ToString()); + return ValueTask.CompletedTask; + } + + public ValueTask BeforeValidateAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.BeforeValidate); + + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.BeforeCreate); + + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.AfterCreate); + + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.BeforeUpdate); + + public ValueTask AfterUpdateAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.AfterUpdate); + + public ValueTask BeforeDeleteAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.BeforeDelete); + + public ValueTask AfterDeleteAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.AfterDelete); + + public ValueTask BeforePublishAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.BeforePublish); + + public ValueTask AfterPublishAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.AfterPublish); + + public ValueTask BeforeUnpublishAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.BeforeUnpublish); + + public ValueTask AfterUnpublishAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.AfterUnpublish); + + public ValueTask BeforeRestoreAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.BeforeRestore); + + public ValueTask AfterRestoreAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.AfterRestore); + } + + private static ContentDocument Doc(string title = "Hello") => new() { ["title"] = title }; + + private static Action Create(Log log, params Type[] hooks) => services => + { + services.AddSingleton(log); + foreach (var hook in hooks) + { + services.AddSingleton(typeof(IBeforeCreateHook), hook); + } + }; + + [Fact] + public async Task AHookSeesTheOperationTheEntryThePreviousStateAndTheChangedPaths() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton, CaptureUpdate>(); + }); + var previous = new ContentDocument { ["title"] = "Old", ["seo"] = new Dictionary { ["metaTitle"] = "m" } }; + var entry = new ContentDocument { ["title"] = "New", ["seo"] = new Dictionary { ["metaTitle"] = "m2" } }; + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeUpdate, entry, previous); + + var context = log.Contexts.ShouldHaveSingleItem(); + context.Operation.ShouldBeSameAs(HookFixtures.Operation); + context.Entry.ShouldBeSameAs(entry); + context.Previous.ShouldBeSameAs(previous); + context.ChangedPaths.ShouldBe(["title", "seo.metaTitle"]); + context.IsCancelled.ShouldBeFalse(); + } + + [Fact] + public async Task ThereIsNoPreviousStateOnCreate() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(Create(log, typeof(Default))); + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeCreate, Doc()); + + var context = log.Contexts.ShouldHaveSingleItem(); + context.Previous.ShouldBeNull(); + context.ChangedPaths.ShouldBe(["title"]); + } + + [Fact] + public async Task EachKindRunsOnlyTheHooksOfThatKind() + { + foreach (var kind in Enum.GetValues()) + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton(); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + }); + + await HookFixtures.RunAsync(pipeline, kind, Doc()); + + log.Entries.ShouldBe([kind.ToString()]); + } + } + + [Fact] + public async Task HooksRunInHookOrderAndAHookWithoutTheAttributeHasOrderZero() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(Create(log, typeof(Late), typeof(Default), typeof(Early))); + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeCreate, Doc()); + + log.Entries.ShouldBe(["early", "default", "late"]); + } + + [Fact] + public async Task HooksOfEqualOrderRunInRegistrationOrderWithoutATiebreaker() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(Create(log, typeof(PluginB), typeof(PluginA))); + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeCreate, Doc()); + + log.Entries.ShouldBe(["B", "A"]); + } + + [Fact] + public async Task TheTiebreakerOrdersHooksOfEqualOrderOnly() + { + var log = new Log(); + var register = Create(log, typeof(PluginB), typeof(PluginA), typeof(Late), typeof(Early)); + var pipeline = HookFixtures.Pipeline(services => + { + register(services); + services.AddSingleton(new RankByType(new() { [typeof(PluginA)] = -1, [typeof(PluginB)] = 1, [typeof(Late)] = -100 })); + }); + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeCreate, Doc()); + + log.Entries.ShouldBe(["early", "A", "B", "late"]); + } + + [Fact] + public async Task ACancellingHookThrowsWithItsCodeAndMessageAndLaterHooksDoNotRun() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(Create(log, typeof(Early), typeof(Rejecting), typeof(Late))); + + var exception = await Should.ThrowAsync(HookFixtures.RunAsync(pipeline, HookKind.BeforeCreate, Doc()).AsTask()); + + exception.Code.ShouldBe("ACME0100"); + exception.Message.ShouldBe("Not today."); + log.Entries.ShouldBe(["early", "rejecting"]); + } + + [Fact] + public async Task AHookCanRejectByThrowingTheException() + { + var pipeline = HookFixtures.Pipeline(Create(new Log(), typeof(Throwing))); + + var exception = await Should.ThrowAsync(HookFixtures.RunAsync(pipeline, HookKind.BeforeCreate, Doc()).AsTask()); + + exception.Code.ShouldBe("ACME0101"); + } + + [Fact] + public async Task AnAfterHookCanCancelToo() + { + var pipeline = HookFixtures.Pipeline(services => services.AddSingleton, RejectingAfter>()); + + var exception = await Should.ThrowAsync(HookFixtures.RunAsync(pipeline, HookKind.AfterCreate, Doc()).AsTask()); + + exception.Code.ShouldBe("ACME0102"); + } + + [Fact] + public async Task AMutatingBeforeHookChangeReachesTheCallerAndTheNextHook() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(Create(log, typeof(Mutating), typeof(SeesTitle))); + var entry = Doc("hello"); + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeCreate, entry); + + entry.Get("title").ShouldBe("HELLO"); + entry.Get("addedByHook").ShouldBeTrue(); + log.Entries.ShouldBe(["HELLO"]); + } + + [Fact] + public async Task ATypedHookGetsTheMappedEntryAndItsChangeReachesTheDocument() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton(); + services.AddSingleton, TypedRetitle>(); + services.AddSingleton, AllTypes>(); + }); + var entry = Doc("New"); + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeUpdate, entry, Doc("Old")); + + entry.Get("title").ShouldBe("New (typed)"); + entry.Get("slug").ShouldBe("set-by-typed-hook"); + log.Entries.ShouldBe(["doc:New", "typed:Old->New"]); + } + + [Fact] + public async Task AllTypesAndTypedHooksFormOneSequenceByOrderWithAllTypesFirstOnATie() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton(); + services.AddSingleton, TypedRetitle>(); + services.AddSingleton, EarlyTyped>(); + services.AddSingleton, BaseClassHook>(); + services.AddSingleton, AllTypes>(); + }); + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeUpdate, Doc("T"), Doc("T")); + + log.Entries.ShouldBe(["typed-early", "doc:T", "base:Article", "typed:T->T"]); + } + + [Fact] + public async Task ATypedHookRunsOnlyForItsTypeAndAnAllTypesHookRunsForEveryType() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton(); + services.AddSingleton, EarlyTyped>(); + services.AddSingleton, AllTypes>(); + }); + var types = SampleSchema.FromClasses().Build().ContentTypes; + + types.Count.ShouldBeGreaterThan(2); + foreach (var type in types) + { + await HookFixtures.RunAsync(pipeline, HookKind.BeforeUpdate, Doc(type.Name), type: type); + } + + log.Entries.Count(entry => entry.StartsWith("doc:", StringComparison.Ordinal)).ShouldBe(types.Count); + log.Entries.Count(entry => entry == "typed-early").ShouldBe(1); + } + + [Fact] + public async Task ATypedHookWithoutAMapperFails() + { + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(new Log()); + services.AddSingleton, EarlyTyped>(); + }); + + await Should.ThrowAsync(HookFixtures.RunAsync(pipeline, HookKind.BeforeUpdate, Doc()).AsTask()); + } + + [Fact] + public async Task WithoutHooksNothingHappens() + { + var pipeline = HookFixtures.Pipeline(_ => { }); + var entry = Doc(); + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeCreate, entry); + + entry.Count.ShouldBe(1); + } +} diff --git a/tests/FluentCMS.Core.Tests/Content/Hooks/ValidationReportTests.cs b/tests/FluentCMS.Core.Tests/Content/Hooks/ValidationReportTests.cs new file mode 100644 index 000000000..b255b0605 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Hooks/ValidationReportTests.cs @@ -0,0 +1,66 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Validation; +using FluentCMS.Core.Tests.Content.Validation; +using FluentCMS.Core.Tests.Schema.Samples; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Content.Hooks; + +public class ValidationReportTests +{ + private sealed class SlugValidator : IFieldValidator + { + public ValueTask ValidateAsync(string? value, ValidationContext context, CancellationToken cancellationToken) + { + if (context.Field?.Name == "slug" && value == "admin") + { + context.AddError("ACME0001", "That slug is reserved."); + } + + return ValueTask.CompletedTask; + } + } + + private sealed class ArticleValidator : IEntryValidator
+ { + public ValueTask ValidateAsync(Article entry, ValidationContext context, CancellationToken cancellationToken) + { + context.AddError("", "ACME0002", "The entry is not ready."); + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task OneResultListsEveryFailingFieldAcrossDeclarativeAndCustomValidators() + { + var values = new Dictionary + { + ["title"] = " ", + ["slug"] = "admin", + ["category"] = ArticleCategory.Video, + ["seo"] = new Dictionary { ["metaTitle"] = new string('x', 500) }, + }; + + var result = await ValidationFixtures.ValidateAsync( + ValidationFixtures.Blog, + "article", + values, + ValidationMode.Publish, + entry: new Article(), + register: services => + { + services.AddSingleton, SlugValidator>(); + services.AddSingleton, ArticleValidator>(); + }); + + var errors = ValidationFixtures.Describe(result); + errors.ShouldContain("title|FCMS2101"); + errors.ShouldContain("videoUrl|FCMS2110"); + errors.ShouldContain("slug|ACME0001"); + errors.ShouldContain("|ACME0002"); + errors.Count(error => error.StartsWith("seo.", StringComparison.Ordinal)).ShouldBeGreaterThanOrEqualTo(1); + errors.Distinct().Count().ShouldBe(errors.Length); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/HookTransactionTests.cs b/tests/FluentCMS.Data.Tests/Content/HookTransactionTests.cs new file mode 100644 index 000000000..bd6a502d5 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/HookTransactionTests.cs @@ -0,0 +1,179 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Data.Tests.Storage; +using FluentCMS.Hooks; +using FluentCMS.Schema; +using FluentCMS.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Data.Tests.Content; + +// Plays the part of the content service: begin a transaction, run the before hooks, write, run the after hooks, commit. +public class HookTransactionTests +{ + private const string Article = "cms_article"; + + private static readonly SchemaModel Model = SampleSchema.FromClasses().Build(); + + private static readonly OperationContext Operation = new( + new Principal(PrincipalKind.AdminUser, Guid.Parse("00000000-0000-0000-0000-000000000001"), "Amir"), + Origin.Admin, + new SiteId(Guid.Parse("00000000-0000-0000-0000-0000000000a1")), + "en", + null, + "corr-1", + new DateTimeOffset(2026, 11, 2, 9, 0, 0, TimeSpan.Zero)); + + private sealed class Calls + { + public List Entries { get; } = []; + } + + private sealed class RejectBeforeCreate : IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Cancel("ACME0100", "Not today."); + return ValueTask.CompletedTask; + } + } + + private sealed class SetCategory : IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["category"] = "video"; + return ValueTask.CompletedTask; + } + } + + private sealed class FailAfterCreate : IAfterCreateHook + { + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) => + throw new InvalidOperationException("A plugin table is gone."); + } + + private sealed class CancelAfterCreate : IAfterCreateHook + { + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Cancel("ACME0101", "Rolled back."); + return ValueTask.CompletedTask; + } + } + + private sealed class RecordWrites(Calls calls, StorageDatabase database) : IAfterCreateHook + { + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + // A second context on the same connection joins the transaction, so it sees the row written before the hook. + using var reader = database.CreateContext(); + calls.Entries.Add($"after:{reader.Rows(Article).Count()}"); + return ValueTask.CompletedTask; + } + } + + private static HookPipeline Pipeline(Action register) + { + var services = new ServiceCollection(); + register(services); + return new HookPipeline(services.BuildServiceProvider()); + } + + private static async Task CreateAsync(StorageDatabase database, HookPipeline pipeline, ContentDocument entry, Guid documentId) + { + var type = Model.FindContentType("article")!; + using var context = database.CreateContext(); + await using var transaction = await context.Database.BeginTransactionAsync(); + + await pipeline.RunAsync(HookKind.BeforeCreate, type, Operation, entry, null, ContentChanges.Between(null, entry), CancellationToken.None); + + context.Rows(Article).Add(new Dictionary + { + ["document_id"] = documentId, + ["site_id"] = Operation.Site.Value, + ["state"] = ContentState.Draft, + ["category"] = entry.Get("category")!, + }); + await context.SaveChangesAsync(); + + await pipeline.RunAsync(HookKind.AfterCreate, type, Operation, entry, null, ContentChanges.Between(null, entry), CancellationToken.None); + + await transaction.CommitAsync(); + } + + private static List> Stored(StorageDatabase database) + { + using var reader = database.CreateContext(); + return [.. reader.Rows(Article)]; + } + + [Fact] + public async Task ACancellingBeforeHookLeavesNoRowAndSurfacesItsCodeAndMessage() + { + using var database = new StorageDatabase(Model); + database.CreateCreatedContext().Dispose(); + var pipeline = Pipeline(services => services.AddSingleton, RejectBeforeCreate>()); + + var exception = await Should.ThrowAsync( + CreateAsync(database, pipeline, new ContentDocument { ["category"] = "news" }, Guid.CreateVersion7())); + + exception.Code.ShouldBe("ACME0100"); + exception.Message.ShouldBe("Not today."); + Stored(database).ShouldBeEmpty(); + } + + [Fact] + public async Task AFailingAfterHookRollsTheWriteBack() + { + using var database = new StorageDatabase(Model); + database.CreateCreatedContext().Dispose(); + var pipeline = Pipeline(services => services.AddSingleton, FailAfterCreate>()); + + await Should.ThrowAsync( + CreateAsync(database, pipeline, new ContentDocument { ["category"] = "news" }, Guid.CreateVersion7())); + + Stored(database).ShouldBeEmpty(); + } + + [Fact] + public async Task ACancellingAfterHookRollsTheWriteBack() + { + using var database = new StorageDatabase(Model); + database.CreateCreatedContext().Dispose(); + var pipeline = Pipeline(services => services.AddSingleton, CancelAfterCreate>()); + + var exception = await Should.ThrowAsync( + CreateAsync(database, pipeline, new ContentDocument { ["category"] = "news" }, Guid.CreateVersion7())); + + exception.Code.ShouldBe("ACME0101"); + Stored(database).ShouldBeEmpty(); + } + + [Fact] + public async Task WithoutRejectionTheRowIsCommittedWithWhatTheBeforeHookSet() + { + using var database = new StorageDatabase(Model); + database.CreateCreatedContext().Dispose(); + var calls = new Calls(); + var pipeline = Pipeline(services => + { + services.AddSingleton(calls); + services.AddSingleton, SetCategory>(); + services.AddSingleton>(new RecordWrites(calls, database)); + }); + var documentId = Guid.CreateVersion7(); + + await CreateAsync(database, pipeline, new ContentDocument { ["category"] = "news" }, documentId); + + var row = Stored(database).ShouldHaveSingleItem(); + row["document_id"].ShouldBe(documentId); + row["category"].ShouldBe("video"); + calls.Entries.ShouldBe(["after:1"]); + } +} From 9025ef6554ce7d8b0be5ca616fcc4af98bafdad0 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 10:17:52 -0500 Subject: [PATCH 2/2] Fix change detection for JSON values, refresh changed paths per hook, copy the entry for after hooks, narrow Get conversions --- .../Content/Hooks/ContentChanges.cs | 55 +++++- .../Content/Hooks/ContentCopy.cs | 31 ++++ .../Content/Hooks/HookPipeline.cs | 30 +++- src/FluentCMS.Sdk/Content/ContentDocument.cs | 38 +++-- .../Content/Hooks/ContentChangesTests.cs | 95 +++++++++++ .../Content/Hooks/HookFixtures.cs | 2 +- .../Content/Hooks/HookPipelineTests.cs | 159 ++++++++++++++++++ .../Content/HookTransactionTests.cs | 4 +- .../Content/ContentDocumentTests.cs | 69 ++++++++ 9 files changed, 457 insertions(+), 26 deletions(-) create mode 100644 src/FluentCMS.Core/Content/Hooks/ContentCopy.cs create mode 100644 tests/FluentCMS.Sdk.Tests/Content/ContentDocumentTests.cs diff --git a/src/FluentCMS.Core/Content/Hooks/ContentChanges.cs b/src/FluentCMS.Core/Content/Hooks/ContentChanges.cs index 12fba151a..4d6de1aff 100644 --- a/src/FluentCMS.Core/Content/Hooks/ContentChanges.cs +++ b/src/FluentCMS.Core/Content/Hooks/ContentChanges.cs @@ -1,5 +1,8 @@ using System.Collections; +using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Content.Validation; +using FluentCMS.Schema; namespace FluentCMS.Core.Content.Hooks; @@ -9,8 +12,10 @@ public static class ContentChanges // The paths of the fields whose values differ, in the form validation reports: seo.metaTitle, sections[2].heading, tags[1]. // A missing field counts as null, so a create (no previous state) lists the fields that hold a value. Lists of the same - // length are compared by index; a list whose length changed is reported as the list itself. Values are API-shaped: - // dictionaries for components and blocks, and scalars compared as a schema stores them. + // length are compared by index; a list whose length changed is reported as the list itself. Components and blocks are + // compared field by field whether they are dictionaries, JSON objects or instances of a component or block class; + // JSON values compare as the scalars they hold. Every other value compares by value as a schema stores it (a Ref by its + // id) or, failing that, by Equals (RichText, MediaRef). public static IReadOnlyList Between(IReadOnlyDictionary? previous, IReadOnlyDictionary current) { ArgumentNullException.ThrowIfNull(current); @@ -39,7 +44,14 @@ private static void CompareMaps(IReadOnlyDictionary previous, I private static void CompareValues(object? before, object? after, string path, List paths) { - if (AsMap(before) is { } beforeMap && AsMap(after) is { } afterMap) + before = Plain(before); + after = Plain(after); + + if (IsInstance(before) && IsInstance(after) && before!.GetType() != after!.GetType()) + { + paths.Add(path); + } + else if (AsMap(before) is { } beforeMap && AsMap(after) is { } afterMap) { CompareMaps(beforeMap, afterMap, path, paths); } @@ -64,10 +76,47 @@ private static void CompareValues(object? before, object? after, string path, Li private static string Join(string prefix, string name) => prefix.Length == 0 ? name : $"{prefix}.{name}"; + // JSON as plain values: dictionaries, lists, strings, booleans, longs and decimals. + private static object? Plain(object? value) => value switch + { + JsonElement { ValueKind: JsonValueKind.Undefined } => null, + JsonElement element => Plain(JsonNode.Parse(element.GetRawText())), + JsonObject node => node.ToDictionary(property => property.Key, property => Plain(property.Value), StringComparer.Ordinal), + JsonArray node => node.Select(Plain).ToList(), + JsonValue node => PlainScalar(node), + _ => value, + }; + + private static object? PlainScalar(JsonValue node) + { + switch (node.GetValueKind()) + { + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Number: + if (node.TryGetValue(out long whole)) + { + return whole; + } + + return node.TryGetValue(out decimal number) ? number : node.ToJsonString(); + case JsonValueKind.String: + return node.TryGetValue(out string? text) ? text : node.ToJsonString(); + default: + return null; + } + } + + private static bool IsInstance(object? value) => + value is not null && (Attribute.IsDefined(value.GetType(), typeof(BlockAttribute)) || Attribute.IsDefined(value.GetType(), typeof(ComponentAttribute))); + private static IReadOnlyDictionary? AsMap(object? value) => value switch { IReadOnlyDictionary map => map, IDictionary map => new Dictionary(map), + _ when IsInstance(value) => FieldValue.ReadInstance(value), _ => null, }; diff --git a/src/FluentCMS.Core/Content/Hooks/ContentCopy.cs b/src/FluentCMS.Core/Content/Hooks/ContentCopy.cs new file mode 100644 index 000000000..b64f2b7c1 --- /dev/null +++ b/src/FluentCMS.Core/Content/Hooks/ContentCopy.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; + +namespace FluentCMS.Core.Content.Hooks; + +// A copy of an entry that no hook can use to reach the original: documents, dictionaries, object lists and JSON are copied +// all the way down. Immutable values (strings, numbers, references, rich text, block lists) are shared. +internal static class ContentCopy +{ + public static ContentDocument Of(ContentDocument document) + { + var copy = new ContentDocument(); + foreach (var (name, value) in document) + { + copy[name] = Value(value); + } + + return copy; + } + + private static object? Value(object? value) => value switch + { + ContentDocument document => Of(document), + JsonNode node => node.DeepClone(), + IDictionary map => map.ToDictionary(item => item.Key, item => Value(item.Value), StringComparer.Ordinal), + IReadOnlyDictionary map => map.ToDictionary(item => item.Key, item => Value(item.Value), StringComparer.Ordinal), + List list => list.ConvertAll(Value), + object?[] array => Array.ConvertAll(array, Value), + _ => value, + }; +} diff --git a/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs b/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs index 175d7709e..5f352f08c 100644 --- a/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs +++ b/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs @@ -10,6 +10,9 @@ namespace FluentCMS.Core.Content.Hooks; // and for its base classes, form one sequence ordered by [HookOrder], then by IHookTiebreaker rank. Hooks that still tie keep // the order of resolution: all-types hooks first, then base classes before derived ones, each in registration order. // The first hook that cancels throws HookRejectedException and the hooks after it do not run. +// The changed paths are those between previous and entry, computed again before each before hook so that a hook sees the +// changes of the hooks before it; an operation that changes no field passes the stored state as previous. +// Only before hooks change the entry: an after hook works on a copy. public sealed class HookPipeline(IServiceProvider services) { private static readonly ConcurrentDictionary Orders = new(); @@ -20,13 +23,11 @@ public async ValueTask RunAsync( OperationContext operation, ContentDocument entry, ContentDocument? previous, - IReadOnlyList changedPaths, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(operation); ArgumentNullException.ThrowIfNull(entry); - ArgumentNullException.ThrowIfNull(changedPaths); var hooks = Resolve(kind, type); if (hooks.Count == 0) @@ -34,13 +35,26 @@ public async ValueTask RunAsync( return; } - var session = new Session(type.ClrType, operation, entry, previous, changedPaths, services.GetService(typeof(IHookEntryMapper)) as IHookEntryMapper); var mutable = kind is HookKind.BeforeValidate or HookKind.BeforeCreate or HookKind.BeforeUpdate or HookKind.BeforeDelete or HookKind.BeforePublish or HookKind.BeforeUnpublish or HookKind.BeforeRestore; - + var session = new Session( + type.ClrType, + operation, + mutable ? entry : ContentCopy.Of(entry), + previous, + services.GetService(typeof(IHookEntryMapper)) as IHookEntryMapper); + + var first = true; foreach (var hook in hooks) { cancellationToken.ThrowIfCancellationRequested(); + if (mutable && !first) + { + session.RefreshChangedPaths(); + } + + first = false; + await hook.InvokeAsync(session, mutable, cancellationToken); } } @@ -81,17 +95,19 @@ private sealed class Session( OperationContext operation, ContentDocument entry, ContentDocument? previous, - IReadOnlyList changedPaths, IHookEntryMapper? mapper) { private object? _previousEntry; + private IReadOnlyList _changedPaths = ContentChanges.Between(previous, entry); + + public void RefreshChangedPaths() => _changedPaths = ContentChanges.Between(previous, entry); public HookContext CreateContext(out object? mapped) { if (typeof(T) == typeof(ContentDocument)) { mapped = null; - return new HookContext(operation, (T)(object)entry, (T?)(object?)previous, changedPaths); + return new HookContext(operation, (T)(object)entry, (T?)(object?)previous, _changedPaths); } var map = RequireMapper(); @@ -101,7 +117,7 @@ public HookContext CreateContext(out object? mapped) _previousEntry ??= map.ToEntry(clrType!, previous); } - return new HookContext(operation, (T)mapped, (T?)_previousEntry, changedPaths); + return new HookContext(operation, (T)mapped, (T?)_previousEntry, _changedPaths); } public void WriteBack(object mapped) => RequireMapper().CopyTo(mapped, entry); diff --git a/src/FluentCMS.Sdk/Content/ContentDocument.cs b/src/FluentCMS.Sdk/Content/ContentDocument.cs index 5a70f33f4..bc316958f 100644 --- a/src/FluentCMS.Sdk/Content/ContentDocument.cs +++ b/src/FluentCMS.Sdk/Content/ContentDocument.cs @@ -56,18 +56,18 @@ public object? this[string name] } /// Gets a value as a type. - /// The type to read the value as. A number converts to another numeric type. + /// The type to read the value as. The value must already be a , except that a number converts to another numeric type when it fits. /// The API field name. /// The value, or the default of when the field is missing or null. - /// The value does not convert to . + /// The value is not a and is not a number that fits it. public T? Get(string name) => TryGet(name, out var value) ? value : default; /// Tries to get a value as a type. - /// The type to read the value as. A number converts to another numeric type. + /// The type to read the value as. The value must already be a , except that a number converts to another numeric type when it fits. /// The API field name. /// The value, or the default of when the field is missing or null. /// when the field exists and holds a non-null value. - /// The value does not convert to . + /// The value is not a and is not a number that fits it. public bool TryGet(string name, out T? value) { if (!_values.TryGetValue(name, out var raw) || raw is null) @@ -130,18 +130,30 @@ public bool TryGet(string name, out T? value) } var target = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - if (raw is IConvertible && typeof(IConvertible).IsAssignableFrom(target) && !target.IsEnum) + if (!IsNumber(raw.GetType()) || !IsNumber(target)) { - try - { - return (T)System.Convert.ChangeType(raw, target, CultureInfo.InvariantCulture); - } - catch (Exception exception) when (exception is FormatException or OverflowException) + throw new InvalidCastException($"A value of type {raw.GetType()} is not a {typeof(T)}."); + } + + try + { + var converted = System.Convert.ChangeType(raw, target, CultureInfo.InvariantCulture); + if (raw is float or double or decimal && target != typeof(float) && target != typeof(double) && target != typeof(decimal) + && System.Convert.ToDecimal(raw, CultureInfo.InvariantCulture) != System.Convert.ToDecimal(converted, CultureInfo.InvariantCulture)) { - throw new InvalidCastException($"The value {raw} does not convert to {typeof(T)}.", exception); + throw new InvalidCastException($"The value {raw} does not fit {typeof(T)}."); } - } - throw new InvalidCastException($"A value of type {raw.GetType()} does not convert to {typeof(T)}."); + return (T)converted; + } + catch (OverflowException exception) + { + throw new InvalidCastException($"The value {raw} does not fit {typeof(T)}.", exception); + } } + + private static bool IsNumber(Type type) => + Type.GetTypeCode(type) is TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32 + or TypeCode.Int64 or TypeCode.UInt64 or TypeCode.Single or TypeCode.Double or TypeCode.Decimal + && !type.IsEnum; } diff --git a/tests/FluentCMS.Core.Tests/Content/Hooks/ContentChangesTests.cs b/tests/FluentCMS.Core.Tests/Content/Hooks/ContentChangesTests.cs index adae3c6de..311c6ab50 100644 --- a/tests/FluentCMS.Core.Tests/Content/Hooks/ContentChangesTests.cs +++ b/tests/FluentCMS.Core.Tests/Content/Hooks/ContentChangesTests.cs @@ -1,5 +1,9 @@ using FluentCMS.Content; +using System.Text.Json.Nodes; using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Media; +using FluentCMS.Schema; using Shouldly; using Xunit; @@ -93,4 +97,95 @@ public void ADocumentIsComparedLikeAnyDictionary() ContentChanges.Between(previous, current).ShouldBe(["seo.metaTitle"]); } + + [Fact] + public void EqualButDistinctJsonObjectsHaveNoChanges() + { + var previous = Map(("seo", JsonNode.Parse("""{"metaTitle":"m","tags":["a","b"],"n":{"x":1}}""")), ("flag", JsonValue.Create(true))); + var current = Map(("seo", JsonNode.Parse("""{"metaTitle":"m","tags":["a","b"],"n":{"x":1}}""")), ("flag", JsonValue.Create(true))); + + ContentChanges.Between(previous, current).ShouldBeEmpty(); + } + + [Fact] + public void AChangeInsideJsonIsReportedAtItsPath() + { + var previous = Map(("seo", JsonNode.Parse("""{"metaTitle":"m","tags":["a","b"],"n":{"x":1}}""")), ("count", JsonValue.Create(1))); + var current = Map(("seo", JsonNode.Parse("""{"metaTitle":"n","tags":["a","c"],"n":{"x":2}}""")), ("count", JsonValue.Create(2))); + + ContentChanges.Between(previous, current).ShouldBe(["seo.metaTitle", "seo.tags[1]", "seo.n.x", "count"]); + } + + [Fact] + public void AJsonArrayWhoseLengthChangedIsReportedAsTheArray() + { + var previous = Map(("sections", JsonNode.Parse("""[{"type":"hero"}]"""))); + var current = Map(("sections", JsonNode.Parse("""[{"type":"hero"},{"type":"quote"}]"""))); + + ContentChanges.Between(previous, current).ShouldBe(["sections"]); + } + + [Fact] + public void JsonAndDictionariesCompareAsTheSameValue() + { + var previous = Map(("seo", JsonNode.Parse("""{"metaTitle":"m","count":3}"""))); + var current = Map(("seo", Map(("metaTitle", "m"), ("count", 3)))); + + ContentChanges.Between(previous, current).ShouldBeEmpty(); + } + + [Fact] + public void JsonNullAndAMissingValueAreTheSame() + { + var previous = Map(("seo", JsonNode.Parse("""{"metaTitle":null}"""))); + var current = Map(("seo", JsonNode.Parse("""{}"""))); + + ContentChanges.Between(previous, current).ShouldBeEmpty(); + } + + [Fact] + public void EqualButDistinctSdkValuesHaveNoChanges() + { + var id = Guid.CreateVersion7(); + var other = Guid.CreateVersion7(); + var previous = Map( + ("body", new RichText("""{"type":"doc"}""")), + ("author", new Ref(id)), + ("tags", new RefList([new Ref(id), new Ref(other)])), + ("hero", new MediaRef(id)), + ("images", new MediaRefList([new MediaRef(id)])), + ("sections", new BlockList([new HeroBlock { Heading = "h", Image = new MediaRef(id) }, new QuoteBlock { Text = "t" }]))); + var current = Map( + ("body", new RichText("""{"type":"doc"}""")), + ("author", new Ref(id)), + ("tags", new RefList([new Ref(id), new Ref(other)])), + ("hero", new MediaRef(id)), + ("images", new MediaRefList([new MediaRef(id)])), + ("sections", new BlockList([new HeroBlock { Heading = "h", Image = new MediaRef(id) }, new QuoteBlock { Text = "t" }]))); + + ContentChanges.Between(previous, current).ShouldBeEmpty(); + } + + [Fact] + public void ChangedSdkValuesAreReported() + { + var id = Guid.CreateVersion7(); + var other = Guid.CreateVersion7(); + var previous = Map( + ("body", new RichText("""{"type":"doc"}""")), + ("author", new Ref(id)), + ("tags", new RefList([new Ref(id), new Ref(other)])), + ("hero", new MediaRef(id)), + ("images", new MediaRefList([new MediaRef(id)])), + ("sections", new BlockList([new HeroBlock { Heading = "h" }, new QuoteBlock { Text = "t" }]))); + var current = Map( + ("body", new RichText("""{"type":"doc","content":[]}""")), + ("author", new Ref(other)), + ("tags", new RefList([new Ref(id), new Ref(id)])), + ("hero", new MediaRef(other)), + ("images", new MediaRefList([new MediaRef(id), new MediaRef(other)])), + ("sections", new BlockList([new HeroBlock { Heading = "changed" }, new GalleryBlock()]))); + + ContentChanges.Between(previous, current).ShouldBe(["body", "author", "tags[1]", "hero", "images", "sections[0].heading", "sections[1]"]); + } } diff --git a/tests/FluentCMS.Core.Tests/Content/Hooks/HookFixtures.cs b/tests/FluentCMS.Core.Tests/Content/Hooks/HookFixtures.cs index 033b95b17..1389f73fe 100644 --- a/tests/FluentCMS.Core.Tests/Content/Hooks/HookFixtures.cs +++ b/tests/FluentCMS.Core.Tests/Content/Hooks/HookFixtures.cs @@ -27,7 +27,7 @@ public static ValueTask RunAsync( ContentDocument entry, ContentDocument? previous = null, SchemaType? type = null) => - pipeline.RunAsync(kind, type ?? ArticleType, Operation, entry, previous, ContentChanges.Between(previous, entry), CancellationToken.None); + pipeline.RunAsync(kind, type ?? ArticleType, Operation, entry, previous, CancellationToken.None); // Maps the title and slug of an article, which is all the tests change. public sealed class ArticleMapper : IHookEntryMapper diff --git a/tests/FluentCMS.Core.Tests/Content/Hooks/HookPipelineTests.cs b/tests/FluentCMS.Core.Tests/Content/Hooks/HookPipelineTests.cs index 864807916..7c6a82bb1 100644 --- a/tests/FluentCMS.Core.Tests/Content/Hooks/HookPipelineTests.cs +++ b/tests/FluentCMS.Core.Tests/Content/Hooks/HookPipelineTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using FluentCMS.Content; using FluentCMS.Core.Content.Hooks; using FluentCMS.Core.Tests.Schema; @@ -200,6 +201,65 @@ private ValueTask Note(HookKind kind) public ValueTask AfterRestoreAsync(HookContext context, CancellationToken cancellationToken) => Note(HookKind.AfterRestore); } + [HookOrder(-1)] + private sealed class SlugSetter : IBeforeUpdateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["slug"] = "new-slug"; + return ValueTask.CompletedTask; + } + } + + [HookOrder(1)] + private sealed class LateCapture(Log log) : IBeforeUpdateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + log.Contexts.Add(context); + return ValueTask.CompletedTask; + } + } + + private sealed class PublishCapture(Log log) : IBeforePublishHook, IAfterPublishHook + { + public ValueTask BeforePublishAsync(HookContext context, CancellationToken cancellationToken) + { + log.Contexts.Add(context); + return ValueTask.CompletedTask; + } + + public ValueTask AfterPublishAsync(HookContext context, CancellationToken cancellationToken) + { + log.Contexts.Add(context); + return ValueTask.CompletedTask; + } + } + + private sealed class MutatingAfter(Log log) : IAfterCreateHook + { + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + log.Entries.Add(context.Entry.Get("title")!); + context.Entry["title"] = "changed after the write"; + context.Entry["added"] = true; + ((Dictionary)context.Entry["seo"]!)["metaTitle"] = "changed"; + ((List)context.Entry["tags"]!).Add("c"); + ((JsonObject)context.Entry["extra"]!)["x"] = 2; + return ValueTask.CompletedTask; + } + } + + private sealed class TypedAfter(Log log) : IAfterUpdateHook
+ { + public ValueTask AfterUpdateAsync(HookContext
context, CancellationToken cancellationToken) + { + log.Entries.Add($"after:{context.Entry.Title}"); + context.Entry.Title = "changed after the write"; + return ValueTask.CompletedTask; + } + } + private static ContentDocument Doc(string title = "Hello") => new() { ["title"] = title }; private static Action Create(Log log, params Type[] hooks) => services => @@ -436,6 +496,105 @@ public async Task ATypedHookWithoutAMapperFails() await Should.ThrowAsync(HookFixtures.RunAsync(pipeline, HookKind.BeforeUpdate, Doc()).AsTask()); } + [Fact] + public async Task ChangedPathsFollowTheChangesOfEarlierBeforeHooks() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton, SlugSetter>(); + services.AddSingleton, CaptureUpdate>(); + services.AddSingleton, LateCapture>(); + }); + var previous = new ContentDocument { ["title"] = "Old", ["slug"] = "old" }; + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeUpdate, new ContentDocument { ["title"] = "New", ["slug"] = "old" }, previous); + + log.Contexts.Select(context => context.ChangedPaths.ToArray()).ShouldBe([["title", "slug"], ["title", "slug"]]); + } + + [Fact] + public async Task ChangedPathsSeeWhatATypedHookChanged() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton(); + services.AddSingleton, TypedRetitle>(); + services.AddSingleton, LateCapture>(); + }); + var previous = new ContentDocument { ["title"] = "Same", ["slug"] = "same" }; + + await HookFixtures.RunAsync(pipeline, HookKind.BeforeUpdate, new ContentDocument { ["title"] = "Same", ["slug"] = "same" }, previous); + + log.Contexts.ShouldHaveSingleItem().ChangedPaths.ShouldBe(["title", "slug"]); + } + + [Fact] + public async Task AnOperationWithoutFieldChangesHasNoChangedPaths() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton, PublishCapture>(); + services.AddSingleton, PublishCapture>(); + }); + var stored = new ContentDocument { ["title"] = "Same", ["seo"] = new Dictionary { ["metaTitle"] = "m" } }; + var copy = new ContentDocument { ["title"] = "Same", ["seo"] = new Dictionary { ["metaTitle"] = "m" } }; + + await HookFixtures.RunAsync(pipeline, HookKind.BeforePublish, stored, copy); + await HookFixtures.RunAsync(pipeline, HookKind.AfterPublish, stored, copy); + + log.Contexts.Count.ShouldBe(2); + log.Contexts.ShouldAllBe(context => context.ChangedPaths.Count == 0); + } + + [Fact] + public async Task AnAfterHookWorksOnACopyThatCannotReachTheCallersEntry() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton, MutatingAfter>(); + }); + var seo = new Dictionary { ["metaTitle"] = "m" }; + var tags = new List { "a", "b" }; + var extra = JsonNode.Parse("""{"x":1}""")!; + var entry = new ContentDocument { ["title"] = "stored", ["seo"] = seo, ["tags"] = tags, ["extra"] = extra }; + + await HookFixtures.RunAsync(pipeline, HookKind.AfterCreate, entry); + + log.Entries.ShouldBe(["stored"]); + entry.Keys.ShouldBe(["title", "seo", "tags", "extra"]); + entry.Get("title").ShouldBe("stored"); + seo["metaTitle"].ShouldBe("m"); + tags.ShouldBe(["a", "b"]); + extra["x"]!.GetValue().ShouldBe(1); + } + + [Fact] + public async Task ATypedAfterHookCannotChangeTheCallersEntryEither() + { + var log = new Log(); + var pipeline = HookFixtures.Pipeline(services => + { + services.AddSingleton(log); + services.AddSingleton(); + services.AddSingleton, TypedAfter>(); + }); + var entry = Doc("stored"); + + await HookFixtures.RunAsync(pipeline, HookKind.AfterUpdate, entry, Doc("old")); + + log.Entries.ShouldBe(["after:stored"]); + entry.Get("title").ShouldBe("stored"); + entry.ContainsKey("slug").ShouldBeFalse(); + } + [Fact] public async Task WithoutHooksNothingHappens() { diff --git a/tests/FluentCMS.Data.Tests/Content/HookTransactionTests.cs b/tests/FluentCMS.Data.Tests/Content/HookTransactionTests.cs index bd6a502d5..b781222fd 100644 --- a/tests/FluentCMS.Data.Tests/Content/HookTransactionTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/HookTransactionTests.cs @@ -91,7 +91,7 @@ private static async Task CreateAsync(StorageDatabase database, HookPipeline pip using var context = database.CreateContext(); await using var transaction = await context.Database.BeginTransactionAsync(); - await pipeline.RunAsync(HookKind.BeforeCreate, type, Operation, entry, null, ContentChanges.Between(null, entry), CancellationToken.None); + await pipeline.RunAsync(HookKind.BeforeCreate, type, Operation, entry, null, CancellationToken.None); context.Rows(Article).Add(new Dictionary { @@ -102,7 +102,7 @@ private static async Task CreateAsync(StorageDatabase database, HookPipeline pip }); await context.SaveChangesAsync(); - await pipeline.RunAsync(HookKind.AfterCreate, type, Operation, entry, null, ContentChanges.Between(null, entry), CancellationToken.None); + await pipeline.RunAsync(HookKind.AfterCreate, type, Operation, entry, null, CancellationToken.None); await transaction.CommitAsync(); } diff --git a/tests/FluentCMS.Sdk.Tests/Content/ContentDocumentTests.cs b/tests/FluentCMS.Sdk.Tests/Content/ContentDocumentTests.cs new file mode 100644 index 000000000..7916eaf9b --- /dev/null +++ b/tests/FluentCMS.Sdk.Tests/Content/ContentDocumentTests.cs @@ -0,0 +1,69 @@ +using FluentCMS.Content; +using Shouldly; +using Xunit; + +namespace FluentCMS.Sdk.Tests.Content; + +public class ContentDocumentTests +{ + [Fact] + public void ValuesKeepTheOrderTheyWereAddedIn() + { + var document = new ContentDocument { ["b"] = 1, ["a"] = 2 }; + document["c"] = 3; + document["b"] = 4; + + document.Keys.ShouldBe(["b", "a", "c"]); + document["b"].ShouldBe(4); + document.Count.ShouldBe(3); + } + + [Fact] + public void NamesAreCaseSensitive() + { + var document = new ContentDocument { ["title"] = "a" }; + + document.ContainsKey("Title").ShouldBeFalse(); + document.TryGetValue("title", out var value).ShouldBeTrue(); + value.ShouldBe("a"); + } + + [Fact] + public void GetReturnsTheValueAsItsOwnTypeOrTheDefault() + { + var document = new ContentDocument { ["title"] = "a", ["nothing"] = null }; + + document.Get("title").ShouldBe("a"); + document.Get("nothing").ShouldBeNull(); + document.Get("missing").ShouldBeNull(); + document.Get("missing").ShouldBe(0); + document.TryGet("title", out var title).ShouldBeTrue(); + title.ShouldBe("a"); + document.TryGet("nothing", out _).ShouldBeFalse(); + } + + [Fact] + public void ANumberConvertsToAnotherNumericTypeWhenItFits() + { + var document = new ContentDocument { ["count"] = 12L, ["price"] = 2.0, ["big"] = long.MaxValue, ["half"] = 1.5m }; + + document.Get("count").ShouldBe(12); + document.Get("count").ShouldBe(12); + document.Get("count").ShouldBe(12m); + document.Get("price").ShouldBe(2); + Should.Throw(() => document.Get("big")); + Should.Throw(() => document.Get("half")); + } + + [Fact] + public void NothingElseConverts() + { + var document = new ContentDocument { ["text"] = "12", ["count"] = 12, ["flag"] = true }; + + Should.Throw(() => document.Get("text")); + Should.Throw(() => document.Get("count")); + Should.Throw(() => document.Get("flag")); + Should.Throw(() => document.Get("count")); + Should.Throw(() => document.TryGet("text", out _)); + } +}