Implement complete e-commerce functionality with shipping and order management

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

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

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

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

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

Generated with Claude Code

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

View File

@ -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 });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

82
LittleShop.sln Normal file
View 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

View 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 });
}
}

View File

@ -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
};

View 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));
}
}

View 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>

View File

@ -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">

View 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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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

View 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>

View 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>

View 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>

View File

@ -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;

View File

@ -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);

View File

@ -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
});

View File

@ -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; }
}

View File

@ -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; }
}

View 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
{
}

View File

@ -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);

View File

@ -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
}

View File

@ -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

View File

@ -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>();
}

View File

@ -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!;

View 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; }
}

View File

@ -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 { }

View File

@ -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

View 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!");
}
}

View File

@ -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);
}

View File

@ -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,

View File

@ -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();
}
}

View 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
};
}
}

View File

@ -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());
}
}

View File

@ -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

View File

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

View 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; }
}
}

View 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();
}
}
}

View 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>

View 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\nWed 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;
}
}
}

View 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; }
}

View 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