diff --git a/.agent/skills/error-handling-patterns/SKILL.md b/.agent/skills/error-handling-patterns/SKILL.md new file mode 100644 index 00000000..ef65cf10 --- /dev/null +++ b/.agent/skills/error-handling-patterns/SKILL.md @@ -0,0 +1,443 @@ +--- +name: error-handling-patterns +description: Global error handling, domain exceptions, và Result pattern. Use for exception middleware, validation errors, Polly resiliency, và health checks. +compatibility: ".NET 8+, Polly, FluentValidation, Microsoft.Extensions.Diagnostics.HealthChecks" +metadata: + author: Velik Ho + version: "1.0" +--- + +# Error Handling Patterns / Mẫu Xử Lý Lỗi + +Error handling và resiliency patterns cho GoodGo microservices. + +## When to Use This Skill / Khi Nào Sử Dụng + +Use this skill when: +- Implementing global exception handling / Triển khai xử lý exception toàn cục +- Creating domain exceptions / Tạo domain exceptions +- Setting up retry policies with Polly / Cài đặt retry policies với Polly +- Implementing Circuit Breaker pattern / Triển khai Circuit Breaker +- Configuring health checks / Cấu hình health checks +- Handling validation errors / Xử lý validation errors + +## Core Concepts / Khái Niệm Cốt Lõi + +### Exception Hierarchy / Phân Cấp Exception + +``` +ApplicationException (Base) +├── DomainException # Business rule violations +│ ├── ValidationException # Input validation errors +│ ├── NotFoundException # Resource not found +│ └── ConflictException # Duplicate resource +├── InfrastructureException # External service failures +│ ├── DatabaseException # Database errors +│ └── ExternalServiceException # Third-party API errors +└── AuthorizationException # Permission errors +``` + +### HTTP Status Code Mapping / Ánh Xạ Status Code + +| Exception Type | HTTP Status | Use Case | +|----------------|-------------|----------| +| `ValidationException` | 400 | Invalid input data | +| `UnauthorizedAccessException` | 401 | Missing/invalid token | +| `ForbiddenException` | 403 | No permission | +| `NotFoundException` | 404 | Resource doesn't exist | +| `ConflictException` | 409 | Duplicate resource | +| `DomainException` | 422 | Business rule violation | +| `Exception` | 500 | Unexpected errors | + +### Resiliency Patterns / Mẫu Phục Hồi + +| Pattern | Purpose | When to Use | +|---------|---------|-------------| +| **Retry** | Thử lại khi lỗi tạm thời | Network timeouts, transient DB errors | +| **Circuit Breaker** | Ngắt mạch khi service chết | External API calls | +| **Timeout** | Giới hạn thời gian chờ | Long-running operations | +| **Bulkhead** | Cô lập tài nguyên | Prevent cascade failures | + +## Key Patterns / Mẫu Chính + +### Global Exception Middleware + +```csharp +/// +/// EN: Global exception handler middleware. +/// VI: Middleware xử lý exception toàn cục. +/// +public class ExceptionHandlerMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlerMiddleware( + RequestDelegate next, + ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception ex) + { + // EN: Log full exception details + // VI: Log chi tiết exception đầy đủ + _logger.LogError(ex, + "EN: Unhandled exception / VI: Exception không xử lý: {Path}", + context.Request.Path); + + var (statusCode, errorCode, message) = ex switch + { + ValidationException ve => (400, "VALIDATION_ERROR", ve.Message), + UnauthorizedAccessException => (401, "UNAUTHORIZED", "Authentication required"), + ForbiddenException => (403, "FORBIDDEN", "Access denied"), + NotFoundException nf => (404, "NOT_FOUND", nf.Message), + ConflictException ce => (409, "CONFLICT", ce.Message), + DomainException de => (422, "DOMAIN_ERROR", de.Message), + _ => (500, "INTERNAL_ERROR", "An unexpected error occurred") + }; + + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + + var response = new ApiResponse + { + Success = false, + Error = message, + ErrorCode = errorCode + }; + + await context.Response.WriteAsJsonAsync(response); + } +} + +// EN: Register in Program.cs / VI: Đăng ký trong Program.cs +app.UseMiddleware(); +``` + +### Domain Exception Classes + +```csharp +/// +/// EN: Base domain exception. +/// VI: Domain exception cơ sở. +/// +public class DomainException : Exception +{ + public string ErrorCode { get; } + + public DomainException(string message, string errorCode = "DOMAIN_ERROR") + : base(message) + { + ErrorCode = errorCode; + } +} + +/// +/// EN: Resource not found exception. +/// VI: Exception không tìm thấy resource. +/// +public class NotFoundException : DomainException +{ + public NotFoundException(string resourceType, object resourceId) + : base($"{resourceType} with ID {resourceId} was not found", "NOT_FOUND") + { } + + public NotFoundException(string message) + : base(message, "NOT_FOUND") + { } +} + +/// +/// EN: Conflict/duplicate resource exception. +/// VI: Exception trùng lặp resource. +/// +public class ConflictException : DomainException +{ + public ConflictException(string message) + : base(message, "CONFLICT") + { } +} + +/// +/// EN: Validation exception with errors. +/// VI: Exception validation với danh sách lỗi. +/// +public class ValidationException : DomainException +{ + public IReadOnlyDictionary Errors { get; } + + public ValidationException(string message) + : base(message, "VALIDATION_ERROR") + { + Errors = new Dictionary(); + } + + public ValidationException(IReadOnlyDictionary errors) + : base("One or more validation errors occurred", "VALIDATION_ERROR") + { + Errors = errors; + } +} +``` + +### Retry with Polly + +```csharp +/// +/// EN: Configure Polly retry policies. +/// VI: Cấu hình Polly retry policies. +/// + +// EN: Register resilient HttpClient / VI: Đăng ký HttpClient có khả năng phục hồi +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["Services:External:BaseUrl"]!); + client.Timeout = TimeSpan.FromSeconds(30); +}) +.AddResilienceHandler("external-api", builder => +{ + // EN: Retry with exponential backoff + // VI: Retry với thời gian chờ tăng dần + builder.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential, + ShouldHandle = new PredicateBuilder() + .HandleResult(r => !r.IsSuccessStatusCode && (int)r.StatusCode >= 500) + .Handle() + .Handle(), + OnRetry = args => + { + Console.WriteLine($"Retry {args.AttemptNumber} after {args.RetryDelay}"); + return ValueTask.CompletedTask; + } + }); + + // EN: Circuit breaker + // VI: Circuit breaker + builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions + { + FailureRatio = 0.5, + SamplingDuration = TimeSpan.FromSeconds(30), + MinimumThroughput = 10, + BreakDuration = TimeSpan.FromSeconds(30) + }); + + // EN: Timeout + // VI: Timeout + builder.AddTimeout(TimeSpan.FromSeconds(10)); +}); +``` + +### Health Checks + +```csharp +/// +/// EN: Configure health checks. +/// VI: Cấu hình health checks. +/// + +// EN: Register health checks / VI: Đăng ký health checks +builder.Services.AddHealthChecks() + .AddNpgSql( + connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!, + name: "postgresql", + tags: new[] { "db", "critical" }) + .AddRedis( + redisConnectionString: builder.Configuration["Redis:ConnectionString"]!, + name: "redis", + tags: new[] { "cache" }) + .AddCheck("external-service", tags: new[] { "external" }); + +// EN: Map health endpoints / VI: Map health endpoints +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + Predicate = _ => false // EN: Just check app is running / VI: Chỉ kiểm tra app đang chạy +}); + +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = check => check.Tags.Contains("critical") +}); + +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse +}); +``` + +### Custom Health Check + +```csharp +/// +/// EN: Custom health check for external service. +/// VI: Health check tùy chỉnh cho external service. +/// +public class ExternalServiceHealthCheck : IHealthCheck +{ + private readonly HttpClient _httpClient; + + public ExternalServiceHealthCheck(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient("ExternalService"); + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken ct = default) + { + try + { + var response = await _httpClient.GetAsync("/health", ct); + + return response.IsSuccessStatusCode + ? HealthCheckResult.Healthy("External service is healthy") + : HealthCheckResult.Degraded($"External service returned {response.StatusCode}"); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy( + "External service is unavailable", + exception: ex); + } + } +} +``` + +## Common Mistakes / Lỗi Thường Gặp + +### 1. Catching Generic Exceptions + +```csharp +// ❌ BAD: Swallowing all exceptions +try +{ + await ProcessOrderAsync(order); +} +catch (Exception) +{ + // Do nothing +} + +// ✅ GOOD: Handle specific exceptions +try +{ + await ProcessOrderAsync(order); +} +catch (ValidationException ex) +{ + _logger.LogWarning(ex, "Validation failed"); + throw; +} +catch (Exception ex) +{ + _logger.LogError(ex, "Unexpected error processing order"); + throw; +} +``` + +### 2. Exposing Internal Errors + +```csharp +// ❌ BAD: Exposing stack trace +return StatusCode(500, new { Error = ex.ToString() }); + +// ✅ GOOD: Generic message for internal errors +return StatusCode(500, new ApiResponse +{ + Success = false, + Error = "An unexpected error occurred", + ErrorCode = "INTERNAL_ERROR" +}); +``` + +### 3. Missing Retry Configuration + +```csharp +// ❌ BAD: No retry for transient failures +var response = await _httpClient.GetAsync("/api/data"); + +// ✅ GOOD: Use resilient HttpClient with Polly +// (configured at DI registration level) +var response = await _resilientHttpClient.GetAsync("/api/data"); +``` + +### 4. Ignoring Cancellation Token + +```csharp +// ❌ BAD: Ignoring cancellation +await Task.Delay(5000); + +// ✅ GOOD: Respect cancellation +await Task.Delay(5000, cancellationToken); +``` + +## Quick Reference / Tham Chiếu Nhanh + +### Error Response Format + +```json +{ + "success": false, + "error": "User-friendly error message", + "errorCode": "VALIDATION_ERROR", + "errors": { + "email": ["Email is required", "Invalid email format"], + "password": ["Password must be at least 8 characters"] + } +} +``` + +### Polly Strategies + +| Strategy | Purpose | Typical Config | +|----------|---------|----------------| +| **Retry** | Retry transient failures | 3 attempts, exponential backoff | +| **Circuit Breaker** | Stop calling failing service | 50% failure, 30s break | +| **Timeout** | Limit wait time | 10-30 seconds | +| **Rate Limiter** | Throttle requests | X requests/minute | + +### Health Check Tags + +| Tag | Purpose | +|-----|---------| +| `critical` | Required for app to function | +| `db` | Database connectivity | +| `cache` | Cache connectivity | +| `external` | External service | + +### Common Status Codes + +```csharp +return BadRequest(...); // 400 +return Unauthorized(...); // 401 +return Forbid(...); // 403 +return NotFound(...); // 404 +return Conflict(...); // 409 +return UnprocessableEntity(...); // 422 +return StatusCode(500, ...); // 500 +``` + +## Resources / Tài Nguyên + +- [Detailed Examples](./references/REFERENCE.md) - Full code examples +- [API Design](../api-design/SKILL.md) - API error responses +- [Security](../security/SKILL.md) - Authentication errors +- [Testing Patterns](../testing-patterns/SKILL.md) - Testing error handling +- [Project Rules](../project-rules/SKILL.md) - Coding standards diff --git a/.agent/skills/error-handling-patterns/references/REFERENCE.md b/.agent/skills/error-handling-patterns/references/REFERENCE.md new file mode 100644 index 00000000..97f40b33 --- /dev/null +++ b/.agent/skills/error-handling-patterns/references/REFERENCE.md @@ -0,0 +1,889 @@ +# Error Handling Patterns - Detailed Reference + +Detailed code examples for error handling and resiliency patterns in ASP.NET Core. + +## Table of Contents + +1. [Exception Middleware](#exception-middleware) +2. [Domain Exceptions](#domain-exceptions) +3. [Validation with FluentValidation](#validation-with-fluentvalidation) +4. [Result Pattern](#result-pattern) +5. [Polly Resiliency](#polly-resiliency) +6. [Health Checks](#health-checks) +7. [Problem Details](#problem-details) + +--- + +## Exception Middleware + +### Complete Exception Handler + +```csharp +/// +/// EN: Global exception handler middleware with detailed error mapping. +/// VI: Middleware xử lý exception toàn cục với ánh xạ lỗi chi tiết. +/// +public class ExceptionHandlerMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _environment; + + public ExceptionHandlerMiddleware( + RequestDelegate next, + ILogger logger, + IHostEnvironment environment) + { + _next = next; + _logger = logger; + _environment = environment; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception ex) + { + var traceId = Activity.Current?.Id ?? context.TraceIdentifier; + + // EN: Log with appropriate level based on exception type + // VI: Log với level phù hợp dựa trên loại exception + LogException(ex, traceId); + + var response = CreateErrorResponse(ex, traceId); + + context.Response.StatusCode = response.StatusCode; + context.Response.ContentType = "application/json"; + + await context.Response.WriteAsJsonAsync(response.Body); + } + + private void LogException(Exception ex, string traceId) + { + var message = "EN: Error occurred / VI: Đã xảy ra lỗi TraceId: {TraceId}"; + + switch (ex) + { + case ValidationException: + case NotFoundException: + _logger.LogWarning(ex, message, traceId); + break; + case DomainException: + _logger.LogWarning(ex, message, traceId); + break; + default: + _logger.LogError(ex, message, traceId); + break; + } + } + + private (int StatusCode, object Body) CreateErrorResponse(Exception ex, string traceId) + { + return ex switch + { + ValidationException ve => (400, new ErrorResponse + { + Success = false, + Error = ve.Message, + ErrorCode = "VALIDATION_ERROR", + Errors = ve.Errors, + TraceId = traceId + }), + + UnauthorizedAccessException => (401, new ErrorResponse + { + Success = false, + Error = "Authentication required", + ErrorCode = "UNAUTHORIZED", + TraceId = traceId + }), + + ForbiddenException fe => (403, new ErrorResponse + { + Success = false, + Error = fe.Message, + ErrorCode = "FORBIDDEN", + TraceId = traceId + }), + + NotFoundException nf => (404, new ErrorResponse + { + Success = false, + Error = nf.Message, + ErrorCode = "NOT_FOUND", + TraceId = traceId + }), + + ConflictException ce => (409, new ErrorResponse + { + Success = false, + Error = ce.Message, + ErrorCode = "CONFLICT", + TraceId = traceId + }), + + DomainException de => (422, new ErrorResponse + { + Success = false, + Error = de.Message, + ErrorCode = de.ErrorCode, + TraceId = traceId + }), + + _ => (500, new ErrorResponse + { + Success = false, + Error = _environment.IsDevelopment() + ? ex.Message + : "An unexpected error occurred", + ErrorCode = "INTERNAL_ERROR", + TraceId = traceId, + StackTrace = _environment.IsDevelopment() ? ex.StackTrace : null + }) + }; + } +} + +/// +/// EN: Standardized error response. +/// VI: Response lỗi chuẩn hóa. +/// +public class ErrorResponse +{ + public bool Success { get; set; } + public string Error { get; set; } = string.Empty; + public string ErrorCode { get; set; } = string.Empty; + public string? TraceId { get; set; } + public IReadOnlyDictionary? Errors { get; set; } + public string? StackTrace { get; set; } +} +``` + +--- + +## Domain Exceptions + +### Complete Exception Hierarchy + +```csharp +/// +/// EN: Base domain exception with error code. +/// VI: Domain exception cơ sở với error code. +/// +public abstract class DomainException : Exception +{ + public string ErrorCode { get; } + + protected DomainException(string message, string errorCode) + : base(message) + { + ErrorCode = errorCode; + } + + protected DomainException(string message, string errorCode, Exception innerException) + : base(message, innerException) + { + ErrorCode = errorCode; + } +} + +/// +/// EN: Validation exception with field-level errors. +/// VI: Exception validation với lỗi từng field. +/// +public class ValidationException : DomainException +{ + public IReadOnlyDictionary Errors { get; } + + public ValidationException(string message) + : base(message, "VALIDATION_ERROR") + { + Errors = new Dictionary(); + } + + public ValidationException(string field, string error) + : base($"Validation failed for {field}", "VALIDATION_ERROR") + { + Errors = new Dictionary + { + { field, new[] { error } } + }; + } + + public ValidationException(IDictionary errors) + : base("One or more validation errors occurred", "VALIDATION_ERROR") + { + Errors = new Dictionary(errors); + } + + public ValidationException(IEnumerable failures) + : base("One or more validation errors occurred", "VALIDATION_ERROR") + { + Errors = failures + .GroupBy(f => f.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(f => f.ErrorMessage).ToArray()); + } +} + +/// +/// EN: Resource not found exception. +/// VI: Exception không tìm thấy resource. +/// +public class NotFoundException : DomainException +{ + public string ResourceType { get; } + public object? ResourceId { get; } + + public NotFoundException(string resourceType, object resourceId) + : base($"{resourceType} with ID '{resourceId}' was not found", "NOT_FOUND") + { + ResourceType = resourceType; + ResourceId = resourceId; + } + + public NotFoundException(string message) + : base(message, "NOT_FOUND") + { + ResourceType = "Resource"; + } +} + +/// +/// EN: Conflict exception for duplicate resources. +/// VI: Exception xung đột cho resource trùng lặp. +/// +public class ConflictException : DomainException +{ + public ConflictException(string message) + : base(message, "CONFLICT") + { } + + public ConflictException(string resourceType, string identifier) + : base($"{resourceType} with identifier '{identifier}' already exists", "CONFLICT") + { } +} + +/// +/// EN: Forbidden exception for permission errors. +/// VI: Exception forbidden cho lỗi phân quyền. +/// +public class ForbiddenException : DomainException +{ + public ForbiddenException(string message = "You do not have permission to perform this action") + : base(message, "FORBIDDEN") + { } +} + +/// +/// EN: Business rule exception. +/// VI: Exception quy tắc nghiệp vụ. +/// +public class BusinessRuleException : DomainException +{ + public BusinessRuleException(string message, string errorCode = "BUSINESS_RULE_VIOLATION") + : base(message, errorCode) + { } +} +``` + +--- + +## Validation with FluentValidation + +### Validation Behavior for MediatR + +```csharp +/// +/// EN: MediatR pipeline behavior for FluentValidation. +/// VI: MediatR pipeline behavior cho FluentValidation. +/// +public class ValidationBehavior + : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!_validators.Any()) + return await next(); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + throw new ValidationException(failures); + + return await next(); + } +} + +// EN: Registration in Program.cs / VI: Đăng ký trong Program.cs +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); +}); + +builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly); +``` + +### Sample Validators + +```csharp +/// +/// EN: Validator for CreateOrderCommand. +/// VI: Validator cho CreateOrderCommand. +/// +public class CreateOrderCommandValidator : AbstractValidator +{ + public CreateOrderCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + + RuleFor(x => x.ShippingAddress) + .NotNull().WithMessage("Shipping address is required") + .SetValidator(new AddressValidator()!); + + RuleFor(x => x.Items) + .NotEmpty().WithMessage("At least one item is required"); + + RuleForEach(x => x.Items) + .SetValidator(new OrderItemValidator()); + } +} + +public class AddressValidator : AbstractValidator
+{ + public AddressValidator() + { + RuleFor(x => x.Street) + .NotEmpty().WithMessage("Street is required") + .MaximumLength(200); + + RuleFor(x => x.City) + .NotEmpty().WithMessage("City is required") + .MaximumLength(100); + + RuleFor(x => x.PostalCode) + .NotEmpty().WithMessage("Postal code is required") + .Matches(@"^\d{5}(-\d{4})?$").WithMessage("Invalid postal code format"); + } +} + +public class OrderItemValidator : AbstractValidator +{ + public OrderItemValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty().WithMessage("Product ID is required"); + + RuleFor(x => x.Quantity) + .GreaterThan(0).WithMessage("Quantity must be positive"); + + RuleFor(x => x.UnitPrice) + .GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative"); + } +} +``` + +--- + +## Result Pattern + +### Generic Result Type + +```csharp +/// +/// EN: Result type for operations that can fail. +/// VI: Result type cho operations có thể thất bại. +/// +public class Result +{ + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + public string? Error { get; } + public string? ErrorCode { get; } + + protected Result(bool isSuccess, string? error, string? errorCode) + { + IsSuccess = isSuccess; + Error = error; + ErrorCode = errorCode; + } + + public static Result Success() => new(true, null, null); + public static Result Failure(string error, string errorCode = "ERROR") + => new(false, error, errorCode); + public static Result Success(T value) => new(value, true, null, null); + public static Result Failure(string error, string errorCode = "ERROR") + => new(default!, false, error, errorCode); +} + +/// +/// EN: Result type with value. +/// VI: Result type có giá trị. +/// +public class Result : Result +{ + public T Value { get; } + + internal Result(T value, bool isSuccess, string? error, string? errorCode) + : base(isSuccess, error, errorCode) + { + Value = value; + } + + public static implicit operator Result(T value) => Success(value); +} + +/// +/// EN: Result extensions for common patterns. +/// VI: Extensions cho các mẫu phổ biến. +/// +public static class ResultExtensions +{ + public static Result Map( + this Result result, + Func mapper) + { + return result.IsSuccess + ? Result.Success(mapper(result.Value)) + : Result.Failure(result.Error!, result.ErrorCode!); + } + + public static async Task> MapAsync( + this Task> resultTask, + Func mapper) + { + var result = await resultTask; + return result.Map(mapper); + } + + public static Result Ensure( + this Result result, + Func predicate, + string error, + string errorCode = "ERROR") + { + if (result.IsFailure) + return result; + + return predicate(result.Value) + ? result + : Result.Failure(error, errorCode); + } +} +``` + +### Using Result Pattern + +```csharp +/// +/// EN: Command handler returning Result instead of throwing. +/// VI: Command handler trả về Result thay vì throw. +/// +public class CreateOrderCommandHandler + : IRequestHandler> +{ + private readonly IOrderRepository _orderRepository; + private readonly IUserService _userService; + + public async Task> Handle( + CreateOrderCommand request, + CancellationToken ct) + { + // EN: Validate user exists + // VI: Xác thực user tồn tại + var userExists = await _userService.ExistsAsync(request.UserId, ct); + if (!userExists) + return Result.Failure("User not found", "USER_NOT_FOUND"); + + // EN: Validate items + // VI: Xác thực items + if (!request.Items.Any()) + return Result.Failure("At least one item required", "EMPTY_ORDER"); + + // EN: Create order + // VI: Tạo order + var order = new Order(request.UserId, request.ShippingAddress); + foreach (var item in request.Items) + { + order.AddItem(item.ProductId, item.Quantity, item.UnitPrice); + } + + await _orderRepository.AddAsync(order, ct); + await _orderRepository.UnitOfWork.SaveChangesAsync(ct); + + return Result.Success(new OrderResult(order.Id)); + } +} + +// EN: Controller using Result pattern / VI: Controller dùng Result pattern +[HttpPost] +public async Task CreateOrder( + CreateOrderRequest request, + CancellationToken ct) +{ + var command = request.ToCommand(GetUserId()); + var result = await _mediator.Send(command, ct); + + if (result.IsFailure) + { + return result.ErrorCode switch + { + "USER_NOT_FOUND" => NotFound(ApiResponse.Fail(result.Error!, result.ErrorCode)), + "EMPTY_ORDER" => BadRequest(ApiResponse.Fail(result.Error!, result.ErrorCode)), + _ => StatusCode(500, ApiResponse.Fail(result.Error!, result.ErrorCode)) + }; + } + + return CreatedAtAction( + nameof(GetOrder), + new { orderId = result.Value.OrderId }, + ApiResponse.Ok(result.Value)); +} +``` + +--- + +## Polly Resiliency + +### Comprehensive Resilience Configuration + +```csharp +/// +/// EN: Configure resilient HTTP client with Polly. +/// VI: Cấu hình HTTP client có khả năng phục hồi với Polly. +/// + +// EN: Using Microsoft.Extensions.Http.Resilience (Polly v8 integration) +// VI: Sử dụng Microsoft.Extensions.Http.Resilience (tích hợp Polly v8) +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["Services:Payment:BaseUrl"]!); +}) +.AddResilienceHandler("payment-service", builder => +{ + // EN: Retry policy with exponential backoff + // VI: Retry policy với thời gian chờ tăng dần + builder.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromMilliseconds(500), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, // EN: Add randomness / VI: Thêm ngẫu nhiên + ShouldHandle = new PredicateBuilder() + .HandleResult(r => (int)r.StatusCode >= 500) + .Handle() + .Handle(), + OnRetry = args => + { + Console.WriteLine( + $"EN: Retry {args.AttemptNumber} after {args.RetryDelay} / " + + $"VI: Thử lại lần {args.AttemptNumber} sau {args.RetryDelay}"); + return ValueTask.CompletedTask; + } + }); + + // EN: Circuit breaker + // VI: Circuit breaker + builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions + { + FailureRatio = 0.5, // EN: 50% failures / VI: 50% thất bại + SamplingDuration = TimeSpan.FromSeconds(30), + MinimumThroughput = 10, + BreakDuration = TimeSpan.FromSeconds(30), + OnOpened = args => + { + Console.WriteLine("EN: Circuit opened / VI: Circuit đã mở"); + return ValueTask.CompletedTask; + }, + OnClosed = args => + { + Console.WriteLine("EN: Circuit closed / VI: Circuit đã đóng"); + return ValueTask.CompletedTask; + } + }); + + // EN: Timeout per request + // VI: Timeout cho mỗi request + builder.AddTimeout(TimeSpan.FromSeconds(10)); +}); +``` + +### Resilient Database Operations + +```csharp +/// +/// EN: Configure EF Core with retry logic. +/// VI: Cấu hình EF Core với logic retry. +/// +builder.Services.AddDbContext(options => +{ + options.UseNpgsql( + builder.Configuration.GetConnectionString("DefaultConnection"), + npgsqlOptions => + { + // EN: Enable retry on transient failures + // VI: Bật retry cho lỗi tạm thời + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 3, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + + npgsqlOptions.CommandTimeout(30); + }); +}); +``` + +--- + +## Health Checks + +### Comprehensive Health Check Setup + +```csharp +/// +/// EN: Configure comprehensive health checks. +/// VI: Cấu hình health checks toàn diện. +/// + +// EN: Add health checks / VI: Thêm health checks +builder.Services.AddHealthChecks() + // EN: Database / VI: Database + .AddNpgSql( + connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!, + name: "postgresql", + failureStatus: HealthStatus.Unhealthy, + tags: new[] { "db", "critical" }) + + // EN: Redis cache / VI: Redis cache + .AddRedis( + redisConnectionString: builder.Configuration["Redis:ConnectionString"]!, + name: "redis", + failureStatus: HealthStatus.Degraded, + tags: new[] { "cache" }) + + // EN: External services / VI: Services bên ngoài + .AddUrlGroup( + new Uri(builder.Configuration["Services:Payment:HealthUrl"]!), + name: "payment-service", + failureStatus: HealthStatus.Degraded, + tags: new[] { "external" }) + + // EN: Custom health check / VI: Health check tùy chỉnh + .AddCheck("storage", tags: new[] { "storage", "critical" }); + +// EN: Configure endpoints / VI: Cấu hình endpoints +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + // EN: Liveness - just check if app is running + // VI: Liveness - chỉ kiểm tra app có đang chạy không + Predicate = _ => false, + ResponseWriter = async (context, report) => + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"status\":\"Healthy\"}"); + } +}); + +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + // EN: Readiness - check critical dependencies + // VI: Readiness - kiểm tra các dependency quan trọng + Predicate = check => check.Tags.Contains("critical"), + ResponseWriter = WriteHealthCheckResponse +}); + +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = WriteHealthCheckResponse +}); + +static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report) +{ + context.Response.ContentType = "application/json"; + + var result = new + { + status = report.Status.ToString(), + totalDuration = report.TotalDuration.TotalMilliseconds, + checks = report.Entries.Select(e => new + { + name = e.Key, + status = e.Value.Status.ToString(), + duration = e.Value.Duration.TotalMilliseconds, + description = e.Value.Description, + exception = e.Value.Exception?.Message + }) + }; + + await context.Response.WriteAsJsonAsync(result); +} +``` + +### Custom Health Check Implementation + +```csharp +/// +/// EN: Custom health check for storage service. +/// VI: Health check tùy chỉnh cho storage service. +/// +public class StorageHealthCheck : IHealthCheck +{ + private readonly IMinioClient _minioClient; + private readonly ILogger _logger; + + public StorageHealthCheck( + IMinioClient minioClient, + ILogger logger) + { + _minioClient = minioClient; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + // EN: Check if bucket exists and is accessible + // VI: Kiểm tra bucket tồn tại và có thể truy cập + var bucketExists = await _minioClient.BucketExistsAsync( + new BucketExistsArgs().WithBucket("uploads"), + cancellationToken); + + if (!bucketExists) + { + return HealthCheckResult.Degraded( + "Storage bucket not found", + data: new Dictionary + { + { "bucket", "uploads" } + }); + } + + return HealthCheckResult.Healthy( + "Storage service is healthy", + data: new Dictionary + { + { "bucket", "uploads" }, + { "status", "available" } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "EN: Storage health check failed / VI: Health check storage thất bại"); + + return HealthCheckResult.Unhealthy( + "Storage service is unavailable", + exception: ex); + } + } +} +``` + +--- + +## Problem Details + +### RFC 7807 Problem Details + +```csharp +/// +/// EN: Configure Problem Details for standardized errors. +/// VI: Cấu hình Problem Details cho lỗi chuẩn hóa. +/// + +// EN: Add Problem Details / VI: Thêm Problem Details +builder.Services.AddProblemDetails(options => +{ + options.CustomizeProblemDetails = context => + { + context.ProblemDetails.Extensions["traceId"] = + Activity.Current?.Id ?? context.HttpContext.TraceIdentifier; + context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow; + }; +}); + +// EN: Use exception handler with Problem Details +// VI: Sử dụng exception handler với Problem Details +app.UseExceptionHandler(appBuilder => +{ + appBuilder.Run(async context => + { + var exceptionHandler = context.Features.Get(); + var exception = exceptionHandler?.Error; + + if (exception != null) + { + var problemDetails = exception switch + { + ValidationException ve => new ProblemDetails + { + Status = 400, + Title = "Validation Error", + Detail = ve.Message, + Extensions = { ["errors"] = ve.Errors } + }, + NotFoundException nf => new ProblemDetails + { + Status = 404, + Title = "Not Found", + Detail = nf.Message + }, + _ => new ProblemDetails + { + Status = 500, + Title = "Internal Server Error", + Detail = "An unexpected error occurred" + } + }; + + problemDetails.Extensions["traceId"] = context.TraceIdentifier; + context.Response.StatusCode = problemDetails.Status ?? 500; + context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsJsonAsync(problemDetails); + } + }); +}); +``` + +--- + +## Resources / Tài Nguyên + +- [Microsoft: Handle Errors in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/web-api/handle-errors) +- [Polly Documentation](https://github.com/App-vNext/Polly) +- [FluentValidation](https://docs.fluentvalidation.net/) +- [RFC 7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807) +- [Health Checks in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks) diff --git a/.agent/skills/repository-pattern/SKILL.md b/.agent/skills/repository-pattern/SKILL.md new file mode 100644 index 00000000..13cc0179 --- /dev/null +++ b/.agent/skills/repository-pattern/SKILL.md @@ -0,0 +1,369 @@ +--- +name: repository-pattern +description: Entity Framework Core repository và data access patterns. Use for DbContext, repositories, migrations, aggregate roots, Unit of Work, và CQRS queries. +compatibility: ".NET 8+, Entity Framework Core 8+, Dapper" +metadata: + author: Velik Ho + version: "1.0" +--- + +# Repository Pattern / Mẫu Repository + +Repository pattern cho GoodGo microservices theo chuẩn Domain-Driven Design (DDD). + +## When to Use This Skill / Khi Nào Sử Dụng + +Use this skill when: +- Creating new repositories for aggregates / Tạo repositories mới cho aggregates +- Implementing data access with EF Core / Triển khai data access với EF Core +- Setting up Unit of Work pattern / Cài đặt Unit of Work pattern +- Separating read/write operations (CQRS) / Phân tách read/write (CQRS) +- Creating database migrations / Tạo database migrations +- Optimizing query performance / Tối ưu hiệu năng query + +## Core Concepts / Khái Niệm Cốt Lõi + +### 1. One Repository Per Aggregate Root / Một Repository Cho Mỗi Aggregate Root + +``` +❌ WRONG: Repository cho từng table + OrderRepository + OrderItemRepository + OrderPaymentRepository + +✅ CORRECT: Repository chỉ cho Aggregate Root + OrderRepository (quản lý Order + OrderItems + OrderPayments) +``` + +**Nguyên tắc:** +- Aggregate Root chịu trách nhiệm duy trì tính nhất quán +- Mọi thao tác với entities con phải thông qua Root +- Repository chỉ expose Aggregate Root, không expose entities con + +### 2. Separated Interface / Tách Giao Diện + +``` +Domain Layer (ServiceName.Domain/) +├── AggregatesModel/ +│ └── OrderAggregate/ +│ ├── Order.cs # Aggregate Root +│ ├── OrderItem.cs # Entity con +│ └── IOrderRepository.cs # Interface + +Infrastructure Layer (ServiceName.Infrastructure/) +├── Repositories/ +│ └── OrderRepository.cs # Implementation +└── Data/ + └── ApplicationDbContext.cs +``` + +**Lợi ích:** +- Domain không phụ thuộc vào công nghệ (EF Core, Dapper) +- Dễ thay thế implementation +- Tuân thủ Dependency Inversion Principle + +### 3. Unit of Work / Đơn Vị Công Việc + +```csharp +// EN: DbContext IS the Unit of Work in EF Core +// VI: DbContext CHÍNH LÀ Unit of Work trong EF Core +public interface IUnitOfWork : IDisposable +{ + Task SaveChangesAsync(CancellationToken ct = default); +} + +// EN: All repositories share the same DbContext +// VI: Tất cả repositories dùng chung một DbContext +``` + +### 4. CQRS Simplified / CQRS Đơn Giản + +| Operation | Pattern | Tool | +|-----------|---------|------| +| **Commands** (Create, Update, Delete) | Repository + EF Core | Full domain model | +| **Queries** (Read) | Dapper / Raw SQL | Lightweight DTOs | + +## Key Patterns / Mẫu Chính + +### Repository Interface / Giao Diện Repository + +```csharp +/// +/// EN: Base repository interface for aggregate roots. +/// VI: Interface repository cơ bản cho aggregate roots. +/// +public interface IRepository where T : class, IAggregateRoot +{ + IUnitOfWork UnitOfWork { get; } + + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task AddAsync(T entity, CancellationToken ct = default); + void Update(T entity); + void Delete(T entity); +} + +/// +/// EN: Order repository interface. +/// VI: Interface repository cho Order. +/// +public interface IOrderRepository : IRepository +{ + Task GetWithItemsAsync(Guid id, CancellationToken ct = default); + Task> GetByUserIdAsync(string userId, CancellationToken ct = default); +} +``` + +### EF Core Implementation / Triển Khai EF Core + +```csharp +/// +/// EN: EF Core repository implementation. +/// VI: Triển khai repository với EF Core. +/// +public class OrderRepository : IOrderRepository +{ + private readonly ApplicationDbContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public OrderRepository(ApplicationDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + return await _context.Orders.FindAsync(new object[] { id }, ct); + } + + public async Task GetWithItemsAsync(Guid id, CancellationToken ct = default) + { + return await _context.Orders + .Include(o => o.OrderItems) + .FirstOrDefaultAsync(o => o.Id == id, ct); + } + + public async Task AddAsync(Order order, CancellationToken ct = default) + { + var entry = await _context.Orders.AddAsync(order, ct); + return entry.Entity; + } + + public void Update(Order order) + { + _context.Entry(order).State = EntityState.Modified; + } + + public void Delete(Order order) + { + _context.Orders.Remove(order); + } +} +``` + +### Command Handler with Repository / Handler với Repository + +```csharp +/// +/// EN: Command handler using repository pattern. +/// VI: Command handler sử dụng repository pattern. +/// +public class CreateOrderCommandHandler : IRequestHandler +{ + private readonly IOrderRepository _orderRepository; + + public CreateOrderCommandHandler(IOrderRepository orderRepository) + { + _orderRepository = orderRepository; + } + + public async Task Handle( + CreateOrderCommand request, + CancellationToken ct) + { + // EN: Create aggregate through domain model + // VI: Tạo aggregate thông qua domain model + var order = new Order(request.UserId, request.ShippingAddress); + + foreach (var item in request.Items) + { + order.AddItem(item.ProductId, item.Quantity, item.UnitPrice); + } + + // EN: Persist through repository + // VI: Lưu qua repository + await _orderRepository.AddAsync(order, ct); + await _orderRepository.UnitOfWork.SaveChangesAsync(ct); + + return new OrderResult(order.Id); + } +} +``` + +### Query with Dapper (CQRS) / Query với Dapper + +```csharp +/// +/// EN: Query handler using Dapper for read operations. +/// VI: Query handler dùng Dapper cho operations đọc. +/// +public class GetOrdersQueryHandler : IRequestHandler> +{ + private readonly IDbConnection _connection; + + public GetOrdersQueryHandler(IDbConnection connection) + { + _connection = connection; + } + + public async Task> Handle( + GetOrdersQuery request, + CancellationToken ct) + { + // EN: Use Dapper for optimized read queries + // VI: Dùng Dapper cho query đọc tối ưu + const string sql = @" + SELECT o.Id, o.Status, o.TotalAmount, o.CreatedAt, + COUNT(oi.Id) as ItemCount + FROM Orders o + LEFT JOIN OrderItems oi ON o.Id = oi.OrderId + WHERE o.UserId = @UserId + GROUP BY o.Id, o.Status, o.TotalAmount, o.CreatedAt + ORDER BY o.CreatedAt DESC + OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY"; + + var orders = await _connection.QueryAsync(sql, new + { + request.UserId, + request.Skip, + request.Take + }); + + return orders.ToList(); + } +} +``` + +## Common Mistakes / Lỗi Thường Gặp + +### 1. Repository Per Table / Repository Cho Từng Table + +```csharp +// ❌ BAD: Multiple repositories for one aggregate +public class OrderItemRepository { } +public class OrderPaymentRepository { } + +// ✅ GOOD: Only repository for aggregate root +public class OrderRepository +{ + // EN: Access child entities through aggregate + // VI: Truy cập entities con thông qua aggregate + public async Task GetWithItemsAsync(Guid id, CancellationToken ct) + { + return await _context.Orders + .Include(o => o.OrderItems) + .Include(o => o.Payment) + .FirstOrDefaultAsync(o => o.Id == id, ct); + } +} +``` + +### 2. Bypassing Aggregate Root / Bỏ Qua Aggregate Root + +```csharp +// ❌ BAD: Modifying child entity directly +var orderItem = await _context.OrderItems.FindAsync(itemId); +orderItem.Quantity = 5; + +// ✅ GOOD: Modify through aggregate root +var order = await _orderRepository.GetWithItemsAsync(orderId, ct); +order.UpdateItemQuantity(itemId, 5); +await _orderRepository.UnitOfWork.SaveChangesAsync(ct); +``` + +### 3. Exposing IQueryable / Lộ IQueryable + +```csharp +// ❌ BAD: Leaking implementation details +public interface IOrderRepository +{ + IQueryable GetAll(); +} + +// ✅ GOOD: Encapsulated queries +public interface IOrderRepository +{ + Task> GetByUserIdAsync(string userId, CancellationToken ct); + Task GetWithItemsAsync(Guid id, CancellationToken ct); +} +``` + +### 4. Missing Unit of Work / Thiếu Unit of Work + +```csharp +// ❌ BAD: SaveChanges in repository +public async Task AddAsync(Order order, CancellationToken ct) +{ + await _context.Orders.AddAsync(order, ct); + await _context.SaveChangesAsync(ct); // WRONG! + return order; +} + +// ✅ GOOD: SaveChanges in handler (Unit of Work) +public async Task Handle(CreateOrderCommand request, CancellationToken ct) +{ + await _orderRepository.AddAsync(order, ct); + await _orderRepository.UnitOfWork.SaveChangesAsync(ct); // CORRECT! +} +``` + +## Quick Reference / Tham Chiếu Nhanh + +### Project Structure + +| Component | Location | +|-----------|----------| +| **Repository Interface** | `Domain/AggregatesModel/{Aggregate}/I{Aggregate}Repository.cs` | +| **Repository Implementation** | `Infrastructure/Repositories/{Aggregate}Repository.cs` | +| **DbContext** | `Infrastructure/Data/ApplicationDbContext.cs` | +| **Entity Configurations** | `Infrastructure/Data/Configurations/` | + +### DI Registration + +```csharp +// EN: Register repositories in Program.cs +// VI: Đăng ký repositories trong Program.cs +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// EN: Register DbContext +// VI: Đăng ký DbContext +builder.Services.AddDbContext(options => + options.UseNpgsql(connectionString)); + +// EN: Register Dapper connection for queries +// VI: Đăng ký Dapper connection cho queries +builder.Services.AddScoped(_ => + new NpgsqlConnection(connectionString)); +``` + +### Migration Commands + +```bash +# EN: Add migration / VI: Thêm migration +dotnet ef migrations add MigrationName --project src/Service.Infrastructure + +# EN: Update database / VI: Cập nhật database +dotnet ef database update --project src/Service.Infrastructure + +# EN: Generate SQL script / VI: Tạo SQL script +dotnet ef migrations script --project src/Service.Infrastructure +``` + +## Resources / Tài Nguyên + +- [Detailed Examples](./references/REFERENCE.md) - Full code examples +- [API Design](../api-design/SKILL.md) - API patterns +- [Error Handling](../error-handling-patterns/SKILL.md) - Error handling +- [Testing Patterns](../testing-patterns/SKILL.md) - Repository testing +- [Project Rules](../project-rules/SKILL.md) - Coding standards diff --git a/.agent/skills/repository-pattern/references/REFERENCE.md b/.agent/skills/repository-pattern/references/REFERENCE.md new file mode 100644 index 00000000..8d5d65ec --- /dev/null +++ b/.agent/skills/repository-pattern/references/REFERENCE.md @@ -0,0 +1,770 @@ +# Repository Pattern - Detailed Reference + +Detailed code examples for Repository pattern in ASP.NET Core with Entity Framework Core. + +## Table of Contents + +1. [Aggregate Root Pattern](#aggregate-root-pattern) +2. [Repository Interface Design](#repository-interface-design) +3. [EF Core Implementation](#ef-core-implementation) +4. [Unit of Work Pattern](#unit-of-work-pattern) +5. [Entity Configuration](#entity-configuration) +6. [CQRS with Dapper](#cqrs-with-dapper) +7. [Specification Pattern](#specification-pattern) +8. [DI Registration](#di-registration) + +--- + +## Aggregate Root Pattern + +### Domain Entities + +```csharp +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu aggregate roots. +/// +public interface IAggregateRoot { } + +/// +/// EN: Base entity with common properties. +/// VI: Entity cơ sở với các properties chung. +/// +public abstract class Entity +{ + public Guid Id { get; protected set; } + public DateTime CreatedAt { get; protected set; } + public DateTime? UpdatedAt { get; protected set; } + + protected Entity() + { + Id = Guid.NewGuid(); + CreatedAt = DateTime.UtcNow; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity other) + return false; + if (ReferenceEquals(this, other)) + return true; + if (GetType() != other.GetType()) + return false; + return Id == other.Id; + } + + public override int GetHashCode() => Id.GetHashCode(); +} +``` + +### Order Aggregate Example + +```csharp +/// +/// EN: Order aggregate root. +/// VI: Aggregate root cho Order. +/// +public class Order : Entity, IAggregateRoot +{ + public string UserId { get; private set; } + public OrderStatus Status { get; private set; } + public Address ShippingAddress { get; private set; } + public decimal TotalAmount { get; private set; } + + private readonly List _orderItems = new(); + public IReadOnlyCollection OrderItems => _orderItems.AsReadOnly(); + + // EN: Private constructor for EF Core + // VI: Constructor private cho EF Core + private Order() { } + + public Order(string userId, Address shippingAddress) + { + UserId = userId ?? throw new ArgumentNullException(nameof(userId)); + ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress)); + Status = OrderStatus.Draft; + } + + /// + /// EN: Add item through aggregate root to maintain invariants. + /// VI: Thêm item thông qua aggregate root để duy trì invariants. + /// + public void AddItem(Guid productId, int quantity, decimal unitPrice) + { + if (Status != OrderStatus.Draft) + throw new DomainException("Cannot add items to non-draft order"); + + var existingItem = _orderItems.FirstOrDefault(x => x.ProductId == productId); + if (existingItem != null) + { + existingItem.AddQuantity(quantity); + } + else + { + _orderItems.Add(new OrderItem(Id, productId, quantity, unitPrice)); + } + + RecalculateTotal(); + } + + /// + /// EN: Update item quantity through aggregate root. + /// VI: Cập nhật số lượng item thông qua aggregate root. + /// + public void UpdateItemQuantity(Guid itemId, int newQuantity) + { + var item = _orderItems.FirstOrDefault(x => x.Id == itemId) + ?? throw new DomainException($"Item {itemId} not found"); + + if (newQuantity <= 0) + { + _orderItems.Remove(item); + } + else + { + item.SetQuantity(newQuantity); + } + + RecalculateTotal(); + } + + public void Submit() + { + if (Status != OrderStatus.Draft) + throw new DomainException("Only draft orders can be submitted"); + if (!_orderItems.Any()) + throw new DomainException("Cannot submit empty order"); + + Status = OrderStatus.Submitted; + UpdatedAt = DateTime.UtcNow; + } + + private void RecalculateTotal() + { + TotalAmount = _orderItems.Sum(x => x.GetSubtotal()); + UpdatedAt = DateTime.UtcNow; + } +} + +/// +/// EN: Order item - child entity of Order aggregate. +/// VI: Order item - entity con của Order aggregate. +/// +public class OrderItem : Entity +{ + public Guid OrderId { get; private set; } + public Guid ProductId { get; private set; } + public int Quantity { get; private set; } + public decimal UnitPrice { get; private set; } + + private OrderItem() { } + + internal OrderItem(Guid orderId, Guid productId, int quantity, decimal unitPrice) + { + OrderId = orderId; + ProductId = productId; + Quantity = quantity > 0 ? quantity : throw new ArgumentException("Quantity must be positive"); + UnitPrice = unitPrice >= 0 ? unitPrice : throw new ArgumentException("Price cannot be negative"); + } + + internal void AddQuantity(int quantity) + { + Quantity += quantity; + } + + internal void SetQuantity(int quantity) + { + Quantity = quantity > 0 ? quantity : throw new ArgumentException("Quantity must be positive"); + } + + public decimal GetSubtotal() => Quantity * UnitPrice; +} + +/// +/// EN: Value object for address. +/// VI: Value object cho địa chỉ. +/// +public record Address( + string Street, + string City, + string State, + string PostalCode, + string Country); + +public enum OrderStatus +{ + Draft, + Submitted, + Processing, + Shipped, + Delivered, + Cancelled +} +``` + +--- + +## Repository Interface Design + +### Base Repository Interface + +```csharp +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +public interface IRepository where T : class, IAggregateRoot +{ + /// + /// EN: Unit of Work for transaction management. + /// VI: Unit of Work cho quản lý transaction. + /// + IUnitOfWork UnitOfWork { get; } + + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task AddAsync(T entity, CancellationToken ct = default); + void Update(T entity); + void Delete(T entity); +} + +/// +/// EN: Unit of Work interface. +/// VI: Interface Unit of Work. +/// +public interface IUnitOfWork : IDisposable +{ + Task SaveChangesAsync(CancellationToken ct = default); + Task SaveEntitiesAsync(CancellationToken ct = default); +} +``` + +### Specific Repository Interface + +```csharp +/// +/// EN: Order repository interface with domain-specific methods. +/// VI: Interface repository Order với các phương thức domain-specific. +/// +public interface IOrderRepository : IRepository +{ + /// + /// EN: Get order with all items loaded. + /// VI: Lấy order với tất cả items được load. + /// + Task GetWithItemsAsync(Guid id, CancellationToken ct = default); + + /// + /// EN: Get orders by user with pagination. + /// VI: Lấy orders theo user với phân trang. + /// + Task> GetByUserIdAsync( + string userId, + int skip = 0, + int take = 20, + CancellationToken ct = default); + + /// + /// EN: Get orders by status. + /// VI: Lấy orders theo status. + /// + Task> GetByStatusAsync( + OrderStatus status, + CancellationToken ct = default); +} +``` + +--- + +## EF Core Implementation + +### DbContext as Unit of Work + +```csharp +/// +/// EN: Application DbContext implementing Unit of Work. +/// VI: DbContext ứng dụng triển khai Unit of Work. +/// +public class ApplicationDbContext : DbContext, IUnitOfWork +{ + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + + public ApplicationDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + } + + /// + /// EN: Save entities with domain events support. + /// VI: Lưu entities với hỗ trợ domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken ct = default) + { + // EN: Dispatch domain events before saving + // VI: Dispatch domain events trước khi lưu + // await _mediator.DispatchDomainEventsAsync(this); + + await SaveChangesAsync(ct); + return true; + } +} +``` + +### Repository Implementation + +```csharp +/// +/// EN: Order repository implementation with EF Core. +/// VI: Triển khai repository Order với EF Core. +/// +public class OrderRepository : IOrderRepository +{ + private readonly ApplicationDbContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public OrderRepository(ApplicationDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + return await _context.Orders.FindAsync(new object[] { id }, ct); + } + + public async Task GetWithItemsAsync(Guid id, CancellationToken ct = default) + { + return await _context.Orders + .Include(o => o.OrderItems) + .FirstOrDefaultAsync(o => o.Id == id, ct); + } + + public async Task> GetByUserIdAsync( + string userId, + int skip = 0, + int take = 20, + CancellationToken ct = default) + { + return await _context.Orders + .Where(o => o.UserId == userId) + .OrderByDescending(o => o.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(ct); + } + + public async Task> GetByStatusAsync( + OrderStatus status, + CancellationToken ct = default) + { + return await _context.Orders + .Where(o => o.Status == status) + .OrderByDescending(o => o.CreatedAt) + .ToListAsync(ct); + } + + public async Task AddAsync(Order order, CancellationToken ct = default) + { + var entry = await _context.Orders.AddAsync(order, ct); + return entry.Entity; + } + + public void Update(Order order) + { + _context.Entry(order).State = EntityState.Modified; + } + + public void Delete(Order order) + { + _context.Orders.Remove(order); + } +} +``` + +--- + +## Entity Configuration + +### Fluent API Configuration + +```csharp +/// +/// EN: EF Core configuration for Order entity. +/// VI: Cấu hình EF Core cho entity Order. +/// +public class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Orders"); + + builder.HasKey(o => o.Id); + + builder.Property(o => o.UserId) + .IsRequired() + .HasMaxLength(36); + + builder.Property(o => o.Status) + .IsRequired() + .HasConversion(); + + builder.Property(o => o.TotalAmount) + .HasPrecision(18, 2); + + // EN: Configure owned type for Address + // VI: Cấu hình owned type cho Address + builder.OwnsOne(o => o.ShippingAddress, a => + { + a.Property(x => x.Street).HasMaxLength(200); + a.Property(x => x.City).HasMaxLength(100); + a.Property(x => x.State).HasMaxLength(100); + a.Property(x => x.PostalCode).HasMaxLength(20); + a.Property(x => x.Country).HasMaxLength(100); + }); + + // EN: Configure navigation to OrderItems + // VI: Cấu hình navigation đến OrderItems + var navigation = builder.Metadata.FindNavigation(nameof(Order.OrderItems)); + navigation?.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(o => o.OrderItems) + .WithOne() + .HasForeignKey(oi => oi.OrderId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +/// +/// EN: EF Core configuration for OrderItem entity. +/// VI: Cấu hình EF Core cho entity OrderItem. +/// +public class OrderItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("OrderItems"); + + builder.HasKey(oi => oi.Id); + + builder.Property(oi => oi.UnitPrice) + .HasPrecision(18, 2); + + builder.HasIndex(oi => oi.OrderId); + } +} +``` + +--- + +## CQRS with Dapper + +### Query DTOs + +```csharp +/// +/// EN: Lightweight DTO for order queries. +/// VI: DTO nhẹ cho order queries. +/// +public record OrderSummaryDto( + Guid Id, + string Status, + decimal TotalAmount, + DateTime CreatedAt, + int ItemCount); + +public record OrderDetailDto( + Guid Id, + string UserId, + string Status, + decimal TotalAmount, + DateTime CreatedAt, + AddressDto ShippingAddress, + List Items); + +public record OrderItemDto( + Guid Id, + Guid ProductId, + string ProductName, + int Quantity, + decimal UnitPrice, + decimal Subtotal); + +public record AddressDto( + string Street, + string City, + string State, + string PostalCode, + string Country); +``` + +### Dapper Query Handler + +```csharp +/// +/// EN: Query handler using Dapper for optimized reads. +/// VI: Query handler dùng Dapper cho đọc tối ưu. +/// +public class GetUserOrdersQueryHandler + : IRequestHandler> +{ + private readonly IDbConnection _connection; + + public GetUserOrdersQueryHandler(IDbConnection connection) + { + _connection = connection; + } + + public async Task> Handle( + GetUserOrdersQuery request, + CancellationToken ct) + { + // EN: Count total records + // VI: Đếm tổng số records + const string countSql = @" + SELECT COUNT(*) FROM Orders WHERE UserId = @UserId"; + + var total = await _connection.ExecuteScalarAsync(countSql, new { request.UserId }); + + // EN: Get paginated results + // VI: Lấy kết quả phân trang + const string dataSql = @" + SELECT + o.Id, + o.Status, + o.TotalAmount, + o.CreatedAt, + COUNT(oi.Id) as ItemCount + FROM Orders o + LEFT JOIN OrderItems oi ON o.Id = oi.OrderId + WHERE o.UserId = @UserId + GROUP BY o.Id, o.Status, o.TotalAmount, o.CreatedAt + ORDER BY o.CreatedAt DESC + OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY"; + + var orders = await _connection.QueryAsync(dataSql, new + { + request.UserId, + request.Skip, + request.Take + }); + + return new PagedResult( + orders.ToList(), + total, + request.Skip / request.Take + 1, + request.Take); + } +} + +/// +/// EN: Get order detail with items using Dapper multi-mapping. +/// VI: Lấy chi tiết order với items dùng Dapper multi-mapping. +/// +public class GetOrderDetailQueryHandler + : IRequestHandler +{ + private readonly IDbConnection _connection; + + public async Task Handle( + GetOrderDetailQuery request, + CancellationToken ct) + { + const string sql = @" + SELECT + o.Id, o.UserId, o.Status, o.TotalAmount, o.CreatedAt, + o.ShippingAddress_Street as Street, + o.ShippingAddress_City as City, + o.ShippingAddress_State as State, + o.ShippingAddress_PostalCode as PostalCode, + o.ShippingAddress_Country as Country + FROM Orders o + WHERE o.Id = @OrderId AND o.UserId = @UserId; + + SELECT + oi.Id, oi.ProductId, p.Name as ProductName, + oi.Quantity, oi.UnitPrice, + (oi.Quantity * oi.UnitPrice) as Subtotal + FROM OrderItems oi + JOIN Products p ON oi.ProductId = p.Id + WHERE oi.OrderId = @OrderId"; + + using var multi = await _connection.QueryMultipleAsync(sql, new + { + request.OrderId, + request.UserId + }); + + var order = await multi.ReadFirstOrDefaultAsync(); + if (order == null) return null; + + var items = (await multi.ReadAsync()).ToList(); + + return new OrderDetailDto( + order.Id, + order.UserId, + order.Status, + order.TotalAmount, + order.CreatedAt, + new AddressDto(order.Street, order.City, order.State, order.PostalCode, order.Country), + items); + } +} +``` + +--- + +## Specification Pattern + +### Generic Specification + +```csharp +/// +/// EN: Specification pattern for complex queries. +/// VI: Specification pattern cho queries phức tạp. +/// +public abstract class Specification +{ + public abstract Expression> ToExpression(); + + public bool IsSatisfiedBy(T entity) + { + return ToExpression().Compile()(entity); + } + + public Specification And(Specification other) + => new AndSpecification(this, other); + + public Specification Or(Specification other) + => new OrSpecification(this, other); + + public Specification Not() + => new NotSpecification(this); +} + +public class AndSpecification : Specification +{ + private readonly Specification _left; + private readonly Specification _right; + + public AndSpecification(Specification left, Specification right) + { + _left = left; + _right = right; + } + + public override Expression> ToExpression() + { + var leftExpr = _left.ToExpression(); + var rightExpr = _right.ToExpression(); + var param = Expression.Parameter(typeof(T)); + var body = Expression.AndAlso( + Expression.Invoke(leftExpr, param), + Expression.Invoke(rightExpr, param)); + return Expression.Lambda>(body, param); + } +} +``` + +### Order Specifications + +```csharp +/// +/// EN: Specification for orders by user. +/// VI: Specification cho orders theo user. +/// +public class OrderByUserSpecification : Specification +{ + private readonly string _userId; + + public OrderByUserSpecification(string userId) => _userId = userId; + + public override Expression> ToExpression() + => order => order.UserId == _userId; +} + +/// +/// EN: Specification for orders by status. +/// VI: Specification cho orders theo status. +/// +public class OrderByStatusSpecification : Specification +{ + private readonly OrderStatus _status; + + public OrderByStatusSpecification(OrderStatus status) => _status = status; + + public override Expression> ToExpression() + => order => order.Status == _status; +} + +// EN: Usage / VI: Cách dùng +var spec = new OrderByUserSpecification(userId) + .And(new OrderByStatusSpecification(OrderStatus.Submitted)); + +var orders = await _context.Orders + .Where(spec.ToExpression()) + .ToListAsync(); +``` + +--- + +## DI Registration + +### Program.cs Configuration + +```csharp +/// +/// EN: Register repositories and DbContext. +/// VI: Đăng ký repositories và DbContext. +/// + +// EN: DbContext registration +// VI: Đăng ký DbContext +builder.Services.AddDbContext(options => +{ + options.UseNpgsql( + builder.Configuration.GetConnectionString("DefaultConnection"), + npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 3, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + + if (builder.Environment.IsDevelopment()) + { + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } +}); + +// EN: Repository registrations +// VI: Đăng ký repositories +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// EN: Dapper connection for queries +// VI: Dapper connection cho queries +builder.Services.AddScoped(_ => + new NpgsqlConnection(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// EN: Register MediatR +// VI: Đăng ký MediatR +builder.Services.AddMediatR(cfg => + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); +``` + +--- + +## Resources / Tài Nguyên + +- [Microsoft: Implementing Repository Pattern](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-implementation-entity-framework-core) +- [DDD Aggregate Pattern](https://martinfowler.com/bliki/DDD_Aggregate.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) +- [Dapper Documentation](https://github.com/DapperLib/Dapper) diff --git a/.agent/skills/testing-patterns/SKILL.md b/.agent/skills/testing-patterns/SKILL.md new file mode 100644 index 00000000..512f46d6 --- /dev/null +++ b/.agent/skills/testing-patterns/SKILL.md @@ -0,0 +1,445 @@ +--- +name: testing-patterns +description: Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers. +compatibility: ".NET 8+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost" +metadata: + author: Velik Ho + version: "1.0" +--- + +# Testing Patterns / Mẫu Kiểm Thử + +Testing patterns cho GoodGo microservices với xUnit, NSubstitute, và Testcontainers. + +## When to Use This Skill / Khi Nào Sử Dụng + +Use this skill when: +- Writing unit tests for handlers / Viết unit tests cho handlers +- Testing controllers with mocked dependencies / Test controllers với dependencies giả lập +- Creating integration tests with database / Tạo integration tests với database +- Setting up functional tests with TestServer / Cài đặt functional tests với TestServer +- Mocking services với NSubstitute / Giả lập services với NSubstitute + +## Core Concepts / Khái Niệm Cốt Lõi + +### Testing Pyramid / Kim Tự Tháp Testing + +``` + /\ + / \ E2E Tests (ít nhất) + /----\ + / \ Integration Tests + /--------\ + / \ Unit Tests (nhiều nhất) + -------------- +``` + +| Level | Scope | Speed | Dependencies | +|-------|-------|-------|--------------| +| **Unit** | Single class/method | Milliseconds | Mocked | +| **Integration** | Multiple components + DB | Seconds | Real/Containerized | +| **Functional/E2E** | Full API workflow | Seconds-Minutes | Real services | + +### Test Project Structure / Cấu Trúc Project Test + +``` +tests/ +├── ServiceName.UnitTests/ +│ ├── Handlers/ +│ │ ├── CreateOrderCommandHandlerTests.cs +│ │ └── GetOrderQueryHandlerTests.cs +│ ├── Domain/ +│ │ └── OrderTests.cs +│ └── ServiceName.UnitTests.csproj +├── ServiceName.IntegrationTests/ +│ ├── Fixtures/ +│ │ └── DatabaseFixture.cs +│ ├── Repositories/ +│ │ └── OrderRepositoryTests.cs +│ └── ServiceName.IntegrationTests.csproj +└── ServiceName.FunctionalTests/ + ├── ApiTests/ + │ └── OrdersApiTests.cs + └── ServiceName.FunctionalTests.csproj +``` + +## Key Patterns / Mẫu Chính + +### Unit Test với xUnit + NSubstitute + +```csharp +/// +/// EN: Unit test for command handler. +/// VI: Unit test cho command handler. +/// +public class CreateOrderCommandHandlerTests +{ + private readonly IOrderRepository _orderRepository; + private readonly ILogger _logger; + private readonly CreateOrderCommandHandler _handler; + + public CreateOrderCommandHandlerTests() + { + // EN: Create mocks with NSubstitute + // VI: Tạo mocks với NSubstitute + _orderRepository = Substitute.For(); + _logger = Substitute.For>(); + + // EN: Create handler with mocked dependencies + // VI: Tạo handler với dependencies giả lập + _handler = new CreateOrderCommandHandler(_orderRepository, _logger); + } + + [Fact] + public async Task Handle_ValidCommand_CreatesOrder() + { + // Arrange + var command = new CreateOrderCommand( + UserId: "user-123", + ShippingAddress: new Address("123 Main St", "City", "State", "12345", "Country"), + Items: new List + { + new(ProductId: Guid.NewGuid(), Quantity: 2, UnitPrice: 10.00m) + }); + + _orderRepository.AddAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + _orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any()) + .Returns(1); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.OrderId.Should().NotBeEmpty(); + + await _orderRepository.Received(1).AddAsync( + Arg.Is(o => o.UserId == "user-123"), + Arg.Any()); + } + + [Fact] + public async Task Handle_EmptyItems_ThrowsDomainException() + { + // Arrange + var command = new CreateOrderCommand( + UserId: "user-123", + ShippingAddress: new Address("123 Main St", "City", "State", "12345", "Country"), + Items: new List()); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + + await _orderRepository.DidNotReceive().AddAsync( + Arg.Any(), + Arg.Any()); + } +} +``` + +### Domain Entity Tests / Test Domain Entity + +```csharp +/// +/// EN: Tests for Order aggregate root. +/// VI: Tests cho Order aggregate root. +/// +public class OrderTests +{ + [Fact] + public void AddItem_ValidItem_IncreasesTotalAmount() + { + // Arrange + var order = new Order("user-123", CreateAddress()); + var productId = Guid.NewGuid(); + + // Act + order.AddItem(productId, quantity: 2, unitPrice: 10.00m); + + // Assert + order.TotalAmount.Should().Be(20.00m); + order.OrderItems.Should().HaveCount(1); + } + + [Fact] + public void AddItem_SameProduct_IncreasesQuantity() + { + // Arrange + var order = new Order("user-123", CreateAddress()); + var productId = Guid.NewGuid(); + + // Act + order.AddItem(productId, quantity: 2, unitPrice: 10.00m); + order.AddItem(productId, quantity: 3, unitPrice: 10.00m); + + // Assert + order.OrderItems.Should().HaveCount(1); + order.OrderItems.First().Quantity.Should().Be(5); + order.TotalAmount.Should().Be(50.00m); + } + + [Fact] + public void Submit_EmptyOrder_ThrowsDomainException() + { + // Arrange + var order = new Order("user-123", CreateAddress()); + + // Act & Assert + var act = () => order.Submit(); + act.Should().Throw() + .WithMessage("Cannot submit empty order"); + } + + private static Address CreateAddress() => + new("123 Main St", "City", "State", "12345", "Country"); +} +``` + +### Integration Tests với Testcontainers + +```csharp +/// +/// EN: Database fixture using Testcontainers. +/// VI: Database fixture dùng Testcontainers. +/// +public class DatabaseFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:15-alpine") + .WithDatabase("testdb") + .WithUsername("test") + .WithPassword("test") + .Build(); + + public string ConnectionString => _container.GetConnectionString(); + public ApplicationDbContext DbContext { get; private set; } = null!; + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .Options; + + DbContext = new ApplicationDbContext(options); + await DbContext.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + await DbContext.DisposeAsync(); + await _container.DisposeAsync(); + } +} + +[Collection("Database")] +public class OrderRepositoryTests : IClassFixture +{ + private readonly DatabaseFixture _fixture; + private readonly OrderRepository _repository; + + public OrderRepositoryTests(DatabaseFixture fixture) + { + _fixture = fixture; + _repository = new OrderRepository(_fixture.DbContext); + } + + [Fact] + public async Task AddAsync_ValidOrder_PersistsToDatabase() + { + // Arrange + var order = new Order("user-123", new Address("St", "City", "State", "12345", "US")); + order.AddItem(Guid.NewGuid(), 2, 10.00m); + + // Act + await _repository.AddAsync(order); + await _repository.UnitOfWork.SaveChangesAsync(); + + // Assert + var savedOrder = await _repository.GetWithItemsAsync(order.Id); + savedOrder.Should().NotBeNull(); + savedOrder!.OrderItems.Should().HaveCount(1); + } +} +``` + +### Functional Tests với TestServer + +```csharp +/// +/// EN: Web application factory for functional tests. +/// VI: Web application factory cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + // EN: Remove real DbContext + // VI: Xóa DbContext thật + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) + services.Remove(descriptor); + + // EN: Add in-memory database for tests + // VI: Thêm in-memory database cho tests + services.AddDbContext(options => + options.UseInMemoryDatabase("TestDb")); + }); + } +} + +public class OrdersApiTests : IClassFixture +{ + private readonly HttpClient _client; + + public OrdersApiTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task CreateOrder_ValidRequest_Returns201() + { + // Arrange + var request = new + { + UserId = "user-123", + ShippingAddress = new { Street = "123 St", City = "City", State = "ST", PostalCode = "12345", Country = "US" }, + Items = new[] { new { ProductId = Guid.NewGuid(), Quantity = 2, UnitPrice = 10.00m } } + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/orders", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task GetOrder_NotFound_Returns404() + { + // Act + var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} +``` + +## Common Mistakes / Lỗi Thường Gặp + +### 1. Testing Implementation Details + +```csharp +// ❌ BAD: Testing internal state +order.GetType().GetField("_status", BindingFlags.NonPublic) + .GetValue(order).Should().Be(OrderStatus.Draft); + +// ✅ GOOD: Testing behavior +order.Status.Should().Be(OrderStatus.Draft); +order.Submit(); +order.Status.Should().Be(OrderStatus.Submitted); +``` + +### 2. Not Using Async Assertions + +```csharp +// ❌ BAD: Blocking call +var result = _handler.Handle(command, ct).Result; + +// ✅ GOOD: Async assertion +var result = await _handler.Handle(command, ct); +``` + +### 3. Sharing State Between Tests + +```csharp +// ❌ BAD: Static shared state +private static Order _order = new Order(...); + +// ✅ GOOD: Fresh instance per test +private Order CreateOrder() => new Order("user-123", CreateAddress()); +``` + +### 4. Ignoring CancellationToken + +```csharp +// ❌ BAD: Ignoring cancellation +await _handler.Handle(command, CancellationToken.None); + +// ✅ GOOD: Testing cancellation +var cts = new CancellationTokenSource(); +cts.Cancel(); +await Assert.ThrowsAsync(() => + _handler.Handle(command, cts.Token)); +``` + +## Quick Reference / Tham Chiếu Nhanh + +### xUnit Attributes + +| Attribute | Purpose | +|-----------|---------| +| `[Fact]` | Single test case | +| `[Theory]` | Parameterized test | +| `[InlineData]` | Inline parameters | +| `[MemberData]` | Complex parameters | +| `[Collection]` | Shared fixture | +| `[ClassData]` | External data source | + +### NSubstitute Patterns + +```csharp +// EN: Create substitute / VI: Tạo substitute +var service = Substitute.For(); + +// EN: Setup return value / VI: Thiết lập giá trị trả về +service.GetByIdAsync(Arg.Any()).Returns(order); + +// EN: Verify call / VI: Xác minh gọi +await service.Received(1).GetByIdAsync(orderId); + +// EN: Capture arguments / VI: Capture arguments +Order? capturedOrder = null; +await repository.AddAsync(Arg.Do(o => capturedOrder = o)); +``` + +### FluentAssertions + +```csharp +result.Should().NotBeNull(); +result.Should().BeEquivalentTo(expected); +orders.Should().HaveCount(5); +exception.Should().Throw().WithMessage("*empty*"); +``` + +### Test Commands + +```bash +# EN: Run all tests / VI: Chạy tất cả tests +dotnet test + +# EN: Run specific project / VI: Chạy project cụ thể +dotnet test tests/Service.UnitTests + +# EN: Run with coverage / VI: Chạy với coverage +dotnet test --collect:"XPlat Code Coverage" + +# EN: Run specific test / VI: Chạy test cụ thể +dotnet test --filter "FullyQualifiedName~CreateOrder" +``` + +## Resources / Tài Nguyên + +- [Detailed Examples](./references/REFERENCE.md) - Full code examples +- [Repository Pattern](../repository-pattern/SKILL.md) - Repository testing +- [Error Handling](../error-handling-patterns/SKILL.md) - Error testing +- [API Design](../api-design/SKILL.md) - Controller testing +- [Project Rules](../project-rules/SKILL.md) - Coding standards diff --git a/.agent/skills/testing-patterns/references/REFERENCE.md b/.agent/skills/testing-patterns/references/REFERENCE.md new file mode 100644 index 00000000..656816f1 --- /dev/null +++ b/.agent/skills/testing-patterns/references/REFERENCE.md @@ -0,0 +1,891 @@ +# Testing Patterns - Detailed Reference + +Detailed code examples for testing patterns in ASP.NET Core with xUnit and NSubstitute. + +## Table of Contents + +1. [Project Setup](#project-setup) +2. [Unit Testing MediatR Handlers](#unit-testing-mediatr-handlers) +3. [Domain Entity Testing](#domain-entity-testing) +4. [Controller Testing](#controller-testing) +5. [Integration Testing with Testcontainers](#integration-testing-with-testcontainers) +6. [Functional Testing with TestServer](#functional-testing-with-testserver) +7. [Testing Validation](#testing-validation) +8. [Test Data Builders](#test-data-builders) + +--- + +## Project Setup + +### Test Project Configuration + +```xml + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + +``` + +### Integration Test Project + +```xml + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + +``` + +--- + +## Unit Testing MediatR Handlers + +### Command Handler Tests + +```csharp +/// +/// EN: Tests for CreateOrderCommandHandler. +/// VI: Tests cho CreateOrderCommandHandler. +/// +public class CreateOrderCommandHandlerTests +{ + private readonly IOrderRepository _orderRepository; + private readonly IUserService _userService; + private readonly ILogger _logger; + private readonly CreateOrderCommandHandler _handler; + + public CreateOrderCommandHandlerTests() + { + _orderRepository = Substitute.For(); + _userService = Substitute.For(); + _logger = Substitute.For>(); + + // EN: Setup default returns + // VI: Thiết lập giá trị trả về mặc định + _orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any()) + .Returns(1); + + _handler = new CreateOrderCommandHandler( + _orderRepository, + _userService, + _logger); + } + + [Fact] + public async Task Handle_ValidCommand_CreatesOrderSuccessfully() + { + // Arrange + var command = CreateValidCommand(); + Order? capturedOrder = null; + + _orderRepository.AddAsync(Arg.Do(o => capturedOrder = o), Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.OrderId.Should().NotBeEmpty(); + + capturedOrder.Should().NotBeNull(); + capturedOrder!.UserId.Should().Be(command.UserId); + capturedOrder.OrderItems.Should().HaveCount(command.Items.Count); + + await _orderRepository.Received(1).AddAsync( + Arg.Any(), + Arg.Any()); + await _orderRepository.UnitOfWork.Received(1) + .SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_EmptyItems_ThrowsDomainException() + { + // Arrange + var command = CreateValidCommand() with { Items = new List() }; + + // Act + var act = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*empty*"); + + await _orderRepository.DidNotReceive().AddAsync( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Handle_RepositoryThrows_LogsAndRethrows() + { + // Arrange + var command = CreateValidCommand(); + var expectedException = new InvalidOperationException("Database error"); + + _orderRepository.AddAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(expectedException); + + // Act + var act = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Database error"); + } + + private static CreateOrderCommand CreateValidCommand() => + new( + UserId: "user-123", + ShippingAddress: new Address("123 Main St", "City", "State", "12345", "US"), + Items: new List + { + new(Guid.NewGuid(), 2, 10.00m), + new(Guid.NewGuid(), 1, 25.00m) + }); +} +``` + +### Query Handler Tests + +```csharp +/// +/// EN: Tests for GetOrderQueryHandler. +/// VI: Tests cho GetOrderQueryHandler. +/// +public class GetOrderQueryHandlerTests +{ + private readonly IOrderRepository _orderRepository; + private readonly GetOrderQueryHandler _handler; + + public GetOrderQueryHandlerTests() + { + _orderRepository = Substitute.For(); + _handler = new GetOrderQueryHandler(_orderRepository); + } + + [Fact] + public async Task Handle_OrderExists_ReturnsOrderDto() + { + // Arrange + var orderId = Guid.NewGuid(); + var order = CreateOrder(orderId, "user-123"); + + _orderRepository.GetWithItemsAsync(orderId, Arg.Any()) + .Returns(order); + + var query = new GetOrderQuery(orderId, "user-123"); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(orderId); + result.UserId.Should().Be("user-123"); + } + + [Fact] + public async Task Handle_OrderNotFound_ReturnsNull() + { + // Arrange + var query = new GetOrderQuery(Guid.NewGuid(), "user-123"); + + _orderRepository.GetWithItemsAsync(Arg.Any(), Arg.Any()) + .Returns((Order?)null); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task Handle_DifferentUser_ReturnsNull() + { + // Arrange + var orderId = Guid.NewGuid(); + var order = CreateOrder(orderId, "user-123"); + + _orderRepository.GetWithItemsAsync(orderId, Arg.Any()) + .Returns(order); + + // EN: Query with different user + // VI: Query với user khác + var query = new GetOrderQuery(orderId, "different-user"); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + private static Order CreateOrder(Guid id, string userId) + { + var order = new Order(userId, new Address("St", "City", "ST", "12345", "US")); + // EN: Use reflection to set Id for testing + // VI: Dùng reflection để set Id cho testing + typeof(Order).GetProperty("Id")!.SetValue(order, id); + return order; + } +} +``` + +--- + +## Domain Entity Testing + +### Aggregate Root Tests + +```csharp +/// +/// EN: Tests for Order aggregate root behavior. +/// VI: Tests cho behavior của Order aggregate root. +/// +public class OrderTests +{ + [Fact] + public void Constructor_ValidParameters_CreatesOrder() + { + // Act + var order = new Order("user-123", CreateAddress()); + + // Assert + order.Id.Should().NotBeEmpty(); + order.UserId.Should().Be("user-123"); + order.Status.Should().Be(OrderStatus.Draft); + order.TotalAmount.Should().Be(0); + order.OrderItems.Should().BeEmpty(); + } + + [Fact] + public void AddItem_NewProduct_AddsToItems() + { + // Arrange + var order = new Order("user-123", CreateAddress()); + var productId = Guid.NewGuid(); + + // Act + order.AddItem(productId, quantity: 2, unitPrice: 10.00m); + + // Assert + order.OrderItems.Should().HaveCount(1); + var item = order.OrderItems.First(); + item.ProductId.Should().Be(productId); + item.Quantity.Should().Be(2); + item.UnitPrice.Should().Be(10.00m); + order.TotalAmount.Should().Be(20.00m); + } + + [Fact] + public void AddItem_ExistingProduct_IncreasesQuantity() + { + // Arrange + var order = new Order("user-123", CreateAddress()); + var productId = Guid.NewGuid(); + + // Act + order.AddItem(productId, quantity: 2, unitPrice: 10.00m); + order.AddItem(productId, quantity: 3, unitPrice: 10.00m); + + // Assert + order.OrderItems.Should().HaveCount(1); + order.OrderItems.First().Quantity.Should().Be(5); + order.TotalAmount.Should().Be(50.00m); + } + + [Fact] + public void AddItem_SubmittedOrder_ThrowsDomainException() + { + // Arrange + var order = new Order("user-123", CreateAddress()); + order.AddItem(Guid.NewGuid(), 1, 10.00m); + order.Submit(); + + // Act + var act = () => order.AddItem(Guid.NewGuid(), 1, 10.00m); + + // Assert + act.Should().Throw() + .WithMessage("Cannot add items to non-draft order"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void AddItem_InvalidQuantity_ThrowsArgumentException(int quantity) + { + // Arrange + var order = new Order("user-123", CreateAddress()); + + // Act + var act = () => order.AddItem(Guid.NewGuid(), quantity, 10.00m); + + // Assert + act.Should().Throw() + .WithMessage("*positive*"); + } + + [Fact] + public void Submit_ValidOrder_ChangesStatus() + { + // Arrange + var order = new Order("user-123", CreateAddress()); + order.AddItem(Guid.NewGuid(), 1, 10.00m); + + // Act + order.Submit(); + + // Assert + order.Status.Should().Be(OrderStatus.Submitted); + } + + [Fact] + public void Submit_EmptyOrder_ThrowsDomainException() + { + // Arrange + var order = new Order("user-123", CreateAddress()); + + // Act + var act = () => order.Submit(); + + // Assert + act.Should().Throw() + .WithMessage("Cannot submit empty order"); + } + + private static Address CreateAddress() => + new("123 Main St", "City", "State", "12345", "Country"); +} +``` + +--- + +## Controller Testing + +### Controller Unit Tests + +```csharp +/// +/// EN: Tests for OrdersController. +/// VI: Tests cho OrdersController. +/// +public class OrdersControllerTests +{ + private readonly IMediator _mediator; + private readonly OrdersController _controller; + + public OrdersControllerTests() + { + _mediator = Substitute.For(); + _controller = new OrdersController(_mediator); + + // EN: Setup mock user claims + // VI: Thiết lập user claims giả lập + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "user-123") + }, "TestAuth")); + + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = user } + }; + } + + [Fact] + public async Task CreateOrder_ValidRequest_ReturnsCreated() + { + // Arrange + var request = new CreateOrderRequest( + new AddressDto("St", "City", "ST", "12345", "US"), + new[] { new OrderItemDto(Guid.NewGuid(), 2, 10.00m) }); + + var expectedResult = new OrderResult(Guid.NewGuid()); + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(expectedResult); + + // Act + var result = await _controller.CreateOrder(request, CancellationToken.None); + + // Assert + var createdResult = result.Result.Should().BeOfType().Subject; + var response = createdResult.Value.Should().BeOfType>().Subject; + response.Success.Should().BeTrue(); + response.Data.Should().Be(expectedResult); + } + + [Fact] + public async Task GetOrder_NotFound_ReturnsNotFound() + { + // Arrange + var orderId = Guid.NewGuid(); + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((OrderDto?)null); + + // Act + var result = await _controller.GetOrder(orderId, CancellationToken.None); + + // Assert + result.Result.Should().BeOfType(); + } + + [Fact] + public async Task GetOrder_Exists_ReturnsOk() + { + // Arrange + var orderId = Guid.NewGuid(); + var orderDto = new OrderDto(orderId, "user-123", "Draft", 100m, DateTime.UtcNow); + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(orderDto); + + // Act + var result = await _controller.GetOrder(orderId, CancellationToken.None); + + // Assert + var okResult = result.Result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType>().Subject; + response.Success.Should().BeTrue(); + response.Data!.Id.Should().Be(orderId); + } +} +``` + +--- + +## Integration Testing with Testcontainers + +### Database Fixture + +```csharp +/// +/// EN: Shared database fixture for integration tests. +/// VI: Fixture database dùng chung cho integration tests. +/// +public class DatabaseFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:15-alpine") + .WithDatabase("testdb") + .WithUsername("test") + .WithPassword("test") + .Build(); + + public string ConnectionString => _container.GetConnectionString(); + public ApplicationDbContext DbContext { get; private set; } = null!; + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .Options; + + DbContext = new ApplicationDbContext(options); + await DbContext.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + await DbContext.DisposeAsync(); + await _container.DisposeAsync(); + } + + /// + /// EN: Create fresh DbContext for each test. + /// VI: Tạo DbContext mới cho mỗi test. + /// + public ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .Options; + return new ApplicationDbContext(options); + } +} + +[CollectionDefinition("Database")] +public class DatabaseCollection : ICollectionFixture { } +``` + +### Repository Integration Tests + +```csharp +/// +/// EN: Integration tests for OrderRepository. +/// VI: Integration tests cho OrderRepository. +/// +[Collection("Database")] +public class OrderRepositoryIntegrationTests : IAsyncLifetime +{ + private readonly DatabaseFixture _fixture; + private ApplicationDbContext _context = null!; + private OrderRepository _repository = null!; + + public OrderRepositoryIntegrationTests(DatabaseFixture fixture) + { + _fixture = fixture; + } + + public Task InitializeAsync() + { + _context = _fixture.CreateContext(); + _repository = new OrderRepository(_context); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + // EN: Clean up test data + // VI: Dọn dẹp dữ liệu test + _context.Orders.RemoveRange(_context.Orders); + await _context.SaveChangesAsync(); + await _context.DisposeAsync(); + } + + [Fact] + public async Task AddAsync_ValidOrder_PersistsToDatabase() + { + // Arrange + var order = new Order("user-123", new Address("St", "City", "ST", "12345", "US")); + order.AddItem(Guid.NewGuid(), 2, 10.00m); + + // Act + var addedOrder = await _repository.AddAsync(order); + await _repository.UnitOfWork.SaveChangesAsync(); + + // Assert - verify with fresh context + using var verifyContext = _fixture.CreateContext(); + var savedOrder = await verifyContext.Orders + .Include(o => o.OrderItems) + .FirstOrDefaultAsync(o => o.Id == addedOrder.Id); + + savedOrder.Should().NotBeNull(); + savedOrder!.UserId.Should().Be("user-123"); + savedOrder.OrderItems.Should().HaveCount(1); + savedOrder.TotalAmount.Should().Be(20.00m); + } + + [Fact] + public async Task GetWithItemsAsync_OrderExists_IncludesItems() + { + // Arrange + var order = new Order("user-123", new Address("St", "City", "ST", "12345", "US")); + order.AddItem(Guid.NewGuid(), 2, 10.00m); + order.AddItem(Guid.NewGuid(), 1, 25.00m); + + await _repository.AddAsync(order); + await _repository.UnitOfWork.SaveChangesAsync(); + + // Act + using var queryContext = _fixture.CreateContext(); + var queryRepo = new OrderRepository(queryContext); + var result = await queryRepo.GetWithItemsAsync(order.Id); + + // Assert + result.Should().NotBeNull(); + result!.OrderItems.Should().HaveCount(2); + result.TotalAmount.Should().Be(45.00m); + } + + [Fact] + public async Task GetByUserIdAsync_HasOrders_ReturnsUserOrders() + { + // Arrange + var order1 = new Order("user-123", new Address("St", "City", "ST", "12345", "US")); + var order2 = new Order("user-123", new Address("St", "City", "ST", "12345", "US")); + var order3 = new Order("other-user", new Address("St", "City", "ST", "12345", "US")); + + foreach (var o in new[] { order1, order2, order3 }) + { + o.AddItem(Guid.NewGuid(), 1, 10.00m); + await _repository.AddAsync(o); + } + await _repository.UnitOfWork.SaveChangesAsync(); + + // Act + using var queryContext = _fixture.CreateContext(); + var queryRepo = new OrderRepository(queryContext); + var result = await queryRepo.GetByUserIdAsync("user-123"); + + // Assert + result.Should().HaveCount(2); + result.Should().AllSatisfy(o => o.UserId.Should().Be("user-123")); + } +} +``` + +--- + +## Functional Testing with TestServer + +### Custom Web Application Factory + +```csharp +/// +/// EN: Custom factory for functional tests. +/// VI: Factory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + private readonly PostgreSqlContainer _container; + + public CustomWebApplicationFactory() + { + _container = new PostgreSqlBuilder() + .WithImage("postgres:15-alpine") + .Build(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + _container.StartAsync().GetAwaiter().GetResult(); + + builder.ConfigureServices(services => + { + // EN: Remove existing DbContext registration + // VI: Xóa đăng ký DbContext hiện có + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) + services.Remove(descriptor); + + // EN: Add test database + // VI: Thêm test database + services.AddDbContext(options => + options.UseNpgsql(_container.GetConnectionString())); + + // EN: Ensure database is created and migrated + // VI: Đảm bảo database được tạo và migrate + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + }); + } + + protected override void Dispose(bool disposing) + { + _container.DisposeAsync().GetAwaiter().GetResult(); + base.Dispose(disposing); + } +} +``` + +### API Functional Tests + +```csharp +/// +/// EN: Functional tests for Orders API. +/// VI: Functional tests cho Orders API. +/// +public class OrdersApiTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; + + public OrdersApiTests(CustomWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + + // EN: Add test authentication + // VI: Thêm authentication test + _client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Test", "user-123"); + } + + [Fact] + public async Task CreateOrder_ValidRequest_Returns201WithOrderId() + { + // Arrange + var request = new + { + ShippingAddress = new + { + Street = "123 Test St", + City = "Test City", + State = "TS", + PostalCode = "12345", + Country = "US" + }, + Items = new[] + { + new { ProductId = Guid.NewGuid(), Quantity = 2, UnitPrice = 10.00m } + } + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/orders", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var content = await response.Content.ReadFromJsonAsync>(); + content.Should().NotBeNull(); + content!.Success.Should().BeTrue(); + content.Data!.OrderId.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetOrder_NotExists_Returns404() + { + // Act + var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task CreateAndGetOrder_Workflow_ReturnsCreatedOrder() + { + // Arrange - Create order + var createRequest = new + { + ShippingAddress = new { Street = "St", City = "City", State = "ST", PostalCode = "12345", Country = "US" }, + Items = new[] { new { ProductId = Guid.NewGuid(), Quantity = 1, UnitPrice = 50.00m } } + }; + + // Act - Create + var createResponse = await _client.PostAsJsonAsync("/api/v1/orders", createRequest); + var createResult = await createResponse.Content.ReadFromJsonAsync>(); + var orderId = createResult!.Data!.OrderId; + + // Act - Get + var getResponse = await _client.GetAsync($"/api/v1/orders/{orderId}"); + var getResult = await getResponse.Content.ReadFromJsonAsync>(); + + // Assert + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + getResult!.Data!.Id.Should().Be(orderId); + getResult.Data.TotalAmount.Should().Be(50.00m); + } +} +``` + +--- + +## Test Data Builders + +### Builder Pattern for Test Data + +```csharp +/// +/// EN: Builder for creating test orders. +/// VI: Builder để tạo test orders. +/// +public class OrderBuilder +{ + private string _userId = "test-user"; + private Address _address = new("123 Test St", "Test City", "TS", "12345", "US"); + private readonly List<(Guid ProductId, int Quantity, decimal UnitPrice)> _items = new(); + private OrderStatus? _status; + + public OrderBuilder WithUserId(string userId) + { + _userId = userId; + return this; + } + + public OrderBuilder WithAddress(Address address) + { + _address = address; + return this; + } + + public OrderBuilder WithItem(Guid? productId = null, int quantity = 1, decimal unitPrice = 10.00m) + { + _items.Add((productId ?? Guid.NewGuid(), quantity, unitPrice)); + return this; + } + + public OrderBuilder AsSubmitted() + { + _status = OrderStatus.Submitted; + return this; + } + + public Order Build() + { + var order = new Order(_userId, _address); + + foreach (var (productId, quantity, unitPrice) in _items) + { + order.AddItem(productId, quantity, unitPrice); + } + + if (_status == OrderStatus.Submitted && _items.Any()) + { + order.Submit(); + } + + return order; + } +} + +// EN: Usage / VI: Cách dùng +var order = new OrderBuilder() + .WithUserId("user-123") + .WithItem(quantity: 2, unitPrice: 15.00m) + .WithItem(quantity: 1, unitPrice: 25.00m) + .AsSubmitted() + .Build(); +``` + +--- + +## Resources / Tài Nguyên + +- [xUnit Documentation](https://xunit.net/docs/getting-started/netcore/cmdline) +- [NSubstitute Documentation](https://nsubstitute.github.io/) +- [FluentAssertions](https://fluentassertions.com/) +- [Testcontainers for .NET](https://testcontainers.com/guides/getting-started-with-testcontainers-for-dotnet/) +- [Microsoft: Integration Testing](https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests) diff --git a/note.md b/note.md index 6f31fd67..8cdd569b 100644 --- a/note.md +++ b/note.md @@ -1,2 +1,50 @@ Tài khoản: hongochai10@icloud.com -Mật Khẩu: Velik@2026 \ No newline at end of file +Mật Khẩu: Velik@2026 + + +📋 Đề Xuất Agent Skills Mới cho GoodGo Platform +Skills Hiện Có (6 skills): +Skill Mô Tả +api-design RESTful API design standards +comment-code Bilingual comments EN/VI +documentation Documentation guidelines +project-rules Coding standards & architecture +security Security patterns +skill-authoring Hướng dẫn viết Agent Skills +🆕 Đề Xuất Skills Mới +1. testing-patterns +Mô tả: Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers. +Lý do: Dự án có thư mục tests/ trong các services nhưng chưa có skill hướng dẫn chuẩn testing. +2. repository-pattern +Mô tả: Entity Framework Core repository và data access patterns. Use for DbContext, repositories, migrations, và query optimization. +Lý do: Tất cả services sử dụng EF Core theo Clean Architecture pattern. +3. error-handling-patterns +Mô tả: Global error handling, domain exceptions, và Result pattern. Use for exception middleware, validation errors, và logging. +Lý do: Được reference trong api-design và project-rules nhưng chưa tồn tại. +4. docker-traefik +Mô tả: Docker containerization và Traefik reverse proxy. Use for Dockerfile, docker-compose, routing rules, và load balancing. +Lý do: Dự án sử dụng Traefik làm API Gateway (thư mục infra/traefik/). +5. observability +Mô tả: Monitoring, logging, và tracing patterns. Use for Prometheus metrics, Grafana dashboards, Loki logging, và distributed tracing. +Lý do: Có thư mục infra/observability/ với stack monitoring đầy đủ. +6. cqrs-mediatr +Mô tả: CQRS pattern với MediatR. Use for Commands, Queries, Handlers, Behaviors, và Pipeline. +Lý do: Tất cả controllers sử dụng MediatR pattern (Commands/Queries). +7. inter-service-communication +Mô tả: Giao tiếp giữa các microservices. Use for HttpClient, gRPC, message queues, và service discovery. +Lý do: Các services như storage-service-net gọi iam-service-net. +8. redis-caching +Mô tả: Redis caching strategies và patterns. Use for cache-aside, cache invalidation, distributed caching. +Lý do: Tech stack bao gồm Redis (StackExchange.Redis). +9. domain-driven-design +Mô tả: DDD patterns cho microservices. Use for Aggregates, Entities, Value Objects, Domain Events, và Bounded Contexts. +Lý do: Cấu trúc Domain/AggregatesModel/ cho thấy dự án tuân thủ DDD. +10. deployment-kubernetes +Mô tả: Kubernetes deployment patterns. Use for manifests, Helm charts, ConfigMaps, Secrets, và rolling updates. +Lý do: Thư mục deployments/kubernetes/ cho K8s deployment. +📊 Mức Độ Ưu Tiên +Ưu Tiên Skills Lý Do +Cao testing-patterns, repository-pattern, error-handling-patterns Core development patterns +Trung bình cqrs-mediatr, docker-traefik, observability Infrastructure & architecture +Thấp redis-caching, inter-service-communication, domain-driven-design, deployment-kubernetes Advanced patterns +Bạn muốn tôi tạo skill nào trước? Hay cần tôi tạo implementation plan chi tiết cho tất cả các skills đề xuất? \ No newline at end of file