From ac8cfcf6340623658f3d5276c0368f7526eb5860 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 31 Jan 2023 12:20:46 +0100 Subject: [PATCH] Align template API with dictionary API (#13714) * Align the template services and API with the dictionary ones (use attempt pattern) * A little controller clean-up * Mimic old file service behavior, make unit tests happy * Align CreateForContentTypeAsync return value with the rest of the TemplateService API * Scaffold endpoint should no longer feature master templates * Update the OpenAPI JSON --- .../Template/ByKeyTemplateController.cs | 2 +- .../Template/CreateTemplateController.cs | 19 +- .../Template/DeleteTemplateController.cs | 13 +- .../Template/ScaffoldTemplateController.cs | 4 +- .../Template/TemplateControllerBase.cs | 19 +- .../Template/UpdateTemplateController.cs | 14 +- src/Umbraco.Cms.Api.Management/OpenApi.json | 39 ++- src/Umbraco.Core/Services/FileService.cs | 107 +++++-- src/Umbraco.Core/Services/ITemplateService.cs | 59 ++-- .../TemplateOperationStatus.cs | 10 + src/Umbraco.Core/Services/TemplateService.cs | 187 +++++------- .../Controllers/TemplateController.cs | 33 ++- .../Testing/UmbracoIntegrationTest.cs | 10 + .../Packaging/PackageDataInstallationTests.cs | 6 + .../Services/TemplateServiceTests.cs | 279 ++++++++++++++++++ 15 files changed, 604 insertions(+), 197 deletions(-) create mode 100644 src/Umbraco.Core/Services/OperationStatus/TemplateOperationStatus.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/ByKeyTemplateController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/ByKeyTemplateController.cs index c541200111..51040067ee 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/ByKeyTemplateController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/ByKeyTemplateController.cs @@ -24,7 +24,7 @@ public class ByKeyTemplateController : TemplateControllerBase [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> ByKey(Guid key) { - ITemplate? template = await _templateService.GetTemplateAsync(key); + ITemplate? template = await _templateService.GetAsync(key); return template == null ? NotFound() : Ok(_umbracoMapper.Map(template)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/CreateTemplateController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/CreateTemplateController.cs index 403d7cc593..1d8331f624 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/CreateTemplateController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/CreateTemplateController.cs @@ -1,10 +1,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.ViewModels.Template; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Template; @@ -12,32 +13,30 @@ public class CreateTemplateController : TemplateControllerBase { private readonly ITemplateService _templateService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly ITemplateContentParserService _templateContentParserService; public CreateTemplateController( ITemplateService templateService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - ITemplateContentParserService templateContentParserService) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { _templateService = templateService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _templateContentParserService = templateContentParserService; } [HttpPost] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Create(TemplateCreateModel createModel) + public async Task Create(TemplateCreateModel createModel) { - ITemplate? template = await _templateService.CreateTemplateWithIdentityAsync( + Attempt result = await _templateService.CreateAsync( createModel.Name, createModel.Alias, createModel.Content, CurrentUserId(_backOfficeSecurityAccessor)); - return template == null - ? NotFound() - : CreatedAtAction(controller => nameof(controller.ByKey), template.Key); + return result.Success + ? CreatedAtAction(controller => nameof(controller.ByKey), result.Result.Key) + : TemplateOperationStatusResult(result.Status); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/DeleteTemplateController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/DeleteTemplateController.cs index ed82af6fd3..d6f31e67ce 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/DeleteTemplateController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/DeleteTemplateController.cs @@ -1,7 +1,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Template; @@ -19,9 +22,13 @@ public class DeleteTemplateController : TemplateControllerBase [HttpDelete("{key:guid}")] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Delete(Guid key) - => await _templateService.DeleteTemplateAsync(key, CurrentUserId(_backOfficeSecurityAccessor)) + public async Task Delete(Guid key) + { + Attempt result = await _templateService.DeleteAsync(key, CurrentUserId(_backOfficeSecurityAccessor)); + return result.Success ? Ok() - : NotFound(); + : TemplateOperationStatusResult(result.Status); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/ScaffoldTemplateController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/ScaffoldTemplateController.cs index f8691ec19f..609a099086 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/ScaffoldTemplateController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/ScaffoldTemplateController.cs @@ -16,11 +16,11 @@ public class ScaffoldTemplateController : TemplateControllerBase [MapToApiVersion("1.0")] [ProducesResponseType(typeof(TemplateScaffoldViewModel), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> Scaffold(string? masterTemplateAlias = null) + public async Task> Scaffold() { var scaffoldViewModel = new TemplateScaffoldViewModel { - Content = _defaultViewContentProvider.GetDefaultFileContent(masterTemplateAlias) + Content = _defaultViewContentProvider.GetDefaultFileContent() }; return await Task.FromResult(Ok(scaffoldViewModel)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/TemplateControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/TemplateControllerBase.cs index fac315a8cf..1d2f800a71 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/TemplateControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/TemplateControllerBase.cs @@ -1,6 +1,9 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Template; @@ -10,4 +13,18 @@ namespace Umbraco.Cms.Api.Management.Controllers.Template; [ApiVersion("1.0")] public class TemplateControllerBase : ManagementApiControllerBase { + protected IActionResult TemplateOperationStatusResult(TemplateOperationStatus status) => + status switch + { + TemplateOperationStatus.TemplateNotFound => NotFound("The template could not be found"), + TemplateOperationStatus.InvalidAlias => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid alias") + .WithDetail("The template alias is not valid.") + .Build()), + TemplateOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cancelled by notification") + .WithDetail("A notification handler prevented the template operation.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown template operation status") + }; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/UpdateTemplateController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/UpdateTemplateController.cs index 92801f3110..4b1462e252 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/UpdateTemplateController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/UpdateTemplateController.cs @@ -1,10 +1,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.ViewModels.Template; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Template; @@ -27,18 +29,22 @@ public class UpdateTemplateController : TemplateControllerBase [HttpPut("{key:guid}")] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Update(Guid key, TemplateUpdateModel updateModel) + public async Task Update(Guid key, TemplateUpdateModel updateModel) { - ITemplate? template = await _templateService.GetTemplateAsync(key); + ITemplate? template = await _templateService.GetAsync(key); if (template == null) { return NotFound(); } template = _umbracoMapper.Map(updateModel, template); - await _templateService.SaveTemplateAsync(template, CurrentUserId(_backOfficeSecurityAccessor)); - return Ok(); + Attempt result = await _templateService.UpdateAsync(template, CurrentUserId(_backOfficeSecurityAccessor)); + + return result.Success ? + Ok() + : TemplateOperationStatusResult(result.Status); } } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 7d90885c3f..4fc53fa6b1 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -4337,6 +4337,16 @@ "201": { "description": "Created" }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "404": { "description": "Not Found", "content": { @@ -4410,6 +4420,16 @@ "200": { "description": "Success" }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "404": { "description": "Not Found", "content": { @@ -4451,6 +4471,16 @@ "200": { "description": "Success" }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "404": { "description": "Not Found", "content": { @@ -4519,15 +4549,6 @@ "Template" ], "operationId": "GetTemplateScaffold", - "parameters": [ - { - "name": "masterTemplateAlias", - "in": "query", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "description": "Success", diff --git a/src/Umbraco.Core/Services/FileService.cs b/src/Umbraco.Core/Services/FileService.cs index b78b987c4c..354cd0a0ef 100644 --- a/src/Umbraco.Core/Services/FileService.cs +++ b/src/Umbraco.Core/Services/FileService.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; using File = System.IO.File; @@ -32,7 +33,7 @@ public class FileService : RepositoryService, IFileService private readonly IScriptRepository _scriptRepository; private readonly IStylesheetRepository _stylesheetRepository; private readonly ITemplateService _templateService; - private readonly IShortStringHelper _shortStringHelper; + private readonly ITemplateRepository _templateRepository; [Obsolete("Use other ctor - will be removed in Umbraco 15")] public FileService( @@ -77,9 +78,9 @@ public class FileService : RepositoryService, IFileService IAuditRepository auditRepository, IHostingEnvironment hostingEnvironment, ITemplateService templateService, + ITemplateRepository templateRepository, // unused dependencies but the DI forces us to have them, otherwise we'll get an "ambiguous constructor" // exception (and [ActivatorUtilitiesConstructor] doesn't work here either) - ITemplateRepository templateRepository, IShortStringHelper shortStringHelper, IOptions globalSettings) : base(uowProvider, loggerFactory, eventMessagesFactory) @@ -91,7 +92,7 @@ public class FileService : RepositoryService, IFileService _auditRepository = auditRepository; _hostingEnvironment = hostingEnvironment; _templateService = templateService; - _shortStringHelper = shortStringHelper; + _templateRepository = templateRepository; } #region Stylesheets @@ -372,7 +373,21 @@ public class FileService : RepositoryService, IFileService [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public Attempt?> CreateTemplateForContentType( string contentTypeAlias, string? contentTypeName, int userId = Constants.Security.SuperUserId) - => _templateService.CreateTemplateForContentTypeAsync(contentTypeAlias, contentTypeName, userId).GetAwaiter().GetResult(); + { + // mimic old service behavior + if (contentTypeAlias.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } + + Attempt result = _templateService.CreateForContentTypeAsync(contentTypeAlias, contentTypeName, userId).GetAwaiter().GetResult(); + + // mimic old service behavior + EventMessages eventMessages = EventMessagesFactory.Get(); + return result.Success + ? OperationResult.Attempt.Succeed(OperationResultType.Success, eventMessages, result.Result) + : OperationResult.Attempt.Succeed(OperationResultType.Failed, eventMessages, result.Result); + } /// /// Create a new template, setting the content if a view exists in the filesystem @@ -390,8 +405,18 @@ public class FileService : RepositoryService, IFileService string? content, ITemplate? masterTemplate = null, int userId = Constants.Security.SuperUserId) - => _templateService.CreateTemplateWithIdentityAsync(name, alias, content, userId).GetAwaiter().GetResult() - ?? new Template(_shortStringHelper, name, alias); + { + // mimic old service behavior + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(alias); + if (name.Length > 255) + { + throw new ArgumentOutOfRangeException(nameof(name), "Name cannot be more than 255 characters in length."); + } + + Attempt result = _templateService.CreateAsync(name, alias, content, userId).GetAwaiter().GetResult(); + return result.Result; + } /// /// Gets a list of all objects @@ -399,7 +424,7 @@ public class FileService : RepositoryService, IFileService /// An enumerable list of objects [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public IEnumerable GetTemplates(params string[] aliases) - => _templateService.GetTemplatesAsync(aliases).GetAwaiter().GetResult(); + => _templateService.GetAllAsync(aliases).GetAwaiter().GetResult(); /// /// Gets a list of all objects @@ -407,7 +432,7 @@ public class FileService : RepositoryService, IFileService /// An enumerable list of objects [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public IEnumerable GetTemplates(int masterTemplateId) - => _templateService.GetTemplatesAsync(masterTemplateId).GetAwaiter().GetResult(); + => _templateService.GetChildrenAsync(masterTemplateId).GetAwaiter().GetResult(); /// /// Gets a object by its alias. @@ -416,7 +441,7 @@ public class FileService : RepositoryService, IFileService /// The object matching the alias, or null. [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public ITemplate? GetTemplate(string? alias) - => _templateService.GetTemplateAsync(alias).GetAwaiter().GetResult(); + => _templateService.GetAsync(alias).GetAwaiter().GetResult(); /// /// Gets a object by its identifier. @@ -425,7 +450,7 @@ public class FileService : RepositoryService, IFileService /// The object matching the identifier, or null. [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public ITemplate? GetTemplate(int id) - => _templateService.GetTemplateAsync(id).GetAwaiter().GetResult(); + => _templateService.GetAsync(id).GetAwaiter().GetResult(); /// /// Gets a object by its guid identifier. @@ -434,7 +459,7 @@ public class FileService : RepositoryService, IFileService /// The object matching the identifier, or null. [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public ITemplate? GetTemplate(Guid id) - => _templateService.GetTemplateAsync(id).GetAwaiter().GetResult(); + => _templateService.GetAsync(id).GetAwaiter().GetResult(); /// /// Gets the template descendants @@ -443,7 +468,7 @@ public class FileService : RepositoryService, IFileService /// [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public IEnumerable GetTemplateDescendants(int masterTemplateId) - => _templateService.GetTemplateDescendantsAsync(masterTemplateId).GetAwaiter().GetResult(); + => _templateService.GetDescendantsAsync(masterTemplateId).GetAwaiter().GetResult(); /// /// Saves a @@ -452,16 +477,62 @@ public class FileService : RepositoryService, IFileService /// [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public void SaveTemplate(ITemplate template, int userId = Constants.Security.SuperUserId) - => _templateService.SaveTemplateAsync(template, userId).GetAwaiter().GetResult(); + { + // mimic old service behavior + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + + if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 255) + { + throw new InvalidOperationException( + "Name cannot be null, empty, contain only white-space characters or be more than 255 characters in length."); + } + + if (template.Id > 0) + { + _templateService.UpdateAsync(template, userId).GetAwaiter().GetResult(); + } + else + { + _templateService.CreateAsync(template, userId).GetAwaiter().GetResult(); + } + } /// /// Saves a collection of objects /// /// List of to save /// Optional id of the user + // FIXME: we need to re-implement PackageDataInstallation.ImportTemplates so it imports templates in the correct order + // instead of relying on being able to save invalid templates (child templates whose master has yet to be created) [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public void SaveTemplate(IEnumerable templates, int userId = Constants.Security.SuperUserId) - => _templateService.SaveTemplateAsync(templates, userId).GetAwaiter().GetResult(); + { + ITemplate[] templatesA = templates.ToArray(); + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new TemplateSavingNotification(templatesA, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return; + } + + foreach (ITemplate template in templatesA) + { + _templateRepository.Save(template); + } + + scope.Notifications.Publish( + new TemplateSavedNotification(templatesA, eventMessages).WithStateFrom(savingNotification)); + + Audit(AuditType.Save, userId, -1, UmbracoObjectTypes.Template.GetName()); + scope.Complete(); + } + } /// /// Deletes a template by its alias @@ -470,22 +541,22 @@ public class FileService : RepositoryService, IFileService /// [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public void DeleteTemplate(string alias, int userId = Constants.Security.SuperUserId) - => _templateService.DeleteTemplateAsync(alias, userId).GetAwaiter().GetResult(); + => _templateService.DeleteAsync(alias, userId).GetAwaiter().GetResult(); /// [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public Stream GetTemplateFileContentStream(string filepath) - => _templateService.GetTemplateFileContentStreamAsync(filepath).GetAwaiter().GetResult(); + => _templateService.GetFileContentStreamAsync(filepath).GetAwaiter().GetResult(); /// [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public void SetTemplateFileContent(string filepath, Stream content) - => _templateService.SetTemplateFileContentAsync(filepath, content).GetAwaiter().GetResult(); + => _templateService.SetFileContentAsync(filepath, content).GetAwaiter().GetResult(); /// [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public long GetTemplateFileSize(string filepath) - => _templateService.GetTemplateFileSizeAsync(filepath).GetAwaiter().GetResult(); + => _templateService.GetFileSizeAsync(filepath).GetAwaiter().GetResult(); #endregion diff --git a/src/Umbraco.Core/Services/ITemplateService.cs b/src/Umbraco.Core/Services/ITemplateService.cs index a7d3d9303d..5da85c7413 100644 --- a/src/Umbraco.Core/Services/ITemplateService.cs +++ b/src/Umbraco.Core/Services/ITemplateService.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -8,49 +9,49 @@ public interface ITemplateService : IService /// Gets a list of all objects /// /// An enumerable list of objects - Task> GetTemplatesAsync(params string[] aliases); + Task> GetAllAsync(params string[] aliases); /// /// Gets a list of all objects /// /// An enumerable list of objects - Task> GetTemplatesAsync(int masterTemplateId); + Task> GetChildrenAsync(int masterTemplateId); /// /// Gets a object by its alias. /// /// The alias of the template. /// The object matching the alias, or null. - Task GetTemplateAsync(string? alias); + Task GetAsync(string? alias); /// /// Gets a object by its identifier. /// /// The identifier of the template. /// The object matching the identifier, or null. - Task GetTemplateAsync(int id); + Task GetAsync(int id); /// /// Gets a object by its guid identifier. /// /// The guid identifier of the template. /// The object matching the identifier, or null. - Task GetTemplateAsync(Guid id); + Task GetAsync(Guid id); /// /// Gets the template descendants /// /// /// - Task> GetTemplateDescendantsAsync(int masterTemplateId); + Task> GetDescendantsAsync(int masterTemplateId); /// - /// Saves a + /// Updates a /// - /// to save + /// to update /// Optional id of the user saving the template - /// True if the template was saved, false otherwise. - Task SaveTemplateAsync(ITemplate template, int userId = Constants.Security.SuperUserId); + /// + Task> UpdateAsync(ITemplate template, int userId = Constants.Security.SuperUserId); /// /// Creates a template for a content type @@ -61,12 +62,28 @@ public interface ITemplateService : IService /// /// The template created /// - Task?>> CreateTemplateForContentTypeAsync( + Task> CreateForContentTypeAsync( string contentTypeAlias, string? contentTypeName, int userId = Constants.Security.SuperUserId); - Task CreateTemplateWithIdentityAsync(string? name, string? alias, string? content, int userId = Constants.Security.SuperUserId); + /// + /// Creates a new template + /// + /// Name of the new template + /// Alias of the template + /// View content for the new template + /// Optional id of the user creating the template + /// + Task> CreateAsync(string name, string alias, string? content, int userId = Constants.Security.SuperUserId); + + /// + /// Creates a new template + /// + /// The new template + /// Optional id of the user creating the template + /// + Task> CreateAsync(ITemplate template, int userId = Constants.Security.SuperUserId); /// /// Deletes a template by its alias @@ -74,7 +91,7 @@ public interface ITemplateService : IService /// Alias of the to delete /// Optional id of the user deleting the template /// True if the template was deleted, false otherwise - Task DeleteTemplateAsync(string alias, int userId = Constants.Security.SuperUserId); + Task> DeleteAsync(string alias, int userId = Constants.Security.SuperUserId); /// /// Deletes a template by its key @@ -82,34 +99,26 @@ public interface ITemplateService : IService /// Key of the to delete /// Optional id of the user deleting the template /// True if the template was deleted, false otherwise - Task DeleteTemplateAsync(Guid key, int userId = Constants.Security.SuperUserId); - - /// - /// Saves a collection of objects - /// - /// List of to save - /// Optional id of the user - /// True if the templates were saved, false otherwise - Task SaveTemplateAsync(IEnumerable templates, int userId = Constants.Security.SuperUserId); + Task> DeleteAsync(Guid key, int userId = Constants.Security.SuperUserId); /// /// Gets the content of a template as a stream. /// /// The filesystem path to the template. /// The content of the template. - Task GetTemplateFileContentStreamAsync(string filepath); + Task GetFileContentStreamAsync(string filepath); /// /// Sets the content of a template. /// /// The filesystem path to the template. /// The content of the template. - Task SetTemplateFileContentAsync(string filepath, Stream content); + Task SetFileContentAsync(string filepath, Stream content); /// /// Gets the size of a template. /// /// The filesystem path to the template. /// The size of the template. - Task GetTemplateFileSizeAsync(string filepath); + Task GetFileSizeAsync(string filepath); } diff --git a/src/Umbraco.Core/Services/OperationStatus/TemplateOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/TemplateOperationStatus.cs new file mode 100644 index 0000000000..88811e2e44 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/TemplateOperationStatus.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum TemplateOperationStatus +{ + Success, + CancelledByNotification, + InvalidAlias, + TemplateNotFound, + MasterTemplateNotFound +} diff --git a/src/Umbraco.Core/Services/TemplateService.cs b/src/Umbraco.Core/Services/TemplateService.cs index af26d63500..a548580474 100644 --- a/src/Umbraco.Core/Services/TemplateService.cs +++ b/src/Umbraco.Core/Services/TemplateService.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; @@ -34,10 +35,10 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task?>> CreateTemplateForContentTypeAsync( + public async Task> CreateForContentTypeAsync( string contentTypeAlias, string? contentTypeName, int userId = Constants.Security.SuperUserId) { - var template = new Template(_shortStringHelper, contentTypeName, + ITemplate template = new Template(_shortStringHelper, contentTypeName, // NOTE: We are NOT passing in the content type alias here, we want to use it's name since we don't // want to save template file names as camelCase, the Template ctor will clean the alias as @@ -45,13 +46,13 @@ public class TemplateService : RepositoryService, ITemplateService // This fixes: http://issues.umbraco.org/issue/U4-7953 contentTypeName); - EventMessages eventMessages = EventMessagesFactory.Get(); - - if (contentTypeAlias != null && contentTypeAlias.Length > 255) + if (IsValidAlias(template.Alias) == false) { - throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + return Attempt.FailWithStatus(TemplateOperationStatus.InvalidAlias, template); } + EventMessages eventMessages = EventMessagesFactory.Get(); + // check that the template hasn't been created on disk before creating the content type // if it exists, set the new template content to the existing file content var content = GetViewContent(contentTypeAlias); @@ -66,56 +67,46 @@ public class TemplateService : RepositoryService, ITemplateService if (scope.Notifications.PublishCancelable(savingEvent)) { scope.Complete(); - return OperationResult.Attempt.Fail( - OperationResultType.FailedCancelledByEvent, eventMessages, template); + return Attempt.FailWithStatus(TemplateOperationStatus.CancelledByNotification, template); } _templateRepository.Save(template); scope.Notifications.Publish( new TemplateSavedNotification(template, eventMessages).WithStateFrom(savingEvent)); - Audit(AuditType.Save, userId, template.Id, UmbracoObjectTypes.Template.GetName()); + Audit(AuditType.New, userId, template.Id, UmbracoObjectTypes.Template.GetName()); scope.Complete(); } - return await Task.FromResult(OperationResult.Attempt.Succeed( - OperationResultType.Success, - eventMessages, - template)); + return await Task.FromResult(Attempt.SucceedWithStatus(TemplateOperationStatus.Success, template)); } /// - public async Task CreateTemplateWithIdentityAsync( - string? name, - string? alias, + public async Task> CreateAsync( + string name, + string alias, string? content, int userId = Constants.Security.SuperUserId) + => await CreateAsync(new Template(_shortStringHelper, name, alias) { Content = content }, userId); + + /// + public async Task> CreateAsync(ITemplate template, int userId = Constants.Security.SuperUserId) { - if (name == null) + try { - throw new ArgumentNullException(nameof(name)); + // file might already be on disk, if so grab the content to avoid overwriting + template.Content = GetViewContent(template.Alias) ?? template.Content; + return await SaveAsync(template, AuditType.New, userId); } - - if (string.IsNullOrWhiteSpace(name)) + catch (PathTooLongException ex) { - throw new ArgumentException("Name cannot be empty or contain only white-space characters", nameof(name)); + LoggerFactory.CreateLogger().LogError(ex, "The template path was too long. Consider making the template alias shorter."); + return Attempt.FailWithStatus(TemplateOperationStatus.InvalidAlias, template); } - - if (name.Length > 255) - { - throw new ArgumentOutOfRangeException(nameof(name), "Name cannot be more than 255 characters in length."); - } - - // file might already be on disk, if so grab the content to avoid overwriting - var template = new Template(_shortStringHelper, name, alias) { Content = GetViewContent(alias) ?? content }; - - return await SaveTemplateAsync(template, userId) - ? template - : null; } /// - public async Task> GetTemplatesAsync(params string[] aliases) + public async Task> GetAllAsync(params string[] aliases) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { @@ -124,7 +115,7 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task> GetTemplatesAsync(int masterTemplateId) + public async Task> GetChildrenAsync(int masterTemplateId) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { @@ -133,7 +124,7 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task GetTemplateAsync(string? alias) + public async Task GetAsync(string? alias) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { @@ -142,7 +133,7 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task GetTemplateAsync(int id) + public async Task GetAsync(int id) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { @@ -151,7 +142,7 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task GetTemplateAsync(Guid id) + public async Task GetAsync(Guid id) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { @@ -161,7 +152,7 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task> GetTemplateDescendantsAsync(int masterTemplateId) + public async Task> GetDescendantsAsync(int masterTemplateId) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { @@ -170,22 +161,42 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task SaveTemplateAsync(ITemplate template, int userId = Constants.Security.SuperUserId) - { - if (template == null) - { - throw new ArgumentNullException(nameof(template)); - } + public async Task> UpdateAsync(ITemplate template, int userId = Constants.Security.SuperUserId) + => await SaveAsync( + template, + AuditType.Save, + userId, + // fail the attempt if the template does not exist within the scope + () => _templateRepository.Exists(template.Id) + ? TemplateOperationStatus.Success + : TemplateOperationStatus.TemplateNotFound); - if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 255) + private async Task> SaveAsync(ITemplate template, AuditType auditType, int userId = Constants.Security.SuperUserId, Func? scopeValidator = null) + { + if (IsValidAlias(template.Alias) == false) { - throw new InvalidOperationException( - "Name cannot be null, empty, contain only white-space characters or be more than 255 characters in length."); + return Attempt.FailWithStatus(TemplateOperationStatus.InvalidAlias, template); } using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { - ITemplate? masterTemplate = await GetMasterTemplate(template.Content); + TemplateOperationStatus scopeValidatorStatus = scopeValidator?.Invoke() ?? TemplateOperationStatus.Success; + if (scopeValidatorStatus != TemplateOperationStatus.Success) + { + return Attempt.FailWithStatus(scopeValidatorStatus, template); + } + + var masterTemplateAlias = _templateContentParserService.MasterTemplateAlias(template.Content); + ITemplate? masterTemplate = masterTemplateAlias.IsNullOrWhiteSpace() + ? null + : await GetAsync(masterTemplateAlias); + + // fail if the template content specifies a master template but said template does not exist + if (masterTemplateAlias.IsNullOrWhiteSpace() == false && masterTemplate == null) + { + return Attempt.FailWithStatus(TemplateOperationStatus.MasterTemplateNotFound, template); + } + await SetMasterTemplateAsync(template, masterTemplate); EventMessages eventMessages = EventMessagesFactory.Get(); @@ -193,7 +204,7 @@ public class TemplateService : RepositoryService, ITemplateService if (scope.Notifications.PublishCancelable(savingNotification)) { scope.Complete(); - return false; + return Attempt.FailWithStatus(TemplateOperationStatus.CancelledByNotification, template); } _templateRepository.Save(template); @@ -201,50 +212,22 @@ public class TemplateService : RepositoryService, ITemplateService scope.Notifications.Publish( new TemplateSavedNotification(template, eventMessages).WithStateFrom(savingNotification)); - Audit(AuditType.Save, userId, template.Id, UmbracoObjectTypes.Template.GetName()); + Audit(auditType, userId, template.Id, UmbracoObjectTypes.Template.GetName()); scope.Complete(); - return true; + return Attempt.SucceedWithStatus(TemplateOperationStatus.Success, template); } } /// - public async Task SaveTemplateAsync(IEnumerable templates, int userId = Constants.Security.SuperUserId) - { - ITemplate[] templatesA = templates.ToArray(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - EventMessages eventMessages = EventMessagesFactory.Get(); - var savingNotification = new TemplateSavingNotification(templatesA, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return false; - } - - foreach (ITemplate template in templatesA) - { - _templateRepository.Save(template); - } - - scope.Notifications.Publish( - new TemplateSavedNotification(templatesA, eventMessages).WithStateFrom(savingNotification)); - - Audit(AuditType.Save, userId, -1, UmbracoObjectTypes.Template.GetName()); - scope.Complete(); - return await Task.FromResult(true); - } - } + public async Task> DeleteAsync(string alias, int userId = Constants.Security.SuperUserId) + => await DeleteAsync(() => Task.FromResult(_templateRepository.Get(alias)), userId); /// - public async Task DeleteTemplateAsync(string alias, int userId = Constants.Security.SuperUserId) - => await DeleteTemplateAsync(() => Task.FromResult(_templateRepository.Get(alias)), userId); + public async Task> DeleteAsync(Guid key, int userId = Constants.Security.SuperUserId) + => await DeleteAsync(async () => await GetAsync(key), userId); /// - public async Task DeleteTemplateAsync(Guid key, int userId = Constants.Security.SuperUserId) - => await DeleteTemplateAsync(async () => await GetTemplateAsync(key), userId); - - /// - public async Task GetTemplateFileContentStreamAsync(string filepath) + public async Task GetFileContentStreamAsync(string filepath) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { @@ -253,7 +236,7 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task SetTemplateFileContentAsync(string filepath, Stream content) + public async Task SetFileContentAsync(string filepath, Stream content) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { @@ -264,7 +247,7 @@ public class TemplateService : RepositoryService, ITemplateService } /// - public async Task GetTemplateFileSizeAsync(string filepath) + public async Task GetFileSizeAsync(string filepath) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { @@ -272,21 +255,6 @@ public class TemplateService : RepositoryService, ITemplateService } } - private async Task GetMasterTemplate(string? content) - { - var masterTemplateAlias = _templateContentParserService.MasterTemplateAlias(content); - ITemplate? masterTemplate = masterTemplateAlias.IsNullOrWhiteSpace() - ? null - : await GetTemplateAsync(masterTemplateAlias); - - if (masterTemplateAlias.IsNullOrWhiteSpace() == false && masterTemplate == null) - { - throw new ArgumentException($"Could not find master template with alias: {masterTemplateAlias}", content); - } - - return masterTemplate; - } - /// private async Task SetMasterTemplateAsync(ITemplate template, ITemplate? masterTemplate) { @@ -308,7 +276,7 @@ public class TemplateService : RepositoryService, ITemplateService //After updating the master - ensure we update the path property if it has any children already assigned if (template.Id > 0) { - IEnumerable templateHasChildren = await GetTemplateDescendantsAsync(template.Id); + IEnumerable templateHasChildren = await GetDescendantsAsync(template.Id); foreach (ITemplate childTemplate in templateHasChildren) { @@ -331,7 +299,7 @@ public class TemplateService : RepositoryService, ITemplateService childTemplate.Path = masterTemplate.Path + "," + template.Id + "," + childTemplatePath; //Save the children with the updated path - await SaveTemplateAsync(childTemplate); + await UpdateAsync(childTemplate); } } } @@ -366,7 +334,7 @@ public class TemplateService : RepositoryService, ITemplateService private void Audit(AuditType type, int userId, int objectId, string? entityType) => _auditRepository.Save(new AuditItem(objectId, type, userId, entityType)); - private async Task DeleteTemplateAsync(Func> getTemplate, int userId) + private async Task> DeleteAsync(Func> getTemplate, int userId) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { @@ -374,7 +342,7 @@ public class TemplateService : RepositoryService, ITemplateService if (template == null) { scope.Complete(); - return true; + return Attempt.FailWithStatus(TemplateOperationStatus.TemplateNotFound, null); } EventMessages eventMessages = EventMessagesFactory.Get(); @@ -382,7 +350,7 @@ public class TemplateService : RepositoryService, ITemplateService if (scope.Notifications.PublishCancelable(deletingNotification)) { scope.Complete(); - return false; + return Attempt.FailWithStatus(TemplateOperationStatus.CancelledByNotification, template); } _templateRepository.Delete(template); @@ -392,7 +360,10 @@ public class TemplateService : RepositoryService, ITemplateService Audit(AuditType.Delete, userId, template.Id, UmbracoObjectTypes.Template.GetName()); scope.Complete(); - return await Task.FromResult(true); + return Attempt.SucceedWithStatus(TemplateOperationStatus.Success, template); } } + + private static bool IsValidAlias(string alias) + => alias.IsNullOrWhiteSpace() == false && alias.Length <= 255; } diff --git a/src/Umbraco.Web.BackOffice/Controllers/TemplateController.cs b/src/Umbraco.Web.BackOffice/Controllers/TemplateController.cs index 46d6e31c24..f44dfc628c 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/TemplateController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/TemplateController.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; @@ -61,7 +62,7 @@ public class TemplateController : BackOfficeNotificationsController /// public TemplateDisplay? GetByAlias(string alias) { - ITemplate? template = _templateService.GetTemplateAsync(alias).GetAwaiter().GetResult(); + ITemplate? template = _templateService.GetAsync(alias).GetAwaiter().GetResult(); return template == null ? null : _umbracoMapper.Map(template); } @@ -69,7 +70,7 @@ public class TemplateController : BackOfficeNotificationsController /// Get all templates /// /// - public IEnumerable? GetAll() => _templateService.GetTemplatesAsync().GetAwaiter().GetResult() + public IEnumerable? GetAll() => _templateService.GetAllAsync().GetAwaiter().GetResult() ?.Select(_umbracoMapper.Map).WhereNotNull(); /// @@ -79,7 +80,7 @@ public class TemplateController : BackOfficeNotificationsController /// public ActionResult GetById(int id) { - ITemplate? template = _templateService.GetTemplateAsync(id).GetAwaiter().GetResult(); + ITemplate? template = _templateService.GetAsync(id).GetAwaiter().GetResult(); if (template == null) { return NotFound(); @@ -96,7 +97,7 @@ public class TemplateController : BackOfficeNotificationsController /// public ActionResult GetById(Guid id) { - ITemplate? template = _templateService.GetTemplateAsync(id).GetAwaiter().GetResult(); + ITemplate? template = _templateService.GetAsync(id).GetAwaiter().GetResult(); if (template == null) { return NotFound(); @@ -118,7 +119,7 @@ public class TemplateController : BackOfficeNotificationsController return NotFound(); } - ITemplate? template = _templateService.GetTemplateAsync(guidUdi.Guid).GetAwaiter().GetResult(); + ITemplate? template = _templateService.GetAsync(guidUdi.Guid).GetAwaiter().GetResult(); if (template == null) { return NotFound(); @@ -136,13 +137,13 @@ public class TemplateController : BackOfficeNotificationsController [HttpPost] public IActionResult DeleteById(int id) { - ITemplate? template = _templateService.GetTemplateAsync(id).GetAwaiter().GetResult(); + ITemplate? template = _templateService.GetAsync(id).GetAwaiter().GetResult(); if (template == null) { return NotFound(); } - _templateService.DeleteTemplateAsync(template.Alias).GetAwaiter().GetResult(); + _templateService.DeleteAsync(template.Alias).GetAwaiter().GetResult(); return Ok(); } @@ -156,7 +157,7 @@ public class TemplateController : BackOfficeNotificationsController if (id > 0) { - ITemplate? master = _templateService.GetTemplateAsync(id).GetAwaiter().GetResult(); + ITemplate? master = _templateService.GetAsync(id).GetAwaiter().GetResult(); if (master != null) { dt.SetMasterTemplate(master); @@ -190,7 +191,7 @@ public class TemplateController : BackOfficeNotificationsController if (display.Id > 0) { // update - ITemplate? template = _templateService.GetTemplateAsync(display.Id).GetAwaiter().GetResult(); + ITemplate? template = _templateService.GetAsync(display.Id).GetAwaiter().GetResult(); if (template == null) { return NotFound(); @@ -200,11 +201,11 @@ public class TemplateController : BackOfficeNotificationsController _umbracoMapper.Map(display, template); - _templateService.SaveTemplateAsync(template).GetAwaiter().GetResult(); + _templateService.UpdateAsync(template).GetAwaiter().GetResult(); if (changeAlias) { - template = _templateService.GetTemplateAsync(template.Id).GetAwaiter().GetResult(); + template = _templateService.GetAsync(template.Id).GetAwaiter().GetResult(); } _umbracoMapper.Map(template, display); @@ -215,7 +216,7 @@ public class TemplateController : BackOfficeNotificationsController ITemplate? master = null; if (string.IsNullOrEmpty(display.MasterTemplateAlias) == false) { - master = _templateService.GetTemplateAsync(display.MasterTemplateAlias).GetAwaiter().GetResult(); + master = _templateService.GetAsync(display.MasterTemplateAlias).GetAwaiter().GetResult(); if (master == null) { return NotFound(); @@ -224,14 +225,14 @@ public class TemplateController : BackOfficeNotificationsController // we need to pass the template name as alias to keep the template file casing consistent with templates created with content // - see comment in FileService.CreateTemplateForContentType for additional details - ITemplate? template = - _templateService.CreateTemplateWithIdentityAsync(display.Name, display.Name, display.Content).GetAwaiter().GetResult(); - if (template == null) + Attempt result = + _templateService.CreateAsync(display.Name!, display.Name!, display.Content).GetAwaiter().GetResult(); + if (result.Success == false) { return NotFound(); } - _umbracoMapper.Map(template, display); + _umbracoMapper.Map(result.Result, display); } return display; diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs index a174476acd..c83b13e0fe 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs @@ -197,4 +197,14 @@ public abstract class UmbracoIntegrationTest : UmbracoIntegrationTestBase configBuilder.AddConfiguration(GlobalSetupTeardown.TestConfiguration); } } + + protected void DeleteAllTemplateViewFiles() + { + var fileSystems = GetRequiredService(); + var viewFileSystem = fileSystems.MvcViewsFileSystem!; + foreach (var file in viewFileSystem.GetFiles(string.Empty).ToArray()) + { + viewFileSystem.DeleteFile(file); + } + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs index 4c3efd5df9..5b08214d8e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs @@ -78,6 +78,12 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent private IMediaTypeService MediaTypeService => GetRequiredService(); + public override void CreateTestData() + { + DeleteAllTemplateViewFiles(); + base.CreateTestData(); + } + [Test] public void Can_Import_uBlogsy_ContentTypes_And_Verify_Structure() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs new file mode 100644 index 0000000000..3517de7213 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs @@ -0,0 +1,279 @@ +// 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.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class TemplateServiceTests : UmbracoIntegrationTest +{ + private ITemplateService TemplateService => GetRequiredService(); + + [SetUp] + public void SetUp() => DeleteAllTemplateViewFiles(); + + [Test] + public async Task Can_Create_Template_Then_Assign_Child() + { + Attempt result = await TemplateService.CreateAsync("Child", "child", "test"); + Assert.IsTrue(result.Success); + Assert.AreEqual(TemplateOperationStatus.Success, result.Status); + var child = result.Result; + + result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + Assert.AreEqual(TemplateOperationStatus.Success, result.Status); + var parent = result.Result; + + child.Content = "Layout = \"Parent.cshtml\";"; + result = await TemplateService.UpdateAsync(child); + Assert.IsTrue(result.Success); + Assert.AreEqual(TemplateOperationStatus.Success, result.Status); + + child = await TemplateService.GetAsync(child.Key); + Assert.NotNull(child); + + Assert.AreEqual(parent.Alias, child.MasterTemplateAlias); + } + + [Test] + public async Task Can_Create_Template_With_Child_Then_Unassign() + { + Attempt result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + var parent = result.Result; + + result = await TemplateService.CreateAsync("Child", "child", "Layout = \"Parent.cshtml\";"); + Assert.IsTrue(result.Success); + var child = result.Result; + + child = await TemplateService.GetAsync(child.Key); + Assert.NotNull(child); + Assert.AreEqual("parent", child.MasterTemplateAlias); + + child.Content = "test"; + result = await TemplateService.UpdateAsync(child); + Assert.IsTrue(result.Success); + + child = await TemplateService.GetAsync(child.Key); + Assert.NotNull(child); + Assert.AreEqual(null, child.MasterTemplateAlias); + } + + [Test] + public async Task Can_Create_Template_With_Child_Then_Reassign() + { + Attempt result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + + result = await TemplateService.CreateAsync("Parent2", "parent2", "test"); + Assert.IsTrue(result.Success); + + result = await TemplateService.CreateAsync("Child", "child", "Layout = \"Parent.cshtml\";"); + Assert.IsTrue(result.Success); + var child = result.Result; + + child = await TemplateService.GetAsync(child.Key); + Assert.NotNull(child); + Assert.AreEqual("parent", child.MasterTemplateAlias); + + child.Content = "Layout = \"Parent2.cshtml\";"; + result = await TemplateService.UpdateAsync(child); + Assert.IsTrue(result.Success); + + child = await TemplateService.GetAsync(child.Key); + Assert.NotNull(child); + Assert.AreEqual("parent2", child.MasterTemplateAlias); + } + + [Test] + public async Task Child_Template_Paths_Are_Updated_When_Reassigning_Master() + { + Attempt result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + var parent = result.Result; + + result = await TemplateService.CreateAsync("Parent2", "parent2", "test"); + Assert.IsTrue(result.Success); + var parent2 = result.Result; + + result = await TemplateService.CreateAsync("Child", "child", "Layout = \"Parent.cshtml\";"); + Assert.IsTrue(result.Success); + var child = result.Result; + + result = await TemplateService.CreateAsync("Child1", "child1", "Layout = \"Child.cshtml\";"); + Assert.IsTrue(result.Success); + var childOfChild1 = result.Result; + + result = await TemplateService.CreateAsync("Child2", "child2", "Layout = \"Child.cshtml\";"); + Assert.IsTrue(result.Success); + var childOfChild2 = result.Result; + + Assert.AreEqual($"child", childOfChild1.MasterTemplateAlias); + Assert.AreEqual($"{parent.Path},{child.Id},{childOfChild1.Id}", childOfChild1.Path); + Assert.AreEqual($"child", childOfChild2.MasterTemplateAlias); + Assert.AreEqual($"{parent.Path},{child.Id},{childOfChild2.Id}", childOfChild2.Path); + + child.Content = "Layout = \"Parent2.cshtml\";"; + result = await TemplateService.UpdateAsync(child); + Assert.IsTrue(result.Success); + + childOfChild1 = await TemplateService.GetAsync(childOfChild1.Key); + Assert.NotNull(childOfChild1); + + childOfChild2 = await TemplateService.GetAsync(childOfChild2.Key); + Assert.NotNull(childOfChild2); + + Assert.AreEqual($"child", childOfChild1.MasterTemplateAlias); + Assert.AreEqual($"{parent2.Path},{child.Id},{childOfChild1.Id}", childOfChild1.Path); + Assert.AreEqual($"child", childOfChild2.MasterTemplateAlias); + Assert.AreEqual($"{parent2.Path},{child.Id},{childOfChild2.Id}", childOfChild2.Path); + } + + [Test] + public async Task Can_Query_Template_Children() + { + Attempt result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + var parent = result.Result; + + result = await TemplateService.CreateAsync("Child1", "child1", "Layout = \"Parent.cshtml\";"); + Assert.IsTrue(result.Success); + var child1 = result.Result; + + result = await TemplateService.CreateAsync("Child2", "child2", "Layout = \"Parent.cshtml\";"); + Assert.IsTrue(result.Success); + var child2 = result.Result; + + var children = await TemplateService.GetChildrenAsync(parent.Id); + + Assert.AreEqual(2, children.Count()); + Assert.NotNull(children.FirstOrDefault(t => t.Id == child1.Id)); + Assert.NotNull(children.FirstOrDefault(t => t.Id == child2.Id)); + } + + [Test] + public async Task Can_Update_Template() + { + var result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + + var parent = result.Result; + parent.Name = "Parent Updated"; + + result = await TemplateService.UpdateAsync(parent); + + Assert.IsTrue(result.Success); + + parent = await TemplateService.GetAsync(parent.Key); + Assert.IsNotNull(parent); + Assert.AreEqual("Parent Updated", parent.Name); + Assert.AreEqual("parent", parent.Alias); + } + + [Test] + public async Task Can_Delete_Template() + { + var result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + + var parent = result.Result; + + result = await TemplateService.DeleteAsync(parent.Key); + Assert.IsTrue(result.Success); + + parent = await TemplateService.GetAsync(parent.Key); + Assert.IsNull(parent); + } + + [Test] + public async Task Deleting_Master_Template_Also_Deletes_Children() + { + Attempt result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + var parent = result.Result; + + result = await TemplateService.CreateAsync("Child", "child", "Layout = \"Parent.cshtml\";"); + Assert.IsTrue(result.Success); + var child = result.Result; + Assert.AreEqual("parent", child.MasterTemplateAlias); + + result = await TemplateService.DeleteAsync(parent.Key); + Assert.IsTrue(result.Success); + + child = await TemplateService.GetAsync(child.Key); + Assert.Null(child); + } + + [Test] + public async Task Cannot_Update_Non_Existing_Template() + { + var result = await TemplateService.CreateAsync("Parent", "parent", "test"); + Assert.IsTrue(result.Success); + + var parent = result.Result; + + result = await TemplateService.DeleteAsync("parent"); + Assert.IsTrue(result.Success); + + parent.Name = "Parent Updated"; + + result = await TemplateService.UpdateAsync(parent); + Assert.IsFalse(result.Success); + Assert.AreEqual(TemplateOperationStatus.TemplateNotFound, result.Status); + } + + [Test] + public async Task Cannot_Create_Child_Template_Without_Master_Template() + { + var result = await TemplateService.CreateAsync("Child", "child", "Layout = \"Parent.cshtml\";"); + Assert.IsFalse(result.Success); + Assert.AreEqual(TemplateOperationStatus.MasterTemplateNotFound, result.Status); + } + + [Test] + public async Task Cannot_Update_Child_Template_Without_Master_Template() + { + var result = await TemplateService.CreateAsync("Child", "child", "test"); + Assert.IsTrue(result.Success); + + var child = result.Result; + child.Content = "Layout = \"Parent.cshtml\";"; + + result = await TemplateService.UpdateAsync(child); + Assert.IsFalse(result.Success); + Assert.AreEqual(TemplateOperationStatus.MasterTemplateNotFound, result.Status); + } + + [Test] + public async Task Cannot_Create_Template_With_Invalid_Alias() + { + var invalidAlias = new string('a', 256); + var result = await TemplateService.CreateAsync("Child", invalidAlias, "test"); + Assert.IsFalse(result.Success); + Assert.AreEqual(TemplateOperationStatus.InvalidAlias, result.Status); + } + + [Test] + public async Task Cannot_Update_Template_With_Invalid_Alias() + { + var result = await TemplateService.CreateAsync("Child", "child", "test"); + Assert.IsTrue(result.Success); + + var child = result.Result; + var invalidAlias = new string('a', 256); + child.Alias = invalidAlias; + + result = await TemplateService.UpdateAsync(child); + Assert.IsFalse(result.Success); + Assert.AreEqual(TemplateOperationStatus.InvalidAlias, result.Status); + } +}