Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
using Grand.Business.Core.Interfaces.Common.Configuration;
using Grand.Business.Core.Interfaces.Common.Directory;
using Grand.Business.Core.Interfaces.Common.Localization;
using Grand.Business.Core.Interfaces.Common.Stores;
using Grand.Domain.Directory;
using Grand.Domain.Stores;
using Grand.Infrastructure.Caching;
using Grand.Web.AdminShared.Models.Directory;
using Grand.Web.AdminShared.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
Expand All @@ -14,6 +17,7 @@ namespace Grand.Web.Admin.Tests.Services;
public class CurrencyViewModelServiceTests
{
private Mock<ICurrencyService> _currencyServiceMock;
private Mock<IStoreService> _storeServiceMock;
private CurrencySettings _currencySettings;
private Mock<ISettingService> _settingServiceMock;
private Mock<ITranslationService> _translationServiceMock;
Expand All @@ -24,13 +28,15 @@ public class CurrencyViewModelServiceTests
public void Setup()
{
_currencyServiceMock = new Mock<ICurrencyService>();
_storeServiceMock = new Mock<IStoreService>();
_currencySettings = new CurrencySettings();
_settingServiceMock = new Mock<ISettingService>();
_translationServiceMock = new Mock<ITranslationService>();
_cacheBaseMock = new Mock<ICacheBase>();

_service = new CurrencyViewModelService(
_currencyServiceMock.Object,
_storeServiceMock.Object,
_currencySettings,
_settingServiceMock.Object,
_translationServiceMock.Object,
Expand Down Expand Up @@ -90,6 +96,68 @@ public async Task ValidateCurrencyUnpublish_OtherCurrenciesExist_CanProceed()
Assert.IsTrue(canProceed);
}

[TestMethod]
public async Task ValidateCurrencyStoreMapping_PublishedAndNotLimited_CanProceed()
{
var (canProceed, message) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" },
new CurrencyModel { Published = true, Stores = [] });

Assert.IsTrue(canProceed);
Assert.AreEqual(string.Empty, message);
_storeServiceMock.Verify(s => s.GetAllStores(), Times.Never);
}

[TestMethod]
public async Task ValidateCurrencyStoreMapping_StoreLeftWithoutCurrency_CannotProceed()
{
_currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(new List<Currency> {
new() { Id = "currency-1" },
new() { Id = "currency-2", LimitedToStores = true, Stores = ["store-1"] }
});
_storeServiceMock.Setup(s => s.GetAllStores())
.ReturnsAsync(new List<Store> { new() { Id = "store-1" }, new() { Id = "store-2", Name = "Second" } });
_translationServiceMock.Setup(t => t.GetResource("Admin.Configuration.Currencies.CantLimitStores"))
.Returns("Store '{0}' has no currency");

var (canProceed, message) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" },
new CurrencyModel { Published = true, Stores = ["store-1"] });

Assert.IsFalse(canProceed);
Assert.AreEqual("Store 'Second' has no currency", message);
}

[TestMethod]
public async Task ValidateCurrencyStoreMapping_AnotherGlobalCurrencyExists_CanProceed()
{
_currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(new List<Currency> { new() { Id = "currency-1" }, new() { Id = "currency-2" } });
_storeServiceMock.Setup(s => s.GetAllStores())
.ReturnsAsync(new List<Store> { new() { Id = "store-1" }, new() { Id = "store-2" } });

var (canProceed, _) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" },
new CurrencyModel { Published = true, Stores = ["store-1"] });

Assert.IsTrue(canProceed);
}

[TestMethod]
public async Task ValidateCurrencyStoreMapping_UnpublishingLastCurrencyOfStore_CannotProceed()
{
_currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(new List<Currency> { new() { Id = "currency-1" } });
_storeServiceMock.Setup(s => s.GetAllStores())
.ReturnsAsync(new List<Store> { new() { Id = "store-1", Name = "First" } });
_translationServiceMock.Setup(t => t.GetResource("Admin.Configuration.Currencies.CantLimitStores"))
.Returns("Store '{0}' has no currency");

var (canProceed, message) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" },
new CurrencyModel { Published = false, Stores = [] });

Assert.IsFalse(canProceed);
Assert.AreEqual("Store 'First' has no currency", message);
}

[TestMethod]
public async Task ValidateCurrencyDelete_PrimaryStoreCurrency_CannotDelete()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Grand.Business.Core.Interfaces.Common.Directory;
using Grand.Business.Core.Interfaces.Common.Localization;
using Grand.Business.Core.Interfaces.Common.Stores;
using Grand.Domain.Customers;
using Grand.Domain.Directory;
using Grand.Domain.Stores;
using Grand.Infrastructure;
using Grand.Web.Store.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using DomainStore = Grand.Domain.Stores.Store;

