diff --git a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor index 74d2db178..3747582e8 100644 --- a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor +++ b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor @@ -26,11 +26,11 @@ break; case "richText": - ", form); + Assert.Matches("]*name=\"Form.Values\\[body\\]\"[^>]*> First line.", form); + + // What a browser posts back for the untouched fields: the widgets' text, with CRLF line breaks and minutes only. + var saved = await host.SubmitAsync($"/content/kitchen-sink/{entry.Id}", "entry", new Dictionary + { + ["Form.Values[title]"] = "Cordless drill", + ["Form.Values[body]"] = "\r\nFirst line.\r\nSecond line.", + ["Form.Values[subtitle]"] = "Line one\r\nLine two", + ["Form.Values[launch]"] = "2026-11-01T07:30", + ["Form.Values[price]"] = "4.50", + ["Form.Rationale"] = "Retitle.", + ["Form.BaseVersion"] = "1", + }); + + var set = await host.AsUserAsync(ownerId, async sets => (await sets.GetAsync(Guid.Parse(host.LocationOf(saved)![6..42]))).Value); + var fields = (await host.AsUserAsync(ownerId, async entries => (await entries.GetAsync(entry.Id)).Value)).Latest.Fields; + Assert.Equal("Cordless drill", fields.GetProperty("title").GetString()); + Assert.Equal("\nFirst line.\nSecond line.", fields.GetProperty("body").GetString()); + Assert.Equal("Line one\nLine two", fields.GetProperty("subtitle").GetString()); + Assert.Equal("2026-11-01T09:30:45.5+02:00", fields.GetProperty("launch").GetString()); + Assert.Equal("4.50", fields.GetProperty("price").GetRawText()); + Assert.False(fields.TryGetProperty("active", out _)); + Assert.Equal(2, set.Operations[0].ProposedVersion); + } + + [Fact] + public async Task Editing_a_published_entry_proposes_an_update_from_the_published_version_and_a_stray_draft_is_named() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + await KitchenSinkAsync(host, ownerId); + var entry = await host.AsUserAsync(ownerId, async entries => + (await entries.CreateAsync(new NewEntry("kitchen-sink", Json("""{"title": "Published"}""")))).Value); + await host.AsUserAsync(ownerId, async sets => + { + var set = (await sets.OpenAsync(new NewChangeSet("Publish it.", []))).Value; + await sets.AddOperationAsync(set.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, entry.Id, null, 1).Value); + await sets.TransitionAsync(set.Id, ChangeSetState.ChecksRunning); + await sets.TransitionAsync(set.Id, ChangeSetState.Approved); + return (await sets.PublishAsync(set.Id)).Value; + }); + + // A draft no set reviews, as a rejected change leaves one. + await host.AsUserAsync(ownerId, async entries => (await entries.UpdateAsync(entry.Id, 1, Json("""{"title": "Stray draft"}"""))).Value); + await host.SignInAsync(AdminHost.Owner); + + var latest = await host.Client.GetStringAsync($"/content/kitchen-sink/{entry.Id}"); + Assert.Contains("data-testid=\"entry-draft-note\"", latest); + Assert.Contains("value=\"Stray draft\"", latest); + var published = await host.Client.GetStringAsync($"/content/kitchen-sink/{entry.Id}?from=published"); + Assert.Contains("value=\"Published\"", published); + Assert.Contains("name=\"Form.BaseVersion\" value=\"2\"", published); + + var saved = await host.SubmitAsync($"/content/kitchen-sink/{entry.Id}?from=published", "entry", new Dictionary + { + ["Form.Values[title]"] = "Published, retitled", + ["Form.Rationale"] = "Retitle.", + ["Form.BaseVersion"] = "2", + }); + + var set = await host.AsUserAsync(ownerId, async sets => (await sets.GetAsync(Guid.Parse(host.LocationOf(saved)![6..42]))).Value); + var operation = Assert.Single(set.Operations); + Assert.Equal((OperationKind.Update, (int?)1, (int?)3), (operation.Kind, operation.BaseVersion, operation.ProposedVersion)); + + // The saved set goes through the checks from the set view like any other. + var page = await host.Client.GetStringAsync($"/sets/{set.Id}"); + var seen = System.Text.RegularExpressions.Regex.Match(page, "name=\"Submit.Seen\" value=\"([0-9a-f]+)\"").Groups[1].Value; + var submitted = await host.SubmitAsync($"/sets/{set.Id}", "submit", new Dictionary { ["Submit.Seen"] = seen }); + Assert.Contains("data-state=\"awaiting-review\"", await submitted.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task After_a_revert_the_form_names_the_reverted_draft_and_a_form_from_a_published_version_since_replaced_is_refused() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + await KitchenSinkAsync(host, ownerId); + var entry = await host.AsUserAsync(ownerId, async entries => + (await entries.CreateAsync(new NewEntry("kitchen-sink", Json("""{"title": "First"}""")))).Value); + await PublishAsync(host, ownerId, sets => sets.OpenAsync(new NewChangeSet("Publish it.", [])), ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, entry.Id, null, 1).Value); + await host.AsUserAsync(ownerId, async entries => (await entries.UpdateAsync(entry.Id, 1, Json("""{"title": "Wrong"}"""))).Value); + var wrong = await PublishAsync(host, ownerId, sets => sets.OpenAsync(new NewChangeSet("Retitle.", [])), ChangeOperation.Create(OperationKind.Update, ItemKind.Entry, entry.Id, 1, 2).Value); + await host.SignInAsync(AdminHost.Owner); + + // A form open on the published version when a revert publishes an older one can't put the reverted values back. + Assert.Contains("name=\"Form.FromPublished\" value=\"2\"", await host.Client.GetStringAsync($"/content/kitchen-sink/{entry.Id}")); + await PublishAsync(host, ownerId, sets => sets.RevertAsync(wrong, "The title was wrong.")); + var stale = await host.SubmitAsync($"/content/kitchen-sink/{entry.Id}", "entry", new Dictionary + { + ["Form.Values[title]"] = "Wrong, retitled", + ["Form.Rationale"] = "Retitle.", + ["Form.BaseVersion"] = "2", + ["Form.FromPublished"] = "2", + }); + Assert.Contains("Someone saved this entry since the form was shown.", await stale.Content.ReadAsStringAsync()); + + var reverted = await host.AsUserAsync(ownerId, async entries => (await entries.GetAsync(entry.Id)).Value); + Assert.Equal((EntryState.Reverted, (int?)1, 2), (reverted.State, reverted.PublishedVersion, reverted.LatestVersion)); + var latest = await host.Client.GetStringAsync($"/content/kitchen-sink/{entry.Id}"); + Assert.Contains("The form shows the reverted draft, version 2.", latest); + Assert.Contains($"href=\"content/kitchen-sink/{entry.Id}?from=published\"", latest); + Assert.Contains("value=\"Wrong\"", latest); + var published = await host.Client.GetStringAsync($"/content/kitchen-sink/{entry.Id}?from=published"); + Assert.Contains("value=\"First\"", published); + Assert.Contains("name=\"Form.FromPublished\" value=\"1\"", published); + + // The published version moves on while the form is open: its values would undo that, so the save is refused. + await host.AsUserAsync(ownerId, async entries => (await entries.UpdateAsync(entry.Id, 2, Json("""{"title": "Second"}"""))).Value); + await PublishAsync(host, ownerId, sets => sets.OpenAsync(new NewChangeSet("Retitle again.", [])), ChangeOperation.Create(OperationKind.Update, ItemKind.Entry, entry.Id, 1, 3).Value); + var refused = await host.SubmitAsync($"/content/kitchen-sink/{entry.Id}?from=published", "entry", new Dictionary + { + ["Form.Values[title]"] = "First, retitled", + ["Form.Rationale"] = "Retitle.", + ["Form.BaseVersion"] = "3", + ["Form.FromPublished"] = "1", + }); + + Assert.Contains("Someone saved this entry since the form was shown.", await refused.Content.ReadAsStringAsync()); + Assert.Equal(3, (await host.AsUserAsync(ownerId, async entries => (await entries.GetAsync(entry.Id)).Value)).LatestVersion); + + // A reverted create leaves nothing published, and the form says whose values it shows. + var created = await host.AsUserAsync(ownerId, async entries => + (await entries.CreateAsync(new NewEntry("kitchen-sink", Json("""{"title": "Never meant"}""")))).Value); + var creation = await PublishAsync(host, ownerId, sets => sets.OpenAsync(new NewChangeSet("Add it.", [])), ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, created.Id, null, 1).Value); + await PublishAsync(host, ownerId, sets => sets.RevertAsync(creation, "It was never meant.")); + var revertedCreate = await host.Client.GetStringAsync($"/content/kitchen-sink/{created.Id}"); + Assert.Contains("whose creation was reverted", revertedCreate); + Assert.DoesNotContain("data-testid=\"from-published\"", revertedCreate); + } + + // A field whose type changed keeps its stored value until it is saved; the form then saves what the new type takes. + [Fact] + public async Task An_untouched_value_its_field_type_no_longer_takes_is_saved_as_the_new_type() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + FieldDefinition[] before = [new FieldDefinition("title", "text"), new FieldDefinition("code", "number")]; + await host.AsUserAsync(ownerId, async types => (await types.CreateAsync( + new NewContentType("Part", "part", new ContentTypeDefinition(before)), TestChangeSetTargets.ContentModel)).Value); + var entry = await host.AsUserAsync(ownerId, async entries => + (await entries.CreateAsync(new NewEntry("part", Json("""{"title": "Bolt", "code": 45}""")))).Value); + await host.AsUserAsync(ownerId, async types => (await types.ReviseAsync( + "part", 1, new ContentTypeDefinition([new FieldDefinition("title", "text"), new FieldDefinition("code", "text")]), TestChangeSetTargets.ContentModel)).Value); + await host.SignInAsync(AdminHost.Owner); + + var saved = await host.SubmitAsync($"/content/part/{entry.Id}", "entry", new Dictionary + { + ["Form.Values[title]"] = "Hex bolt", + ["Form.Values[code]"] = "45", + ["Form.Rationale"] = "Retitle.", + ["Form.BaseVersion"] = "1", + }); + + Assert.StartsWith("/sets/", host.LocationOf(saved)); + var code = (await host.AsUserAsync(ownerId, async entries => (await entries.GetAsync(entry.Id)).Value)).Latest.Fields.GetProperty("code"); + Assert.Equal((JsonValueKind.String, "45"), (code.ValueKind, code.GetString())); + } + [Fact] public async Task The_content_pages_list_types_and_entries_and_need_a_signed_in_person() { @@ -153,6 +367,23 @@ public async Task The_content_pages_list_types_and_entries_and_need_a_signed_in_ Assert.Equal(HttpStatusCode.NotFound, (await host.Client.GetAsync($"/content/kitchen-sink/{Guid.NewGuid()}")).StatusCode); } + // Opens a set, adds the operations and publishes it; a revert opens its own set with its operations. + private static Task PublishAsync(AdminHost host, Guid ownerId, Func>> open, params ChangeOperation[] operations) + { + return host.AsUserAsync(ownerId, async sets => + { + var set = (await open(sets)).Value; + foreach (var operation in operations) + { + await sets.AddOperationAsync(set.Id, operation); + } + + await sets.TransitionAsync(set.Id, ChangeSetState.ChecksRunning); + await sets.TransitionAsync(set.Id, ChangeSetState.Approved); + return (await sets.PublishAsync(set.Id)).Value.Id; + }); + } + // A type with one field of every built-in type, a category entry to reference and an asset to show. private static async Task<(Guid Category, Guid Asset)> KitchenSinkAsync(AdminHost host, Guid ownerId) { @@ -165,6 +396,7 @@ await host.AsUserAsync(ownerId, async types [ new FieldDefinition("title", "text", required: true, settings: Json("""{"maxLength": 80}""")), new FieldDefinition("body", "richText"), + new FieldDefinition("subtitle", "text"), new FieldDefinition("price", "number"), new FieldDefinition("active", "boolean"), new FieldDefinition("launch", "dateTime"), diff --git a/tests/FluentCMS.Tests/Admin/FactValuesTests.cs b/tests/FluentCMS.Tests/Admin/FactValuesTests.cs index 98e25955f..d86223791 100644 --- a/tests/FluentCMS.Tests/Admin/FactValuesTests.cs +++ b/tests/FluentCMS.Tests/Admin/FactValuesTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using FluentCMS.Admin.Forms; +using FluentCMS.Core.Schema; namespace FluentCMS.Tests.Admin; @@ -61,6 +62,94 @@ public void A_stored_fact_shows_its_value_and_parts() Assert.False(FactValues.IsUnsourced(stored)); } + [Fact] + public void Parts_that_show_as_the_stored_fact_keep_its_text() + { + var stored = JsonDocument.Parse($$"""{"value": 10, "source": null, "owner": "{{Owner.ToString().ToUpperInvariant()}}", "validFrom": null, "validUntil": null, "lastVerified": "2026-03-04T05:06:07.89+01:00"}""").RootElement; + var shown = new Dictionary(); + FactValues.Show("price", stored, shown); + var problems = new Dictionary(); + + var fact = FactValues.Read("price", JsonValue.Create(10), shown, problems, stored, new HashSet { Owner }); + + Assert.Empty(problems); + Assert.Equal(Owner.ToString(), shown["price.owner"]); + Assert.Equal(Owner.ToString().ToUpperInvariant(), fact![FactMetadata.Owner]!.GetValue()); + Assert.Equal("2026-03-04T05:06:07.89+01:00", fact[FactMetadata.LastVerified]!.GetValue()); + } + + [Fact] + public void A_fact_whose_value_and_parts_all_show_as_stored_is_kept_exactly() + { + var stored = JsonDocument.Parse($$"""{"value": 4.50, "source": {"reference": "prices.csv ", "kind": "file"}, "owner": "{{Owner.ToString().ToUpperInvariant()}}", "validFrom": null, "validUntil": null, "lastVerified": "2026-03-04T05:06:07+01:00", "note": "kept"}""").RootElement; + var shown = new Dictionary(); + FactValues.Show("price", stored, shown); + + var fact = FactValues.Read("price", JsonNode.Parse("4.50")!, shown, [], stored, new HashSet { Owner }); + + var unescaped = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + Assert.Equal(stored.GetRawText().Replace(" ", "", StringComparison.Ordinal), fact!.ToJsonString(unescaped).Replace(" ", "", StringComparison.Ordinal)); + } + + [Fact] + public void A_stored_plain_value_is_read_as_no_fact_yet() + { + var problems = new Dictionary(); + + var fact = FactValues.Read("price", JsonValue.Create(10m), Posted(("price.owner", Owner.ToString())), problems, JsonDocument.Parse("10").RootElement, new HashSet { Owner }); + + Assert.Empty(problems); + Assert.Equal(Owner.ToString(), fact![FactMetadata.Owner]!.GetValue()); + Assert.Null(FactValues.OwnerOf(JsonDocument.Parse("10").RootElement)); + } + + [Fact] + public void A_json_object_without_a_value_is_a_plain_value_not_a_fact() + { + var facts = new Dictionary(); + var plain = JsonDocument.Parse("""{"watts": 500}""").RootElement; + + var shown = FactValues.Show("specs", plain, facts); + + Assert.Equal(500, shown!.Value.GetProperty("watts").GetInt32()); + Assert.Empty(facts); + Assert.False(FactValues.IsFact(plain)); + Assert.True(FactValues.IsUnsourced(plain)); + } + + [Fact] + public void The_owner_a_fact_already_has_is_kept_though_no_user_has_that_id() + { + var stranger = Guid.NewGuid(); + var stored = JsonDocument.Parse($$"""{"value": 10, "source": null, "owner": "{{stranger}}", "validFrom": null, "validUntil": null, "lastVerified": null}""").RootElement; + var problems = new Dictionary(); + + var fact = FactValues.Read("price", JsonValue.Create(11m), Posted(("price.owner", stranger.ToString())), problems, stored, new HashSet { Owner }); + + Assert.Empty(problems); + Assert.Equal(stranger.ToString(), fact![FactMetadata.Owner]!.GetValue()); + Assert.Equal(stranger, FactValues.OwnerOf(stored)); + } + + [Fact] + public void An_owner_who_is_not_a_known_user_is_a_problem() + { + var problems = new Dictionary(); + + Assert.Null(FactValues.Read("price", JsonValue.Create(10m), Posted(("price.owner", Guid.NewGuid().ToString())), problems, owners: new HashSet { Owner })); + Assert.Equal("owner", problems["owner"]); + } + + [Theory] + [InlineData("price.reference", "prices.csv", true)] + [InlineData("price.validFrom", "2026-01-01T00:00", true)] + [InlineData("other.kind", "url", false)] + [InlineData("price.owner", " ", false)] + public void Details_are_given_when_any_part_of_the_field_has_text(string key, string posted, bool expected) + { + Assert.Equal(expected, FactValues.HasDetails("price", Posted((key, posted)))); + } + private static Dictionary Posted(params (string Key, string Value)[] parts) { return parts.ToDictionary(part => part.Key, part => (string?)part.Value); diff --git a/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs b/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs index a6fc7da7c..54db0e101 100644 --- a/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs +++ b/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs @@ -14,7 +14,6 @@ public sealed class GeneratedFormTests [InlineData("number", "{}", "129.5", "129.5")] [InlineData("number", "{}", "-3", "-3")] [InlineData("boolean", "{}", "true", "true")] - [InlineData("boolean", "{}", null, "false")] [InlineData("dateTime", "{}", "2026-11-01T09:30", "\"2026-11-01T09:30:00Z\"")] [InlineData("dateTime", """{"dateOnly": true}""", "2026-11-02", "\"2026-11-02\"")] [InlineData("enum", """{"values": ["red"]}""", "red", "\"red\"")] @@ -50,6 +49,63 @@ public void An_empty_field_has_no_value(string type) Assert.Null(problem); } + [Theory] + [InlineData("dateTime", "{}", "\"2026-11-01T09:30:45.5+02:00\"", "2026-11-01T07:30")] + [InlineData("number", "{}", "4.50", "4.50")] + [InlineData("text", "{}", "\"one\\ntwo\"", "one\r\ntwo")] + [InlineData("json", "{}", """{"k":[1,2]}""", "{\r\n \"k\": [\r\n 1,\r\n 2\r\n ]\r\n}")] + [InlineData("boolean", "{}", "true", "true")] + public void A_posted_text_that_shows_as_the_stored_value_keeps_it_exactly(string type, string settings, string stored, string posted) + { + var value = FieldValues.Read(Field(type, settings), posted, out var problem, JsonDocument.Parse(stored).RootElement); + + Assert.Null(problem); + Assert.Equal(stored, value!.ToJsonString(Unescaped)); + } + + [Fact] + public void Only_carriage_returns_are_line_breaks_to_compare_and_other_separators_stay_as_typed() + { + var value = FieldValues.Read(Field("richText", "{}"), "One\r\nTwo\u2028three", out _, JsonDocument.Parse("\"One\\nTwo\"").RootElement); + + Assert.Equal("One\nTwo\u2028three", value!.GetValue()); + } + + [Theory] + [InlineData("a\nb")] + [InlineData("a\rb")] + public void A_text_with_line_breaks_is_shown_in_a_textarea(string value) + { + using var context = new AdminTestContext(); + + var cut = context.Render(widget => widget.Add(w => w.Field, Field("text", "{}")).Add(w => w.Value, value)); + + Assert.Single(cut.FindAll("textarea")); + Assert.Empty(cut.FindAll("input")); + } + + [Fact] + public void A_changed_text_replaces_the_stored_value() + { + var value = FieldValues.Read(Field("dateTime", "{}"), "2026-11-01T07:31", out _, JsonDocument.Parse("\"2026-11-01T09:30:45+02:00\"").RootElement); + + Assert.Equal("\"2026-11-01T07:31:00Z\"", value!.ToJsonString()); + } + + [Theory] + [InlineData(null, false, null)] + [InlineData("true", false, "false")] + [InlineData(null, true, "false")] + public void An_unchecked_box_is_false_only_when_the_field_is_required_or_had_a_value(string? stored, bool required, string? expected) + { + var storedValue = stored is null ? (JsonElement?)null : JsonDocument.Parse(stored).RootElement; + + var value = FieldValues.Read(Field("boolean", "{}", required), null, out var problem, storedValue); + + Assert.Null(problem); + Assert.Equal(expected, value?.ToJsonString()); + } + [Theory] [InlineData("dateTime", "{}", "\"2026-11-01T11:30:00+02:00\"", "2026-11-01T09:30")] [InlineData("boolean", "{}", "true", "true")] @@ -103,6 +159,8 @@ public void An_enum_offers_its_values_and_marks_the_stored_one() Assert.True(cut.Find("option[value=blue]").HasAttribute("selected")); } + private static readonly JsonSerializerOptions Unescaped = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + private static FieldDefinition Field(string type, string settings, bool required = false) { return new FieldDefinition("field", type, required, JsonDocument.Parse(settings).RootElement); diff --git a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs index 6fdd3c4e7..b70f7ff34 100644 --- a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs +++ b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs @@ -36,6 +36,7 @@ public void An_entry_picker_searches_the_target_type_and_posts_the_chosen_id() var cut = context.Render(EntryPicker(chosen: false)); Assert.Equal("", cut.Find("input[type=hidden]").GetAttribute("value") ?? ""); + Assert.False(cut.Find("input[type=search]").HasAttribute("aria-controls")); cut.Find("input[type=search]").Input("dri"); Assert.Equal(("dri", "product"), (search.Queries[0].Text, search.Queries[0].ContentTypes!.Single()));