Migrations: Create missing tabs on content types when referenced by both composition and content type groups (closes #20058) (#20303)
* Add migration to create missing tabs In v13, if a tab had groups in both a composition and the content type, the tab might not exist on the content type itself. Newer versions require such tabs to also exist directly on the content type. This migration ensures those tabs are created. Also fixes an issue in LeftJoin where nested sql arguments were being discarded. * Small fixes * WIP: Integration test. * Added asserts to show the current issue with the integration test. * Adjusted the integration test * Added logging of result. Minor re-order and extraction refactoring in integration test. --------- Co-authored-by: Andy Butland <abutland73@gmail.com>
This commit is contained in:
@@ -45,6 +45,7 @@ public class PropertyGroupBuilder<TParent>
|
||||
private int? _sortOrder;
|
||||
private bool? _supportsPublishing;
|
||||
private DateTime? _updateDate;
|
||||
private PropertyGroupType? _type;
|
||||
|
||||
public PropertyGroupBuilder(TParent parentBuilder)
|
||||
: base(parentBuilder)
|
||||
@@ -99,6 +100,12 @@ public class PropertyGroupBuilder<TParent>
|
||||
set => _updateDate = value;
|
||||
}
|
||||
|
||||
public PropertyGroupBuilder<TParent> WithType(PropertyGroupType type)
|
||||
{
|
||||
_type = type;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyGroupBuilder<TParent> WithPropertyTypeCollection(PropertyTypeCollection propertyTypeCollection)
|
||||
{
|
||||
_propertyTypeCollection = propertyTypeCollection;
|
||||
@@ -122,6 +129,7 @@ public class PropertyGroupBuilder<TParent>
|
||||
var name = _name ?? Guid.NewGuid().ToString();
|
||||
var sortOrder = _sortOrder ?? 0;
|
||||
var supportsPublishing = _supportsPublishing ?? false;
|
||||
var type = _type ?? PropertyGroupType.Group;
|
||||
|
||||
PropertyTypeCollection propertyTypeCollection;
|
||||
if (_propertyTypeCollection != null)
|
||||
@@ -145,7 +153,8 @@ public class PropertyGroupBuilder<TParent>
|
||||
Name = name,
|
||||
SortOrder = sortOrder,
|
||||
CreateDate = createDate,
|
||||
UpdateDate = updateDate
|
||||
UpdateDate = updateDate,
|
||||
Type = type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NPoco;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.DocumentType;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_4_0;
|
||||
using Umbraco.Cms.Infrastructure.Persistence;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using Umbraco.Cms.Infrastructure.Scoping;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Builders.Extensions;
|
||||
using Umbraco.Cms.Tests.Integration.TestServerTest;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Migrations.Upgrade.V16_4_0;
|
||||
|
||||
[TestFixture]
|
||||
internal sealed class CreateMissingTabsTest : UmbracoTestServerTestBase
|
||||
{
|
||||
private IScopeProvider ScopeProvider => GetRequiredService<IScopeProvider>();
|
||||
|
||||
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
|
||||
|
||||
private IUmbracoMapper UmbracoMapper => GetRequiredService<IUmbracoMapper>();
|
||||
|
||||
/// <summary>
|
||||
/// A verification integration test for the solution to https://github.com/umbraco/Umbraco-CMS/issues/20058
|
||||
/// provided in https://github.com/umbraco/Umbraco-CMS/pull/20303.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Can_Create_Missing_Tabs()
|
||||
{
|
||||
// Prepare a base and composed content type.
|
||||
(IContentType baseContentType, IContentType composedContentType) = await PrepareTestData();
|
||||
|
||||
// Assert the groups and properties are created in the database and that the content type model is as expected.
|
||||
await AssertValidDbGroupsAndProperties(baseContentType.Id, composedContentType.Id);
|
||||
await AssertValidContentTypeModel(composedContentType.Key);
|
||||
|
||||
// Prepare the database state as it would have been in Umbraco 13.
|
||||
await PreparePropertyGroupPersistedStateForUmbraco13(composedContentType);
|
||||
|
||||
// Assert that the content type groups are now without a parent tab.
|
||||
await AssertInvalidContentTypeModel(composedContentType.Key);
|
||||
|
||||
// Run the migration to add the missing tab back.
|
||||
await ExecuteMigration();
|
||||
|
||||
// Re-retrieve the content types and assert that the groups and types are as expected.
|
||||
await AssertValidContentTypeModel(composedContentType.Key);
|
||||
|
||||
// Verify in the database that the migration has re-added only the record we removed in the setup.
|
||||
await AssertValidDbGroupsAndProperties(baseContentType.Id, composedContentType.Id);
|
||||
}
|
||||
|
||||
private async Task<(IContentType BaseContentType, IContentType ComposedContentType)> PrepareTestData()
|
||||
{
|
||||
// Prepare document types as per reproduction steps described here: https://github.com/umbraco/Umbraco-CMS/issues/20058#issuecomment-3332742559
|
||||
// - Create a new composition with a tab "Content" and inside add a group "Header" with a "Text 1" property inside.
|
||||
// - Save the composition.
|
||||
// - Create a new document type and inherit the composition created in step 2.
|
||||
// - Add a new property "Text 2" to the Content > Header group.
|
||||
// - Create a new group "Home Content", inside the "Content" tab, and add a property "Text 3".
|
||||
// - Save the document type.
|
||||
|
||||
// Create base content type.
|
||||
var baseContentType = new ContentTypeBuilder()
|
||||
.WithAlias("baseType")
|
||||
.WithName("Base Type")
|
||||
.AddPropertyGroup()
|
||||
.WithAlias("content")
|
||||
.WithName("Content")
|
||||
.WithType(PropertyGroupType.Tab)
|
||||
.Done()
|
||||
.AddPropertyGroup()
|
||||
.WithAlias("content/header")
|
||||
.WithName("Header")
|
||||
.WithType(PropertyGroupType.Group)
|
||||
.AddPropertyType()
|
||||
.WithAlias("text1")
|
||||
.WithName("Text 1")
|
||||
.Done()
|
||||
.Done()
|
||||
.Build();
|
||||
await ContentTypeService.CreateAsync(baseContentType, Constants.Security.SuperUserKey);
|
||||
baseContentType = await ContentTypeService.GetAsync(baseContentType.Key);
|
||||
|
||||
// Create composed content type.
|
||||
var composedContentType = new ContentTypeBuilder()
|
||||
.WithAlias("composedType")
|
||||
.WithName("Composed Type")
|
||||
.AddPropertyGroup()
|
||||
.WithAlias("content")
|
||||
.WithName("Content")
|
||||
.WithType(PropertyGroupType.Tab)
|
||||
.Done()
|
||||
.AddPropertyGroup()
|
||||
.WithAlias("content/header")
|
||||
.WithName("Header")
|
||||
.WithType(PropertyGroupType.Group)
|
||||
.AddPropertyType()
|
||||
.WithAlias("text2")
|
||||
.WithName("Text 2")
|
||||
.Done()
|
||||
.Done()
|
||||
.AddPropertyGroup()
|
||||
.WithAlias("content/homeContent")
|
||||
.WithName("Home Content")
|
||||
.WithType(PropertyGroupType.Group)
|
||||
.AddPropertyType()
|
||||
.WithAlias("text3")
|
||||
.WithName("Text 3")
|
||||
.Done()
|
||||
.Done()
|
||||
.Build();
|
||||
composedContentType.ContentTypeComposition = [baseContentType];
|
||||
await ContentTypeService.CreateAsync(composedContentType, Constants.Security.SuperUserKey);
|
||||
composedContentType = await ContentTypeService.GetAsync(composedContentType.Key);
|
||||
return (baseContentType, composedContentType);
|
||||
}
|
||||
|
||||
private async Task AssertValidDbGroupsAndProperties(int baseContentTypeId, int composedContentTypeId)
|
||||
{
|
||||
using IScope scope = ScopeProvider.CreateScope();
|
||||
Sql<ISqlContext> groupsSql = scope.Database.SqlContext.Sql()
|
||||
.Select<PropertyTypeGroupDto>()
|
||||
.From<PropertyTypeGroupDto>()
|
||||
.WhereIn<PropertyTypeGroupDto>(x => x.ContentTypeNodeId, new[] { baseContentTypeId, composedContentTypeId });
|
||||
var groups = await scope.Database.FetchAsync<PropertyTypeGroupDto>(groupsSql);
|
||||
Assert.AreEqual(5, groups.Count);
|
||||
|
||||
Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == baseContentTypeId && x.Type == (int)PropertyGroupType.Tab));
|
||||
Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == baseContentTypeId && x.Type == (int)PropertyGroupType.Group));
|
||||
|
||||
Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == composedContentTypeId && x.Type == (int)PropertyGroupType.Tab));
|
||||
Assert.AreEqual(2, groups.Count(x => x.ContentTypeNodeId == composedContentTypeId && x.Type == (int)PropertyGroupType.Group));
|
||||
|
||||
Sql<ISqlContext> propertiesSql = scope.Database.SqlContext.Sql()
|
||||
.Select<PropertyTypeDto>()
|
||||
.From<PropertyTypeDto>()
|
||||
.WhereIn<PropertyTypeDto>(x => x.ContentTypeId, new[] { baseContentTypeId, composedContentTypeId });
|
||||
var types = await scope.Database.FetchAsync<PropertyTypeDto>(propertiesSql);
|
||||
Assert.AreEqual(3, types.Count);
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
private async Task AssertValidContentTypeModel(Guid contentTypeKey)
|
||||
{
|
||||
var contentType = await ContentTypeService.GetAsync(contentTypeKey);
|
||||
DocumentTypeResponseModel model = UmbracoMapper.Map<DocumentTypeResponseModel>(contentType)!;
|
||||
Assert.AreEqual(3, model.Containers.Count());
|
||||
|
||||
var contentTab = model.Containers.FirstOrDefault(c => c.Name == "Content" && c.Type == nameof(PropertyGroupType.Tab));
|
||||
Assert.IsNotNull(contentTab);
|
||||
|
||||
var headerGroup = model.Containers.FirstOrDefault(c => c.Name == "Header" && c.Type == nameof(PropertyGroupType.Group));
|
||||
Assert.IsNotNull(headerGroup);
|
||||
Assert.IsNotNull(headerGroup.Parent);
|
||||
Assert.AreEqual(contentTab.Id, headerGroup.Parent.Id);
|
||||
|
||||
var homeContentGroup = model.Containers.FirstOrDefault(c => c.Name == "Home Content" && c.Type == nameof(PropertyGroupType.Group));
|
||||
Assert.IsNotNull(homeContentGroup);
|
||||
Assert.IsNotNull(homeContentGroup.Parent);
|
||||
Assert.AreEqual(contentTab.Id, homeContentGroup.Parent.Id);
|
||||
}
|
||||
|
||||
private async Task PreparePropertyGroupPersistedStateForUmbraco13(IContentType composedContentType)
|
||||
{
|
||||
// Delete one of the tab records so we get to the 13 state.
|
||||
using IScope scope = ScopeProvider.CreateScope();
|
||||
Sql<ISqlContext> deleteTabSql = scope.Database.SqlContext.Sql()
|
||||
.Delete<PropertyTypeGroupDto>()
|
||||
.Where<PropertyTypeGroupDto>(x => x.Type == (int)PropertyGroupType.Tab && x.ContentTypeNodeId == composedContentType.Id);
|
||||
var deletedCount = await scope.Database.ExecuteAsync(deleteTabSql);
|
||||
scope.Complete();
|
||||
Assert.AreEqual(1, deletedCount);
|
||||
}
|
||||
|
||||
private async Task AssertInvalidContentTypeModel(Guid contentTypeKey)
|
||||
{
|
||||
var contentType = await ContentTypeService.GetAsync(contentTypeKey);
|
||||
DocumentTypeResponseModel model = UmbracoMapper.Map<DocumentTypeResponseModel>(contentType)!;
|
||||
Assert.AreEqual(2, model.Containers.Count());
|
||||
|
||||
var contentTab = model.Containers.FirstOrDefault(c => c.Name == "Content" && c.Type == nameof(PropertyGroupType.Tab));
|
||||
Assert.IsNull(contentTab);
|
||||
|
||||
var headerGroup = model.Containers.FirstOrDefault(c => c.Name == "Header" && c.Type == nameof(PropertyGroupType.Group));
|
||||
Assert.IsNotNull(headerGroup);
|
||||
Assert.IsNull(headerGroup.Parent);
|
||||
|
||||
var homeContentGroup = model.Containers.FirstOrDefault(c => c.Name == "Home Content" && c.Type == nameof(PropertyGroupType.Group));
|
||||
Assert.IsNotNull(homeContentGroup);
|
||||
Assert.IsNull(homeContentGroup.Parent);
|
||||
}
|
||||
|
||||
private async Task ExecuteMigration()
|
||||
{
|
||||
using IScope scope = ScopeProvider.CreateScope();
|
||||
await CreateMissingTabs.ExecuteMigration(scope.Database, new NullLogger<CreateMissingTabs>());
|
||||
scope.Complete();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user