Features: - Complete .NET client SDK for LittleShop API - JWT authentication with automatic token management - Catalog service for products and categories - Order service with payment creation - Retry policies using Polly for resilience - Error handling middleware - Dependency injection support - Comprehensive documentation and examples SDK Components: - Authentication service with token refresh - Strongly-typed models for all API responses - HTTP handlers for retry and error handling - Extension methods for easy DI registration - Example console application demonstrating usage Test Updates: - Fixed test compilation errors - Updated test data builders for new models - Corrected service constructor dependencies - Fixed enum value changes (PaymentStatus, OrderStatus) Documentation: - Complete project README with features and usage - Client SDK README with detailed examples - API endpoint documentation - Security considerations - Deployment guidelines Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
510 lines
17 KiB
C#
510 lines
17 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using LittleShop.Controllers;
|
|
using LittleShop.Data;
|
|
using LittleShop.DTOs;
|
|
using LittleShop.Enums;
|
|
using LittleShop.Models;
|
|
using LittleShop.Tests.Infrastructure;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Xunit;
|
|
|
|
namespace LittleShop.Tests.Integration;
|
|
|
|
public class OrdersControllerTests : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly HttpClient _client;
|
|
private readonly TestWebApplicationFactory _factory;
|
|
private readonly JsonSerializerOptions _jsonOptions;
|
|
|
|
public OrdersControllerTests(TestWebApplicationFactory factory)
|
|
{
|
|
_factory = factory;
|
|
_client = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
|
{
|
|
AllowAutoRedirect = false
|
|
});
|
|
|
|
_jsonOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAllOrders_WithAdminToken_ReturnsAllOrders()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateAdminJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
await SeedTestOrders();
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/orders");
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
var orders = JsonSerializer.Deserialize<List<OrderDto>>(content, _jsonOptions);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
orders.Should().NotBeNull();
|
|
orders.Should().HaveCountGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAllOrders_WithUserToken_ReturnsUnauthorized()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateJwtToken(role: "User");
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/orders");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetOrderById_WithAdminToken_ReturnsOrder()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateAdminJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
var orderId = await SeedOrderAndGetId();
|
|
|
|
// Act
|
|
var response = await _client.GetAsync($"/api/orders/{orderId}");
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
var order = JsonSerializer.Deserialize<OrderDto>(content, _jsonOptions);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
order.Should().NotBeNull();
|
|
order!.Id.Should().Be(orderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetOrdersByIdentity_WithValidToken_ReturnsOrders()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
var identityReference = "test-identity-" + Guid.NewGuid();
|
|
await SeedOrdersForIdentity(identityReference);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync($"/api/orders/by-identity/{identityReference}");
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
var orders = JsonSerializer.Deserialize<List<OrderDto>>(content, _jsonOptions);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
orders.Should().NotBeNull();
|
|
orders.Should().OnlyContain(o => o.IdentityReference == identityReference);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateOrder_WithValidData_ReturnsCreatedOrder()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
var productIds = await SeedProductsAndGetIds();
|
|
|
|
var createOrderDto = new CreateOrderDto
|
|
{
|
|
IdentityReference = "test-identity-" + Guid.NewGuid(),
|
|
ShippingName = "John Doe",
|
|
ShippingAddress = "123 Test Street",
|
|
ShippingCity = "Test City",
|
|
ShippingPostCode = "TE5 7CD",
|
|
ShippingCountry = "United Kingdom",
|
|
Items = new List<CreateOrderItemDto>
|
|
{
|
|
new CreateOrderItemDto { ProductId = productIds[0], Quantity = 2 },
|
|
new CreateOrderItemDto { ProductId = productIds[1], Quantity = 1 }
|
|
}
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(createOrderDto, _jsonOptions);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
// Act
|
|
var response = await _client.PostAsync("/api/orders", content);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var responseContent = await response.Content.ReadAsStringAsync();
|
|
var order = JsonSerializer.Deserialize<OrderDto>(responseContent, _jsonOptions);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
order.Should().NotBeNull();
|
|
order!.IdentityReference.Should().Be(createOrderDto.IdentityReference);
|
|
order.Items.Should().HaveCount(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateOrder_WithInvalidProductId_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
var createOrderDto = new CreateOrderDto
|
|
{
|
|
IdentityReference = "test-identity-" + Guid.NewGuid(),
|
|
ShippingName = "John Doe",
|
|
ShippingAddress = "123 Test Street",
|
|
ShippingCity = "Test City",
|
|
ShippingPostCode = "TE5 7CD",
|
|
ShippingCountry = "United Kingdom",
|
|
Items = new List<CreateOrderItemDto>
|
|
{
|
|
new CreateOrderItemDto { ProductId = Guid.NewGuid(), Quantity = 1 }
|
|
}
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(createOrderDto, _jsonOptions);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
// Act
|
|
var response = await _client.PostAsync("/api/orders", content);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOrderStatus_WithAdminToken_UpdatesStatus()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateAdminJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
var orderId = await SeedOrderAndGetId();
|
|
|
|
var updateDto = new UpdateOrderStatusDto
|
|
{
|
|
Status = OrderStatus.Shipped,
|
|
TrackingNumber = "TRACK123456"
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(updateDto, _jsonOptions);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/api/orders/{orderId}/status", content);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePayment_WithValidOrder_ReturnsPaymentInfo()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
var orderId = await SeedOrderAndGetId();
|
|
|
|
var createPaymentDto = new CreatePaymentDto
|
|
{
|
|
Currency = CryptoCurrency.BTC
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(createPaymentDto, _jsonOptions);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
// Act
|
|
var response = await _client.PostAsync($"/api/orders/{orderId}/payments", content);
|
|
|
|
// Assert - May return error if BTCPay not configured, but should authenticate
|
|
response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelOrder_WithValidIdentity_CancelsOrder()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
var identityReference = "test-identity-" + Guid.NewGuid();
|
|
var orderId = await SeedOrderAndGetId(identityReference);
|
|
|
|
var cancelDto = new CancelOrderDto
|
|
{
|
|
IdentityReference = identityReference
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(cancelDto, _jsonOptions);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
// Act
|
|
var response = await _client.PostAsync($"/api/orders/{orderId}/cancel", content);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelOrder_WithWrongIdentity_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
var orderId = await SeedOrderAndGetId("correct-identity");
|
|
|
|
var cancelDto = new CancelOrderDto
|
|
{
|
|
IdentityReference = "wrong-identity"
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(cancelDto, _jsonOptions);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
// Act
|
|
var response = await _client.PostAsync($"/api/orders/{orderId}/cancel", content);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentWebhook_WithValidData_ReturnsOk()
|
|
{
|
|
// Arrange
|
|
var token = JwtTokenHelper.GenerateJwtToken();
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
var webhookDto = new PaymentWebhookDto
|
|
{
|
|
InvoiceId = "INV123456",
|
|
Status = PaymentStatus.Paid,
|
|
Amount = 100.00m,
|
|
TransactionHash = "tx123456789"
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(webhookDto, _jsonOptions);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
// Act
|
|
var response = await _client.PostAsync("/api/orders/payments/webhook", content);
|
|
|
|
// Assert - Will return BadRequest if invoice not found, but should authenticate
|
|
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
private async Task SeedTestOrders()
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
|
|
|
|
// Clear existing data
|
|
context.Orders.RemoveRange(context.Orders);
|
|
context.Products.RemoveRange(context.Products);
|
|
context.Categories.RemoveRange(context.Categories);
|
|
await context.SaveChangesAsync();
|
|
|
|
// Add test category and product
|
|
var category = new Category
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Test Category",
|
|
Description = "Test",
|
|
IsActive = true
|
|
};
|
|
context.Categories.Add(category);
|
|
|
|
var product = new Product
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Test Product",
|
|
Description = "Test",
|
|
Price = 99.99m,
|
|
CategoryId = category.Id,
|
|
IsActive = true,
|
|
Weight = 1,
|
|
WeightUnit = ProductWeightUnit.Kilograms
|
|
};
|
|
context.Products.Add(product);
|
|
|
|
// Add test orders
|
|
var orders = new[]
|
|
{
|
|
new Order
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
IdentityReference = "test-identity-1",
|
|
Status = OrderStatus.PendingPayment,
|
|
ShippingName = "Customer 1",
|
|
ShippingAddress = "Address 1",
|
|
ShippingCity = "City 1",
|
|
ShippingPostCode = "PC1",
|
|
ShippingCountry = "Country 1",
|
|
TotalAmount = 99.99m,
|
|
CreatedAt = DateTime.UtcNow
|
|
},
|
|
new Order
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
IdentityReference = "test-identity-2",
|
|
Status = OrderStatus.Processing,
|
|
ShippingName = "Customer 2",
|
|
ShippingAddress = "Address 2",
|
|
ShippingCity = "City 2",
|
|
ShippingPostCode = "PC2",
|
|
ShippingCountry = "Country 2",
|
|
TotalAmount = 199.99m,
|
|
CreatedAt = DateTime.UtcNow
|
|
}
|
|
};
|
|
|
|
context.Orders.AddRange(orders);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task<Guid> SeedOrderAndGetId(string identityReference = "test-identity")
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
|
|
|
|
// Add category and product if not exists
|
|
var category = context.Categories.FirstOrDefault() ?? new Category
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Test Category",
|
|
Description = "Test",
|
|
IsActive = true
|
|
};
|
|
if (!context.Categories.Any())
|
|
context.Categories.Add(category);
|
|
|
|
var product = context.Products.FirstOrDefault() ?? new Product
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Test Product",
|
|
Description = "Test",
|
|
Price = 99.99m,
|
|
CategoryId = category.Id,
|
|
IsActive = true,
|
|
Weight = 1,
|
|
WeightUnit = ProductWeightUnit.Kilograms
|
|
};
|
|
if (!context.Products.Any())
|
|
context.Products.Add(product);
|
|
|
|
var order = new Order
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
IdentityReference = identityReference,
|
|
Status = OrderStatus.PendingPayment,
|
|
ShippingName = "Test Customer",
|
|
ShippingAddress = "Test Address",
|
|
ShippingCity = "Test City",
|
|
ShippingPostCode = "TE5 7CD",
|
|
ShippingCountry = "United Kingdom",
|
|
TotalAmount = 99.99m,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
context.Orders.Add(order);
|
|
await context.SaveChangesAsync();
|
|
|
|
return order.Id;
|
|
}
|
|
|
|
private async Task SeedOrdersForIdentity(string identityReference)
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
|
|
|
|
var orders = new[]
|
|
{
|
|
new Order
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
IdentityReference = identityReference,
|
|
Status = OrderStatus.PendingPayment,
|
|
ShippingName = "Customer",
|
|
ShippingAddress = "Address",
|
|
ShippingCity = "City",
|
|
ShippingPostCode = "PC",
|
|
ShippingCountry = "Country",
|
|
TotalAmount = 50.00m,
|
|
CreatedAt = DateTime.UtcNow
|
|
},
|
|
new Order
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
IdentityReference = identityReference,
|
|
Status = OrderStatus.Shipped,
|
|
ShippingName = "Customer",
|
|
ShippingAddress = "Address",
|
|
ShippingCity = "City",
|
|
ShippingPostCode = "PC",
|
|
ShippingCountry = "Country",
|
|
TotalAmount = 75.00m,
|
|
CreatedAt = DateTime.UtcNow.AddDays(-5)
|
|
}
|
|
};
|
|
|
|
context.Orders.AddRange(orders);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task<List<Guid>> SeedProductsAndGetIds()
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
|
|
|
|
var category = new Category
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Test Category",
|
|
Description = "Test",
|
|
IsActive = true
|
|
};
|
|
context.Categories.Add(category);
|
|
|
|
var products = new[]
|
|
{
|
|
new Product
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Product 1",
|
|
Description = "Test",
|
|
Price = 25.00m,
|
|
CategoryId = category.Id,
|
|
IsActive = true,
|
|
Weight = 0.5m,
|
|
WeightUnit = ProductWeightUnit.Kilograms
|
|
},
|
|
new Product
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Product 2",
|
|
Description = "Test",
|
|
Price = 35.00m,
|
|
CategoryId = category.Id,
|
|
IsActive = true,
|
|
Weight = 1.0m,
|
|
WeightUnit = ProductWeightUnit.Kilograms
|
|
}
|
|
};
|
|
|
|
context.Products.AddRange(products);
|
|
await context.SaveChangesAsync();
|
|
|
|
return products.Select(p => p.Id).ToList();
|
|
}
|
|
} |