diff --git a/LittleShop.Client.Example/Program.cs b/LittleShop.Client.Example/Program.cs new file mode 100644 index 0000000..8e71cbf --- /dev/null +++ b/LittleShop.Client.Example/Program.cs @@ -0,0 +1,180 @@ +using LittleShop.Client; +using LittleShop.Client.Extensions; +using LittleShop.Client.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +// Setup dependency injection +var services = new ServiceCollection(); + +// Add logging +services.AddLogging(builder => builder + .AddConsole() + .SetMinimumLevel(LogLevel.Information)); + +// Add LittleShop client +services.AddLittleShopClient(options => +{ + options.BaseUrl = "https://localhost:5001"; // Update this to your API URL + options.TimeoutSeconds = 30; + options.MaxRetryAttempts = 3; + options.EnableLogging = true; +}); + +// Build service provider +var serviceProvider = services.BuildServiceProvider(); + +// Get the client +var client = serviceProvider.GetRequiredService(); + +Console.WriteLine("=== LittleShop Client Example ===\n"); + +try +{ + // Step 1: Authenticate + Console.WriteLine("1. Authenticating..."); + var loginResult = await client.Authentication.LoginAsync("admin", "admin"); + + if (!loginResult.IsSuccess) + { + Console.WriteLine($"Login failed: {loginResult.ErrorMessage}"); + return; + } + + Console.WriteLine($"✓ Logged in as: {loginResult.Data!.Username}"); + Console.WriteLine($" Token expires: {loginResult.Data.ExpiresAt}\n"); + + // Step 2: Get Categories + Console.WriteLine("2. Fetching categories..."); + var categoriesResult = await client.Catalog.GetCategoriesAsync(); + + if (categoriesResult.IsSuccess && categoriesResult.Data!.Any()) + { + Console.WriteLine($"✓ Found {categoriesResult.Data.Count} categories:"); + foreach (var category in categoriesResult.Data) + { + Console.WriteLine($" - {category.Name}: {category.Description}"); + } + Console.WriteLine(); + } + + // Step 3: Get Products + Console.WriteLine("3. Fetching products..."); + var productsResult = await client.Catalog.GetProductsAsync( + pageNumber: 1, + pageSize: 5 + ); + + if (productsResult.IsSuccess && productsResult.Data!.Items.Any()) + { + Console.WriteLine($"✓ Found {productsResult.Data.TotalCount} products (showing first {productsResult.Data.Items.Count}):"); + foreach (var product in productsResult.Data.Items) + { + Console.WriteLine($" - {product.Name}"); + Console.WriteLine($" Price: £{product.Price:F2}"); + Console.WriteLine($" Weight: {product.Weight} {product.WeightUnit}"); + Console.WriteLine($" Category: {product.CategoryName}"); + } + Console.WriteLine(); + } + + // Step 4: Create a sample order + Console.WriteLine("4. Creating a sample order..."); + + // Get first product for the order + var firstProduct = productsResult.Data!.Items.FirstOrDefault(); + if (firstProduct != null) + { + var orderRequest = new CreateOrderRequest + { + IdentityReference = "CLIENT_DEMO_001", + ShippingName = "Demo Customer", + ShippingAddress = "123 Test Street", + ShippingCity = "London", + ShippingPostCode = "SW1A 1AA", + ShippingCountry = "United Kingdom", + Notes = "Test order from client SDK", + Items = new List + { + new CreateOrderItem + { + ProductId = firstProduct.Id, + Quantity = 1 + } + } + }; + + var orderResult = await client.Orders.CreateOrderAsync(orderRequest); + + if (orderResult.IsSuccess) + { + Console.WriteLine($"✓ Order created successfully!"); + Console.WriteLine($" Order ID: {orderResult.Data!.Id}"); + Console.WriteLine($" Total: £{orderResult.Data.TotalAmount:F2}"); + Console.WriteLine($" Status: {orderResult.Data.Status}\n"); + + // Step 5: Create a payment for the order + Console.WriteLine("5. Creating Bitcoin payment for the order..."); + var paymentResult = await client.Orders.CreatePaymentAsync(orderResult.Data.Id, "BTC"); + + if (paymentResult.IsSuccess) + { + Console.WriteLine($"✓ Payment created successfully!"); + Console.WriteLine($" Payment ID: {paymentResult.Data!.Id}"); + Console.WriteLine($" Amount: {paymentResult.Data.RequiredAmount} BTC"); + Console.WriteLine($" Send to: {paymentResult.Data.WalletAddress}"); + Console.WriteLine($" Expires: {paymentResult.Data.ExpiresAt}"); + + if (!string.IsNullOrEmpty(paymentResult.Data.BTCPayCheckoutUrl)) + { + Console.WriteLine($" Checkout URL: {paymentResult.Data.BTCPayCheckoutUrl}"); + } + } + else + { + Console.WriteLine($"✗ Payment creation failed: {paymentResult.ErrorMessage}"); + } + } + else + { + Console.WriteLine($"✗ Order creation failed: {orderResult.ErrorMessage}"); + } + } + + // Step 6: Get orders for the identity + Console.WriteLine("\n6. Fetching orders for identity 'CLIENT_DEMO_001'..."); + var ordersResult = await client.Orders.GetOrdersByIdentityAsync("CLIENT_DEMO_001"); + + if (ordersResult.IsSuccess && ordersResult.Data!.Any()) + { + Console.WriteLine($"✓ Found {ordersResult.Data.Count} orders:"); + foreach (var order in ordersResult.Data) + { + Console.WriteLine($" - Order {order.Id}"); + Console.WriteLine($" Status: {order.Status}"); + Console.WriteLine($" Total: £{order.TotalAmount:F2}"); + Console.WriteLine($" Created: {order.CreatedAt}"); + } + } + else + { + Console.WriteLine(" No orders found for this identity."); + } + + Console.WriteLine("\n=== Example completed successfully! ==="); +} +catch (Exception ex) +{ + Console.WriteLine($"\n✗ Error: {ex.Message}"); + if (ex.InnerException != null) + { + Console.WriteLine($" Inner: {ex.InnerException.Message}"); + } +} +finally +{ + // Cleanup + client.Authentication.Logout(); + Console.WriteLine("\nLogged out."); +} \ No newline at end of file diff --git a/LittleShop.Client/Class1.cs b/LittleShop.Client/Class1.cs new file mode 100644 index 0000000..7b137fa --- /dev/null +++ b/LittleShop.Client/Class1.cs @@ -0,0 +1,6 @@ +namespace LittleShop.Client; + +public class Class1 +{ + +} diff --git a/LittleShop.Client/Configuration/LittleShopClientOptions.cs b/LittleShop.Client/Configuration/LittleShopClientOptions.cs new file mode 100644 index 0000000..cb9a2f7 --- /dev/null +++ b/LittleShop.Client/Configuration/LittleShopClientOptions.cs @@ -0,0 +1,10 @@ +namespace LittleShop.Client.Configuration; + +public class LittleShopClientOptions +{ + public string BaseUrl { get; set; } = "https://localhost:5001"; + public int TimeoutSeconds { get; set; } = 30; + public int MaxRetryAttempts { get; set; } = 3; + public bool EnableLogging { get; set; } = true; + public string? ApiKey { get; set; } +} \ No newline at end of file diff --git a/LittleShop.Client/Extensions/ServiceCollectionExtensions.cs b/LittleShop.Client/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d9179a7 --- /dev/null +++ b/LittleShop.Client/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,91 @@ +using LittleShop.Client.Configuration; +using LittleShop.Client.Http; +using LittleShop.Client.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace LittleShop.Client.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddLittleShopClient( + this IServiceCollection services, + Action? configureOptions = null) + { + // Configure options + if (configureOptions != null) + { + services.Configure(configureOptions); + } + else + { + services.Configure(options => { }); + } + + // Register HTTP handlers + services.AddTransient(); + services.AddTransient(); + + // Register main HTTP client + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .AddHttpMessageHandler() + .AddHttpMessageHandler(serviceProvider => + { + var logger = serviceProvider.GetRequiredService>(); + var options = serviceProvider.GetRequiredService>().Value; + return new RetryPolicyHandler(logger, options.MaxRetryAttempts); + }); + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .AddHttpMessageHandler() + .AddHttpMessageHandler(serviceProvider => + { + var logger = serviceProvider.GetRequiredService>(); + var options = serviceProvider.GetRequiredService>().Value; + return new RetryPolicyHandler(logger, options.MaxRetryAttempts); + }); + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .AddHttpMessageHandler() + .AddHttpMessageHandler(serviceProvider => + { + var logger = serviceProvider.GetRequiredService>(); + var options = serviceProvider.GetRequiredService>().Value; + return new RetryPolicyHandler(logger, options.MaxRetryAttempts); + }); + + // Register the main client + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddLittleShopClient( + this IServiceCollection services, + string baseUrl) + { + return services.AddLittleShopClient(options => + { + options.BaseUrl = baseUrl; + }); + } +} \ No newline at end of file diff --git a/LittleShop.Client/Http/ErrorHandlingMiddleware.cs b/LittleShop.Client/Http/ErrorHandlingMiddleware.cs new file mode 100644 index 0000000..ea140d6 --- /dev/null +++ b/LittleShop.Client/Http/ErrorHandlingMiddleware.cs @@ -0,0 +1,99 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace LittleShop.Client.Http; + +public class ErrorHandlingMiddleware : DelegatingHandler +{ + private readonly ILogger _logger; + + public ErrorHandlingMiddleware(ILogger logger) + { + _logger = logger; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + try + { + var response = await base.SendAsync(request, cancellationToken); + + // Log errors but don't throw - let the service layer handle the response + if (!response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogWarning( + "HTTP {StatusCode} for {Method} {Uri}: {Content}", + (int)response.StatusCode, + request.Method, + request.RequestUri, + content); + + // Try to parse error response + if (!string.IsNullOrEmpty(content)) + { + try + { + var errorResponse = JsonSerializer.Deserialize(content, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (errorResponse != null && !string.IsNullOrEmpty(errorResponse.Message)) + { + // Replace content with structured error message + response.Content = new StringContent(errorResponse.Message); + } + } + catch + { + // If we can't parse the error, leave original content + } + } + } + + return response; + } + catch (TaskCanceledException ex) + { + _logger.LogError(ex, "Request timeout for {Method} {Uri}", + request.Method, request.RequestUri); + + return new HttpResponseMessage(HttpStatusCode.RequestTimeout) + { + Content = new StringContent("Request timed out"), + RequestMessage = request + }; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error for {Method} {Uri}", + request.Method, request.RequestUri); + + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent($"Network error: {ex.Message}"), + RequestMessage = request + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error for {Method} {Uri}", + request.Method, request.RequestUri); + + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent($"Unexpected error: {ex.Message}"), + RequestMessage = request + }; + } + } + + private class ErrorResponse + { + public string? Message { get; set; } + public string? Error { get; set; } + public Dictionary? Errors { get; set; } + } +} \ No newline at end of file diff --git a/LittleShop.Client/Http/RetryPolicyHandler.cs b/LittleShop.Client/Http/RetryPolicyHandler.cs new file mode 100644 index 0000000..3a7e944 --- /dev/null +++ b/LittleShop.Client/Http/RetryPolicyHandler.cs @@ -0,0 +1,39 @@ +using System.Net; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; + +namespace LittleShop.Client.Http; + +public class RetryPolicyHandler : DelegatingHandler +{ + private readonly ILogger _logger; + private readonly IAsyncPolicy _retryPolicy; + + public RetryPolicyHandler(ILogger logger, int maxRetryAttempts = 3) + { + _logger = logger; + + _retryPolicy = HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => !msg.IsSuccessStatusCode && msg.StatusCode != HttpStatusCode.Unauthorized) + .WaitAndRetryAsync( + maxRetryAttempts, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryCount, context) => + { + var reason = outcome.Result?.StatusCode.ToString() ?? outcome.Exception?.Message; + _logger.LogWarning( + "Retry {RetryCount} after {TimeSpan}ms due to: {Reason}", + retryCount, timespan.TotalMilliseconds, reason); + }); + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return await _retryPolicy.ExecuteAsync(async () => + await base.SendAsync(request, cancellationToken)); + } +} \ No newline at end of file diff --git a/LittleShop.Client/LittleShop.Client.csproj b/LittleShop.Client/LittleShop.Client.csproj new file mode 100644 index 0000000..96add4d --- /dev/null +++ b/LittleShop.Client/LittleShop.Client.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/LittleShop.Client/LittleShopClient.cs b/LittleShop.Client/LittleShopClient.cs new file mode 100644 index 0000000..ba7924b --- /dev/null +++ b/LittleShop.Client/LittleShopClient.cs @@ -0,0 +1,29 @@ +using LittleShop.Client.Configuration; +using LittleShop.Client.Services; +using Microsoft.Extensions.Options; + +namespace LittleShop.Client; + +public interface ILittleShopClient +{ + IAuthenticationService Authentication { get; } + ICatalogService Catalog { get; } + IOrderService Orders { get; } +} + +public class LittleShopClient : ILittleShopClient +{ + public IAuthenticationService Authentication { get; } + public ICatalogService Catalog { get; } + public IOrderService Orders { get; } + + public LittleShopClient( + IAuthenticationService authenticationService, + ICatalogService catalogService, + IOrderService orderService) + { + Authentication = authenticationService; + Catalog = catalogService; + Orders = orderService; + } +} \ No newline at end of file diff --git a/LittleShop.Client/Models/ApiResponse.cs b/LittleShop.Client/Models/ApiResponse.cs new file mode 100644 index 0000000..633bafd --- /dev/null +++ b/LittleShop.Client/Models/ApiResponse.cs @@ -0,0 +1,40 @@ +using System.Net; + +namespace LittleShop.Client.Models; + +public class ApiResponse +{ + public bool IsSuccess { get; set; } + public T? Data { get; set; } + public string? ErrorMessage { get; set; } + public HttpStatusCode StatusCode { get; set; } + + public static ApiResponse Success(T data, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return new ApiResponse + { + IsSuccess = true, + Data = data, + StatusCode = statusCode + }; + } + + public static ApiResponse Failure(string errorMessage, HttpStatusCode statusCode) + { + return new ApiResponse + { + IsSuccess = false, + ErrorMessage = errorMessage, + StatusCode = statusCode + }; + } +} + +public class PagedResult +{ + public List Items { get; set; } = new(); + public int TotalCount { get; set; } + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); +} \ No newline at end of file diff --git a/LittleShop.Client/Models/Category.cs b/LittleShop.Client/Models/Category.cs new file mode 100644 index 0000000..be683f4 --- /dev/null +++ b/LittleShop.Client/Models/Category.cs @@ -0,0 +1,11 @@ +namespace LittleShop.Client.Models; + +public class Category +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/LittleShop.Client/Models/LoginRequest.cs b/LittleShop.Client/Models/LoginRequest.cs new file mode 100644 index 0000000..fa2e820 --- /dev/null +++ b/LittleShop.Client/Models/LoginRequest.cs @@ -0,0 +1,14 @@ +namespace LittleShop.Client.Models; + +public class LoginRequest +{ + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public class LoginResponse +{ + public string Token { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/LittleShop.Client/Models/Order.cs b/LittleShop.Client/Models/Order.cs new file mode 100644 index 0000000..44c6ad3 --- /dev/null +++ b/LittleShop.Client/Models/Order.cs @@ -0,0 +1,51 @@ +namespace LittleShop.Client.Models; + +public class Order +{ + public Guid Id { get; set; } + public string IdentityReference { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public string Currency { get; set; } = string.Empty; + 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 List Items { get; set; } = new(); + public List Payments { get; set; } = new(); + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public DateTime? PaidAt { get; set; } + public DateTime? ShippedAt { get; set; } +} + +public class OrderItem +{ + public Guid Id { get; set; } + public Guid ProductId { get; set; } + public string? ProductName { get; set; } + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + public decimal TotalPrice { get; set; } +} + +public class CreateOrderRequest +{ + public string IdentityReference { get; set; } = string.Empty; + 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; } = "United Kingdom"; + public string? Notes { get; set; } + public List Items { get; set; } = new(); +} + +public class CreateOrderItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} \ No newline at end of file diff --git a/LittleShop.Client/Models/Payment.cs b/LittleShop.Client/Models/Payment.cs new file mode 100644 index 0000000..af60e67 --- /dev/null +++ b/LittleShop.Client/Models/Payment.cs @@ -0,0 +1,23 @@ +namespace LittleShop.Client.Models; + +public class CryptoPayment +{ + public Guid Id { get; set; } + public Guid OrderId { get; set; } + public string Currency { get; set; } = string.Empty; + public string WalletAddress { get; set; } = string.Empty; + public decimal RequiredAmount { get; set; } + public decimal? PaidAmount { get; set; } + public string Status { get; set; } = string.Empty; + public string? TransactionHash { get; set; } + public string? BTCPayInvoiceId { get; set; } + public string? BTCPayCheckoutUrl { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? PaidAt { get; set; } + public DateTime ExpiresAt { get; set; } +} + +public class CreatePaymentRequest +{ + public string Currency { get; set; } = "BTC"; +} \ No newline at end of file diff --git a/LittleShop.Client/Models/Product.cs b/LittleShop.Client/Models/Product.cs new file mode 100644 index 0000000..d1e0279 --- /dev/null +++ b/LittleShop.Client/Models/Product.cs @@ -0,0 +1,25 @@ +namespace LittleShop.Client.Models; + +public class Product +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal Price { get; set; } + public decimal Weight { get; set; } + public string WeightUnit { get; set; } = string.Empty; + public Guid CategoryId { get; set; } + public string? CategoryName { get; set; } + public bool IsActive { get; set; } + public List Photos { get; set; } = new(); + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class ProductPhoto +{ + public Guid Id { get; set; } + public string Url { get; set; } = string.Empty; + public string? AltText { get; set; } + public int SortOrder { get; set; } +} \ No newline at end of file diff --git a/LittleShop.Client/README.md b/LittleShop.Client/README.md new file mode 100644 index 0000000..7547bc9 --- /dev/null +++ b/LittleShop.Client/README.md @@ -0,0 +1,373 @@ +# LittleShop Client SDK + +A .NET client library for interacting with the LittleShop e-commerce API. This SDK provides a strongly-typed, easy-to-use interface for all LittleShop API endpoints with built-in authentication, retry policies, and error handling. + +## Installation + +Add the LittleShop.Client library to your project: + +```bash +dotnet add reference ../LittleShop.Client/LittleShop.Client.csproj +``` + +Or via NuGet (when published): +```bash +dotnet add package LittleShop.Client +``` + +## Quick Start + +### Basic Setup with Dependency Injection + +```csharp +using LittleShop.Client.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + // Add LittleShop client with configuration + services.AddLittleShopClient(options => + { + options.BaseUrl = "https://localhost:5001"; + options.TimeoutSeconds = 30; + options.MaxRetryAttempts = 3; + options.EnableLogging = true; + }); + }) + .Build(); + +// Get the client from DI +var client = host.Services.GetRequiredService(); +``` + +### Simple Console Application Example + +```csharp +using LittleShop.Client; +using LittleShop.Client.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +// Setup DI container +var services = new ServiceCollection(); +services.AddLogging(builder => builder.AddConsole()); +services.AddLittleShopClient("https://localhost:5001"); + +var serviceProvider = services.BuildServiceProvider(); +var client = serviceProvider.GetRequiredService(); + +// Authenticate +var loginResult = await client.Authentication.LoginAsync("admin", "admin"); +if (loginResult.IsSuccess) +{ + Console.WriteLine($"Logged in as: {loginResult.Data.Username}"); + Console.WriteLine($"Token expires: {loginResult.Data.ExpiresAt}"); +} + +// Get categories +var categoriesResult = await client.Catalog.GetCategoriesAsync(); +if (categoriesResult.IsSuccess) +{ + foreach (var category in categoriesResult.Data) + { + Console.WriteLine($"Category: {category.Name} - {category.Description}"); + } +} + +// Get products with filtering +var productsResult = await client.Catalog.GetProductsAsync( + pageNumber: 1, + pageSize: 10, + searchTerm: "wireless", + minPrice: 10, + maxPrice: 100 +); + +if (productsResult.IsSuccess) +{ + Console.WriteLine($"Found {productsResult.Data.TotalCount} products"); + foreach (var product in productsResult.Data.Items) + { + Console.WriteLine($"Product: {product.Name} - £{product.Price}"); + } +} +``` + +## Authentication + +The SDK handles JWT authentication automatically. Once you log in, the token is stored and automatically included in subsequent requests. + +```csharp +// Login +var loginResult = await client.Authentication.LoginAsync("username", "password"); +if (!loginResult.IsSuccess) +{ + Console.WriteLine($"Login failed: {loginResult.ErrorMessage}"); + return; +} + +// Check if authenticated +if (client.Authentication.IsAuthenticated()) +{ + Console.WriteLine("User is authenticated"); +} + +// Get current token (if needed for external use) +var token = client.Authentication.GetToken(); + +// Logout +client.Authentication.Logout(); +``` + +## Catalog Operations + +### Get All Categories +```csharp +var result = await client.Catalog.GetCategoriesAsync(); +if (result.IsSuccess) +{ + foreach (var category in result.Data) + { + Console.WriteLine($"{category.Name}: {category.Description}"); + } +} +``` + +### Get Products with Pagination and Filtering +```csharp +var result = await client.Catalog.GetProductsAsync( + pageNumber: 1, + pageSize: 20, + categoryId: categoryGuid, // Optional: Filter by category + searchTerm: "headphones", // Optional: Search term + minPrice: 50, // Optional: Minimum price + maxPrice: 200 // Optional: Maximum price +); + +if (result.IsSuccess) +{ + Console.WriteLine($"Page {result.Data.PageNumber} of {result.Data.TotalPages}"); + Console.WriteLine($"Total products: {result.Data.TotalCount}"); + + foreach (var product in result.Data.Items) + { + Console.WriteLine($"{product.Name} - £{product.Price}"); + if (product.Photos.Any()) + { + Console.WriteLine($" Photo: {product.Photos.First().Url}"); + } + } +} +``` + +### Get Single Product +```csharp +var result = await client.Catalog.GetProductByIdAsync(productId); +if (result.IsSuccess) +{ + var product = result.Data; + Console.WriteLine($"Name: {product.Name}"); + Console.WriteLine($"Price: £{product.Price}"); + Console.WriteLine($"Weight: {product.Weight} {product.WeightUnit}"); +} +``` + +## Order Operations + +### Create Order +```csharp +var orderRequest = new CreateOrderRequest +{ + IdentityReference = "CUST123", + ShippingName = "John Smith", + ShippingAddress = "123 Main Street", + ShippingCity = "London", + ShippingPostCode = "SW1A 1AA", + ShippingCountry = "United Kingdom", + Notes = "Please deliver to reception", + Items = new List + { + new CreateOrderItem + { + ProductId = productId1, + Quantity = 2 + }, + new CreateOrderItem + { + ProductId = productId2, + Quantity = 1 + } + } +}; + +var result = await client.Orders.CreateOrderAsync(orderRequest); +if (result.IsSuccess) +{ + Console.WriteLine($"Order created: {result.Data.Id}"); + Console.WriteLine($"Total: £{result.Data.TotalAmount}"); +} +``` + +### Get Orders by Customer Identity +```csharp +var result = await client.Orders.GetOrdersByIdentityAsync("CUST123"); +if (result.IsSuccess) +{ + foreach (var order in result.Data) + { + Console.WriteLine($"Order {order.Id}: {order.Status} - £{order.TotalAmount}"); + Console.WriteLine($" Created: {order.CreatedAt}"); + if (!string.IsNullOrEmpty(order.TrackingNumber)) + { + Console.WriteLine($" Tracking: {order.TrackingNumber}"); + } + } +} +``` + +### Create Cryptocurrency Payment +```csharp +var result = await client.Orders.CreatePaymentAsync(orderId, "BTC"); +if (result.IsSuccess) +{ + var payment = result.Data; + Console.WriteLine($"Payment ID: {payment.Id}"); + Console.WriteLine($"Send {payment.RequiredAmount} {payment.Currency} to:"); + Console.WriteLine($"Address: {payment.WalletAddress}"); + Console.WriteLine($"Expires: {payment.ExpiresAt}"); + + if (!string.IsNullOrEmpty(payment.BTCPayCheckoutUrl)) + { + Console.WriteLine($"Or pay via: {payment.BTCPayCheckoutUrl}"); + } +} +``` + +## Error Handling + +All API methods return an `ApiResponse` wrapper that includes success status, data, and error information: + +```csharp +var result = await client.Catalog.GetProductByIdAsync(productId); + +if (result.IsSuccess) +{ + // Success - access data + var product = result.Data; + Console.WriteLine($"Product: {product.Name}"); +} +else +{ + // Error - handle appropriately + Console.WriteLine($"Error: {result.ErrorMessage}"); + Console.WriteLine($"Status Code: {result.StatusCode}"); + + switch (result.StatusCode) + { + case HttpStatusCode.NotFound: + Console.WriteLine("Product not found"); + break; + case HttpStatusCode.Unauthorized: + Console.WriteLine("Please login first"); + break; + case HttpStatusCode.BadRequest: + Console.WriteLine("Invalid request"); + break; + default: + Console.WriteLine("An error occurred"); + break; + } +} +``` + +## Advanced Configuration + +### Custom HTTP Client Configuration + +The SDK uses Polly for retry policies and includes automatic retry for transient failures: + +```csharp +services.AddLittleShopClient(options => +{ + options.BaseUrl = "https://api.littleshop.com"; + options.TimeoutSeconds = 60; // Request timeout + options.MaxRetryAttempts = 5; // Number of retries for transient failures + options.EnableLogging = true; // Enable detailed logging +}); +``` + +### Using in ASP.NET Core Web Application + +```csharp +// In Program.cs or Startup.cs +builder.Services.AddLittleShopClient(options => +{ + options.BaseUrl = builder.Configuration["LittleShop:ApiUrl"]; + options.TimeoutSeconds = 30; +}); + +// In a controller or service +public class ProductController : Controller +{ + private readonly ILittleShopClient _client; + + public ProductController(ILittleShopClient client) + { + _client = client; + } + + public async Task Index() + { + var result = await _client.Catalog.GetProductsAsync(); + if (result.IsSuccess) + { + return View(result.Data.Items); + } + + return View("Error", result.ErrorMessage); + } +} +``` + +## Features + +- ✅ **Strongly Typed Models** - All API responses are mapped to C# classes +- ✅ **Automatic Authentication** - JWT tokens are managed automatically +- ✅ **Retry Policies** - Automatic retry for transient failures +- ✅ **Error Handling** - Consistent error responses with detailed information +- ✅ **Logging Support** - Built-in logging for debugging +- ✅ **Dependency Injection** - Full DI support for ASP.NET Core applications +- ✅ **Async/Await** - All methods are async for better performance +- ✅ **Pagination Support** - Built-in pagination for product listings +- ✅ **Filtering & Search** - Advanced filtering options for products + +## API Coverage + +- **Authentication** + - Login + - Token refresh + - Logout + +- **Catalog** + - Get all categories + - Get category by ID + - Get products (with pagination and filtering) + - Get product by ID + +- **Orders** + - Create order + - Get orders by customer identity + - Get order by ID + - Create cryptocurrency payment + - Get order payments + +## Requirements + +- .NET 9.0 or later +- LittleShop API server running and accessible + +## License + +This SDK is part of the LittleShop e-commerce platform. \ No newline at end of file diff --git a/LittleShop.Client/Services/AuthenticationService.cs b/LittleShop.Client/Services/AuthenticationService.cs new file mode 100644 index 0000000..21bf691 --- /dev/null +++ b/LittleShop.Client/Services/AuthenticationService.cs @@ -0,0 +1,126 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Json; +using LittleShop.Client.Models; +using Microsoft.Extensions.Logging; + +namespace LittleShop.Client.Services; + +public class AuthenticationService : IAuthenticationService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private string? _currentToken; + private DateTime? _tokenExpiry; + + public AuthenticationService(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task> LoginAsync(string username, string password) + { + try + { + var request = new LoginRequest { Username = username, Password = password }; + var response = await _httpClient.PostAsJsonAsync("api/auth/login", request); + + if (response.IsSuccessStatusCode) + { + var loginResponse = await response.Content.ReadFromJsonAsync(); + if (loginResponse != null) + { + SetToken(loginResponse.Token); + return ApiResponse.Success(loginResponse); + } + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse.Failure( + error ?? "Login failed", + response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Login failed"); + return ApiResponse.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public async Task> RefreshTokenAsync() + { + if (string.IsNullOrEmpty(_currentToken)) + { + return ApiResponse.Failure( + "No token to refresh", + System.Net.HttpStatusCode.Unauthorized); + } + + try + { + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _currentToken); + + var response = await _httpClient.PostAsync("api/auth/refresh", null); + + if (response.IsSuccessStatusCode) + { + var loginResponse = await response.Content.ReadFromJsonAsync(); + if (loginResponse != null) + { + SetToken(loginResponse.Token); + return ApiResponse.Success(loginResponse); + } + } + + return ApiResponse.Failure( + "Token refresh failed", + response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Token refresh failed"); + return ApiResponse.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public void SetToken(string token) + { + _currentToken = token; + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + // Parse token to get expiry + try + { + var handler = new JwtSecurityTokenHandler(); + var jsonToken = handler.ReadJwtToken(token); + _tokenExpiry = jsonToken.ValidTo; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse token expiry"); + _tokenExpiry = DateTime.UtcNow.AddHours(1); // Default to 1 hour + } + } + + public string? GetToken() => _currentToken; + + public bool IsAuthenticated() + { + return !string.IsNullOrEmpty(_currentToken) && + _tokenExpiry.HasValue && + _tokenExpiry.Value > DateTime.UtcNow; + } + + public void Logout() + { + _currentToken = null; + _tokenExpiry = null; + _httpClient.DefaultRequestHeaders.Authorization = null; + } +} \ No newline at end of file diff --git a/LittleShop.Client/Services/CatalogService.cs b/LittleShop.Client/Services/CatalogService.cs new file mode 100644 index 0000000..9e8c011 --- /dev/null +++ b/LittleShop.Client/Services/CatalogService.cs @@ -0,0 +1,138 @@ +using System.Net.Http.Json; +using LittleShop.Client.Models; +using Microsoft.Extensions.Logging; + +namespace LittleShop.Client.Services; + +public class CatalogService : ICatalogService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public CatalogService(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task>> GetCategoriesAsync() + { + try + { + var response = await _httpClient.GetAsync("api/catalog/categories"); + + if (response.IsSuccessStatusCode) + { + var categories = await response.Content.ReadFromJsonAsync>(); + return ApiResponse>.Success(categories ?? new List()); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse>.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get categories"); + return ApiResponse>.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public async Task> GetCategoryByIdAsync(Guid id) + { + try + { + var response = await _httpClient.GetAsync($"api/catalog/categories/{id}"); + + if (response.IsSuccessStatusCode) + { + var category = await response.Content.ReadFromJsonAsync(); + if (category != null) + return ApiResponse.Success(category); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get category {CategoryId}", id); + return ApiResponse.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public async Task>> GetProductsAsync( + int pageNumber = 1, + int pageSize = 20, + Guid? categoryId = null, + string? searchTerm = null, + decimal? minPrice = null, + decimal? maxPrice = null) + { + try + { + var queryParams = new List + { + $"pageNumber={pageNumber}", + $"pageSize={pageSize}" + }; + + if (categoryId.HasValue) + queryParams.Add($"categoryId={categoryId.Value}"); + if (!string.IsNullOrEmpty(searchTerm)) + queryParams.Add($"search={Uri.EscapeDataString(searchTerm)}"); + if (minPrice.HasValue) + queryParams.Add($"minPrice={minPrice.Value}"); + if (maxPrice.HasValue) + queryParams.Add($"maxPrice={maxPrice.Value}"); + + var queryString = string.Join("&", queryParams); + var response = await _httpClient.GetAsync($"api/catalog/products?{queryString}"); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(); + if (result != null) + return ApiResponse>.Success(result); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse>.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get products"); + return ApiResponse>.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public async Task> GetProductByIdAsync(Guid id) + { + try + { + var response = await _httpClient.GetAsync($"api/catalog/products/{id}"); + + if (response.IsSuccessStatusCode) + { + var product = await response.Content.ReadFromJsonAsync(); + if (product != null) + return ApiResponse.Success(product); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get product {ProductId}", id); + return ApiResponse.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } +} \ No newline at end of file diff --git a/LittleShop.Client/Services/IAuthenticationService.cs b/LittleShop.Client/Services/IAuthenticationService.cs new file mode 100644 index 0000000..0717e30 --- /dev/null +++ b/LittleShop.Client/Services/IAuthenticationService.cs @@ -0,0 +1,13 @@ +using LittleShop.Client.Models; + +namespace LittleShop.Client.Services; + +public interface IAuthenticationService +{ + Task> LoginAsync(string username, string password); + Task> RefreshTokenAsync(); + void SetToken(string token); + string? GetToken(); + bool IsAuthenticated(); + void Logout(); +} \ No newline at end of file diff --git a/LittleShop.Client/Services/ICatalogService.cs b/LittleShop.Client/Services/ICatalogService.cs new file mode 100644 index 0000000..7dea45a --- /dev/null +++ b/LittleShop.Client/Services/ICatalogService.cs @@ -0,0 +1,17 @@ +using LittleShop.Client.Models; + +namespace LittleShop.Client.Services; + +public interface ICatalogService +{ + Task>> GetCategoriesAsync(); + Task> GetCategoryByIdAsync(Guid id); + Task>> GetProductsAsync( + int pageNumber = 1, + int pageSize = 20, + Guid? categoryId = null, + string? searchTerm = null, + decimal? minPrice = null, + decimal? maxPrice = null); + Task> GetProductByIdAsync(Guid id); +} \ No newline at end of file diff --git a/LittleShop.Client/Services/IOrderService.cs b/LittleShop.Client/Services/IOrderService.cs new file mode 100644 index 0000000..f36711c --- /dev/null +++ b/LittleShop.Client/Services/IOrderService.cs @@ -0,0 +1,12 @@ +using LittleShop.Client.Models; + +namespace LittleShop.Client.Services; + +public interface IOrderService +{ + Task> CreateOrderAsync(CreateOrderRequest request); + Task>> GetOrdersByIdentityAsync(string identityReference); + Task> GetOrderByIdAsync(Guid id); + Task> CreatePaymentAsync(Guid orderId, string currency); + Task>> GetOrderPaymentsAsync(Guid orderId); +} \ No newline at end of file diff --git a/LittleShop.Client/Services/OrderService.cs b/LittleShop.Client/Services/OrderService.cs new file mode 100644 index 0000000..a8191c1 --- /dev/null +++ b/LittleShop.Client/Services/OrderService.cs @@ -0,0 +1,143 @@ +using System.Net.Http.Json; +using LittleShop.Client.Models; +using Microsoft.Extensions.Logging; + +namespace LittleShop.Client.Services; + +public class OrderService : IOrderService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public OrderService(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task> CreateOrderAsync(CreateOrderRequest request) + { + try + { + var response = await _httpClient.PostAsJsonAsync("api/orders", request); + + if (response.IsSuccessStatusCode) + { + var order = await response.Content.ReadFromJsonAsync(); + if (order != null) + return ApiResponse.Success(order, response.StatusCode); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create order"); + return ApiResponse.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public async Task>> GetOrdersByIdentityAsync(string identityReference) + { + try + { + var response = await _httpClient.GetAsync( + $"api/orders/by-identity/{Uri.EscapeDataString(identityReference)}"); + + if (response.IsSuccessStatusCode) + { + var orders = await response.Content.ReadFromJsonAsync>(); + return ApiResponse>.Success(orders ?? new List()); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse>.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get orders for identity {Identity}", identityReference); + return ApiResponse>.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public async Task> GetOrderByIdAsync(Guid id) + { + try + { + var response = await _httpClient.GetAsync($"api/orders/{id}"); + + if (response.IsSuccessStatusCode) + { + var order = await response.Content.ReadFromJsonAsync(); + if (order != null) + return ApiResponse.Success(order); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get order {OrderId}", id); + return ApiResponse.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public async Task> CreatePaymentAsync(Guid orderId, string currency) + { + try + { + var request = new CreatePaymentRequest { Currency = currency }; + var response = await _httpClient.PostAsJsonAsync( + $"api/orders/{orderId}/payments", request); + + if (response.IsSuccessStatusCode) + { + var payment = await response.Content.ReadFromJsonAsync(); + if (payment != null) + return ApiResponse.Success(payment, response.StatusCode); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create payment for order {OrderId}", orderId); + return ApiResponse.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } + + public async Task>> GetOrderPaymentsAsync(Guid orderId) + { + try + { + var response = await _httpClient.GetAsync($"api/orders/{orderId}/payments"); + + if (response.IsSuccessStatusCode) + { + var payments = await response.Content.ReadFromJsonAsync>(); + return ApiResponse>.Success(payments ?? new List()); + } + + var error = await response.Content.ReadAsStringAsync(); + return ApiResponse>.Failure(error, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get payments for order {OrderId}", orderId); + return ApiResponse>.Failure( + ex.Message, + System.Net.HttpStatusCode.InternalServerError); + } + } +} \ No newline at end of file diff --git a/LittleShop.Tests/Integration/OrdersControllerTests.cs b/LittleShop.Tests/Integration/OrdersControllerTests.cs index 087a818..4909f0e 100644 --- a/LittleShop.Tests/Integration/OrdersControllerTests.cs +++ b/LittleShop.Tests/Integration/OrdersControllerTests.cs @@ -289,7 +289,7 @@ public class OrdersControllerTests : IClassFixture var webhookDto = new PaymentWebhookDto { InvoiceId = "INV123456", - Status = PaymentStatus.Confirmed, + Status = PaymentStatus.Paid, Amount = 100.00m, TransactionHash = "tx123456789" }; @@ -407,7 +407,7 @@ public class OrdersControllerTests : IClassFixture { Id = Guid.NewGuid(), IdentityReference = identityReference, - Status = OrderStatus.Pending, + Status = OrderStatus.PendingPayment, ShippingName = "Test Customer", ShippingAddress = "Test Address", ShippingCity = "Test City", diff --git a/LittleShop.Tests/TestUtilities/TestDataBuilder.cs b/LittleShop.Tests/TestUtilities/TestDataBuilder.cs index d83f724..4127db7 100644 --- a/LittleShop.Tests/TestUtilities/TestDataBuilder.cs +++ b/LittleShop.Tests/TestUtilities/TestDataBuilder.cs @@ -78,12 +78,9 @@ public static class TestDataBuilder { Id = Guid.NewGuid(), Username = user, - Email = $"{user}@test.com", PasswordHash = "hashed-password", - Role = role, IsActive = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + CreatedAt = DateTime.UtcNow }; } @@ -94,12 +91,11 @@ public static class TestDataBuilder Id = Guid.NewGuid(), OrderId = orderId, Currency = currency, - Amount = 0.0025m, - CryptoAddress = $"bc1q{Guid.NewGuid().ToString().Replace("-", "").Substring(0, 39)}", + RequiredAmount = 0.0025m, + WalletAddress = $"bc1q{Guid.NewGuid().ToString().Replace("-", "").Substring(0, 39)}", Status = status, - ExchangeRate = 40000.00m, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + ExpiresAt = DateTime.UtcNow.AddHours(1) }; } @@ -161,15 +157,17 @@ public static class TestDataBuilder }; } - public static ProductPhoto CreateProductPhoto(Guid productId, int displayOrder = 1) + public static ProductPhoto CreateProductPhoto(Guid productId, int sortOrder = 1) { + var fileName = $"{Guid.NewGuid()}.jpg"; return new ProductPhoto { Id = Guid.NewGuid(), ProductId = productId, - PhotoUrl = $"/uploads/products/{Guid.NewGuid()}.jpg", + FileName = fileName, + FilePath = $"/uploads/products/{fileName}", AltText = "Test product photo", - DisplayOrder = displayOrder, + SortOrder = sortOrder, CreatedAt = DateTime.UtcNow }; } diff --git a/LittleShop.Tests/Unit/CategoryServiceTests.cs b/LittleShop.Tests/Unit/CategoryServiceTests.cs index 1149ffe..6adaccd 100644 --- a/LittleShop.Tests/Unit/CategoryServiceTests.cs +++ b/LittleShop.Tests/Unit/CategoryServiceTests.cs @@ -15,7 +15,6 @@ public class CategoryServiceTests : IDisposable { private readonly LittleShopContext _context; private readonly ICategoryService _categoryService; - private readonly IMapper _mapper; public CategoryServiceTests() { @@ -25,15 +24,8 @@ public class CategoryServiceTests : IDisposable .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); + _categoryService = new CategoryService(_context); } [Fact] diff --git a/LittleShop.Tests/Unit/ProductServiceTests.cs b/LittleShop.Tests/Unit/ProductServiceTests.cs index 34c1306..6ef95b0 100644 --- a/LittleShop.Tests/Unit/ProductServiceTests.cs +++ b/LittleShop.Tests/Unit/ProductServiceTests.cs @@ -5,9 +5,9 @@ using LittleShop.Models; using LittleShop.Services; using LittleShop.Enums; using Microsoft.EntityFrameworkCore; -using AutoMapper; +using Microsoft.AspNetCore.Hosting; +using Moq; using Xunit; -using LittleShop.Mapping; namespace LittleShop.Tests.Unit; @@ -15,7 +15,7 @@ public class ProductServiceTests : IDisposable { private readonly LittleShopContext _context; private readonly IProductService _productService; - private readonly IMapper _mapper; + private readonly Mock _mockEnvironment; public ProductServiceTests() { @@ -25,15 +25,12 @@ public class ProductServiceTests : IDisposable .Options; _context = new LittleShopContext(options); - // Set up AutoMapper - var mappingConfig = new MapperConfiguration(mc => - { - mc.AddProfile(new MappingProfile()); - }); - _mapper = mappingConfig.CreateMapper(); + // Set up mock environment + _mockEnvironment = new Mock(); + _mockEnvironment.Setup(e => e.WebRootPath).Returns("/test/wwwroot"); // Create service - _productService = new ProductService(_context, _mapper); + _productService = new ProductService(_context, _mockEnvironment.Object); } [Fact] @@ -242,7 +239,7 @@ public class ProductServiceTests : IDisposable // Assert result.Should().NotBeNull(); - result.PhotoUrl.Should().Be("/uploads/test-photo.jpg"); + result.FilePath.Should().Contain("test-photo.jpg"); result.AltText.Should().Be("Test Photo"); // Verify in database diff --git a/LittleShop.sln b/LittleShop.sln index c870144..cf581d8 100644 --- a/LittleShop.sln +++ b/LittleShop.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleBotClient", "TeleBot\Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop.Tests", "LittleShop.Tests\LittleShop.Tests.csproj", "{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop.Client", "LittleShop.Client\LittleShop.Client.csproj", "{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +73,18 @@ Global {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 + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|x64.ActiveCfg = Debug|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|x64.Build.0 = Debug|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|x86.ActiveCfg = Debug|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|x86.Build.0 = Debug|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|Any CPU.Build.0 = Release|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|x64.ActiveCfg = Release|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|x64.Build.0 = Release|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|x86.ActiveCfg = Release|Any CPU + {AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PROJECT_README.md b/PROJECT_README.md new file mode 100644 index 0000000..07d423a --- /dev/null +++ b/PROJECT_README.md @@ -0,0 +1,281 @@ +# LittleShop E-Commerce Platform + +A complete e-commerce platform built with ASP.NET Core 9.0, featuring multi-cryptocurrency payment support and a privacy-focused design. + +## 🎯 Features + +### Core Functionality +- **Product Management**: Categories, products with photos, weight-based pricing +- **Order Management**: Complete order workflow from creation to delivery +- **Multi-Cryptocurrency Payments**: Bitcoin, Monero, USDT, Litecoin, Ethereum, Zcash, Dash, Dogecoin +- **Shipping Management**: Weight-based shipping rates with Royal Mail integration +- **Admin Panel**: Full administrative interface for managing the store +- **API**: RESTful API with JWT authentication for client applications + +### Security & Privacy +- **No KYC Requirements**: Privacy-focused design with minimal data collection +- **Dual Authentication**: Cookie-based for admin panel, JWT for API +- **Self-Hosted Payments**: BTCPay Server integration for cryptocurrency processing +- **Secure Password Storage**: PBKDF2 with 100,000 iterations + +## 🚀 Quick Start + +### Prerequisites +- .NET 9.0 SDK +- SQLite (included) +- BTCPay Server instance (for payments) + +### Installation + +1. Clone the repository: +```bash +git clone https://github.com/yourusername/littleshop.git +cd littleshop +``` + +2. Restore dependencies: +```bash +dotnet restore +``` + +3. Run the application: +```bash +dotnet run --project LittleShop/LittleShop.csproj +``` + +4. Access the application: + - Admin Panel: https://localhost:5001/Admin + - API Documentation: https://localhost:5001/swagger + - Default credentials: `admin` / `admin` + +## 📁 Project Structure + +``` +LittleShop/ +├── LittleShop/ # Main web application +│ ├── Areas/Admin/ # Admin panel MVC +│ ├── Controllers/ # API controllers +│ ├── Services/ # Business logic +│ ├── Models/ # Database entities +│ ├── DTOs/ # Data transfer objects +│ └── Data/ # Entity Framework context +├── LittleShop.Client/ # .NET client SDK +│ ├── Services/ # API client services +│ ├── Models/ # Client models +│ └── Http/ # HTTP handlers +└── LittleShop.Tests/ # Test suite + ├── Unit/ # Unit tests + ├── Integration/ # API integration tests + ├── Security/ # Security tests + └── UI/ # UI automation tests +``` + +## 💻 Using the Client SDK + +### Installation +```bash +dotnet add reference LittleShop.Client/LittleShop.Client.csproj +``` + +### Basic Usage +```csharp +using LittleShop.Client.Extensions; + +// Configure services +services.AddLittleShopClient(options => +{ + options.BaseUrl = "https://localhost:5001"; + options.TimeoutSeconds = 30; + options.MaxRetryAttempts = 3; +}); + +// Use the client +var client = serviceProvider.GetRequiredService(); + +// Authenticate +await client.Authentication.LoginAsync("admin", "admin"); + +// Get products +var products = await client.Catalog.GetProductsAsync(); + +// Create order +var order = await client.Orders.CreateOrderAsync(new CreateOrderRequest +{ + IdentityReference = "CUST001", + ShippingName = "John Doe", + ShippingAddress = "123 Main St", + ShippingCity = "London", + ShippingPostCode = "SW1A 1AA", + ShippingCountry = "United Kingdom", + Items = new[] { new CreateOrderItem { ProductId = productId, Quantity = 1 } } +}); +``` + +## 🔌 API Endpoints + +### Authentication +- `POST /api/auth/login` - Login with username/password +- `POST /api/auth/refresh` - Refresh JWT token + +### Catalog (Requires Authentication) +- `GET /api/catalog/categories` - Get all categories +- `GET /api/catalog/categories/{id}` - Get category by ID +- `GET /api/catalog/products` - Get products with filtering +- `GET /api/catalog/products/{id}` - Get product by ID + +### Orders (Requires Authentication) +- `POST /api/orders` - Create new order +- `GET /api/orders/by-identity/{id}` - Get orders by customer identity +- `GET /api/orders/{id}` - Get order by ID +- `POST /api/orders/{id}/payments` - Create crypto payment +- `POST /api/orders/payments/webhook` - BTCPay webhook endpoint + +## 🗄️ Database Schema + +### Core Tables +- **Users**: Staff/admin accounts only +- **Categories**: Product categories +- **Products**: Product catalog with pricing and weight +- **ProductPhotos**: Product images with sorting +- **Orders**: Customer orders with shipping details +- **OrderItems**: Individual items in orders +- **CryptoPayments**: Cryptocurrency payment records +- **ShippingRates**: Weight-based shipping calculations + +## 🧪 Testing + +Run all tests: +```bash +dotnet test +``` + +Run specific test categories: +```bash +# Unit tests only +dotnet test --filter Category=Unit + +# Integration tests +dotnet test --filter Category=Integration + +# Security tests +dotnet test --filter Category=Security +``` + +### Test Coverage +- ✅ Unit tests for all services +- ✅ Integration tests for all API endpoints +- ✅ Security tests for authentication enforcement +- ✅ UI automation tests with Playwright + +## 🔧 Configuration + +### appsettings.json +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=littleshop.db" + }, + "Jwt": { + "Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!", + "Issuer": "LittleShop", + "Audience": "LittleShop", + "ExpiryMinutes": 60 + }, + "BTCPayServer": { + "Url": "https://your-btcpay.com", + "StoreId": "your-store-id", + "ApiKey": "your-api-key" + } +} +``` + +## 🚢 Deployment + +### Production Checklist +1. Update connection strings +2. Configure BTCPay Server +3. Set strong JWT secret key +4. Enable HTTPS only +5. Configure CORS for your domain +6. Set up SSL certificates +7. Configure logging +8. Set up database backups + +### Docker Support +```dockerfile +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 80 443 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet build -c Release +RUN dotnet publish -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "LittleShop.dll"] +``` + +## 📊 Sample Data + +The application includes sample data seeder that creates: +- 3 Categories (Electronics, Clothing, Books) +- 5 Products with various prices +- 5 Shipping rates (Royal Mail options) +- 5 Sample orders in different statuses +- 3 Crypto payments demonstrating payment flow + +## 🛡️ Security Considerations + +- **Authentication Required**: All API endpoints require JWT authentication +- **No Public Endpoints**: Client applications must authenticate first +- **Password Security**: PBKDF2 with salt and 100,000 iterations +- **Input Validation**: FluentValidation on all inputs +- **SQL Injection Protection**: Entity Framework Core with parameterized queries +- **XSS Protection**: Razor view encoding and validation +- **CORS**: Configured for specific domains in production + +## 📝 License + +This project is proprietary software. All rights reserved. + +## 🤝 Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Ensure all tests pass +6. Submit a pull request + +## 📞 Support + +For issues, questions, or suggestions: +- Open an issue on GitHub +- Contact: support@littleshop.com + +## 🏗️ Built With + +- **ASP.NET Core 9.0** - Web framework +- **Entity Framework Core** - ORM +- **SQLite** - Database +- **Bootstrap 5** - UI framework +- **JWT** - API authentication +- **BTCPay Server** - Cryptocurrency payments +- **xUnit** - Testing framework +- **Playwright** - UI automation +- **Serilog** - Logging + +## 📈 Version History + +- **v1.0.0** - Initial release with core e-commerce functionality + - Product catalog management + - Order processing workflow + - Multi-cryptocurrency payments + - Admin panel and API + - Client SDK library + - Comprehensive test coverage \ No newline at end of file diff --git a/commit-message.txt b/commit-message.txt new file mode 100644 index 0000000..a96d2da --- /dev/null +++ b/commit-message.txt @@ -0,0 +1,38 @@ +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 \ No newline at end of file