From 37680ae7d637ea4745504fbf692ba108697f6d27 Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 17:22:26 +0200 Subject: [PATCH 1/3] Count discount usage once per request in limitation checks A listing validates every applicable discount for every product. For an NTimes or NTimesPerUser discount each check sent GetDiscountUsageHistoryQuery with PageSize = 1, which costs a sorted find plus a count: 48 queries on a page of 24 products. DiscountValidationService now counts the usage history with one CountAsync and keeps the count task for the rest of the scope, keyed by discount and customer. Product overviews are prepared concurrently, so concurrent checks share one query. ExistsCodeInDiscount uses AnyAsync instead of loading a list. --- .../Discounts/DiscountValidationService.cs | 40 ++++-- .../Discounts/DiscountServiceTests.cs | 114 +++++++++++++++++- .../Plugins/PluginDependencyCycleTests.cs | 6 +- 3 files changed, 144 insertions(+), 16 deletions(-) diff --git a/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs b/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs index 52f6287dd2..9a8140a1fe 100644 --- a/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs @@ -1,5 +1,4 @@ using Grand.Business.Core.Interfaces.Catalog.Discounts; -using Grand.Business.Core.Queries.Catalog; using Grand.Business.Core.Utilities.Catalog; using Grand.Data; using Grand.Domain.Customers; @@ -8,23 +7,26 @@ using Grand.Domain.Orders; using Grand.Domain.Stores; using Grand.Infrastructure.Extensions; -using Grand.Mediator; +using System.Collections.Concurrent; namespace Grand.Business.Catalog.Services.Discounts; public class DiscountValidationService : IDiscountValidationService { private readonly IRepository _discountCouponRepository; + private readonly IRepository _discountUsageHistoryRepository; private readonly IDiscountProviderLoader _discountProviderLoader; - private readonly IMediator _mediator; + + //usage counts read in this scope; a listing validates the same discount for every product, concurrently + private readonly ConcurrentDictionary<(string discountId, string customerId), Task> _usageCounts = new(); public DiscountValidationService( IDiscountProviderLoader discountProviderLoader, IRepository discountCouponRepository, - IMediator mediator) + IRepository discountUsageHistoryRepository) { _discountCouponRepository = discountCouponRepository; - _mediator = mediator; + _discountUsageHistoryRepository = discountUsageHistoryRepository; _discountProviderLoader = discountProviderLoader; } @@ -163,17 +165,15 @@ public virtual async Task ValidateDiscount(Discount di { case DiscountLimitationType.NTimes: { - var usedTimes = await _mediator.Send(new GetDiscountUsageHistoryQuery - { DiscountId = discount.Id, PageSize = 1 }); - if (usedTimes.TotalCount >= discount.LimitationTimes) + var usedTimes = await GetUsedTimes(discount.Id, null); + if (usedTimes >= discount.LimitationTimes) return result; } break; case DiscountLimitationType.NTimesPerUser: { - var usedTimes = await _mediator.Send(new GetDiscountUsageHistoryQuery - { DiscountId = discount.Id, CustomerId = customer.Id, PageSize = 1 }); - if (usedTimes.TotalCount >= discount.LimitationTimes) + var usedTimes = await GetUsedTimes(discount.Id, customer.Id); + if (usedTimes >= discount.LimitationTimes) { result.UserErrorResource = "ShoppingCart.Discount.CannotBeUsedAnymore"; return result; @@ -219,6 +219,21 @@ public virtual async Task ValidateDiscount(Discount di return result; } + /// + /// Counts the usage history of a discount, of one customer when customerId is set + /// + private Task GetUsedTimes(string discountId, string customerId) + { + return _usageCounts.GetOrAdd((discountId, customerId), key => + { + var query = _discountUsageHistoryRepository.Table.Where(x => x.DiscountId == key.discountId); + if (!string.IsNullOrEmpty(key.customerId)) + query = query.Where(x => x.CustomerId == key.customerId); + + return _discountUsageHistoryRepository.CountAsync(query); + }); + } + /// /// Exist coupon code in discount /// @@ -237,7 +252,6 @@ public async Task ExistsCodeInDiscount(string couponCode, string discountI if (used.HasValue) query = query.Where(x => x.Used == used.Value); - var result = await _discountCouponRepository.ToListAsync(query); - return result.Count != 0; + return await _discountCouponRepository.AnyAsync(query); } } \ No newline at end of file diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs index 4d2c8f33cd..4b08104d48 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs @@ -57,7 +57,8 @@ public void InitializeTests() _discountAmountProviders = new List { new DiscountAmountProviderTests() }; _discountProviderLoader = new DiscountProviderLoader(_discountProviders, _discountAmountProviders); _discountValidationService = - new DiscountValidationService(_discountProviderLoader, _discountCouponRepository, _mediatorMock.Object); + new DiscountValidationService(_discountProviderLoader, _discountCouponRepository, + _discountUsageHistoryRepository); _dicountService = new DiscountService(_cacheBase, _repository, _discountCouponRepository, _discountUsageHistoryRepository, _mediatorMock.Object, new AccessControlConfig()); handler = new GetDiscountUsageHistoryQueryHandler(_discountUsageHistoryRepository); @@ -850,4 +851,115 @@ public void LoadDiscountAmountProvidersTest() //Assert Assert.HasCount(1, discountProviders); } + + [TestMethod] + public async Task ValidateDiscountTest_NTimes_InValid_WhenLimitReached() + { + //Arrange + var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 2); + await InsertUsage(discount.Id, "customer1"); + await InsertUsage(discount.Id, "customer2"); + //Act + var result = await _discountValidationService.ValidateDiscount(discount, new Customer(), new Store(), + new Currency { CurrencyCode = "USD" }); + //Assert + Assert.IsFalse(result.IsValid); + } + + [TestMethod] + public async Task ValidateDiscountTest_NTimes_Valid_BelowLimit() + { + //Arrange + var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 2); + await InsertUsage(discount.Id, "customer1"); + await InsertUsage("otherDiscount", "customer1"); + //Act + var result = await _discountValidationService.ValidateDiscount(discount, new Customer(), new Store(), + new Currency { CurrencyCode = "USD" }); + //Assert + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public async Task ValidateDiscountTest_NTimesPerUser_CountsOnlyTheCustomer() + { + //Arrange + var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimesPerUser, 1); + var customer = new Customer(); + await InsertUsage(discount.Id, "otherCustomer"); + //Act + var valid = await _discountValidationService.ValidateDiscount(discount, customer, new Store(), + new Currency { CurrencyCode = "USD" }); + await InsertUsage(discount.Id, customer.Id); + var used = await new DiscountValidationService(_discountProviderLoader, _discountCouponRepository, + _discountUsageHistoryRepository) + .ValidateDiscount(discount, customer, new Store(), new Currency { CurrencyCode = "USD" }); + //Assert + Assert.IsTrue(valid.IsValid); + Assert.IsFalse(used.IsValid); + Assert.AreEqual("ShoppingCart.Discount.CannotBeUsedAnymore", used.UserErrorResource); + } + + [TestMethod] + public async Task ValidateDiscountTest_NTimes_CountsUsageOncePerScope() + { + //Arrange + var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 1); + //Act + var first = await _discountValidationService.ValidateDiscount(discount, new Customer(), new Store(), + new Currency { CurrencyCode = "USD" }); + await InsertUsage(discount.Id, "customer1"); + var second = await _discountValidationService.ValidateDiscount(discount, new Customer(), new Store(), + new Currency { CurrencyCode = "USD" }); + //Assert - the count read for the first product is reused for the rest of the scope + Assert.IsTrue(first.IsValid); + Assert.IsTrue(second.IsValid); + } + + [TestMethod] + public async Task ValidateDiscountTest_NTimes_ConcurrentProductsShareTheCount() + { + //Arrange - product overviews of a listing are prepared concurrently in one scope + var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 1); + await InsertUsage(discount.Id, "customer1"); + //Act + var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => + Task.Run(() => _discountValidationService.ValidateDiscount(discount, new Customer(), new Store(), + new Currency { CurrencyCode = "USD" })))); + //Assert + Assert.IsTrue(results.All(x => !x.IsValid)); + } + + [TestMethod] + public async Task ExistsCodeInDiscountTest() + { + //Arrange + await _discountCouponRepository.InsertAsync(new DiscountCoupon + { CouponCode = "USED1", DiscountId = "discount1", Used = true }); + //Act & Assert + Assert.IsTrue(await _discountValidationService.ExistsCodeInDiscount("USED1", "discount1", null)); + Assert.IsTrue(await _discountValidationService.ExistsCodeInDiscount("USED1", "discount1", true)); + Assert.IsFalse(await _discountValidationService.ExistsCodeInDiscount("USED1", "discount1", false)); + Assert.IsFalse(await _discountValidationService.ExistsCodeInDiscount("USED1", "discount2", null)); + Assert.IsFalse(await _discountValidationService.ExistsCodeInDiscount("", "discount1", null)); + } + + private async Task InsertLimitedDiscount(DiscountLimitationType limitation, int times) + { + var discount = new Discount { + Name = "limited", + IsEnabled = true, + CurrencyCode = "USD", + DiscountLimitationId = limitation, + LimitationTimes = times + }; + await _dicountService.InsertDiscount(discount); + return discount; + } + + private Task InsertUsage(string discountId, string customerId) + { + return _discountUsageHistoryRepository.InsertAsync(new DiscountUsageHistory + { DiscountId = discountId, CustomerId = customerId, OrderId = "order" }); + } } \ No newline at end of file diff --git a/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs index e1fd739670..f3af65c82b 100644 --- a/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs +++ b/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs @@ -107,7 +107,8 @@ public void StandardDiscountRules_DoNotDependBackOnPricing() AddMocks(services, typeof(IContextAccessor), typeof(IProductService), typeof(IMediator), typeof(ICurrencyService), typeof(IDiscountService), typeof(ICategoryService), typeof(IBrandService), typeof(ICollectionService), typeof(IVendorService), typeof(IRepository), - typeof(IOrderService), typeof(ITranslationService), typeof(IRequestTranslationService)); + typeof(IRepository), typeof(IOrderService), typeof(ITranslationService), + typeof(IRequestTranslationService)); services.AddSingleton(new ShoppingCartSettings()); services.AddSingleton(new CatalogSettings()); @@ -134,7 +135,8 @@ public void ShippingProviders_DoNotDependBackOnShipping() typeof(IShippingMethodService), typeof(IShippingByWeightService), typeof(ICheckoutAttributeParser), typeof(IContextAccessor), typeof(IProductService), typeof(IMediator), typeof(ICurrencyService), typeof(IDiscountService), typeof(ICategoryService), typeof(IBrandService), typeof(ICollectionService), - typeof(IVendorService), typeof(IRepository), typeof(IOrderService), + typeof(IVendorService), typeof(IRepository), typeof(IRepository), + typeof(IOrderService), typeof(ITranslationService), typeof(IRequestTranslationService)); services.AddSingleton(new ShippingProviderSettings()); services.AddSingleton(new ShippingSettings()); From 87d7192439715ed0ad0cee1cdaa95402bcaa8f6e Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 17:47:18 +0200 Subject: [PATCH 2/3] Query a discount usage count once and retry it after a failure GetOrAdd could run the count factory once per racing thread, so a concurrent listing still sent duplicate queries; the count is now a Lazy task. A faulted count is removed from the scope instead of failing every later validation of that discount. The concurrency test now counts repository calls, and a new test covers the retry after a failed count. --- .../Discounts/DiscountValidationService.cs | 26 ++++++++---- .../Discounts/DiscountServiceTests.cs | 42 ++++++++++++++++++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs b/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs index 9a8140a1fe..d62ad980f3 100644 --- a/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs @@ -18,7 +18,8 @@ public class DiscountValidationService : IDiscountValidationService private readonly IDiscountProviderLoader _discountProviderLoader; //usage counts read in this scope; a listing validates the same discount for every product, concurrently - private readonly ConcurrentDictionary<(string discountId, string customerId), Task> _usageCounts = new(); + //Lazy, because GetOrAdd may run the value factory once per racing thread + private readonly ConcurrentDictionary<(string discountId, string customerId), Lazy>> _usageCounts = new(); public DiscountValidationService( IDiscountProviderLoader discountProviderLoader, @@ -222,16 +223,27 @@ public virtual async Task ValidateDiscount(Discount di /// /// Counts the usage history of a discount, of one customer when customerId is set /// - private Task GetUsedTimes(string discountId, string customerId) + private async Task GetUsedTimes(string discountId, string customerId) { - return _usageCounts.GetOrAdd((discountId, customerId), key => + var key = (discountId, customerId); + var usedTimes = _usageCounts.GetOrAdd(key, k => new Lazy>(() => { - var query = _discountUsageHistoryRepository.Table.Where(x => x.DiscountId == key.discountId); - if (!string.IsNullOrEmpty(key.customerId)) - query = query.Where(x => x.CustomerId == key.customerId); + var query = _discountUsageHistoryRepository.Table.Where(x => x.DiscountId == k.discountId); + if (!string.IsNullOrEmpty(k.customerId)) + query = query.Where(x => x.CustomerId == k.customerId); return _discountUsageHistoryRepository.CountAsync(query); - }); + })); + try + { + return await usedTimes.Value; + } + catch + { + //a failed count is not kept, the next validation in the scope queries again + _usageCounts.TryRemove(new KeyValuePair<(string, string), Lazy>>(key, usedTimes)); + throw; + } } /// diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs index 4b08104d48..453d16425c 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs @@ -921,13 +921,51 @@ public async Task ValidateDiscountTest_NTimes_ConcurrentProductsShareTheCount() { //Arrange - product overviews of a listing are prepared concurrently in one scope var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 1); - await InsertUsage(discount.Id, "customer1"); + var counts = 0; + var usageHistoryRepository = new Mock>(); + usageHistoryRepository.Setup(r => r.Table) + .Returns(new List().AsQueryable()); + usageHistoryRepository + .Setup(r => r.CountAsync(It.IsAny>(), It.IsAny())) + .Returns(async () => + { + Interlocked.Increment(ref counts); + await Task.Delay(50); + return 1; + }); + var service = new DiscountValidationService(_discountProviderLoader, _discountCouponRepository, + usageHistoryRepository.Object); //Act var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => - Task.Run(() => _discountValidationService.ValidateDiscount(discount, new Customer(), new Store(), + Task.Run(() => service.ValidateDiscount(discount, new Customer(), new Store(), new Currency { CurrencyCode = "USD" })))); //Assert Assert.IsTrue(results.All(x => !x.IsValid)); + Assert.AreEqual(1, counts); + } + + [TestMethod] + public async Task ValidateDiscountTest_NTimes_FailedCountIsQueriedAgain() + { + //Arrange + var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 1); + var usageHistoryRepository = new Mock>(); + usageHistoryRepository.Setup(r => r.Table) + .Returns(new List().AsQueryable()); + usageHistoryRepository + .SetupSequence(r => r.CountAsync(It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new TimeoutException()) + .ReturnsAsync(0); + var service = new DiscountValidationService(_discountProviderLoader, _discountCouponRepository, + usageHistoryRepository.Object); + //Act + await Assert.ThrowsExactlyAsync(() => service.ValidateDiscount(discount, + new Customer(), new Store(), new Currency { CurrencyCode = "USD" })); + var result = await service.ValidateDiscount(discount, new Customer(), new Store(), + new Currency { CurrencyCode = "USD" }); + //Assert + Assert.IsTrue(result.IsValid); } [TestMethod] From 87c6584bce11e7588033550ea37a59f06441e02b Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 20:23:33 +0200 Subject: [PATCH 3/3] Read the discount usage count on every validation The scope-wide count also served order placement, which validates through the same pricing path as a listing. A usage recorded earlier in the scope went unseen, so an N-times limit could be passed again. Every validation now counts the usage history itself. The tests check that a usage recorded between two validations in one scope is seen. --- .../Discounts/DiscountValidationService.cs | 29 ++------- .../Discounts/DiscountServiceTests.cs | 65 ++----------------- 2 files changed, 11 insertions(+), 83 deletions(-) diff --git a/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs b/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs index d62ad980f3..530335039e 100644 --- a/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Discounts/DiscountValidationService.cs @@ -7,7 +7,6 @@ using Grand.Domain.Orders; using Grand.Domain.Stores; using Grand.Infrastructure.Extensions; -using System.Collections.Concurrent; namespace Grand.Business.Catalog.Services.Discounts; @@ -17,10 +16,6 @@ public class DiscountValidationService : IDiscountValidationService private readonly IRepository _discountUsageHistoryRepository; private readonly IDiscountProviderLoader _discountProviderLoader; - //usage counts read in this scope; a listing validates the same discount for every product, concurrently - //Lazy, because GetOrAdd may run the value factory once per racing thread - private readonly ConcurrentDictionary<(string discountId, string customerId), Lazy>> _usageCounts = new(); - public DiscountValidationService( IDiscountProviderLoader discountProviderLoader, IRepository discountCouponRepository, @@ -223,27 +218,13 @@ public virtual async Task ValidateDiscount(Discount di /// /// Counts the usage history of a discount, of one customer when customerId is set /// - private async Task GetUsedTimes(string discountId, string customerId) + private Task GetUsedTimes(string discountId, string customerId) { - var key = (discountId, customerId); - var usedTimes = _usageCounts.GetOrAdd(key, k => new Lazy>(() => - { - var query = _discountUsageHistoryRepository.Table.Where(x => x.DiscountId == k.discountId); - if (!string.IsNullOrEmpty(k.customerId)) - query = query.Where(x => x.CustomerId == k.customerId); + var query = _discountUsageHistoryRepository.Table.Where(x => x.DiscountId == discountId); + if (!string.IsNullOrEmpty(customerId)) + query = query.Where(x => x.CustomerId == customerId); - return _discountUsageHistoryRepository.CountAsync(query); - })); - try - { - return await usedTimes.Value; - } - catch - { - //a failed count is not kept, the next validation in the scope queries again - _usageCounts.TryRemove(new KeyValuePair<(string, string), Lazy>>(key, usedTimes)); - throw; - } + return _discountUsageHistoryRepository.CountAsync(query); } /// diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs index 453d16425c..a10f717453 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Discounts/DiscountServiceTests.cs @@ -891,9 +891,8 @@ public async Task ValidateDiscountTest_NTimesPerUser_CountsOnlyTheCustomer() var valid = await _discountValidationService.ValidateDiscount(discount, customer, new Store(), new Currency { CurrencyCode = "USD" }); await InsertUsage(discount.Id, customer.Id); - var used = await new DiscountValidationService(_discountProviderLoader, _discountCouponRepository, - _discountUsageHistoryRepository) - .ValidateDiscount(discount, customer, new Store(), new Currency { CurrencyCode = "USD" }); + var used = await _discountValidationService.ValidateDiscount(discount, customer, new Store(), + new Currency { CurrencyCode = "USD" }); //Assert Assert.IsTrue(valid.IsValid); Assert.IsFalse(used.IsValid); @@ -901,71 +900,19 @@ public async Task ValidateDiscountTest_NTimesPerUser_CountsOnlyTheCustomer() } [TestMethod] - public async Task ValidateDiscountTest_NTimes_CountsUsageOncePerScope() + public async Task ValidateDiscountTest_NTimes_SeesUsageRecordedInTheScope() { //Arrange var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 1); - //Act + //Act - order placement validates, records the usage, and the next order validates again var first = await _discountValidationService.ValidateDiscount(discount, new Customer(), new Store(), new Currency { CurrencyCode = "USD" }); await InsertUsage(discount.Id, "customer1"); var second = await _discountValidationService.ValidateDiscount(discount, new Customer(), new Store(), new Currency { CurrencyCode = "USD" }); - //Assert - the count read for the first product is reused for the rest of the scope - Assert.IsTrue(first.IsValid); - Assert.IsTrue(second.IsValid); - } - - [TestMethod] - public async Task ValidateDiscountTest_NTimes_ConcurrentProductsShareTheCount() - { - //Arrange - product overviews of a listing are prepared concurrently in one scope - var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 1); - var counts = 0; - var usageHistoryRepository = new Mock>(); - usageHistoryRepository.Setup(r => r.Table) - .Returns(new List().AsQueryable()); - usageHistoryRepository - .Setup(r => r.CountAsync(It.IsAny>(), It.IsAny())) - .Returns(async () => - { - Interlocked.Increment(ref counts); - await Task.Delay(50); - return 1; - }); - var service = new DiscountValidationService(_discountProviderLoader, _discountCouponRepository, - usageHistoryRepository.Object); - //Act - var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => - Task.Run(() => service.ValidateDiscount(discount, new Customer(), new Store(), - new Currency { CurrencyCode = "USD" })))); - //Assert - Assert.IsTrue(results.All(x => !x.IsValid)); - Assert.AreEqual(1, counts); - } - - [TestMethod] - public async Task ValidateDiscountTest_NTimes_FailedCountIsQueriedAgain() - { - //Arrange - var discount = await InsertLimitedDiscount(DiscountLimitationType.NTimes, 1); - var usageHistoryRepository = new Mock>(); - usageHistoryRepository.Setup(r => r.Table) - .Returns(new List().AsQueryable()); - usageHistoryRepository - .SetupSequence(r => r.CountAsync(It.IsAny>(), - It.IsAny())) - .ThrowsAsync(new TimeoutException()) - .ReturnsAsync(0); - var service = new DiscountValidationService(_discountProviderLoader, _discountCouponRepository, - usageHistoryRepository.Object); - //Act - await Assert.ThrowsExactlyAsync(() => service.ValidateDiscount(discount, - new Customer(), new Store(), new Currency { CurrencyCode = "USD" })); - var result = await service.ValidateDiscount(discount, new Customer(), new Store(), - new Currency { CurrencyCode = "USD" }); //Assert - Assert.IsTrue(result.IsValid); + Assert.IsTrue(first.IsValid); + Assert.IsFalse(second.IsValid); } [TestMethod]