From bb731bf2bc0e493e89fac2366817855f741401e7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Feb 2021 10:50:47 +0100 Subject: [PATCH] Fixes for usage notification. --- .../Apps/Plans/UsageGate.cs | 22 ++- .../Apps/Plans/UsageNotifierGrain.cs | 17 +- .../Apps/Plans/UsageGateTests.cs | 26 ++- .../Apps/Plans/UsageNotifierGrainTests.cs | 179 ++++++++++++++++++ 4 files changed, 229 insertions(+), 15 deletions(-) create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageNotifierGrainTests.cs diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs index 000282550..7a3457c84 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs @@ -54,21 +54,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans var (plan, _) = appPlansProvider.GetPlanForApp(app); - if (plan.MaxApiCalls > 0 || plan.BlockingApiCalls > 0) + var limit = plan.MaxApiCalls; + + if (limit > 0 || plan.BlockingApiCalls > 0) { var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, null); - if (IsAboutToBeLocked(today, plan.MaxApiCalls, usage) && !HasNotifiedBefore(app.Id)) + if (IsOver10Percent(limit, usage) && IsAboutToBeLocked(today, limit, usage) && !HasNotifiedBefore(app.Id)) { - var users = app.Contributors.Where(x => x.Value == Role.Owner).Select(x => x.Key).ToArray(); - var notification = new UsageNotification { AppId = appId, AppName = app.Name, Usage = usage, - UsageLimit = plan.MaxApiCalls, - Users = users + UsageLimit = limit, + Users = GetUsers(app) }; GetGrain().NotifyAsync(notification).Forget(); @@ -97,6 +97,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans return memoryCache.Set(appId, true, TimeSpan.FromHours(1)); } + private static string[] GetUsers(IAppEntity app) + { + return app.Contributors.Where(x => x.Value == Role.Owner).Select(x => x.Key).ToArray(); + } + + private static bool IsOver10Percent(long limit, long usage) + { + return usage > limit * 0.1; + } + private static bool IsAboutToBeLocked(DateTime today, long limit, long usage) { var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs index b8aa330f5..7c2029816 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using NodaTime; using Squidex.Domain.Apps.Entities.Notifications; using Squidex.Infrastructure; using Squidex.Infrastructure.Orleans; @@ -19,10 +20,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans { public sealed class UsageNotifierGrain : GrainOfString, IUsageNotifierGrain { - private static readonly TimeSpan TimeBetweenNotifications = TimeSpan.FromHours(12); + private static readonly TimeSpan TimeBetweenNotifications = TimeSpan.FromDays(3); private readonly IGrainState state; private readonly INotificationSender notificationSender; private readonly IUserResolver userResolver; + private readonly IClock clock; [CollectionName("UsageNotifications")] public sealed class State @@ -30,20 +32,27 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans public Dictionary NotificationsSent { get; } = new Dictionary(); } - public UsageNotifierGrain(IGrainState state, INotificationSender notificationSender, IUserResolver userResolver) + public UsageNotifierGrain(IGrainState state, INotificationSender notificationSender, IUserResolver userResolver, IClock clock) { Guard.NotNull(state, nameof(state)); Guard.NotNull(notificationSender, nameof(notificationSender)); Guard.NotNull(userResolver, nameof(userResolver)); + Guard.NotNull(clock, nameof(clock)); this.state = state; this.notificationSender = notificationSender; this.userResolver = userResolver; + this.clock = clock; } public async Task NotifyAsync(UsageNotification notification) { - var now = DateTime.UtcNow; + if (!notificationSender.IsActive) + { + return; + } + + var now = clock.GetCurrentInstant().ToDateTimeUtc(); if (!HasBeenSentBefore(notification.AppId, now)) { @@ -73,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans { var elapsed = now - lastSent; - return elapsed > TimeBetweenNotifications; + return elapsed < TimeBetweenNotifications; } return false; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs index d9ca6990e..f37fe1967 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs @@ -26,10 +26,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans private readonly IApiUsageTracker usageTracker = A.Fake(); private readonly IGrainFactory grainFactory = A.Fake(); private readonly IUsageNotifierGrain usageNotifierGrain = A.Fake(); - private readonly DateTime today = new DateTime(2020, 04, 10); - private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly string clientId = Guid.NewGuid().ToString(); + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly UsageGate sut; + private DateTime today = new DateTime(2020, 10, 3); private long apiCallsBlocking; private long apiCallsMax; private long apiCallsCurrent; @@ -110,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans [Fact] public async Task Should_return_false_and_notify_if_about_to_over_included_contingent() { - apiCallsCurrent = 1200; // * 30 days = 3600 + apiCallsCurrent = 1200; // in 10 days = 4000 / month apiCallsBlocking = 5000; apiCallsMax = 3000; @@ -125,7 +125,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans [Fact] public async Task Should_return_false_and_notify_if_about_to_over_included_contingent_but_no_max_given() { - apiCallsCurrent = 1200; // * 30 days = 3600 + apiCallsCurrent = 1200; // in 10 days = 4000 / month apiCallsBlocking = 5000; apiCallsMax = 0; @@ -140,7 +140,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans [Fact] public async Task Should_only_notify_once_if_about_to_be_over_included_contingent() { - apiCallsCurrent = 1200; // * 30 days = 3600 + apiCallsCurrent = 1200; // in 10 days = 4000 / month apiCallsBlocking = 5000; apiCallsMax = 3000; @@ -150,5 +150,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans A.CallTo(() => usageNotifierGrain.NotifyAsync(A.Ignored)) .MustHaveHappenedOnceExactly(); } + + [Fact] + public async Task Should_not_notify_if_lower_than_10_percent() + { + today = new DateTime(2020, 10, 2); + + apiCallsCurrent = 220; // in 3 days = 3300 / month + apiCallsBlocking = 5000; + apiCallsMax = 3000; + + await sut.IsBlockedAsync(appEntity, clientId, today); + await sut.IsBlockedAsync(appEntity, clientId, today); + + A.CallTo(() => usageNotifierGrain.NotifyAsync(A.Ignored)) + .MustNotHaveHappened(); + } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageNotifierGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageNotifierGrainTests.cs new file mode 100644 index 000000000..77231aecf --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageNotifierGrainTests.cs @@ -0,0 +1,179 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using NodaTime; +using Squidex.Domain.Apps.Entities.Notifications; +using Squidex.Infrastructure.Orleans; +using Squidex.Shared.Users; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps.Plans +{ + public class UsageNotifierGrainTests + { + private readonly IGrainState state = A.Fake>(); + private readonly INotificationSender notificationSender = A.Fake(); + private readonly IUserResolver userResolver = A.Fake(); + private readonly IClock clock = A.Fake(); + private readonly UsageNotifierGrain sut; + private Instant time = SystemClock.Instance.GetCurrentInstant(); + + public UsageNotifierGrainTests() + { + A.CallTo(() => clock.GetCurrentInstant()) + .ReturnsLazily(() => time); + + A.CallTo(() => notificationSender.IsActive) + .Returns(true); + + sut = new UsageNotifierGrain(state, notificationSender, userResolver, clock); + } + + [Fact] + public async Task Should_not_send_notification_if_not_active() + { + SetupUser("1", null); + SetupUser("2", null); + + var notification = new UsageNotification + { + AppName = "my-app", + Usage = 1000, + UsageLimit = 3000, + Users = new[] { "1", "2" } + }; + + await sut.NotifyAsync(notification); + + A.CallTo(() => notificationSender.SendUsageAsync(A._, "my-app", 1000, 3000)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_notify_found_users() + { + var user1 = SetupUser("1", "user1@email.com"); + var user2 = SetupUser("2", "user2@email.com"); + var user3 = SetupUser("3", null); + + var notification = new UsageNotification + { + AppName = "my-app", + Usage = 1000, + UsageLimit = 3000, + Users = new[] { "1", "2", "3" } + }; + + await sut.NotifyAsync(notification); + + A.CallTo(() => notificationSender.SendUsageAsync(user1!, "my-app", 1000, 3000)) + .MustHaveHappened(); + + A.CallTo(() => notificationSender.SendUsageAsync(user2!, "my-app", 1000, 3000)) + .MustHaveHappened(); + + A.CallTo(() => notificationSender.SendUsageAsync(user3!, "my-app", 1000, 3000)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_notify_again() + { + var user = SetupUser("1", "user1@email.com"); + + var notification = new UsageNotification + { + AppName = "my-app", + Usage = 1000, + UsageLimit = 3000, + Users = new[] { "1" } + }; + + await sut.NotifyAsync(notification); + await sut.NotifyAsync(notification); + + A.CallTo(() => notificationSender.SendUsageAsync(user!, "my-app", 1000, 3000)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_send_again_after_3_days() + { + var user = SetupUser("1", "user1@email.com"); + + var notification = new UsageNotification + { + AppName = "my-app", + Usage = 1000, + UsageLimit = 3000, + Users = new[] { "1" } + }; + + await sut.NotifyAsync(notification); + + time = time.Plus(Duration.FromDays(3)); + + await sut.NotifyAsync(notification); + + A.CallTo(() => notificationSender.SendUsageAsync(user!, "my-app", 1000, 3000)) + .MustHaveHappenedTwiceExactly(); + } + + [Theory] + [InlineData(1)] + [InlineData(6)] + [InlineData(12)] + [InlineData(24)] + [InlineData(48)] + public async Task Should_not_notify_again_after_few_hours(int hours) + { + var user = SetupUser("1", "user1@email.com"); + + var notification = new UsageNotification + { + AppName = "my-app", + Usage = 1000, + UsageLimit = 3000, + Users = new[] { "1" } + }; + + await sut.NotifyAsync(notification); + + time = time.Plus(Duration.FromHours(hours)); + + await sut.NotifyAsync(notification); + + A.CallTo(() => notificationSender.SendUsageAsync(user!, "my-app", 1000, 3000)) + .MustHaveHappenedOnceExactly(); + } + + private IUser? SetupUser(string id, string? email) + { + if (email != null) + { + var user = A.Fake(); + + A.CallTo(() => user.Email) + .Returns(email); + + A.CallTo(() => userResolver.FindByIdOrEmailAsync(id)) + .Returns(user); + + return user; + } + else + { + A.CallTo(() => userResolver.FindByIdOrEmailAsync(id)) + .Returns(Task.FromResult(null)); + + return null; + } + } + } +}