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:
20
LittleShop.Tests/BasicTests.cs
Normal file
20
LittleShop.Tests/BasicTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
65
LittleShop.Tests/Infrastructure/JwtTokenHelper.cs
Normal file
65
LittleShop.Tests/Infrastructure/JwtTokenHelper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
65
LittleShop.Tests/Infrastructure/TestWebApplicationFactory.cs
Normal file
65
LittleShop.Tests/Infrastructure/TestWebApplicationFactory.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
339
LittleShop.Tests/Integration/CatalogControllerTests.cs
Normal file
339
LittleShop.Tests/Integration/CatalogControllerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
510
LittleShop.Tests/Integration/OrdersControllerTests.cs
Normal file
510
LittleShop.Tests/Integration/OrdersControllerTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
30
LittleShop.Tests/LittleShop.Tests.csproj
Normal file
30
LittleShop.Tests/LittleShop.Tests.csproj
Normal 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>
|
||||
92
LittleShop.Tests/README.md
Normal file
92
LittleShop.Tests/README.md
Normal 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!
|
||||
184
LittleShop.Tests/Security/AuthenticationEnforcementTests.cs
Normal file
184
LittleShop.Tests/Security/AuthenticationEnforcementTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
295
LittleShop.Tests/TestUtilities/TestDataBuilder.cs
Normal file
295
LittleShop.Tests/TestUtilities/TestDataBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
322
LittleShop.Tests/UI/AdminPanelTests.cs
Normal file
322
LittleShop.Tests/UI/AdminPanelTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
262
LittleShop.Tests/Unit/CategoryServiceTests.cs
Normal file
262
LittleShop.Tests/Unit/CategoryServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
313
LittleShop.Tests/Unit/ProductServiceTests.cs
Normal file
313
LittleShop.Tests/Unit/ProductServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
10
LittleShop.Tests/UnitTest1.cs
Normal file
10
LittleShop.Tests/UnitTest1.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LittleShop.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
23
LittleShop.Tests/appsettings.Testing.json
Normal file
23
LittleShop.Tests/appsettings.Testing.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
LittleShop.Tests/cookies.txt
Normal file
5
LittleShop.Tests/cookies.txt
Normal 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
|
||||
1
LittleShop.Tests/test.jpg
Normal file
1
LittleShop.Tests/test.jpg
Normal file
@@ -0,0 +1 @@
|
||||
test image content
|
||||
Reference in New Issue
Block a user