Implement complete e-commerce functionality with shipping and order management

Features Added:
- Standard e-commerce properties (Price, Weight, shipping fields)
- Order management with Create/Edit views and shipping information
- ShippingRates system for weight-based shipping calculations
- Comprehensive test coverage with JWT authentication tests
- Sample data seeder with 5 orders demonstrating full workflow
- Photo upload functionality for products
- Multi-cryptocurrency payment support (BTC, XMR, USDT, etc.)

Database Changes:
- Added ShippingRates table
- Added shipping fields to Orders (Name, Address, City, PostCode, Country)
- Renamed properties to standard names (BasePrice to Price, ProductWeight to Weight)
- Added UpdatedAt timestamps to models

UI Improvements:
- Added Create/Edit views for Orders
- Added ShippingRates management UI
- Updated navigation menu with Shipping option
- Enhanced Order Details view with shipping information

Sample Data:
- 3 Categories (Electronics, Clothing, Books)
- 5 Products with various prices
- 5 Shipping rates (Royal Mail options)
- 5 Orders in different statuses (Pending to Delivered)
- 3 Crypto payments demonstrating payment flow

Security:
- All API endpoints secured with JWT authentication
- No public endpoints - client apps must authenticate
- Privacy-focused design with minimal data collection

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sysadmin
2025-08-20 17:37:24 +01:00
parent df71a80eb9
commit a281bb2896
101 changed files with 4874 additions and 159 deletions

View File

@@ -0,0 +1,20 @@
using Xunit;
using FluentAssertions;
namespace LittleShop.Tests;
public class BasicTests
{
[Fact]
public void BasicTest_ShouldPass()
{
// Arrange
var expected = 4;
// Act
var result = 2 + 2;
// Assert
result.Should().Be(expected);
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace LittleShop.Tests.Infrastructure;
public static class JwtTokenHelper
{
public static string GenerateJwtToken(
string userId = "test-user-id",
string username = "testuser",
string role = "User",
int expirationMinutes = 60,
string secretKey = "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
string issuer = "LittleShop",
string audience = "LittleShop")
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(secretKey);
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, role)
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(expirationMinutes),
Issuer = issuer,
Audience = audience,
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public static string GenerateExpiredJwtToken(
string userId = "test-user-id",
string username = "testuser",
string role = "User")
{
return GenerateJwtToken(userId, username, role, expirationMinutes: -60);
}
public static string GenerateInvalidJwtToken()
{
// Generate token with wrong secret key
return GenerateJwtToken(secretKey: "WrongSecretKeyThatIsAtLeast32CharactersLong!");
}
public static string GenerateAdminJwtToken()
{
return GenerateJwtToken(
userId: "admin-user-id",
username: "admin",
role: "Admin");
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using LittleShop.Data;
using System.Linq;
namespace LittleShop.Tests.Infrastructure;
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove the existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<LittleShopContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Add in-memory database for testing
services.AddDbContext<LittleShopContext>(options =>
{
options.UseInMemoryDatabase("InMemoryDbForTesting");
});
// Build service provider
var sp = services.BuildServiceProvider();
// Create scope for database initialization
using (var scope = sp.CreateScope())
{
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<LittleShopContext>();
var logger = scopedServices.GetRequiredService<ILogger<TestWebApplicationFactory>>();
// Ensure database is created
db.Database.EnsureCreated();
try
{
// Seed test data if needed
SeedTestData(db);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred seeding the database with test data.");
}
}
});
builder.UseEnvironment("Testing");
}
private static void SeedTestData(LittleShopContext context)
{
// Seed test data will be added as needed for specific tests
context.SaveChanges();
}
}

View File

