diff --git a/LittleShop.Tests/Infrastructure/JwtTokenHelper.cs b/LittleShop.Tests/Infrastructure/JwtTokenHelper.cs index dff0fa9..3074c4a 100644 --- a/LittleShop.Tests/Infrastructure/JwtTokenHelper.cs +++ b/LittleShop.Tests/Infrastructure/JwtTokenHelper.cs @@ -17,7 +17,7 @@ public static class JwtTokenHelper string audience = "LittleShop") { var tokenHandler = new JwtSecurityTokenHandler(); - var key = Encoding.ASCII.GetBytes(secretKey); + var key = Encoding.UTF8.GetBytes(secretKey); // Use UTF8 encoding to match Program.cs var claims = new List { diff --git a/LittleShop.Tests/Infrastructure/TestWebApplicationFactory.cs b/LittleShop.Tests/Infrastructure/TestWebApplicationFactory.cs index ed898cb..6c41f55 100644 --- a/LittleShop.Tests/Infrastructure/TestWebApplicationFactory.cs +++ b/LittleShop.Tests/Infrastructure/TestWebApplicationFactory.cs @@ -1,9 +1,14 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.InMemory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using LittleShop.Data; +using LittleShop.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; using System.Linq; namespace LittleShop.Tests.Infrastructure; @@ -15,19 +20,19 @@ public class TestWebApplicationFactory : WebApplicationFactory builder.ConfigureServices(services => { // Remove the existing DbContext registration - var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(DbContextOptions)); - + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); if (descriptor != null) - { services.Remove(descriptor); - } - // Add in-memory database for testing + // Add InMemory database for testing services.AddDbContext(options => - { - options.UseInMemoryDatabase("InMemoryDbForTesting"); - }); + options.UseInMemoryDatabase("InMemoryDbForTesting") + .ConfigureWarnings(warnings => warnings.Default(WarningBehavior.Ignore))); + + // Mock external services that might cause issues in tests + services.Replace(ServiceDescriptor.Scoped(_ => Mock.Of())); + services.Replace(ServiceDescriptor.Scoped(_ => Mock.Of())); + services.Replace(ServiceDescriptor.Scoped(_ => Mock.Of())); // Build service provider var sp = services.BuildServiceProvider(); diff --git a/LittleShop.Tests/Integration/CatalogControllerTests.cs b/LittleShop.Tests/Integration/CatalogControllerTests.cs index eb1e62f..a1c8cfb 100644 --- a/LittleShop.Tests/Integration/CatalogControllerTests.cs +++ b/LittleShop.Tests/Integration/CatalogControllerTests.cs @@ -114,12 +114,13 @@ public class CatalogControllerTests : IClassFixture response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(); - var products = JsonSerializer.Deserialize>(content, _jsonOptions); + var pagedResult = JsonSerializer.Deserialize>(content, _jsonOptions); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); - products.Should().NotBeNull(); - products.Should().HaveCountGreaterThan(0); + pagedResult.Should().NotBeNull(); + pagedResult!.Items.Should().HaveCountGreaterThan(0); + pagedResult.TotalCount.Should().BeGreaterThan(0); } [Fact] @@ -133,13 +134,13 @@ public class CatalogControllerTests : IClassFixture response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(); - var products = JsonSerializer.Deserialize>(content, _jsonOptions); + var pagedResult = JsonSerializer.Deserialize>(content, _jsonOptions); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); - products.Should().NotBeNull(); - products.Should().HaveCountGreaterThan(0); - products.Should().OnlyContain(p => p.CategoryId == categoryId); + pagedResult.Should().NotBeNull(); + pagedResult!.Items.Should().HaveCountGreaterThan(0); + pagedResult.Items.Should().OnlyContain(p => p.CategoryId == categoryId); } [Fact] diff --git a/LittleShop.Tests/Security/AuthenticationEnforcementTests.cs b/LittleShop.Tests/Security/AuthenticationEnforcementTests.cs index 6a6e8c9..e1c7a6c 100644 --- a/LittleShop.Tests/Security/AuthenticationEnforcementTests.cs +++ b/LittleShop.Tests/Security/AuthenticationEnforcementTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using System.Text; using FluentAssertions; using LittleShop.Tests.Infrastructure; using Microsoft.AspNetCore.Mvc.Testing; @@ -23,16 +24,26 @@ public class AuthenticationEnforcementTests : IClassFixture _logger; + + public ShippingController( + IRoyalMailService royalMailService, + IShippingRateService shippingRateService, + IOrderService orderService, + ILogger logger) + { + _royalMailService = royalMailService; + _shippingRateService = shippingRateService; + _orderService = orderService; + _logger = logger; + } + + [HttpPost("calculate")] + [AllowAnonymous] + public async Task> CalculateShipping([FromBody] CalculateShippingRequest request) + { + try + { + // First try Royal Mail services + var services = await _royalMailService.GetAvailableServicesAsync(request.WeightInGrams, request.Country); + + if (services.Any()) + { + var result = new ShippingCalculationResult + { + Success = true, + Services = services.Select(s => new ShippingOption + { + ServiceCode = s.Code, + ServiceName = s.Name, + Description = s.Description, + Cost = s.Price, + EstimatedDeliveryDays = s.EstimatedDeliveryDays, + SupportsTracking = s.SupportsTracking + }).ToList() + }; + + return Ok(result); + } + + // Fallback to local shipping rates + var fallbackRate = await _shippingRateService.CalculateShippingAsync(request.WeightInGrams / 1000, request.Country); + if (fallbackRate != null) + { + var fallbackResult = new ShippingCalculationResult + { + Success = true, + Services = new List + { + new ShippingOption + { + ServiceCode = "Standard", + ServiceName = fallbackRate.Name, + Description = fallbackRate.Description ?? "Standard shipping", + Cost = fallbackRate.Price, + EstimatedDeliveryDays = (fallbackRate.MinDeliveryDays + fallbackRate.MaxDeliveryDays) / 2, + SupportsTracking = false + } + } + }; + + return Ok(fallbackResult); + } + + return BadRequest(new { Error = "No shipping options available for this destination" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error calculating shipping for weight {Weight}g to {Country}", + request.WeightInGrams, request.Country); + return StatusCode(500, new { Error = "Error calculating shipping costs" }); + } + } + + [HttpPost("create")] + [Authorize(Roles = "Admin")] + public async Task> CreateShipment([FromBody] CreateShipmentRequest request) + { + try + { + var result = await _royalMailService.CreateShipmentAsync(request); + + if (result.Success) + { + _logger.LogInformation("Created shipment {ShipmentId} with tracking {TrackingNumber}", + result.ShipmentId, result.TrackingNumber); + return Ok(result); + } + else + { + return BadRequest(new { Error = result.ErrorMessage }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating shipment"); + return StatusCode(500, new { Error = "Error creating shipment" }); + } + } + + [HttpPost("orders/{orderId}/ship")] + [Authorize(Roles = "Admin")] + public async Task> ShipOrder(Guid orderId, [FromBody] ShipOrderRequest request) + { + try + { + // Get the order + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) + { + return NotFound(new { Error = "Order not found" }); + } + + // Calculate total weight from order items + var totalWeight = order.Items.Sum(item => + { + // Estimate weight if not available (you might want to add weight to ProductDto) + return item.Quantity * 200; // Default 200g per item + }); + + // Create shipment request + var shipmentRequest = new CreateShipmentRequest + { + RecipientName = order.ShippingName, + AddressLine1 = order.ShippingAddress, + City = order.ShippingCity, + PostCode = order.ShippingPostCode, + Country = order.ShippingCountry, + WeightInGrams = totalWeight, + Value = order.TotalAmount, + ServiceCode = request.ServiceCode ?? "1st Class", + Reference = $"Order-{order.Id}" + }; + + var result = await _royalMailService.CreateShipmentAsync(shipmentRequest); + + if (result.Success && !string.IsNullOrEmpty(result.TrackingNumber)) + { + // Update order with tracking information + var updateRequest = new UpdateOrderStatusDto + { + Status = Enums.OrderStatus.Shipped, + TrackingNumber = result.TrackingNumber + }; + + await _orderService.UpdateOrderStatusAsync(orderId, updateRequest); + + _logger.LogInformation("Order {OrderId} shipped with tracking {TrackingNumber}", + orderId, result.TrackingNumber); + } + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error shipping order {OrderId}", orderId); + return StatusCode(500, new { Error = "Error creating shipment for order" }); + } + } + + [HttpGet("track/{trackingNumber}")] + [AllowAnonymous] + public async Task> GetTrackingInfo(string trackingNumber) + { + try + { + var result = await _royalMailService.GetTrackingInfoAsync(trackingNumber); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting tracking info for {TrackingNumber}", trackingNumber); + return StatusCode(500, new { Error = "Error retrieving tracking information" }); + } + } + + [HttpGet("services")] + [AllowAnonymous] + public async Task>> GetAvailableServices( + [FromQuery] decimal weight = 500, + [FromQuery] string country = "United Kingdom") + { + try + { + var services = await _royalMailService.GetAvailableServicesAsync(weight, country); + return Ok(services); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Royal Mail services"); + return StatusCode(500, new { Error = "Error retrieving shipping services" }); + } + } + + [HttpGet("labels/{shipmentId}")] + [Authorize(Roles = "Admin")] + public async Task GetShippingLabel(string shipmentId) + { + try + { + var labelData = await _royalMailService.GenerateShippingLabelAsync(shipmentId); + + if (labelData != null) + { + return File(labelData, "application/pdf", $"shipping-label-{shipmentId}.pdf"); + } + + return NotFound(new { Error = "Shipping label not found" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating shipping label for {ShipmentId}", shipmentId); + return StatusCode(500, new { Error = "Error generating shipping label" }); + } + } + + [HttpDelete("{shipmentId}")] + [Authorize(Roles = "Admin")] + public async Task CancelShipment(string shipmentId) + { + try + { + var result = await _royalMailService.CancelShipmentAsync(shipmentId); + + if (result) + { + return Ok(new { Message = "Shipment cancelled successfully" }); + } + + return BadRequest(new { Error = "Failed to cancel shipment" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cancelling shipment {ShipmentId}", shipmentId); + return StatusCode(500, new { Error = "Error cancelling shipment" }); + } + } +} + +public class CalculateShippingRequest +{ + public decimal WeightInGrams { get; set; } + public string Country { get; set; } = "United Kingdom"; +} + +public class ShipOrderRequest +{ + public string? ServiceCode { get; set; } +} + +public class ShippingCalculationResult +{ + public bool Success { get; set; } + public List Services { get; set; } = new(); + public string? ErrorMessage { get; set; } +} + +public class ShippingOption +{ + public string ServiceCode { get; set; } = string.Empty; + public string ServiceName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Cost { get; set; } + public int EstimatedDeliveryDays { get; set; } + public bool SupportsTracking { get; set; } +} \ No newline at end of file diff --git a/LittleShop/Program.cs b/LittleShop/Program.cs index fba8b0d..be8e512 100644 --- a/LittleShop/Program.cs +++ b/LittleShop/Program.cs @@ -22,8 +22,16 @@ builder.Services.AddControllers(); builder.Services.AddControllersWithViews(); // Add MVC for Admin Panel // Database -builder.Services.AddDbContext(options => - options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); +if (builder.Environment.EnvironmentName == "Testing") +{ + builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("InMemoryDbForTesting")); +} +else +{ + builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); +} // Authentication - Cookie for Admin Panel, JWT for API var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!"; @@ -67,6 +75,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/LittleShop/Services/RoyalMailService.cs b/LittleShop/Services/RoyalMailService.cs new file mode 100644 index 0000000..2c33819 --- /dev/null +++ b/LittleShop/Services/RoyalMailService.cs @@ -0,0 +1,605 @@ +using System.Text; +using System.Text.Json; +using LittleShop.DTOs; +using LittleShop.Models; + +namespace LittleShop.Services; + +public interface IRoyalMailService +{ + Task CreateShipmentAsync(CreateShipmentRequest request); + Task GetTrackingInfoAsync(string trackingNumber); + Task> GetAvailableServicesAsync(decimal weight, string country); + Task CalculateShippingCostAsync(decimal weight, string country, string serviceCode); + Task GenerateShippingLabelAsync(string shipmentId); + Task CancelShipmentAsync(string shipmentId); +} + +public class RoyalMailShippingService : IRoyalMailService +{ + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly bool _isProduction; + private string? _accessToken; + private DateTime _tokenExpiry; + + public RoyalMailShippingService( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger) + { + _httpClient = httpClient; + _configuration = configuration; + _logger = logger; + _isProduction = !string.IsNullOrEmpty(_configuration["RoyalMail:ClientId"]); + + if (_isProduction) + { + _httpClient.BaseAddress = new Uri("https://api.royalmail.net/"); + } + else + { + _logger.LogInformation("Royal Mail service running in development mode (API credentials not configured)"); + } + } + + public async Task CreateShipmentAsync(CreateShipmentRequest request) + { + if (!_isProduction) + { + return CreateMockShipmentResult(request); + } + + try + { + await EnsureAuthenticatedAsync(); + + var payload = new + { + shipmentType = "Delivery", + serviceCode = request.ServiceCode ?? "1st Class", + recipientName = request.RecipientName, + recipientAddress = new + { + addressLine1 = request.AddressLine1, + addressLine2 = request.AddressLine2, + city = request.City, + postCode = request.PostCode, + country = request.Country + }, + senderAddress = new + { + addressLine1 = _configuration["RoyalMail:SenderAddress1"] ?? "123 Business St", + city = _configuration["RoyalMail:SenderCity"] ?? "London", + postCode = _configuration["RoyalMail:SenderPostCode"] ?? "SW1A 1AA", + country = "United Kingdom" + }, + weight = request.WeightInGrams, + dimensions = new + { + length = request.Length ?? 20, + width = request.Width ?? 15, + height = request.Height ?? 5 + }, + value = request.Value, + currency = "GBP", + reference = request.Reference + }; + + var json = JsonSerializer.Serialize(payload); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); + + var response = await _httpClient.PostAsync("shipping/v2/shipments", content); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + var result = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + return new CreateShipmentResult + { + Success = true, + ShipmentId = result?.ShipmentId ?? Guid.NewGuid().ToString(), + TrackingNumber = result?.TrackingNumber ?? GenerateMockTrackingNumber(), + LabelUrl = result?.LabelUrl, + EstimatedDeliveryDate = DateTime.UtcNow.AddDays(2), + Cost = request.EstimatedCost ?? 5.50m + }; + } + else + { + _logger.LogError("Royal Mail API error: {StatusCode} - {Response}", + response.StatusCode, responseContent); + + return new CreateShipmentResult + { + Success = false, + ErrorMessage = $"Royal Mail API error: {response.StatusCode}" + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating Royal Mail shipment"); + return new CreateShipmentResult + { + Success = false, + ErrorMessage = ex.Message + }; + } + } + + public async Task GetTrackingInfoAsync(string trackingNumber) + { + if (!_isProduction) + { + return CreateMockTrackingResult(trackingNumber); + } + + try + { + await EnsureAuthenticatedAsync(); + + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); + + var response = await _httpClient.GetAsync($"tracking/v2/items/{trackingNumber}"); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + var result = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + return new TrackingResult + { + Success = true, + TrackingNumber = trackingNumber, + Status = result?.Status ?? "In Transit", + LastUpdate = result?.LastUpdate ?? DateTime.UtcNow, + EstimatedDelivery = result?.EstimatedDelivery, + TrackingEvents = result?.Events?.Select(e => new TrackingEvent + { + Timestamp = e.Timestamp, + Location = e.Location, + Description = e.Description, + Status = e.Status + }).ToList() ?? new List() + }; + } + else + { + return new TrackingResult + { + Success = false, + TrackingNumber = trackingNumber, + ErrorMessage = $"Tracking not found: {response.StatusCode}" + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting tracking info for {TrackingNumber}", trackingNumber); + return new TrackingResult + { + Success = false, + TrackingNumber = trackingNumber, + ErrorMessage = ex.Message + }; + } + } + + public async Task> GetAvailableServicesAsync(decimal weight, string country) + { + if (!_isProduction) + { + return CreateMockServices(weight, country); + } + + try + { + await EnsureAuthenticatedAsync(); + + var query = $"services?weight={weight}&country={country}"; + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); + + var response = await _httpClient.GetAsync($"shipping/v2/{query}"); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + var services = JsonSerializer.Deserialize>(responseContent, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + return services?.Select(s => new RoyalMailServiceOption + { + Code = s.Code, + Name = s.Name, + Description = s.Description, + Price = s.Price, + EstimatedDeliveryDays = s.EstimatedDeliveryDays, + MaxWeight = s.MaxWeight, + SupportsTracking = s.SupportsTracking + }).ToList() ?? new List(); + } + + return new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Royal Mail services"); + return CreateMockServices(weight, country); + } + } + + public async Task CalculateShippingCostAsync(decimal weight, string country, string serviceCode) + { + if (!_isProduction) + { + return CalculateMockShippingCost(weight, country, serviceCode); + } + + var services = await GetAvailableServicesAsync(weight, country); + var service = services.FirstOrDefault(s => s.Code == serviceCode); + return service?.Price ?? 0m; + } + + public async Task GenerateShippingLabelAsync(string shipmentId) + { + if (!_isProduction) + { + return GenerateMockLabel(shipmentId); + } + + try + { + await EnsureAuthenticatedAsync(); + + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); + + var response = await _httpClient.GetAsync($"shipping/v2/shipments/{shipmentId}/label"); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsByteArrayAsync(); + } + + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating shipping label for {ShipmentId}", shipmentId); + return null; + } + } + + public async Task CancelShipmentAsync(string shipmentId) + { + if (!_isProduction) + { + _logger.LogInformation("Mock: Cancelled shipment {ShipmentId}", shipmentId); + return true; + } + + try + { + await EnsureAuthenticatedAsync(); + + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); + + var response = await _httpClient.DeleteAsync($"shipping/v2/shipments/{shipmentId}"); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cancelling shipment {ShipmentId}", shipmentId); + return false; + } + } + + private async Task EnsureAuthenticatedAsync() + { + if (!string.IsNullOrEmpty(_accessToken) && DateTime.UtcNow < _tokenExpiry) + return; + + var clientId = _configuration["RoyalMail:ClientId"]; + var clientSecret = _configuration["RoyalMail:ClientSecret"]; + + if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) + { + throw new InvalidOperationException("Royal Mail API credentials not configured"); + } + + var authPayload = new + { + grant_type = "client_credentials", + client_id = clientId, + client_secret = clientSecret + }; + + var json = JsonSerializer.Serialize(authPayload); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync("oauth2/token", content); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + var authResult = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + _accessToken = authResult?.AccessToken; + _tokenExpiry = DateTime.UtcNow.AddSeconds(authResult?.ExpiresIn ?? 3600); + + _logger.LogInformation("Successfully authenticated with Royal Mail API"); + } + else + { + _logger.LogError("Failed to authenticate with Royal Mail API: {StatusCode} - {Response}", + response.StatusCode, responseContent); + throw new InvalidOperationException("Royal Mail API authentication failed"); + } + } + + // Mock methods for development + private CreateShipmentResult CreateMockShipmentResult(CreateShipmentRequest request) + { + var trackingNumber = GenerateMockTrackingNumber(); + + _logger.LogInformation("Mock: Created shipment for {Recipient} to {City}, {Country} - Tracking: {TrackingNumber}", + request.RecipientName, request.City, request.Country, trackingNumber); + + return new CreateShipmentResult + { + Success = true, + ShipmentId = Guid.NewGuid().ToString(), + TrackingNumber = trackingNumber, + EstimatedDeliveryDate = DateTime.UtcNow.AddDays(GetMockDeliveryDays(request.Country)), + Cost = CalculateMockShippingCost(request.WeightInGrams, request.Country, request.ServiceCode ?? "1st Class"), + LabelUrl = $"https://mock-royal-mail/labels/{trackingNumber}.pdf" + }; + } + + private TrackingResult CreateMockTrackingResult(string trackingNumber) + { + var statuses = new[] { "Collected", "In Transit", "Out for Delivery", "Delivered" }; + var randomStatus = statuses[new Random().Next(statuses.Length)]; + + return new TrackingResult + { + Success = true, + TrackingNumber = trackingNumber, + Status = randomStatus, + LastUpdate = DateTime.UtcNow.AddHours(-2), + EstimatedDelivery = DateTime.UtcNow.AddDays(1), + TrackingEvents = new List + { + new() { Timestamp = DateTime.UtcNow.AddDays(-1), Location = "London", Description = "Item collected", Status = "Collected" }, + new() { Timestamp = DateTime.UtcNow.AddHours(-6), Location = "Sorting Office", Description = "Item processed", Status = "In Transit" }, + new() { Timestamp = DateTime.UtcNow.AddHours(-2), Location = "Local Depot", Description = "Item arriving at delivery office", Status = randomStatus } + } + }; + } + + private List CreateMockServices(decimal weight, string country) + { + var services = new List(); + + if (country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase)) + { + services.Add(new RoyalMailServiceOption + { + Code = "1st Class", + Name = "First Class", + Description = "Next working day delivery", + Price = CalculateMockShippingCost(weight, country, "1st Class"), + EstimatedDeliveryDays = 1, + MaxWeight = 2000, + SupportsTracking = true + }); + + services.Add(new RoyalMailServiceOption + { + Code = "2nd Class", + Name = "Second Class", + Description = "2-3 working days delivery", + Price = CalculateMockShippingCost(weight, country, "2nd Class"), + EstimatedDeliveryDays = 3, + MaxWeight = 2000, + SupportsTracking = false + }); + + services.Add(new RoyalMailServiceOption + { + Code = "Signed For", + Name = "Signed For 1st Class", + Description = "Next working day with signature", + Price = CalculateMockShippingCost(weight, country, "Signed For"), + EstimatedDeliveryDays = 1, + MaxWeight = 2000, + SupportsTracking = true + }); + } + else + { + services.Add(new RoyalMailServiceOption + { + Code = "International Standard", + Name = "International Standard", + Description = "5-7 working days to Europe", + Price = CalculateMockShippingCost(weight, country, "International Standard"), + EstimatedDeliveryDays = 7, + MaxWeight = 2000, + SupportsTracking = true + }); + + services.Add(new RoyalMailServiceOption + { + Code = "International Tracked", + Name = "International Tracked & Signed", + Description = "3-5 working days with tracking", + Price = CalculateMockShippingCost(weight, country, "International Tracked"), + EstimatedDeliveryDays = 5, + MaxWeight = 2000, + SupportsTracking = true + }); + } + + return services; + } + + private decimal CalculateMockShippingCost(decimal weight, string country, string serviceCode) + { + var baseRate = country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase) ? 2.50m : 8.50m; + var weightRate = weight / 100 * 0.50m; // £0.50 per 100g + + var serviceMultiplier = serviceCode switch + { + "1st Class" => 1.2m, + "2nd Class" => 1.0m, + "Signed For" => 1.5m, + "International Standard" => 2.0m, + "International Tracked" => 3.0m, + _ => 1.0m + }; + + return Math.Round(baseRate + weightRate * serviceMultiplier, 2); + } + + private int GetMockDeliveryDays(string country) + { + return country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase) ? 2 : 7; + } + + private string GenerateMockTrackingNumber() + { + var random = new Random(); + return $"RM{random.Next(100000000, 999999999)}GB"; + } + + private byte[] GenerateMockLabel(string shipmentId) + { + // Generate a simple mock PDF label (in production this would be the actual Royal Mail label) + var mockPdfContent = $"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>\nendobj\n4 0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Royal Mail Label - {shipmentId}) Tj\nET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000174 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n267\n%%EOF"; + return Encoding.UTF8.GetBytes(mockPdfContent); + } +} + +// DTOs and response models +public class CreateShipmentRequest +{ + public string RecipientName { get; set; } = string.Empty; + public string AddressLine1 { get; set; } = string.Empty; + public string? AddressLine2 { get; set; } + public string City { get; set; } = string.Empty; + public string PostCode { get; set; } = string.Empty; + public string Country { get; set; } = "United Kingdom"; + public decimal WeightInGrams { get; set; } + public decimal? Length { get; set; } + public decimal? Width { get; set; } + public decimal? Height { get; set; } + public decimal Value { get; set; } + public string? ServiceCode { get; set; } + public string? Reference { get; set; } + public decimal? EstimatedCost { get; set; } +} + +public class CreateShipmentResult +{ + public bool Success { get; set; } + public string? ShipmentId { get; set; } + public string? TrackingNumber { get; set; } + public string? LabelUrl { get; set; } + public DateTime? EstimatedDeliveryDate { get; set; } + public decimal Cost { get; set; } + public string? ErrorMessage { get; set; } +} + +public class TrackingResult +{ + public bool Success { get; set; } + public string TrackingNumber { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime LastUpdate { get; set; } + public DateTime? EstimatedDelivery { get; set; } + public List TrackingEvents { get; set; } = new(); + public string? ErrorMessage { get; set; } +} + +public class TrackingEvent +{ + public DateTime Timestamp { get; set; } + public string Location { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +public class RoyalMailServiceOption +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } + public int EstimatedDeliveryDays { get; set; } + public decimal MaxWeight { get; set; } + public bool SupportsTracking { get; set; } +} + +// API Response models +public class RoyalMailTokenResponse +{ + public string AccessToken { get; set; } = string.Empty; + public int ExpiresIn { get; set; } + public string TokenType { get; set; } = string.Empty; +} + +public class RoyalMailShipmentResponse +{ + public string ShipmentId { get; set; } = string.Empty; + public string TrackingNumber { get; set; } = string.Empty; + public string? LabelUrl { get; set; } + public DateTime EstimatedDelivery { get; set; } +} + +public class RoyalMailTrackingResponse +{ + public string Status { get; set; } = string.Empty; + public DateTime LastUpdate { get; set; } + public DateTime? EstimatedDelivery { get; set; } + public List Events { get; set; } = new(); +} + +public class RoyalMailTrackingEvent +{ + public DateTime Timestamp { get; set; } + public string Location { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +public class RoyalMailShippingServiceResponse +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } + public int EstimatedDeliveryDays { get; set; } + public decimal MaxWeight { get; set; } + public bool SupportsTracking { get; set; } +} \ No newline at end of file diff --git a/LittleShop/appsettings.json b/LittleShop/appsettings.json index fffd9a4..e129dcb 100644 --- a/LittleShop/appsettings.json +++ b/LittleShop/appsettings.json @@ -15,8 +15,13 @@ "WebhookSecret": "" }, "RoyalMail": { - "ApiKey": "your-royal-mail-api-key", - "BaseUrl": "https://api.royalmail.com" + "ClientId": "", + "ClientSecret": "", + "BaseUrl": "https://api.royalmail.net/", + "SenderAddress1": "SilverLabs Ltd, 123 Business Street", + "SenderCity": "London", + "SenderPostCode": "SW1A 1AA", + "SenderCountry": "United Kingdom" }, "WebPush": { "VapidPublicKey": "BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4", diff --git a/LittleShop/wwwroot/js/pwa.js b/LittleShop/wwwroot/js/pwa.js index e10e195..dcb5606 100644 --- a/LittleShop/wwwroot/js/pwa.js +++ b/LittleShop/wwwroot/js/pwa.js @@ -336,7 +336,11 @@ class PWAManager { applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) }); - // Send subscription to server + // Send subscription to server with timeout + console.log('PWA: Sending subscription to server...'); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + const response = await fetch('/api/push/subscribe', { method: 'POST', headers: { @@ -347,8 +351,12 @@ class PWAManager { p256dh: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('p256dh')))), auth: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('auth')))) }), - credentials: 'same-origin' + credentials: 'same-origin', + signal: controller.signal }); + + clearTimeout(timeoutId); + console.log('PWA: Server response received:', response.status, response.statusText); if (response.ok) { this.pushSubscription = subscription; @@ -436,7 +444,14 @@ class PWAManager { subscribeBtn.innerHTML = 'Subscribing...'; try { - await this.subscribeToPushNotifications(); + // Add timeout to prevent infinite hanging + const subscriptionPromise = this.subscribeToPushNotifications(); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Push subscription timed out after 15 seconds. This may be due to network connectivity or browser push service issues.')), 15000) + ); + + await Promise.race([subscriptionPromise, timeoutPromise]); + this.showNotification('Push notifications enabled!', { body: 'You will now receive notifications for new orders and updates.' }); @@ -447,6 +462,8 @@ class PWAManager { let userMessage = error.message; if (error.message.includes('permission')) { userMessage = 'Please allow notifications when your browser asks, then try again.'; + } else if (error.message.includes('timeout')) { + userMessage = 'Push notification setup timed out. This may be due to network or browser issues. Please try again or check your internet connection.'; } alert('Failed to enable push notifications: ' + userMessage);