docs(note): Add new Agent Skills proposals for GoodGo Platform
- Expanded the note.md file to include a comprehensive list of proposed new skills for the GoodGo Platform, detailing descriptions and reasons for each skill. - Organized skills into priority levels to guide implementation focus, enhancing clarity on core development patterns and infrastructure needs. - This update aims to improve documentation and facilitate better planning for future skill development.
This commit is contained in:
443
.agent/skills/error-handling-patterns/SKILL.md
Normal file
443
.agent/skills/error-handling-patterns/SKILL.md
Normal file
@@ -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
|
||||
/// <summary>
|
||||
/// EN: Global exception handler middleware.
|
||||
/// VI: Middleware xử lý exception toàn cục.
|
||||
/// </summary>
|
||||
public class ExceptionHandlerMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
|
||||
|
||||
public ExceptionHandlerMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<ExceptionHandlerMiddleware> 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<object>
|
||||
{
|
||||
Success = false,
|
||||
Error = message,
|
||||
ErrorCode = errorCode
|
||||
};
|
||||
|
||||
await context.Response.WriteAsJsonAsync(response);
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Register in Program.cs / VI: Đăng ký trong Program.cs
|
||||
app.UseMiddleware<ExceptionHandlerMiddleware>();
|
||||
```
|
||||
|
||||
### Domain Exception Classes
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Base domain exception.
|
||||
/// VI: Domain exception cơ sở.
|
||||
/// </summary>
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public string ErrorCode { get; }
|
||||
|
||||
public DomainException(string message, string errorCode = "DOMAIN_ERROR")
|
||||
: base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Resource not found exception.
|
||||
/// VI: Exception không tìm thấy resource.
|
||||
/// </summary>
|
||||
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")
|
||||
{ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Conflict/duplicate resource exception.
|
||||
/// VI: Exception trùng lặp resource.
|
||||
/// </summary>
|
||||
public class ConflictException : DomainException
|
||||
{
|
||||
public ConflictException(string message)
|
||||
: base(message, "CONFLICT")
|
||||
{ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validation exception with errors.
|
||||
/// VI: Exception validation với danh sách lỗi.
|
||||
/// </summary>
|
||||
public class ValidationException : DomainException
|
||||
{
|
||||
public IReadOnlyDictionary<string, string[]> Errors { get; }
|
||||
|
||||
public ValidationException(string message)
|
||||
: base(message, "VALIDATION_ERROR")
|
||||
{
|
||||
Errors = new Dictionary<string, string[]>();
|
||||
}
|
||||
|
||||
public ValidationException(IReadOnlyDictionary<string, string[]> errors)
|
||||
: base("One or more validation errors occurred", "VALIDATION_ERROR")
|
||||
{
|
||||
Errors = errors;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Retry with Polly
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure Polly retry policies.
|
||||
/// VI: Cấu hình Polly retry policies.
|
||||
/// </summary>
|
||||
|
||||
// EN: Register resilient HttpClient / VI: Đăng ký HttpClient có khả năng phục hồi
|
||||
builder.Services.AddHttpClient<IExternalService, ExternalService>(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<HttpResponseMessage>()
|
||||
.HandleResult(r => !r.IsSuccessStatusCode && (int)r.StatusCode >= 500)
|
||||
.Handle<HttpRequestException>()
|
||||
.Handle<TimeoutRejectedException>(),
|
||||
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
|
||||
/// <summary>
|
||||
/// EN: Configure health checks.
|
||||
/// VI: Cấu hình health checks.
|
||||
/// </summary>
|
||||
|
||||
// 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<ExternalServiceHealthCheck>("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
|
||||
/// <summary>
|
||||
/// EN: Custom health check for external service.
|
||||
/// VI: Health check tùy chỉnh cho external service.
|
||||
/// </summary>
|
||||
public class ExternalServiceHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public ExternalServiceHealthCheck(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("ExternalService");
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> 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<object>
|
||||
{
|
||||
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
|
||||
889
.agent/skills/error-handling-patterns/references/REFERENCE.md
Normal file
889
.agent/skills/error-handling-patterns/references/REFERENCE.md
Normal file
@@ -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
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ExceptionHandlerMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
|
||||
private readonly IHostEnvironment _environment;
|
||||
|
||||
public ExceptionHandlerMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<ExceptionHandlerMiddleware> 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
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Standardized error response.
|
||||
/// VI: Response lỗi chuẩn hóa.
|
||||
/// </summary>
|
||||
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<string, string[]>? Errors { get; set; }
|
||||
public string? StackTrace { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Exceptions
|
||||
|
||||
### Complete Exception Hierarchy
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Base domain exception with error code.
|
||||
/// VI: Domain exception cơ sở với error code.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validation exception with field-level errors.
|
||||
/// VI: Exception validation với lỗi từng field.
|
||||
/// </summary>
|
||||
public class ValidationException : DomainException
|
||||
{
|
||||
public IReadOnlyDictionary<string, string[]> Errors { get; }
|
||||
|
||||
public ValidationException(string message)
|
||||
: base(message, "VALIDATION_ERROR")
|
||||
{
|
||||
Errors = new Dictionary<string, string[]>();
|
||||
}
|
||||
|
||||
public ValidationException(string field, string error)
|
||||
: base($"Validation failed for {field}", "VALIDATION_ERROR")
|
||||
{
|
||||
Errors = new Dictionary<string, string[]>
|
||||
{
|
||||
{ field, new[] { error } }
|
||||
};
|
||||
}
|
||||
|
||||
public ValidationException(IDictionary<string, string[]> errors)
|
||||
: base("One or more validation errors occurred", "VALIDATION_ERROR")
|
||||
{
|
||||
Errors = new Dictionary<string, string[]>(errors);
|
||||
}
|
||||
|
||||
public ValidationException(IEnumerable<FluentValidation.Results.ValidationFailure> 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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Resource not found exception.
|
||||
/// VI: Exception không tìm thấy resource.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Conflict exception for duplicate resources.
|
||||
/// VI: Exception xung đột cho resource trùng lặp.
|
||||
/// </summary>
|
||||
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")
|
||||
{ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Forbidden exception for permission errors.
|
||||
/// VI: Exception forbidden cho lỗi phân quyền.
|
||||
/// </summary>
|
||||
public class ForbiddenException : DomainException
|
||||
{
|
||||
public ForbiddenException(string message = "You do not have permission to perform this action")
|
||||
: base(message, "FORBIDDEN")
|
||||
{ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Business rule exception.
|
||||
/// VI: Exception quy tắc nghiệp vụ.
|
||||
/// </summary>
|
||||
public class BusinessRuleException : DomainException
|
||||
{
|
||||
public BusinessRuleException(string message, string errorCode = "BUSINESS_RULE_VIOLATION")
|
||||
: base(message, errorCode)
|
||||
{ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation with FluentValidation
|
||||
|
||||
### Validation Behavior for MediatR
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: MediatR pipeline behavior for FluentValidation.
|
||||
/// VI: MediatR pipeline behavior cho FluentValidation.
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse>
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
|
||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||
{
|
||||
_validators = validators;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_validators.Any())
|
||||
return await next();
|
||||
|
||||
var context = new ValidationContext<TRequest>(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
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateOrderCommand.
|
||||
/// VI: Validator cho CreateOrderCommand.
|
||||
/// </summary>
|
||||
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
|
||||
{
|
||||
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<Address>
|
||||
{
|
||||
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<OrderItemDto>
|
||||
{
|
||||
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
|
||||
/// <summary>
|
||||
/// EN: Result type for operations that can fail.
|
||||
/// VI: Result type cho operations có thể thất bại.
|
||||
/// </summary>
|
||||
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<T> Success<T>(T value) => new(value, true, null, null);
|
||||
public static Result<T> Failure<T>(string error, string errorCode = "ERROR")
|
||||
=> new(default!, false, error, errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result type with value.
|
||||
/// VI: Result type có giá trị.
|
||||
/// </summary>
|
||||
public class Result<T> : 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>(T value) => Success(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result extensions for common patterns.
|
||||
/// VI: Extensions cho các mẫu phổ biến.
|
||||
/// </summary>
|
||||
public static class ResultExtensions
|
||||
{
|
||||
public static Result<TOut> Map<TIn, TOut>(
|
||||
this Result<TIn> result,
|
||||
Func<TIn, TOut> mapper)
|
||||
{
|
||||
return result.IsSuccess
|
||||
? Result.Success(mapper(result.Value))
|
||||
: Result.Failure<TOut>(result.Error!, result.ErrorCode!);
|
||||
}
|
||||
|
||||
public static async Task<Result<TOut>> MapAsync<TIn, TOut>(
|
||||
this Task<Result<TIn>> resultTask,
|
||||
Func<TIn, TOut> mapper)
|
||||
{
|
||||
var result = await resultTask;
|
||||
return result.Map(mapper);
|
||||
}
|
||||
|
||||
public static Result<T> Ensure<T>(
|
||||
this Result<T> result,
|
||||
Func<T, bool> predicate,
|
||||
string error,
|
||||
string errorCode = "ERROR")
|
||||
{
|
||||
if (result.IsFailure)
|
||||
return result;
|
||||
|
||||
return predicate(result.Value)
|
||||
? result
|
||||
: Result.Failure<T>(error, errorCode);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Result Pattern
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Command handler returning Result instead of throwing.
|
||||
/// VI: Command handler trả về Result thay vì throw.
|
||||
/// </summary>
|
||||
public class CreateOrderCommandHandler
|
||||
: IRequestHandler<CreateOrderCommand, Result<OrderResult>>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public async Task<Result<OrderResult>> 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<OrderResult>("User not found", "USER_NOT_FOUND");
|
||||
|
||||
// EN: Validate items
|
||||
// VI: Xác thực items
|
||||
if (!request.Items.Any())
|
||||
return Result.Failure<OrderResult>("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<IActionResult> 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
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
|
||||
// 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<IExternalPaymentService, ExternalPaymentService>(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<HttpResponseMessage>()
|
||||
.HandleResult(r => (int)r.StatusCode >= 500)
|
||||
.Handle<HttpRequestException>()
|
||||
.Handle<TimeoutRejectedException>(),
|
||||
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
|
||||
/// <summary>
|
||||
/// EN: Configure EF Core with retry logic.
|
||||
/// VI: Cấu hình EF Core với logic retry.
|
||||
/// </summary>
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(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
|
||||
/// <summary>
|
||||
/// EN: Configure comprehensive health checks.
|
||||
/// VI: Cấu hình health checks toàn diện.
|
||||
/// </summary>
|
||||
|
||||
// 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<StorageHealthCheck>("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
|
||||
/// <summary>
|
||||
/// EN: Custom health check for storage service.
|
||||
/// VI: Health check tùy chỉnh cho storage service.
|
||||
/// </summary>
|
||||
public class StorageHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IMinioClient _minioClient;
|
||||
private readonly ILogger<StorageHealthCheck> _logger;
|
||||
|
||||
public StorageHealthCheck(
|
||||
IMinioClient minioClient,
|
||||
ILogger<StorageHealthCheck> logger)
|
||||
{
|
||||
_minioClient = minioClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> 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<string, object>
|
||||
{
|
||||
{ "bucket", "uploads" }
|
||||
});
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy(
|
||||
"Storage service is healthy",
|
||||
data: new Dictionary<string, object>
|
||||
{
|
||||
{ "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
|
||||
/// <summary>
|
||||
/// EN: Configure Problem Details for standardized errors.
|
||||
/// VI: Cấu hình Problem Details cho lỗi chuẩn hóa.
|
||||
/// </summary>
|
||||
|
||||
// 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<IExceptionHandlerFeature>();
|
||||
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)
|
||||
369
.agent/skills/repository-pattern/SKILL.md
Normal file
369
.agent/skills/repository-pattern/SKILL.md
Normal file
@@ -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<int> 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
|
||||
/// <summary>
|
||||
/// EN: Base repository interface for aggregate roots.
|
||||
/// VI: Interface repository cơ bản cho aggregate roots.
|
||||
/// </summary>
|
||||
public interface IRepository<T> where T : class, IAggregateRoot
|
||||
{
|
||||
IUnitOfWork UnitOfWork { get; }
|
||||
|
||||
Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<T> AddAsync(T entity, CancellationToken ct = default);
|
||||
void Update(T entity);
|
||||
void Delete(T entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Order repository interface.
|
||||
/// VI: Interface repository cho Order.
|
||||
/// </summary>
|
||||
public interface IOrderRepository : IRepository<Order>
|
||||
{
|
||||
Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Order>> GetByUserIdAsync(string userId, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### EF Core Implementation / Triển Khai EF Core
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: EF Core repository implementation.
|
||||
/// VI: Triển khai repository với EF Core.
|
||||
/// </summary>
|
||||
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<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Orders.FindAsync(new object[] { id }, ct);
|
||||
}
|
||||
|
||||
public async Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Orders
|
||||
.Include(o => o.OrderItems)
|
||||
.FirstOrDefaultAsync(o => o.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<Order> 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
|
||||
/// <summary>
|
||||
/// EN: Command handler using repository pattern.
|
||||
/// VI: Command handler sử dụng repository pattern.
|
||||
/// </summary>
|
||||
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
|
||||
public CreateOrderCommandHandler(IOrderRepository orderRepository)
|
||||
{
|
||||
_orderRepository = orderRepository;
|
||||
}
|
||||
|
||||
public async Task<OrderResult> 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
|
||||
/// <summary>
|
||||
/// EN: Query handler using Dapper for read operations.
|
||||
/// VI: Query handler dùng Dapper cho operations đọc.
|
||||
/// </summary>
|
||||
public class GetOrdersQueryHandler : IRequestHandler<GetOrdersQuery, IReadOnlyList<OrderDto>>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public GetOrdersQueryHandler(IDbConnection connection)
|
||||
{
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OrderDto>> 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<OrderDto>(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<Order?> 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<Order> GetAll();
|
||||
}
|
||||
|
||||
// ✅ GOOD: Encapsulated queries
|
||||
public interface IOrderRepository
|
||||
{
|
||||
Task<IReadOnlyList<Order>> GetByUserIdAsync(string userId, CancellationToken ct);
|
||||
Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Missing Unit of Work / Thiếu Unit of Work
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: SaveChanges in repository
|
||||
public async Task<Order> 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<OrderResult> 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<IOrderRepository, OrderRepository>();
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
|
||||
// EN: Register DbContext
|
||||
// VI: Đăng ký DbContext
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
// EN: Register Dapper connection for queries
|
||||
// VI: Đăng ký Dapper connection cho queries
|
||||
builder.Services.AddScoped<IDbConnection>(_ =>
|
||||
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
|
||||
770
.agent/skills/repository-pattern/references/REFERENCE.md
Normal file
770
.agent/skills/repository-pattern/references/REFERENCE.md
Normal file
@@ -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
|
||||
/// <summary>
|
||||
/// EN: Marker interface for aggregate roots.
|
||||
/// VI: Interface đánh dấu aggregate roots.
|
||||
/// </summary>
|
||||
public interface IAggregateRoot { }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base entity with common properties.
|
||||
/// VI: Entity cơ sở với các properties chung.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>
|
||||
/// EN: Order aggregate root.
|
||||
/// VI: Aggregate root cho Order.
|
||||
/// </summary>
|
||||
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<OrderItem> _orderItems = new();
|
||||
public IReadOnlyCollection<OrderItem> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add item through aggregate root to maintain invariants.
|
||||
/// VI: Thêm item thông qua aggregate root để duy trì invariants.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update item quantity through aggregate root.
|
||||
/// VI: Cập nhật số lượng item thông qua aggregate root.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Order item - child entity of Order aggregate.
|
||||
/// VI: Order item - entity con của Order aggregate.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Value object for address.
|
||||
/// VI: Value object cho địa chỉ.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>
|
||||
/// EN: Generic repository interface for aggregate roots.
|
||||
/// VI: Interface repository generic cho aggregate roots.
|
||||
/// </summary>
|
||||
public interface IRepository<T> where T : class, IAggregateRoot
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Unit of Work for transaction management.
|
||||
/// VI: Unit of Work cho quản lý transaction.
|
||||
/// </summary>
|
||||
IUnitOfWork UnitOfWork { get; }
|
||||
|
||||
Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<T> AddAsync(T entity, CancellationToken ct = default);
|
||||
void Update(T entity);
|
||||
void Delete(T entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit of Work interface.
|
||||
/// VI: Interface Unit of Work.
|
||||
/// </summary>
|
||||
public interface IUnitOfWork : IDisposable
|
||||
{
|
||||
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||
Task<bool> SaveEntitiesAsync(CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### Specific Repository Interface
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Order repository interface with domain-specific methods.
|
||||
/// VI: Interface repository Order với các phương thức domain-specific.
|
||||
/// </summary>
|
||||
public interface IOrderRepository : IRepository<Order>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get order with all items loaded.
|
||||
/// VI: Lấy order với tất cả items được load.
|
||||
/// </summary>
|
||||
Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get orders by user with pagination.
|
||||
/// VI: Lấy orders theo user với phân trang.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Order>> GetByUserIdAsync(
|
||||
string userId,
|
||||
int skip = 0,
|
||||
int take = 20,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get orders by status.
|
||||
/// VI: Lấy orders theo status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Order>> GetByStatusAsync(
|
||||
OrderStatus status,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EF Core Implementation
|
||||
|
||||
### DbContext as Unit of Work
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Application DbContext implementing Unit of Work.
|
||||
/// VI: DbContext ứng dụng triển khai Unit of Work.
|
||||
/// </summary>
|
||||
public class ApplicationDbContext : DbContext, IUnitOfWork
|
||||
{
|
||||
public DbSet<Order> Orders => Set<Order>();
|
||||
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
|
||||
|
||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options) { }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Save entities with domain events support.
|
||||
/// VI: Lưu entities với hỗ trợ domain events.
|
||||
/// </summary>
|
||||
public async Task<bool> 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
|
||||
/// <summary>
|
||||
/// EN: Order repository implementation with EF Core.
|
||||
/// VI: Triển khai repository Order với EF Core.
|
||||
/// </summary>
|
||||
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<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Orders.FindAsync(new object[] { id }, ct);
|
||||
}
|
||||
|
||||
public async Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Orders
|
||||
.Include(o => o.OrderItems)
|
||||
.FirstOrDefaultAsync(o => o.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Order>> 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<IReadOnlyList<Order>> GetByStatusAsync(
|
||||
OrderStatus status,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Orders
|
||||
.Where(o => o.Status == status)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Order> 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
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Order entity.
|
||||
/// VI: Cấu hình EF Core cho entity Order.
|
||||
/// </summary>
|
||||
public class OrderConfiguration : IEntityTypeConfiguration<Order>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Order> builder)
|
||||
{
|
||||
builder.ToTable("Orders");
|
||||
|
||||
builder.HasKey(o => o.Id);
|
||||
|
||||
builder.Property(o => o.UserId)
|
||||
.IsRequired()
|
||||
.HasMaxLength(36);
|
||||
|
||||
builder.Property(o => o.Status)
|
||||
.IsRequired()
|
||||
.HasConversion<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for OrderItem entity.
|
||||
/// VI: Cấu hình EF Core cho entity OrderItem.
|
||||
/// </summary>
|
||||
public class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OrderItem> 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
|
||||
/// <summary>
|
||||
/// EN: Lightweight DTO for order queries.
|
||||
/// VI: DTO nhẹ cho order queries.
|
||||
/// </summary>
|
||||
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<OrderItemDto> 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
|
||||
/// <summary>
|
||||
/// EN: Query handler using Dapper for optimized reads.
|
||||
/// VI: Query handler dùng Dapper cho đọc tối ưu.
|
||||
/// </summary>
|
||||
public class GetUserOrdersQueryHandler
|
||||
: IRequestHandler<GetUserOrdersQuery, PagedResult<OrderSummaryDto>>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public GetUserOrdersQueryHandler(IDbConnection connection)
|
||||
{
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<OrderSummaryDto>> 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<int>(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<OrderSummaryDto>(dataSql, new
|
||||
{
|
||||
request.UserId,
|
||||
request.Skip,
|
||||
request.Take
|
||||
});
|
||||
|
||||
return new PagedResult<OrderSummaryDto>(
|
||||
orders.ToList(),
|
||||
total,
|
||||
request.Skip / request.Take + 1,
|
||||
request.Take);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class GetOrderDetailQueryHandler
|
||||
: IRequestHandler<GetOrderDetailQuery, OrderDetailDto?>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public async Task<OrderDetailDto?> 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<dynamic>();
|
||||
if (order == null) return null;
|
||||
|
||||
var items = (await multi.ReadAsync<OrderItemDto>()).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
|
||||
/// <summary>
|
||||
/// EN: Specification pattern for complex queries.
|
||||
/// VI: Specification pattern cho queries phức tạp.
|
||||
/// </summary>
|
||||
public abstract class Specification<T>
|
||||
{
|
||||
public abstract Expression<Func<T, bool>> ToExpression();
|
||||
|
||||
public bool IsSatisfiedBy(T entity)
|
||||
{
|
||||
return ToExpression().Compile()(entity);
|
||||
}
|
||||
|
||||
public Specification<T> And(Specification<T> other)
|
||||
=> new AndSpecification<T>(this, other);
|
||||
|
||||
public Specification<T> Or(Specification<T> other)
|
||||
=> new OrSpecification<T>(this, other);
|
||||
|
||||
public Specification<T> Not()
|
||||
=> new NotSpecification<T>(this);
|
||||
}
|
||||
|
||||
public class AndSpecification<T> : Specification<T>
|
||||
{
|
||||
private readonly Specification<T> _left;
|
||||
private readonly Specification<T> _right;
|
||||
|
||||
public AndSpecification(Specification<T> left, Specification<T> right)
|
||||
{
|
||||
_left = left;
|
||||
_right = right;
|
||||
}
|
||||
|
||||
public override Expression<Func<T, bool>> 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<Func<T, bool>>(body, param);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Order Specifications
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Specification for orders by user.
|
||||
/// VI: Specification cho orders theo user.
|
||||
/// </summary>
|
||||
public class OrderByUserSpecification : Specification<Order>
|
||||
{
|
||||
private readonly string _userId;
|
||||
|
||||
public OrderByUserSpecification(string userId) => _userId = userId;
|
||||
|
||||
public override Expression<Func<Order, bool>> ToExpression()
|
||||
=> order => order.UserId == _userId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Specification for orders by status.
|
||||
/// VI: Specification cho orders theo status.
|
||||
/// </summary>
|
||||
public class OrderByStatusSpecification : Specification<Order>
|
||||
{
|
||||
private readonly OrderStatus _status;
|
||||
|
||||
public OrderByStatusSpecification(OrderStatus status) => _status = status;
|
||||
|
||||
public override Expression<Func<Order, bool>> 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
|
||||
/// <summary>
|
||||
/// EN: Register repositories and DbContext.
|
||||
/// VI: Đăng ký repositories và DbContext.
|
||||
/// </summary>
|
||||
|
||||
// EN: DbContext registration
|
||||
// VI: Đăng ký DbContext
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(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<IOrderRepository, OrderRepository>();
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
builder.Services.AddScoped<IProductRepository, ProductRepository>();
|
||||
|
||||
// EN: Dapper connection for queries
|
||||
// VI: Dapper connection cho queries
|
||||
builder.Services.AddScoped<IDbConnection>(_ =>
|
||||
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)
|
||||
445
.agent/skills/testing-patterns/SKILL.md
Normal file
445
.agent/skills/testing-patterns/SKILL.md
Normal file
@@ -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
|
||||
/// <summary>
|
||||
/// EN: Unit test for command handler.
|
||||
/// VI: Unit test cho command handler.
|
||||
/// </summary>
|
||||
public class CreateOrderCommandHandlerTests
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly ILogger<CreateOrderCommandHandler> _logger;
|
||||
private readonly CreateOrderCommandHandler _handler;
|
||||
|
||||
public CreateOrderCommandHandlerTests()
|
||||
{
|
||||
// EN: Create mocks with NSubstitute
|
||||
// VI: Tạo mocks với NSubstitute
|
||||
_orderRepository = Substitute.For<IOrderRepository>();
|
||||
_logger = Substitute.For<ILogger<CreateOrderCommandHandler>>();
|
||||
|
||||
// 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<OrderItemDto>
|
||||
{
|
||||
new(ProductId: Guid.NewGuid(), Quantity: 2, UnitPrice: 10.00m)
|
||||
});
|
||||
|
||||
_orderRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo => callInfo.Arg<Order>());
|
||||
_orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
|
||||
.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<Order>(o => o.UserId == "user-123"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<OrderItemDto>());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<DomainException>(() =>
|
||||
_handler.Handle(command, CancellationToken.None));
|
||||
|
||||
await _orderRepository.DidNotReceive().AddAsync(
|
||||
Arg.Any<Order>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Entity Tests / Test Domain Entity
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Tests for Order aggregate root.
|
||||
/// VI: Tests cho Order aggregate root.
|
||||
/// </summary>
|
||||
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<DomainException>()
|
||||
.WithMessage("Cannot submit empty order");
|
||||
}
|
||||
|
||||
private static Address CreateAddress() =>
|
||||
new("123 Main St", "City", "State", "12345", "Country");
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests với Testcontainers
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Database fixture using Testcontainers.
|
||||
/// VI: Database fixture dùng Testcontainers.
|
||||
/// </summary>
|
||||
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<ApplicationDbContext>()
|
||||
.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<DatabaseFixture>
|
||||
{
|
||||
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
|
||||
/// <summary>
|
||||
/// EN: Web application factory for functional tests.
|
||||
/// VI: Web application factory cho functional tests.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
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<ApplicationDbContext>));
|
||||
if (descriptor != null)
|
||||
services.Remove(descriptor);
|
||||
|
||||
// EN: Add in-memory database for tests
|
||||
// VI: Thêm in-memory database cho tests
|
||||
services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseInMemoryDatabase("TestDb"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
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<OperationCanceledException>(() =>
|
||||
_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<IOrderService>();
|
||||
|
||||
// EN: Setup return value / VI: Thiết lập giá trị trả về
|
||||
service.GetByIdAsync(Arg.Any<Guid>()).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<Order>(o => capturedOrder = o));
|
||||
```
|
||||
|
||||
### FluentAssertions
|
||||
|
||||
```csharp
|
||||
result.Should().NotBeNull();
|
||||
result.Should().BeEquivalentTo(expected);
|
||||
orders.Should().HaveCount(5);
|
||||
exception.Should().Throw<DomainException>().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
|
||||
891
.agent/skills/testing-patterns/references/REFERENCE.md
Normal file
891
.agent/skills/testing-patterns/references/REFERENCE.md
Normal file
@@ -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
|
||||
<!-- ServiceName.UnitTests.csproj -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.16" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ServiceName.API\ServiceName.API.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
### Integration Test Project
|
||||
|
||||
```xml
|
||||
<!-- ServiceName.IntegrationTests.csproj -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="3.6.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ServiceName.API\ServiceName.API.csproj" />
|
||||
<ProjectReference Include="..\..\src\ServiceName.Infrastructure\ServiceName.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unit Testing MediatR Handlers
|
||||
|
||||
### Command Handler Tests
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Tests for CreateOrderCommandHandler.
|
||||
/// VI: Tests cho CreateOrderCommandHandler.
|
||||
/// </summary>
|
||||
public class CreateOrderCommandHandlerTests
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ILogger<CreateOrderCommandHandler> _logger;
|
||||
private readonly CreateOrderCommandHandler _handler;
|
||||
|
||||
public CreateOrderCommandHandlerTests()
|
||||
{
|
||||
_orderRepository = Substitute.For<IOrderRepository>();
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_logger = Substitute.For<ILogger<CreateOrderCommandHandler>>();
|
||||
|
||||
// EN: Setup default returns
|
||||
// VI: Thiết lập giá trị trả về mặc định
|
||||
_orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
|
||||
.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<Order>(o => capturedOrder = o), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo => callInfo.Arg<Order>());
|
||||
|
||||
// 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<Order>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _orderRepository.UnitOfWork.Received(1)
|
||||
.SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyItems_ThrowsDomainException()
|
||||
{
|
||||
// Arrange
|
||||
var command = CreateValidCommand() with { Items = new List<OrderItemDto>() };
|
||||
|
||||
// Act
|
||||
var act = () => _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<DomainException>()
|
||||
.WithMessage("*empty*");
|
||||
|
||||
await _orderRepository.DidNotReceive().AddAsync(
|
||||
Arg.Any<Order>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RepositoryThrows_LogsAndRethrows()
|
||||
{
|
||||
// Arrange
|
||||
var command = CreateValidCommand();
|
||||
var expectedException = new InvalidOperationException("Database error");
|
||||
|
||||
_orderRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(expectedException);
|
||||
|
||||
// Act
|
||||
var act = () => _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("Database error");
|
||||
}
|
||||
|
||||
private static CreateOrderCommand CreateValidCommand() =>
|
||||
new(
|
||||
UserId: "user-123",
|
||||
ShippingAddress: new Address("123 Main St", "City", "State", "12345", "US"),
|
||||
Items: new List<OrderItemDto>
|
||||
{
|
||||
new(Guid.NewGuid(), 2, 10.00m),
|
||||
new(Guid.NewGuid(), 1, 25.00m)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Query Handler Tests
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Tests for GetOrderQueryHandler.
|
||||
/// VI: Tests cho GetOrderQueryHandler.
|
||||
/// </summary>
|
||||
public class GetOrderQueryHandlerTests
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly GetOrderQueryHandler _handler;
|
||||
|
||||
public GetOrderQueryHandlerTests()
|
||||
{
|
||||
_orderRepository = Substitute.For<IOrderRepository>();
|
||||
_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<CancellationToken>())
|
||||
.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<Guid>(), Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.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
|
||||
/// <summary>
|
||||
/// EN: Tests for Order aggregate root behavior.
|
||||
/// VI: Tests cho behavior của Order aggregate root.
|
||||
/// </summary>
|
||||
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<DomainException>()
|
||||
.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<ArgumentException>()
|
||||
.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<DomainException>()
|
||||
.WithMessage("Cannot submit empty order");
|
||||
}
|
||||
|
||||
private static Address CreateAddress() =>
|
||||
new("123 Main St", "City", "State", "12345", "Country");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controller Testing
|
||||
|
||||
### Controller Unit Tests
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Tests for OrdersController.
|
||||
/// VI: Tests cho OrdersController.
|
||||
/// </summary>
|
||||
public class OrdersControllerTests
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly OrdersController _controller;
|
||||
|
||||
public OrdersControllerTests()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_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<CreateOrderCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(expectedResult);
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateOrder(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var createdResult = result.Result.Should().BeOfType<CreatedAtActionResult>().Subject;
|
||||
var response = createdResult.Value.Should().BeOfType<ApiResponse<OrderResult>>().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<GetOrderQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns((OrderDto?)null);
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOrder(orderId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[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<GetOrderQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(orderDto);
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOrder(orderId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var response = okResult.Value.Should().BeOfType<ApiResponse<OrderDto>>().Subject;
|
||||
response.Success.Should().BeTrue();
|
||||
response.Data!.Id.Should().Be(orderId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Testing with Testcontainers
|
||||
|
||||
### Database Fixture
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Shared database fixture for integration tests.
|
||||
/// VI: Fixture database dùng chung cho integration tests.
|
||||
/// </summary>
|
||||
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<ApplicationDbContext>()
|
||||
.UseNpgsql(ConnectionString)
|
||||
.Options;
|
||||
|
||||
DbContext = new ApplicationDbContext(options);
|
||||
await DbContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await DbContext.DisposeAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create fresh DbContext for each test.
|
||||
/// VI: Tạo DbContext mới cho mỗi test.
|
||||
/// </summary>
|
||||
public ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseNpgsql(ConnectionString)
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("Database")]
|
||||
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
|
||||
```
|
||||
|
||||
### Repository Integration Tests
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Integration tests for OrderRepository.
|
||||
/// VI: Integration tests cho OrderRepository.
|
||||
/// </summary>
|
||||
[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
|
||||
/// <summary>
|
||||
/// EN: Custom factory for functional tests.
|
||||
/// VI: Factory tùy chỉnh cho functional tests.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
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<ApplicationDbContext>));
|
||||
if (descriptor != null)
|
||||
services.Remove(descriptor);
|
||||
|
||||
// EN: Add test database
|
||||
// VI: Thêm test database
|
||||
services.AddDbContext<ApplicationDbContext>(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<ApplicationDbContext>();
|
||||
db.Database.Migrate();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_container.DisposeAsync().GetAwaiter().GetResult();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Functional Tests
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Functional tests for Orders API.
|
||||
/// VI: Functional tests cho Orders API.
|
||||
/// </summary>
|
||||
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
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<ApiResponse<OrderResult>>();
|
||||
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<ApiResponse<OrderResult>>();
|
||||
var orderId = createResult!.Data!.OrderId;
|
||||
|
||||
// Act - Get
|
||||
var getResponse = await _client.GetAsync($"/api/v1/orders/{orderId}");
|
||||
var getResult = await getResponse.Content.ReadFromJsonAsync<ApiResponse<OrderDto>>();
|
||||
|
||||
// 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
|
||||
/// <summary>
|
||||
/// EN: Builder for creating test orders.
|
||||
/// VI: Builder để tạo test orders.
|
||||
/// </summary>
|
||||
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)
|
||||
Reference in New Issue
Block a user