diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index d13b932..8f6a4c9 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -40,4 +40,20 @@ public static class Inertia public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); + + public static OptionalProp Optional(Func callback) => _factory.Optional(callback); + + public static OptionalProp Optional(Func> callback) => _factory.Optional(callback); + + public static MergeProp Merge(object? value) => _factory.Merge(value); + + public static MergeProp Merge(Func callback) => _factory.Merge(callback); + + public static MergeProp Merge(Func> callback) => _factory.Merge(callback); + + public static DeepMergeProp DeepMerge(object? value) => _factory.DeepMerge(value); + + public static DeepMergeProp DeepMerge(Func callback) => _factory.DeepMerge(callback); + + public static DeepMergeProp DeepMerge(Func> callback) => _factory.DeepMerge(callback); } diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index 9df6d58..1002e57 100644 --- a/InertiaCore/Models/Page.cs +++ b/InertiaCore/Models/Page.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace InertiaCore.Models; internal class Page @@ -8,4 +10,16 @@ internal class Page public string Url { get; set; } = default!; public bool EncryptHistory { get; set; } = false; public bool ClearHistory { get; set; } = false; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? MergeProps { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? MatchPropsOn { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? PrependProps { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? DeepMergeProps { get; set; } } diff --git a/InertiaCore/Props/DeepMergeProp.cs b/InertiaCore/Props/DeepMergeProp.cs new file mode 100644 index 0000000..e91141b --- /dev/null +++ b/InertiaCore/Props/DeepMergeProp.cs @@ -0,0 +1,33 @@ +using InertiaCore.Utils; + +namespace InertiaCore.Props; + +public class DeepMergeProp : InvokableProp, Mergeable +{ + public bool merge { get; set; } = true; + public string[]? matchOn { get; set; } + public bool deepMerge { get; set; } = true; + public bool Append { get; set; } = true; + public List AppendsAtPaths { get; } = new(); + public List PrependsAtPaths { get; } = new(); + + public DeepMergeProp(object? value) : base(value) + { + merge = true; + deepMerge = true; + } + + internal DeepMergeProp(Func value) : base(value) + { + merge = true; + deepMerge = true; + } + + internal DeepMergeProp(Func> value) : base(value) + { + merge = true; + deepMerge = true; + } + + public bool ShouldDeepMerge() => deepMerge; +} \ No newline at end of file diff --git a/InertiaCore/Props/LazyProp.cs b/InertiaCore/Props/LazyProp.cs index 6e3e958..11bad35 100644 --- a/InertiaCore/Props/LazyProp.cs +++ b/InertiaCore/Props/LazyProp.cs @@ -1,6 +1,8 @@ +using InertiaCore.Utils; + namespace InertiaCore.Props; -public class LazyProp : InvokableProp +public class LazyProp : InvokableProp, IIgnoresFirstLoad { internal LazyProp(Func value) : base(value) { diff --git a/InertiaCore/Props/MergeProp.cs b/InertiaCore/Props/MergeProp.cs new file mode 100644 index 0000000..4210cc6 --- /dev/null +++ b/InertiaCore/Props/MergeProp.cs @@ -0,0 +1,31 @@ +using InertiaCore.Props; + +namespace InertiaCore.Utils; + +public class MergeProp : InvokableProp, Mergeable +{ + public bool merge { get; set; } = true; + public bool deepMerge { get; set; } = false; + public string[]? matchOn { get; set; } + + public bool Append { get; set; } = true; + public List AppendsAtPaths { get; } = new(); + public List PrependsAtPaths { get; } = new(); + + public MergeProp(object? value) : base(value) + { + merge = true; + } + + internal MergeProp(Func value) : base(value) + { + merge = true; + } + + internal MergeProp(Func> value) : base(value) + { + merge = true; + } +} + + diff --git a/InertiaCore/Props/OptionalProp.cs b/InertiaCore/Props/OptionalProp.cs new file mode 100644 index 0000000..cf9c971 --- /dev/null +++ b/InertiaCore/Props/OptionalProp.cs @@ -0,0 +1,14 @@ +using InertiaCore.Utils; + +namespace InertiaCore.Props; + +public class OptionalProp : InvokableProp, IIgnoresFirstLoad +{ + internal OptionalProp(Func value) : base(value) + { + } + + internal OptionalProp(Func> value) : base(value) + { + } +} diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 7bdd2cb..549d021 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -47,6 +47,11 @@ protected internal async Task ProcessResponse() ClearHistory = _clearHistory, }; + var mergeable = GetMergeablePropsForRequest(); + page.MergeProps = ResolveMergeProps(mergeable); + page.PrependProps = ResolvePrependProps(mergeable); + page.DeepMergeProps = ResolveDeepMergeProps(mergeable); + page.MatchPropsOn = ResolveMatchPropsOn(mergeable); page.Props["errors"] = GetErrors(); SetPage(page); @@ -88,7 +93,7 @@ protected internal async Task ProcessResponse() if (!isPartial) return props - .Where(kv => kv.Value is not LazyProp) + .Where(kv => kv.Value is not IIgnoresFirstLoad) .ToDictionary(kv => kv.Key, kv => kv.Value); props = props.ToDictionary(kv => kv.Key, kv => kv.Value); @@ -144,6 +149,162 @@ protected internal async Task ProcessResponse() .Concat(alwaysProps).ToDictionary(kv => kv.Key, kv => kv.Value); } + /// + /// Get the props eligible for merging on this request. + /// Mirrors Laravel's Response::getMergePropsForRequest(): + /// filters _props to only Mergeable && ShouldMerge(), removes any keys + /// listed in the X-Inertia-Reset header, then applies Partial-Only / + /// Partial-Except filtering. + /// + private Dictionary GetMergeablePropsForRequest(bool rejectResetProps = true) + { + var headers = _context!.HttpContext.Request.Headers; + + var resetKeys = rejectResetProps + ? ParseHeaderList(headers[InertiaHeader.Reset].ToString()) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + var hasPartialOnly = headers.ContainsKey(InertiaHeader.PartialOnly); + var onlyKeys = hasPartialOnly + ? ParseHeaderList(headers[InertiaHeader.PartialOnly].ToString()) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + var exceptKeys = ParseHeaderList(headers[InertiaHeader.PartialExcept].ToString()); + + var result = new Dictionary(); + foreach (var kv in _props) + { + if (kv.Value is not Mergeable m || !m.ShouldMerge()) continue; + + var camel = kv.Key.ToCamelCase(); + + if (resetKeys.Contains(camel)) continue; + if (hasPartialOnly && !onlyKeys.Contains(camel)) continue; + if (exceptKeys.Contains(camel)) continue; + + result[kv.Key] = kv.Value; + } + + return result; + } + + private static HashSet ParseHeaderList(string? value) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(value)) return set; + + foreach (var part in value!.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.Length > 0) set.Add(trimmed); + } + return set; + } + + /// + /// Resolve merge props that should be appended (excludes deep merge and prepend props). + /// Returns a flat list of prop keys or key.path entries. + /// + private static List? ResolveMergeProps(Dictionary mergeProps) + { + var mergeableProps = mergeProps + .Where(kv => kv.Value is Mergeable m && !m.ShouldDeepMerge()) + .ToList(); + + if (mergeableProps.Count == 0) return null; + + var result = new List(); + + foreach (var kv in mergeableProps) + { + var m = (Mergeable)kv.Value!; + var key = kv.Key.ToCamelCase(); + + if (m.AppendsAtRoot()) + { + result.Add(key); + } + + foreach (var path in m.AppendsAtPaths) + { + result.Add($"{key}.{path}"); + } + } + + return result.Count > 0 ? result : null; + } + + /// + /// Resolve props that should be prepended during merging. + /// Returns a flat list of prop keys or key.path entries. + /// + private static List? ResolvePrependProps(Dictionary mergeProps) + { + var mergeableProps = mergeProps + .Where(kv => kv.Value is Mergeable m && !m.ShouldDeepMerge()) + .ToList(); + + if (mergeableProps.Count == 0) return null; + + var result = new List(); + + foreach (var kv in mergeableProps) + { + var m = (Mergeable)kv.Value!; + var key = kv.Key.ToCamelCase(); + + if (m.PrependsAtRoot()) + { + result.Add(key); + } + + foreach (var path in m.PrependsAtPaths) + { + result.Add($"{key}.{path}"); + } + } + + return result.Count > 0 ? result : null; + } + + /// + /// Resolve props that should be deep merged. + /// + private static List? ResolveDeepMergeProps(Dictionary mergeProps) + { + var deepMergeProps = mergeProps + .Where(kv => kv.Value is Mergeable m && m.ShouldDeepMerge()) + .Select(kv => kv.Key.ToCamelCase()) + .ToList(); + + return deepMergeProps.Count > 0 ? deepMergeProps : null; + } + + /// + /// Resolve the match-on keys for merge props as a flat list. + /// Returns entries like "propKey.strategy" matching Laravel's format. + /// + private static List? ResolveMatchPropsOn(Dictionary mergeProps) + { + var result = new List(); + + foreach (var kv in mergeProps) + { + if (kv.Value is not Mergeable m) continue; + + var matchOnKeys = m.GetMatchOn(); + if (matchOnKeys == null || matchOnKeys.Length == 0) continue; + + var key = kv.Key.ToCamelCase(); + foreach (var matchOnItem in matchOnKeys) + { + result.Add($"{key}.{matchOnItem}"); + } + } + + return result.Count > 0 ? result : null; + } + /// /// Resolve all necessary class instances in the given props. /// diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 534b7ba..870e667 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -29,6 +29,14 @@ internal interface IResponseFactory public AlwaysProp Always(Func> callback); public LazyProp Lazy(Func callback); public LazyProp Lazy(Func> callback); + public MergeProp Merge(object? value); + public MergeProp Merge(Func callback); + public MergeProp Merge(Func> callback); + public DeepMergeProp DeepMerge(object? value); + public DeepMergeProp DeepMerge(Func callback); + public DeepMergeProp DeepMerge(Func> callback); + public OptionalProp Optional(Func callback); + public OptionalProp Optional(Func> callback); } internal class ResponseFactory : IResponseFactory @@ -144,4 +152,12 @@ public void Share(IDictionary data) public AlwaysProp Always(object? value) => new(value); public AlwaysProp Always(Func callback) => new(callback); public AlwaysProp Always(Func> callback) => new(callback); + public MergeProp Merge(object? value) => new(value); + public MergeProp Merge(Func callback) => new(callback); + public MergeProp Merge(Func> callback) => new(callback); + public DeepMergeProp DeepMerge(object? value) => new(value); + public DeepMergeProp DeepMerge(Func callback) => new(callback); + public DeepMergeProp DeepMerge(Func> callback) => new(callback); + public OptionalProp Optional(Func callback) => new(callback); + public OptionalProp Optional(Func> callback) => new(callback); } diff --git a/InertiaCore/Utils/IIgnoresFirstLoad.cs b/InertiaCore/Utils/IIgnoresFirstLoad.cs new file mode 100644 index 0000000..10fc9ba --- /dev/null +++ b/InertiaCore/Utils/IIgnoresFirstLoad.cs @@ -0,0 +1,5 @@ +namespace InertiaCore.Utils; + +public interface IIgnoresFirstLoad +{ +} diff --git a/InertiaCore/Utils/InertiaHeader.cs b/InertiaCore/Utils/InertiaHeader.cs index 80de7d8..b75e6cf 100644 --- a/InertiaCore/Utils/InertiaHeader.cs +++ b/InertiaCore/Utils/InertiaHeader.cs @@ -15,4 +15,6 @@ public static class InertiaHeader public const string PartialOnly = "X-Inertia-Partial-Data"; public const string PartialExcept = "X-Inertia-Partial-Except"; + + public const string Reset = "X-Inertia-Reset"; } diff --git a/InertiaCore/Utils/Mergeable.cs b/InertiaCore/Utils/Mergeable.cs new file mode 100644 index 0000000..0c4af81 --- /dev/null +++ b/InertiaCore/Utils/Mergeable.cs @@ -0,0 +1,88 @@ +namespace InertiaCore.Utils; + +public interface Mergeable +{ + public bool merge { get; set; } + public bool deepMerge { get; set; } + public string[]? matchOn { get; set; } + + public Mergeable Merge() + { + merge = true; + + return this; + } + + public Mergeable DeepMerge() + { + deepMerge = true; + + merge = true; + + return this; + } + + public Mergeable MatchesOn(params string[] keys) + { + matchOn = keys; + return this; + } + + public bool ShouldMerge() => merge; + public bool ShouldDeepMerge() => deepMerge; + public string[]? GetMatchOn() => matchOn; + + bool Append { get; set; } + List AppendsAtPaths { get; } + List PrependsAtPaths { get; } + + /// + /// Specify that the value should be appended. + /// + public Mergeable AppendAt(string path, string? matchOnKey = null) + { + AppendsAtPaths.Add(path); + if (matchOnKey != null) + { + var existing = matchOn?.ToList() ?? new List(); + existing.Add($"{path}.{matchOnKey}"); + matchOn = existing.ToArray(); + } + return this; + } + + /// + /// Specify that the value should be prepended. + /// + public Mergeable PrependAt(string path, string? matchOnKey = null) + { + PrependsAtPaths.Add(path); + if (matchOnKey != null) + { + var existing = matchOn?.ToList() ?? new List(); + existing.Add($"{path}.{matchOnKey}"); + matchOn = existing.ToArray(); + } + return this; + } + + /// + /// Set the default merge direction to prepend. + /// + public Mergeable Prepend() + { + Append = false; + merge = true; + return this; + } + + /// + /// Whether this property appends at root level (no nested paths defined, default direction is append). + /// + public bool AppendsAtRoot() => Append && AppendsAtPaths.Count == 0 && PrependsAtPaths.Count == 0; + + /// + /// Whether this property prepends at root level. + /// + public bool PrependsAtRoot() => !Append && AppendsAtPaths.Count == 0 && PrependsAtPaths.Count == 0; +} diff --git a/InertiaCoreTests/UnitTestDeepMergeData.cs b/InertiaCoreTests/UnitTestDeepMergeData.cs new file mode 100644 index 0000000..de0e395 --- /dev/null +++ b/InertiaCoreTests/UnitTestDeepMergeData.cs @@ -0,0 +1,313 @@ +using InertiaCore.Models; +using InertiaCore.Utils; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the deep merge data is fetched properly.")] + public async Task TestDeepMergeData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(() => + { + return "Deep Merge"; + }) + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testDeepMerge", "Deep Merge" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge data is fetched properly with specified partial props.")] + public async Task TestDeepMergePartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(() => "Deep Merge") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testDeepMerge" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testDeepMerge", "Deep Merge" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge async data is fetched properly.")] + public async Task TestDeepMergeAsyncData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Deep Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(testFunction) + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testDeepMerge", "Deep Merge Async" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge data is fetched properly without specified partial props.")] + public async Task TestDeepMergePartialDataOmitted() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Deep Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.DeepMergeProps, Is.EqualTo(null)); + } + + [Test] + [Description("Test if there are no deep merge props when none are specified.")] + public async Task TestNoDeepMergeProps() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(null)); + } + + [Test] + [Description("Test if deep merge props are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestDeepMergePropsWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = _factory.DeepMerge(() => "Deep Merge1"), + TestDeepMerge2 = _factory.DeepMerge(() => "Deep Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testDeepMerge1" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testDeepMerge2", "Deep Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testDeepMerge1 should be excluded from deep merge props due to PARTIAL_EXCEPT header + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge2" })); + } + + [Test] + [Description("Test if only specified deep merge props are included when using PARTIAL_ONLY header.")] + public async Task TestDeepMergePropsWithPartialOnly() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = _factory.DeepMerge(() => "Deep Merge1"), + TestDeepMerge2 = _factory.DeepMerge(() => "Deep Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testDeepMerge1,testNormal" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testDeepMerge1", "Deep Merge1" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // Only testDeepMerge1 should be in deep merge props since testDeepMerge2 was not included in PARTIAL_ONLY + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge1" })); + } + + [Test] + [Description("Test if deep merge props work with match on keys.")] + public async Task TestDeepMergeWithMatchOn() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = ((Mergeable)_factory.DeepMerge("Deep Merge1")).MatchesOn("deep"), + TestDeepMerge2 = ((Mergeable)_factory.DeepMerge(() => "Deep Merge2")).MatchesOn("shallow", "replace"), + TestNormal = "Normal" + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testDeepMerge1", "Deep Merge1" }, + { "testDeepMerge2", "Deep Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge1", "testDeepMerge2" })); + // Deep merge props should also appear in match props on since they inherit from Mergeable + Assert.That(page?.MatchPropsOn, Is.EqualTo(new List + { + "testDeepMerge1.deep", + "testDeepMerge2.shallow", + "testDeepMerge2.replace" + })); + } + + [Test] + [Description("Test if regular merge and deep merge props coexist properly.")] + public async Task TestMergeAndDeepMergeCoexistence() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge = _factory.Merge(() => "Regular Merge"), + TestDeepMerge = _factory.DeepMerge(() => "Deep Merge"), + TestNormal = "Normal" + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testMerge", "Regular Merge" }, + { "testDeepMerge", "Deep Merge" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } +} \ No newline at end of file diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs new file mode 100644 index 0000000..2ec10f6 --- /dev/null +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -0,0 +1,496 @@ +using InertiaCore.Models; +using InertiaCore.Utils; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the merge data is fetched properly.")] + public async Task TestMergeData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(() => + { + return "Merge"; + }) + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testMerge", "Merge" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge data is fetched properly with specified partial props.")] + public async Task TestMergePartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(() => "Merge") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testMerge" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testMerge", "Merge" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly.")] + public async Task TestMergeAsyncData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(testFunction) + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testMerge", "Merge Async" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly with specified partial props.")] + public async Task TestMergeAsyncPartialData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testMerge" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testMerge", "Merge Async" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly without specified partial props.")] + public async Task TestMergeAsyncPartialDataOmitted() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(null)); + } + + [Test] + [Description("Test if the merge async data is fetched properly without specified partial props.")] + public async Task TestNoMergeProps() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(null)); + } + + [Test] + [Description("Test if merge props are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestMergePropsWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testMerge1" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testMerge1 should be excluded from merge props due to PARTIAL_EXCEPT header + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); + } + + [Test] + [Description("Test if only specified merge props are included when using PARTIAL_ONLY header.")] + public async Task TestMergePropsWithPartialOnly() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testNormal" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testMerge1", "Merge1" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // Only testMerge1 should be in merge props since testMerge2 was not included in PARTIAL_ONLY + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1" })); + } + + [Test] + [Description("Test if merge props respect both PARTIAL_ONLY and PARTIAL_EXCEPT headers.")] + public async Task TestMergePropsWithPartialOnlyAndExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestMerge3 = _factory.Merge(() => "Merge3"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testMerge2,testMerge3,testNormal" }, + { "X-Inertia-Partial-Except", "testMerge2" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testMerge1", "Merge1" }, + { "testMerge3", "Merge3" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testMerge1 and testMerge3 should be in merge props (testMerge2 excluded by PARTIAL_EXCEPT) + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); + } + + [Test] + [Description("Test if match props on are resolved properly for merge props.")] + public async Task TestMatchPropsOn() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), + TestNormal = "Normal" + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testMerge1", "Merge1" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge2" })); + Assert.That(page?.MatchPropsOn, Is.EqualTo(new List + { + "testMerge1.deep", + "testMerge2.shallow", + "testMerge2.replace" + })); + } + + [Test] + [Description("Test if match props on are handled properly with partial props.")] + public async Task TestMatchPropsOnWithPartialProps() + { + var response = _factory.Render("Test/Page", new + { + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), + TestMerge3 = ((Mergeable)_factory.Merge("Merge3")).MatchesOn("custom") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testMerge3" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testMerge1", "Merge1" }, + { "testMerge3", "Merge3" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); + Assert.That(page?.MatchPropsOn, Is.EqualTo(new List + { + "testMerge1.deep", + "testMerge3.custom" + })); + } + + [Test] + [Description("Test if match props on are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestMatchPropsOnWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testMerge1" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); + Assert.That(page?.MatchPropsOn, Is.EqualTo(new List + { + "testMerge2.shallow", + "testMerge2.replace" + })); + } + + [Test] + [Description("Test if match props on are null when no merge props have match keys.")] + public async Task TestNoMatchPropsOn() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge = _factory.Merge(() => "Merge"), // No strategies + TestNormal = "Normal" + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testMerge", "Merge" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + Assert.That(page?.MatchPropsOn, Is.EqualTo(null)); + } + + [Test] + [Description("Test if merge props honor the X-Inertia-Reset header.")] + public async Task TestMergePropsWithResetHeader() + { + var response = _factory.Render("Test/Page", new + { + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Reset", "testMerge1" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + // testMerge1 should NOT appear in mergeProps because it's in the reset list + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); + } + +} diff --git a/InertiaCoreTests/UnitTestOptionalData.cs b/InertiaCoreTests/UnitTestOptionalData.cs new file mode 100644 index 0000000..3d925ec --- /dev/null +++ b/InertiaCoreTests/UnitTestOptionalData.cs @@ -0,0 +1,139 @@ +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the optional data is fetched properly.")] + public async Task TestOptionalData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(() => + { + Assert.Fail(); + return "Optional"; + }) + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the optional data is fetched properly with specified partial props.")] + public async Task TestOptionalPartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(() => "Optional") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testOptional" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testOptional", "Optional" }, + { "errors", new Dictionary(0) } + })); + } + + + [Test] + [Description("Test if the optional async data is fetched properly.")] + public async Task TestOptionalAsyncData() + { + var testFunction = new Func>(async () => + { + Assert.Fail(); + await Task.Delay(100); + return "Optional Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(testFunction) + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the optional async data is fetched properly with specified partial props.")] + public async Task TestOptionalAsyncPartialData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Optional Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testOptional" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testOptional", "Optional Async" }, + { "errors", new Dictionary(0) } + })); + } +} diff --git a/InertiaCoreTests/UnitTestResult.cs b/InertiaCoreTests/UnitTestResult.cs index a88e79d..36e4cc9 100644 --- a/InertiaCoreTests/UnitTestResult.cs +++ b/InertiaCoreTests/UnitTestResult.cs @@ -1,6 +1,7 @@ using InertiaCore.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.Text.Json; namespace InertiaCoreTests; @@ -40,6 +41,62 @@ public async Task TestJsonResult() { "test", "Test" }, { "errors", new Dictionary(0) } })); + + // Check the serialized JSON + var jsonString = JsonSerializer.Serialize(json); + var dictionary = JsonSerializer.Deserialize>(jsonString); + + Assert.That(dictionary, Is.Not.Null); + Assert.That(dictionary!.ContainsKey("MergeProps"), Is.False); + }); + } + + [Test] + [Description("Test if the JSON result with merged data is created correctly.")] + public async Task TestJsonMergedResult() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerged = _factory.Merge(() => "Merged") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var result = response.GetResult(); + + Assert.Multiple(() => + { + Assert.That(result, Is.InstanceOf(typeof(JsonResult))); + + var json = (result as JsonResult)?.Value; + Assert.That(json, Is.InstanceOf(typeof(Page))); + + Assert.That((json as Page)?.Component, Is.EqualTo("Test/Page")); + Assert.That((json as Page)?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testMerged", "Merged" }, + { "errors", new Dictionary(0) } + })); + Assert.That((json as Page)?.MergeProps, Is.EqualTo(new List { + "testMerged" + })); + + // Check the serialized JSON + var jsonString = JsonSerializer.Serialize(json); + var dictionary = JsonSerializer.Deserialize>(jsonString); + + Assert.That(dictionary, Is.Not.Null); + Assert.That(dictionary!.ContainsKey("MergeProps"), Is.True); }); }