diff --git a/src/VirtoCommerce.ContentModule.Data/Search/DocumentIdentifierHelper.cs b/src/VirtoCommerce.ContentModule.Data/Search/DocumentIdentifierHelper.cs index 5501249a..4a762f03 100644 --- a/src/VirtoCommerce.ContentModule.Data/Search/DocumentIdentifierHelper.cs +++ b/src/VirtoCommerce.ContentModule.Data/Search/DocumentIdentifierHelper.cs @@ -14,7 +14,7 @@ public static string GenerateId(string storeId, string contentType, ContentItem public static (string storeId, string contentType, string relativeUrl) ParseId(string id) { var decoded = Encoding.ASCII.GetString(Convert.FromBase64String(id.Replace('-', '='))); - var result = decoded.Split(new[] { "::" }, StringSplitOptions.RemoveEmptyEntries); + var result = decoded.Split(["::"], StringSplitOptions.RemoveEmptyEntries); if (result.Length == 3) { diff --git a/src/VirtoCommerce.ContentModule.Data/Services/ContentStatisticService.cs b/src/VirtoCommerce.ContentModule.Data/Services/ContentStatisticService.cs index 6669a8fb..b6f4c8db 100644 --- a/src/VirtoCommerce.ContentModule.Data/Services/ContentStatisticService.cs +++ b/src/VirtoCommerce.ContentModule.Data/Services/ContentStatisticService.cs @@ -12,19 +12,20 @@ namespace VirtoCommerce.ContentModule.Data.Services public class ContentStatisticService( IBlobContentStorageProviderFactory blobContentStorageProviderFactory, IContentPathResolver contentPathResolver, - IContentItemTypeRegistrar contentItemTypeRegistrar) + IContentItemTypeRegistrar contentItemTypeRegistrar, + IPublishingService publishingService) : IContentStatisticService { public async Task GetStorePagesCountAsync(string storeId) { - var (contentStorageProvider, path) = Prepare(storeId, ContentConstants.ContentTypes.Pages); + var contentStorageProvider = Prepare(storeId, ContentConstants.ContentTypes.Pages); var result = await CountContentItemsRecursive(folderUrl: null, contentStorageProvider, startDate: null, endDate: null, ContentConstants.ContentTypes.Blogs); return result; } public async Task GetStoreChangedPagesCountAsync(string storeId, DateTime? startDate, DateTime? endDate) { - var (contentStorageProvider, path) = Prepare(storeId, ContentConstants.ContentTypes.Pages); + var contentStorageProvider = Prepare(storeId, ContentConstants.ContentTypes.Pages); var result = await CountContentItemsRecursive(folderUrl: null, contentStorageProvider, startDate, endDate); return result; } @@ -43,17 +44,17 @@ public async Task GetStoreBlogsCountAsync(string storeId) private async Task GetFoldersCount(string storeId, string contentType) { - var (contentStorageProvider, targetPath) = Prepare(storeId, contentType); + var contentStorageProvider = Prepare(storeId, contentType); var folders = await contentStorageProvider.SearchAsync(folderUrl: null, keyword: null); var result = folders.Results.OfType().Count(); return result; } - private (IBlobContentStorageProvider provider, string targetPath) Prepare(string storeId, string contentType) + private IBlobContentStorageProvider Prepare(string storeId, string contentType) { var targetPath = contentPathResolver.GetContentBasePath(contentType, storeId); var contentStorageProvider = blobContentStorageProviderFactory.CreateProvider(targetPath); - return (contentStorageProvider, targetPath); + return contentStorageProvider; } private async Task CountContentItemsRecursive(string folderUrl, IBlobStorageProvider blobContentStorageProvider, DateTime? startDate, DateTime? endDate, string excludedFolderName = null) @@ -61,9 +62,14 @@ private async Task CountContentItemsRecursive(string folderUrl, IBlobStorag var searchResult = await blobContentStorageProvider.SearchAsync(folderUrl, keyword: null); var folders = searchResult.Results.OfType(); var blobs = searchResult.Results.OfType() - .Where(x => contentItemTypeRegistrar.IsRegisteredContentItemType(x.RelativeUrl)); + .Where(x => contentItemTypeRegistrar.IsRegisteredContentItemType(x.RelativeUrl) && + IsDateBetween(x.ModifiedDate, startDate, endDate)); + + var result = blobs + .Select(x => publishingService.GetRelativeDraftUrl(x.RelativeUrl, draft: false)) + .Distinct() + .Count(); - var result = blobs.Count(x => (startDate == null || x.ModifiedDate >= startDate) && (endDate == null || x.ModifiedDate <= endDate)); var children = folders.Where(x => (excludedFolderName.IsNullOrEmpty() || !x.Name.EqualsIgnoreCase(excludedFolderName)) // exclude predefined folders && x.Url != folderUrl); // the simplest way to avoid loop (i.e. "https://qademovc3.blob.core.windows.net/cms/Pages/Electronics/blogs/https://") @@ -76,5 +82,11 @@ private async Task CountContentItemsRecursive(string folderUrl, IBlobStorag return result; } + + private static bool IsDateBetween(DateTime? modifiedDate, DateTime? startDate, DateTime? endDate) + { + return (startDate == null || modifiedDate >= startDate) && + (endDate == null || modifiedDate <= endDate); + } } } diff --git a/src/VirtoCommerce.ContentModule.Data/Services/PublishingService.cs b/src/VirtoCommerce.ContentModule.Data/Services/PublishingService.cs index f70ead65..a77b4b8b 100644 --- a/src/VirtoCommerce.ContentModule.Data/Services/PublishingService.cs +++ b/src/VirtoCommerce.ContentModule.Data/Services/PublishingService.cs @@ -71,7 +71,8 @@ private static void SetFileStatusByName(ContentFile file) var isDraft = file.Name.EndsWith("-draft"); file.HasChanges = isDraft; file.Published = !isDraft; - file.Name = isDraft + + file.Name = file.Name.EndsWith("-draft") ? file.Name.Substring(0, file.Name.Length - "-draft".Length) : file.Name; } diff --git a/src/VirtoCommerce.ContentModule.Web/Controllers/Api/ContentController.cs b/src/VirtoCommerce.ContentModule.Web/Controllers/Api/ContentController.cs index b4b09161..75aa6c2e 100644 --- a/src/VirtoCommerce.ContentModule.Web/Controllers/Api/ContentController.cs +++ b/src/VirtoCommerce.ContentModule.Web/Controllers/Api/ContentController.cs @@ -41,7 +41,6 @@ public class ContentController( IConfiguration configuration) : Controller { - private readonly IPublishingService _publishingService = publishingService; private static readonly FormOptions _defaultFormOptions = new(); /// @@ -93,8 +92,8 @@ public async Task DeleteContent(string contentType, string storeId foreach (var url in urls) { var isFolder = true; - var draftUrl = _publishingService.GetRelativeDraftUrl(url, true); - var publishedUrl = _publishingService.GetRelativeDraftUrl(url, false); + var draftUrl = publishingService.GetRelativeDraftUrl(url, true); + var publishedUrl = publishingService.GetRelativeDraftUrl(url, false); if (await contentService.ItemExistsAsync(contentType, storeId, draftUrl)) { urlsToRemove.Add(draftUrl); @@ -116,7 +115,7 @@ public async Task DeleteContent(string contentType, string storeId } /// - /// Return streamed data for requested by relativeUrl content (Used to prevent Cross domain requests in manager) + /// Return streamed data for requested by relativeUrl content (Used to prevent Cross domain requests in manager) /// /// possible values Themes or Pages /// Store id @@ -131,13 +130,13 @@ public async Task> GetContentItemDataStream(string contentT if (draft) { // use the draft logic, try to load the draft file, and if it isn't found, load to published version - var draftUrl = _publishingService.GetRelativeDraftUrl(relativeUrl, true); + var draftUrl = publishingService.GetRelativeDraftUrl(relativeUrl, true); if (await contentService.ItemExistsAsync(contentType, storeId, draftUrl)) { var result = await contentService.GetItemStreamAsync(contentType, storeId, draftUrl); return File(result, MimeTypeResolver.ResolveContentType(relativeUrl)); } - var sourceUrl = _publishingService.GetRelativeDraftUrl(relativeUrl, false); + var sourceUrl = publishingService.GetRelativeDraftUrl(relativeUrl, false); if (await contentService.ItemExistsAsync(contentType, storeId, sourceUrl)) { var result = await contentService.GetItemStreamAsync(contentType, storeId, sourceUrl); @@ -176,7 +175,7 @@ public async Task> SearchContent(string contentType, criteria.Keyword = keyword; var result = await contentFileService.FilterItemsAsync(criteria); var folders = result.Where(x => x is not ContentFile); - var files = await _publishingService.SetFilesStatuses(result.OfType()); + var files = await publishingService.SetFilesStatuses(result.OfType()); var response = folders.Union(files); return Ok(response); } @@ -194,7 +193,7 @@ public async Task> FulltextSearchContent([FromBody] criteria.Skip = 0; criteria.Take = 100; var result = await fullTextContentSearchService.SearchAllAsync(criteria); - var response = _publishingService.SetFilesStatuses(result); + var response = publishingService.SetFilesStatuses(result); return Ok(response); } @@ -220,22 +219,22 @@ public ActionResult GetContentFullTextSearchEnabled() [ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)] public async Task MoveContent(string contentType, string storeId, [FromQuery] string oldUrl, [FromQuery] string newUrl) { - var publishedSrc = _publishingService.GetRelativeDraftUrl(oldUrl, false); - var unpublishedSrc = _publishingService.GetRelativeDraftUrl(oldUrl, true); + var publishedSrc = publishingService.GetRelativeDraftUrl(oldUrl, false); + var unpublishedSrc = publishingService.GetRelativeDraftUrl(oldUrl, true); var isFile = false; if (await contentService.ItemExistsAsync(contentType, storeId, publishedSrc)) { isFile = true; - var publishedDest = _publishingService.GetRelativeDraftUrl(newUrl, false); + var publishedDest = publishingService.GetRelativeDraftUrl(newUrl, false); await contentService.MoveContentAsync(contentType, storeId, publishedSrc, publishedDest); } if (await contentService.ItemExistsAsync(contentType, storeId, unpublishedSrc)) { isFile = true; - var unpublishedDest = _publishingService.GetRelativeDraftUrl(newUrl, true); + var unpublishedDest = publishingService.GetRelativeDraftUrl(newUrl, true); await contentService.MoveContentAsync(contentType, storeId, unpublishedSrc, unpublishedDest); } @@ -302,7 +301,7 @@ await contentService.ItemExistsAsync(contentType, storeId, Path.Combine(path, $" destFile = Path.Combine(path, $"{filename}_{index}{langSuffix}{ext}"); } - destFile = _publishingService.GetRelativeDraftUrl(destFile, true); + destFile = publishingService.GetRelativeDraftUrl(destFile, true); await contentService.CopyFileAsync(contentType, storeId, srcFile, destFile); return NoContent(); } @@ -401,7 +400,7 @@ public async Task> UploadContent(string contentType, { var fileName = Path.GetFileName(contentDisposition.FileName.Value ?? contentDisposition.Name.Value.Replace("\"", string.Empty)); - fileName = _publishingService.GetRelativeDraftUrl(fileName, draft); + fileName = publishingService.GetRelativeDraftUrl(fileName, draft); var file = await contentService.SaveContentAsync(contentType, storeId, folderUrl, fileName, section.Body); retVal.Add(file); @@ -420,7 +419,9 @@ public async Task> UploadContent(string contentType, ContentCacheRegion.ExpireContent(($"content-{storeId}")); - return Ok(retVal.ToArray()); + var files = await publishingService.SetFilesStatuses(retVal); + + return Ok(files); } [HttpPost] @@ -428,7 +429,7 @@ public async Task> UploadContent(string contentType, [Authorize(Permissions.Create)] public async Task Publishing(string contentType, string storeId, [FromQuery] string relativeUrl, [FromQuery] bool publish) { - await _publishingService.PublishingAsync(contentType, storeId, relativeUrl, publish); + await publishingService.PublishingAsync(contentType, storeId, relativeUrl, publish); return Ok(); } @@ -437,7 +438,7 @@ public async Task Publishing(string contentType, string storeId, [ [Authorize(Permissions.Create)] public async Task> PublishStatus(string contentType, string storeId, [FromQuery] string relativeUrl) { - var result = await _publishingService.PublishStatusAsync(contentType, storeId, relativeUrl); + var result = await publishingService.PublishStatusAsync(contentType, storeId, relativeUrl); return Ok(result); } } diff --git a/src/VirtoCommerce.ContentModule.Web/Scripts/blades/pages/page-detail.js b/src/VirtoCommerce.ContentModule.Web/Scripts/blades/pages/page-detail.js index d4a4c886..5619c918 100644 --- a/src/VirtoCommerce.ContentModule.Web/Scripts/blades/pages/page-detail.js +++ b/src/VirtoCommerce.ContentModule.Web/Scripts/blades/pages/page-detail.js @@ -185,6 +185,7 @@ angular.module('virtoCommerce.contentModule') blade.isLoading = false; var needRefresh = true; blade.currentEntity = Object.assign(blade.currentEntity, result[0]); + blade.published = blade.currentEntity.published; angular.copy(blade.currentEntity, blade.origEntity); if (blade.isNew) { $scope.bladeClose(); diff --git a/tests/VirtoCommerce.ContentModule.Tests/ContentStatisticServiceTests.cs b/tests/VirtoCommerce.ContentModule.Tests/ContentStatisticServiceTests.cs new file mode 100644 index 00000000..5fdc1387 --- /dev/null +++ b/tests/VirtoCommerce.ContentModule.Tests/ContentStatisticServiceTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Moq; +using VirtoCommerce.AssetsModule.Core.Assets; +using VirtoCommerce.ContentModule.Core.Search; +using VirtoCommerce.ContentModule.Core.Services; +using VirtoCommerce.ContentModule.Data.Services; +using Xunit; + +namespace VirtoCommerce.ContentModule.Tests; + +public class ContentStatisticServiceTests +{ + private const string StoreId = "TestStore"; + + [Theory] + [InlineData("file1.md", "file2.md", 2)] + [InlineData("file1.md-draft", "file2.md-draft", 2)] + [InlineData("file1.md-draft", "file2.md", 2)] + [InlineData("file1.md-draft", "file1.md", 1)] + public async Task GetStorePagesCount_IsCorrect(string filename1, string filename2, int expectedCount) + { + var sut = GetService([filename1, filename2]); + var result = await sut.GetStorePagesCountAsync(StoreId); + Assert.Equal(expectedCount, result); + } + + private static ContentStatisticService GetService(params string[] files) + { + var blobContentProviderFactory = new BlobContentStorageProviderStub(files); + var contentPathResolver = new ContentPathResolverStub(); + + var contentItemPathRegistrar = new Mock(); + contentItemPathRegistrar.Setup(x => x.IsRegisteredContentItemType(It.IsAny())).Returns(true); + + var contentService = new Mock(); + var publishingService = new PublishingServices(contentService.Object); + var result = new ContentStatisticService(blobContentProviderFactory, contentPathResolver, contentItemPathRegistrar.Object, publishingService); + return result; + } + + private class ContentPathResolverStub : IContentPathResolver + { + public string GetContentBasePath(string contentType, string storeId, string themeName = null) + { + return string.Empty; + } + } + + private class BlobContentStorageProviderStub(string[] files) : IBlobContentStorageProviderFactory + { + public IBlobContentStorageProvider CreateProvider(string basePath) + { + return new ContentBlobStorageProviderStub(files.ToList()); + } + } + + private class ContentBlobStorageProviderStub(List files) : IBlobContentStorageProvider + { + public Task DeleteAsync(string blobUrl) + { + files.Remove(blobUrl); + return Task.CompletedTask; + } + + public Task ExistsAsync(string blobUrl) + { + var exists = files.Contains(blobUrl); + return Task.FromResult(exists); + } + + public Task SearchAsync(string folderUrl, string keyword) + { + var result = new BlobEntrySearchResult + { + Results = files.Select(x => new BlobInfo { Name = x, RelativeUrl = x }).Cast().ToList(), + TotalCount = files.Count + }; + return Task.FromResult(result); + } + + public Task GetBlobInfoAsync(string blobUrl) + { + var result = new BlobInfo(); + return Task.FromResult(result); + } + + public Task CreateFolderAsync(BlobFolder folder) + { + return Task.CompletedTask; + } + + public Stream OpenRead(string blobUrl) + { + throw new NotImplementedException(); + } + + public Task OpenReadAsync(string blobUrl) + { + throw new NotImplementedException(); + } + + public Stream OpenWrite(string blobUrl) + { + throw new NotImplementedException(); + } + + public Task OpenWriteAsync(string blobUrl) + { + throw new NotImplementedException(); + } + + public Task RemoveAsync(string[] urls) + { + throw new NotImplementedException(); + } + + public void Move(string srcUrl, string destUrl) + { + throw new NotImplementedException(); + } + + public Task MoveAsyncPublic(string srcUrl, string destUrl) + { + throw new NotImplementedException(); + } + + public void Copy(string srcUrl, string destUrl) + { + throw new NotImplementedException(); + } + + public Task CopyAsync(string srcUrl, string destUrl) + { + throw new NotImplementedException(); + } + + public Task UploadAsync(string blobUrl, Stream content, string contentType, bool overwrite = true) + { + throw new NotImplementedException(); + } + + public string GetAbsoluteUrl(string blobKey) + { + throw new NotImplementedException(); + } + } +} diff --git a/tests/VirtoCommerce.ContentModule.Tests/PublishingServiceTests.cs b/tests/VirtoCommerce.ContentModule.Tests/PublishingServiceTests.cs new file mode 100644 index 00000000..46e9de28 --- /dev/null +++ b/tests/VirtoCommerce.ContentModule.Tests/PublishingServiceTests.cs @@ -0,0 +1,171 @@ +using System.Linq; +using System.Threading.Tasks; +using Moq; +using VirtoCommerce.ContentModule.Core.Model; +using VirtoCommerce.ContentModule.Core.Services; +using VirtoCommerce.ContentModule.Data.Services; +using Xunit; + +namespace VirtoCommerce.ContentModule.Tests; + +public class PublishingServiceTests +{ + private const string ContentType = "content"; + private const string StoreId = "storeId"; + + [Theory] + [InlineData("file.md", false)] + [InlineData("file.md-draft", true)] + [InlineData("file", false)] + public void IsDraft_IsCorrect(string filename, bool expectedIsDraft) + { + var contentService = new Mock(); + var sut = new PublishingServices(contentService.Object); + + var result = sut.IsDraft(filename); + Assert.Equal(expectedIsDraft, result); + } + + [Theory] + [InlineData("file.md", true, "file.md-draft")] + [InlineData("file.md-draft", true, "file.md-draft")] + [InlineData("file.md", false, "file.md")] + [InlineData("file.md-draft", false, "file.md")] + public void GetRelativeDraftUrlTests(string source, bool isDraft, string expectedTarget) + { + var contentService = new Mock(); + var sut = new PublishingServices(contentService.Object); + + var result = sut.GetRelativeDraftUrl(source, isDraft); + Assert.Equal(expectedTarget, result); + } + + #region PublishingAsync + + [Fact] + public async Task PublishingAsync_MakeDraft() + { + var (sut, contentService) = GetPublishingService([("file.md", true), ("file.md-draft", false)]); + + await sut.PublishingAsync(ContentType, StoreId, "file.md", publish: false); + + contentService.Verify(x => x.MoveContentAsync(ContentType, StoreId, "file.md", "file.md-draft"), Times.Exactly(1)); + contentService.Verify(x => x.DeleteContentAsync(ContentType, StoreId, It.IsAny()), Times.Never); + } + + [Fact] + public async Task PublishingAsync_PublishFile() + { + var (sut, contentService) = GetPublishingService([("file.md", false), ("file.md-draft", true)]); + + await sut.PublishingAsync(ContentType, StoreId, "file.md", publish: true); + + contentService.Verify(x => x.MoveContentAsync(ContentType, StoreId, "file.md-draft", "file.md"), Times.Exactly(1)); + contentService.Verify(x => x.DeleteContentAsync(ContentType, StoreId, It.IsAny()), Times.Never); + } + + [Fact] + public async Task PublishingAsync_PublishWhenPublishedExists() + { + var (sut, contentService) = GetPublishingService([("file.md", true), ("file.md-draft", true)]); + + await sut.PublishingAsync(ContentType, StoreId, "file.md", publish: true); + + contentService.Verify(x => x.MoveContentAsync(ContentType, StoreId, "file.md-draft", "file.md"), Times.Exactly(1)); + + string[] filesToRemove = ["file.md"]; + contentService.Verify(x => x.DeleteContentAsync(ContentType, StoreId, filesToRemove), Times.Exactly(1)); + } + + [Fact] + public async Task PublishingAsync_DontUnpublishWithDraft() + { + var (sut, contentService) = GetPublishingService([("file.md", true), ("file.md-draft", true)]); + + await sut.PublishingAsync(ContentType, StoreId, "file.md", publish: false); + + contentService.Verify(x => x.MoveContentAsync(ContentType, StoreId, It.IsAny(), It.IsAny()), Times.Never); + contentService.Verify(x => x.DeleteContentAsync(ContentType, StoreId, It.IsAny()), Times.Never); + } + + #endregion + + #region PublishStatusAsync + + [Fact] + public async Task PublishStatusAsync_Published() + { + var (sut, _) = GetPublishingService([("file.md", true), ("file.md-draft", false)]); + + var result = await sut.PublishStatusAsync(ContentType, StoreId, "file.md"); + Assert.True(result.Published); + Assert.False(result.HasChanges); + } + + [Fact] + public async Task PublishStatusAsync_Unpublished() + { + var (sut, _) = GetPublishingService([("file.md", false), ("file.md-draft", true)]); + + var result = await sut.PublishStatusAsync(ContentType, StoreId, "file.md"); + Assert.False(result.Published); + Assert.True(result.HasChanges); + } + + [Fact] + public async Task PublishStatusAsync_HasChanged() + { + var (sut, _) = GetPublishingService([("file.md", true), ("file.md-draft", true)]); + + var result = await sut.PublishStatusAsync(ContentType, StoreId, "file.md"); + Assert.True(result.Published); + Assert.True(result.HasChanges); + } + + #endregion + + [Theory] + [InlineData(1, 0, 0, 1, "file.md")] + [InlineData(0, 1, 1, 1, "file.md-draft")] + [InlineData(1, 0, 1, 1, "file.md-draft", "file.md")] + [InlineData(1, 1, 1, 2, "file1.md-draft", "file2.md")] + [InlineData(2, 0, 1, 2, "file1.md-draft", "file1.md", "file2.md")] + [InlineData(1, 1, 2, 2, "file1.md-draft", "file1.md", "file2.md-draft")] + public async Task SetFileStatuses(int expectedPublishedCount, int expectedUnpublishedCount, int expectedHasChangesCount, int expectedTotalCount, params string[] filenames) + { + var contentService = new Mock(); + var sut = new PublishingServices(contentService.Object); + + ContentFile CreateContentFile(string filename) + { + return new ContentFile + { + Name = filename, + RelativeUrl = filename, + }; + } + + var files = filenames.Select(CreateContentFile); + + var result = (await sut.SetFilesStatuses(files)).ToList(); + + Assert.Equal(expectedPublishedCount, result.Count(x => x.Published)); + Assert.Equal(expectedUnpublishedCount, result.Count(x => !x.Published)); + Assert.Equal(expectedHasChangesCount, result.Count(x => x.HasChanges)); + Assert.Equal(expectedTotalCount, result.Count); + } + + private static (PublishingServices, Mock) GetPublishingService((string Filename, bool Exists)[] files) + { + var contentService = new Mock(); + + foreach (var file in files) + { + contentService + .Setup(x => x.ItemExistsAsync(ContentType, StoreId, file.Filename)) + .ReturnsAsync(file.Exists); + } + + return (new PublishingServices(contentService.Object), contentService); + } +} diff --git a/tests/VirtoCommerce.ContentModule.Tests/VirtoCommerce.ContentModule.Tests.csproj b/tests/VirtoCommerce.ContentModule.Tests/VirtoCommerce.ContentModule.Tests.csproj index f11646b9..78e8bb01 100644 --- a/tests/VirtoCommerce.ContentModule.Tests/VirtoCommerce.ContentModule.Tests.csproj +++ b/tests/VirtoCommerce.ContentModule.Tests/VirtoCommerce.ContentModule.Tests.csproj @@ -6,6 +6,7 @@ +