namespace Grand.Web.Store.Tests.Controllers;

[TestClass]
public class CurrencyControllerTests
{
private const string StoreId = "storeId";

private CurrencyController _controller;
private Mock<ICurrencyService> _currencyServiceMock;
private Mock<IStoreService> _storeServiceMock;
private Mock<ITranslationService> _translationServiceMock;
private CurrencySettings _currencySettings;

[TestInitialize]
public void Setup()
{
_currencyServiceMock = new Mock<ICurrencyService>();
_storeServiceMock = new Mock<IStoreService>();
_translationServiceMock = new Mock<ITranslationService>();
_translationServiceMock.Setup(t => t.GetResource(It.IsAny<string>())).Returns<string>(x => x);
_currencySettings = new CurrencySettings();

var workContextMock = new Mock<IWorkContext>();
workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = StoreId });
var contextAccessorMock = new Mock<IContextAccessor>();
contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object);

_controller = new CurrencyController(
_currencyServiceMock.Object,
_currencySettings,
_translationServiceMock.Object,
_storeServiceMock.Object,
contextAccessorMock.Object);

var httpContext = new DefaultHttpContext();
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
_controller.TempData = new TempDataDictionary(httpContext, new Mock<ITempDataProvider>().Object);
}

private static Currency StoreCurrency(string id)
{
return new Currency { Id = id, Published = true, LimitedToStores = true, Stores = [StoreId] };
}

[TestMethod]
public async Task UnassignStore_LastAvailableCurrency_IsRejected()
{
var currency = StoreCurrency("currency-1");
_currencyServiceMock.Setup(c => c.GetCurrencyById(currency.Id)).ReturnsAsync(currency);
_storeServiceMock.Setup(s => s.GetStoreById(StoreId)).ReturnsAsync(new DomainStore { Id = StoreId });
_currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny<bool>(), StoreId))
.ReturnsAsync(new List<Currency> { currency });

var result = await _controller.UnassignStore(currency.Id) as JsonResult;

Assert.IsNotNull(result);
Assert.AreEqual("Admin.Configuration.Currencies.CantUnassignLast",
result.Value.GetType().GetProperty("message")!.GetValue(result.Value));
Assert.IsTrue(currency.Stores.Contains(StoreId));
_currencyServiceMock.Verify(c => c.UpdateCurrency(It.IsAny<Currency>()), Times.Never);
}

[TestMethod]
public async Task UnassignStore_AnotherCurrencyRemains_IsUnassigned()
{
var currency = StoreCurrency("currency-1");
_currencyServiceMock.Setup(c => c.GetCurrencyById(currency.Id)).ReturnsAsync(currency);
_storeServiceMock.Setup(s => s.GetStoreById(StoreId)).ReturnsAsync(new DomainStore { Id = StoreId });
_currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny<bool>(), StoreId))
.ReturnsAsync(new List<Currency> { currency, StoreCurrency("currency-2") });

var result = await _controller.UnassignStore(currency.Id) as JsonResult;

Assert.IsNotNull(result);
Assert.IsTrue((bool)result.Value.GetType().GetProperty("success")!.GetValue(result.Value)!);
Assert.IsFalse(currency.Stores.Contains(StoreId));
_currencyServiceMock.Verify(c => c.UpdateCurrency(currency), Times.Once);
}
}
9 changes: 9 additions & 0 deletions src/Web/Grand.Web.Admin/Controllers/CurrencyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,15 @@ public async Task<IActionResult> Edit(CurrencyModel model, bool continueEditing)
return RedirectToAction("Edit", new { id = currency.Id });
}

//ensure no store is left without an available currency
var (canMap, mappingMessage) =
await _currencyViewModelService.ValidateCurrencyStoreMapping(currency, model);
if (!canMap)
{
Error(mappingMessage);
return RedirectToAction("Edit", new { id = currency.Id });
}

currency = await _currencyViewModelService.UpdateCurrencyModel(currency, model);
Success(_translationService.GetResource("Admin.Configuration.Currencies.Updated"));
if (continueEditing)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ public interface ICurrencyViewModelService
Task MarkAsPrimaryExchangeRateCurrency(string id);
Task MarkAsPrimaryStoreCurrency(string id);
Task<(bool canProceed, string message)> ValidateCurrencyUnpublish(string currencyId, bool published);
Task<(bool canProceed, string message)> ValidateCurrencyStoreMapping(Currency currency, CurrencyModel model);
Task<(bool canDelete, string message)> ValidateCurrencyDelete(Currency currency);
}
31 changes: 31 additions & 0 deletions src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Grand.Business.Core.Interfaces.Common.Configuration;
using Grand.Business.Core.Interfaces.Common.Directory;
using Grand.Business.Core.Interfaces.Common.Localization;
using Grand.Business.Core.Interfaces.Common.Stores;
using Grand.Domain.Directory;
using Grand.Infrastructure.Caching;
using Grand.Web.AdminShared.Extensions.Mapping;
Expand All @@ -14,6 +15,7 @@ public class CurrencyViewModelService : ICurrencyViewModelService
#region Fields

