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:
parent
df71a80eb9
commit
a281bb2896
@ -1,47 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.DTOs;
|
||||
|
||||
namespace LittleShop.Areas.Admin.Controllers;
|
||||
|
||||
[Area("Admin")]
|
||||
[Authorize(Policy = "AdminOnly")]
|
||||
public class OrdersController : Controller
|
||||
{
|
||||
private readonly IOrderService _orderService;
|
||||
|
||||
public OrdersController(IOrderService orderService)
|
||||
{
|
||||
_orderService = orderService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var orders = await _orderService.GetAllOrdersAsync();
|
||||
return View(orders.OrderByDescending(o => o.CreatedAt));
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Details(Guid id)
|
||||
{
|
||||
var order = await _orderService.GetOrderByIdAsync(id);
|
||||
if (order == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return View(order);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UpdateStatus(Guid id, UpdateOrderStatusDto model)
|
||||
{
|
||||
var success = await _orderService.UpdateOrderStatusAsync(id, model);
|
||||
if (!success)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
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
|
||||
82
LittleShop.sln
Normal file
82
LittleShop.sln
Normal file
@ -0,0 +1,82 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop", "LittleShop\LittleShop.csproj", "{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TeleBot", "TeleBot", "{92C8E2EB-69F0-B69F-DB5B-725FD6E47E88}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleBot", "TeleBot\TeleBot\TeleBot.csproj", "{0B5C4E8B-0618-496A-8614-B8580B28257F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleBotClient", "TeleBot\TeleBotClient\TeleBotClient.csproj", "{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop.Tests", "LittleShop.Tests\LittleShop.Tests.csproj", "{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Release|x64.Build.0 = Release|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{45F90A9D-4B8B-48D8-8D80-7B2335DD9072}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{0B5C4E8B-0618-496A-8614-B8580B28257F} = {92C8E2EB-69F0-B69F-DB5B-725FD6E47E88}
|
||||
{4ABAC8E7-9088-4D68-ADDF-E3249CBED85C} = {92C8E2EB-69F0-B69F-DB5B-725FD6E47E88}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
99
LittleShop/Areas/Admin/Controllers/OrdersController.cs
Normal file
99
LittleShop/Areas/Admin/Controllers/OrdersController.cs
Normal file
@ -0,0 +1,99 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.DTOs;
|
||||
|
||||
namespace LittleShop.Areas.Admin.Controllers;
|
||||
|
||||
[Area("Admin")]
|
||||
[Authorize(Policy = "AdminOnly")]
|
||||
public class OrdersController : Controller
|
||||
{
|
||||
private readonly IOrderService _orderService;
|
||||
|
||||
public OrdersController(IOrderService orderService)
|
||||
{
|
||||
_orderService = orderService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var orders = await _orderService.GetAllOrdersAsync();
|
||||
return View(orders.OrderByDescending(o => o.CreatedAt));
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Details(Guid id)
|
||||
{
|
||||
var order = await _orderService.GetOrderByIdAsync(id);
|
||||
if (order == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return View(order);
|
||||
}
|
||||
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new CreateOrderDto());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateOrderDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var order = await _orderService.CreateOrderAsync(model);
|
||||
return RedirectToAction(nameof(Details), new { id = order.Id });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Edit(Guid id)
|
||||
{
|
||||
var order = await _orderService.GetOrderByIdAsync(id);
|
||||
if (order == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return View(order);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Edit(Guid id, OrderDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var updateDto = new UpdateOrderStatusDto
|
||||
{
|
||||
Status = model.Status,
|
||||
TrackingNumber = model.TrackingNumber,
|
||||
Notes = model.Notes
|
||||
};
|
||||
|
||||
var success = await _orderService.UpdateOrderStatusAsync(id, updateDto);
|
||||
if (!success)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UpdateStatus(Guid id, UpdateOrderStatusDto model)
|
||||
{
|
||||
var success = await _orderService.UpdateOrderStatusAsync(id, model);
|
||||
if (!success)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ public class ProductsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateProductDto model)
|
||||
{
|
||||
Console.WriteLine($"Received Product: Name='{model?.Name}', Description='{model?.Description}', Price={model?.BasePrice}");
|
||||
Console.WriteLine($"Received Product: Name='{model?.Name}', Description='{model?.Description}', Price={model?.Price}");
|
||||
Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
@ -64,9 +64,9 @@ public class ProductsController : Controller
|
||||
{
|
||||
Name = product.Name,
|
||||
Description = product.Description,
|
||||
ProductWeightUnit = product.ProductWeightUnit,
|
||||
ProductWeight = product.ProductWeight,
|
||||
BasePrice = product.BasePrice,
|
||||
WeightUnit = product.WeightUnit,
|
||||
Weight = product.Weight,
|
||||
Price = product.Price,
|
||||
CategoryId = product.CategoryId,
|
||||
IsActive = product.IsActive
|
||||
};
|
||||
102
LittleShop/Areas/Admin/Controllers/ShippingRatesController.cs
Normal file
102
LittleShop/Areas/Admin/Controllers/ShippingRatesController.cs
Normal file
@ -0,0 +1,102 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.DTOs;
|
||||
|
||||
namespace LittleShop.Areas.Admin.Controllers;
|
||||
|
||||
[Area("Admin")]
|
||||
[Authorize(Policy = "AdminOnly")]
|
||||
public class ShippingRatesController : Controller
|
||||
{
|
||||
private readonly IShippingRateService _shippingRateService;
|
||||
private readonly ILogger<ShippingRatesController> _logger;
|
||||
|
||||
public ShippingRatesController(IShippingRateService shippingRateService, ILogger<ShippingRatesController> logger)
|
||||
{
|
||||
_shippingRateService = shippingRateService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var rates = await _shippingRateService.GetAllShippingRatesAsync();
|
||||
return View(rates);
|
||||
}
|
||||
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new CreateShippingRateDto());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateShippingRateDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var rate = await _shippingRateService.CreateShippingRateAsync(model);
|
||||
_logger.LogInformation("Created shipping rate {RateId}", rate.Id);
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Edit(Guid id)
|
||||
{
|
||||
var rate = await _shippingRateService.GetShippingRateByIdAsync(id);
|
||||
if (rate == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var model = new UpdateShippingRateDto
|
||||
{
|
||||
Name = rate.Name,
|
||||
Description = rate.Description,
|
||||
Country = rate.Country,
|
||||
MinWeight = rate.MinWeight,
|
||||
MaxWeight = rate.MaxWeight,
|
||||
Price = rate.Price,
|
||||
MinDeliveryDays = rate.MinDeliveryDays,
|
||||
MaxDeliveryDays = rate.MaxDeliveryDays,
|
||||
IsActive = rate.IsActive
|
||||
};
|
||||
|
||||
ViewData["RateId"] = id;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Edit(Guid id, UpdateShippingRateDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
ViewData["RateId"] = id;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var success = await _shippingRateService.UpdateShippingRateAsync(id, model);
|
||||
if (!success)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Updated shipping rate {RateId}", id);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var success = await _shippingRateService.DeleteShippingRateAsync(id);
|
||||
if (!success)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Deleted shipping rate {RateId}", id);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
106
LittleShop/Areas/Admin/Views/Orders/Create.cshtml
Normal file
106
LittleShop/Areas/Admin/Views/Orders/Create.cshtml
Normal file
@ -0,0 +1,106 @@
|
||||
@model LittleShop.DTOs.CreateOrderDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Order";
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-plus"></i> Create Order</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-user"></i> Customer Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" asp-area="Admin" asp-controller="Orders" asp-action="Create">
|
||||
@Html.AntiForgeryToken()
|
||||
@if (ViewData.ModelState[""] != null && ViewData.ModelState[""].Errors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@foreach (var error in ViewData.ModelState[""].Errors)
|
||||
{
|
||||
<div>@error.ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="IdentityReference" class="form-label">Customer Reference</label>
|
||||
<input name="IdentityReference" id="IdentityReference" class="form-control" required
|
||||
placeholder="Customer ID or reference" />
|
||||
<small class="text-muted">Anonymous identifier for the customer</small>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4 mb-3">Shipping Information</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="ShippingName" class="form-label">Recipient Name</label>
|
||||
<input name="ShippingName" id="ShippingName" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="ShippingAddress" class="form-label">Shipping Address</label>
|
||||
<textarea name="ShippingAddress" id="ShippingAddress" class="form-control" rows="2" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="ShippingCity" class="form-label">City</label>
|
||||
<input name="ShippingCity" id="ShippingCity" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="ShippingPostCode" class="form-label">Post Code</label>
|
||||
<input name="ShippingPostCode" id="ShippingPostCode" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="ShippingCountry" class="form-label">Country</label>
|
||||
<input name="ShippingCountry" id="ShippingCountry" class="form-control" value="United Kingdom" required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Notes" class="form-label">Order Notes (Optional)</label>
|
||||
<textarea name="Notes" id="Notes" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Orders
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Create Order
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-info-circle"></i> Order Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Create a new order manually for phone or email orders.</p>
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>Customer Reference:</strong> Anonymous identifier</li>
|
||||
<li><strong>Shipping Details:</strong> Required for delivery</li>
|
||||
<li><strong>Payment:</strong> Add after order creation</li>
|
||||
<li><strong>Products:</strong> Add items after creation</li>
|
||||
</ul>
|
||||
<small class="text-muted">Orders created here will appear in the orders list with PendingPayment status.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -10,6 +10,9 @@
|
||||
<p class="text-muted">Order ID: @Model.Id</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="@Url.Action("Edit", new { id = Model.Id })" class="btn btn-primary">
|
||||
<i class="fas fa-edit"></i> Edit Order
|
||||
</a>
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Orders
|
||||
</a>
|
||||
@ -69,6 +72,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-truck"></i> Shipping Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Name:</strong> @Model.ShippingName</p>
|
||||
<p><strong>Address:</strong> @Model.ShippingAddress</p>
|
||||
<p><strong>City:</strong> @Model.ShippingCity</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Post Code:</strong> @Model.ShippingPostCode</p>
|
||||
<p><strong>Country:</strong> @Model.ShippingCountry</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-list"></i> Order Items</h5>
|
||||
@ -85,7 +107,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.OrderItems)
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.ProductName</td>
|
||||
@ -144,14 +166,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.CryptoPayments.Any())
|
||||
@if (Model.Payments.Any())
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-coins"></i> Crypto Payments</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@foreach (var payment in Model.CryptoPayments)
|
||||
@foreach (var payment in Model.Payments)
|
||||
{
|
||||
<div class="mb-3 p-2 border rounded">
|
||||
<div class="d-flex justify-content-between">
|
||||
218
LittleShop/Areas/Admin/Views/Orders/Edit.cshtml
Normal file
218
LittleShop/Areas/Admin/Views/Orders/Edit.cshtml
Normal file
@ -0,0 +1,218 @@
|
||||
@model LittleShop.DTOs.OrderDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Order";
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-edit"></i> Edit Order #@Model?.Id</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-user"></i> Order Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" asp-area="Admin" asp-controller="Orders" asp-action="Edit" asp-route-id="@Model?.Id">
|
||||
@Html.AntiForgeryToken()
|
||||
@if (ViewData.ModelState[""] != null && ViewData.ModelState[""].Errors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@foreach (var error in ViewData.ModelState[""].Errors)
|
||||
{
|
||||
<div>@error.ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Order ID</label>
|
||||
<input class="form-control" value="@Model?.Id" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Customer Reference</label>
|
||||
<input class="form-control" value="@Model?.IdentityReference" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4 mb-3">Shipping Information</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="ShippingName" class="form-label">Recipient Name</label>
|
||||
<input name="ShippingName" id="ShippingName" value="@Model?.ShippingName" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="ShippingAddress" class="form-label">Shipping Address</label>
|
||||
<textarea name="ShippingAddress" id="ShippingAddress" class="form-control" rows="2" required>@Model?.ShippingAddress</textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="ShippingCity" class="form-label">City</label>
|
||||
<input name="ShippingCity" id="ShippingCity" value="@Model?.ShippingCity" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="ShippingPostCode" class="form-label">Post Code</label>
|
||||
<input name="ShippingPostCode" id="ShippingPostCode" value="@Model?.ShippingPostCode" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="ShippingCountry" class="form-label">Country</label>
|
||||
<input name="ShippingCountry" id="ShippingCountry" value="@Model?.ShippingCountry" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="Status" class="form-label">Order Status</label>
|
||||
<select name="Status" id="Status" class="form-select">
|
||||
<option value="0" selected="@(Model?.Status == LittleShop.Enums.OrderStatus.PendingPayment)">Pending Payment</option>
|
||||
<option value="1" selected="@(Model?.Status == LittleShop.Enums.OrderStatus.PaymentReceived)">Payment Received</option>
|
||||
<option value="2" selected="@(Model?.Status == LittleShop.Enums.OrderStatus.Processing)">Processing</option>
|
||||
<option value="3" selected="@(Model?.Status == LittleShop.Enums.OrderStatus.Shipped)">Shipped</option>
|
||||
<option value="4" selected="@(Model?.Status == LittleShop.Enums.OrderStatus.Delivered)">Delivered</option>
|
||||
<option value="5" selected="@(Model?.Status == LittleShop.Enums.OrderStatus.Cancelled)">Cancelled</option>
|
||||
<option value="6" selected="@(Model?.Status == LittleShop.Enums.OrderStatus.Refunded)">Refunded</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="TrackingNumber" class="form-label">Tracking Number</label>
|
||||
<input name="TrackingNumber" id="TrackingNumber" value="@Model?.TrackingNumber" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Notes" class="form-label">Order Notes</label>
|
||||
<textarea name="Notes" id="Notes" class="form-control" rows="3">@Model?.Notes</textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="@Url.Action("Details", new { id = Model?.Id })" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Details
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-shopping-cart"></i> Order Items</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (Model?.Items?.Any() == true)
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Quantity</th>
|
||||
<th>Unit Price</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.ProductName</td>
|
||||
<td>@item.Quantity</td>
|
||||
<td>£@item.UnitPrice</td>
|
||||
<td>£@item.TotalPrice</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="3">Total Amount</th>
|
||||
<th>£@Model.TotalAmount</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">No items in this order.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-info-circle"></i> Order Summary</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl>
|
||||
<dt>Created</dt>
|
||||
<dd>@Model?.CreatedAt.ToString("dd/MM/yyyy HH:mm")</dd>
|
||||
|
||||
<dt>Updated</dt>
|
||||
<dd>@Model?.UpdatedAt.ToString("dd/MM/yyyy HH:mm")</dd>
|
||||
|
||||
@if (Model?.PaidAt != null)
|
||||
{
|
||||
<dt>Paid</dt>
|
||||
<dd>@Model.PaidAt.Value.ToString("dd/MM/yyyy HH:mm")</dd>
|
||||
}
|
||||
|
||||
@if (Model?.ShippedAt != null)
|
||||
{
|
||||
<dt>Shipped</dt>
|
||||
<dd>@Model.ShippedAt.Value.ToString("dd/MM/yyyy HH:mm")</dd>
|
||||
}
|
||||
|
||||
<dt>Total Amount</dt>
|
||||
<dd class="h5">£@Model?.TotalAmount</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Information -->
|
||||
@if (Model?.Payments?.Any() == true)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-credit-card"></i> Payments</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@foreach (var payment in Model.Payments)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="badge bg-info">@payment.Currency</span>
|
||||
<span class="badge @(payment.Status == LittleShop.Enums.PaymentStatus.Paid ? "bg-success" : "bg-warning")">
|
||||
@payment.Status
|
||||
</span>
|
||||
<small class="d-block mt-1">Amount: @payment.RequiredAmount</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@ -8,6 +8,11 @@
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-shopping-cart"></i> Orders</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="@Url.Action("Create")" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create Order
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@ -19,9 +24,10 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order ID</th>
|
||||
<th>Identity Reference</th>
|
||||
<th>Customer</th>
|
||||
<th>Shipping To</th>
|
||||
<th>Status</th>
|
||||
<th>Total Amount</th>
|
||||
<th>Total</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@ -31,7 +37,8 @@
|
||||
{
|
||||
<tr>
|
||||
<td><code>@order.Id.ToString().Substring(0, 8)...</code></td>
|
||||
<td>@order.IdentityReference</td>
|
||||
<td>@order.ShippingName</td>
|
||||
<td>@order.ShippingCity, @order.ShippingCountry</td>
|
||||
<td>
|
||||
@{
|
||||
var badgeClass = order.Status switch
|
||||
@ -40,8 +40,8 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="BasePrice" class="form-label">Base Price (£)</label>
|
||||
<input name="BasePrice" id="BasePrice" type="number" step="0.01" class="form-control" required />
|
||||
<label for="Price" class="form-label">Price (£)</label>
|
||||
<input name="Price" id="Price" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@ -64,14 +64,14 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="ProductWeight" class="form-label">Weight/Volume</label>
|
||||
<input name="ProductWeight" id="ProductWeight" type="number" step="0.01" class="form-control" required />
|
||||
<label for="Weight" class="form-label">Weight/Volume</label>
|
||||
<input name="Weight" id="Weight" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="ProductWeightUnit" class="form-label">Unit</label>
|
||||
<select name="ProductWeightUnit" id="ProductWeightUnit" class="form-select">
|
||||
<label for="WeightUnit" class="form-label">Unit</label>
|
||||
<select name="WeightUnit" id="WeightUnit" class="form-select">
|
||||
<option value="0">Unit</option>
|
||||
<option value="1">Micrograms</option>
|
||||
<option value="2">Grams</option>
|
||||
@ -41,8 +41,8 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="BasePrice" class="form-label">Base Price (£)</label>
|
||||
<input name="BasePrice" id="BasePrice" value="@Model?.BasePrice" type="number" step="0.01" class="form-control" required />
|
||||
<label for="Price" class="form-label">Price (£)</label>
|
||||
<input name="Price" id="Price" value="@Model?.Price" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@ -65,21 +65,21 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="ProductWeight" class="form-label">Weight/Volume</label>
|
||||
<input name="ProductWeight" id="ProductWeight" value="@Model?.ProductWeight" type="number" step="0.01" class="form-control" required />
|
||||
<label for="Weight" class="form-label">Weight/Volume</label>
|
||||
<input name="Weight" id="Weight" value="@Model?.Weight" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="ProductWeightUnit" class="form-label">Unit</label>
|
||||
<select name="ProductWeightUnit" id="ProductWeightUnit" class="form-select">
|
||||
<option value="0" selected="@(Model?.ProductWeightUnit == LittleShop.Enums.ProductWeightUnit.Unit)">Unit</option>
|
||||
<option value="1" selected="@(Model?.ProductWeightUnit == LittleShop.Enums.ProductWeightUnit.Micrograms)">Micrograms</option>
|
||||
<option value="2" selected="@(Model?.ProductWeightUnit == LittleShop.Enums.ProductWeightUnit.Grams)">Grams</option>
|
||||
<option value="3" selected="@(Model?.ProductWeightUnit == LittleShop.Enums.ProductWeightUnit.Ounces)">Ounces</option>
|
||||
<option value="4" selected="@(Model?.ProductWeightUnit == LittleShop.Enums.ProductWeightUnit.Pounds)">Pounds</option>
|
||||
<option value="5" selected="@(Model?.ProductWeightUnit == LittleShop.Enums.ProductWeightUnit.Millilitres)">Millilitres</option>
|
||||
<option value="6" selected="@(Model?.ProductWeightUnit == LittleShop.Enums.ProductWeightUnit.Litres)">Litres</option>
|
||||
<label for="WeightUnit" class="form-label">Unit</label>
|
||||
<select name="WeightUnit" id="WeightUnit" class="form-select">
|
||||
<option value="0" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Unit)">Unit</option>
|
||||
<option value="1" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Micrograms)">Micrograms</option>
|
||||
<option value="2" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Grams)">Grams</option>
|
||||
<option value="3" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Ounces)">Ounces</option>
|
||||
<option value="4" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Pounds)">Pounds</option>
|
||||
<option value="5" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Millilitres)">Millilitres</option>
|
||||
<option value="6" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Litres)">Litres</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -56,10 +56,10 @@
|
||||
<span class="badge bg-secondary">@product.CategoryName</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>£@product.BasePrice</strong>
|
||||
<strong>£@product.Price</strong>
|
||||
</td>
|
||||
<td>
|
||||
@product.ProductWeight @product.ProductWeightUnit.ToString().ToLower()
|
||||
@product.Weight @product.WeightUnit.ToString().ToLower()
|
||||
</td>
|
||||
<td>
|
||||
@if (product.IsActive)
|
||||
@ -39,6 +39,11 @@
|
||||
<i class="fas fa-shopping-cart"></i> Orders
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="@Url.Action("Index", "ShippingRates", new { area = "Admin" })">
|
||||
<i class="fas fa-truck"></i> Shipping
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="@Url.Action("Index", "Users", new { area = "Admin" })">
|
||||
<i class="fas fa-users"></i> Users
|
||||
127
LittleShop/Areas/Admin/Views/ShippingRates/Create.cshtml
Normal file
127
LittleShop/Areas/Admin/Views/ShippingRates/Create.cshtml
Normal file
@ -0,0 +1,127 @@
|
||||
@model LittleShop.DTOs.CreateShippingRateDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Shipping Rate";
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-plus"></i> Create Shipping Rate</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" asp-area="Admin" asp-controller="ShippingRates" asp-action="Create">
|
||||
@Html.AntiForgeryToken()
|
||||
@if (ViewData.ModelState[""] != null && ViewData.ModelState[""].Errors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@foreach (var error in ViewData.ModelState[""].Errors)
|
||||
{
|
||||
<div>@error.ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Name" class="form-label">Rate Name</label>
|
||||
<input name="Name" id="Name" class="form-control" required
|
||||
placeholder="e.g., Royal Mail First Class" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Description" class="form-label">Description (Optional)</label>
|
||||
<textarea name="Description" id="Description" class="form-control" rows="2"
|
||||
placeholder="Additional information about this shipping method"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Country" class="form-label">Country</label>
|
||||
<input name="Country" id="Country" class="form-control" value="United Kingdom" required />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="MinWeight" class="form-label">Minimum Weight (grams)</label>
|
||||
<input name="MinWeight" id="MinWeight" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="MaxWeight" class="form-label">Maximum Weight (grams)</label>
|
||||
<input name="MaxWeight" id="MaxWeight" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Price" class="form-label">Price (£)</label>
|
||||
<input name="Price" id="Price" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="MinDeliveryDays" class="form-label">Minimum Delivery Days</label>
|
||||
<input name="MinDeliveryDays" id="MinDeliveryDays" type="number" class="form-control" value="1" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="MaxDeliveryDays" class="form-label">Maximum Delivery Days</label>
|
||||
<input name="MaxDeliveryDays" id="MaxDeliveryDays" type="number" class="form-control" value="3" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input name="IsActive" type="checkbox" class="form-check-input" checked value="true" />
|
||||
<input name="IsActive" type="hidden" value="false" />
|
||||
<label for="IsActive" class="form-check-label">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Shipping Rates
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Create Shipping Rate
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-info-circle"></i> Weight Guidelines</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>Common Weight Ranges (in grams):</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>Letter:</strong> 0 - 100g</li>
|
||||
<li><strong>Large Letter:</strong> 100 - 750g</li>
|
||||
<li><strong>Small Parcel:</strong> 750 - 2000g</li>
|
||||
<li><strong>Medium Parcel:</strong> 2000 - 10000g</li>
|
||||
<li><strong>Large Parcel:</strong> 10000 - 30000g</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h6>Delivery Time Examples:</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>First Class:</strong> 1-3 days</li>
|
||||
<li><strong>Second Class:</strong> 2-5 days</li>
|
||||
<li><strong>Express:</strong> 1 day</li>
|
||||
<li><strong>International:</strong> 5-10 days</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
126
LittleShop/Areas/Admin/Views/ShippingRates/Edit.cshtml
Normal file
126
LittleShop/Areas/Admin/Views/ShippingRates/Edit.cshtml
Normal file
@ -0,0 +1,126 @@
|
||||
@model LittleShop.DTOs.UpdateShippingRateDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Shipping Rate";
|
||||
var rateId = ViewData["RateId"];
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-edit"></i> Edit Shipping Rate</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="@Url.Action("Edit", new { id = rateId })">
|
||||
@Html.AntiForgeryToken()
|
||||
@if (ViewData.ModelState[""] != null && ViewData.ModelState[""].Errors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@foreach (var error in ViewData.ModelState[""].Errors)
|
||||
{
|
||||
<div>@error.ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Name" class="form-label">Rate Name</label>
|
||||
<input name="Name" id="Name" value="@Model?.Name" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Description" class="form-label">Description (Optional)</label>
|
||||
<textarea name="Description" id="Description" class="form-control" rows="2">@Model?.Description</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Country" class="form-label">Country</label>
|
||||
<input name="Country" id="Country" value="@Model?.Country" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="MinWeight" class="form-label">Minimum Weight (grams)</label>
|
||||
<input name="MinWeight" id="MinWeight" value="@Model?.MinWeight" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="MaxWeight" class="form-label">Maximum Weight (grams)</label>
|
||||
<input name="MaxWeight" id="MaxWeight" value="@Model?.MaxWeight" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Price" class="form-label">Price (£)</label>
|
||||
<input name="Price" id="Price" value="@Model?.Price" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="MinDeliveryDays" class="form-label">Minimum Delivery Days</label>
|
||||
<input name="MinDeliveryDays" id="MinDeliveryDays" value="@Model?.MinDeliveryDays" type="number" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="MaxDeliveryDays" class="form-label">Maximum Delivery Days</label>
|
||||
<input name="MaxDeliveryDays" id="MaxDeliveryDays" value="@Model?.MaxDeliveryDays" type="number" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input name="IsActive" type="checkbox" class="form-check-input" checked="@(Model?.IsActive == true)" value="true" />
|
||||
<input name="IsActive" type="hidden" value="false" />
|
||||
<label for="IsActive" class="form-check-label">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Shipping Rates
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-info-circle"></i> Weight Guidelines</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>Common Weight Ranges (in grams):</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>Letter:</strong> 0 - 100g</li>
|
||||
<li><strong>Large Letter:</strong> 100 - 750g</li>
|
||||
<li><strong>Small Parcel:</strong> 750 - 2000g</li>
|
||||
<li><strong>Medium Parcel:</strong> 2000 - 10000g</li>
|
||||
<li><strong>Large Parcel:</strong> 10000 - 30000g</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h6>Delivery Time Examples:</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>First Class:</strong> 1-3 days</li>
|
||||
<li><strong>Second Class:</strong> 2-5 days</li>
|
||||
<li><strong>Express:</strong> 1 day</li>
|
||||
<li><strong>International:</strong> 5-10 days</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
102
LittleShop/Areas/Admin/Views/ShippingRates/Index.cshtml
Normal file
102
LittleShop/Areas/Admin/Views/ShippingRates/Index.cshtml
Normal file
@ -0,0 +1,102 @@
|
||||
@model IEnumerable<LittleShop.DTOs.ShippingRateDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Shipping Rates";
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-truck"></i> Shipping Rates</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="@Url.Action("Create")" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Add Shipping Rate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@if (Model.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Country</th>
|
||||
<th>Weight Range (g)</th>
|
||||
<th>Price</th>
|
||||
<th>Delivery Days</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rate in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<strong>@rate.Name</strong>
|
||||
@if (!string.IsNullOrEmpty(rate.Description))
|
||||
{
|
||||
<br><small class="text-muted">@rate.Description</small>
|
||||
}
|
||||
</td>
|
||||
<td>@rate.Country</td>
|
||||
<td>@rate.MinWeight - @rate.MaxWeight</td>
|
||||
<td>£@rate.Price</td>
|
||||
<td>@rate.MinDeliveryDays - @rate.MaxDeliveryDays days</td>
|
||||
<td>
|
||||
@if (rate.IsActive)
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Inactive</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="@Url.Action("Edit", new { id = rate.Id })" class="btn btn-outline-primary">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<form method="post" action="@Url.Action("Delete", new { id = rate.Id })" class="d-inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this shipping rate?')">
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-truck fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">No shipping rates configured. <a href="@Url.Action("Create")">Add your first shipping rate</a>.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-info-circle"></i> Shipping Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Configure shipping rates based on weight ranges and destination countries.</p>
|
||||
<ul>
|
||||
<li>Weight ranges are in grams</li>
|
||||
<li>Rates are automatically calculated based on order weight</li>
|
||||
<li>The cheapest applicable rate is selected for customers</li>
|
||||
<li>Set rates as inactive to temporarily disable them</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Services;
|
||||
@ -6,6 +7,7 @@ namespace LittleShop.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "Bearer")]
|
||||
public class CatalogController : ControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categoryService;
|
||||
@ -8,6 +8,7 @@ namespace LittleShop.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "Bearer")]
|
||||
public class OrdersController : ControllerBase
|
||||
{
|
||||
private readonly IOrderService _orderService;
|
||||
@ -21,7 +22,7 @@ public class OrdersController : ControllerBase
|
||||
|
||||
// Admin endpoints
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<IEnumerable<OrderDto>>> GetAllOrders()
|
||||
{
|
||||
var orders = await _orderService.GetAllOrdersAsync();
|
||||
@ -29,7 +30,7 @@ public class OrdersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[Authorize]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<OrderDto>> GetOrder(Guid id)
|
||||
{
|
||||
var order = await _orderService.GetOrderByIdAsync(id);
|
||||
@ -42,7 +43,7 @@ public class OrdersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id}/status")]
|
||||
[Authorize]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult> UpdateOrderStatus(Guid id, [FromBody] UpdateOrderStatusDto updateOrderStatusDto)
|
||||
{
|
||||
var success = await _orderService.UpdateOrderStatusAsync(id, updateOrderStatusDto);
|
||||
@ -35,9 +35,9 @@ public class TestController : ControllerBase
|
||||
{
|
||||
Name = "Test Product via API",
|
||||
Description = "This product was created via the test API endpoint 🚀",
|
||||
BasePrice = 49.99m,
|
||||
ProductWeight = 0.5,
|
||||
ProductWeightUnit = LittleShop.Enums.ProductWeightUnit.Pounds,
|
||||
Price = 49.99m,
|
||||
Weight = 0.5m,
|
||||
WeightUnit = LittleShop.Enums.ProductWeightUnit.Pounds,
|
||||
CategoryId = firstCategory.Id
|
||||
});
|
||||
|
||||
@ -69,9 +69,9 @@ public class TestController : ControllerBase
|
||||
{
|
||||
Name = "Sample Product",
|
||||
Description = "This is a test product with emoji support 📱💻",
|
||||
BasePrice = 99.99m,
|
||||
ProductWeight = 1.5,
|
||||
ProductWeightUnit = LittleShop.Enums.ProductWeightUnit.Pounds,
|
||||
Price = 99.99m,
|
||||
Weight = 1.5m,
|
||||
WeightUnit = LittleShop.Enums.ProductWeightUnit.Pounds,
|
||||
CategoryId = category.Id
|
||||
});
|
||||
|
||||
@ -10,13 +10,19 @@ public class OrderDto
|
||||
public OrderStatus Status { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public string Currency { get; set; } = "GBP";
|
||||
public string ShippingName { get; set; } = string.Empty;
|
||||
public string ShippingAddress { get; set; } = string.Empty;
|
||||
public string ShippingCity { get; set; } = string.Empty;
|
||||
public string ShippingPostCode { get; set; } = string.Empty;
|
||||
public string ShippingCountry { get; set; } = string.Empty;
|
||||
public string? Notes { get; set; }
|
||||
public string? TrackingNumber { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public DateTime? PaidAt { get; set; }
|
||||
public DateTime? ShippedAt { get; set; }
|
||||
public List<OrderItemDto> OrderItems { get; set; } = new();
|
||||
public List<CryptoPaymentDto> CryptoPayments { get; set; } = new();
|
||||
public List<OrderItemDto> Items { get; set; } = new();
|
||||
public List<CryptoPaymentDto> Payments { get; set; } = new();
|
||||
}
|
||||
|
||||
public class OrderItemDto
|
||||
@ -35,7 +41,22 @@ public class CreateOrderDto
|
||||
public string IdentityReference { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public List<CreateOrderItemDto> OrderItems { get; set; } = new();
|
||||
public string ShippingName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ShippingAddress { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ShippingCity { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ShippingPostCode { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ShippingCountry { get; set; } = "United Kingdom";
|
||||
|
||||
[Required]
|
||||
public List<CreateOrderItemDto> Items { get; set; } = new();
|
||||
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@ -8,12 +8,13 @@ public class ProductDto
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public ProductWeightUnit ProductWeightUnit { get; set; }
|
||||
public double ProductWeight { get; set; }
|
||||
public decimal BasePrice { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public decimal Weight { get; set; }
|
||||
public ProductWeightUnit WeightUnit { get; set; }
|
||||
public Guid CategoryId { get; set; }
|
||||
public string CategoryName { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<ProductPhotoDto> Photos { get; set; } = new();
|
||||
}
|
||||
@ -36,12 +37,12 @@ public class CreateProductDto
|
||||
[Required]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public ProductWeightUnit ProductWeightUnit { get; set; }
|
||||
|
||||
public double ProductWeight { get; set; }
|
||||
|
||||
[Range(0.01, double.MaxValue)]
|
||||
public decimal BasePrice { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
|
||||
public decimal Weight { get; set; }
|
||||
|
||||
public ProductWeightUnit WeightUnit { get; set; }
|
||||
|
||||
[Required]
|
||||
public Guid CategoryId { get; set; }
|
||||
@ -54,14 +55,27 @@ public class UpdateProductDto
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public ProductWeightUnit? ProductWeightUnit { get; set; }
|
||||
|
||||
public double? ProductWeight { get; set; }
|
||||
|
||||
[Range(0.01, double.MaxValue)]
|
||||
public decimal? BasePrice { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
|
||||
public decimal? Weight { get; set; }
|
||||
|
||||
public ProductWeightUnit? WeightUnit { get; set; }
|
||||
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class CreateProductPhotoDto
|
||||
{
|
||||
[Required]
|
||||
public Guid ProductId { get; set; }
|
||||
|
||||
[Required]
|
||||
public string PhotoUrl { get; set; } = string.Empty;
|
||||
|
||||
public string? AltText { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
}
|
||||
59
LittleShop/DTOs/ShippingRateDto.cs
Normal file
59
LittleShop/DTOs/ShippingRateDto.cs
Normal file
@ -0,0 +1,59 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LittleShop.DTOs;
|
||||
|
||||
public class ShippingRateDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string Country { get; set; } = string.Empty;
|
||||
public decimal MinWeight { get; set; }
|
||||
public decimal MaxWeight { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public int MinDeliveryDays { get; set; }
|
||||
public int MaxDeliveryDays { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateShippingRateDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Country { get; set; } = "United Kingdom";
|
||||
|
||||
[Required]
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal MinWeight { get; set; }
|
||||
|
||||
[Required]
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal MaxWeight { get; set; }
|
||||
|
||||
[Required]
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal Price { get; set; }
|
||||
|
||||
[Required]
|
||||
[Range(1, 365)]
|
||||
public int MinDeliveryDays { get; set; } = 1;
|
||||
|
||||
[Required]
|
||||
[Range(1, 365)]
|
||||
public int MaxDeliveryDays { get; set; } = 3;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class UpdateShippingRateDto : CreateShippingRateDto
|
||||
{
|
||||
}
|
||||
@ -16,6 +16,7 @@ public class LittleShopContext : DbContext
|
||||
public DbSet<Order> Orders { get; set; }
|
||||
public DbSet<OrderItem> OrderItems { get; set; }
|
||||
public DbSet<CryptoPayment> CryptoPayments { get; set; }
|
||||
public DbSet<ShippingRate> ShippingRates { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@ -53,12 +54,12 @@ public class LittleShopContext : DbContext
|
||||
// Order entity
|
||||
modelBuilder.Entity<Order>(entity =>
|
||||
{
|
||||
entity.HasMany(o => o.OrderItems)
|
||||
entity.HasMany(o => o.Items)
|
||||
.WithOne(oi => oi.Order)
|
||||
.HasForeignKey(oi => oi.OrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(o => o.CryptoPayments)
|
||||
entity.HasMany(o => o.Payments)
|
||||
.WithOne(cp => cp.Order)
|
||||
.HasForeignKey(cp => cp.OrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
@ -8,5 +8,7 @@ public enum ProductWeightUnit
|
||||
Ounces = 3,
|
||||
Pounds = 4,
|
||||
Millilitres = 5,
|
||||
Litres = 6
|
||||
Litres = 6,
|
||||
Kilogram = 7,
|
||||
Kilograms = 7 // Alias for compatibility with tests
|
||||
}
|
||||
@ -16,6 +16,8 @@ public class Category
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
// Navigation properties
|
||||
@ -21,6 +21,27 @@ public class Order
|
||||
[StringLength(10)]
|
||||
public string Currency { get; set; } = "GBP";
|
||||
|
||||
// Shipping Information
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string ShippingName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(500)]
|
||||
public string ShippingAddress { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string ShippingCity { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(20)]
|
||||
public string ShippingPostCode { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string ShippingCountry { get; set; } = "United Kingdom";
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
@ -29,11 +50,13 @@ public class Order
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
public DateTime? ShippedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public virtual ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
|
||||
public virtual ICollection<CryptoPayment> CryptoPayments { get; set; } = new List<CryptoPayment>();
|
||||
public virtual ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
|
||||
public virtual ICollection<CryptoPayment> Payments { get; set; } = new List<CryptoPayment>();
|
||||
}
|
||||
@ -16,18 +16,21 @@ public class Product
|
||||
[Required]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public ProductWeightUnit ProductWeightUnit { get; set; }
|
||||
|
||||
public double ProductWeight { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal BasePrice { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal Weight { get; set; }
|
||||
|
||||
public ProductWeightUnit WeightUnit { get; set; } = ProductWeightUnit.Kilogram;
|
||||
|
||||
public Guid CategoryId { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation properties
|
||||
public virtual Category Category { get; set; } = null!;
|
||||
40
LittleShop/Models/ShippingRate.cs
Normal file
40
LittleShop/Models/ShippingRate.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace LittleShop.Models;
|
||||
|
||||
public class ShippingRate
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Country { get; set; } = "United Kingdom";
|
||||
|
||||
// Weight range in grams
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal MinWeight { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal MaxWeight { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal Price { get; set; }
|
||||
|
||||
// Estimated delivery days
|
||||
public int MinDeliveryDays { get; set; }
|
||||
public int MaxDeliveryDays { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@ -64,6 +64,8 @@ builder.Services.AddScoped<IProductService, ProductService>();
|
||||
builder.Services.AddScoped<IOrderService, OrderService>();
|
||||
builder.Services.AddScoped<ICryptoPaymentService, CryptoPaymentService>();
|
||||
builder.Services.AddScoped<IBTCPayServerService, BTCPayServerService>();
|
||||
builder.Services.AddScoped<IShippingRateService, ShippingRateService>();
|
||||
builder.Services.AddScoped<IDataSeederService, DataSeederService>();
|
||||
|
||||
// AutoMapper
|
||||
builder.Services.AddAutoMapper(typeof(Program));
|
||||
@ -164,8 +166,15 @@ using (var scope = app.Services.CreateScope())
|
||||
// Seed default admin user
|
||||
var authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
|
||||
await authService.SeedDefaultUserAsync();
|
||||
|
||||
// Seed sample data
|
||||
var dataSeeder = scope.ServiceProvider.GetRequiredService<IDataSeederService>();
|
||||
await dataSeeder.SeedSampleDataAsync();
|
||||
}
|
||||
|
||||
Log.Information("LittleShop API starting up...");
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program accessible to test project
|
||||
public partial class Program { }
|
||||
@ -27,7 +27,7 @@ public class CryptoPaymentService : ICryptoPaymentService
|
||||
public async Task<CryptoPaymentDto> CreatePaymentAsync(Guid orderId, CryptoCurrency currency)
|
||||
{
|
||||
var order = await _context.Orders
|
||||
.Include(o => o.OrderItems)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.FirstOrDefaultAsync(o => o.Id == orderId);
|
||||
|
||||
@ -48,7 +48,7 @@ public class CryptoPaymentService : ICryptoPaymentService
|
||||
order.TotalAmount,
|
||||
currency,
|
||||
order.Id.ToString(),
|
||||
$"Order #{order.Id} - {order.OrderItems.Count} items"
|
||||
$"Order #{order.Id} - {order.Items.Count} items"
|
||||
);
|
||||
|
||||
// For now, generate a placeholder wallet address
|
||||
486
LittleShop/Services/DataSeederService.cs
Normal file
486
LittleShop/Services/DataSeederService.cs
Normal file
@ -0,0 +1,486 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.Models;
|
||||
using LittleShop.Enums;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
public interface IDataSeederService
|
||||
{
|
||||
Task SeedSampleDataAsync();
|
||||
}
|
||||
|
||||
public class DataSeederService : IDataSeederService
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly ILogger<DataSeederService> _logger;
|
||||
|
||||
public DataSeederService(LittleShopContext context, ILogger<DataSeederService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SeedSampleDataAsync()
|
||||
{
|
||||
// Check if we already have data
|
||||
var hasCategories = await _context.Categories.AnyAsync();
|
||||
if (hasCategories)
|
||||
{
|
||||
_logger.LogInformation("Sample data already exists, skipping seed");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Seeding sample data...");
|
||||
|
||||
// Create Categories
|
||||
var categories = new List<Category>
|
||||
{
|
||||
new Category
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Electronics",
|
||||
Description = "Electronic devices and accessories",
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Clothing",
|
||||
Description = "Apparel and fashion items",
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Books",
|
||||
Description = "Physical and digital books",
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
_context.Categories.AddRange(categories);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Created {Count} categories", categories.Count);
|
||||
|
||||
// Create Products
|
||||
var products = new List<Product>
|
||||
{
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Wireless Headphones",
|
||||
Description = "High-quality Bluetooth headphones with noise cancellation",
|
||||
Price = 89.99m,
|
||||
Weight = 250,
|
||||
WeightUnit = ProductWeightUnit.Grams,
|
||||
CategoryId = categories[0].Id,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Smartphone Case",
|
||||
Description = "Durable protective case for latest smartphones",
|
||||
Price = 19.99m,
|
||||
Weight = 50,
|
||||
WeightUnit = ProductWeightUnit.Grams,
|
||||
CategoryId = categories[0].Id,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "T-Shirt",
|
||||
Description = "100% cotton comfortable t-shirt",
|
||||
Price = 24.99m,
|
||||
Weight = 200,
|
||||
WeightUnit = ProductWeightUnit.Grams,
|
||||
CategoryId = categories[1].Id,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Jeans",
|
||||
Description = "Classic denim jeans",
|
||||
Price = 59.99m,
|
||||
Weight = 500,
|
||||
WeightUnit = ProductWeightUnit.Grams,
|
||||
CategoryId = categories[1].Id,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Programming Book",
|
||||
Description = "Learn programming with practical examples",
|
||||
Price = 34.99m,
|
||||
Weight = 800,
|
||||
WeightUnit = ProductWeightUnit.Grams,
|
||||
CategoryId = categories[2].Id,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
_context.Products.AddRange(products);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Created {Count} products", products.Count);
|
||||
|
||||
// Create Shipping Rates
|
||||
var shippingRates = new List<ShippingRate>
|
||||
{
|
||||
new ShippingRate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Royal Mail First Class",
|
||||
Description = "Next working day delivery",
|
||||
Country = "United Kingdom",
|
||||
MinWeight = 0,
|
||||
MaxWeight = 100,
|
||||
Price = 2.99m,
|
||||
MinDeliveryDays = 1,
|
||||
MaxDeliveryDays = 2,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new ShippingRate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Royal Mail Second Class",
|
||||
Description = "2-3 working days delivery",
|
||||
Country = "United Kingdom",
|
||||
MinWeight = 0,
|
||||
MaxWeight = 100,
|
||||
Price = 1.99m,
|
||||
MinDeliveryDays = 2,
|
||||
MaxDeliveryDays = 3,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new ShippingRate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Royal Mail Small Parcel",
|
||||
Description = "For items up to 2kg",
|
||||
Country = "United Kingdom",
|
||||
MinWeight = 100,
|
||||
MaxWeight = 2000,
|
||||
Price = 4.99m,
|
||||
MinDeliveryDays = 1,
|
||||
MaxDeliveryDays = 3,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new ShippingRate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Royal Mail Medium Parcel",
|
||||
Description = "For items 2kg to 10kg",
|
||||
Country = "United Kingdom",
|
||||
MinWeight = 2000,
|
||||
MaxWeight = 10000,
|
||||
Price = 8.99m,
|
||||
MinDeliveryDays = 1,
|
||||
MaxDeliveryDays = 3,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
},
|
||||
new ShippingRate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Express Delivery",
|
||||
Description = "Guaranteed next day delivery",
|
||||
Country = "United Kingdom",
|
||||
MinWeight = 0,
|
||||
MaxWeight = 30000,
|
||||
Price = 14.99m,
|
||||
MinDeliveryDays = 1,
|
||||
MaxDeliveryDays = 1,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
_context.ShippingRates.AddRange(shippingRates);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Created {Count} shipping rates", shippingRates.Count);
|
||||
|
||||
// Create Sample Orders with different statuses
|
||||
var orders = new List<Order>
|
||||
{
|
||||
// Order 1: Pending Payment
|
||||
new Order
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IdentityReference = "CUST001",
|
||||
Status = OrderStatus.PendingPayment,
|
||||
TotalAmount = 109.98m,
|
||||
Currency = "GBP",
|
||||
ShippingName = "John Smith",
|
||||
ShippingAddress = "123 High Street",
|
||||
ShippingCity = "London",
|
||||
ShippingPostCode = "SW1A 1AA",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Notes = "Please leave with neighbor if not home",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-5),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-5)
|
||||
},
|
||||
// Order 2: Payment Received
|
||||
new Order
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IdentityReference = "CUST002",
|
||||
Status = OrderStatus.PaymentReceived,
|
||||
TotalAmount = 44.98m,
|
||||
Currency = "GBP",
|
||||
ShippingName = "Sarah Johnson",
|
||||
ShippingAddress = "456 Oak Avenue",
|
||||
ShippingCity = "Manchester",
|
||||
ShippingPostCode = "M1 2AB",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Notes = null,
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-3),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-2),
|
||||
PaidAt = DateTime.UtcNow.AddDays(-2)
|
||||
},
|
||||
// Order 3: Processing
|
||||
new Order
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IdentityReference = "CUST003",
|
||||
Status = OrderStatus.Processing,
|
||||
TotalAmount = 84.98m,
|
||||
Currency = "GBP",
|
||||
ShippingName = "Michael Brown",
|
||||
ShippingAddress = "789 Park Lane",
|
||||
ShippingCity = "Birmingham",
|
||||
ShippingPostCode = "B1 1AA",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Notes = "Gift wrapping requested",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-4),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-1),
|
||||
PaidAt = DateTime.UtcNow.AddDays(-3)
|
||||
},
|
||||
// Order 4: Shipped
|
||||
new Order
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IdentityReference = "CUST004",
|
||||
Status = OrderStatus.Shipped,
|
||||
TotalAmount = 79.98m,
|
||||
Currency = "GBP",
|
||||
ShippingName = "Emma Wilson",
|
||||
ShippingAddress = "321 Queen Street",
|
||||
ShippingCity = "Liverpool",
|
||||
ShippingPostCode = "L1 1AA",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Notes = "Express delivery",
|
||||
TrackingNumber = "RM123456789GB",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-7),
|
||||
UpdatedAt = DateTime.UtcNow.AddHours(-12),
|
||||
PaidAt = DateTime.UtcNow.AddDays(-6),
|
||||
ShippedAt = DateTime.UtcNow.AddHours(-12)
|
||||
},
|
||||
// Order 5: Delivered
|
||||
new Order
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IdentityReference = "CUST005",
|
||||
Status = OrderStatus.Delivered,
|
||||
TotalAmount = 34.99m,
|
||||
Currency = "GBP",
|
||||
ShippingName = "David Taylor",
|
||||
ShippingAddress = "555 Castle Road",
|
||||
ShippingCity = "Edinburgh",
|
||||
ShippingPostCode = "EH1 1AA",
|
||||
ShippingCountry = "United Kingdom",
|
||||
Notes = null,
|
||||
TrackingNumber = "RM987654321GB",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-10),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-2),
|
||||
PaidAt = DateTime.UtcNow.AddDays(-9),
|
||||
ShippedAt = DateTime.UtcNow.AddDays(-7)
|
||||
}
|
||||
};
|
||||
|
||||
_context.Orders.AddRange(orders);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Created {Count} orders", orders.Count);
|
||||
|
||||
// Create Order Items
|
||||
var orderItems = new List<OrderItem>
|
||||
{
|
||||
// Order 1 items
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[0].Id,
|
||||
ProductId = products[0].Id, // Wireless Headphones
|
||||
Quantity = 1,
|
||||
UnitPrice = 89.99m,
|
||||
TotalPrice = 89.99m
|
||||
},
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[0].Id,
|
||||
ProductId = products[1].Id, // Smartphone Case
|
||||
Quantity = 1,
|
||||
UnitPrice = 19.99m,
|
||||
TotalPrice = 19.99m
|
||||
},
|
||||
// Order 2 items
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[1].Id,
|
||||
ProductId = products[2].Id, // T-Shirt
|
||||
Quantity = 1,
|
||||
UnitPrice = 24.99m,
|
||||
TotalPrice = 24.99m
|
||||
},
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[1].Id,
|
||||
ProductId = products[1].Id, // Smartphone Case
|
||||
Quantity = 1,
|
||||
UnitPrice = 19.99m,
|
||||
TotalPrice = 19.99m
|
||||
},
|
||||
// Order 3 items
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[2].Id,
|
||||
ProductId = products[2].Id, // T-Shirt
|
||||
Quantity = 2,
|
||||
UnitPrice = 24.99m,
|
||||
TotalPrice = 49.98m
|
||||
},
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[2].Id,
|
||||
ProductId = products[4].Id, // Programming Book
|
||||
Quantity = 1,
|
||||
UnitPrice = 34.99m,
|
||||
TotalPrice = 34.99m
|
||||
},
|
||||
// Order 4 items
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[3].Id,
|
||||
ProductId = products[3].Id, // Jeans
|
||||
Quantity = 1,
|
||||
UnitPrice = 59.99m,
|
||||
TotalPrice = 59.99m
|
||||
},
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[3].Id,
|
||||
ProductId = products[1].Id, // Smartphone Case
|
||||
Quantity = 1,
|
||||
UnitPrice = 19.99m,
|
||||
TotalPrice = 19.99m
|
||||
},
|
||||
// Order 5 items
|
||||
new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[4].Id,
|
||||
ProductId = products[4].Id, // Programming Book
|
||||
Quantity = 1,
|
||||
UnitPrice = 34.99m,
|
||||
TotalPrice = 34.99m
|
||||
}
|
||||
};
|
||||
|
||||
_context.OrderItems.AddRange(orderItems);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Created {Count} order items", orderItems.Count);
|
||||
|
||||
// Create sample crypto payments for some orders
|
||||
var payments = new List<CryptoPayment>
|
||||
{
|
||||
// Payment for Order 2 (Paid)
|
||||
new CryptoPayment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[1].Id,
|
||||
Currency = CryptoCurrency.BTC,
|
||||
WalletAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
|
||||
RequiredAmount = 0.00089m,
|
||||
PaidAmount = 0.00089m,
|
||||
Status = PaymentStatus.Paid,
|
||||
BTCPayInvoiceId = "INV001",
|
||||
TransactionHash = "3a1b9e330afbe003e0f8c7d0e3c3f7e3a1b9e330afbe003e0",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-2).AddHours(-1),
|
||||
PaidAt = DateTime.UtcNow.AddDays(-2),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(-1)
|
||||
},
|
||||
// Payment for Order 3 (Paid)
|
||||
new CryptoPayment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[2].Id,
|
||||
Currency = CryptoCurrency.XMR,
|
||||
WalletAddress = "4AdUndXHHZ6cfufTMvppY6JwXNb9b1LoaGain57XbP",
|
||||
RequiredAmount = 0.45m,
|
||||
PaidAmount = 0.45m,
|
||||
Status = PaymentStatus.Paid,
|
||||
BTCPayInvoiceId = "INV002",
|
||||
TransactionHash = "7c4b5e440bfce113f1f9c8d1f4e4f8e7c4b5e440bfce113f1",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-3).AddHours(-2),
|
||||
PaidAt = DateTime.UtcNow.AddDays(-3),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(-2)
|
||||
},
|
||||
// Payment for Order 4 (Paid)
|
||||
new CryptoPayment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orders[3].Id,
|
||||
Currency = CryptoCurrency.USDT,
|
||||
WalletAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7",
|
||||
RequiredAmount = 79.98m,
|
||||
PaidAmount = 79.98m,
|
||||
Status = PaymentStatus.Paid,
|
||||
BTCPayInvoiceId = "INV003",
|
||||
TransactionHash = "0x9f2e5b550afe223c5e1f9c9d2f5e5f9f2e5b550afe223c5e1",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-6).AddHours(-1),
|
||||
PaidAt = DateTime.UtcNow.AddDays(-6),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(-5)
|
||||
}
|
||||
};
|
||||
|
||||
_context.CryptoPayments.AddRange(payments);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Created {Count} crypto payments", payments.Count);
|
||||
|
||||
_logger.LogInformation("Sample data seeding completed successfully!");
|
||||
}
|
||||
}
|
||||
@ -11,5 +11,7 @@ public interface IProductService
|
||||
Task<bool> UpdateProductAsync(Guid id, UpdateProductDto updateProductDto);
|
||||
Task<bool> DeleteProductAsync(Guid id);
|
||||
Task<bool> AddProductPhotoAsync(Guid productId, IFormFile file, string? altText = null);
|
||||
Task<ProductPhotoDto?> AddProductPhotoAsync(CreateProductPhotoDto photoDto);
|
||||
Task<bool> RemoveProductPhotoAsync(Guid productId, Guid photoId);
|
||||
Task<IEnumerable<ProductDto>> SearchProductsAsync(string searchTerm);
|
||||
}
|
||||
@ -20,9 +20,9 @@ public class OrderService : IOrderService
|
||||
public async Task<IEnumerable<OrderDto>> GetAllOrdersAsync()
|
||||
{
|
||||
var orders = await _context.Orders
|
||||
.Include(o => o.OrderItems)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.CryptoPayments)
|
||||
.Include(o => o.Payments)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
@ -32,9 +32,9 @@ public class OrderService : IOrderService
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersByIdentityAsync(string identityReference)
|
||||
{
|
||||
var orders = await _context.Orders
|
||||
.Include(o => o.OrderItems)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.CryptoPayments)
|
||||
.Include(o => o.Payments)
|
||||
.Where(o => o.IdentityReference == identityReference)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync();
|
||||
@ -45,9 +45,9 @@ public class OrderService : IOrderService
|
||||
public async Task<OrderDto?> GetOrderByIdAsync(Guid id)
|
||||
{
|
||||
var order = await _context.Orders
|
||||
.Include(o => o.OrderItems)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.CryptoPayments)
|
||||
.Include(o => o.Payments)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
return order == null ? null : MapToDto(order);
|
||||
@ -66,14 +66,20 @@ public class OrderService : IOrderService
|
||||
Status = OrderStatus.PendingPayment,
|
||||
TotalAmount = 0,
|
||||
Currency = "GBP",
|
||||
ShippingName = createOrderDto.ShippingName,
|
||||
ShippingAddress = createOrderDto.ShippingAddress,
|
||||
ShippingCity = createOrderDto.ShippingCity,
|
||||
ShippingPostCode = createOrderDto.ShippingPostCode,
|
||||
ShippingCountry = createOrderDto.ShippingCountry,
|
||||
Notes = createOrderDto.Notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Orders.Add(order);
|
||||
|
||||
decimal totalAmount = 0;
|
||||
foreach (var itemDto in createOrderDto.OrderItems)
|
||||
foreach (var itemDto in createOrderDto.Items)
|
||||
{
|
||||
var product = await _context.Products.FindAsync(itemDto.ProductId);
|
||||
if (product == null || !product.IsActive)
|
||||
@ -87,8 +93,8 @@ public class OrderService : IOrderService
|
||||
OrderId = order.Id,
|
||||
ProductId = itemDto.ProductId,
|
||||
Quantity = itemDto.Quantity,
|
||||
UnitPrice = product.BasePrice,
|
||||
TotalPrice = product.BasePrice * itemDto.Quantity
|
||||
UnitPrice = product.Price,
|
||||
TotalPrice = product.Price * itemDto.Quantity
|
||||
};
|
||||
|
||||
_context.OrderItems.Add(orderItem);
|
||||
@ -135,6 +141,8 @@ public class OrderService : IOrderService
|
||||
order.ShippedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated order {OrderId} status to {Status}", id, updateOrderStatusDto.Status);
|
||||
@ -154,6 +162,7 @@ public class OrderService : IOrderService
|
||||
}
|
||||
|
||||
order.Status = OrderStatus.Cancelled;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Cancelled order {OrderId} by identity {Identity}", id, identityReference);
|
||||
@ -170,12 +179,18 @@ public class OrderService : IOrderService
|
||||
Status = order.Status,
|
||||
TotalAmount = order.TotalAmount,
|
||||
Currency = order.Currency,
|
||||
ShippingName = order.ShippingName,
|
||||
ShippingAddress = order.ShippingAddress,
|
||||
ShippingCity = order.ShippingCity,
|
||||
ShippingPostCode = order.ShippingPostCode,
|
||||
ShippingCountry = order.ShippingCountry,
|
||||
Notes = order.Notes,
|
||||
TrackingNumber = order.TrackingNumber,
|
||||
CreatedAt = order.CreatedAt,
|
||||
UpdatedAt = order.UpdatedAt,
|
||||
PaidAt = order.PaidAt,
|
||||
ShippedAt = order.ShippedAt,
|
||||
OrderItems = order.OrderItems.Select(oi => new OrderItemDto
|
||||
Items = order.Items.Select(oi => new OrderItemDto
|
||||
{
|
||||
Id = oi.Id,
|
||||
ProductId = oi.ProductId,
|
||||
@ -184,7 +199,7 @@ public class OrderService : IOrderService
|
||||
UnitPrice = oi.UnitPrice,
|
||||
TotalPrice = oi.TotalPrice
|
||||
}).ToList(),
|
||||
CryptoPayments = order.CryptoPayments.Select(cp => new CryptoPaymentDto
|
||||
Payments = order.Payments.Select(cp => new CryptoPaymentDto
|
||||
{
|
||||
Id = cp.Id,
|
||||
OrderId = cp.OrderId,
|
||||
@ -27,12 +27,13 @@ public class ProductService : IProductService
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
ProductWeightUnit = p.ProductWeightUnit,
|
||||
ProductWeight = p.ProductWeight,
|
||||
BasePrice = p.BasePrice,
|
||||
Price = p.Price,
|
||||
Weight = p.Weight,
|
||||
WeightUnit = p.WeightUnit,
|
||||
CategoryId = p.CategoryId,
|
||||
CategoryName = p.Category.Name,
|
||||
CreatedAt = p.CreatedAt,
|
||||
UpdatedAt = p.UpdatedAt,
|
||||
IsActive = p.IsActive,
|
||||
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
|
||||
{
|
||||
@ -57,12 +58,13 @@ public class ProductService : IProductService
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
ProductWeightUnit = p.ProductWeightUnit,
|
||||
ProductWeight = p.ProductWeight,
|
||||
BasePrice = p.BasePrice,
|
||||
Price = p.Price,
|
||||
Weight = p.Weight,
|
||||
WeightUnit = p.WeightUnit,
|
||||
CategoryId = p.CategoryId,
|
||||
CategoryName = p.Category.Name,
|
||||
CreatedAt = p.CreatedAt,
|
||||
UpdatedAt = p.UpdatedAt,
|
||||
IsActive = p.IsActive,
|
||||
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
|
||||
{
|
||||
@ -90,12 +92,13 @@ public class ProductService : IProductService
|
||||
Id = product.Id,
|
||||
Name = product.Name,
|
||||
Description = product.Description,
|
||||
ProductWeightUnit = product.ProductWeightUnit,
|
||||
ProductWeight = product.ProductWeight,
|
||||
BasePrice = product.BasePrice,
|
||||
Price = product.Price,
|
||||
Weight = product.Weight,
|
||||
WeightUnit = product.WeightUnit,
|
||||
CategoryId = product.CategoryId,
|
||||
CategoryName = product.Category.Name,
|
||||
CreatedAt = product.CreatedAt,
|
||||
UpdatedAt = product.UpdatedAt,
|
||||
IsActive = product.IsActive,
|
||||
Photos = product.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
|
||||
{
|
||||
@ -115,11 +118,12 @@ public class ProductService : IProductService
|
||||
Id = Guid.NewGuid(),
|
||||
Name = createProductDto.Name,
|
||||
Description = createProductDto.Description,
|
||||
ProductWeightUnit = createProductDto.ProductWeightUnit,
|
||||
ProductWeight = createProductDto.ProductWeight,
|
||||
BasePrice = createProductDto.BasePrice,
|
||||
Price = createProductDto.Price,
|
||||
Weight = createProductDto.Weight,
|
||||
WeightUnit = createProductDto.WeightUnit,
|
||||
CategoryId = createProductDto.CategoryId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
@ -133,12 +137,13 @@ public class ProductService : IProductService
|
||||
Id = product.Id,
|
||||
Name = product.Name,
|
||||
Description = product.Description,
|
||||
ProductWeightUnit = product.ProductWeightUnit,
|
||||
ProductWeight = product.ProductWeight,
|
||||
BasePrice = product.BasePrice,
|
||||
Price = product.Price,
|
||||
Weight = product.Weight,
|
||||
WeightUnit = product.WeightUnit,
|
||||
CategoryId = product.CategoryId,
|
||||
CategoryName = category?.Name ?? "",
|
||||
CreatedAt = product.CreatedAt,
|
||||
UpdatedAt = product.UpdatedAt,
|
||||
IsActive = product.IsActive,
|
||||
Photos = new List<ProductPhotoDto>()
|
||||
};
|
||||
@ -155,14 +160,14 @@ public class ProductService : IProductService
|
||||
if (!string.IsNullOrEmpty(updateProductDto.Description))
|
||||
product.Description = updateProductDto.Description;
|
||||
|
||||
if (updateProductDto.ProductWeightUnit.HasValue)
|
||||
product.ProductWeightUnit = updateProductDto.ProductWeightUnit.Value;
|
||||
if (updateProductDto.Price.HasValue)
|
||||
product.Price = updateProductDto.Price.Value;
|
||||
|
||||
if (updateProductDto.ProductWeight.HasValue)
|
||||
product.ProductWeight = updateProductDto.ProductWeight.Value;
|
||||
if (updateProductDto.Weight.HasValue)
|
||||
product.Weight = updateProductDto.Weight.Value;
|
||||
|
||||
if (updateProductDto.BasePrice.HasValue)
|
||||
product.BasePrice = updateProductDto.BasePrice.Value;
|
||||
if (updateProductDto.WeightUnit.HasValue)
|
||||
product.WeightUnit = updateProductDto.WeightUnit.Value;
|
||||
|
||||
if (updateProductDto.CategoryId.HasValue)
|
||||
product.CategoryId = updateProductDto.CategoryId.Value;
|
||||
@ -170,6 +175,8 @@ public class ProductService : IProductService
|
||||
if (updateProductDto.IsActive.HasValue)
|
||||
product.IsActive = updateProductDto.IsActive.Value;
|
||||
|
||||
product.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
@ -202,9 +209,8 @@ public class ProductService : IProductService
|
||||
|
||||
var maxSortOrder = await _context.ProductPhotos
|
||||
.Where(pp => pp.ProductId == productId)
|
||||
.Select(pp => pp.SortOrder)
|
||||
.DefaultIfEmpty(0)
|
||||
.MaxAsync();
|
||||
.Select(pp => (int?)pp.SortOrder)
|
||||
.MaxAsync() ?? 0;
|
||||
|
||||
var productPhoto = new ProductPhoto
|
||||
{
|
||||
@ -239,4 +245,78 @@ public class ProductService : IProductService
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ProductPhotoDto?> AddProductPhotoAsync(CreateProductPhotoDto photoDto)
|
||||
{
|
||||
var product = await _context.Products.FindAsync(photoDto.ProductId);
|
||||
if (product == null) return null;
|
||||
|
||||
var maxSortOrder = await _context.ProductPhotos
|
||||
.Where(pp => pp.ProductId == photoDto.ProductId)
|
||||
.Select(pp => pp.SortOrder)
|
||||
.DefaultIfEmpty(0)
|
||||
.MaxAsync();
|
||||
|
||||
var productPhoto = new ProductPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProductId = photoDto.ProductId,
|
||||
FileName = Path.GetFileName(photoDto.PhotoUrl),
|
||||
FilePath = photoDto.PhotoUrl,
|
||||
AltText = photoDto.AltText,
|
||||
SortOrder = photoDto.DisplayOrder > 0 ? photoDto.DisplayOrder : maxSortOrder + 1,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.ProductPhotos.Add(productPhoto);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return new ProductPhotoDto
|
||||
{
|
||||
Id = productPhoto.Id,
|
||||
FileName = productPhoto.FileName,
|
||||
FilePath = productPhoto.FilePath,
|
||||
AltText = productPhoto.AltText,
|
||||
SortOrder = productPhoto.SortOrder
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ProductDto>> SearchProductsAsync(string searchTerm)
|
||||
{
|
||||
var query = _context.Products
|
||||
.Include(p => p.Category)
|
||||
.Include(p => p.Photos)
|
||||
.Where(p => p.IsActive);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
searchTerm = searchTerm.ToLower();
|
||||
query = query.Where(p =>
|
||||
p.Name.ToLower().Contains(searchTerm) ||
|
||||
p.Description.ToLower().Contains(searchTerm));
|
||||
}
|
||||
|
||||
return await query.Select(p => new ProductDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
Price = p.Price,
|
||||
Weight = p.Weight,
|
||||
WeightUnit = p.WeightUnit,
|
||||
CategoryId = p.CategoryId,
|
||||
CategoryName = p.Category.Name,
|
||||
CreatedAt = p.CreatedAt,
|
||||
UpdatedAt = p.UpdatedAt,
|
||||
IsActive = p.IsActive,
|
||||
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
|
||||
{
|
||||
Id = ph.Id,
|
||||
FileName = ph.FileName,
|
||||
FilePath = ph.FilePath,
|
||||
AltText = ph.AltText,
|
||||
SortOrder = ph.SortOrder
|
||||
}).ToList()
|
||||
}).ToListAsync();
|
||||
}
|
||||
}
|
||||
142
LittleShop/Services/ShippingRateService.cs
Normal file
142
LittleShop/Services/ShippingRateService.cs
Normal file
@ -0,0 +1,142 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.Models;
|
||||
using LittleShop.DTOs;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
public interface IShippingRateService
|
||||
{
|
||||
Task<IEnumerable<ShippingRateDto>> GetAllShippingRatesAsync();
|
||||
Task<ShippingRateDto?> GetShippingRateByIdAsync(Guid id);
|
||||
Task<ShippingRateDto> CreateShippingRateAsync(CreateShippingRateDto dto);
|
||||
Task<bool> UpdateShippingRateAsync(Guid id, UpdateShippingRateDto dto);
|
||||
Task<bool> DeleteShippingRateAsync(Guid id);
|
||||
Task<ShippingRateDto?> CalculateShippingAsync(decimal weight, string country);
|
||||
}
|
||||
|
||||
public class ShippingRateService : IShippingRateService
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly ILogger<ShippingRateService> _logger;
|
||||
|
||||
public ShippingRateService(LittleShopContext context, ILogger<ShippingRateService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ShippingRateDto>> GetAllShippingRatesAsync()
|
||||
{
|
||||
var rates = await _context.ShippingRates
|
||||
.OrderBy(sr => sr.Country)
|
||||
.ToListAsync();
|
||||
|
||||
// Sort by MinWeight in memory to avoid SQLite decimal ordering issue
|
||||
return rates.OrderBy(sr => sr.MinWeight).Select(MapToDto);
|
||||
}
|
||||
|
||||
public async Task<ShippingRateDto?> GetShippingRateByIdAsync(Guid id)
|
||||
{
|
||||
var rate = await _context.ShippingRates.FindAsync(id);
|
||||
return rate == null ? null : MapToDto(rate);
|
||||
}
|
||||
|
||||
public async Task<ShippingRateDto> CreateShippingRateAsync(CreateShippingRateDto dto)
|
||||
{
|
||||
var rate = new ShippingRate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
Country = dto.Country,
|
||||
MinWeight = dto.MinWeight,
|
||||
MaxWeight = dto.MaxWeight,
|
||||
Price = dto.Price,
|
||||
MinDeliveryDays = dto.MinDeliveryDays,
|
||||
MaxDeliveryDays = dto.MaxDeliveryDays,
|
||||
IsActive = dto.IsActive,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.ShippingRates.Add(rate);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created shipping rate {RateId} for {Country}: {Name}",
|
||||
rate.Id, rate.Country, rate.Name);
|
||||
|
||||
return MapToDto(rate);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateShippingRateAsync(Guid id, UpdateShippingRateDto dto)
|
||||
{
|
||||
var rate = await _context.ShippingRates.FindAsync(id);
|
||||
if (rate == null)
|
||||
return false;
|
||||
|
||||
rate.Name = dto.Name;
|
||||
rate.Description = dto.Description;
|
||||
rate.Country = dto.Country;
|
||||
rate.MinWeight = dto.MinWeight;
|
||||
rate.MaxWeight = dto.MaxWeight;
|
||||
rate.Price = dto.Price;
|
||||
rate.MinDeliveryDays = dto.MinDeliveryDays;
|
||||
rate.MaxDeliveryDays = dto.MaxDeliveryDays;
|
||||
rate.IsActive = dto.IsActive;
|
||||
rate.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated shipping rate {RateId}", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteShippingRateAsync(Guid id)
|
||||
{
|
||||
var rate = await _context.ShippingRates.FindAsync(id);
|
||||
if (rate == null)
|
||||
return false;
|
||||
|
||||
_context.ShippingRates.Remove(rate);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Deleted shipping rate {RateId}", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ShippingRateDto?> CalculateShippingAsync(decimal weight, string country)
|
||||
{
|
||||
// Convert weight to grams for comparison
|
||||
var weightInGrams = weight * 1000; // Assuming weight is in kg
|
||||
|
||||
var rate = await _context.ShippingRates
|
||||
.Where(sr => sr.IsActive
|
||||
&& sr.Country.ToLower() == country.ToLower()
|
||||
&& sr.MinWeight <= weightInGrams
|
||||
&& sr.MaxWeight >= weightInGrams)
|
||||
.OrderBy(sr => sr.Price)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return rate == null ? null : MapToDto(rate);
|
||||
}
|
||||
|
||||
private static ShippingRateDto MapToDto(ShippingRate rate)
|
||||
{
|
||||
return new ShippingRateDto
|
||||
{
|
||||
Id = rate.Id,
|
||||
Name = rate.Name,
|
||||
Description = rate.Description,
|
||||
Country = rate.Country,
|
||||
MinWeight = rate.MinWeight,
|
||||
MaxWeight = rate.MaxWeight,
|
||||
Price = rate.Price,
|
||||
MinDeliveryDays = rate.MinDeliveryDays,
|
||||
MaxDeliveryDays = rate.MaxDeliveryDays,
|
||||
IsActive = rate.IsActive,
|
||||
CreatedAt = rate.CreatedAt,
|
||||
UpdatedAt = rate.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -11,10 +11,25 @@ public class CreateOrderDtoValidator : AbstractValidator<CreateOrderDto>
|
||||
.NotEmpty().WithMessage("Identity reference is required")
|
||||
.MaximumLength(100).WithMessage("Identity reference cannot exceed 100 characters");
|
||||
|
||||
RuleFor(x => x.OrderItems)
|
||||
RuleFor(x => x.ShippingName)
|
||||
.NotEmpty().WithMessage("Shipping name is required");
|
||||
|
||||
RuleFor(x => x.ShippingAddress)
|
||||
.NotEmpty().WithMessage("Shipping address is required");
|
||||
|
||||
RuleFor(x => x.ShippingCity)
|
||||
.NotEmpty().WithMessage("Shipping city is required");
|
||||
|
||||
RuleFor(x => x.ShippingPostCode)
|
||||
.NotEmpty().WithMessage("Shipping post code is required");
|
||||
|
||||
RuleFor(x => x.ShippingCountry)
|
||||
.NotEmpty().WithMessage("Shipping country is required");
|
||||
|
||||
RuleFor(x => x.Items)
|
||||
.NotEmpty().WithMessage("Order must contain at least one item");
|
||||
|
||||
RuleForEach(x => x.OrderItems).SetValidator(new CreateOrderItemDtoValidator());
|
||||
RuleForEach(x => x.Items).SetValidator(new CreateOrderItemDtoValidator());
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,11 +14,11 @@ public class CreateProductDtoValidator : AbstractValidator<CreateProductDto>
|
||||
RuleFor(x => x.Description)
|
||||
.NotEmpty().WithMessage("Product description is required");
|
||||
|
||||
RuleFor(x => x.BasePrice)
|
||||
.GreaterThan(0).WithMessage("Base price must be greater than 0");
|
||||
RuleFor(x => x.Price)
|
||||
.GreaterThan(0).WithMessage("Price must be greater than 0");
|
||||
|
||||
RuleFor(x => x.ProductWeight)
|
||||
.GreaterThan(0).WithMessage("Product weight must be greater than 0");
|
||||
RuleFor(x => x.Weight)
|
||||
.GreaterThan(0).WithMessage("Weight must be greater than 0");
|
||||
|
||||
RuleFor(x => x.CategoryId)
|
||||
.NotEmpty().WithMessage("Category is required");
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
@ -0,0 +1 @@
|
||||
test image content
|
||||
47
TeleBot/TeleBot/BotScript.cs
Normal file
47
TeleBot/TeleBot/BotScript.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TeleBot
|
||||
{
|
||||
public class BotScript
|
||||
{
|
||||
public string WelcomeText { get; set; }
|
||||
public Dictionary<Guid, BotOption> Questions { get; internal set; } = new Dictionary<Guid, BotOption>();
|
||||
public Dictionary<Guid, string> Answers { get; internal set; } = new Dictionary<Guid, string>();
|
||||
public int Stage { get; set; }
|
||||
|
||||
public static BotScript CreateBotScript(string welcomeText)
|
||||
{
|
||||
var bs = new BotScript();
|
||||
bs.WelcomeText = welcomeText;
|
||||
|
||||
return bs;
|
||||
}
|
||||
|
||||
public void AddScaledQuestion(string question)
|
||||
{
|
||||
AddQuestion(question, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]);
|
||||
}
|
||||
public void AddQuestion(string question, string[] answers)
|
||||
{
|
||||
var q = new BotOption();
|
||||
q.Order = Questions.Count() + 1;
|
||||
q.Text = question;
|
||||
q.Options = answers;
|
||||
Questions.Add(q.Id,q);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class BotOption
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public int Order { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string[] Options { get; set; }
|
||||
}
|
||||
}
|
||||
12
TeleBot/TeleBot/Program.cs
Normal file
12
TeleBot/TeleBot/Program.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace TeleBot
|
||||
{
|
||||
internal class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("Hello, World!");
|
||||
var bot = new TelgramBotService();
|
||||
await bot.Startup();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
TeleBot/TeleBot/TeleBot.csproj
Normal file
14
TeleBot/TeleBot/TeleBot.csproj
Normal file
@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Telegram.Bot" Version="22.5.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
133
TeleBot/TeleBot/TelegramBot.cs
Normal file
133
TeleBot/TeleBot/TelegramBot.cs
Normal file
@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TeleBot
|
||||
{
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Polling;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Exceptions;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
public class TelgramBotService
|
||||
{
|
||||
private readonly string BotToken = "7880403661:AAGma1wAyoHsmG45iO6VvHCqzimhJX1pp14";
|
||||
|
||||
public BotScript Master { get; set; }
|
||||
public Dictionary<long, BotScript> Chats { get; set; } = new Dictionary<long, BotScript>();
|
||||
|
||||
public async Task Startup()
|
||||
{
|
||||
|
||||
if (Master == null)
|
||||
{
|
||||
Master = BotScript.CreateBotScript("Anonymous Feedback Survey for The Sweetshop \r\n\r\nWe’d love to hear your thoughts so we can improve your experience and keep the shop evolving in the best way possible.");
|
||||
Master.AddScaledQuestion("How would you rate communication at the shop (including updates, clarity, and friendliness)?\r\n(1 = Poor | 10 = Excellent)");
|
||||
Master.AddScaledQuestion("How would you rate the quality of the products?\r\n(1 = Poor | 10 = Excellent)");
|
||||
}
|
||||
|
||||
var botClient = new TelegramBotClient(BotToken);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var receiverOptions = new ReceiverOptions
|
||||
{
|
||||
AllowedUpdates = Array.Empty<UpdateType>() // Receive all updates
|
||||
};
|
||||
|
||||
botClient.StartReceiving(
|
||||
HandleUpdateAsync,
|
||||
HandleErrorAsync,
|
||||
receiverOptions,
|
||||
cancellationToken: cts.Token
|
||||
);
|
||||
|
||||
var me = await botClient.GetMe();
|
||||
Console.WriteLine($"Bot started: {me.Username}");
|
||||
Console.ReadLine();
|
||||
cts.Cancel();
|
||||
}
|
||||
|
||||
private async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
|
||||
{
|
||||
if (update.Message is { } message)
|
||||
{
|
||||
|
||||
var chatId = message.Chat.Id;
|
||||
if (!Chats.ContainsKey(chatId))
|
||||
{
|
||||
var s = Master;
|
||||
Chats.Add(chatId, s);
|
||||
}
|
||||
|
||||
//if (message.Text == "/start")
|
||||
//{
|
||||
await ProcessMessage(botClient, chatId, cancellationToken);
|
||||
//var responseText = $"Hello, {message.From?.FirstName}! You said: {message.Text}";
|
||||
//await botClient.SendMessage(chatId, responseText, cancellationToken: cancellationToken);
|
||||
}
|
||||
else if (update.CallbackQuery is { } callbackQuery)
|
||||
{
|
||||
var data = callbackQuery.Data?.Split(':');
|
||||
var chatId = callbackQuery.Message.Chat.Id;
|
||||
var aID = Guid.Parse(data[0]);
|
||||
Chats[chatId].Answers.Add(aID, data[1]);
|
||||
|
||||
var response = $"Thank for choosing: {data[1]} in response to '{Chats[chatId].Questions.First(x => x.Key == aID).Value.Text}'";
|
||||
await botClient.SendMessage(callbackQuery.Message.Chat.Id, response, cancellationToken: cancellationToken);
|
||||
Chats[chatId].Stage++;
|
||||
if (Chats[chatId].Stage > Chats[chatId].Questions.Count)
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Thank you for completing our questions, we appreciete your feedback!", cancellationToken: cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ProcessMessage(botClient, chatId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessMessage(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Chats[chatId].Stage > Chats[chatId].Questions.Count)
|
||||
{
|
||||
await botClient.SendMessage(chatId, "You have already completed the questionaire. Thank you for your feedback.", cancellationToken: cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (Chats[chatId].Stage)
|
||||
{
|
||||
case 0:
|
||||
await botClient.SendMessage(chatId, Chats[chatId].WelcomeText, cancellationToken: cancellationToken);
|
||||
Chats[chatId].Stage++;
|
||||
break;
|
||||
default:
|
||||
var q = Chats[chatId].Questions.OrderBy(x => x.Value.Order).Skip(Chats[chatId].Stage - 1).Take(1).FirstOrDefault();
|
||||
var opts = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
q.Value.Options.Take(5).Select(x => InlineKeyboardButton.WithCallbackData(x, $"{q.Key}:{x}")),
|
||||
q.Value.Options.Skip(5).Select(x => InlineKeyboardButton.WithCallbackData(x, $"{q.Key}:{x}"))
|
||||
});
|
||||
await botClient.SendMessage(chatId, q.Value.Text, replyMarkup: opts, cancellationToken: cancellationToken);
|
||||
|
||||
opts = new InlineKeyboardMarkup(q.Value.Options.Skip(5).Select(x => InlineKeyboardButton.WithCallbackData(x, $"{q.Key}:{x}")));
|
||||
await botClient.SendMessage(chatId, "", replyMarkup: opts, cancellationToken: cancellationToken);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine($"Error: {exception.Message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
TeleBot/TeleBotClient/Program.cs
Normal file
96
TeleBot/TeleBotClient/Program.cs
Normal file
@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
class TelegramClient
|
||||
{
|
||||
private static readonly string BotToken = "7330819864:AAHx9GEL-G-WeH2ON5-ncdsbbhV6YaOqZzg";
|
||||
private static readonly string ApiUrl = $"https://api.telegram.org/bot{BotToken}/";
|
||||
|
||||
static async Task Main()
|
||||
{
|
||||
Console.WriteLine("Telegram Bot Client Started...");
|
||||
|
||||
while (true)
|
||||
{
|
||||
await ReceiveMessagesAsync();
|
||||
await Task.Delay(5000); // Polling delay
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendMessageAsync(string chatId, string message)
|
||||
{
|
||||
using HttpClient client = new HttpClient();
|
||||
var payload = new
|
||||
{
|
||||
chat_id = chatId,
|
||||
text = message
|
||||
};
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsync(ApiUrl + "sendMessage", content);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Console.WriteLine($"Response: {responseText}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error sending message: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReceiveMessagesAsync()
|
||||
{
|
||||
using HttpClient client = new HttpClient();
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync(ApiUrl + "getUpdates");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
var updates = JsonSerializer.Deserialize<TelegramUpdateResponse>(responseText);
|
||||
|
||||
if (updates?.Result != null)
|
||||
{
|
||||
foreach (var update in updates.Result)
|
||||
{
|
||||
Console.WriteLine($"Received message from {update.Message.Chat.Id}: {update.Message.Text}");
|
||||
await SendMessageAsync(update.Message.Chat.Id.ToString(), "Message received!");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException httpEx)
|
||||
{
|
||||
Console.WriteLine($"HTTP error: {httpEx.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Unexpected error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TelegramUpdateResponse
|
||||
{
|
||||
public TelegramUpdate[] Result { get; set; }
|
||||
}
|
||||
|
||||
class TelegramUpdate
|
||||
{
|
||||
public TelegramMessage Message { get; set; }
|
||||
}
|
||||
|
||||
class TelegramMessage
|
||||
{
|
||||
public TelegramChat Chat { get; set; }
|
||||
public string Text { get; set; }
|
||||
}
|
||||
|
||||
class TelegramChat
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
10
TeleBot/TeleBotClient/TeleBotClient.csproj
Normal file
10
TeleBot/TeleBotClient/TeleBotClient.csproj
Normal file
@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user