@@ -0,0 +1,339 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using LittleShop.Data;
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 CatalogControllerTests : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly TestWebApplicationFactory _factory;
private readonly JsonSerializerOptions _jsonOptions;
public CatalogControllerTests(TestWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
// Add default JWT token for authenticated requests
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
[Fact]
public async Task GetCategories_WithAuthentication_ReturnsOnlyActiveCategories()
{
// Arrange
await SeedTestData();
// Act
var response = await _client.GetAsync("/api/catalog/categories");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var categories = JsonSerializer.Deserialize<List<CategoryDto>>(content, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
categories.Should().NotBeNull();
categories.Should().HaveCountGreaterThan(0);
categories.Should().OnlyContain(c => c.IsActive);
}
[Fact]
public async Task GetCategoryById_WithValidId_ReturnsCategory()
{
// Arrange
var categoryId = await SeedCategoryAndGetId("Test Category");
// Act
var response = await _client.GetAsync($"/api/catalog/categories/{categoryId}");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var category = JsonSerializer.Deserialize<CategoryDto>(content, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
category.Should().NotBeNull();
category!.Id.Should().Be(categoryId);
category.Name.Should().Be("Test Category");
}
[Fact]
public async Task GetCategoryById_WithInvalidId_Returns404()
{
// Arrange
var invalidId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/catalog/categories/{invalidId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetCategoryById_WithInactiveCategory_Returns404()
{
// Arrange
var categoryId = await SeedCategoryAndGetId("Inactive Category", isActive: false);
// Act
var response = await _client.GetAsync($"/api/catalog/categories/{categoryId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetProducts_WithoutCategoryFilter_ReturnsAllActiveProducts()
{
// Arrange
await SeedTestData();
// Act
var response = await _client.GetAsync("/api/catalog/products");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var products = JsonSerializer.Deserialize<List<ProductDto>>(content, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
products.Should().NotBeNull();
products.Should().HaveCountGreaterThan(0);
}
[Fact]
public async Task GetProducts_WithCategoryFilter_ReturnsOnlyProductsInCategory()
{
// Arrange
var categoryId = await SeedCategoryWithProducts();
// Act
var response = await _client.GetAsync($"/api/catalog/products?categoryId={categoryId}");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var products = JsonSerializer.Deserialize<List<ProductDto>>(content, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
products.Should().NotBeNull();
products.Should().HaveCountGreaterThan(0);
products.Should().OnlyContain(p => p.CategoryId == categoryId);
}
[Fact]
public async Task GetProductById_WithValidId_ReturnsProduct()
{
// Arrange
var productId = await SeedProductAndGetId();
// Act
var response = await _client.GetAsync($"/api/catalog/products/{productId}");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var product = JsonSerializer.Deserialize<ProductDto>(content, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
product.Should().NotBeNull();
product!.Id.Should().Be(productId);
}
[Fact]
public async Task GetProductById_WithInvalidId_Returns404()
{
// Arrange
var invalidId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/catalog/products/{invalidId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetProductById_WithInactiveProduct_Returns404()
{
// Arrange
var productId = await SeedProductAndGetId(isActive: false);
// Act
var response = await _client.GetAsync($"/api/catalog/products/{productId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
private async Task SeedTestData()
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
// Clear existing data
context.Categories.RemoveRange(context.Categories);
context.Products.RemoveRange(context.Products);
await context.SaveChangesAsync();
// Add test categories
var categories = new[]
{
new Category { Id = Guid.NewGuid(), Name = "Electronics", Description = "Electronic devices", IsActive = true },
new Category { Id = Guid.NewGuid(), Name = "Books", Description = "Books and literature", IsActive = true },
new Category { Id = Guid.NewGuid(), Name = "Archived", Description = "Archived category", IsActive = false }
};
context.Categories.AddRange(categories);
// Add test products
var products = new[]
{
new Product
{
Id = Guid.NewGuid(),
Name = "Laptop",
Description = "High-performance laptop",
Price = 999.99m,
CategoryId = categories[0].Id,
IsActive = true,
Weight = 2.5m,
WeightUnit = Enums.ProductWeightUnit.Kilograms
},
new Product
{
Id = Guid.NewGuid(),
Name = "Programming Book",
Description = "Learn programming",
Price = 49.99m,
CategoryId = categories[1].Id,
IsActive = true,
Weight = 500,
WeightUnit = Enums.ProductWeightUnit.Grams
}
};
context.Products.AddRange(products);
await context.SaveChangesAsync();
}
private async Task<Guid> SeedCategoryAndGetId(string name, bool isActive = true)
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
var category = new Category
{
Id = Guid.NewGuid(),
Name = name,
Description = "Test category",
IsActive = isActive
};
context.Categories.Add(category);
await context.SaveChangesAsync();
return category.Id;
}
private async Task<Guid> SeedCategoryWithProducts()
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
var category = new Category
{
Id = Guid.NewGuid(),
Name = "Test Category",
Description = "Category with products",
IsActive = true
};
context.Categories.Add(category);
var products = new[]
{
new Product
{
Id = Guid.NewGuid(),
Name = "Product 1",
Description = "First product",
Price = 19.99m,
CategoryId = category.Id,
IsActive = true,
Weight = 100,
WeightUnit = Enums.ProductWeightUnit.Grams
},
new Product
{
Id = Guid.NewGuid(),
Name = "Product 2",
Description = "Second product",
Price = 29.99m,
CategoryId = category.Id,
IsActive = true,
Weight = 200,
WeightUnit = Enums.ProductWeightUnit.Grams
}
};
context.Products.AddRange(products);
await context.SaveChangesAsync();
return category.Id;
}
private async Task<Guid> SeedProductAndGetId(bool isActive = true)
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
var category = new Category
{
Id = Guid.NewGuid(),
Name = "Product Category",
Description = "Category for product",
IsActive = true
};
context.Categories.Add(category);
var product = new Product
{
Id = Guid.NewGuid(),
Name = "Test Product",
Description = "Test product description",
Price = 99.99m,
CategoryId = category.Id,
IsActive = isActive,
Weight = 1.5m,
WeightUnit = Enums.ProductWeightUnit.Kilograms
};
context.Products.Add(product);
await context.SaveChangesAsync();
return product.Id;
}
}

View File

@@ -0,0 +1,510 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using LittleShop.Controllers;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Enums;
using LittleShop.Models;
using LittleShop.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace LittleShop.Tests.Integration;
public class OrdersControllerTests : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly TestWebApplicationFactory _factory;
private readonly JsonSerializerOptions _jsonOptions;
public OrdersControllerTests(TestWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
}
[Fact]
public async Task GetAllOrders_WithAdminToken_ReturnsAllOrders()
{
// Arrange
var token = JwtTokenHelper.GenerateAdminJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await SeedTestOrders();
// Act
var response = await _client.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var orders = JsonSerializer.Deserialize<List<OrderDto>>(content, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
orders.Should().NotBeNull();
orders.Should().HaveCountGreaterThan(0);
}
[Fact]
public async Task GetAllOrders_WithUserToken_ReturnsUnauthorized()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken(role: "User");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync("/api/orders");
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden);
}
[Fact]
public async Task GetOrderById_WithAdminToken_ReturnsOrder()
{
// Arrange
var token = JwtTokenHelper.GenerateAdminJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var orderId = await SeedOrderAndGetId();
// Act
var response = await _client.GetAsync($"/api/orders/{orderId}");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var order = JsonSerializer.Deserialize<OrderDto>(content, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
order.Should().NotBeNull();
order!.Id.Should().Be(orderId);
}
[Fact]
public async Task GetOrdersByIdentity_WithValidToken_ReturnsOrders()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var identityReference = "test-identity-" + Guid.NewGuid();
await SeedOrdersForIdentity(identityReference);
// Act
var response = await _client.GetAsync($"/api/orders/by-identity/{identityReference}");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var orders = JsonSerializer.Deserialize<List<OrderDto>>(content, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
orders.Should().NotBeNull();
orders.Should().OnlyContain(o => o.IdentityReference == identityReference);
}
[Fact]
public async Task CreateOrder_WithValidData_ReturnsCreatedOrder()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var productIds = await SeedProductsAndGetIds();
var createOrderDto = new CreateOrderDto
{
IdentityReference = "test-identity-" + Guid.NewGuid(),
ShippingName = "John Doe",
ShippingAddress = "123 Test Street",
ShippingCity = "Test City",
ShippingPostCode = "TE5 7CD",
ShippingCountry = "United Kingdom",
Items = new List<CreateOrderItemDto>
{
new CreateOrderItemDto { ProductId = productIds[0], Quantity = 2 },
new CreateOrderItemDto { ProductId = productIds[1], Quantity = 1 }
}
};
var json = JsonSerializer.Serialize(createOrderDto, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/orders", content);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync();
var order = JsonSerializer.Deserialize<OrderDto>(responseContent, _jsonOptions);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
order.Should().NotBeNull();
order!.IdentityReference.Should().Be(createOrderDto.IdentityReference);
order.Items.Should().HaveCount(2);
}
[Fact]
public async Task CreateOrder_WithInvalidProductId_ReturnsBadRequest()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createOrderDto = new CreateOrderDto
{
IdentityReference = "test-identity-" + Guid.NewGuid(),
ShippingName = "John Doe",
ShippingAddress = "123 Test Street",
ShippingCity = "Test City",
ShippingPostCode = "TE5 7CD",
ShippingCountry = "United Kingdom",
Items = new List<CreateOrderItemDto>
{
new CreateOrderItemDto { ProductId = Guid.NewGuid(), Quantity = 1 }
}
};
var json = JsonSerializer.Serialize(createOrderDto, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/orders", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task UpdateOrderStatus_WithAdminToken_UpdatesStatus()
{
// Arrange
var token = JwtTokenHelper.GenerateAdminJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var orderId = await SeedOrderAndGetId();
var updateDto = new UpdateOrderStatusDto
{
Status = OrderStatus.Shipped,
TrackingNumber = "TRACK123456"
};
var json = JsonSerializer.Serialize(updateDto, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PutAsync($"/api/orders/{orderId}/status", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task CreatePayment_WithValidOrder_ReturnsPaymentInfo()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var orderId = await SeedOrderAndGetId();
var createPaymentDto = new CreatePaymentDto
{
Currency = CryptoCurrency.BTC
};
var json = JsonSerializer.Serialize(createPaymentDto, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync($"/api/orders/{orderId}/payments", content);
// Assert - May return error if BTCPay not configured, but should authenticate
response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task CancelOrder_WithValidIdentity_CancelsOrder()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var identityReference = "test-identity-" + Guid.NewGuid();
var orderId = await SeedOrderAndGetId(identityReference);
var cancelDto = new CancelOrderDto
{
IdentityReference = identityReference
};
var json = JsonSerializer.Serialize(cancelDto, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync($"/api/orders/{orderId}/cancel", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task CancelOrder_WithWrongIdentity_ReturnsBadRequest()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var orderId = await SeedOrderAndGetId("correct-identity");
var cancelDto = new CancelOrderDto
{
IdentityReference = "wrong-identity"
};
var json = JsonSerializer.Serialize(cancelDto, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync($"/api/orders/{orderId}/cancel", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task PaymentWebhook_WithValidData_ReturnsOk()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var webhookDto = new PaymentWebhookDto
{
InvoiceId = "INV123456",
Status = PaymentStatus.Confirmed,
Amount = 100.00m,
TransactionHash = "tx123456789"
};
var json = JsonSerializer.Serialize(webhookDto, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/orders/payments/webhook", content);
// Assert - Will return BadRequest if invoice not found, but should authenticate
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.BadRequest);
}
private async Task SeedTestOrders()
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
// Clear existing data
context.Orders.RemoveRange(context.Orders);
context.Products.RemoveRange(context.Products);
context.Categories.RemoveRange(context.Categories);
await context.SaveChangesAsync();
// Add test category and product
var category = new Category
{
Id = Guid.NewGuid(),
Name = "Test Category",
Description = "Test",
IsActive = true
};
context.Categories.Add(category);
var product = new Product
{
Id = Guid.NewGuid(),
Name = "Test Product",
Description = "Test",
Price = 99.99m,
CategoryId = category.Id,
IsActive = true,
Weight = 1,
WeightUnit = ProductWeightUnit.Kilograms
};
context.Products.Add(product);
// Add test orders
var orders = new[]
{
new Order
{
Id = Guid.NewGuid(),
IdentityReference = "test-identity-1",
Status = OrderStatus.PendingPayment,
ShippingName = "Customer 1",
ShippingAddress = "Address 1",
ShippingCity = "City 1",
ShippingPostCode = "PC1",
ShippingCountry = "Country 1",
TotalAmount = 99.99m,
CreatedAt = DateTime.UtcNow
},
new Order
{
Id = Guid.NewGuid(),
IdentityReference = "test-identity-2",
Status = OrderStatus.Processing,
ShippingName = "Customer 2",
ShippingAddress = "Address 2",
ShippingCity = "City 2",
ShippingPostCode = "PC2",
ShippingCountry = "Country 2",
TotalAmount = 199.99m,
CreatedAt = DateTime.UtcNow
}
};
context.Orders.AddRange(orders);
await context.SaveChangesAsync();
}
private async Task<Guid> SeedOrderAndGetId(string identityReference = "test-identity")
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
// Add category and product if not exists
var category = context.Categories.FirstOrDefault() ?? new Category
{
Id = Guid.NewGuid(),
Name = "Test Category",
Description = "Test",
IsActive = true
};
if (!context.Categories.Any())
context.Categories.Add(category);
var product = context.Products.FirstOrDefault() ?? new Product
{
Id = Guid.NewGuid(),
Name = "Test Product",
Description = "Test",
Price = 99.99m,
CategoryId = category.Id,
IsActive = true,
Weight = 1,
WeightUnit = ProductWeightUnit.Kilograms
};
if (!context.Products.Any())
context.Products.Add(product);
var order = new Order
{
Id = Guid.NewGuid(),
IdentityReference = identityReference,
Status = OrderStatus.Pending,
ShippingName = "Test Customer",
ShippingAddress = "Test Address",
ShippingCity = "Test City",
ShippingPostCode = "TE5 7CD",
ShippingCountry = "United Kingdom",
TotalAmount = 99.99m,
CreatedAt = DateTime.UtcNow
};
context.Orders.Add(order);
await context.SaveChangesAsync();
return order.Id;
}
private async Task SeedOrdersForIdentity(string identityReference)
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
var orders = new[]
{
new Order
{
Id = Guid.NewGuid(),
IdentityReference = identityReference,
Status = OrderStatus.PendingPayment,
ShippingName = "Customer",
ShippingAddress = "Address",
ShippingCity = "City",
ShippingPostCode = "PC",
ShippingCountry = "Country",
TotalAmount = 50.00m,
CreatedAt = DateTime.UtcNow
},
new Order
{
Id = Guid.NewGuid(),
IdentityReference = identityReference,
Status = OrderStatus.Shipped,
ShippingName = "Customer",
ShippingAddress = "Address",
ShippingCity = "City",
ShippingPostCode = "PC",
ShippingCountry = "Country",
TotalAmount = 75.00m,
CreatedAt = DateTime.UtcNow.AddDays(-5)
}
};
context.Orders.AddRange(orders);
await context.SaveChangesAsync();
}
private async Task<List<Guid>> SeedProductsAndGetIds()
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
var category = new Category
{
Id = Guid.NewGuid(),
Name = "Test Category",
Description = "Test",
IsActive = true
};
context.Categories.Add(category);
var products = new[]
{
new Product
{
Id = Guid.NewGuid(),
Name = "Product 1",
Description = "Test",
Price = 25.00m,
CategoryId = category.Id,
IsActive = true,
Weight = 0.5m,
WeightUnit = ProductWeightUnit.Kilograms
},
new Product
{
Id = Guid.NewGuid(),
Name = "Product 2",
Description = "Test",
Price = 35.00m,
CategoryId = category.Id,
IsActive = true,
Weight = 1.0m,
WeightUnit = ProductWeightUnit.Kilograms
}
};
context.Products.AddRange(products);
await context.SaveChangesAsync();
return products.Select(p => p.Id).ToList();
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="FluentAssertions" Version="8.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.Playwright" Version="1.54.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LittleShop\LittleShop.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,92 @@
# LittleShop Test Suite
## ✅ Test Infrastructure Complete
### **Test Coverage Implemented**
#### 🔒 **Security Testing**
- **AuthenticationEnforcementTests.cs**: Verifies ALL endpoints require JWT authentication
- **JwtTokenHelper.cs**: Helper for generating test JWT tokens (valid, expired, invalid)
- All catalog endpoints now require Bearer authentication
#### 🔧 **API Integration Testing**
- **CatalogControllerTests.cs**: Tests category and product endpoints with authentication
- **OrdersControllerTests.cs**: Tests order lifecycle and payment endpoints
- **TestWebApplicationFactory.cs**: In-memory database for isolated testing
#### 🎭 **UI Testing (Playwright)**
- **AdminPanelTests.cs**:
- Login/logout flows
- 404 error detection
- Network error monitoring
- Console error capture
- CRUD operation validation
#### ⚡ **Unit Testing**
- **CategoryServiceTests.cs**: Business logic for category management
- **ProductServiceTests.cs**: Product service operations
- Test data builders for consistent test data generation
### **Key Features**
1. **All endpoints secured** - No anonymous access to API
2. **JWT authentication** - Token generation and validation helpers
3. **In-memory database** - Fast, isolated test execution
4. **Playwright UI tests** - Catches 404s, JavaScript errors, network failures
5. **Comprehensive coverage** - Security, integration, UI, and unit tests
### **Test Configuration**
- `appsettings.Testing.json` - Test-specific configuration
- Uses xUnit, FluentAssertions, Moq, Playwright
- ASP.NET Core Test Host for integration testing
## ⚠️ **Note: Model Property Adjustments Needed**
The test files reference standard e-commerce properties that need to be mapped to your actual model properties:
### **Product Model Mapping**
- Test uses `Price` → Model has `BasePrice`
- Test uses `Weight` → Model has `ProductWeight`
- Test uses `WeightUnit` → Model has `ProductWeightUnit`
### **Order Model**
- Tests expect shipping fields (`ShippingName`, `ShippingAddress`, etc.)
- Current model doesn't include shipping information
- Consider adding shipping fields to Order model or adjusting tests
### **Missing Properties**
- `Category.UpdatedAt` - Not in current model
- `Product.UpdatedAt` - Not in current model
- `Order.Items` → Should use `OrderItems`
## **Running Tests**
```bash
# Run all tests
dotnet test
# Run specific test category
dotnet test --filter Category=Security
dotnet test --filter FullyQualifiedName~AuthenticationEnforcementTests
# Run with coverage
dotnet test --collect:"XPlat Code Coverage"
# Run Playwright tests
dotnet test --filter FullyQualifiedName~AdminPanelTests
```
## **Test Categories**
1. **Security Tests**: Authentication/authorization enforcement
2. **Integration Tests**: API endpoint testing with auth
3. **UI Tests**: Playwright browser automation
4. **Unit Tests**: Service layer business logic
## **Next Steps**
To make tests fully functional:
1. Either update model properties to match test expectations
2. Or update tests to use actual model property names
3. Add shipping fields to Order model if needed for e-commerce functionality
The comprehensive test infrastructure is in place and ready for these adjustments!

View File

@@ -0,0 +1,184 @@
using System.Net;
using System.Net.Http.Headers;
using FluentAssertions;
using LittleShop.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace LittleShop.Tests.Security;
public class AuthenticationEnforcementTests : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly TestWebApplicationFactory _factory;
public AuthenticationEnforcementTests(TestWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
[Theory]
[InlineData("/api/catalog/categories")]
[InlineData("/api/catalog/categories/00000000-0000-0000-0000-000000000001")]
[InlineData("/api/catalog/products")]
[InlineData("/api/catalog/products/00000000-0000-0000-0000-000000000001")]
public async Task CatalogEndpoints_WithoutAuthentication_ShouldReturn401(string url)
{
// Act
var response = await _client.GetAsync(url);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Theory]
[InlineData("/api/orders")]
[InlineData("/api/orders/00000000-0000-0000-0000-000000000001")]
public async Task AdminOrderEndpoints_WithoutAuthentication_ShouldReturn401(string url)
{
// Act
var response = await _client.GetAsync(url);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Theory]
[InlineData("/api/orders/by-identity/test-identity")]
[InlineData("/api/orders/by-identity/test-identity/00000000-0000-0000-0000-000000000001")]
[InlineData("/api/orders/00000000-0000-0000-0000-000000000001/payments")]
[InlineData("/api/orders/payments/00000000-0000-0000-0000-000000000001/status")]
public async Task PublicOrderEndpoints_WithoutAuthentication_ShouldReturn401(string url)
{
// Act
var response = await _client.GetAsync(url);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task PostOrder_WithoutAuthentication_ShouldReturn401()
{
// Act
var response = await _client.PostAsync("/api/orders", null);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task PostPayment_WithoutAuthentication_ShouldReturn401()
{
// Act
var response = await _client.PostAsync("/api/orders/00000000-0000-0000-0000-000000000001/payments", null);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task PaymentWebhook_WithoutAuthentication_ShouldReturn401()
{
// Act
var response = await _client.PostAsync("/api/orders/payments/webhook", null);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Theory]
[InlineData("/api/catalog/categories")]
[InlineData("/api/catalog/products")]
public async Task CatalogEndpoints_WithValidJwtToken_ShouldReturn200(string url)
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync(url);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Theory]
[InlineData("/api/catalog/categories")]
[InlineData("/api/catalog/products")]
public async Task CatalogEndpoints_WithExpiredJwtToken_ShouldReturn401(string url)
{
// Arrange
var token = JwtTokenHelper.GenerateExpiredJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync(url);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Theory]
[InlineData("/api/catalog/categories")]
[InlineData("/api/catalog/products")]
public async Task CatalogEndpoints_WithInvalidJwtToken_ShouldReturn401(string url)
{
// Arrange
var token = JwtTokenHelper.GenerateInvalidJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync(url);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Theory]
[InlineData("/api/catalog/categories")]
[InlineData("/api/catalog/products")]
public async Task CatalogEndpoints_WithMalformedToken_ShouldReturn401(string url)
{
// Arrange
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-valid-jwt-token");
// Act
var response = await _client.GetAsync(url);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task AdminEndpoint_WithUserToken_ShouldReturnForbiddenOrUnauthorized()
{
// Arrange
var token = JwtTokenHelper.GenerateJwtToken(role: "User");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync("/api/orders");
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden);
}
[Fact]
public async Task AdminEndpoint_WithAdminToken_ShouldReturn200()
{
// Arrange
var token = JwtTokenHelper.GenerateAdminJwtToken();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync("/api/orders");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}

View File

@@ -0,0 +1,295 @@
using LittleShop.DTOs;
using LittleShop.Enums;
using LittleShop.Models;
using System;
using System.Collections.Generic;
namespace LittleShop.Tests.TestUtilities;
public static class TestDataBuilder
{
public static Category CreateCategory(string? name = null, bool isActive = true)
{
return new Category
{
Id = Guid.NewGuid(),
Name = name ?? $"Category-{Guid.NewGuid()}",
Description = "Test category description",
IsActive = isActive,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
}
public static Product CreateProduct(Guid? categoryId = null, string? name = null, decimal? price = null, bool isActive = true)
{
return new Product
{
Id = Guid.NewGuid(),
Name = name ?? $"Product-{Guid.NewGuid()}",
Description = "Test product description",
Price = price ?? 99.99m,
CategoryId = categoryId ?? Guid.NewGuid(),
IsActive = isActive,
Weight = 1.5m,
WeightUnit = ProductWeightUnit.Kilograms,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
}
public static Order CreateOrder(string? identityReference = null, OrderStatus status = OrderStatus.PendingPayment)
{
var reference = identityReference ?? $"identity-{Guid.NewGuid()}";
return new Order
{
Id = Guid.NewGuid(),
IdentityReference = reference,
Status = status,
ShippingName = "Test Customer",
ShippingAddress = "123 Test Street",
ShippingCity = "Test City",
ShippingPostCode = "TE5 7CD",
ShippingCountry = "United Kingdom",
TotalAmount = 199.99m,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
Items = new List<OrderItem>()
};
}
public static OrderItem CreateOrderItem(Guid orderId, Guid productId, int quantity = 1, decimal price = 99.99m)
{
return new OrderItem
{
Id = Guid.NewGuid(),
OrderId = orderId,
ProductId = productId,
Quantity = quantity,
UnitPrice = price,
TotalPrice = price * quantity
};
}
public static User CreateUser(string? username = null, string role = "User")
{
var user = username ?? $"user-{Guid.NewGuid()}";
return new User
{
Id = Guid.NewGuid(),
Username = user,
Email = $"{user}@test.com",
PasswordHash = "hashed-password",
Role = role,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
}
public static CryptoPayment CreateCryptoPayment(Guid orderId, CryptoCurrency currency = CryptoCurrency.BTC, PaymentStatus status = PaymentStatus.Pending)
{
return new CryptoPayment
{
Id = Guid.NewGuid(),
OrderId = orderId,
Currency = currency,
Amount = 0.0025m,
CryptoAddress = $"bc1q{Guid.NewGuid().ToString().Replace("-", "").Substring(0, 39)}",
Status = status,
ExchangeRate = 40000.00m,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
}
public static CreateOrderDto CreateOrderDto(List<Guid>? productIds = null)
{
var items = new List<CreateOrderItemDto>();
if (productIds != null)
{
foreach (var productId in productIds)
{
items.Add(new CreateOrderItemDto
{
ProductId = productId,
Quantity = 1
});
}
}
else
{
items.Add(new CreateOrderItemDto
{
ProductId = Guid.NewGuid(),
Quantity = 1
});
}
return new CreateOrderDto
{
IdentityReference = $"test-identity-{Guid.NewGuid()}",
ShippingName = "Test Customer",
ShippingAddress = "123 Test Street",
ShippingCity = "Test City",
ShippingPostCode = "TE5 7CD",
ShippingCountry = "United Kingdom",
Items = items
};
}
public static CreateProductDto CreateProductDto(Guid? categoryId = null)
{
return new CreateProductDto
{
Name = $"Product-{Guid.NewGuid()}",
Description = "Test product description",
Price = 99.99m,
CategoryId = categoryId ?? Guid.NewGuid(),
Weight = 1.5m,
WeightUnit = ProductWeightUnit.Kilograms
};
}
public static CreateCategoryDto CreateCategoryDto()
{
return new CreateCategoryDto
{
Name = $"Category-{Guid.NewGuid()}",
Description = "Test category description"
};
}
public static ProductPhoto CreateProductPhoto(Guid productId, int displayOrder = 1)
{
return new ProductPhoto
{
Id = Guid.NewGuid(),
ProductId = productId,
PhotoUrl = $"/uploads/products/{Guid.NewGuid()}.jpg",
AltText = "Test product photo",
DisplayOrder = displayOrder,
CreatedAt = DateTime.UtcNow
};
}
// Builder class for complex test scenarios
public class OrderBuilder
{
private Order _order;
private List<Product> _products = new();
public OrderBuilder()
{
_order = CreateOrder();
}
public OrderBuilder WithStatus(OrderStatus status)
{
_order.Status = status;
return this;
}
public OrderBuilder WithIdentity(string identityReference)
{
_order.IdentityReference = identityReference;
return this;
}
public OrderBuilder WithShipping(string name, string address, string city, string postCode, string country)
{
_order.ShippingName = name;
_order.ShippingAddress = address;
_order.ShippingCity = city;
_order.ShippingPostCode = postCode;
_order.ShippingCountry = country;
return this;
}
public OrderBuilder AddItem(Product product, int quantity)
{
_products.Add(product);
var item = CreateOrderItem(_order.Id, product.Id, quantity, product.Price);
_order.Items.Add(item);
_order.TotalAmount = _order.Items.Sum(i => i.TotalPrice);
return this;
}
public OrderBuilder WithPayment(CryptoCurrency currency, PaymentStatus status)
{
var payment = CreateCryptoPayment(_order.Id, currency, status);
_order.Payments ??= new List<CryptoPayment>();
_order.Payments.Add(payment);
return this;
}
public Order Build()
{
return _order;
}
public (Order order, List<Product> products) BuildWithProducts()
{
return (_order, _products);
}
}
// Bulk data generation for stress testing
public static class BulkDataGenerator
{
public static List<Category> GenerateCategories(int count)
{
var categories = new List<Category>();
for (int i = 0; i < count; i++)
{
categories.Add(CreateCategory($"Category {i + 1}"));
}
return categories;
}
public static List<Product> GenerateProducts(List<Category> categories, int productsPerCategory)
{
var products = new List<Product>();
foreach (var category in categories)
{
for (int i = 0; i < productsPerCategory; i++)
{
products.Add(CreateProduct(
category.Id,
$"{category.Name} - Product {i + 1}",
Random.Shared.Next(10, 1000)));
}
}
return products;
}
public static List<Order> GenerateOrders(int count, List<Product> availableProducts)
{
var orders = new List<Order>();
var statuses = Enum.GetValues<OrderStatus>();
for (int i = 0; i < count; i++)
{
var builder = new OrderBuilder()
.WithIdentity($"customer-{i}")
.WithStatus(statuses[Random.Shared.Next(statuses.Length)]);
// Add random products to order
var productCount = Random.Shared.Next(1, Math.Min(5, availableProducts.Count));
var selectedProducts = availableProducts
.OrderBy(x => Random.Shared.Next())
.Take(productCount);
foreach (var product in selectedProducts)
{
builder.AddItem(product, Random.Shared.Next(1, 3));
}
orders.Add(builder.Build());
}
return orders;
}
}
}

View File

@@ -0,0 +1,322 @@
using Microsoft.Playwright;
using Xunit;
using FluentAssertions;
using LittleShop.Tests.Infrastructure;
namespace LittleShop.Tests.UI;
[Collection("Playwright")]
public class AdminPanelTests : IClassFixture<TestWebApplicationFactory>, IAsyncLifetime
{
private readonly TestWebApplicationFactory _factory;
private IPlaywright? _playwright;
private IBrowser? _browser;
private IBrowserContext? _context;
private IPage? _page;
private readonly string _baseUrl;
public AdminPanelTests(TestWebApplicationFactory factory)
{
_factory = factory;
_baseUrl = "https://localhost:5001"; // Adjust based on your test configuration
}
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true // Set to false for debugging
});
_context = await _browser.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true // For self-signed certificates in test
});
_page = await _context.NewPageAsync();
}
public async Task DisposeAsync()
{
if (_page != null) await _page.CloseAsync();
if (_context != null) await _context.CloseAsync();
if (_browser != null) await _browser.CloseAsync();
_playwright?.Dispose();
}
[Fact]
public async Task LoginPage_ShouldLoadWithoutErrors()
{
// Arrange & Act
var response = await _page!.GotoAsync($"{_baseUrl}/Admin/Account/Login");
// Assert
response.Should().NotBeNull();
response!.Status.Should().Be(200);
// Check for login form elements
var usernameInput = await _page.QuerySelectorAsync("input[name='Username']");
var passwordInput = await _page.QuerySelectorAsync("input[name='Password']");
var submitButton = await _page.QuerySelectorAsync("button[type='submit']");
usernameInput.Should().NotBeNull();
passwordInput.Should().NotBeNull();
submitButton.Should().NotBeNull();
}
[Fact]
public async Task Login_WithValidCredentials_ShouldRedirectToDashboard()
{
// Arrange
await _page!.GotoAsync($"{_baseUrl}/Admin/Account/Login");
// Act
await _page.FillAsync("input[name='Username']", "admin");
await _page.FillAsync("input[name='Password']", "admin");
await _page.ClickAsync("button[type='submit']");
// Wait for navigation
await _page.WaitForURLAsync($"{_baseUrl}/Admin/Dashboard");
// Assert
_page.Url.Should().Contain("/Admin/Dashboard");
// Check dashboard loaded
var dashboardTitle = await _page.TextContentAsync("h1");
dashboardTitle.Should().Contain("Dashboard");
}
[Fact]
public async Task Login_WithInvalidCredentials_ShouldShowError()
{
// Arrange
await _page!.GotoAsync($"{_baseUrl}/Admin/Account/Login");
// Act
await _page.FillAsync("input[name='Username']", "wronguser");
await _page.FillAsync("input[name='Password']", "wrongpass");
await _page.ClickAsync("button[type='submit']");
// Assert
// Should stay on login page
_page.Url.Should().Contain("/Admin/Account/Login");
// Check for error message
var errorMessage = await _page.QuerySelectorAsync(".text-danger, .alert-danger");
errorMessage.Should().NotBeNull();
}
[Fact]
public async Task AdminPages_WithoutLogin_ShouldRedirectToLogin()
{
// Arrange & Act
var pagesToTest = new[]
{
"/Admin/Dashboard",
"/Admin/Categories",
"/Admin/Products",
"/Admin/Orders",
"/Admin/Users"
};
foreach (var pageUrl in pagesToTest)
{
await _page!.GotoAsync($"{_baseUrl}{pageUrl}");
// Assert - Should redirect to login
_page.Url.Should().Contain("/Admin/Account/Login");
}
}
[Fact]
public async Task Dashboard_AfterLogin_ShouldLoadAllSections()
{
// Arrange - Login first
await LoginAsAdmin();
// Act
await _page!.GotoAsync($"{_baseUrl}/Admin/Dashboard");
// Assert
var response = await _page.GotoAsync($"{_baseUrl}/Admin/Dashboard");
response!.Status.Should().Be(200);
// Check for dashboard sections
var statsCards = await _page.QuerySelectorAllAsync(".card");
statsCards.Count.Should().BeGreaterThan(0);
// Check navigation menu is present
var navMenu = await _page.QuerySelectorAsync(".navbar");
navMenu.Should().NotBeNull();
}
[Fact]
public async Task Categories_CRUD_ShouldWorkWithoutErrors()
{
// Arrange - Login first
await LoginAsAdmin();
// Navigate to Categories
await _page!.GotoAsync($"{_baseUrl}/Admin/Categories");
var response = await _page.GotoAsync($"{_baseUrl}/Admin/Categories");
response!.Status.Should().Be(200);
// Create Category
await _page.ClickAsync("a[href*='Categories/Create']");
await _page.WaitForURLAsync("**/Admin/Categories/Create");
await _page.FillAsync("input[name='Name']", "Test Category");
await _page.FillAsync("textarea[name='Description']", "Test Description");
await _page.ClickAsync("button[type='submit']");
// Should redirect back to index
await _page.WaitForURLAsync("**/Admin/Categories");
// Verify category appears in list
var categoryName = await _page.TextContentAsync("td:has-text('Test Category')");
categoryName.Should().NotBeNull();
}
[Fact]
public async Task Products_Index_ShouldLoadWithoutErrors()
{
// Arrange - Login first
await LoginAsAdmin();
// Act
var response = await _page!.GotoAsync($"{_baseUrl}/Admin/Products");
// Assert
response!.Status.Should().Be(200);
// Check for products table or empty message
var productsTable = await _page.QuerySelectorAsync("table");
var emptyMessage = await _page.QuerySelectorAsync("text=No products found");
(productsTable != null || emptyMessage != null).Should().BeTrue();
}
[Fact]
public async Task Orders_Index_ShouldLoadWithoutErrors()
{
// Arrange - Login first
await LoginAsAdmin();
// Act
var response = await _page!.GotoAsync($"{_baseUrl}/Admin/Orders");
// Assert
response!.Status.Should().Be(200);
// Check for orders table or empty message
var ordersTable = await _page.QuerySelectorAsync("table");
var emptyMessage = await _page.QuerySelectorAsync("text=No orders found");
(ordersTable != null || emptyMessage != null).Should().BeTrue();
}
[Fact]
public async Task Users_Index_ShouldLoadWithoutErrors()
{
// Arrange - Login first
await LoginAsAdmin();
// Act
var response = await _page!.GotoAsync($"{_baseUrl}/Admin/Users");
// Assert
response!.Status.Should().Be(200);
// Should at least show the admin user
var usersTable = await _page.QuerySelectorAsync("table");
usersTable.Should().NotBeNull();
var adminUser = await _page.TextContentAsync("td:has-text('admin')");
adminUser.Should().NotBeNull();
}
[Fact]
public async Task Logout_ShouldRedirectToLogin()
{
// Arrange - Login first
await LoginAsAdmin();
// Act - Find and click logout
await _page!.ClickAsync("a[href*='Account/Logout']");
// Assert
await _page.WaitForURLAsync("**/Admin/Account/Login");
_page.Url.Should().Contain("/Admin/Account/Login");
}
[Fact]
public async Task NetworkErrors_ShouldBeCaptured()
{
// Arrange
var failedRequests = new List<string>();
_page!.RequestFailed += (_, request) =>
{
failedRequests.Add($"{request.Method} {request.Url} - {request.Failure}");
};
await LoginAsAdmin();
// Act - Navigate through admin pages
var pagesToCheck = new[]
{
"/Admin/Dashboard",
"/Admin/Categories",
"/Admin/Products",
"/Admin/Orders",
"/Admin/Users"
};
foreach (var pageUrl in pagesToCheck)
{
await _page.GotoAsync($"{_baseUrl}{pageUrl}");
}
// Assert - No failed requests
failedRequests.Should().BeEmpty("No network requests should fail");
}
[Fact]
public async Task ConsoleErrors_ShouldBeCaptured()
{
// Arrange
var consoleErrors = new List<string>();
_page!.Console += (_, msg) =>
{
if (msg.Type == "error")
{
consoleErrors.Add(msg.Text);
}
};
await LoginAsAdmin();
// Act - Navigate through admin pages
var pagesToCheck = new[]
{
"/Admin/Dashboard",
"/Admin/Categories",
"/Admin/Products"
};
foreach (var pageUrl in pagesToCheck)
{
await _page.GotoAsync($"{_baseUrl}{pageUrl}");
}
// Assert - No console errors
consoleErrors.Should().BeEmpty("No JavaScript errors should occur");
}
private async Task LoginAsAdmin()
{
await _page!.GotoAsync($"{_baseUrl}/Admin/Account/Login");
await _page.FillAsync("input[name='Username']", "admin");
await _page.FillAsync("input[name='Password']", "admin");
await _page.ClickAsync("button[type='submit']");
await _page.WaitForURLAsync($"{_baseUrl}/Admin/Dashboard");
}
}

View File

@@ -0,0 +1,262 @@
using FluentAssertions;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Models;
using LittleShop.Services;
using Microsoft.EntityFrameworkCore;
using Moq;
using AutoMapper;
using Xunit;
using LittleShop.Mapping;
namespace LittleShop.Tests.Unit;
public class CategoryServiceTests : IDisposable
{
private readonly LittleShopContext _context;
private readonly ICategoryService _categoryService;
private readonly IMapper _mapper;
public CategoryServiceTests()
{
// Set up in-memory database
var options = new DbContextOptionsBuilder<LittleShopContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new LittleShopContext(options);
// Set up AutoMapper
var mappingConfig = new MapperConfiguration(mc =>
{
mc.AddProfile(new MappingProfile());
});
_mapper = mappingConfig.CreateMapper();
// Create service
_categoryService = new CategoryService(_context, _mapper);
}
[Fact]
public async Task GetAllCategoriesAsync_ReturnsAllCategories()
{
// Arrange
var categories = new[]
{
new Category { Id = Guid.NewGuid(), Name = "Category 1", Description = "Desc 1", IsActive = true },
new Category { Id = Guid.NewGuid(), Name = "Category 2", Description = "Desc 2", IsActive = true },
new Category { Id = Guid.NewGuid(), Name = "Category 3", Description = "Desc 3", IsActive = false }
};
_context.Categories.AddRange(categories);
await _context.SaveChangesAsync();
// Act
var result = await _categoryService.GetAllCategoriesAsync();
// Assert
result.Should().HaveCount(3);
result.Should().Contain(c => c.Name == "Category 1");
result.Should().Contain(c => c.Name == "Category 2");
result.Should().Contain(c => c.Name == "Category 3");
}
[Fact]
public async Task GetCategoryByIdAsync_WithValidId_ReturnsCategory()
{
// Arrange
var categoryId = Guid.NewGuid();
var category = new Category
{
Id = categoryId,
Name = "Test Category",
Description = "Test Description",
IsActive = true
};
_context.Categories.Add(category);
await _context.SaveChangesAsync();
// Act
var result = await _categoryService.GetCategoryByIdAsync(categoryId);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(categoryId);
result.Name.Should().Be("Test Category");
result.Description.Should().Be("Test Description");
}
[Fact]
public async Task GetCategoryByIdAsync_WithInvalidId_ReturnsNull()
{
// Arrange
var invalidId = Guid.NewGuid();
// Act
var result = await _categoryService.GetCategoryByIdAsync(invalidId);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task CreateCategoryAsync_WithValidData_CreatesCategory()
{
// Arrange
var createDto = new CreateCategoryDto
{
Name = "New Category",
Description = "New Description"
};
// Act
var result = await _categoryService.CreateCategoryAsync(createDto);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("New Category");
result.Description.Should().Be("New Description");
result.IsActive.Should().BeTrue();
// Verify in database
var dbCategory = await _context.Categories.FindAsync(result.Id);
dbCategory.Should().NotBeNull();
dbCategory!.Name.Should().Be("New Category");
}
[Fact]
public async Task UpdateCategoryAsync_WithValidData_UpdatesCategory()
{
// Arrange
var categoryId = Guid.NewGuid();
var category = new Category
{
Id = categoryId,
Name = "Original Name",
Description = "Original Description",
IsActive = true
};
_context.Categories.Add(category);
await _context.SaveChangesAsync();
var updateDto = new UpdateCategoryDto
{
Name = "Updated Name",
Description = "Updated Description",
IsActive = false
};
// Act
var result = await _categoryService.UpdateCategoryAsync(categoryId, updateDto);
// Assert
result.Should().BeTrue();
// Verify in database
var dbCategory = await _context.Categories.FindAsync(categoryId);
dbCategory!.Name.Should().Be("Updated Name");
dbCategory.Description.Should().Be("Updated Description");
dbCategory.IsActive.Should().BeFalse();
}
[Fact]
public async Task UpdateCategoryAsync_WithInvalidId_ReturnsFalse()
{
// Arrange
var invalidId = Guid.NewGuid();
var updateDto = new UpdateCategoryDto
{
Name = "Updated Name",
Description = "Updated Description",
IsActive = true
};
// Act
var result = await _categoryService.UpdateCategoryAsync(invalidId, updateDto);
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task DeleteCategoryAsync_WithValidId_DeletesCategory()
{
// Arrange
var categoryId = Guid.NewGuid();
var category = new Category
{
Id = categoryId,
Name = "To Delete",
Description = "Will be deleted",
IsActive = true
};
_context.Categories.Add(category);
await _context.SaveChangesAsync();
// Act
var result = await _categoryService.DeleteCategoryAsync(categoryId);
// Assert
result.Should().BeTrue();
// Verify in database
var dbCategory = await _context.Categories.FindAsync(categoryId);
dbCategory.Should().BeNull();
}
[Fact]
public async Task DeleteCategoryAsync_WithInvalidId_ReturnsFalse()
{
// Arrange
var invalidId = Guid.NewGuid();
// Act
var result = await _categoryService.DeleteCategoryAsync(invalidId);
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task DeleteCategoryAsync_WithProductsAttached_ThrowsException()
{
// Arrange
var categoryId = Guid.NewGuid();
var category = new Category
{
Id = categoryId,
Name = "Category with Products",
Description = "Has products",
IsActive = true
};
var product = new Product
{
Id = Guid.NewGuid(),
Name = "Product",
Description = "Product in category",
Price = 10.00m,
CategoryId = categoryId,
IsActive = true,
Weight = 1,
WeightUnit = Enums.ProductWeightUnit.Kilograms
};
_context.Categories.Add(category);
_context.Products.Add(product);
await _context.SaveChangesAsync();
// Act & Assert
await Assert.ThrowsAsync<DbUpdateException>(async () =>
{
await _categoryService.DeleteCategoryAsync(categoryId);
});
}
public void Dispose()
{
_context.Dispose();
}
}

View File

@@ -0,0 +1,313 @@
using FluentAssertions;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Models;
using LittleShop.Services;
using LittleShop.Enums;
using Microsoft.EntityFrameworkCore;
using AutoMapper;
using Xunit;
using LittleShop.Mapping;
namespace LittleShop.Tests.Unit;
public class ProductServiceTests : IDisposable
{
private readonly LittleShopContext _context;
private readonly IProductService _productService;
private readonly IMapper _mapper;
public ProductServiceTests()
{
// Set up in-memory database
var options = new DbContextOptionsBuilder<LittleShopContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new LittleShopContext(options);
// Set up AutoMapper
var mappingConfig = new MapperConfiguration(mc =>
{
mc.AddProfile(new MappingProfile());
});
_mapper = mappingConfig.CreateMapper();
// Create service
_productService = new ProductService(_context, _mapper);
}
[Fact]
public async Task GetAllProductsAsync_ReturnsAllProducts()
{
// Arrange
var category = await CreateTestCategory();
var products = new[]
{
CreateTestProduct("Product 1", category.Id, 10.00m),
CreateTestProduct("Product 2", category.Id, 20.00m),
CreateTestProduct("Product 3", category.Id, 30.00m, isActive: false)
};
_context.Products.AddRange(products);
await _context.SaveChangesAsync();
// Act
var result = await _productService.GetAllProductsAsync();
// Assert
result.Should().HaveCount(3);
result.Should().Contain(p => p.Name == "Product 1");
result.Should().Contain(p => p.Name == "Product 2");
result.Should().Contain(p => p.Name == "Product 3");
}
[Fact]
public async Task GetProductByIdAsync_WithValidId_ReturnsProduct()
{
// Arrange
var category = await CreateTestCategory();
var productId = Guid.NewGuid();
var product = new Product
{
Id = productId,
Name = "Test Product",
Description = "Test Description",
Price = 99.99m,
CategoryId = category.Id,
IsActive = true,
Weight = 1.5m,
WeightUnit = ProductWeightUnit.Kilograms
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
// Act
var result = await _productService.GetProductByIdAsync(productId);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(productId);
result.Name.Should().Be("Test Product");
result.Price.Should().Be(99.99m);
}
[Fact]
public async Task GetProductsByCategoryAsync_ReturnsOnlyProductsInCategory()
{
// Arrange
var category1 = await CreateTestCategory("Category 1");
var category2 = await CreateTestCategory("Category 2");
var products = new[]
{
CreateTestProduct("Product 1", category1.Id, 10.00m),
CreateTestProduct("Product 2", category1.Id, 20.00m),
CreateTestProduct("Product 3", category2.Id, 30.00m)
};
_context.Products.AddRange(products);
await _context.SaveChangesAsync();
// Act
var result = await _productService.GetProductsByCategoryAsync(category1.Id);
// Assert
result.Should().HaveCount(2);
result.Should().OnlyContain(p => p.CategoryId == category1.Id);
}
[Fact]
public async Task CreateProductAsync_WithValidData_CreatesProduct()
{
// Arrange
var category = await CreateTestCategory();
var createDto = new CreateProductDto
{
Name = "New Product",
Description = "New Description",
Price = 49.99m,
CategoryId = category.Id,
Weight = 2.5m,
WeightUnit = ProductWeightUnit.Kilograms
};
// Act
var result = await _productService.CreateProductAsync(createDto);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("New Product");
result.Price.Should().Be(49.99m);
result.IsActive.Should().BeTrue();
// Verify in database
var dbProduct = await _context.Products.FindAsync(result.Id);
dbProduct.Should().NotBeNull();
dbProduct!.Name.Should().Be("New Product");
}
[Fact]
public async Task UpdateProductAsync_WithValidData_UpdatesProduct()
{
// Arrange
var category = await CreateTestCategory();
var productId = Guid.NewGuid();
var product = new Product
{
Id = productId,
Name = "Original Name",
Description = "Original Description",
Price = 10.00m,
CategoryId = category.Id,
IsActive = true,
Weight = 1.0m,
WeightUnit = ProductWeightUnit.Kilograms
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
var updateDto = new UpdateProductDto
{
Name = "Updated Name",
Description = "Updated Description",
Price = 20.00m,
CategoryId = category.Id,
Weight = 2.0m,
WeightUnit = ProductWeightUnit.Pounds,
IsActive = false
};
// Act
var result = await _productService.UpdateProductAsync(productId, updateDto);
// Assert
result.Should().BeTrue();
// Verify in database
var dbProduct = await _context.Products.FindAsync(productId);
dbProduct!.Name.Should().Be("Updated Name");
dbProduct.Price.Should().Be(20.00m);
dbProduct.Weight.Should().Be(2.0m);
dbProduct.WeightUnit.Should().Be(ProductWeightUnit.Pounds);
dbProduct.IsActive.Should().BeFalse();
}
[Fact]
public async Task DeleteProductAsync_WithValidId_DeletesProduct()
{
// Arrange
var category = await CreateTestCategory();
var productId = Guid.NewGuid();
var product = CreateTestProduct("To Delete", category.Id, 10.00m);
product.Id = productId;
_context.Products.Add(product);
await _context.SaveChangesAsync();
// Act
var result = await _productService.DeleteProductAsync(productId);
// Assert
result.Should().BeTrue();
// Verify in database
var dbProduct = await _context.Products.FindAsync(productId);
dbProduct.Should().BeNull();
}
[Fact]
public async Task AddProductPhotoAsync_AddsPhotoToProduct()
{
// Arrange
var category = await CreateTestCategory();
var productId = Guid.NewGuid();
var product = CreateTestProduct("Product", category.Id, 10.00m);
product.Id = productId;
_context.Products.Add(product);
await _context.SaveChangesAsync();
var photoDto = new CreateProductPhotoDto
{
ProductId = productId,
PhotoUrl = "/uploads/test-photo.jpg",
AltText = "Test Photo",
DisplayOrder = 1
};
// Act
var result = await _productService.AddProductPhotoAsync(photoDto);
// Assert
result.Should().NotBeNull();
result.PhotoUrl.Should().Be("/uploads/test-photo.jpg");
result.AltText.Should().Be("Test Photo");
// Verify in database
var dbProduct = await _context.Products
.Include(p => p.Photos)
.FirstOrDefaultAsync(p => p.Id == productId);
dbProduct!.Photos.Should().HaveCount(1);
}
[Fact]
public async Task GetProductsBySearchAsync_ReturnsMatchingProducts()
{
// Arrange
var category = await CreateTestCategory();
var products = new[]
{
CreateTestProduct("Laptop Computer", category.Id, 999.00m),
CreateTestProduct("Desktop Computer", category.Id, 799.00m),
CreateTestProduct("Mouse Pad", category.Id, 9.99m)
};
_context.Products.AddRange(products);
await _context.SaveChangesAsync();
// Act - Search for "Computer"
var result = await _productService.SearchProductsAsync("Computer");
// Assert
result.Should().HaveCount(2);
result.Should().Contain(p => p.Name.Contains("Computer"));
result.Should().NotContain(p => p.Name == "Mouse Pad");
}
private async Task<Category> CreateTestCategory(string name = "Test Category")
{
var category = new Category
{
Id = Guid.NewGuid(),
Name = name,
Description = "Test Description",
IsActive = true
};
_context.Categories.Add(category);
await _context.SaveChangesAsync();
return category;
}
private Product CreateTestProduct(string name, Guid categoryId, decimal price, bool isActive = true)
{
return new Product
{
Id = Guid.NewGuid(),
Name = name,
Description = $"Description for {name}",
Price = price,
CategoryId = categoryId,
IsActive = isActive,
Weight = 1.0m,
WeightUnit = ProductWeightUnit.Kilograms
};
}
public void Dispose()
{
_context.Dispose();
}
}

View File

@@ -0,0 +1,10 @@
namespace LittleShop.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,23 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": "DataSource=:memory:"
},
"Jwt": {
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "LittleShop",
"Audience": "LittleShop",
"ExpirationMinutes": 60
},
"BTCPayServer": {
"ServerUrl": "https://testnet.btcpayserver.com",
"StoreId": "test-store-id",
"ApiKey": "test-api-key"
}
}

View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Cookies CfDJ8DxJ63S0c9FElmGFRyriOyyD6rmycqL7JuHuVlcOFX2R_GxlIY4qgsVAgJmK8AahopahF-vF2JcxuIuSjY5B3zuZBJrG0jFcmDO9SR2Xz3eS74gnvvfLu7llRaIbh0xleQwPmHgydzoxzZNBG6Yz0xbHGHyeL4YPCPbOZu8YxvYeKzKRbuR2hkw5iUR6mZAGSqGCkq2cCiin1c0Lu-I4v4VZAuYnz7l6yDCYBDLUJMTFa9EMS5zebh4yVTsixzLzmQMRBcbLBswGb20vjQBVSfjFhDCn-SKGkqLRmvuakfKaXVsV002NSFakFRdQWc8sdX-uTWj2I7UTNjX-yC8UUXLdl7UldMjkztkBYO2imCXwBONa9RR2W0YLfFrthx4F3PaPoxAVX8fHVBZ-QCO5iQFJUtUhDR7E_ygdKFNE-z8n_6qSSrpac2hnnbq0h2lewg

View File

@@ -0,0 +1 @@
test image content