diff --git a/OrchardCoreContrib.Modules.sln b/OrchardCoreContrib.Modules.sln index 05619217..6a73e8e2 100644 --- a/OrchardCoreContrib.Modules.sln +++ b/OrchardCoreContrib.Modules.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31912.275 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949}" EndProject @@ -78,6 +78,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.UserGrou EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.UserGroups.Tests", "test\OrchardCoreContrib.UserGroups.Tests\OrchardCoreContrib.UserGroups.Tests.csproj", "{689A006D-0992-456E-86C9-66B7A2EE06A5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.ViewCount.Tests", "test\OrchardCoreContrib.ViewCount.Tests\OrchardCoreContrib.ViewCount.Tests.csproj", "{8FFF983F-F6B5-4990-B571-77EDEC2FAA00}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -204,6 +206,10 @@ Global {689A006D-0992-456E-86C9-66B7A2EE06A5}.Debug|Any CPU.Build.0 = Debug|Any CPU {689A006D-0992-456E-86C9-66B7A2EE06A5}.Release|Any CPU.ActiveCfg = Release|Any CPU {689A006D-0992-456E-86C9-66B7A2EE06A5}.Release|Any CPU.Build.0 = Release|Any CPU + {8FFF983F-F6B5-4990-B571-77EDEC2FAA00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FFF983F-F6B5-4990-B571-77EDEC2FAA00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FFF983F-F6B5-4990-B571-77EDEC2FAA00}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FFF983F-F6B5-4990-B571-77EDEC2FAA00}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -239,6 +245,7 @@ Global {330877B1-7BE6-4B57-8714-2DADE1C53563} = {C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949} {C76B5687-3079-4E80-B7D2-B691F2B05A8A} = {C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949} {689A006D-0992-456E-86C9-66B7A2EE06A5} = {A239BFB0-9BA7-467C-AD41-405D0740633F} + {8FFF983F-F6B5-4990-B571-77EDEC2FAA00} = {A239BFB0-9BA7-467C-AD41-405D0740633F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {48F73B05-7D3D-4ACF-81AE-A98B2B4EFDB2} diff --git a/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs b/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs index 644891f8..18cd1950 100644 --- a/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs +++ b/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs @@ -20,6 +20,8 @@ public class ViewCountService( IEnumerable handlers, ILogger logger) : IViewCountService { + private static readonly SemaphoreSlim _semaphore = new(initialCount: 1, maxCount: 1); + /// public int GetViewsCount(ContentItem contentItem) { @@ -35,6 +37,8 @@ public async Task ViewAsync(ContentItem contentItem) { Guard.ArgumentNotNull(contentItem, nameof(contentItem)); + await _semaphore.WaitAsync(); + var viewCountPart = contentItem.As() ?? throw new InvalidOperationException($"The content item doesn't have a `{nameof(ViewCountPart)}`."); var count = viewCountPart.Count; @@ -42,12 +46,16 @@ public async Task ViewAsync(ContentItem contentItem) await handlers.InvokeAsync((handler, context) => handler.ViewingAsync(context), context, logger); - contentItem.Content.ViewCountPart.Count = ++count; + viewCountPart.Count = ++count; + + contentItem.Content.ViewCountPart.Count = count; await contentManager.UpdateAsync(contentItem); context = new ViewCountContentContext(contentItem, count); await handlers.InvokeAsync((handler, context) => handler.ViewedAsync(context), context, logger); + + _semaphore.Release(); } } diff --git a/test/OrchardCoreContrib.ViewCount.Tests/OrchardCoreContrib.ViewCount.Tests.csproj b/test/OrchardCoreContrib.ViewCount.Tests/OrchardCoreContrib.ViewCount.Tests.csproj new file mode 100644 index 00000000..d43b83a5 --- /dev/null +++ b/test/OrchardCoreContrib.ViewCount.Tests/OrchardCoreContrib.ViewCount.Tests.csproj @@ -0,0 +1,23 @@ + + + + true + + + + + + + + + + + + + + + + + + + diff --git a/test/OrchardCoreContrib.ViewCount.Tests/Services/ViewCountServiceTests.cs b/test/OrchardCoreContrib.ViewCount.Tests/Services/ViewCountServiceTests.cs new file mode 100644 index 00000000..1022b30d --- /dev/null +++ b/test/OrchardCoreContrib.ViewCount.Tests/Services/ViewCountServiceTests.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; +using Moq; +using OrchardCore.ContentManagement; +using OrchardCoreContrib.ViewCount.Models; + +namespace OrchardCoreContrib.ViewCount.Services.Tests; + +public class ViewCountServiceTests +{ + [Fact] + public async Task ViewAsync_ConcurrentCalls_WithSemaphore_AllIncrementsAreApplied() + { + // Arrange + const int concurrentCalls = 20; + var contentItem = new ContentItem(); + contentItem.Weld(new ViewCountPart { Count = 0 }); + + var contentManagerMock = new Mock(); + contentManagerMock + .Setup(m => m.UpdateAsync(It.IsAny())) + .Returns(async () => await Task.Delay(10).ConfigureAwait(false)); + + var service = new ViewCountService( + contentManagerMock.Object, + [], + Mock.Of>()); + + // Act + var tasks = Enumerable.Range(0, concurrentCalls) + .Select(_ => Task.Run(() => service.ViewAsync(contentItem))) + .ToArray(); + + await Task.WhenAll(tasks); + + // Assert + var count = contentItem.As().Count; + Assert.Equal(concurrentCalls, count); + } +}