Implement product multi-buys and variants system
Major restructuring of product variations: - Renamed ProductVariation to ProductMultiBuy for quantity-based pricing (e.g., "3 for £25") - Added new ProductVariant model for string-based options (colors, flavors) - Complete separation of multi-buy pricing from variant selection Features implemented: - Multi-buy deals with automatic price-per-unit calculation - Product variants for colors/flavors/sizes with stock tracking - TeleBot checkout supports both multi-buys and variant selection - Shopping cart correctly calculates multi-buy bundle prices - Order system tracks selected variants and multi-buy choices - Real-time bot activity monitoring with SignalR - Public bot directory page with QR codes for Telegram launch - Admin dashboard shows multi-buy and variant metrics Technical changes: - Updated all DTOs, services, and controllers - Fixed cart total calculation for multi-buy bundles - Comprehensive test coverage for new functionality - All existing tests passing with new features Database changes: - Migrated ProductVariations to ProductMultiBuys - Added ProductVariants table - Updated OrderItems to track variants 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
373
LittleShop.Tests/Integration/OrdersWithVariantsTests.cs
Normal file
373
LittleShop.Tests/Integration/OrdersWithVariantsTests.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Models;
|
||||
using LittleShop.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace LittleShop.Tests.Integration;
|
||||
|
||||
public class OrdersWithVariantsTests : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
|
||||
public OrdersWithVariantsTests(TestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrder_WithVariants_ShouldStoreVariantInfo()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateProductWithVariants();
|
||||
|
||||
var orderRequest = new CreateOrderDto
|
||||
{
|
||||
IdentityReference = "test_user_" + Guid.NewGuid(),
|
||||
ShippingName = "John Doe",
|
||||
ShippingAddress = "123 Test St",
|
||||
ShippingCity = "Test City",
|
||||
ShippingPostCode = "TE1 1ST",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Items = new List<CreateOrderItemDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Quantity = 1,
|
||||
SelectedVariant = "Red"
|
||||
},
|
||||
new()
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Quantity = 2,
|
||||
SelectedVariant = "Blue"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/orders", orderRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
|
||||
order.Should().NotBeNull();
|
||||
order!.Items.Should().HaveCount(2);
|
||||
order.Items.Should().Contain(i => i.SelectedVariant == "Red" && i.Quantity == 1);
|
||||
order.Items.Should().Contain(i => i.SelectedVariant == "Blue" && i.Quantity == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrder_WithMultiBuy_ShouldApplyMultiBuyPricing()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateProductWithMultiBuys();
|
||||
var multiBuy = product.MultiBuys.First(mb => mb.Quantity == 3);
|
||||
|
||||
var orderRequest = new CreateOrderDto
|
||||
{
|
||||
IdentityReference = "test_user_" + Guid.NewGuid(),
|
||||
ShippingName = "Jane Doe",
|
||||
ShippingAddress = "456 Test Ave",
|
||||
ShippingCity = "Test Town",
|
||||
ShippingPostCode = "TT2 2ST",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Items = new List<CreateOrderItemDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ProductId = product.Id,
|
||||
ProductMultiBuyId = multiBuy.Id,
|
||||
Quantity = 3
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/orders", orderRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
|
||||
order.Should().NotBeNull();
|
||||
order!.Items.Should().HaveCount(1);
|
||||
|
||||
var item = order.Items.First();
|
||||
item.Quantity.Should().Be(3);
|
||||
item.ProductMultiBuyId.Should().Be(multiBuy.Id);
|
||||
item.UnitPrice.Should().Be(multiBuy.PricePerUnit);
|
||||
item.TotalPrice.Should().Be(multiBuy.Price);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrder_WithMultiBuyAndVariant_ShouldStoreBoth()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateProductWithMultiBuysAndVariants();
|
||||
var multiBuy = product.MultiBuys.First(mb => mb.Quantity == 2);
|
||||
|
||||
var orderRequest = new CreateOrderDto
|
||||
{
|
||||
IdentityReference = "test_user_" + Guid.NewGuid(),
|
||||
ShippingName = "Bob Smith",
|
||||
ShippingAddress = "789 Test Rd",
|
||||
ShippingCity = "Testville",
|
||||
ShippingPostCode = "TV3 3ST",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Items = new List<CreateOrderItemDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ProductId = product.Id,
|
||||
ProductMultiBuyId = multiBuy.Id,
|
||||
Quantity = 2,
|
||||
SelectedVariant = "Vanilla"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/orders", orderRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
|
||||
order.Should().NotBeNull();
|
||||
|
||||
var item = order.Items.First();
|
||||
item.ProductMultiBuyId.Should().Be(multiBuy.Id);
|
||||
item.SelectedVariant.Should().Be("Vanilla");
|
||||
item.Quantity.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrder_MixedItems_ShouldCalculateTotalCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var regularProduct = await CreateRegularProduct();
|
||||
var variantProduct = await CreateProductWithVariants();
|
||||
var multiBuyProduct = await CreateProductWithMultiBuys();
|
||||
|
||||
var multiBuy = multiBuyProduct.MultiBuys.First(mb => mb.Quantity == 3);
|
||||
|
||||
var orderRequest = new CreateOrderDto
|
||||
{
|
||||
IdentityReference = "test_user_" + Guid.NewGuid(),
|
||||
ShippingName = "Alice Johnson",
|
||||
ShippingAddress = "321 Mixed St",
|
||||
ShippingCity = "Complex City",
|
||||
ShippingPostCode = "CC4 4ST",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Items = new List<CreateOrderItemDto>
|
||||
{
|
||||
// Regular product
|
||||
new()
|
||||
{
|
||||
ProductId = regularProduct.Id,
|
||||
Quantity = 2
|
||||
},
|
||||
// Product with variant
|
||||
new()
|
||||
{
|
||||
ProductId = variantProduct.Id,
|
||||
Quantity = 1,
|
||||
SelectedVariant = "Blue"
|
||||
},
|
||||
// Product with multi-buy
|
||||
new()
|
||||
{
|
||||
ProductId = multiBuyProduct.Id,
|
||||
ProductMultiBuyId = multiBuy.Id,
|
||||
Quantity = 3
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/orders", orderRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
|
||||
order.Should().NotBeNull();
|
||||
order!.Items.Should().HaveCount(3);
|
||||
|
||||
// Verify total calculation
|
||||
var expectedTotal = (regularProduct.Price * 2) +
|
||||
variantProduct.Price +
|
||||
multiBuy.Price;
|
||||
order.TotalAmount.Should().Be(expectedTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrder_ShouldIncludeVariantAndMultiBuyInfo()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateProductWithMultiBuysAndVariants();
|
||||
var multiBuy = product.MultiBuys.First();
|
||||
|
||||
var orderRequest = new CreateOrderDto
|
||||
{
|
||||
IdentityReference = "test_user_" + Guid.NewGuid(),
|
||||
ShippingName = "Test User",
|
||||
ShippingAddress = "Test Address",
|
||||
ShippingCity = "Test City",
|
||||
ShippingPostCode = "TE1 1ST",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Items = new List<CreateOrderItemDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ProductId = product.Id,
|
||||
ProductMultiBuyId = multiBuy.Id,
|
||||
Quantity = multiBuy.Quantity,
|
||||
SelectedVariant = "Chocolate"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/orders", orderRequest);
|
||||
var createdOrder = await createResponse.Content.ReadFromJsonAsync<OrderDto>();
|
||||
|
||||
// Act
|
||||
var getResponse = await _client.GetAsync($"/api/orders/by-identity/{orderRequest.IdentityReference}/{createdOrder!.Id}");
|
||||
|
||||
// Assert
|
||||
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var retrievedOrder = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
|
||||
retrievedOrder.Should().NotBeNull();
|
||||
|
||||
var item = retrievedOrder!.Items.First();
|
||||
item.ProductMultiBuyId.Should().Be(multiBuy.Id);
|
||||
item.SelectedVariant.Should().Be("Chocolate");
|
||||
}
|
||||
|
||||
private async Task<ProductDto> CreateRegularProduct()
|
||||
{
|
||||
var category = await CreateTestCategory();
|
||||
|
||||
var productDto = new CreateProductDto
|
||||
{
|
||||
Name = "Regular Product",
|
||||
Description = "A regular product without variants",
|
||||
Price = 15.00m,
|
||||
CategoryId = category.Id,
|
||||
Weight = 1.0m,
|
||||
WeightUnit = (int)LittleShop.Enums.ProductWeightUnit.Kilograms
|
||||
};
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/admin/products", productDto);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<ProductDto>())!;
|
||||
}
|
||||
|
||||
private async Task<ProductDto> CreateProductWithVariants()
|
||||
{
|
||||
var product = await CreateRegularProduct();
|
||||
|
||||
// Add variants
|
||||
var variants = new[] { "Red", "Blue", "Green" };
|
||||
foreach (var variantName in variants)
|
||||
{
|
||||
var variantDto = new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = variantName,
|
||||
VariantType = "Color",
|
||||
StockLevel = 100
|
||||
};
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/admin/products/{product.Id}/variants", variantDto);
|
||||
}
|
||||
|
||||
// Refresh product to get variants
|
||||
var response = await _client.GetAsync($"/api/catalog/products/{product.Id}");
|
||||
return (await response.Content.ReadFromJsonAsync<ProductDto>())!;
|
||||
}
|
||||
|
||||
private async Task<ProductDto> CreateProductWithMultiBuys()
|
||||
{
|
||||
var product = await CreateRegularProduct();
|
||||
|
||||
// Add multi-buys
|
||||
var multiBuys = new[]
|
||||
{
|
||||
new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Twin Pack",
|
||||
Quantity = 2,
|
||||
Price = 25.00m
|
||||
},
|
||||
new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Triple Pack",
|
||||
Quantity = 3,
|
||||
Price = 35.00m
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var multiBuyDto in multiBuys)
|
||||
{
|
||||
await _client.PostAsJsonAsync($"/api/admin/products/{product.Id}/multibuys", multiBuyDto);
|
||||
}
|
||||
|
||||
// Refresh product to get multi-buys
|
||||
var response = await _client.GetAsync($"/api/catalog/products/{product.Id}");
|
||||
return (await response.Content.ReadFromJsonAsync<ProductDto>())!;
|
||||
}
|
||||
|
||||
private async Task<ProductDto> CreateProductWithMultiBuysAndVariants()
|
||||
{
|
||||
var product = await CreateProductWithMultiBuys();
|
||||
|
||||
// Add variants
|
||||
var variants = new[] { "Vanilla", "Chocolate", "Strawberry" };
|
||||
foreach (var variantName in variants)
|
||||
{
|
||||
var variantDto = new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = variantName,
|
||||
VariantType = "Flavor",
|
||||
StockLevel = 50
|
||||
};
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/admin/products/{product.Id}/variants", variantDto);
|
||||
}
|
||||
|
||||
// Refresh product
|
||||
var response = await _client.GetAsync($"/api/catalog/products/{product.Id}");
|
||||
return (await response.Content.ReadFromJsonAsync<ProductDto>())!;
|
||||
}
|
||||
|
||||
private async Task<CategoryDto> CreateTestCategory()
|
||||
{
|
||||
var categoryDto = new CreateCategoryDto
|
||||
{
|
||||
Name = "Test Category " + Guid.NewGuid(),
|
||||
Description = "Test category for integration tests"
|
||||
};
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/admin/categories", categoryDto);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<CategoryDto>())!;
|
||||
}
|
||||
}
|
||||
252
LittleShop.Tests/Unit/ProductMultiBuyServiceTests.cs
Normal file
252
LittleShop.Tests/Unit/ProductMultiBuyServiceTests.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using FluentAssertions;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Models;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace LittleShop.Tests.Unit;
|
||||
|
||||
public class ProductMultiBuyServiceTests : IDisposable
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly IProductService _productService;
|
||||
private readonly Mock<IWebHostEnvironment> _mockEnvironment;
|
||||
|
||||
public ProductMultiBuyServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LittleShopContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new LittleShopContext(options);
|
||||
|
||||
_mockEnvironment = new Mock<IWebHostEnvironment>();
|
||||
_mockEnvironment.Setup(e => e.WebRootPath).Returns("/test/wwwroot");
|
||||
|
||||
_productService = new ProductService(_context, _mockEnvironment.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateProductMultiBuy_ShouldCreateSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
var createDto = new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "3 for £25",
|
||||
Description = "Save £5 when you buy 3",
|
||||
Quantity = 3,
|
||||
Price = 25.00m
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _productService.CreateProductMultiBuyAsync(createDto);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Name.Should().Be("3 for £25");
|
||||
result.Quantity.Should().Be(3);
|
||||
result.Price.Should().Be(25.00m);
|
||||
result.PricePerUnit.Should().BeApproximately(8.33m, 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateProductMultiBuy_DuplicateQuantity_ShouldThrow()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
|
||||
// Create first multi-buy
|
||||
await _productService.CreateProductMultiBuyAsync(new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Twin Pack",
|
||||
Quantity = 2,
|
||||
Price = 19.00m
|
||||
});
|
||||
|
||||
// Try to create duplicate quantity
|
||||
var duplicateDto = new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Double Deal",
|
||||
Quantity = 2, // Same quantity
|
||||
Price = 18.00m
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _productService.CreateProductMultiBuyAsync(duplicateDto);
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*already exists*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProductWithMultiBuys_ShouldReturnAllMultiBuys()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
|
||||
// Create multiple multi-buys
|
||||
await _productService.CreateProductMultiBuyAsync(new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Twin Pack",
|
||||
Quantity = 2,
|
||||
Price = 19.00m
|
||||
});
|
||||
|
||||
await _productService.CreateProductMultiBuyAsync(new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Triple Pack",
|
||||
Quantity = 3,
|
||||
Price = 25.00m
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _productService.GetProductByIdAsync(product.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.MultiBuys.Should().HaveCount(2);
|
||||
result.MultiBuys.Should().Contain(mb => mb.Name == "Twin Pack");
|
||||
result.MultiBuys.Should().Contain(mb => mb.Name == "Triple Pack");
|
||||
result.MultiBuys.Should().BeInAscendingOrder(mb => mb.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateProductMultiBuy_ShouldUpdateSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
var multiBuy = await _productService.CreateProductMultiBuyAsync(new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Original Name",
|
||||
Quantity = 2,
|
||||
Price = 20.00m
|
||||
});
|
||||
|
||||
var updateDto = new UpdateProductMultiBuyDto
|
||||
{
|
||||
Name = "Updated Name",
|
||||
Description = "New description",
|
||||
Price = 18.00m,
|
||||
SortOrder = 10,
|
||||
IsActive = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var success = await _productService.UpdateProductMultiBuyAsync(multiBuy.Id, updateDto);
|
||||
|
||||
// Assert
|
||||
success.Should().BeTrue();
|
||||
|
||||
// Get the updated multi-buy to verify changes
|
||||
var updated = await _productService.GetProductMultiBuyByIdAsync(multiBuy.Id);
|
||||
updated.Should().NotBeNull();
|
||||
updated!.Name.Should().Be("Updated Name");
|
||||
updated.Description.Should().Be("New description");
|
||||
updated.Price.Should().Be(18.00m);
|
||||
updated.PricePerUnit.Should().Be(9.00m);
|
||||
updated.SortOrder.Should().Be(10);
|
||||
updated.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteProductMultiBuy_ShouldSoftDelete()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
var multiBuy = await _productService.CreateProductMultiBuyAsync(new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "To Delete",
|
||||
Quantity = 2,
|
||||
Price = 20.00m
|
||||
});
|
||||
|
||||
// Act
|
||||
await _productService.DeleteProductMultiBuyAsync(multiBuy.Id);
|
||||
|
||||
// Assert
|
||||
var deletedMultiBuy = await _context.ProductMultiBuys
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(mb => mb.Id == multiBuy.Id);
|
||||
|
||||
deletedMultiBuy.Should().NotBeNull();
|
||||
deletedMultiBuy!.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PricePerUnit_ShouldCalculateCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var product = await CreateTestProduct();
|
||||
|
||||
var singleItem = await _productService.CreateProductMultiBuyAsync(new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Single",
|
||||
Quantity = 1,
|
||||
Price = 10.00m
|
||||
});
|
||||
|
||||
var twinPack = await _productService.CreateProductMultiBuyAsync(new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Twin Pack",
|
||||
Quantity = 2,
|
||||
Price = 19.00m
|
||||
});
|
||||
|
||||
var triplePack = await _productService.CreateProductMultiBuyAsync(new CreateProductMultiBuyDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Triple Pack",
|
||||
Quantity = 3,
|
||||
Price = 25.00m
|
||||
});
|
||||
|
||||
// Assert
|
||||
singleItem.PricePerUnit.Should().Be(10.00m);
|
||||
twinPack.PricePerUnit.Should().Be(9.50m);
|
||||
triplePack.PricePerUnit.Should().BeApproximately(8.33m, 0.01m);
|
||||
}
|
||||
|
||||
private async Task<Product> CreateTestProduct()
|
||||
{
|
||||
var category = new Category
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Category",
|
||||
IsActive = true
|
||||
};
|
||||
_context.Categories.Add(category);
|
||||
|
||||
var product = new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Product",
|
||||
Price = 10.00m,
|
||||
CategoryId = category.Id,
|
||||
IsActive = true,
|
||||
Weight = 1.0m,
|
||||
WeightUnit = ProductWeightUnit.Kilograms
|
||||
};
|
||||
_context.Products.Add(product);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
}
|
||||
}
|
||||
312
LittleShop.Tests/Unit/ProductVariantServiceTests.cs
Normal file
312
LittleShop.Tests/Unit/ProductVariantServiceTests.cs
Normal file
@@ -0,0 +1,312 @@
|
||||
using FluentAssertions;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Models;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace LittleShop.Tests.Unit;
|
||||
|
||||
public class ProductVariantServiceTests : IDisposable
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly IProductService _productService;
|
||||
private readonly Mock<IWebHostEnvironment> _mockEnvironment;
|
||||
|
||||
public ProductVariantServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LittleShopContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new LittleShopContext(options);
|
||||
|
||||
_mockEnvironment = new Mock<IWebHostEnvironment>();
|
||||
_mockEnvironment.Setup(e => e.WebRootPath).Returns("/test/wwwroot");
|
||||
|
||||
_productService = new ProductService(_context, _mockEnvironment.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateProductVariant_ShouldCreateSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
var createDto = new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Red",
|
||||
VariantType = "Color",
|
||||
SortOrder = 1,
|
||||
StockLevel = 50
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _productService.CreateProductVariantAsync(createDto);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Name.Should().Be("Red");
|
||||
result.VariantType.Should().Be("Color");
|
||||
result.StockLevel.Should().Be(50);
|
||||
result.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateProductVariant_MultipleTypes_ShouldCreateSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
|
||||
// Create color variants
|
||||
var redVariant = await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Red",
|
||||
VariantType = "Color",
|
||||
SortOrder = 1
|
||||
});
|
||||
|
||||
var blueVariant = await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Blue",
|
||||
VariantType = "Color",
|
||||
SortOrder = 2
|
||||
});
|
||||
|
||||
// Act - Get product with variants
|
||||
var result = await _productService.GetProductByIdAsync(product.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Variants.Should().HaveCount(2);
|
||||
result.Variants.Should().AllSatisfy(v => v.VariantType.Should().Be("Color"));
|
||||
result.Variants.Should().Contain(v => v.Name == "Red");
|
||||
result.Variants.Should().Contain(v => v.Name == "Blue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateProductVariant_FlavorVariants_ShouldWork()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
|
||||
// Create flavor variants
|
||||
await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Vanilla",
|
||||
VariantType = "Flavor",
|
||||
SortOrder = 1
|
||||
});
|
||||
|
||||
await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Chocolate",
|
||||
VariantType = "Flavor",
|
||||
SortOrder = 2
|
||||
});
|
||||
|
||||
await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Strawberry",
|
||||
VariantType = "Flavor",
|
||||
SortOrder = 3
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _productService.GetProductByIdAsync(product.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Variants.Should().HaveCount(3);
|
||||
result.Variants.Should().BeInAscendingOrder(v => v.SortOrder);
|
||||
result.Variants.Select(v => v.Name).Should().BeEquivalentTo(
|
||||
new[] { "Vanilla", "Chocolate", "Strawberry" }
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateProductVariant_ShouldUpdateSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
var variant = await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Original",
|
||||
VariantType = "Size",
|
||||
StockLevel = 100
|
||||
});
|
||||
|
||||
var updateDto = new UpdateProductVariantDto
|
||||
{
|
||||
Name = "Updated",
|
||||
SortOrder = 10,
|
||||
StockLevel = 50,
|
||||
IsActive = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var success = await _productService.UpdateProductVariantAsync(variant.Id, updateDto);
|
||||
|
||||
// Assert
|
||||
success.Should().BeTrue();
|
||||
|
||||
// Get the updated variant to verify changes
|
||||
var updated = await _productService.GetProductVariantByIdAsync(variant.Id);
|
||||
updated.Should().NotBeNull();
|
||||
updated!.Name.Should().Be("Updated");
|
||||
updated.SortOrder.Should().Be(10);
|
||||
updated.StockLevel.Should().Be(50);
|
||||
updated.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteProductVariant_ShouldSoftDelete()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
var variant = await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "ToDelete",
|
||||
VariantType = "Color"
|
||||
});
|
||||
|
||||
// Act
|
||||
await _productService.DeleteProductVariantAsync(variant.Id);
|
||||
|
||||
// Assert
|
||||
var deletedVariant = await _context.ProductVariants
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(v => v.Id == variant.Id);
|
||||
|
||||
deletedVariant.Should().NotBeNull();
|
||||
deletedVariant!.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProductWithVariants_StockTracking_ShouldWork()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
|
||||
// Create variants with different stock levels
|
||||
var variants = new[]
|
||||
{
|
||||
new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Small",
|
||||
VariantType = "Size",
|
||||
StockLevel = 10,
|
||||
SortOrder = 1
|
||||
},
|
||||
new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Medium",
|
||||
VariantType = "Size",
|
||||
StockLevel = 25,
|
||||
SortOrder = 2
|
||||
},
|
||||
new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Large",
|
||||
VariantType = "Size",
|
||||
StockLevel = 5,
|
||||
SortOrder = 3
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var variantDto in variants)
|
||||
{
|
||||
await _productService.CreateProductVariantAsync(variantDto);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _productService.GetProductByIdAsync(product.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Variants.Should().HaveCount(3);
|
||||
result.Variants.Sum(v => v.StockLevel).Should().Be(40);
|
||||
result.Variants.First(v => v.Name == "Medium").StockLevel.Should().Be(25);
|
||||
result.Variants.First(v => v.Name == "Large").StockLevel.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveVariants_ShouldOnlyReturnActive()
|
||||
{
|
||||
// Arrange
|
||||
var product = await CreateTestProduct();
|
||||
|
||||
// Create active variant
|
||||
await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Active",
|
||||
VariantType = "Status"
|
||||
});
|
||||
|
||||
// Create inactive variant
|
||||
var inactiveVariant = await _productService.CreateProductVariantAsync(new CreateProductVariantDto
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = "Inactive",
|
||||
VariantType = "Status"
|
||||
});
|
||||
|
||||
await _productService.UpdateProductVariantAsync(inactiveVariant.Id, new UpdateProductVariantDto
|
||||
{
|
||||
IsActive = false
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _productService.GetProductByIdAsync(product.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Variants.Should().HaveCount(1);
|
||||
result.Variants.First().Name.Should().Be("Active");
|
||||
}
|
||||
|
||||
private async Task<Product> CreateTestProduct()
|
||||
{
|
||||
var category = new Category
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Category",
|
||||
IsActive = true
|
||||
};
|
||||
_context.Categories.Add(category);
|
||||
|
||||
var product = new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Product",
|
||||
Price = 10.00m,
|
||||
CategoryId = category.Id,
|
||||
IsActive = true,
|
||||
Weight = 1.0m,
|
||||
WeightUnit = ProductWeightUnit.Kilograms
|
||||
};
|
||||
_context.Products.Add(product);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user