feat: EOD reports, security audit (rate limiting + 44 validators), and 30 critical path tests

EOD Reports & Daily Close (order-service + Blazor UI):
- GetEodReportQuery: Dapper query for revenue, orders, payment breakdown, top items, hourly chart
- CloseDayCommand: check pending orders, generate final report
- EodReport.razor: 6 KPI cards, donut/bar charts, top 10 table, close-day dialog
- FluentValidation for both query and command
- BFF proxy endpoints for reports

Security Audit — Rate Limiting:
- Tighten auth-ratelimit from 100 to 10 req/min (brute force protection)
- Add payment-ratelimit (30/min), api-ratelimit (100/min), hub-ratelimit (500/min)
- Apply rate limits to ALL Traefik routers (previously many had none)

Security Audit — Input Sanitization (44 missing validators created):
- iam-service: 14 validators (auth, user, role commands)
- merchant-service: 11 validators (admin, attendance commands)
- wallet-service: 7 validators (wallet, points commands)
- fnb-engine: 7 validators (session, table, ticket, reservation)
- catalog-service: 6 validators (product, category CRUD)
- storage-service: 6 validators (upload, share, quota)
- order-service: 2 validators (complete order/payment)

Critical Path Unit Tests (30 new tests):
- inventory-service: 12 tests (deduction, partial stock, idempotency)
- wallet-service: 14 tests (create payment, process callback, domain events)
- fnb-engine: 8 tests (kitchen-served event handler, inventory client integration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-06 16:33:39 +07:00
parent 653322b26c
commit a7a753bf38
27 changed files with 3741 additions and 13 deletions

View File

@@ -11,7 +11,7 @@
| Metric | Current | Phase 1 Target | Phase 2 Target | Phase 3 Target |
|--------|:-------:|:--------------:|:--------------:|:--------------:|
| Services production-ready | 8/24 | 12/24 | 16/24 | 20/24 |
| Test coverage (estimated) | ~40% | 70% | 80% | 85% |
| Test coverage (estimated) | ~45% | 70% | 80% | 85% |
| POS verticals fully working | 2/5 | 2/5 (stable) | 4/5 | 5/5 |
| Payment methods live | 0 | 2 | 3 | 4+ |
| Real-time features | 0 | KDS + Orders | Full POS | Full |
@@ -109,17 +109,18 @@
| 11 | EOD Reports + Daily Close | `TODO` | Frontend Blazor | Phase 1 / W4 | order-service queries |
| 12 | FnB Engine Test Coverage | `DONE` | QA Engineer | Phase 1 / W3 | 96 tests (57 domain + 39 handler) |
| 13 | Cafe Workflow Completion | `TODO` | Backend + Frontend | Phase 2 / W5-6 | Loyalty stamps, barista queue |
| 14 | Critical Path Unit Tests (inventory, payment, events) | `IN-PROGRESS` | QA Engineer | Phase 1 / W4 | Deduction, payment callback, domain event handlers |
### P2 — Enhancement
| # | Gap | Status | Sprint | Notes |
|:-:|-----|:------:|:------:|-------|
| 14 | Marketing — Zalo OA | `TODO` | Phase 3 | mkt-zalo-service |
| 15 | Marketing — Facebook | `TODO` | Phase 3 | mkt-facebook-service |
| 16 | Ads Platform | `TODO` | Phase 3 | 5 ads services |
| 17 | Mobile iOS v1 | `TODO` | Phase 3 | app-client-base-swift |
| 18 | Mobile MAUI v1 | `TODO` | Phase 3 | app-client-base-net |
| 19 | Observability Stack | `TODO` | Phase 2 | Prometheus + Grafana + Loki |
| 15 | Marketing — Zalo OA | `TODO` | Phase 3 | mkt-zalo-service |
| 16 | Marketing — Facebook | `TODO` | Phase 3 | mkt-facebook-service |
| 17 | Ads Platform | `TODO` | Phase 3 | 5 ads services |
| 18 | Mobile iOS v1 | `TODO` | Phase 3 | app-client-base-swift |
| 19 | Mobile MAUI v1 | `TODO` | Phase 3 | app-client-base-net |
| 20 | Observability Stack | `TODO` | Phase 2 | Prometheus + Grafana + Loki |
---
@@ -146,8 +147,8 @@
|------|-------|:------:|:----------:|
| Kitchen → Inventory auto-deduction | Senior Backend #1 | `DONE` | fnb-engine, inventory |
| Row-level security (all services) | Senior Backend #2 | `DONE` | — |
| Rate limiting audit | DevOps | `TODO` | — |
| Input sanitization audit | QA | `TODO` | — |
| Rate limiting audit | DevOps | `IN-PROGRESS` | — |
| Input sanitization audit | QA | `IN-PROGRESS` | — |
| FnB Engine unit tests | QA | `DONE` | — |
| Order lifecycle integration tests | QA | `DONE` | 29 tests, WebApplicationFactory |
@@ -155,7 +156,7 @@
| Task | Agent | Status | Depends On |
|------|-------|:------:|:----------:|
| EOD reports + daily close workflow | Senior Frontend | `TODO` | order-service |
| EOD reports + daily close workflow | Senior Frontend | `IN-PROGRESS` | order-service |
| Full regression testing | QA | `TODO` | All P0 done |
| Staging K8s deployment | DevOps | `DONE` | 16 manifests + CI/CD |
| Grafana monitoring dashboards | DevOps | `TODO` | Observability stack |
@@ -235,6 +236,24 @@
| Traefik Route (subscriptions) | DevOps | /api/v1/subscriptions → merchant-service |
| Admin Settings 5-Tab UI | Frontend | Tai khoan, Bao mat, Goi dich vu, Thong bao, He thong |
### 2026-03-06 (Code Review Fixes)
| Task | Agent | Details |
|------|-------|---------|
| Code Review — 75 issues identified | All Agents | Backend (16), Frontend (11), Infrastructure (32), Tests (16) |
| Fix wallet-service EF Config | Backend #1 | Removed 11 conflicting Ignore() calls for mapped backing fields |
| Fix KitchenTicket constructor | Backend #1 | Removed short constructor that assigned productId=orderItemId, updated 14 test call sites |
| Fix fire-and-forget inventory deduction | Backend #1 | Replaced Task.Run with direct await for reliable inventory deduction |
| Implement TenantMiddleware RLS (4 services) | Backend #2 | wallet, fnb, inventory, catalog — PostgreSQL SET LOCAL for RLS |
| Fix SQL injection pattern in order-service | Backend #2 | Guid.ToString("D") for safe formatting in TenantMiddleware |
| Add SignalR Hub shop authorization | Backend #2 | ValidateShopAccess() check in PosHub JoinShop/JoinKds/JoinPos |
| Fix PosDataService false success on error | Frontend | PayOrderWithDetailsAsync now returns Success=false on parse failure |
| Fix QrPayment timer race condition | Frontend | Added _disposed guard for safe timer disposal |
| Add [Authorize] to BFF OrderController | Frontend | Require JWT for all BFF order endpoints |
| PostgreSQL 15 → 16 in docker-compose | DevOps | Match project spec |
| Add 4 missing databases to init-databases.sh | DevOps | mkt_facebook, mkt_whatsapp, mkt_x, mkt_zalo |
| Add Traefik routes (wallet, catalog, booking) | DevOps | Plus /api/v1/stock for inventory |
### 2026-03-05
| Task | Agent | Details |
@@ -252,7 +271,7 @@
|------|----------|-----------|:------:|
| 2026-03-06 | IPaymentGateway in Domain, implementations in Infrastructure | Multiple gateways (VNPay, Momo) via same interface | ACTIVE |
| 2026-03-06 | PosHub in order-service (not separate service) | Order lifecycle owns real-time notifications | ACTIVE |
| 2026-03-06 | Kitchen→Inventory via HTTP + Polly (not message queue) | Simpler, sufficient for MVP, fire-and-forget pattern | ACTIVE |
| 2026-03-06 | Kitchen→Inventory via HTTP + Polly (not message queue) | Simpler, sufficient for MVP, direct await (changed from fire-and-forget after code review) | ACTIVE |
| 2026-03-06 | 3 payment flows: cash (instant), card (instant), online (async) | Cash/card don't need gateway, only VNPay/Momo need redirect | ACTIVE |
| 2026-03-06 | Subscription stored in Merchant aggregate | Simple, no separate service needed for MVP | ACTIVE |
| 2026-03-06 | Static plan definitions in frontend + backend | 4 fixed tiers sufficient for MVP launch | ACTIVE |

View File

@@ -0,0 +1,463 @@
@page "/admin/reports/eod"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@*
EN: End-of-Day Report page — daily closing summary with revenue, orders, payment breakdown, and top items.
VI: Trang bao cao cuoi ngay — tom tat dong ngay voi doanh thu, don hang, phan tich thanh toan, va san pham ban chay.
*@
<PageTitle>Bao cao cuoi ngay — GoodGo POS</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Bao cao cuoi ngay</h1>
<p class="admin-topbar__subtitle">Tong hop doanh thu va don hang trong ngay</p>
</div>
<div class="admin-topbar__right" style="display:flex;align-items:center;gap:12px;">
<MudDatePicker @bind-Date="_selectedDate"
Label="Chon ngay"
Variant="Variant.Outlined"
DateFormat="dd/MM/yyyy"
MaxDate="DateTime.Today"
Style="max-width:180px;"
Color="Color.Primary"
Class="mud-input-dark" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="LoadReportAsync"
Disabled="_loading"
StartIcon="@Icons.Material.Filled.Assessment">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" />
}
Xem bao cao
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Warning"
OnClick="CloseDayAsync"
Disabled="_loading || _report == null"
StartIcon="@Icons.Material.Filled.Lock">
Dong ngay
</MudButton>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (_loading)
{
<div style="display:flex;justify-content:center;padding:48px;">
<MudProgressCircular Size="Size.Large" Indeterminate="true" Color="Color.Primary" />
</div>
}
else if (_report == null)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
Chon ngay va nhan "Xem bao cao" de tai du lieu bao cao cuoi ngay.
</MudAlert>
}
else
{
@* ── KPI SUMMARY CARDS ── *@
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
@* Total Revenue *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);">
<i data-lucide="trending-up" style="color:#22C55E;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@FormatVND(_report.TotalRevenue)</span>
<span class="admin-stat-card__label">Tong doanh thu</span>
</div>
</div>
@* Total Orders *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);">
<i data-lucide="shopping-bag" style="color:#3B82F6;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_report.TotalOrders</span>
<span class="admin-stat-card__label">Tong don hang</span>
</div>
</div>
@* Average Order Value *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);">
<i data-lucide="banknote" style="color:#8B5CF6;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@FormatVND(_report.TotalOrders > 0 ? _report.TotalRevenue / _report.TotalOrders : 0)</span>
<span class="admin-stat-card__label">Gia tri TB / don</span>
</div>
</div>
@* Discount Total *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);">
<i data-lucide="percent" style="color:#EC4899;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@FormatVND(_report.DiscountTotal)</span>
<span class="admin-stat-card__label">Giam gia</span>
</div>
</div>
@* Completed Orders *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);">
<i data-lucide="check-circle" style="color:#22C55E;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_report.CompletedOrders</span>
<span class="admin-stat-card__label">Hoan thanh</span>
</div>
</div>
@* Cancelled Orders *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(239,68,68,0.1);">
<i data-lucide="x-circle" style="color:#EF4444;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_report.CancelledOrders</span>
<span class="admin-stat-card__label">Da huy</span>
</div>
</div>
</div>
@* ── REVENUE BY PAYMENT METHOD ── *@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
@* Payment Breakdown Chart *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Phan tich thanh toan</h3>
</div>
<div class="admin-panel__body">
@if (_report.PaymentBreakdown.Any())
{
<div style="display:flex;gap:24px;align-items:center;">
<div style="flex:1;">
<MudChart ChartType="ChartType.Donut"
InputData="@_paymentChartData"
InputLabels="@_paymentChartLabels"
Width="200px"
Height="200px"
ChartOptions="_donutOptions" />
</div>
<div style="flex:1;">
@foreach (var p in _report.PaymentBreakdown)
{
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--admin-border-subtle);">
<div>
<span style="font-weight:600;color:var(--admin-text-primary);">@GetPaymentMethodLabel(p.Method)</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);margin-left:8px;">(@p.Count don)</span>
</div>
<span style="font-weight:700;color:var(--admin-orange-primary);">@FormatVND(p.Amount)</span>
</div>
}
</div>
</div>
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu thanh toan.</div>
}
</div>
</div>
@* Revenue Breakdown Cards *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Doanh thu theo loai</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-radius:8px;background:rgba(34,197,94,0.08);">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="banknote" style="color:#22C55E;width:20px;height:20px;"></i>
<span style="font-weight:600;">Tien mat</span>
</div>
<span style="font-weight:700;color:#22C55E;">@FormatVND(_report.CashRevenue)</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-radius:8px;background:rgba(59,130,246,0.08);">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="credit-card" style="color:#3B82F6;width:20px;height:20px;"></i>
<span style="font-weight:600;">The</span>
</div>
<span style="font-weight:700;color:#3B82F6;">@FormatVND(_report.CardRevenue)</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-radius:8px;background:rgba(139,92,246,0.08);">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="smartphone" style="color:#8B5CF6;width:20px;height:20px;"></i>
<span style="font-weight:600;">Truc tuyen</span>
</div>
<span style="font-weight:700;color:#8B5CF6;">@FormatVND(_report.OnlineRevenue)</span>
</div>
</div>
</div>
</div>
@* ── HOURLY REVENUE CHART ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Doanh thu theo gio</h3>
</div>
<div class="admin-panel__body">
@if (_hourlyChartData.Length > 0)
{
<MudChart ChartType="ChartType.Bar"
ChartSeries="@_hourlyChartSeries"
XAxisLabels="@_hourlyChartLabels"
Width="100%"
Height="300px"
ChartOptions="_barOptions" />
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu doanh thu theo gio.</div>
}
</div>
</div>
@* ── TOP 10 ITEMS TABLE ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Top 10 san pham ban chay</h3>
</div>
<div class="admin-panel__body" style="padding:0;">
@if (_report.TopItems.Any())
{
<MudTable Items="@_report.TopItems"
Dense="true"
Hover="true"
Striped="true"
Elevation="0"
Class="mud-table-dark">
<HeaderContent>
<MudTh Style="font-weight:700;">#</MudTh>
<MudTh Style="font-weight:700;">Ten san pham</MudTh>
<MudTh Style="font-weight:700;text-align:right;">So luong</MudTh>
<MudTh Style="font-weight:700;text-align:right;">Doanh thu</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd Style="font-weight:700;color:var(--admin-orange-primary);">@((_report.TopItems.IndexOf(context) + 1))</MudTd>
<MudTd Style="font-weight:600;">@context.ItemName</MudTd>
<MudTd Style="text-align:right;font-weight:600;color:#3B82F6;">@context.Quantity</MudTd>
<MudTd Style="text-align:right;font-weight:700;color:var(--admin-orange-primary);">@FormatVND(context.Revenue)</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu san pham.</div>
}
</div>
</div>
}
</div>
@code {
// EN: Selected date for the report / VI: Ngay duoc chon cho bao cao
private DateTime? _selectedDate = DateTime.Today;
private bool _loading = false;
private PosDataService.EodReportInfo? _report;
// EN: Chart data arrays / VI: Mang du lieu bieu do
private double[] _paymentChartData = Array.Empty<double>();
private string[] _paymentChartLabels = Array.Empty<string>();
private double[] _hourlyChartData = Array.Empty<double>();
private string[] _hourlyChartLabels = Array.Empty<string>();
private List<ChartSeries> _hourlyChartSeries = new();
private ChartOptions _donutOptions = new()
{
ChartPalette = new[] { "#22C55E", "#3B82F6", "#8B5CF6", "#F59E0B", "#EC4899", "#FF5C00" }
};
private ChartOptions _barOptions = new()
{
ChartPalette = new[] { "#FF5C00" },
YAxisTicks = 5
};
/// <summary>
/// EN: Get the current shop ID from the admin context.
/// VI: Lay shop ID hien tai tu admin context.
/// </summary>
private Guid? GetCurrentShopId()
{
// EN: Try to get shop ID from query string or AuthStateService context
// VI: Thu lay shop ID tu query string hoac AuthStateService context
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
if (Guid.TryParse(query["shopId"], out var qsShopId))
return qsShopId;
return null;
}
/// <summary>
/// EN: Load the EOD report for the selected date.
/// VI: Tai bao cao cuoi ngay cho ngay duoc chon.
/// </summary>
private async Task LoadReportAsync()
{
var shopId = GetCurrentShopId();
if (!shopId.HasValue)
{
Snackbar.Add("Vui long chon shop truoc khi xem bao cao.", Severity.Warning);
return;
}
if (!_selectedDate.HasValue)
{
Snackbar.Add("Vui long chon ngay bao cao.", Severity.Warning);
return;
}
_loading = true;
_report = null;
StateHasChanged();
try
{
_report = await DataService.GetEodReportAsync(shopId.Value, _selectedDate.Value);
if (_report != null)
{
BuildChartData();
Snackbar.Add($"Da tai bao cao ngay {_selectedDate.Value:dd/MM/yyyy}.", Severity.Success);
}
else
{
Snackbar.Add("Khong the tai bao cao. Vui long thu lai.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
StateHasChanged();
}
}
/// <summary>
/// EN: Close the business day with confirmation dialog.
/// VI: Dong ngay kinh doanh voi dialog xac nhan.
/// </summary>
private async Task CloseDayAsync()
{
var shopId = GetCurrentShopId();
if (!shopId.HasValue || !_selectedDate.HasValue) return;
var confirmed = await DialogService.ShowMessageBox(
"Xac nhan dong ngay",
$"Ban co chac chan muon dong ngay {_selectedDate.Value:dd/MM/yyyy}? Hanh dong nay se tao bao cao cuoi ngay chinh thuc.",
yesText: "Dong ngay",
cancelText: "Huy");
if (confirmed != true) return;
_loading = true;
StateHasChanged();
try
{
var result = await DataService.CloseDayAsync(shopId.Value, _selectedDate.Value);
if (result != null && result.Success)
{
if (result.Report != null)
{
_report = result.Report;
BuildChartData();
}
var message = $"Da dong ngay {_selectedDate.Value:dd/MM/yyyy} thanh cong.";
if (result.PendingOrderCount > 0)
{
message += $" Luu y: {result.PendingOrderCount} don hang van dang cho xu ly.";
Snackbar.Add(message, Severity.Warning);
}
else
{
Snackbar.Add(message, Severity.Success);
}
}
else
{
Snackbar.Add(result?.Message ?? "Khong the dong ngay. Vui long thu lai.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
StateHasChanged();
}
}
/// <summary>
/// EN: Build chart data arrays from the report.
/// VI: Xay dung mang du lieu bieu do tu bao cao.
/// </summary>
private void BuildChartData()
{
if (_report == null) return;
// EN: Payment breakdown donut chart / VI: Bieu do tron phan tich thanh toan
if (_report.PaymentBreakdown.Any())
{
_paymentChartData = _report.PaymentBreakdown.Select(p => (double)p.Amount).ToArray();
_paymentChartLabels = _report.PaymentBreakdown.Select(p => GetPaymentMethodLabel(p.Method)).ToArray();
}
else
{
_paymentChartData = Array.Empty<double>();
_paymentChartLabels = Array.Empty<string>();
}
// EN: Hourly revenue bar chart / VI: Bieu do cot doanh thu theo gio
if (_report.HourlyRevenue.Any())
{
_hourlyChartData = _report.HourlyRevenue.Select(h => (double)h.Revenue).ToArray();
_hourlyChartLabels = _report.HourlyRevenue.Select(h => $"{h.Hour:00}:00").ToArray();
_hourlyChartSeries = new List<ChartSeries>
{
new ChartSeries { Name = "Doanh thu", Data = _hourlyChartData }
};
}
else
{
_hourlyChartData = Array.Empty<double>();
_hourlyChartLabels = Array.Empty<string>();
_hourlyChartSeries = new();
}
}
/// <summary>
/// EN: Get Vietnamese label for payment method.
/// VI: Lay nhan tieng Viet cho phuong thuc thanh toan.
/// </summary>
private static string GetPaymentMethodLabel(string method) => method.ToLowerInvariant() switch
{
"cash" => "Tien mat",
"card" => "The",
"vnpay" => "VNPay",
"momo" => "MoMo",
"qr" => "QR Code",
"transfer" => "Chuyen khoan",
"unknown" => "Chua xac dinh",
_ => method
};
private static string FormatVND(decimal val) => val.ToString("N0") + " d";
}

View File

@@ -1358,6 +1358,47 @@ public class PosDataService
return resp.IsSuccessStatusCode;
}
// ═══ EOD REPORTS / DAILY CLOSE ═══
// EN: EOD report DTO — matches backend EodReportDto
// VI: DTO bao cao cuoi ngay — khop voi EodReportDto backend
public record EodReportInfo(
DateTime ReportDate, Guid ShopId,
int TotalOrders, int CompletedOrders, int CancelledOrders,
decimal TotalRevenue, decimal CashRevenue, decimal CardRevenue, decimal OnlineRevenue,
decimal DiscountTotal,
List<EodPaymentBreakdownInfo> PaymentBreakdown,
List<EodTopItemInfo> TopItems,
List<EodHourlyRevenueInfo> HourlyRevenue);
public record EodPaymentBreakdownInfo(string Method, int Count, decimal Amount);
public record EodTopItemInfo(string ItemName, int Quantity, decimal Revenue);
public record EodHourlyRevenueInfo(int Hour, int OrderCount, decimal Revenue);
// EN: Close day result DTO
// VI: DTO ket qua dong ngay
public record CloseDayResultInfo(bool Success, EodReportInfo? Report, string? Message, int PendingOrderCount);
/// <summary>
/// EN: Get End-of-Day report for a shop on a specific date.
/// VI: Lay bao cao cuoi ngay cho shop vao ngay cu the.
/// </summary>
public async Task<EodReportInfo?> GetEodReportAsync(Guid shopId, DateTime date)
{
var url = $"api/bff/reports/eod?shopId={shopId}&date={date:yyyy-MM-dd}";
return await GetObjectFromApiAsync<EodReportInfo>(url);
}
/// <summary>
/// EN: Close the business day and generate final EOD report.
/// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay cuoi cung.
/// </summary>
public async Task<CloseDayResultInfo?> CloseDayAsync(Guid shopId, DateTime date)
{
var url = "api/bff/reports/close-day";
return await PostAndGetAsync<CloseDayResultInfo>(url, new { shopId, closeDate = date.ToString("yyyy-MM-dd") });
}
// ═══ SERVICE HEALTH CHECK ═══
public record ServiceHealthInfo(string Name, string Icon, bool IsOnline, int? LatencyMs);

View File

@@ -45,4 +45,40 @@ public class ReportsController : ControllerBase
if (shopId.HasValue) qs.Add($"shopId={shopId}");
return _order.GetAsync($"/api/v1/reports/top-products?{string.Join("&", qs)}").ProxyAsync();
}
/// <summary>
/// EN: Get End-of-Day report for a shop.
/// VI: Lay bao cao cuoi ngay cho shop.
/// </summary>
[HttpGet("reports/eod")]
public Task<IActionResult> GetEodReport(
[FromQuery] Guid? shopId = null,
[FromQuery] string? date = null)
{
var qs = new List<string>();
if (shopId.HasValue) qs.Add($"shopId={shopId}");
if (!string.IsNullOrEmpty(date)) qs.Add($"date={Uri.EscapeDataString(date)}");
var query = qs.Any() ? "?" + string.Join("&", qs) : "";
return _order.GetAsync($"/api/v1/reports/eod{query}").ProxyAsync();
}
/// <summary>
/// EN: Close the business day and generate final EOD report.
/// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay.
/// </summary>
[HttpPost("reports/close-day")]
public Task<IActionResult> CloseDay([FromBody] CloseDayBffRequest request)
{
return _order.PostAsJsonAsync("/api/v1/reports/close-day", new
{
shopId = request.ShopId,
closeDate = request.CloseDate
}).ProxyAsync();
}
}
/// <summary>
/// EN: BFF request for closing the business day.
/// VI: BFF request de dong ngay kinh doanh.
/// </summary>
public record CloseDayBffRequest(Guid ShopId, string? CloseDate = null);

View File

@@ -27,10 +27,33 @@ http:
accessControlAllowCredentials: true
accessControlMaxAge: 86400
# EN: STRICT rate limit for auth endpoints (login, register, 2FA) - 10 req/min per IP
# VI: Rate limit NGHIEM NGAT cho auth endpoints (login, register, 2FA) - 10 req/phut moi IP
auth-ratelimit:
rateLimit:
average: 10
burst: 5
# EN: STRICT rate limit for payment endpoints - 30 req/min per IP
# VI: Rate limit NGHIEM NGAT cho payment endpoints - 30 req/phut moi IP
payment-ratelimit:
rateLimit:
average: 30
burst: 10
# EN: MODERATE rate limit for general API - 100 req/min per IP
# VI: Rate limit VUA PHAI cho API chung - 100 req/phut moi IP
api-ratelimit:
rateLimit:
average: 100
burst: 50
# EN: RELAXED rate limit for SignalR hubs (real-time needs higher limits) - 500 req/min
# VI: Rate limit THOAI MAI cho SignalR hubs (real-time can gioi han cao hon) - 500 req/phut
hub-ratelimit:
rateLimit:
average: 500
burst: 100
compress:
compress: {}

View File

@@ -16,7 +16,7 @@ http:
service: iam-service
priority: 100
middlewares:
- auth-ratelimit
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -29,7 +29,7 @@ http:
service: iam-service
priority: 150
middlewares:
- auth-ratelimit
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -60,6 +60,7 @@ http:
service: storage-service
priority: 100
middlewares:
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -72,6 +73,7 @@ http:
service: membership-service
priority: 100
middlewares:
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -84,6 +86,7 @@ http:
service: merchant-service
priority: 100
middlewares:
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -96,6 +99,7 @@ http:
service: order-service
priority: 100
middlewares:
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -108,6 +112,7 @@ http:
service: order-service
priority: 110
middlewares:
- hub-ratelimit
- cors
entryPoints:
- web
@@ -119,6 +124,7 @@ http:
service: fnb-engine
priority: 100
middlewares:
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -131,6 +137,7 @@ http:
service: inventory-service
priority: 100
middlewares:
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -143,6 +150,7 @@ http:
service: wallet-service
priority: 100
middlewares:
- payment-ratelimit
- cors
- secure-headers
entryPoints:
@@ -155,6 +163,7 @@ http:
service: catalog-service
priority: 100
middlewares:
- api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -167,6 +176,7 @@ http:
service: booking-service
priority: 100
middlewares:
- api-ratelimit
- cors
- secure-headers
entryPoints:

View File

@@ -0,0 +1,184 @@
// EN: Validators for Catalog commands (products, categories).
// VI: Validators cho cac commands Catalog (san pham, danh muc).
using FluentValidation;
using CatalogService.API.Application.Commands;
namespace CatalogService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateProductCommand.
/// VI: Validator cho CreateProductCommand.
/// </summary>
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
private static readonly string[] ValidProductTypes = ["Physical", "Service", "PreparedFood"];
public CreateProductCommandValidator()
{
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("Shop ID is required / Shop ID la bat buoc");
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Product name is required / Ten san pham la bat buoc")
.MaximumLength(200)
.WithMessage("Product name must not exceed 200 characters / Ten san pham khong vuot qua 200 ky tu");
RuleFor(x => x.Description)
.MaximumLength(2000)
.WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu")
.When(x => x.Description != null);
RuleFor(x => x.Price)
.GreaterThanOrEqualTo(0)
.WithMessage("Price must not be negative / Gia khong duoc am");
RuleFor(x => x.Type)
.NotEmpty()
.WithMessage("Product type is required / Loai san pham la bat buoc")
.Must(t => ValidProductTypes.Contains(t))
.WithMessage("Invalid product type. Valid values: Physical, Service, PreparedFood / Loai san pham khong hop le");
RuleFor(x => x.Sku)
.MaximumLength(100)
.WithMessage("SKU must not exceed 100 characters / Ma SKU khong vuot qua 100 ky tu")
.When(x => x.Sku != null);
RuleFor(x => x.ImageUrl)
.MaximumLength(2048)
.WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
.When(x => x.ImageUrl != null);
}
}
/// <summary>
/// EN: Validator for UpdateProductCommand.
/// VI: Validator cho UpdateProductCommand.
/// </summary>
public class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
{
public UpdateProductCommandValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty()
.WithMessage("Product ID is required / Product ID la bat buoc");
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Product name is required / Ten san pham la bat buoc")
.MaximumLength(200)
.WithMessage("Product name must not exceed 200 characters / Ten san pham khong vuot qua 200 ky tu");
RuleFor(x => x.Description)
.MaximumLength(2000)
.WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu")
.When(x => x.Description != null);
RuleFor(x => x.Price)
.GreaterThanOrEqualTo(0)
.WithMessage("Price must not be negative / Gia khong duoc am");
RuleFor(x => x.ImageUrl)
.MaximumLength(2048)
.WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
.When(x => x.ImageUrl != null);
}
}
/// <summary>
/// EN: Validator for DeleteProductCommand.
/// VI: Validator cho DeleteProductCommand.
/// </summary>
public class DeleteProductCommandValidator : AbstractValidator<DeleteProductCommand>
{
public DeleteProductCommandValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty()
.WithMessage("Product ID is required / Product ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for CreateCategoryCommand.
/// VI: Validator cho CreateCategoryCommand.
/// </summary>
public class CreateCategoryCommandValidator : AbstractValidator<CreateCategoryCommand>
{
public CreateCategoryCommandValidator()
{
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("Shop ID is required / Shop ID la bat buoc");
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Category name is required / Ten danh muc la bat buoc")
.MaximumLength(200)
.WithMessage("Category name must not exceed 200 characters / Ten danh muc khong vuot qua 200 ky tu");
RuleFor(x => x.Description)
.MaximumLength(1000)
.WithMessage("Description must not exceed 1000 characters / Mo ta khong vuot qua 1000 ky tu")
.When(x => x.Description != null);
RuleFor(x => x.DisplayOrder)
.GreaterThanOrEqualTo(0)
.WithMessage("Display order must not be negative / Thu tu hien thi khong duoc am");
RuleFor(x => x.ImageUrl)
.MaximumLength(2048)
.WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
.When(x => x.ImageUrl != null);
}
}
/// <summary>
/// EN: Validator for UpdateCategoryCommand.
/// VI: Validator cho UpdateCategoryCommand.
/// </summary>
public class UpdateCategoryCommandValidator : AbstractValidator<UpdateCategoryCommand>
{
public UpdateCategoryCommandValidator()
{
RuleFor(x => x.CategoryId)
.NotEmpty()
.WithMessage("Category ID is required / Category ID la bat buoc");
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Category name is required / Ten danh muc la bat buoc")
.MaximumLength(200)
.WithMessage("Category name must not exceed 200 characters / Ten danh muc khong vuot qua 200 ky tu");
RuleFor(x => x.Description)
.MaximumLength(1000)
.WithMessage("Description must not exceed 1000 characters / Mo ta khong vuot qua 1000 ky tu")
.When(x => x.Description != null);
RuleFor(x => x.DisplayOrder)
.GreaterThanOrEqualTo(0)
.WithMessage("Display order must not be negative / Thu tu hien thi khong duoc am");
RuleFor(x => x.ImageUrl)
.MaximumLength(2048)
.WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
.When(x => x.ImageUrl != null);
}
}
/// <summary>
/// EN: Validator for DeleteCategoryCommand.
/// VI: Validator cho DeleteCategoryCommand.
/// </summary>
public class DeleteCategoryCommandValidator : AbstractValidator<DeleteCategoryCommand>
{
public DeleteCategoryCommandValidator()
{
RuleFor(x => x.CategoryId)
.NotEmpty()
.WithMessage("Category ID is required / Category ID la bat buoc");
}
}

View File

@@ -0,0 +1,201 @@
// EN: Validators for FnB Engine commands (sessions, tables, tickets, reservations).
// VI: Validators cho cac commands FnB Engine (phien, ban, phieu bep, dat cho).
using FluentValidation;
using FnbEngine.API.Application.Commands;
namespace FnbEngine.API.Application.Validations;
/// <summary>
/// EN: Validator for OpenSessionCommand.
/// VI: Validator cho OpenSessionCommand.
/// </summary>
public class OpenSessionCommandValidator : AbstractValidator<OpenSessionCommand>
{
public OpenSessionCommandValidator()
{
RuleFor(x => x.TableId)
.NotEmpty()
.WithMessage("Table ID is required / Table ID la bat buoc");
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("Shop ID is required / Shop ID la bat buoc");
RuleFor(x => x.GuestCount)
.GreaterThan(0)
.WithMessage("Guest count must be greater than zero / So khach phai lon hon 0")
.LessThanOrEqualTo(100)
.WithMessage("Guest count must not exceed 100 / So khach khong vuot qua 100");
}
}
/// <summary>
/// EN: Validator for CloseSessionCommand.
/// VI: Validator cho CloseSessionCommand.
/// </summary>
public class CloseSessionCommandValidator : AbstractValidator<CloseSessionCommand>
{
public CloseSessionCommandValidator()
{
RuleFor(x => x.SessionId)
.NotEmpty()
.WithMessage("Session ID is required / Session ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for ChangeTableStatusCommand.
/// VI: Validator cho ChangeTableStatusCommand.
/// </summary>
public class ChangeTableStatusCommandValidator : AbstractValidator<ChangeTableStatusCommand>
{
private static readonly string[] ValidStatuses = ["Available", "Occupied", "Cleaning", "Reserved", "OutOfService"];
public ChangeTableStatusCommandValidator()
{
RuleFor(x => x.TableId)
.NotEmpty()
.WithMessage("Table ID is required / Table ID la bat buoc");
RuleFor(x => x.Status)
.NotEmpty()
.WithMessage("Status is required / Trang thai la bat buoc")
.MaximumLength(50)
.WithMessage("Status must not exceed 50 characters / Trang thai khong vuot qua 50 ky tu")
.Must(s => ValidStatuses.Contains(s))
.WithMessage("Invalid status. Valid values: Available, Occupied, Cleaning, Reserved, OutOfService / Trang thai khong hop le");
}
}
/// <summary>
/// EN: Validator for UpdateTableCommand.
/// VI: Validator cho UpdateTableCommand.
/// </summary>
public class UpdateTableCommandValidator : AbstractValidator<UpdateTableCommand>
{
public UpdateTableCommandValidator()
{
RuleFor(x => x.TableId)
.NotEmpty()
.WithMessage("Table ID is required / Table ID la bat buoc");
RuleFor(x => x.Capacity)
.GreaterThan(0)
.WithMessage("Capacity must be greater than zero / Suc chua phai lon hon 0")
.When(x => x.Capacity.HasValue);
RuleFor(x => x.Zone)
.MaximumLength(100)
.WithMessage("Zone must not exceed 100 characters / Khu vuc khong vuot qua 100 ky tu")
.When(x => x.Zone != null);
RuleFor(x => x.HourlyRate)
.GreaterThanOrEqualTo(0)
.WithMessage("Hourly rate must not be negative / Gia theo gio khong duoc am")
.When(x => x.HourlyRate.HasValue);
}
}
/// <summary>
/// EN: Validator for UpdateTicketStatusCommand.
/// VI: Validator cho UpdateTicketStatusCommand.
/// </summary>
public class UpdateTicketStatusCommandValidator : AbstractValidator<UpdateTicketStatusCommand>
{
private static readonly string[] ValidStatuses = ["Pending", "InProgress", "Ready", "Served", "Cancelled"];
public UpdateTicketStatusCommandValidator()
{
RuleFor(x => x.TicketId)
.NotEmpty()
.WithMessage("Ticket ID is required / Ticket ID la bat buoc");
RuleFor(x => x.Status)
.NotEmpty()
.WithMessage("Status is required / Trang thai la bat buoc")
.MaximumLength(50)
.WithMessage("Status must not exceed 50 characters / Trang thai khong vuot qua 50 ky tu")
.Must(s => ValidStatuses.Contains(s))
.WithMessage("Invalid status. Valid values: Pending, InProgress, Ready, Served, Cancelled / Trang thai khong hop le");
}
}
/// <summary>
/// EN: Validator for CreateReservationCommand.
/// VI: Validator cho CreateReservationCommand.
/// </summary>
public class CreateReservationCommandValidator : AbstractValidator<CreateReservationCommand>
{
public CreateReservationCommandValidator()
{
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("Shop ID is required / Shop ID la bat buoc");
RuleFor(x => x.GuestName)
.NotEmpty()
.WithMessage("Guest name is required / Ten khach la bat buoc")
.MaximumLength(200)
.WithMessage("Guest name must not exceed 200 characters / Ten khach khong vuot qua 200 ky tu");
RuleFor(x => x.PartySize)
.GreaterThan(0)
.WithMessage("Party size must be greater than zero / So nguoi phai lon hon 0")
.LessThanOrEqualTo(100)
.WithMessage("Party size must not exceed 100 / So nguoi khong vuot qua 100");
RuleFor(x => x.ReservationTime)
.GreaterThan(DateTime.UtcNow.AddMinutes(-5))
.WithMessage("Reservation time must be in the future / Thoi gian dat cho phai trong tuong lai");
RuleFor(x => x.Phone)
.MaximumLength(20)
.WithMessage("Phone must not exceed 20 characters / So dien thoai khong vuot qua 20 ky tu")
.Matches(@"^[\d\+\-\(\)\s]+$")
.WithMessage("Invalid phone format / Dinh dang so dien thoai khong hop le")
.When(x => x.Phone != null);
RuleFor(x => x.Note)
.MaximumLength(1000)
.WithMessage("Note must not exceed 1000 characters / Ghi chu khong vuot qua 1000 ky tu")
.When(x => x.Note != null);
}
}
/// <summary>
/// EN: Validator for CreateKitchenTicketCommand.
/// VI: Validator cho CreateKitchenTicketCommand.
/// </summary>
public class CreateKitchenTicketCommandValidator : AbstractValidator<CreateKitchenTicketCommand>
{
public CreateKitchenTicketCommandValidator()
{
RuleFor(x => x.SessionId)
.NotEmpty()
.WithMessage("Session ID is required / Session ID la bat buoc");
RuleFor(x => x.OrderItemId)
.NotEmpty()
.WithMessage("Order item ID is required / Order item ID la bat buoc");
RuleFor(x => x.ItemName)
.NotEmpty()
.WithMessage("Item name is required / Ten mon la bat buoc")
.MaximumLength(200)
.WithMessage("Item name must not exceed 200 characters / Ten mon khong vuot qua 200 ky tu");
RuleFor(x => x.Station)
.MaximumLength(100)
.WithMessage("Station must not exceed 100 characters / Tram bep khong vuot qua 100 ky tu")
.When(x => x.Station != null);
RuleFor(x => x.Priority)
.InclusiveBetween(0, 10)
.WithMessage("Priority must be between 0 and 10 / Do uu tien phai tu 0 den 10");
RuleFor(x => x.Quantity)
.GreaterThan(0)
.WithMessage("Quantity must be greater than zero / So luong phai lon hon 0");
}
}

View File

@@ -0,0 +1,324 @@
// EN: Unit tests for KitchenTicketServedDomainEventHandler - inventory deduction on served tickets.
// VI: Unit tests cho KitchenTicketServedDomainEventHandler - tru kho khi phieu da phuc vu.
using FluentAssertions;
using FnbEngine.API.Application.IntegrationEvents.EventHandlers;
using FnbEngine.Domain.AggregatesModel.KitchenAggregate;
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
using FnbEngine.Domain.AggregatesModel.SessionAggregate;
using FnbEngine.Domain.Events;
using FnbEngine.Infrastructure.ExternalServices;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace FnbEngine.UnitTests.Application.IntegrationEvents;
/// <summary>
/// EN: Unit tests for KitchenTicketServedDomainEventHandler.
/// VI: Unit tests cho KitchenTicketServedDomainEventHandler.
/// </summary>
public class KitchenTicketServedDomainEventHandlerTests
{
private readonly Mock<IRecipeRepository> _recipeRepositoryMock;
private readonly Mock<ISessionRepository> _sessionRepositoryMock;
private readonly Mock<IInventoryServiceClient> _inventoryClientMock;
private readonly Mock<ILogger<KitchenTicketServedDomainEventHandler>> _loggerMock;
private readonly KitchenTicketServedDomainEventHandler _handler;
private readonly Guid _shopId = Guid.NewGuid();
private readonly Guid _sessionId = Guid.NewGuid();
private readonly Guid _productId = Guid.NewGuid();
public KitchenTicketServedDomainEventHandlerTests()
{
_recipeRepositoryMock = new Mock<IRecipeRepository>();
_sessionRepositoryMock = new Mock<ISessionRepository>();
_inventoryClientMock = new Mock<IInventoryServiceClient>();
_loggerMock = new Mock<ILogger<KitchenTicketServedDomainEventHandler>>();
_handler = new KitchenTicketServedDomainEventHandler(
_recipeRepositoryMock.Object,
_sessionRepositoryMock.Object,
_inventoryClientMock.Object,
_loggerMock.Object);
}
/// <summary>
/// EN: Helper to create a KitchenTicket for testing.
/// VI: Helper de tao KitchenTicket de test.
/// </summary>
private KitchenTicket CreateTicket(Guid? productId = null, int quantity = 1)
{
return new KitchenTicket(
_sessionId,
Guid.NewGuid(),
productId ?? _productId,
"Pho Bo",
quantity,
"Kitchen",
0);
}
/// <summary>
/// EN: Helper to create a Session for testing.
/// VI: Helper de tao Session de test.
/// </summary>
private Session CreateSession()
{
return new Session(Guid.NewGuid(), _shopId, 2);
}
/// <summary>
/// EN: Helper to create a Recipe with linked ingredients.
/// VI: Helper de tao Recipe voi nguyen lieu co lien ket.
/// </summary>
private Recipe CreateRecipeWithLinkedIngredients(int ingredientCount = 2)
{
var recipe = new Recipe(_shopId, _productId, "Pho Bo Recipe", "Boil broth", 30);
for (int i = 0; i < ingredientCount; i++)
{
recipe.AddIngredient(
$"Ingredient {i + 1}",
quantity: 100,
unit: "g",
costPerUnit: 10_000m,
inventoryItemId: Guid.NewGuid(),
quantityPerServing: 50m);
}
return recipe;
}
/// <summary>
/// EN: Helper to create a Recipe with no linked inventory items.
/// VI: Helper de tao Recipe khong co lien ket den inventory items.
/// </summary>
private Recipe CreateRecipeWithoutLinkedIngredients()
{
var recipe = new Recipe(_shopId, _productId, "Simple Drink", "Pour and serve", 2);
recipe.AddIngredient("Water", 200, "ml", 0, inventoryItemId: null, quantityPerServing: 0);
return recipe;
}
[Fact]
public async Task Handle_WithValidEvent_ShouldCallInventoryClient()
{
// Arrange
var ticket = CreateTicket();
var session = CreateSession();
var recipe = CreateRecipeWithLinkedIngredients();
var notification = new KitchenTicketServedDomainEvent(ticket);
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
.ReturnsAsync(recipe);
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
await _handler.Handle(notification, CancellationToken.None);
// Assert
// EN: Should call inventory client exactly once with the correct deduction request.
// VI: Nen goi inventory client dung 1 lan voi request tru kho chinh xac.
_inventoryClientMock.Verify(
c => c.DeductInventoryAsync(
It.Is<DeductInventoryRequest>(req =>
req.ShopId == _shopId &&
req.ReferenceId == ticket.Id &&
req.ReferenceType == "KitchenTicket" &&
req.Items.Count == 2),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Handle_WithNoRecipeFound_ShouldSkipDeduction()
{
// Arrange
var ticket = CreateTicket();
var session = CreateSession();
var notification = new KitchenTicketServedDomainEvent(ticket);
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
.ReturnsAsync((Recipe?)null);
// Act
await _handler.Handle(notification, CancellationToken.None);
// Assert
// EN: No recipe means no inventory deduction should be attempted.
// VI: Khong co cong thuc nghia la khong nen co gang tru kho.
_inventoryClientMock.Verify(
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Handle_WhenInventoryClientFails_ShouldLogErrorNotThrow()
{
// Arrange
var ticket = CreateTicket();
var session = CreateSession();
var recipe = CreateRecipeWithLinkedIngredients();
var notification = new KitchenTicketServedDomainEvent(ticket);
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
.ReturnsAsync(recipe);
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Inventory service unavailable"));
// Act
var action = () => _handler.Handle(notification, CancellationToken.None);
// Assert
// EN: Should NOT throw - inventory failure should not block kitchen workflow.
// VI: KHONG nen throw - loi tru kho khong nen chan luong bep.
await action.Should().NotThrowAsync();
_inventoryClientMock.Verify(
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Handle_WithMultipleIngredients_ShouldDeductAll()
{
// Arrange
var ticket = CreateTicket(quantity: 3);
var session = CreateSession();
var recipe = CreateRecipeWithLinkedIngredients(ingredientCount: 4);
var notification = new KitchenTicketServedDomainEvent(ticket);
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
.ReturnsAsync(recipe);
DeductInventoryRequest? capturedRequest = null;
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
.Callback<DeductInventoryRequest, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(true);
// Act
await _handler.Handle(notification, CancellationToken.None);
// Assert
// EN: Should send all 4 ingredients in one deduction request.
// VI: Nen gui tat ca 4 nguyen lieu trong mot request tru kho.
capturedRequest.Should().NotBeNull();
capturedRequest!.Items.Should().HaveCount(4);
// EN: Each ingredient amount should be multiplied by ticket quantity (3).
// VI: So luong moi nguyen lieu nen duoc nhan voi so luong phieu (3).
capturedRequest.Items.Should().AllSatisfy(item =>
{
// EN: quantityPerServing=50, ticket.Quantity=3, so Math.Ceiling(50*3)=150
// VI: quantityPerServing=50, ticket.Quantity=3, nen Math.Ceiling(50*3)=150
item.Amount.Should().Be(150);
});
}
[Fact]
public async Task Handle_WithNoLinkedInventoryItems_ShouldSkipDeduction()
{
// Arrange
var ticket = CreateTicket();
var session = CreateSession();
var recipe = CreateRecipeWithoutLinkedIngredients();
var notification = new KitchenTicketServedDomainEvent(ticket);
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
.ReturnsAsync(recipe);
// Act
await _handler.Handle(notification, CancellationToken.None);
// Assert
// EN: Recipe with no linked inventory items should not trigger deduction.
// VI: Cong thuc khong co nguyen lieu lien ket kho khong nen kich hoat tru kho.
_inventoryClientMock.Verify(
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Handle_WithSessionNotFound_ShouldSkipDeduction()
{
// Arrange
var ticket = CreateTicket();
var notification = new KitchenTicketServedDomainEvent(ticket);
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync((Session?)null);
// Act
await _handler.Handle(notification, CancellationToken.None);
// Assert
// EN: Missing session means we cannot determine the shop, so skip deduction.
// VI: Thieu session nghia la khong the xac dinh shop, nen bo qua tru kho.
_recipeRepositoryMock.Verify(
r => r.GetByProductIdAndShopAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()),
Times.Never);
_inventoryClientMock.Verify(
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Handle_WhenRepositoryThrows_ShouldNotThrow()
{
// Arrange
var ticket = CreateTicket();
var notification = new KitchenTicketServedDomainEvent(ticket);
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Database connection failed"));
// Act
var action = () => _handler.Handle(notification, CancellationToken.None);
// Assert
// EN: Repository failures should be caught and logged, not propagated.
// VI: Loi repository nen duoc bat va log, khong nen lan truyen.
await action.Should().NotThrowAsync();
}
[Fact]
public void Constructor_WithNullRecipeRepository_ShouldThrowArgumentNullException()
{
// Act
var action = () => new KitchenTicketServedDomainEventHandler(
null!,
_sessionRepositoryMock.Object,
_inventoryClientMock.Object,
_loggerMock.Object);
// Assert
action.Should().Throw<ArgumentNullException>()
.And.ParamName.Should().Be("recipeRepository");
}
[Fact]
public void Constructor_WithNullInventoryClient_ShouldThrowArgumentNullException()
{
// Act
var action = () => new KitchenTicketServedDomainEventHandler(
_recipeRepositoryMock.Object,
_sessionRepositoryMock.Object,
null!,
_loggerMock.Object);
// Assert
action.Should().Throw<ArgumentNullException>()
.And.ParamName.Should().Be("inventoryClient");
}
}

View File

@@ -0,0 +1,167 @@
// EN: Validators for Auth commands (external login, 2FA, logout).
// VI: Validators cho cac commands Auth (external login, 2FA, logout).
using FluentValidation;
using IamService.API.Application.Commands.Auth;
namespace IamService.API.Application.Validations;
/// <summary>
/// EN: Validator for ExternalLoginCommand.
/// VI: Validator cho ExternalLoginCommand.
/// </summary>
public class ExternalLoginCommandValidator : AbstractValidator<ExternalLoginCommand>
{
private static readonly string[] ValidProviders = ["Google", "Facebook", "Apple", "google", "facebook", "apple"];
public ExternalLoginCommandValidator()
{
RuleFor(x => x.Provider)
.NotEmpty()
.WithMessage("Provider is required / Nha cung cap la bat buoc")
.MaximumLength(50)
.WithMessage("Provider must not exceed 50 characters / Nha cung cap khong vuot qua 50 ky tu")
.Must(p => ValidProviders.Contains(p))
.WithMessage("Invalid provider. Valid values: Google, Facebook, Apple / Nha cung cap khong hop le");
RuleFor(x => x.ProviderUserId)
.NotEmpty()
.WithMessage("Provider user ID is required / ID nguoi dung tu nha cung cap la bat buoc")
.MaximumLength(256)
.WithMessage("Provider user ID must not exceed 256 characters / ID nguoi dung khong vuot qua 256 ky tu");
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage("Email is required / Email la bat buoc")
.EmailAddress()
.WithMessage("Invalid email format / Dinh dang email khong hop le")
.MaximumLength(256)
.WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu");
RuleFor(x => x.Name)
.MaximumLength(200)
.WithMessage("Name must not exceed 200 characters / Ten khong vuot qua 200 ky tu")
.When(x => x.Name != null);
RuleFor(x => x.PictureUrl)
.MaximumLength(2048)
.WithMessage("Picture URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
.Must(url => url == null || Uri.TryCreate(url, UriKind.Absolute, out _))
.WithMessage("Picture URL must be a valid URL / URL hinh anh phai hop le")
.When(x => x.PictureUrl != null);
}
}
/// <summary>
/// EN: Validator for Enable2FACommand.
/// VI: Validator cho Enable2FACommand.
/// </summary>
public class Enable2FACommandValidator : AbstractValidator<Enable2FACommand>
{
public Enable2FACommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for Verify2FACommand.
/// VI: Validator cho Verify2FACommand.
/// </summary>
public class Verify2FACommandValidator : AbstractValidator<Verify2FACommand>
{
public Verify2FACommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.Code)
.NotEmpty()
.WithMessage("2FA code is required / Ma 2FA la bat buoc")
.MaximumLength(10)
.WithMessage("2FA code must not exceed 10 characters / Ma 2FA khong vuot qua 10 ky tu")
.Matches(@"^\d{6}$")
.WithMessage("2FA code must be 6 digits / Ma 2FA phai la 6 chu so");
}
}
/// <summary>
/// EN: Validator for Disable2FACommand.
/// VI: Validator cho Disable2FACommand.
/// </summary>
public class Disable2FACommandValidator : AbstractValidator<Disable2FACommand>
{
public Disable2FACommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.Code)
.NotEmpty()
.WithMessage("2FA code is required / Ma 2FA la bat buoc")
.MaximumLength(10)
.WithMessage("2FA code must not exceed 10 characters / Ma 2FA khong vuot qua 10 ky tu")
.Matches(@"^\d{6}$")
.WithMessage("2FA code must be 6 digits / Ma 2FA phai la 6 chu so");
}
}
/// <summary>
/// EN: Validator for LogoutCommand.
/// VI: Validator cho LogoutCommand.
/// </summary>
public class LogoutCommandValidator : AbstractValidator<LogoutCommand>
{
public LogoutCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for SendVerificationEmailCommand.
/// VI: Validator cho SendVerificationEmailCommand.
/// </summary>
public class SendVerificationEmailCommandValidator : AbstractValidator<SendVerificationEmailCommand>
{
public SendVerificationEmailCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage("Email is required / Email la bat buoc")
.EmailAddress()
.WithMessage("Invalid email format / Dinh dang email khong hop le")
.MaximumLength(256)
.WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu");
}
}
/// <summary>
/// EN: Validator for ConfirmEmailCommand.
/// VI: Validator cho ConfirmEmailCommand.
/// </summary>
public class ConfirmEmailCommandValidator : AbstractValidator<ConfirmEmailCommand>
{
public ConfirmEmailCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage("Email is required / Email la bat buoc")
.EmailAddress()
.WithMessage("Invalid email format / Dinh dang email khong hop le")
.MaximumLength(256)
.WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu");
RuleFor(x => x.Token)
.NotEmpty()
.WithMessage("Token is required / Token la bat buoc")
.MaximumLength(1024)
.WithMessage("Token must not exceed 1024 characters / Token khong vuot qua 1024 ky tu");
}
}

View File

@@ -0,0 +1,111 @@
// EN: Validators for Role commands (create, update, delete, assign, remove).
// VI: Validators cho cac commands Role (tao, cap nhat, xoa, gan, go).
using FluentValidation;
using IamService.API.Application.Commands.Roles;
namespace IamService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateRoleCommand.
/// VI: Validator cho CreateRoleCommand.
/// </summary>
public class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
{
public CreateRoleCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Role name is required / Ten role la bat buoc")
.MaximumLength(100)
.WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu")
.Matches(@"^[a-zA-Z0-9_\-\.]+$")
.WithMessage("Role name must contain only letters, numbers, underscores, hyphens, and dots / Ten role chi chua chu cai, so, gach duoi, gach ngang va dau cham");
RuleFor(x => x.Description)
.MaximumLength(500)
.WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu")
.When(x => x.Description != null);
}
}
/// <summary>
/// EN: Validator for UpdateRoleCommand.
/// VI: Validator cho UpdateRoleCommand.
/// </summary>
public class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
{
public UpdateRoleCommandValidator()
{
RuleFor(x => x.RoleId)
.NotEmpty()
.WithMessage("Role ID is required / Role ID la bat buoc");
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Role name is required / Ten role la bat buoc")
.MaximumLength(100)
.WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu")
.Matches(@"^[a-zA-Z0-9_\-\.]+$")
.WithMessage("Role name must contain only letters, numbers, underscores, hyphens, and dots / Ten role chi chua chu cai, so, gach duoi, gach ngang va dau cham");
RuleFor(x => x.Description)
.MaximumLength(500)
.WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu")
.When(x => x.Description != null);
}
}
/// <summary>
/// EN: Validator for DeleteRoleCommand.
/// VI: Validator cho DeleteRoleCommand.
/// </summary>
public class DeleteRoleCommandValidator : AbstractValidator<DeleteRoleCommand>
{
public DeleteRoleCommandValidator()
{
RuleFor(x => x.RoleId)
.NotEmpty()
.WithMessage("Role ID is required / Role ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for AssignRoleToUserCommand.
/// VI: Validator cho AssignRoleToUserCommand.
/// </summary>
public class AssignRoleToUserCommandValidator : AbstractValidator<AssignRoleToUserCommand>
{
public AssignRoleToUserCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.RoleName)
.NotEmpty()
.WithMessage("Role name is required / Ten role la bat buoc")
.MaximumLength(100)
.WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu");
}
}
/// <summary>
/// EN: Validator for RemoveRoleFromUserCommand.
/// VI: Validator cho RemoveRoleFromUserCommand.
/// </summary>
public class RemoveRoleFromUserCommandValidator : AbstractValidator<RemoveRoleFromUserCommand>
{
public RemoveRoleFromUserCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.RoleName)
.NotEmpty()
.WithMessage("Role name is required / Ten role la bat buoc")
.MaximumLength(100)
.WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu");
}
}

View File

@@ -0,0 +1,45 @@
// EN: Validators for User commands (update, delete).
// VI: Validators cho cac commands User (cap nhat, xoa).
using FluentValidation;
using IamService.API.Application.Commands.Users;
namespace IamService.API.Application.Validations;
/// <summary>
/// EN: Validator for UpdateUserCommand.
/// VI: Validator cho UpdateUserCommand.
/// </summary>
public class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
{
public UpdateUserCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.FirstName)
.MaximumLength(100)
.WithMessage("First name must not exceed 100 characters / Ho khong vuot qua 100 ky tu")
.When(x => x.FirstName != null);
RuleFor(x => x.LastName)
.MaximumLength(100)
.WithMessage("Last name must not exceed 100 characters / Ten khong vuot qua 100 ky tu")
.When(x => x.LastName != null);
}
}
/// <summary>
/// EN: Validator for DeleteUserCommand.
/// VI: Validator cho DeleteUserCommand.
/// </summary>
public class DeleteUserCommandValidator : AbstractValidator<DeleteUserCommand>
{
public DeleteUserCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
}
}

View File

@@ -0,0 +1,426 @@
// EN: Unit tests for DeductInventoryCommandHandler - bulk inventory deduction with idempotency.
// VI: Unit tests cho DeductInventoryCommandHandler - tru kho hang loat voi idempotency.
using FluentAssertions;
using InventoryService.API.Application.Commands.DeductInventory;
using InventoryService.Domain.AggregatesModel.InventoryAggregate;
using InventoryService.Domain.SeedWork;
using InventoryService.Infrastructure.Idempotency;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace InventoryService.UnitTests.Application.Commands;
/// <summary>
/// EN: Unit tests for DeductInventoryCommandHandler.
/// VI: Unit tests cho DeductInventoryCommandHandler.
/// </summary>
public class DeductInventoryCommandHandlerTests
{
private readonly Mock<IInventoryRepository> _repositoryMock;
private readonly Mock<IRequestManager> _requestManagerMock;
private readonly Mock<ILogger<DeductInventoryCommandHandler>> _loggerMock;
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly DeductInventoryCommandHandler _handler;
public DeductInventoryCommandHandlerTests()
{
_repositoryMock = new Mock<IInventoryRepository>();
_requestManagerMock = new Mock<IRequestManager>();
_loggerMock = new Mock<ILogger<DeductInventoryCommandHandler>>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
_unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_repositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
_requestManagerMock.Setup(r => r.ExistAsync(It.IsAny<Guid>()))
.ReturnsAsync(false);
_requestManagerMock.Setup(r => r.CreateRequestForCommandAsync<DeductInventoryCommand>(It.IsAny<Guid>()))
.Returns(Task.CompletedTask);
_handler = new DeductInventoryCommandHandler(
_repositoryMock.Object,
_requestManagerMock.Object,
_loggerMock.Object);
}
/// <summary>
/// EN: Helper to create an InventoryItem with a specified available quantity.
/// VI: Helper de tao InventoryItem voi so luong kha dung chi dinh.
/// </summary>
private static InventoryItem CreateInventoryItemWithStock(Guid id, int quantity)
{
var item = new InventoryItem(Guid.NewGuid(), Guid.NewGuid(), reorderLevel: 5);
// EN: Use reflection to set Id and quantity since they use private fields.
// VI: Dung reflection de set Id va quantity vi chung dung private fields.
typeof(InventoryItem).BaseType!.GetProperty("Id")!.SetValue(item, id);
if (quantity > 0)
{
item.StockIn(quantity, "Initial stock for test");
}
return item;
}
[Fact]
public async Task Handle_WithValidCommand_ShouldDeductInventory()
{
// Arrange
var itemId = Guid.NewGuid();
var inventoryItem = CreateInventoryItemWithStock(itemId, 100);
_repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny<CancellationToken>()))
.ReturnsAsync(inventoryItem);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: Guid.NewGuid(),
ReferenceType: "KitchenTicket",
Reason: "Test deduction",
Items: new List<DeductionItem>
{
new(itemId, 10, "pcs", "Sugar")
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.ItemsDeducted.Should().Be(1);
result.ItemsSkipped.Should().Be(0);
result.Items.Should().HaveCount(1);
result.Items[0].Deducted.Should().BeTrue();
result.Items[0].Error.Should().BeNull();
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_WithInsufficientStock_ShouldReturnPartialDeduction()
{
// Arrange
var itemId = Guid.NewGuid();
var inventoryItem = CreateInventoryItemWithStock(itemId, 5);
_repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny<CancellationToken>()))
.ReturnsAsync(inventoryItem);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: Guid.NewGuid(),
ReferenceType: "KitchenTicket",
Reason: "Test partial deduction",
Items: new List<DeductionItem>
{
new(itemId, 20, "pcs", "Flour")
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Should partially deduct what is available (5 of 20 requested).
// VI: Nen tru mot phan nhung gi co san (5 cua 20 yeu cau).
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.ItemsDeducted.Should().Be(1);
result.Items[0].Deducted.Should().BeTrue();
result.Items[0].Error.Should().Contain("Partial deduction");
}
[Fact]
public async Task Handle_WithNonExistentItem_ShouldReturnNotFound()
{
// Arrange
var missingItemId = Guid.NewGuid();
_repositoryMock.Setup(r => r.GetByIdAsync(missingItemId, It.IsAny<CancellationToken>()))
.ReturnsAsync((InventoryItem?)null);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: Guid.NewGuid(),
ReferenceType: "KitchenTicket",
Reason: "Test not found",
Items: new List<DeductionItem>
{
new(missingItemId, 5, "pcs", "Missing Item")
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Success.Should().BeFalse();
result.ItemsDeducted.Should().Be(0);
result.ItemsSkipped.Should().Be(1);
result.Items[0].Deducted.Should().BeFalse();
result.Items[0].Error.Should().Be("Item not found");
}
[Fact]
public async Task Handle_WithZeroStockAvailable_ShouldSkipDeduction()
{
// Arrange
var itemId = Guid.NewGuid();
var inventoryItem = CreateInventoryItemWithStock(itemId, 0);
_repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny<CancellationToken>()))
.ReturnsAsync(inventoryItem);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: Guid.NewGuid(),
ReferenceType: "KitchenTicket",
Reason: "Test zero stock",
Items: new List<DeductionItem>
{
new(itemId, 10, "pcs", "Empty Item")
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Item with 0 stock and insufficient stock should be skipped.
// VI: Item voi 0 stock va khong du stock nen duoc bo qua.
result.Should().NotBeNull();
result.Success.Should().BeFalse();
result.ItemsSkipped.Should().Be(1);
result.Items[0].Deducted.Should().BeFalse();
result.Items[0].Error.Should().Contain("Insufficient stock (0 available)");
}
[Fact]
public async Task Handle_WithDuplicateRequest_ShouldReturnIdempotentResult()
{
// Arrange
var referenceId = Guid.NewGuid();
_requestManagerMock.Setup(r => r.ExistAsync(referenceId))
.ReturnsAsync(true);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: referenceId,
ReferenceType: "KitchenTicket",
Reason: "Duplicate test",
Items: new List<DeductionItem>
{
new(Guid.NewGuid(), 5, "pcs", "Item A")
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Duplicate request should be detected and skipped.
// VI: Request trung lap nen duoc phat hien va bo qua.
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.ItemsDeducted.Should().Be(0);
result.ItemsSkipped.Should().Be(1);
result.Items[0].Error.Should().Be("Duplicate request");
// EN: Should not attempt to get inventory items.
// VI: Khong nen co gang lay inventory items.
_repositoryMock.Verify(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Handle_WithMultipleItems_ShouldDeductAll()
{
// Arrange
var itemId1 = Guid.NewGuid();
var itemId2 = Guid.NewGuid();
var itemId3 = Guid.NewGuid();
var item1 = CreateInventoryItemWithStock(itemId1, 50);
var item2 = CreateInventoryItemWithStock(itemId2, 30);
var item3 = CreateInventoryItemWithStock(itemId3, 100);
_repositoryMock.Setup(r => r.GetByIdAsync(itemId1, It.IsAny<CancellationToken>())).ReturnsAsync(item1);
_repositoryMock.Setup(r => r.GetByIdAsync(itemId2, It.IsAny<CancellationToken>())).ReturnsAsync(item2);
_repositoryMock.Setup(r => r.GetByIdAsync(itemId3, It.IsAny<CancellationToken>())).ReturnsAsync(item3);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: Guid.NewGuid(),
ReferenceType: "KitchenTicket",
Reason: "Multi-item deduction",
Items: new List<DeductionItem>
{
new(itemId1, 10, "g", "Sugar"),
new(itemId2, 5, "ml", "Milk"),
new(itemId3, 2, "pcs", "Cup")
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.ItemsDeducted.Should().Be(3);
result.ItemsSkipped.Should().Be(0);
result.Items.Should().HaveCount(3);
result.Items.Should().AllSatisfy(i => i.Deducted.Should().BeTrue());
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_WithMixedResults_ShouldDeductFoundItemsAndSkipMissing()
{
// Arrange
var existingItemId = Guid.NewGuid();
var missingItemId = Guid.NewGuid();
var existingItem = CreateInventoryItemWithStock(existingItemId, 50);
_repositoryMock.Setup(r => r.GetByIdAsync(existingItemId, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingItem);
_repositoryMock.Setup(r => r.GetByIdAsync(missingItemId, It.IsAny<CancellationToken>()))
.ReturnsAsync((InventoryItem?)null);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: Guid.NewGuid(),
ReferenceType: "KitchenTicket",
Reason: "Mixed test",
Items: new List<DeductionItem>
{
new(existingItemId, 5, "pcs", "Sugar"),
new(missingItemId, 3, "ml", "Missing Ingredient")
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Should deduct existing item and skip missing one.
// VI: Nen tru item ton tai va bo qua item khong tim thay.
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.ItemsDeducted.Should().Be(1);
result.ItemsSkipped.Should().Be(1);
}
[Fact]
public async Task Handle_WithItemExceptionDuringDeduction_ShouldContinueWithRemainingItems()
{
// Arrange
var faultyItemId = Guid.NewGuid();
var goodItemId = Guid.NewGuid();
var goodItem = CreateInventoryItemWithStock(goodItemId, 50);
_repositoryMock.Setup(r => r.GetByIdAsync(faultyItemId, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Database timeout"));
_repositoryMock.Setup(r => r.GetByIdAsync(goodItemId, It.IsAny<CancellationToken>()))
.ReturnsAsync(goodItem);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: Guid.NewGuid(),
ReferenceType: "KitchenTicket",
Reason: "Error resilience test",
Items: new List<DeductionItem>
{
new(faultyItemId, 5, "pcs", "Faulty Item"),
new(goodItemId, 3, "pcs", "Good Item")
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Should handle error for faulty item but still deduct good item.
// VI: Nen xu ly loi cho item loi nhung van tru item tot.
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.ItemsDeducted.Should().Be(1);
result.ItemsSkipped.Should().Be(1);
result.Items[0].Deducted.Should().BeFalse();
result.Items[0].Error.Should().Be("Database timeout");
result.Items[1].Deducted.Should().BeTrue();
}
[Fact]
public void Constructor_WithNullRepository_ShouldThrowArgumentNullException()
{
// Act
var action = () => new DeductInventoryCommandHandler(
null!,
_requestManagerMock.Object,
_loggerMock.Object);
// Assert
action.Should().Throw<ArgumentNullException>()
.And.ParamName.Should().Be("repository");
}
[Fact]
public void Constructor_WithNullRequestManager_ShouldThrowArgumentNullException()
{
// Act
var action = () => new DeductInventoryCommandHandler(
_repositoryMock.Object,
null!,
_loggerMock.Object);
// Assert
action.Should().Throw<ArgumentNullException>()
.And.ParamName.Should().Be("requestManager");
}
[Fact]
public void Constructor_WithNullLogger_ShouldThrowArgumentNullException()
{
// Act
var action = () => new DeductInventoryCommandHandler(
_repositoryMock.Object,
_requestManagerMock.Object,
null!);
// Assert
action.Should().Throw<ArgumentNullException>()
.And.ParamName.Should().Be("logger");
}
[Fact]
public async Task Handle_ShouldRecordIdempotencyRequest()
{
// Arrange
var referenceId = Guid.NewGuid();
var itemId = Guid.NewGuid();
var inventoryItem = CreateInventoryItemWithStock(itemId, 50);
_repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny<CancellationToken>()))
.ReturnsAsync(inventoryItem);
var command = new DeductInventoryCommand(
ShopId: Guid.NewGuid(),
ReferenceId: referenceId,
ReferenceType: "KitchenTicket",
Reason: "Idempotency test",
Items: new List<DeductionItem>
{
new(itemId, 5, "pcs", "Item A")
});
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Should check and record the idempotency request.
// VI: Nen kiem tra va ghi nhan idempotency request.
_requestManagerMock.Verify(r => r.ExistAsync(referenceId), Times.Once);
_requestManagerMock.Verify(
r => r.CreateRequestForCommandAsync<DeductInventoryCommand>(referenceId), Times.Once);
}
}

View File

@@ -0,0 +1,103 @@
// EN: Validators for Admin commands (approve, reject, suspend, reactivate, ban merchant).
// VI: Validators cho cac commands Admin (duyet, tu choi, tam dinh chi, kich hoat lai, cam merchant).
using FluentValidation;
using MerchantService.API.Application.Commands.Admin;
namespace MerchantService.API.Application.Validations;
/// <summary>
/// EN: Validator for ApproveMerchantCommand.
/// VI: Validator cho ApproveMerchantCommand.
/// </summary>
public class ApproveMerchantCommandValidator : AbstractValidator<ApproveMerchantCommand>
{
public ApproveMerchantCommandValidator()
{
RuleFor(x => x.MerchantId)
.NotEmpty()
.WithMessage("Merchant ID is required / Merchant ID la bat buoc");
RuleFor(x => x.ApprovedBy)
.NotEmpty()
.WithMessage("Approver ID is required / ID nguoi duyet la bat buoc");
}
}
/// <summary>
/// EN: Validator for RejectMerchantCommand.
/// VI: Validator cho RejectMerchantCommand.
/// </summary>
public class RejectMerchantCommandValidator : AbstractValidator<RejectMerchantCommand>
{
public RejectMerchantCommandValidator()
{
RuleFor(x => x.MerchantId)
.NotEmpty()
.WithMessage("Merchant ID is required / Merchant ID la bat buoc");
RuleFor(x => x.Reason)
.NotEmpty()
.WithMessage("Rejection reason is required / Ly do tu choi la bat buoc")
.MaximumLength(1000)
.WithMessage("Rejection reason must not exceed 1000 characters / Ly do tu choi khong vuot qua 1000 ky tu");
RuleFor(x => x.RejectedBy)
.NotEmpty()
.WithMessage("Rejector ID is required / ID nguoi tu choi la bat buoc");
}
}
/// <summary>
/// EN: Validator for SuspendMerchantCommand.
/// VI: Validator cho SuspendMerchantCommand.
/// </summary>
public class SuspendMerchantCommandValidator : AbstractValidator<SuspendMerchantCommand>
{
public SuspendMerchantCommandValidator()
{
RuleFor(x => x.MerchantId)
.NotEmpty()
.WithMessage("Merchant ID is required / Merchant ID la bat buoc");
RuleFor(x => x.Reason)
.NotEmpty()
.WithMessage("Suspension reason is required / Ly do tam dinh chi la bat buoc")
.MaximumLength(1000)
.WithMessage("Suspension reason must not exceed 1000 characters / Ly do tam dinh chi khong vuot qua 1000 ky tu");
}
}
/// <summary>
/// EN: Validator for ReactivateMerchantCommand.
/// VI: Validator cho ReactivateMerchantCommand.
/// </summary>
public class ReactivateMerchantCommandValidator : AbstractValidator<ReactivateMerchantCommand>
{
public ReactivateMerchantCommandValidator()
{
RuleFor(x => x.MerchantId)
.NotEmpty()
.WithMessage("Merchant ID is required / Merchant ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for BanMerchantCommand.
/// VI: Validator cho BanMerchantCommand.
/// </summary>
public class BanMerchantCommandValidator : AbstractValidator<BanMerchantCommand>
{
public BanMerchantCommandValidator()
{
RuleFor(x => x.MerchantId)
.NotEmpty()
.WithMessage("Merchant ID is required / Merchant ID la bat buoc");
RuleFor(x => x.Reason)
.NotEmpty()
.WithMessage("Ban reason is required / Ly do cam la bat buoc")
.MaximumLength(1000)
.WithMessage("Ban reason must not exceed 1000 characters / Ly do cam khong vuot qua 1000 ky tu");
}
}

View File

@@ -0,0 +1,173 @@
// EN: Validators for Attendance and Leave Request commands.
// VI: Validators cho cac commands Cham cong va Nghi phep.
using FluentValidation;
using MerchantService.API.Application.Commands.Attendance;
using MerchantService.API.Application.Commands.LeaveRequests;
using MerchantService.API.Application.Commands.Shops;
namespace MerchantService.API.Application.Validations;
/// <summary>
/// EN: Validator for CheckInCommand.
/// VI: Validator cho CheckInCommand.
/// </summary>
public class CheckInCommandValidator : AbstractValidator<CheckInCommand>
{
public CheckInCommandValidator()
{
RuleFor(x => x.StaffId)
.NotEmpty()
.WithMessage("Staff ID is required / Staff ID la bat buoc");
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("Shop ID is required / Shop ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for CheckOutCommand.
/// VI: Validator cho CheckOutCommand.
/// </summary>
public class CheckOutCommandValidator : AbstractValidator<CheckOutCommand>
{
public CheckOutCommandValidator()
{
RuleFor(x => x.StaffId)
.NotEmpty()
.WithMessage("Staff ID is required / Staff ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for CreateLeaveRequestCommand.
/// VI: Validator cho CreateLeaveRequestCommand.
/// </summary>
public class CreateLeaveRequestCommandValidator : AbstractValidator<CreateLeaveRequestCommand>
{
private static readonly string[] ValidLeaveTypes = ["Annual", "Sick", "Personal", "Maternity", "Paternity", "Unpaid"];
public CreateLeaveRequestCommandValidator()
{
RuleFor(x => x.StaffId)
.NotEmpty()
.WithMessage("Staff ID is required / Staff ID la bat buoc");
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("Shop ID is required / Shop ID la bat buoc");
RuleFor(x => x.LeaveType)
.NotEmpty()
.WithMessage("Leave type is required / Loai nghi phep la bat buoc")
.MaximumLength(50)
.WithMessage("Leave type must not exceed 50 characters / Loai nghi phep khong vuot qua 50 ky tu");
RuleFor(x => x.StartDate)
.NotEmpty()
.WithMessage("Start date is required / Ngay bat dau la bat buoc");
RuleFor(x => x.EndDate)
.NotEmpty()
.WithMessage("End date is required / Ngay ket thuc la bat buoc")
.GreaterThanOrEqualTo(x => x.StartDate)
.WithMessage("End date must be on or after start date / Ngay ket thuc phai bang hoac sau ngay bat dau");
RuleFor(x => x.Reason)
.MaximumLength(1000)
.WithMessage("Reason must not exceed 1000 characters / Ly do khong vuot qua 1000 ky tu")
.When(x => x.Reason != null);
}
}
/// <summary>
/// EN: Validator for ApproveLeaveRequestCommand.
/// VI: Validator cho ApproveLeaveRequestCommand.
/// </summary>
public class ApproveLeaveRequestCommandValidator : AbstractValidator<ApproveLeaveRequestCommand>
{
public ApproveLeaveRequestCommandValidator()
{
RuleFor(x => x.LeaveRequestId)
.NotEmpty()
.WithMessage("Leave request ID is required / ID yeu cau nghi phep la bat buoc");
RuleFor(x => x.ApprovedBy)
.NotEmpty()
.WithMessage("Approver ID is required / ID nguoi duyet la bat buoc");
}
}
/// <summary>
/// EN: Validator for RejectLeaveRequestCommand.
/// VI: Validator cho RejectLeaveRequestCommand.
/// </summary>
public class RejectLeaveRequestCommandValidator : AbstractValidator<RejectLeaveRequestCommand>
{
public RejectLeaveRequestCommandValidator()
{
RuleFor(x => x.LeaveRequestId)
.NotEmpty()
.WithMessage("Leave request ID is required / ID yeu cau nghi phep la bat buoc");
RuleFor(x => x.RejectedBy)
.NotEmpty()
.WithMessage("Rejector ID is required / ID nguoi tu choi la bat buoc");
RuleFor(x => x.Reason)
.MaximumLength(1000)
.WithMessage("Reason must not exceed 1000 characters / Ly do khong vuot qua 1000 ky tu")
.When(x => x.Reason != null);
}
}
/// <summary>
/// EN: Validator for UpdateShopCommand.
/// VI: Validator cho UpdateShopCommand.
/// </summary>
public class UpdateShopCommandValidator : AbstractValidator<UpdateShopCommand>
{
public UpdateShopCommandValidator()
{
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("Shop ID is required / Shop ID la bat buoc");
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Shop name is required / Ten shop la bat buoc")
.MaximumLength(100)
.WithMessage("Shop name must not exceed 100 characters / Ten shop khong vuot qua 100 ky tu");
RuleFor(x => x.Description)
.MaximumLength(2000)
.WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu")
.When(x => x.Description != null);
RuleFor(x => x.Phone)
.MaximumLength(20)
.WithMessage("Phone must not exceed 20 characters / So dien thoai khong vuot qua 20 ky tu")
.When(x => x.Phone != null);
RuleFor(x => x.Email)
.EmailAddress()
.WithMessage("Invalid email format / Dinh dang email khong hop le")
.When(x => !string.IsNullOrEmpty(x.Email));
RuleFor(x => x.Website)
.MaximumLength(200)
.WithMessage("Website must not exceed 200 characters / Website khong vuot qua 200 ky tu")
.When(x => x.Website != null);
RuleFor(x => x.LogoUrl)
.MaximumLength(2048)
.WithMessage("Logo URL must not exceed 2048 characters / URL logo khong vuot qua 2048 ky tu")
.When(x => x.LogoUrl != null);
RuleFor(x => x.CoverImageUrl)
.MaximumLength(2048)
.WithMessage("Cover image URL must not exceed 2048 characters / URL anh bia khong vuot qua 2048 ky tu")
.When(x => x.CoverImageUrl != null);
}
}

View File

@@ -0,0 +1,101 @@
// EN: Command to close the business day and generate final report.
// VI: Command dong ngay kinh doanh va tao bao cao cuoi cung.
using System.Data;
using Dapper;
using MediatR;
using OrderService.API.Application.Queries.Reports;
namespace OrderService.API.Application.Commands.Reports;
/// <summary>
/// EN: Command to close the business day for a shop and generate the final EOD report.
/// VI: Command dong ngay kinh doanh cho shop va tao bao cao cuoi ngay cuoi cung.
/// </summary>
public record CloseDayCommand(
Guid ShopId,
DateTime CloseDate
) : IRequest<CloseDayResult>;
/// <summary>
/// EN: Result of day close operation with final report.
/// VI: Ket qua thao tac dong ngay voi bao cao cuoi cung.
/// </summary>
public record CloseDayResult(
bool Success,
EodReportDto? Report,
string? Message,
int PendingOrderCount = 0
);
/// <summary>
/// EN: Handler for CloseDayCommand — validates no pending orders and generates final EOD report.
/// VI: Handler cho CloseDayCommand — kiem tra khong co don hang cho xu ly va tao bao cao cuoi ngay.
/// </summary>
public class CloseDayCommandHandler : IRequestHandler<CloseDayCommand, CloseDayResult>
{
private readonly IDbConnection _connection;
private readonly IMediator _mediator;
private readonly ILogger<CloseDayCommandHandler> _logger;
public CloseDayCommandHandler(
IDbConnection connection,
IMediator mediator,
ILogger<CloseDayCommandHandler> logger)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CloseDayResult> Handle(CloseDayCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"EN: Closing day for shop {ShopId} on {Date} / VI: Dong ngay cho shop {ShopId} ngay {Date}",
request.ShopId, request.CloseDate.ToString("yyyy-MM-dd"));
var dayStart = request.CloseDate.Date;
var dayEnd = dayStart.AddDays(1);
var parameters = new DynamicParameters();
parameters.Add("ShopId", request.ShopId);
parameters.Add("DayStart", dayStart);
parameters.Add("DayEnd", dayEnd);
// EN: Check for pending/in-progress orders (status: Draft=1, Validated=2, Paid=3, Processing=4, PaymentPending=7)
// VI: Kiem tra don hang dang cho/dang xu ly (trang thai: Draft=1, Validated=2, Paid=3, Processing=4, PaymentPending=7)
var pendingSql = @"
SELECT COUNT(*)
FROM orders o
WHERE o.shop_id = @ShopId
AND o.created_at >= @DayStart
AND o.created_at < @DayEnd
AND o.status_id IN (1, 2, 3, 4, 7)";
var pendingCount = await _connection.ExecuteScalarAsync<int>(pendingSql, parameters);
string? warningMessage = null;
if (pendingCount > 0)
{
warningMessage = $"EN: Warning: {pendingCount} order(s) are still pending/in-progress. / " +
$"VI: Canh bao: {pendingCount} don hang van dang cho xu ly.";
_logger.LogWarning(
"EN: Closing day with {Count} pending orders for shop {ShopId} / VI: Dong ngay voi {Count} don hang dang cho cho shop {ShopId}",
pendingCount, request.ShopId);
}
// EN: Generate the EOD report via the query handler / VI: Tao bao cao cuoi ngay qua query handler
var report = await _mediator.Send(new GetEodReportQuery(request.ShopId, request.CloseDate), cancellationToken);
_logger.LogInformation(
"EN: Day closed for shop {ShopId} — {Orders} orders, {Revenue:C} revenue / " +
"VI: Da dong ngay cho shop {ShopId} — {Orders} don hang, {Revenue:C} doanh thu",
request.ShopId, report.TotalOrders, report.TotalRevenue);
return new CloseDayResult(
Success: true,
Report: report,
Message: warningMessage,
PendingOrderCount: pendingCount);
}
}

View File

@@ -0,0 +1,201 @@
// EN: Query for End-of-Day report data.
// VI: Query lay du lieu bao cao cuoi ngay.
using System.Data;
using Dapper;
using MediatR;
namespace OrderService.API.Application.Queries.Reports;
/// <summary>
/// EN: Query for End-of-Day report aggregating orders, revenue, payments, and top items.
/// VI: Query cho bao cao cuoi ngay tong hop don hang, doanh thu, thanh toan, va san pham ban chay.
/// </summary>
public record GetEodReportQuery(
Guid ShopId,
DateTime ReportDate
) : IRequest<EodReportDto>;
/// <summary>
/// EN: End-of-Day report DTO with complete daily summary.
/// VI: DTO bao cao cuoi ngay voi tom tat day du trong ngay.
/// </summary>
public record EodReportDto(
DateTime ReportDate,
Guid ShopId,
int TotalOrders,
int CompletedOrders,
int CancelledOrders,
decimal TotalRevenue,
decimal CashRevenue,
decimal CardRevenue,
decimal OnlineRevenue,
decimal DiscountTotal,
List<EodPaymentBreakdownDto> PaymentBreakdown,
List<EodTopItemDto> TopItems,
List<EodHourlyRevenueDto> HourlyRevenue
);
/// <summary>
/// EN: Payment breakdown by method (class for Dapper compatibility).
/// VI: Phan tich thanh toan theo phuong thuc (class cho tuong thich Dapper).
/// </summary>
public class EodPaymentBreakdownDto
{
public string Method { get; set; } = string.Empty;
public int Count { get; set; }
public decimal Amount { get; set; }
}
/// <summary>
/// EN: Top selling item for the day (class for Dapper compatibility).
/// VI: San pham ban chay trong ngay (class cho tuong thich Dapper).
/// </summary>
public class EodTopItemDto
{
public string ItemName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal Revenue { get; set; }
}
/// <summary>
/// EN: Revenue data per hour (class for Dapper compatibility).
/// VI: Du lieu doanh thu theo gio (class cho tuong thich Dapper).
/// </summary>
public class EodHourlyRevenueDto
{
public int Hour { get; set; }
public int OrderCount { get; set; }
public decimal Revenue { get; set; }
}
/// <summary>
/// EN: Handler for GetEodReportQuery — aggregates daily order data using Dapper.
/// VI: Handler cho GetEodReportQuery — tong hop du lieu don hang hang ngay bang Dapper.
/// </summary>
public class GetEodReportQueryHandler : IRequestHandler<GetEodReportQuery, EodReportDto>
{
private readonly IDbConnection _connection;
private readonly ILogger<GetEodReportQueryHandler> _logger;
public GetEodReportQueryHandler(IDbConnection connection, ILogger<GetEodReportQueryHandler> logger)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<EodReportDto> Handle(GetEodReportQuery request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"EN: Generating EOD report for shop {ShopId} on {Date} / VI: Tao bao cao cuoi ngay cho shop {ShopId} ngay {Date}",
request.ShopId, request.ReportDate.ToString("yyyy-MM-dd"));
var dayStart = request.ReportDate.Date;
var dayEnd = dayStart.AddDays(1);
var parameters = new DynamicParameters();
parameters.Add("ShopId", request.ShopId);
parameters.Add("DayStart", dayStart);
parameters.Add("DayEnd", dayEnd);
// EN: Completed status ID = 5, Cancelled status ID = 6 (from OrderStatus enumeration)
// VI: ID trang thai hoan thanh = 5, ID trang thai huy = 6 (tu OrderStatus enumeration)
parameters.Add("CompletedStatusId", 5);
parameters.Add("CancelledStatusId", 6);
// EN: Aggregate order stats for the day / VI: Thong ke tong hop don hang trong ngay
var aggregateSql = @"
SELECT
COUNT(*) AS TotalOrders,
COUNT(*) FILTER (WHERE o.status_id = @CompletedStatusId) AS CompletedOrders,
COUNT(*) FILTER (WHERE o.status_id = @CancelledStatusId) AS CancelledOrders,
COALESCE(SUM(o.total_amount) FILTER (WHERE o.status_id NOT IN (@CancelledStatusId)), 0) AS TotalRevenue,
COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method = 'cash' AND o.status_id NOT IN (@CancelledStatusId)), 0) AS CashRevenue,
COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method = 'card' AND o.status_id NOT IN (@CancelledStatusId)), 0) AS CardRevenue,
COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method NOT IN ('cash', 'card') AND o.payment_method IS NOT NULL AND o.status_id NOT IN (@CancelledStatusId)), 0) AS OnlineRevenue,
COALESCE(SUM(o.discount_amount) FILTER (WHERE o.status_id NOT IN (@CancelledStatusId)), 0) AS DiscountTotal
FROM orders o
WHERE o.shop_id = @ShopId
AND o.created_at >= @DayStart
AND o.created_at < @DayEnd";
var aggregate = await _connection.QuerySingleAsync<AggregateRow>(aggregateSql, parameters);
// EN: Payment breakdown by method / VI: Phan tich theo phuong thuc thanh toan
var paymentSql = @"
SELECT
COALESCE(o.payment_method, 'unknown') AS Method,
COUNT(*) AS Count,
COALESCE(SUM(o.total_amount), 0) AS Amount
FROM orders o
WHERE o.shop_id = @ShopId
AND o.created_at >= @DayStart
AND o.created_at < @DayEnd
AND o.status_id NOT IN (@CancelledStatusId)
GROUP BY o.payment_method
ORDER BY Amount DESC";
var paymentBreakdown = (await _connection.QueryAsync<EodPaymentBreakdownDto>(paymentSql, parameters)).AsList();
// EN: Top 10 items by quantity sold / VI: Top 10 san pham theo so luong ban
var topItemsSql = @"
SELECT
oi.product_name AS ItemName,
SUM(oi.quantity) AS Quantity,
SUM(oi.unit_price * oi.quantity) AS Revenue
FROM order_items oi
INNER JOIN orders o ON o.id = oi.order_id
WHERE o.shop_id = @ShopId
AND o.created_at >= @DayStart
AND o.created_at < @DayEnd
AND o.status_id NOT IN (@CancelledStatusId)
GROUP BY oi.product_name
ORDER BY Quantity DESC
LIMIT 10";
var topItems = (await _connection.QueryAsync<EodTopItemDto>(topItemsSql, parameters)).AsList();
// EN: Hourly revenue breakdown / VI: Doanh thu theo tung gio
var hourlySql = @"
SELECT
EXTRACT(HOUR FROM o.created_at)::int AS Hour,
COUNT(*) AS OrderCount,
COALESCE(SUM(o.total_amount), 0) AS Revenue
FROM orders o
WHERE o.shop_id = @ShopId
AND o.created_at >= @DayStart
AND o.created_at < @DayEnd
AND o.status_id NOT IN (@CancelledStatusId)
GROUP BY EXTRACT(HOUR FROM o.created_at)
ORDER BY Hour";
var hourlyRevenue = (await _connection.QueryAsync<EodHourlyRevenueDto>(hourlySql, parameters)).AsList();
return new EodReportDto(
request.ReportDate.Date,
request.ShopId,
(int)aggregate.TotalOrders,
(int)aggregate.CompletedOrders,
(int)aggregate.CancelledOrders,
aggregate.TotalRevenue,
aggregate.CashRevenue,
aggregate.CardRevenue,
aggregate.OnlineRevenue,
aggregate.DiscountTotal,
paymentBreakdown,
topItems,
hourlyRevenue);
}
private record AggregateRow
{
public long TotalOrders { get; init; }
public long CompletedOrders { get; init; }
public long CancelledOrders { get; init; }
public decimal TotalRevenue { get; init; }
public decimal CashRevenue { get; init; }
public decimal CardRevenue { get; init; }
public decimal OnlineRevenue { get; init; }
public decimal DiscountTotal { get; init; }
}
}

View File

@@ -0,0 +1,27 @@
// EN: Validator for CloseDayCommand.
// VI: Validator cho CloseDayCommand.
using FluentValidation;
using OrderService.API.Application.Commands.Reports;
namespace OrderService.API.Application.Validations;
/// <summary>
/// EN: Validator for CloseDayCommand.
/// VI: Validator cho CloseDayCommand.
/// </summary>
public class CloseDayCommandValidator : AbstractValidator<CloseDayCommand>
{
public CloseDayCommandValidator()
{
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc");
RuleFor(x => x.CloseDate)
.NotEmpty()
.WithMessage("EN: Close date is required / VI: Ngay dong la bat buoc")
.LessThanOrEqualTo(DateTime.UtcNow.Date.AddDays(1))
.WithMessage("EN: Close date cannot be in the future / VI: Ngay dong khong the trong tuong lai");
}
}

View File

@@ -0,0 +1,25 @@
// EN: Validator for CompleteOrderCommand.
// VI: Validator cho CompleteOrderCommand.
using FluentValidation;
using OrderService.API.Application.Commands;
namespace OrderService.API.Application.Validations;
/// <summary>
/// EN: Validator for CompleteOrderCommand.
/// VI: Validator cho CompleteOrderCommand.
/// </summary>
public class CompleteOrderCommandValidator : AbstractValidator<CompleteOrderCommand>
{
public CompleteOrderCommandValidator()
{
RuleFor(x => x.OrderId)
.NotEmpty()
.WithMessage("EN: Order ID is required / VI: Order ID la bat buoc");
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc");
}
}

View File

@@ -0,0 +1,32 @@
// EN: Validator for CompleteOrderPaymentCommand.
// VI: Validator cho CompleteOrderPaymentCommand.
using FluentValidation;
using OrderService.API.Application.Commands;
namespace OrderService.API.Application.Validations;
/// <summary>
/// EN: Validator for CompleteOrderPaymentCommand.
/// VI: Validator cho CompleteOrderPaymentCommand.
/// </summary>
public class CompleteOrderPaymentCommandValidator : AbstractValidator<CompleteOrderPaymentCommand>
{
public CompleteOrderPaymentCommandValidator()
{
RuleFor(x => x.OrderId)
.NotEmpty()
.WithMessage("EN: Order ID is required / VI: Order ID la bat buoc");
RuleFor(x => x.GatewayTransactionId)
.NotEmpty()
.WithMessage("EN: Gateway transaction ID is required / VI: Ma giao dich cong thanh toan la bat buoc")
.MaximumLength(200)
.WithMessage("EN: Gateway transaction ID must not exceed 200 characters / VI: Ma giao dich khong vuot qua 200 ky tu");
RuleFor(x => x.GatewayResponseCode)
.MaximumLength(50)
.WithMessage("EN: Gateway response code must not exceed 50 characters / VI: Ma phan hoi khong vuot qua 50 ky tu")
.When(x => x.GatewayResponseCode != null);
}
}

View File

@@ -0,0 +1,27 @@
// EN: Validator for GetEodReportQuery.
// VI: Validator cho GetEodReportQuery.
using FluentValidation;
using OrderService.API.Application.Queries.Reports;
namespace OrderService.API.Application.Validations;
/// <summary>
/// EN: Validator for GetEodReportQuery.
/// VI: Validator cho GetEodReportQuery.
/// </summary>
public class GetEodReportQueryValidator : AbstractValidator<GetEodReportQuery>
{
public GetEodReportQueryValidator()
{
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc");
RuleFor(x => x.ReportDate)
.NotEmpty()
.WithMessage("EN: Report date is required / VI: Ngay bao cao la bat buoc")
.LessThanOrEqualTo(DateTime.UtcNow.Date.AddDays(1))
.WithMessage("EN: Report date cannot be in the future / VI: Ngay bao cao khong the trong tuong lai");
}
}

View File

@@ -3,7 +3,9 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using OrderService.API.Application.Commands.Reports;
using OrderService.API.Application.Queries;
using OrderService.API.Application.Queries.Reports;
namespace OrderService.API.Controllers;
@@ -71,4 +73,61 @@ public class ReportsController : ControllerBase
return Ok(result);
}
/// <summary>
/// EN: Get End-of-Day report for a shop on a specific date.
/// VI: Lay bao cao cuoi ngay cho shop vao ngay cu the.
/// </summary>
[HttpGet("eod")]
[ProducesResponseType(typeof(EodReportDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<EodReportDto>> GetEodReport(
[FromQuery] Guid shopId,
[FromQuery] DateTime? date = null,
CancellationToken cancellationToken = default)
{
var reportDate = date ?? DateTime.UtcNow.Date;
_logger.LogInformation(
"EN: Getting EOD report for shop {ShopId} on {Date} / VI: Lay bao cao cuoi ngay cho shop {ShopId} ngay {Date}",
shopId, reportDate.ToString("yyyy-MM-dd"));
var query = new GetEodReportQuery(shopId, reportDate);
var result = await _mediator.Send(query, cancellationToken);
return Ok(new { success = true, data = result });
}
/// <summary>
/// EN: Close the business day and generate final EOD report.
/// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay cuoi cung.
/// </summary>
[HttpPost("close-day")]
[ProducesResponseType(typeof(CloseDayResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CloseDayResult>> CloseDay(
[FromBody] CloseDayRequest request,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Closing day for shop {ShopId} on {Date} / VI: Dong ngay cho shop {ShopId} ngay {Date}",
request.ShopId, request.CloseDate?.ToString("yyyy-MM-dd") ?? "today");
var closeDate = request.CloseDate ?? DateTime.UtcNow.Date;
var command = new CloseDayCommand(request.ShopId, closeDate);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
{
return BadRequest(new { success = false, error = new { code = "CLOSE_DAY_FAILED", message = result.Message } });
}
return Ok(new { success = true, data = result });
}
}
/// <summary>
/// EN: Request to close the business day.
/// VI: Yeu cau dong ngay kinh doanh.
/// </summary>
public record CloseDayRequest(Guid ShopId, DateTime? CloseDate = null);

View File

@@ -0,0 +1,226 @@
// EN: Validators for Storage commands (upload, delete, share, admin).
// VI: Validators cho cac commands Storage (upload, xoa, chia se, admin).
using FluentValidation;
using StorageService.API.Application.Commands;
using StorageService.API.Application.Commands.Admin;
using StorageService.API.Application.Commands.FileShare;
namespace StorageService.API.Application.Validations;
/// <summary>
/// EN: Validator for UploadFileCommand.
/// VI: Validator cho UploadFileCommand.
/// </summary>
public class UploadFileCommandValidator : AbstractValidator<UploadFileCommand>
{
private static readonly long MaxFileSizeBytes = 500L * 1024 * 1024; // 500 MB
public UploadFileCommandValidator()
{
RuleFor(x => x.FileName)
.NotEmpty()
.WithMessage("File name is required / Ten file la bat buoc")
.MaximumLength(255)
.WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu")
.Must(name => !name.Contains("..") && !name.Contains('/') && !name.Contains('\\'))
.WithMessage("File name contains invalid characters / Ten file chua ky tu khong hop le");
RuleFor(x => x.ContentType)
.NotEmpty()
.WithMessage("Content type is required / Content type la bat buoc")
.MaximumLength(100)
.WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu");
RuleFor(x => x.FileSizeBytes)
.GreaterThan(0)
.WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0")
.LessThanOrEqualTo(MaxFileSizeBytes)
.WithMessage("File size exceeds maximum limit of 500 MB / Kich thuoc file vuot qua gioi han 500 MB");
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc")
.MaximumLength(128)
.WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
RuleFor(x => x.TenantId)
.MaximumLength(128)
.WithMessage("Tenant ID must not exceed 128 characters / Tenant ID khong vuot qua 128 ky tu")
.When(x => x.TenantId != null);
}
}
/// <summary>
/// EN: Validator for SignUploadCommand.
/// VI: Validator cho SignUploadCommand.
/// </summary>
public class SignUploadCommandValidator : AbstractValidator<SignUploadCommand>
{
private static readonly long MaxFileSizeBytes = 500L * 1024 * 1024; // 500 MB
public SignUploadCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc")
.MaximumLength(128)
.WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
RuleFor(x => x.FileName)
.NotEmpty()
.WithMessage("File name is required / Ten file la bat buoc")
.MaximumLength(255)
.WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu")
.Must(name => !name.Contains("..") && !name.Contains('/') && !name.Contains('\\'))
.WithMessage("File name contains invalid characters / Ten file chua ky tu khong hop le");
RuleFor(x => x.ContentType)
.NotEmpty()
.WithMessage("Content type is required / Content type la bat buoc")
.MaximumLength(100)
.WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu");
RuleFor(x => x.FileSizeBytes)
.GreaterThan(0)
.WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0")
.LessThanOrEqualTo(MaxFileSizeBytes)
.WithMessage("File size exceeds maximum limit of 500 MB / Kich thuoc file vuot qua gioi han 500 MB");
RuleFor(x => x.TenantId)
.MaximumLength(128)
.WithMessage("Tenant ID must not exceed 128 characters / Tenant ID khong vuot qua 128 ky tu")
.When(x => x.TenantId != null);
}
}
/// <summary>
/// EN: Validator for ConfirmUploadCommand.
/// VI: Validator cho ConfirmUploadCommand.
/// </summary>
public class ConfirmUploadCommandValidator : AbstractValidator<ConfirmUploadCommand>
{
public ConfirmUploadCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc")
.MaximumLength(128)
.WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
RuleFor(x => x.ObjectKey)
.NotEmpty()
.WithMessage("Object key is required / Object key la bat buoc")
.MaximumLength(1024)
.WithMessage("Object key must not exceed 1024 characters / Object key khong vuot qua 1024 ky tu");
RuleFor(x => x.FileName)
.NotEmpty()
.WithMessage("File name is required / Ten file la bat buoc")
.MaximumLength(255)
.WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu");
RuleFor(x => x.ContentType)
.NotEmpty()
.WithMessage("Content type is required / Content type la bat buoc")
.MaximumLength(100)
.WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu");
RuleFor(x => x.FileSizeBytes)
.GreaterThan(0)
.WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0");
}
}
/// <summary>
/// EN: Validator for DeleteFileCommand.
/// VI: Validator cho DeleteFileCommand.
/// </summary>
public class DeleteFileCommandValidator : AbstractValidator<DeleteFileCommand>
{
public DeleteFileCommandValidator()
{
RuleFor(x => x.FileId)
.NotEmpty()
.WithMessage("File ID is required / File ID la bat buoc");
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc")
.MaximumLength(128)
.WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
}
}
/// <summary>
/// EN: Validator for CreateFileShareCommand.
/// VI: Validator cho CreateFileShareCommand.
/// </summary>
public class CreateFileShareCommandValidator : AbstractValidator<CreateFileShareCommand>
{
public CreateFileShareCommandValidator()
{
RuleFor(x => x.FileId)
.NotEmpty()
.WithMessage("File ID is required / File ID la bat buoc");
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc")
.MaximumLength(128)
.WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
RuleFor(x => x.SharedWith)
.MaximumLength(256)
.WithMessage("Shared with must not exceed 256 characters / Chia se voi khong vuot qua 256 ky tu")
.When(x => x.SharedWith != null);
RuleFor(x => x.Password)
.MinimumLength(4)
.WithMessage("Password must be at least 4 characters / Mat khau phai it nhat 4 ky tu")
.MaximumLength(100)
.WithMessage("Password must not exceed 100 characters / Mat khau khong vuot qua 100 ky tu")
.When(x => x.Password != null);
RuleFor(x => x.MaxDownloads)
.GreaterThan(0)
.WithMessage("Max downloads must be greater than zero / So luot tai toi da phai lon hon 0")
.LessThanOrEqualTo(10000)
.WithMessage("Max downloads must not exceed 10000 / So luot tai toi da khong vuot qua 10000")
.When(x => x.MaxDownloads.HasValue);
RuleFor(x => x.ExpiresAt)
.GreaterThan(DateTime.UtcNow)
.WithMessage("Expiration time must be in the future / Thoi gian het han phai trong tuong lai")
.When(x => x.ExpiresAt.HasValue);
}
}
/// <summary>
/// EN: Validator for UpdateUserQuotaCommand.
/// VI: Validator cho UpdateUserQuotaCommand.
/// </summary>
public class UpdateUserQuotaCommandValidator : AbstractValidator<UpdateUserQuotaCommand>
{
public UpdateUserQuotaCommandValidator()
{
RuleFor(x => x.TargetUserId)
.NotEmpty()
.WithMessage("Target user ID is required / User ID muc tieu la bat buoc")
.MaximumLength(128)
.WithMessage("Target user ID must not exceed 128 characters / User ID muc tieu khong vuot qua 128 ky tu");
RuleFor(x => x.MaxStorageBytes)
.GreaterThan(0)
.WithMessage("Max storage must be greater than zero / Dung luong toi da phai lon hon 0");
RuleFor(x => x.MaxFileCount)
.GreaterThan(0)
.WithMessage("Max file count must be greater than zero / So file toi da phai lon hon 0");
RuleFor(x => x.QuotaTier)
.MaximumLength(50)
.WithMessage("Quota tier must not exceed 50 characters / Goi quota khong vuot qua 50 ky tu")
.When(x => x.QuotaTier != null);
}
}

View File

@@ -0,0 +1,88 @@
// EN: Validators for Point commands (earn, spend, create account).
// VI: Validators cho cac commands Point (tich diem, tieu diem, tao tai khoan).
using FluentValidation;
using WalletService.API.Application.Commands;
namespace WalletService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreatePointAccountCommand.
/// VI: Validator cho CreatePointAccountCommand.
/// </summary>
public class CreatePointAccountCommandValidator : AbstractValidator<CreatePointAccountCommand>
{
public CreatePointAccountCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
}
}
/// <summary>
/// EN: Validator for EarnPointsCommand.
/// VI: Validator cho EarnPointsCommand.
/// </summary>
public class EarnPointsCommandValidator : AbstractValidator<EarnPointsCommand>
{
public EarnPointsCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.Points)
.GreaterThan(0)
.WithMessage("Points must be greater than zero / Diem phai lon hon 0");
RuleFor(x => x.Source)
.NotEmpty()
.WithMessage("Source is required / Nguon la bat buoc")
.MaximumLength(100)
.WithMessage("Source must not exceed 100 characters / Nguon khong vuot qua 100 ky tu");
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("Description is required / Mo ta la bat buoc")
.MaximumLength(500)
.WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu");
RuleFor(x => x.ExpiryMonths)
.GreaterThan(0)
.WithMessage("Expiry months must be greater than zero / So thang het han phai lon hon 0")
.LessThanOrEqualTo(120)
.WithMessage("Expiry months must not exceed 120 / So thang het han khong vuot qua 120")
.When(x => x.ExpiryMonths.HasValue);
}
}
/// <summary>
/// EN: Validator for SpendPointsCommand.
/// VI: Validator cho SpendPointsCommand.
/// </summary>
public class SpendPointsCommandValidator : AbstractValidator<SpendPointsCommand>
{
public SpendPointsCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.Points)
.GreaterThan(0)
.WithMessage("Points must be greater than zero / Diem phai lon hon 0");
RuleFor(x => x.Source)
.NotEmpty()
.WithMessage("Source is required / Nguon la bat buoc")
.MaximumLength(100)
.WithMessage("Source must not exceed 100 characters / Nguon khong vuot qua 100 ky tu");
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("Description is required / Mo ta la bat buoc")
.MaximumLength(500)
.WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu");
}
}

View File

@@ -0,0 +1,122 @@
// EN: Validators for Wallet commands (deposit, withdraw, create wallet, exchange).
// VI: Validators cho cac commands Wallet (nap tien, rut tien, tao vi, quy doi).
using FluentValidation;
using WalletService.API.Application.Commands;
namespace WalletService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateWalletCommand.
/// VI: Validator cho CreateWalletCommand.
/// </summary>
public class CreateWalletCommandValidator : AbstractValidator<CreateWalletCommand>
{
public CreateWalletCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.Currency)
.NotEmpty()
.WithMessage("Currency is required / Tien te la bat buoc")
.MaximumLength(10)
.WithMessage("Currency code must not exceed 10 characters / Ma tien te khong vuot qua 10 ky tu");
}
}
/// <summary>
/// EN: Validator for DepositCommand.
/// VI: Validator cho DepositCommand.
/// </summary>
public class DepositCommandValidator : AbstractValidator<DepositCommand>
{
public DepositCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("Amount must be greater than zero / So tien phai lon hon 0")
.LessThanOrEqualTo(1_000_000_000m)
.WithMessage("Amount exceeds maximum limit / So tien vuot qua gioi han toi da");
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("Description is required / Mo ta la bat buoc")
.MaximumLength(500)
.WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu");
RuleFor(x => x.ReferenceId)
.MaximumLength(100)
.WithMessage("Reference ID must not exceed 100 characters / Ma tham chieu khong vuot qua 100 ky tu")
.When(x => x.ReferenceId != null);
}
}
/// <summary>
/// EN: Validator for WithdrawCommand.
/// VI: Validator cho WithdrawCommand.
/// </summary>
public class WithdrawCommandValidator : AbstractValidator<WithdrawCommand>
{
public WithdrawCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("Amount must be greater than zero / So tien phai lon hon 0")
.LessThanOrEqualTo(1_000_000_000m)
.WithMessage("Amount exceeds maximum limit / So tien vuot qua gioi han toi da");
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("Description is required / Mo ta la bat buoc")
.MaximumLength(500)
.WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu");
RuleFor(x => x.ReferenceId)
.MaximumLength(100)
.WithMessage("Reference ID must not exceed 100 characters / Ma tham chieu khong vuot qua 100 ky tu")
.When(x => x.ReferenceId != null);
}
}
/// <summary>
/// EN: Validator for ExchangeCommand.
/// VI: Validator cho ExchangeCommand.
/// </summary>
public class ExchangeCommandValidator : AbstractValidator<ExchangeCommand>
{
public ExchangeCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty()
.WithMessage("User ID is required / User ID la bat buoc");
RuleFor(x => x.FromAmount)
.GreaterThan(0)
.WithMessage("From amount must be greater than zero / So tien quy doi phai lon hon 0");
RuleFor(x => x.FromCurrencyTypeId)
.GreaterThan(0)
.WithMessage("From currency type is required / Loai tien nguon la bat buoc");
RuleFor(x => x.ToCurrencyTypeId)
.GreaterThan(0)
.WithMessage("To currency type is required / Loai tien dich la bat buoc")
.NotEqual(x => x.FromCurrencyTypeId)
.WithMessage("Cannot exchange to same currency type / Khong the quy doi cung loai tien te");
RuleFor(x => x.CustomRate)
.GreaterThan(0)
.WithMessage("Custom rate must be greater than zero / Ty gia tuy chinh phai lon hon 0")
.When(x => x.CustomRate.HasValue);
}
}

View File

@@ -0,0 +1,251 @@
// EN: Unit tests for CreatePaymentCommandHandler - payment creation with gateway integration.
// VI: Unit tests cho CreatePaymentCommandHandler - tao thanh toan voi tich hop cong thanh toan.
using FluentAssertions;
using MediatR;
using Moq;
using WalletService.API.Application.Commands.Payments;
using WalletService.Domain.AggregatesModel.PaymentAggregate;
using WalletService.Domain.Events;
using WalletService.Domain.Exceptions;
using WalletService.Domain.SeedWork;
using Microsoft.Extensions.Logging;
using Xunit;
namespace WalletService.UnitTests.Application.Commands;
/// <summary>
/// EN: Unit tests for CreatePaymentCommandHandler.
/// VI: Unit tests cho CreatePaymentCommandHandler.
/// </summary>
public class CreatePaymentCommandHandlerTests
{
private readonly Mock<IPaymentRepository> _paymentRepositoryMock;
private readonly Mock<IPaymentGateway> _vnpayGatewayMock;
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly Mock<ILogger<CreatePaymentCommandHandler>> _loggerMock;
private readonly CreatePaymentCommandHandler _handler;
public CreatePaymentCommandHandlerTests()
{
_paymentRepositoryMock = new Mock<IPaymentRepository>();
_vnpayGatewayMock = new Mock<IPaymentGateway>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
_loggerMock = new Mock<ILogger<CreatePaymentCommandHandler>>();
_vnpayGatewayMock.Setup(g => g.GatewayName).Returns("VNPay");
_unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_paymentRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
_paymentRepositoryMock.Setup(r => r.Add(It.IsAny<Payment>()))
.Returns((Payment p) => p);
var gateways = new List<IPaymentGateway> { _vnpayGatewayMock.Object };
_handler = new CreatePaymentCommandHandler(
_paymentRepositoryMock.Object,
gateways,
_loggerMock.Object);
}
[Fact]
public async Task Handle_WithValidPayment_ShouldCreatePayment()
{
// Arrange
_vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentResult(
Success: true,
TransactionId: "TXN123",
PaymentUrl: "https://vnpay.vn/pay?txn=TXN123"));
var command = new CreatePaymentCommand(
OrderId: Guid.NewGuid(),
Amount: 100_000m,
Currency: "VND",
GatewayName: "VNPay",
ReturnUrl: "https://goodgo.vn/payment/return",
IpAddress: "127.0.0.1");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.PaymentId.Should().NotBeEmpty();
result.OrderId.Should().Be(command.OrderId);
result.Amount.Should().Be(100_000m);
result.Currency.Should().Be("VND");
result.GatewayName.Should().Be("VNPay");
result.Status.Should().Be("Processing");
result.PaymentUrl.Should().Be("https://vnpay.vn/pay?txn=TXN123");
_paymentRepositoryMock.Verify(r => r.Add(It.IsAny<Payment>()), Times.Once);
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_WithInvalidGateway_ShouldThrowWalletDomainException()
{
// Arrange
var command = new CreatePaymentCommand(
OrderId: Guid.NewGuid(),
Amount: 50_000m,
Currency: "VND",
GatewayName: "NonExistentGateway",
ReturnUrl: "https://goodgo.vn/payment/return",
IpAddress: "127.0.0.1");
// Act
var action = () => _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Should throw WalletDomainException for unsupported gateway.
// VI: Nen throw WalletDomainException cho cong thanh toan khong ho tro.
await action.Should().ThrowAsync<WalletDomainException>()
.WithMessage("*NonExistentGateway*not supported*");
}
[Fact]
public async Task Handle_WithGatewayFailure_ShouldCreatePaymentWithFailedStatus()
{
// Arrange
_vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentResult(
Success: false,
ErrorCode: "TIMEOUT",
ErrorMessage: "Gateway timeout"));
var command = new CreatePaymentCommand(
OrderId: Guid.NewGuid(),
Amount: 200_000m,
Currency: "VND",
GatewayName: "VNPay",
ReturnUrl: "https://goodgo.vn/payment/return",
IpAddress: "127.0.0.1");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Should create the payment entity but mark it as failed.
// VI: Nen tao payment entity nhung danh dau la that bai.
result.Should().NotBeNull();
result.Status.Should().Be("Failed");
result.PaymentUrl.Should().BeNull();
_paymentRepositoryMock.Verify(r => r.Add(It.IsAny<Payment>()), Times.Once);
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public void Handle_WithZeroAmount_ShouldThrowWalletDomainException()
{
// Arrange
// EN: Payment entity constructor validates amount > 0.
// VI: Constructor cua Payment entity validate amount > 0.
var command = new CreatePaymentCommand(
OrderId: Guid.NewGuid(),
Amount: 0m,
Currency: "VND",
GatewayName: "VNPay",
ReturnUrl: "https://goodgo.vn/payment/return",
IpAddress: "127.0.0.1");
_vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentResult(Success: true, TransactionId: "TXN123", PaymentUrl: "https://vnpay.vn/pay"));
// Act
var action = () => _handler.Handle(command, CancellationToken.None);
// Assert
action.Should().ThrowAsync<WalletDomainException>()
.WithMessage("*amount*greater than zero*");
}
[Fact]
public void Handle_WithNegativeAmount_ShouldThrowWalletDomainException()
{
// Arrange
var command = new CreatePaymentCommand(
OrderId: Guid.NewGuid(),
Amount: -100_000m,
Currency: "VND",
GatewayName: "VNPay",
ReturnUrl: "https://goodgo.vn/payment/return",
IpAddress: "127.0.0.1");
_vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentResult(Success: true, TransactionId: "TXN123", PaymentUrl: "https://vnpay.vn/pay"));
// Act
var action = () => _handler.Handle(command, CancellationToken.None);
// Assert
action.Should().ThrowAsync<WalletDomainException>()
.WithMessage("*amount*greater than zero*");
}
[Fact]
public async Task Handle_ShouldRaiseDomainEvent()
{
// Arrange
_vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentResult(
Success: true,
TransactionId: "TXN456",
PaymentUrl: "https://vnpay.vn/pay?txn=TXN456"));
Payment? capturedPayment = null;
_paymentRepositoryMock.Setup(r => r.Add(It.IsAny<Payment>()))
.Callback<Payment>(p => capturedPayment = p)
.Returns((Payment p) => p);
var command = new CreatePaymentCommand(
OrderId: Guid.NewGuid(),
Amount: 150_000m,
Currency: "VND",
GatewayName: "VNPay",
ReturnUrl: "https://goodgo.vn/payment/return",
IpAddress: "127.0.0.1");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Payment constructor raises PaymentCreatedDomainEvent.
// VI: Constructor cua Payment phat ra PaymentCreatedDomainEvent.
capturedPayment.Should().NotBeNull();
capturedPayment!.DomainEvents.Should().NotBeEmpty();
capturedPayment.DomainEvents.Should().ContainItemsAssignableTo<PaymentCreatedDomainEvent>();
}
[Fact]
public async Task Handle_WithSuccessfulGateway_ShouldSetPaymentUrlAndTransactionId()
{
// Arrange
var expectedUrl = "https://vnpay.vn/pay?txn=ABC789";
var expectedTxnId = "ABC789";
_vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentResult(
Success: true,
TransactionId: expectedTxnId,
PaymentUrl: expectedUrl));
var command = new CreatePaymentCommand(
OrderId: Guid.NewGuid(),
Amount: 300_000m,
Currency: "VND",
GatewayName: "VNPay",
ReturnUrl: "https://goodgo.vn/payment/return",
IpAddress: "192.168.1.1");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.PaymentUrl.Should().Be(expectedUrl);
result.Status.Should().Be("Processing");
}
}

View File

@@ -0,0 +1,243 @@
// EN: Unit tests for ProcessPaymentCallbackCommandHandler - payment callback (IPN) processing.
// VI: Unit tests cho ProcessPaymentCallbackCommandHandler - xu ly callback (IPN) thanh toan.
using FluentAssertions;
using Moq;
using WalletService.API.Application.Commands.Payments;
using WalletService.Domain.AggregatesModel.PaymentAggregate;
using WalletService.Domain.Exceptions;
using WalletService.Domain.SeedWork;
using Microsoft.Extensions.Logging;
using Xunit;
namespace WalletService.UnitTests.Application.Commands;
/// <summary>
/// EN: Unit tests for ProcessPaymentCallbackCommandHandler.
/// VI: Unit tests cho ProcessPaymentCallbackCommandHandler.
/// </summary>
public class ProcessPaymentCallbackCommandHandlerTests
{
private readonly Mock<IPaymentRepository> _paymentRepositoryMock;
private readonly Mock<IPaymentGateway> _vnpayGatewayMock;
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly Mock<ILogger<ProcessPaymentCallbackCommandHandler>> _loggerMock;
private readonly ProcessPaymentCallbackCommandHandler _handler;
public ProcessPaymentCallbackCommandHandlerTests()
{
_paymentRepositoryMock = new Mock<IPaymentRepository>();
_vnpayGatewayMock = new Mock<IPaymentGateway>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
_loggerMock = new Mock<ILogger<ProcessPaymentCallbackCommandHandler>>();
_vnpayGatewayMock.Setup(g => g.GatewayName).Returns("VNPay");
_unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_paymentRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
var gateways = new List<IPaymentGateway> { _vnpayGatewayMock.Object };
_handler = new ProcessPaymentCallbackCommandHandler(
_paymentRepositoryMock.Object,
gateways,
_loggerMock.Object);
}
/// <summary>
/// EN: Helper to create a Payment entity in Processing state for callback testing.
/// VI: Helper de tao Payment entity o trang thai Processing de test callback.
/// </summary>
private static Payment CreateProcessingPayment(Guid orderId)
{
var payment = new Payment(orderId, 100_000m, "VND", "VNPay");
payment.MarkAsProcessing("https://vnpay.vn/pay", "TXN_ORIGINAL");
return payment;
}
[Fact]
public async Task Handle_WithValidCallback_ShouldUpdatePaymentStatusToCompleted()
{
// Arrange
var orderId = Guid.NewGuid();
var payment = CreateProcessingPayment(orderId);
_vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny<IDictionary<string, string>>(), It.IsAny<string>()))
.Returns(true);
_paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
.ReturnsAsync(payment);
var parameters = new Dictionary<string, string>
{
{ "vnp_SecureHash", "valid_hash_123" },
{ "vnp_TxnRef", orderId.ToString() },
{ "vnp_ResponseCode", "00" },
{ "vnp_TransactionNo", "VNP_TXN_001" }
};
var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.PaymentId.Should().Be(payment.Id);
result.OrderId.Should().Be(orderId);
result.Status.Should().Be("Completed");
result.ErrorMessage.Should().BeNull();
_paymentRepositoryMock.Verify(r => r.Update(payment), Times.Once);
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_WithInvalidSignature_ShouldRejectCallback()
{
// Arrange
_vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny<IDictionary<string, string>>(), It.IsAny<string>()))
.Returns(false);
var parameters = new Dictionary<string, string>
{
{ "vnp_SecureHash", "invalid_hash" },
{ "vnp_TxnRef", Guid.NewGuid().ToString() },
{ "vnp_ResponseCode", "00" }
};
var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Invalid signature should be rejected without updating any payment.
// VI: Chu ky khong hop le nen bi tu choi ma khong cap nhat payment nao.
result.Should().NotBeNull();
result.Success.Should().BeFalse();
result.Status.Should().Be("InvalidSignature");
result.ErrorMessage.Should().Contain("Invalid callback signature");
_paymentRepositoryMock.Verify(r => r.GetByOrderIdAsync(It.IsAny<Guid>()), Times.Never);
_paymentRepositoryMock.Verify(r => r.Update(It.IsAny<Payment>()), Times.Never);
}
[Fact]
public async Task Handle_WithNonExistentPayment_ShouldReturnNotFound()
{
// Arrange
var orderId = Guid.NewGuid();
_vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny<IDictionary<string, string>>(), It.IsAny<string>()))
.Returns(true);
_paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
.ReturnsAsync((Payment?)null);
var parameters = new Dictionary<string, string>
{
{ "vnp_SecureHash", "valid_hash" },
{ "vnp_TxnRef", orderId.ToString() },
{ "vnp_ResponseCode", "00" },
{ "vnp_TransactionNo", "VNP_TXN_002" }
};
var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Success.Should().BeFalse();
result.PaymentId.Should().BeNull();
result.OrderId.Should().Be(orderId);
result.Status.Should().Be("NotFound");
result.ErrorMessage.Should().Contain("Payment not found");
}
[Fact]
public async Task Handle_WithFailedResponseCode_ShouldUpdatePaymentToFailed()
{
// Arrange
var orderId = Guid.NewGuid();
var payment = CreateProcessingPayment(orderId);
_vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny<IDictionary<string, string>>(), It.IsAny<string>()))
.Returns(true);
_paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
.ReturnsAsync(payment);
var parameters = new Dictionary<string, string>
{
{ "vnp_SecureHash", "valid_hash" },
{ "vnp_TxnRef", orderId.ToString() },
{ "vnp_ResponseCode", "24" },
{ "vnp_TransactionNo", "" },
{ "vnp_OrderInfo", "Customer cancelled payment" }
};
var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
// EN: Failed response code should mark payment as failed.
// VI: Ma phan hoi that bai nen danh dau payment la that bai.
result.Should().NotBeNull();
result.Success.Should().BeFalse();
result.Status.Should().Be("Failed");
result.ErrorMessage.Should().Contain("24");
_paymentRepositoryMock.Verify(r => r.Update(payment), Times.Once);
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_WithInvalidTransactionReference_ShouldReturnInvalidReference()
{
// Arrange
_vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny<IDictionary<string, string>>(), It.IsAny<string>()))
.Returns(true);
var parameters = new Dictionary<string, string>
{
{ "vnp_SecureHash", "valid_hash" },
{ "vnp_TxnRef", "not-a-valid-guid" },
{ "vnp_ResponseCode", "00" }
};
var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Success.Should().BeFalse();
result.Status.Should().Be("InvalidReference");
result.ErrorMessage.Should().Contain("Invalid transaction reference");
}
[Fact]
public async Task Handle_WithUnsupportedGateway_ShouldThrowWalletDomainException()
{
// Arrange
var parameters = new Dictionary<string, string>
{
{ "vnp_SecureHash", "hash" },
{ "vnp_TxnRef", Guid.NewGuid().ToString() }
};
var command = new ProcessPaymentCallbackCommand("MoMo", parameters);
// Act
var action = () => _handler.Handle(command, CancellationToken.None);
// Assert
await action.Should().ThrowAsync<WalletDomainException>()
.WithMessage("*MoMo*not supported*");
}
}