private readonly ICurrencyService _currencyService;
private readonly IStoreService _storeService;
private readonly CurrencySettings _currencySettings;
private readonly ISettingService _settingService;
private readonly ITranslationService _translationService;
Expand All @@ -24,12 +26,14 @@ public class CurrencyViewModelService : ICurrencyViewModelService
#region Constructors

public CurrencyViewModelService(ICurrencyService currencyService,
IStoreService storeService,
CurrencySettings currencySettings,
ISettingService settingService,
ITranslationService translationService,
ICacheBase cacheBase)
{
_currencyService = currencyService;
_storeService = storeService;
_currencySettings = currencySettings;
_settingService = settingService;
_translationService = translationService;
Expand Down Expand Up @@ -90,6 +94,33 @@ public virtual async Task MarkAsPrimaryStoreCurrency(string id)
return (true, string.Empty);
}

public virtual async Task<(bool canProceed, string message)> ValidateCurrencyStoreMapping(Currency currency,
CurrencyModel model)
{
var limitedToStores = model.Stores is { Length: > 0 };

//a published currency available to every store can never leave a store without one
if (model.Published && !limitedToStores)
return (true, string.Empty);

var otherCurrencies = (await _currencyService.GetAllCurrencies())
.Where(c => c.Id != currency.Id).ToList();

foreach (var store in await _storeService.GetAllStores())
{
if (otherCurrencies.Any(c => !c.LimitedToStores || c.Stores.Contains(store.Id)))
continue;

if (model.Published && (!limitedToStores || model.Stores.Contains(store.Id)))
continue;

return (false, string.Format(
_translationService.GetResource("Admin.Configuration.Currencies.CantLimitStores"), store.Name));
}
Comment on lines +109 to +119

return (true, string.Empty);
}

public virtual async Task<(bool canDelete, string message)> ValidateCurrencyDelete(Currency currency)
{
if (currency.Id == _currencySettings.PrimaryStoreCurrencyId)
Expand Down
6 changes: 6 additions & 0 deletions src/Web/Grand.Web.Common/WorkContextSetter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,12 @@ protected async Task<Currency> WorkingCurrency(Customer customer, Language langu

var allStoreCurrencies = await _currencyService.GetAllCurrencies(storeId: store.Id);

//no published currency is mapped to the store - fall back to the primary store currency
//rather than failing every request for that store
if (!allStoreCurrencies.Any())
return await _currencyService.GetPrimaryStoreCurrency() ??
throw new Exception("No currency could be loaded");

if (allStoreCurrencies.Count == 1)
return allStoreCurrencies.FirstOrDefault();

Expand Down
5 changes: 5 additions & 0 deletions src/Web/Grand.Web.Store/Controllers/CurrencyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ public async Task<IActionResult> UnassignStore(string id)
if (store?.DefaultCurrencyId == currency.Id)
return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.CantUnassignDefault") });

//the store must keep at least one available currency, otherwise its work context cannot be built
var storeCurrencies = await currencyService.GetAllCurrencies(storeId: storeId);
if (!storeCurrencies.Any(c => c.Id != currency.Id))
return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.CantUnassignLast") });

if (currency.Stores.Remove(storeId))
await currencyService.UpdateCurrency(currency);

Expand Down
Binary file modified src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml
Binary file not shown.
6 changes: 6 additions & 0 deletions src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,15 @@
<Resource Name="admin.configuration.currencies.cannotmodifyglobal" Area="Admin">
<Value>Global currencies cannot be modified by store owners.</Value>
</Resource>
<Resource Name="admin.configuration.currencies.cantlimitstores" Area="Admin">
<Value>The store '{0}' would be left without an available currency.</Value>
</Resource>
<Resource Name="admin.configuration.currencies.cantunassigndefault" Area="Admin">
<Value>The default store currency can't be unassigned.</Value>
</Resource>
<Resource Name="admin.configuration.currencies.cantunassignlast" Area="Admin">
<Value>At least one currency must remain available for your store.</Value>
</Resource>
<Resource Name="admin.configuration.currencies.notassignedtostore" Area="Admin">
<Value>This currency is not assigned to your store.</Value>
</Resource>
Expand Down
Loading