mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
style: format all files with prettier
This commit is contained in:
@@ -59,19 +59,19 @@ public static class ServiceCollectionExtensions
|
||||
// Scoped: One instance per HTTP request
|
||||
services.AddScoped<IProductService, ProductService>();
|
||||
services.AddScoped<IOrderService, OrderService>();
|
||||
|
||||
|
||||
// Singleton: One instance for app lifetime
|
||||
services.AddSingleton<ICacheService, RedisCacheService>();
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!));
|
||||
|
||||
|
||||
// Transient: New instance every time
|
||||
services.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();
|
||||
|
||||
|
||||
// Options pattern for configuration
|
||||
services.Configure<CatalogOptions>(configuration.GetSection("Catalog"));
|
||||
services.Configure<RedisOptions>(configuration.GetSection("Redis"));
|
||||
|
||||
|
||||
// Factory pattern for conditional creation
|
||||
services.AddScoped<IPriceCalculator>(sp =>
|
||||
{
|
||||
@@ -80,11 +80,11 @@ public static class ServiceCollectionExtensions
|
||||
? sp.GetRequiredService<NewPriceCalculator>()
|
||||
: sp.GetRequiredService<LegacyPriceCalculator>();
|
||||
});
|
||||
|
||||
|
||||
// Keyed services (.NET 8+)
|
||||
services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
|
||||
services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
|
||||
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -111,14 +111,14 @@ public async Task<Product> GetProductAsync(string id, CancellationToken ct = def
|
||||
|
||||
// ✅ CORRECT: Parallel execution with WhenAll
|
||||
public async Task<(Stock, Price)> GetStockAndPriceAsync(
|
||||
string productId,
|
||||
string productId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var stockTask = _stockService.GetAsync(productId, ct);
|
||||
var priceTask = _priceService.GetAsync(productId, ct);
|
||||
|
||||
|
||||
await Task.WhenAll(stockTask, priceTask);
|
||||
|
||||
|
||||
return (await stockTask, await priceTask);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ public ValueTask<Product?> GetCachedProductAsync(string id)
|
||||
{
|
||||
if (_cache.TryGetValue(id, out Product? product))
|
||||
return ValueTask.FromResult(product);
|
||||
|
||||
|
||||
return new ValueTask<Product?>(GetFromDatabaseAsync(id));
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ await Task.Run(async () => await GetDataAsync()); // Wastes thread
|
||||
public class CatalogOptions
|
||||
{
|
||||
public const string SectionName = "Catalog";
|
||||
|
||||
|
||||
public int DefaultPageSize { get; set; } = 50;
|
||||
public int MaxPageSize { get; set; } = 200;
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
@@ -166,7 +166,7 @@ public class CatalogOptions
|
||||
public class RedisOptions
|
||||
{
|
||||
public const string SectionName = "Redis";
|
||||
|
||||
|
||||
public string Connection { get; set; } = "localhost:6379";
|
||||
public string KeyPrefix { get; set; } = "mcp:";
|
||||
public int Database { get; set; } = 0;
|
||||
@@ -195,7 +195,7 @@ services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionNa
|
||||
public class CatalogService
|
||||
{
|
||||
private readonly CatalogOptions _options;
|
||||
|
||||
|
||||
public CatalogService(IOptions<CatalogOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
@@ -206,7 +206,7 @@ public class CatalogService
|
||||
public class DynamicService
|
||||
{
|
||||
private readonly CatalogOptions _options;
|
||||
|
||||
|
||||
public DynamicService(IOptionsSnapshot<CatalogOptions> options)
|
||||
{
|
||||
_options = options.Value; // Fresh value per request
|
||||
@@ -217,7 +217,7 @@ public class DynamicService
|
||||
public class MonitoredService
|
||||
{
|
||||
private CatalogOptions _options;
|
||||
|
||||
|
||||
public MonitoredService(IOptionsMonitor<CatalogOptions> monitor)
|
||||
{
|
||||
_options = monitor.CurrentValue;
|
||||
@@ -236,7 +236,7 @@ public class Result<T>
|
||||
public T? Value { get; }
|
||||
public string? Error { get; }
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
|
||||
private Result(bool isSuccess, T? value, string? error, string? errorCode)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
@@ -244,13 +244,13 @@ public class Result<T>
|
||||
Error = error;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
|
||||
public static Result<T> Success(T value) => new(true, value, null, null);
|
||||
public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);
|
||||
|
||||
|
||||
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
|
||||
IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
|
||||
|
||||
|
||||
public async Task<Result<TNew>> MapAsync<TNew>(Func<T, Task<TNew>> mapper) =>
|
||||
IsSuccess ? Result<TNew>.Success(await mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
|
||||
}
|
||||
@@ -262,19 +262,19 @@ public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, Ca
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
return Result<Order>.Failure(
|
||||
validation.Errors.First().ErrorMessage,
|
||||
validation.Errors.First().ErrorMessage,
|
||||
"VALIDATION_ERROR");
|
||||
|
||||
|
||||
// Business rule check
|
||||
var stock = await _stockService.CheckAsync(request.ProductId, request.Quantity, ct);
|
||||
if (!stock.IsAvailable)
|
||||
return Result<Order>.Failure(
|
||||
$"Insufficient stock: {stock.Available} available, {request.Quantity} requested",
|
||||
"INSUFFICIENT_STOCK");
|
||||
|
||||
|
||||
// Create order
|
||||
var order = await _repository.CreateAsync(request.ToEntity(), ct);
|
||||
|
||||
|
||||
return Result<Order>.Success(order);
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ app.MapPost("/orders", async (
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await orderService.CreateOrderAsync(request, ct);
|
||||
|
||||
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/orders/{result.Value!.Id}", result.Value)
|
||||
: Results.BadRequest(new { error = result.Error, code = result.ErrorCode });
|
||||
@@ -302,12 +302,12 @@ public class AppDbContext : DbContext
|
||||
{
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<Order> Orders => Set<Order>();
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Apply all configurations from assembly
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||
|
||||
|
||||
// Global query filters
|
||||
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
|
||||
}
|
||||
@@ -319,15 +319,15 @@ public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||
public void Configure(EntityTypeBuilder<Product> builder)
|
||||
{
|
||||
builder.ToTable("Products");
|
||||
|
||||
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id).HasMaxLength(40);
|
||||
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
|
||||
builder.Property(p => p.Price).HasPrecision(18, 2);
|
||||
|
||||
|
||||
builder.HasIndex(p => p.Sku).IsUnique();
|
||||
builder.HasIndex(p => new { p.CategoryId, p.Name });
|
||||
|
||||
|
||||
builder.HasMany(p => p.OrderItems)
|
||||
.WithOne(oi => oi.Product)
|
||||
.HasForeignKey(oi => oi.ProductId);
|
||||
@@ -338,32 +338,32 @@ public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||
public class ProductRepository : IProductRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Products
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
}
|
||||
|
||||
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(
|
||||
ProductSearchCriteria criteria,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = _context.Products.AsNoTracking();
|
||||
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
|
||||
query = query.Where(p => EF.Functions.Like(p.Name, $"%{criteria.SearchTerm}%"));
|
||||
|
||||
|
||||
if (criteria.CategoryId.HasValue)
|
||||
query = query.Where(p => p.CategoryId == criteria.CategoryId);
|
||||
|
||||
|
||||
if (criteria.MinPrice.HasValue)
|
||||
query = query.Where(p => p.Price >= criteria.MinPrice);
|
||||
|
||||
|
||||
if (criteria.MaxPrice.HasValue)
|
||||
query = query.Where(p => p.Price <= criteria.MaxPrice);
|
||||
|
||||
|
||||
return await query
|
||||
.OrderBy(p => p.Name)
|
||||
.Skip((criteria.Page - 1) * criteria.PageSize)
|
||||
@@ -379,7 +379,7 @@ public class ProductRepository : IProductRepository
|
||||
public class DapperProductRepository : IProductRepository
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
@@ -387,11 +387,11 @@ public class DapperProductRepository : IProductRepository
|
||||
FROM Products
|
||||
WHERE Id = @Id AND IsDeleted = 0
|
||||
""";
|
||||
|
||||
|
||||
return await _connection.QueryFirstOrDefaultAsync<Product>(
|
||||
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(
|
||||
ProductSearchCriteria criteria,
|
||||
CancellationToken ct = default)
|
||||
@@ -401,43 +401,43 @@ public class DapperProductRepository : IProductRepository
|
||||
FROM Products
|
||||
WHERE IsDeleted = 0
|
||||
""");
|
||||
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
|
||||
{
|
||||
sql.Append(" AND Name LIKE @SearchTerm");
|
||||
parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%");
|
||||
}
|
||||
|
||||
|
||||
if (criteria.CategoryId.HasValue)
|
||||
{
|
||||
sql.Append(" AND CategoryId = @CategoryId");
|
||||
parameters.Add("CategoryId", criteria.CategoryId);
|
||||
}
|
||||
|
||||
|
||||
if (criteria.MinPrice.HasValue)
|
||||
{
|
||||
sql.Append(" AND Price >= @MinPrice");
|
||||
parameters.Add("MinPrice", criteria.MinPrice);
|
||||
}
|
||||
|
||||
|
||||
if (criteria.MaxPrice.HasValue)
|
||||
{
|
||||
sql.Append(" AND Price <= @MaxPrice");
|
||||
parameters.Add("MaxPrice", criteria.MaxPrice);
|
||||
}
|
||||
|
||||
|
||||
sql.Append(" ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY");
|
||||
parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize);
|
||||
parameters.Add("PageSize", criteria.PageSize);
|
||||
|
||||
|
||||
var results = await _connection.QueryAsync<Product>(
|
||||
new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct));
|
||||
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
|
||||
// Multi-mapping for related data
|
||||
public async Task<Order?> GetOrderWithItemsAsync(int orderId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -448,9 +448,9 @@ public class DapperProductRepository : IProductRepository
|
||||
LEFT JOIN Products p ON oi.ProductId = p.Id
|
||||
WHERE o.Id = @OrderId
|
||||
""";
|
||||
|
||||
|
||||
var orderDictionary = new Dictionary<int, Order>();
|
||||
|
||||
|
||||
await _connection.QueryAsync<Order, OrderItem, Product, Order>(
|
||||
new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: ct),
|
||||
(order, item, product) =>
|
||||
@@ -461,17 +461,17 @@ public class DapperProductRepository : IProductRepository
|
||||
existingOrder.Items = new List<OrderItem>();
|
||||
orderDictionary.Add(order.Id, existingOrder);
|
||||
}
|
||||
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
item.Product = product;
|
||||
existingOrder.Items.Add(item);
|
||||
}
|
||||
|
||||
|
||||
return existingOrder;
|
||||
},
|
||||
splitOn: "Id,Id");
|
||||
|
||||
|
||||
return orderDictionary.Values.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -488,41 +488,41 @@ public class CachedProductService : IProductService
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly IDistributedCache _distributedCache;
|
||||
private readonly ILogger<CachedProductService> _logger;
|
||||
|
||||
|
||||
private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"product:{id}";
|
||||
|
||||
|
||||
// L1: Memory cache (in-process, fastest)
|
||||
if (_memoryCache.TryGetValue(cacheKey, out Product? cached))
|
||||
{
|
||||
_logger.LogDebug("L1 cache hit for {CacheKey}", cacheKey);
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
||||
// L2: Distributed cache (Redis)
|
||||
var distributed = await _distributedCache.GetStringAsync(cacheKey, ct);
|
||||
if (distributed != null)
|
||||
{
|
||||
_logger.LogDebug("L2 cache hit for {CacheKey}", cacheKey);
|
||||
var product = JsonSerializer.Deserialize<Product>(distributed);
|
||||
|
||||
|
||||
// Populate L1
|
||||
_memoryCache.Set(cacheKey, product, MemoryCacheDuration);
|
||||
return product;
|
||||
}
|
||||
|
||||
|
||||
// L3: Database
|
||||
_logger.LogDebug("Cache miss for {CacheKey}, fetching from database", cacheKey);
|
||||
var fromDb = await _repository.GetByIdAsync(id, ct);
|
||||
|
||||
|
||||
if (fromDb != null)
|
||||
{
|
||||
var serialized = JsonSerializer.Serialize(fromDb);
|
||||
|
||||
|
||||
// Populate both caches
|
||||
await _distributedCache.SetStringAsync(
|
||||
cacheKey,
|
||||
@@ -532,20 +532,20 @@ public class CachedProductService : IProductService
|
||||
AbsoluteExpirationRelativeToNow = DistributedCacheDuration
|
||||
},
|
||||
ct);
|
||||
|
||||
|
||||
_memoryCache.Set(cacheKey, fromDb, MemoryCacheDuration);
|
||||
}
|
||||
|
||||
|
||||
return fromDb;
|
||||
}
|
||||
|
||||
|
||||
public async Task InvalidateAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"product:{id}";
|
||||
|
||||
|
||||
_memoryCache.Remove(cacheKey);
|
||||
await _distributedCache.RemoveAsync(cacheKey, ct);
|
||||
|
||||
|
||||
_logger.LogInformation("Invalidated cache for {CacheKey}", cacheKey);
|
||||
}
|
||||
}
|
||||
@@ -556,18 +556,18 @@ public class StaleWhileRevalidateCache<T>
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly TimeSpan _freshDuration;
|
||||
private readonly TimeSpan _staleDuration;
|
||||
|
||||
|
||||
public async Task<T?> GetOrCreateAsync(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cached = await _cache.GetStringAsync(key, ct);
|
||||
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<CacheEntry<T>>(cached)!;
|
||||
|
||||
|
||||
if (entry.IsStale && !entry.IsExpired)
|
||||
{
|
||||
// Return stale data immediately, refresh in background
|
||||
@@ -577,17 +577,17 @@ public class StaleWhileRevalidateCache<T>
|
||||
await SetAsync(key, fresh, CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!entry.IsExpired)
|
||||
return entry.Value;
|
||||
}
|
||||
|
||||
|
||||
// Cache miss or expired
|
||||
var value = await factory(ct);
|
||||
await SetAsync(key, value, ct);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
private record CacheEntry<TValue>(TValue Value, DateTime CreatedAt)
|
||||
{
|
||||
public bool IsStale => DateTime.UtcNow - CreatedAt > _freshDuration;
|
||||
@@ -607,24 +607,24 @@ public class OrderServiceTests
|
||||
private readonly Mock<IStockService> _mockStockService;
|
||||
private readonly Mock<IValidator<CreateOrderRequest>> _mockValidator;
|
||||
private readonly OrderService _sut; // System Under Test
|
||||
|
||||
|
||||
public OrderServiceTests()
|
||||
{
|
||||
_mockRepository = new Mock<IOrderRepository>();
|
||||
_mockStockService = new Mock<IStockService>();
|
||||
_mockValidator = new Mock<IValidator<CreateOrderRequest>>();
|
||||
|
||||
|
||||
// Default: validation passes
|
||||
_mockValidator
|
||||
.Setup(v => v.ValidateAsync(It.IsAny<CreateOrderRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ValidationResult());
|
||||
|
||||
|
||||
_sut = new OrderService(
|
||||
_mockRepository.Object,
|
||||
_mockStockService.Object,
|
||||
_mockValidator.Object);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrderAsync_WithValidRequest_ReturnsSuccess()
|
||||
{
|
||||
@@ -635,52 +635,52 @@ public class OrderServiceTests
|
||||
Quantity = 5,
|
||||
CustomerOrderCode = "ORD-2024-001"
|
||||
};
|
||||
|
||||
|
||||
_mockStockService
|
||||
.Setup(s => s.CheckAsync("PROD-001", 5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 });
|
||||
|
||||
|
||||
_mockRepository
|
||||
.Setup(r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Order { Id = 1, CustomerOrderCode = "ORD-2024-001" });
|
||||
|
||||
|
||||
// Act
|
||||
var result = await _sut.CreateOrderAsync(request);
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal(1, result.Value.Id);
|
||||
|
||||
|
||||
_mockRepository.Verify(
|
||||
r => r.CreateAsync(It.Is<Order>(o => o.CustomerOrderCode == "ORD-2024-001"),
|
||||
It.IsAny<CancellationToken>()),
|
||||
r => r.CreateAsync(It.Is<Order>(o => o.CustomerOrderCode == "ORD-2024-001"),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrderAsync_WithInsufficientStock_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = 100 };
|
||||
|
||||
|
||||
_mockStockService
|
||||
.Setup(s => s.CheckAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new StockResult { IsAvailable = false, Available = 5 });
|
||||
|
||||
|
||||
// Act
|
||||
var result = await _sut.CreateOrderAsync(request);
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("INSUFFICIENT_STOCK", result.ErrorCode);
|
||||
Assert.Contains("5 available", result.Error);
|
||||
|
||||
|
||||
_mockRepository.Verify(
|
||||
r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
|
||||
r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
@@ -689,17 +689,17 @@ public class OrderServiceTests
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = quantity };
|
||||
|
||||
|
||||
_mockValidator
|
||||
.Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ValidationResult(new[]
|
||||
{
|
||||
new ValidationFailure("Quantity", "Quantity must be greater than 0")
|
||||
}));
|
||||
|
||||
|
||||
// Act
|
||||
var result = await _sut.CreateOrderAsync(request);
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("VALIDATION_ERROR", result.ErrorCode);
|
||||
@@ -714,7 +714,7 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
|
||||
public ProductsApiTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
@@ -725,23 +725,23 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
services.RemoveAll<DbContextOptions<AppDbContext>>();
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseInMemoryDatabase("TestDb"));
|
||||
|
||||
|
||||
// Replace Redis with memory cache
|
||||
services.RemoveAll<IDistributedCache>();
|
||||
services.AddDistributedMemoryCache();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task GetProduct_WithValidId_ReturnsProduct()
|
||||
{
|
||||
// Arrange
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
|
||||
context.Products.Add(new Product
|
||||
{
|
||||
Id = "TEST-001",
|
||||
@@ -749,22 +749,22 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
Price = 99.99m
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/products/TEST-001");
|
||||
|
||||
|
||||
// Assert
|
||||
response.EnsureSuccessStatusCode();
|
||||
var product = await response.Content.ReadFromJsonAsync<Product>();
|
||||
Assert.Equal("Test Product", product!.Name);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task GetProduct_WithInvalidId_Returns404()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/products/NONEXISTENT");
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
@@ -774,6 +774,7 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
|
||||
1. **Use async/await** all the way through the call stack
|
||||
2. **Inject dependencies** through constructor injection
|
||||
3. **Use IOptions<T>** for typed configuration
|
||||
@@ -786,6 +787,7 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
10. **Use record types** for DTOs and immutable data
|
||||
|
||||
### DON'T
|
||||
|
||||
1. **Don't block on async** with `.Result` or `.Wait()`
|
||||
2. **Don't use async void** except for event handlers
|
||||
3. **Don't catch generic Exception** without re-throwing or logging
|
||||
|
||||
Reference in New Issue
Block a user