diff --git a/src/Umbraco.Core/Extensions/StringExtensions.cs b/src/Umbraco.Core/Extensions/StringExtensions.cs index eab9d3fabf..7a3d90b205 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.cs @@ -1575,4 +1575,22 @@ public static class StringExtensions // this is by far the fastest way to find string needles in a string haystack public static int CountOccurrences(this string haystack, string needle) => haystack.Length - haystack.Replace(needle, string.Empty).Length; + + /// + /// Verifies the provided string is a valid culture code and returns it in a consistent casing. + /// + /// Culture code. + /// Culture code in standard casing. + public static string? EnsureCultureCode(this string? culture) + { + if (string.IsNullOrEmpty(culture) || culture == "*") + { + return culture; + } + + // Create as CultureInfo instance from provided name so we can ensure consistent casing of culture code when persisting. + // This will accept mixed case but once created have a `Name` property that is consistently and correctly cased. + // Will throw in an invalid culture code is provided. + return new CultureInfo(culture).Name; + } } diff --git a/src/Umbraco.Core/Models/ContentBase.cs b/src/Umbraco.Core/Models/ContentBase.cs index 768e631359..f9fa7263ba 100644 --- a/src/Umbraco.Core/Models/ContentBase.cs +++ b/src/Umbraco.Core/Models/ContentBase.cs @@ -1,5 +1,7 @@ -using System.Collections.Specialized; +using System.Collections.Specialized; using System.Diagnostics; +using System.Globalization; +using System.Runtime.ConstrainedExecution; using System.Runtime.Serialization; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Extensions; @@ -288,6 +290,8 @@ public abstract class ContentBase : TreeEntityBase, IContentBase // set on variant content type if (ContentType.VariesByCulture()) { + culture = culture.EnsureCultureCode(); + // invariant is ok if (culture.IsNullOrWhiteSpace()) { @@ -297,7 +301,7 @@ public abstract class ContentBase : TreeEntityBase, IContentBase // clear else if (name.IsNullOrWhiteSpace()) { - ClearCultureInfo(culture!); + ClearCultureInfo(culture); } // set @@ -322,11 +326,6 @@ public abstract class ContentBase : TreeEntityBase, IContentBase private void ClearCultureInfo(string culture) { - if (culture == null) - { - throw new ArgumentNullException(nameof(culture)); - } - if (string.IsNullOrWhiteSpace(culture)) { throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(culture)); @@ -455,6 +454,7 @@ public abstract class ContentBase : TreeEntityBase, IContentBase $"No PropertyType exists with the supplied alias \"{propertyTypeAlias}\"."); } + culture = culture.EnsureCultureCode(); var updated = property.SetValue(value, culture, segment); if (updated) { diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index c72218e8f3..f246b9c3f0 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1184,6 +1184,8 @@ public class ContentService : RepositoryService, IContentService throw new ArgumentException("Cultures cannot be null or whitespace", nameof(cultures)); } + cultures = cultures.Select(x => x.EnsureCultureCode()!).ToArray(); + EventMessages evtMsgs = EventMessagesFactory.Get(); // we need to guard against unsaved changes before proceeding; the content will be saved, but we're not firing any saved notifications @@ -1263,7 +1265,7 @@ public class ContentService : RepositoryService, IContentService EventMessages evtMsgs = EventMessagesFactory.Get(); - culture = culture?.NullOrWhiteSpaceAsNull(); + culture = culture?.NullOrWhiteSpaceAsNull().EnsureCultureCode(); PublishedState publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) @@ -2063,7 +2065,7 @@ public class ContentService : RepositoryService, IContentService cultures = ["*"]; } - return cultures; + return cultures.Select(x => x.EnsureCultureCode()!).ToArray(); } private static bool ProvidedCulturesIndicatePublishAll(string[] cultures) => cultures.Length == 0 || (cultures.Length == 1 && cultures[0] == "invariant"); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceVariantTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceVariantTests.cs new file mode 100644 index 0000000000..b93800fe91 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceVariantTests.cs @@ -0,0 +1,151 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +// ReSharper disable CommentTypo +// ReSharper disable StringLiteralTypo +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class ContentServiceVariantTests : UmbracoIntegrationTest +{ + private IContentService ContentService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + /// + /// Provides both happy path with correctly cased cultures, and originally failing test cases for + /// https://github.com/umbraco/Umbraco-CMS/issues/19287, where the culture codes are provided with inconsistent casing. + /// + [TestCase("en-US", "en-US", "en-US")] + [TestCase("en-us", "en-us", "en-us")] + [TestCase("en-US", "en-US", "en-us")] + [TestCase("en-us", "en-US", "en-US")] + [TestCase("en-US", "en-us", "en-US")] + public async Task Can_Save_And_Publish_With_Inconsistent_Provision_Of_Culture_Codes(string cultureNameCultureCode, string valueCultureCode, string publishCultureCode) + { + var contentType = await SetupVariantTest(); + + IContent content = ContentService.Create("Test Item", Constants.System.Root, contentType); + content.SetCultureName("Test item", cultureNameCultureCode); + content.SetValue("title", "Title", valueCultureCode); + ContentService.Save(content); + + var publishResult = ContentService.Publish(content, [publishCultureCode]); + Assert.IsTrue(publishResult.Success); + + content = ContentService.GetById(content.Key)!; + Assert.Multiple(() => + { + Assert.IsTrue(content.Published); + Assert.AreEqual(1, content.PublishedCultures.Count()); + Assert.AreEqual("en-US", content.PublishedCultures.FirstOrDefault()); + }); + } + + [TestCase("en-US", "en-US", "en-US")] + [TestCase("en-us", "en-us", "en-us")] + [TestCase("en-US", "en-US", "en-us")] + [TestCase("en-us", "en-US", "en-US")] + [TestCase("en-US", "en-us", "en-US")] + public async Task Can_Unpublish_With_Inconsistent_Provision_Of_Culture_Codes(string cultureNameCultureCode, string valueCultureCode, string unpublishCultureCode) + { + var contentType = await SetupVariantTest(); + + IContent content = ContentService.Create("Test Item", Constants.System.Root, contentType); + content.SetCultureName("Test item", cultureNameCultureCode); + content.SetValue("title", "Title", valueCultureCode); + ContentService.Save(content); + // use correctly cased culture code to publish + ContentService.Publish(content, ["en-US"]); + + var unpublishResult = ContentService.Unpublish(content, unpublishCultureCode); + Assert.IsTrue(unpublishResult.Success); + + content = ContentService.GetById(content.Key)!; + Assert.Multiple(() => + { + Assert.IsFalse(content.Published); + Assert.AreEqual(0, content.PublishedCultures.Count()); + }); + } + + [TestCase("en-US", "en-US", "en-US")] + [TestCase("en-us", "en-us", "en-us")] + [TestCase("en-US", "en-US", "en-us")] + [TestCase("en-us", "en-US", "en-US")] + [TestCase("en-US", "en-us", "en-US")] + public async Task Can_Publish_Branch_With_Inconsistent_Provision_Of_Culture_Codes(string cultureNameCultureCode, string valueCultureCode, string publishCultureCode) + { + var contentType = await SetupVariantTest(); + + IContent root = ContentService.Create("Root", Constants.System.Root, contentType); + root.SetCultureName("Root", cultureNameCultureCode); + root.SetValue("title", "Root Title", valueCultureCode); + ContentService.Save(root); + + var child = ContentService.Create("Child", root.Id, contentType); + child.SetCultureName("Child", cultureNameCultureCode); + child.SetValue("title", "Child Title", valueCultureCode); + ContentService.Save(child); + + var publishResult = ContentService.PublishBranch(root, PublishBranchFilter.All, [publishCultureCode]); + Assert.AreEqual(2, publishResult.Count()); + Assert.IsTrue(publishResult.First().Success); + Assert.IsTrue(publishResult.Last().Success); + + root = ContentService.GetById(root.Key)!; + Assert.Multiple(() => + { + Assert.IsTrue(root.Published); + Assert.AreEqual(1, root.PublishedCultures.Count()); + Assert.AreEqual("en-US", root.PublishedCultures.FirstOrDefault()); + }); + + child = ContentService.GetById(child.Key)!; + Assert.Multiple(() => + { + Assert.IsTrue(child.Published); + Assert.AreEqual(1, child.PublishedCultures.Count()); + Assert.AreEqual("en-US", child.PublishedCultures.FirstOrDefault()); + }); + } + + private async Task SetupVariantTest() + { + var key = Guid.NewGuid(); + var contentType = new ContentTypeBuilder() + .WithAlias("variantContent") + .WithName("Variant Content") + .WithKey(key) + .WithContentVariation(ContentVariation.Culture) + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithVariations(ContentVariation.Culture) + .Done() + .Done() + .Build(); + + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + contentType.AllowedContentTypes = [new ContentTypeSort(contentType.Key, 0, contentType.Alias)]; + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + + return contentType; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/StringExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/StringExtensionsTests.cs index 58b73ccf83..d0f93044c3 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/StringExtensionsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/StringExtensionsTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -395,4 +396,17 @@ public class StringExtensionsTests var ids = input.GetIdsFromPathReversed(); Assert.AreEqual(expected, string.Join(",", ids)); } + + [TestCase(null, null)] + [TestCase("", "")] + [TestCase("*", "*")] + [TestCase("en", "en")] + [TestCase("EN", "en")] + [TestCase("en-US", "en-US")] + [TestCase("en-gb", "en-GB")] + public void EnsureCultureCode_ReturnsExpectedResult(string? culture, string? expected) => Assert.AreEqual(expected, culture.EnsureCultureCode()); + + [Test] + [Platform(Include = "Win")] + public void EnsureCultureCode_ThrowsOnUnrecognisedCode() => Assert.Throws(() => "xxx-xxx".EnsureCultureCode()); }