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>
312 lines
9.2 KiB
C#
312 lines
9.2 KiB
C#
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();
|
|
}
|
|
} |