feat: add order lifecycle integration tests (29 tests) and staging K8s deployment manifests

Testing (P0-7):
- 29 functional tests for order-service API (create/pay/complete/cancel lifecycle)
- CustomWebApplicationFactory with InMemory DB, mocked wallet/SignalR/tenant
- TestAuthHandler for JWT auth in tests
- Full lifecycle tests: cash flow and online payment flow end-to-end

Staging Deployment (P0-8):
- K8s manifests for 8 MVP services + Redis + POS web (namespace, configmap, secrets)
- Traefik Ingress with path-based routing and TLS via cert-manager
- HPA auto-scaling (2-4 replicas, CPU/memory thresholds)
- deploy-staging.sh script with --dry-run and --service flags
- CI/CD: deploy-staging.yml and docker-build.yml with matrix strategy
- Consistent patterns: port 8080, 3 health probes, RollingUpdate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-06 13:56:03 +07:00
parent 914dda3fe9
commit 1d12a7980b
24 changed files with 3448 additions and 164 deletions

View File

@@ -1,3 +1,5 @@
# EN: Deploy GoodGo Platform MVP services to Kubernetes staging
# VI: Trien khai cac service MVP cua GoodGo Platform len K8s staging
name: Deploy to Staging
on:
@@ -6,48 +8,345 @@ on:
- develop
paths:
- 'services/iam-service-net/**'
- 'apps/web-client/**'
- 'services/merchant-service-net/**'
- 'services/order-service-net/**'
- 'services/fnb-engine-net/**'
- 'services/inventory-service-net/**'
- 'services/wallet-service-net/**'
- 'services/catalog-service-net/**'
- 'services/storage-service-net/**'
- 'apps/web-client-tpos-net/**'
- 'deployments/staging/**'
workflow_dispatch:
inputs:
service:
description: 'Service to deploy (leave empty for all)'
required: false
default: ''
type: choice
options:
- ''
- iam-service
- merchant-service
- order-service
- fnb-engine
- inventory-service
- wallet-service
- catalog-service
- storage-service
- pos-web
env:
REGISTRY: docker.io
NAMESPACE: staging
jobs:
deploy:
# =========================================================================
# Build & Push Docker Images
# =========================================================================
detect-changes:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.changes.outputs.services }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changed services
id: changes
run: |
if [ -n "${{ github.event.inputs.service }}" ]; then
echo 'services=["${{ github.event.inputs.service }}"]' >> $GITHUB_OUTPUT
exit 0
fi
SERVICES=()
CHANGED=$(git diff --name-only HEAD~1 HEAD)
declare -A SERVICE_MAP=(
["services/iam-service-net"]="iam-service"
["services/merchant-service-net"]="merchant-service"
["services/order-service-net"]="order-service"
["services/fnb-engine-net"]="fnb-engine"
["services/inventory-service-net"]="inventory-service"
["services/wallet-service-net"]="wallet-service"
["services/catalog-service-net"]="catalog-service"
["services/storage-service-net"]="storage-service"
["apps/web-client-tpos-net"]="pos-web"
)
for path in "${!SERVICE_MAP[@]}"; do
if echo "$CHANGED" | grep -q "^${path}/"; then
SERVICES+=("\"${SERVICE_MAP[$path]}\"")
fi
done
# EN: If deployment configs changed, deploy all
# VI: Neu cau hinh deployment thay doi, deploy tat ca
if echo "$CHANGED" | grep -q "^deployments/staging/"; then
SERVICES=("\"iam-service\"" "\"merchant-service\"" "\"order-service\"" "\"fnb-engine\"" "\"inventory-service\"" "\"wallet-service\"" "\"catalog-service\"" "\"storage-service\"" "\"pos-web\"")
fi
if [ ${#SERVICES[@]} -eq 0 ]; then
echo 'services=[]' >> $GITHUB_OUTPUT
else
JOINED=$(IFS=,; echo "${SERVICES[*]}")
echo "services=[${JOINED}]" >> $GITHUB_OUTPUT
fi
build-and-push:
needs: detect-changes
if: needs.detect-changes.outputs.services != '[]'
runs-on: ubuntu-latest
strategy:
matrix:
service: ${{ fromJSON(needs.detect-changes.outputs.services) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Determine build context
id: context
run: |
declare -A CONTEXT_MAP=(
["iam-service"]="./services/iam-service-net"
["merchant-service"]="./services/merchant-service-net"
["order-service"]="./services/order-service-net"
["fnb-engine"]="./services/fnb-engine-net"
["inventory-service"]="./services/inventory-service-net"
["wallet-service"]="./services/wallet-service-net"
["catalog-service"]="./services/catalog-service-net"
["storage-service"]="./services/storage-service-net"
["pos-web"]="./apps/web-client-tpos-net"
)
declare -A IMAGE_MAP=(
["iam-service"]="goodgo/iam-service-net"
["merchant-service"]="goodgo/merchant-service-net"
["order-service"]="goodgo/order-service-net"
["fnb-engine"]="goodgo/fnb-engine-net"
["inventory-service"]="goodgo/inventory-service-net"
["wallet-service"]="goodgo/wallet-service-net"
["catalog-service"]="goodgo/catalog-service-net"
["storage-service"]="goodgo/storage-service-net"
["pos-web"]="goodgo/web-client-tpos-net"
)
echo "context=${CONTEXT_MAP[${{ matrix.service }}]}" >> $GITHUB_OUTPUT
echo "image=${IMAGE_MAP[${{ matrix.service }}]}" >> $GITHUB_OUTPUT
- name: Build and push ${{ matrix.service }}
uses: docker/build-push-action@v5
with:
context: ${{ steps.context.outputs.context }}
push: true
tags: |
${{ steps.context.outputs.image }}:staging
${{ steps.context.outputs.image }}:${{ github.sha }}
cache-from: type=registry,ref=${{ steps.context.outputs.image }}:buildcache
cache-to: type=registry,ref=${{ steps.context.outputs.image }}:buildcache,mode=max
# =========================================================================
# Run Database Migrations
# =========================================================================
migrations:
needs: [detect-changes, build-and-push]
if: needs.detect-changes.outputs.services != '[]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Run database migrations
- name: Install EF Core tools
run: dotnet tool install --global dotnet-ef || true
- name: Run IAM migrations
if: contains(needs.detect-changes.outputs.services, 'iam-service')
run: |
dotnet tool install --global dotnet-ef || true
dotnet ef database update \
--project services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj \
--startup-project services/iam-service-net/src/IamService.API/IamService.API.csproj
env:
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_DATABASE_URL_STAGING }}
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_IAM_DATABASE_URL_STAGING }}
- name: Run Merchant migrations
if: contains(needs.detect-changes.outputs.services, 'merchant-service')
run: |
dotnet ef database update \
--project services/merchant-service-net/src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj \
--startup-project services/merchant-service-net/src/MerchantService.API/MerchantService.API.csproj
env:
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_MERCHANT_DATABASE_URL_STAGING }}
- name: Run Order migrations
if: contains(needs.detect-changes.outputs.services, 'order-service')
run: |
dotnet ef database update \
--project services/order-service-net/src/OrderService.Infrastructure/OrderService.Infrastructure.csproj \
--startup-project services/order-service-net/src/OrderService.API/OrderService.API.csproj
env:
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_ORDER_DATABASE_URL_STAGING }}
- name: Run FnB Engine migrations
if: contains(needs.detect-changes.outputs.services, 'fnb-engine')
run: |
dotnet ef database update \
--project services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbEngine.Infrastructure.csproj \
--startup-project services/fnb-engine-net/src/FnbEngine.API/FnbEngine.API.csproj
env:
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_FNB_DATABASE_URL_STAGING }}
- name: Run Inventory migrations
if: contains(needs.detect-changes.outputs.services, 'inventory-service')
run: |
dotnet ef database update \
--project services/inventory-service-net/src/InventoryService.Infrastructure/InventoryService.Infrastructure.csproj \
--startup-project services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj
env:
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_INVENTORY_DATABASE_URL_STAGING }}
- name: Run Wallet migrations
if: contains(needs.detect-changes.outputs.services, 'wallet-service')
run: |
dotnet ef database update \
--project services/wallet-service-net/src/WalletService.Infrastructure/WalletService.Infrastructure.csproj \
--startup-project services/wallet-service-net/src/WalletService.API/WalletService.API.csproj
env:
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_WALLET_DATABASE_URL_STAGING }}
- name: Run Catalog migrations
if: contains(needs.detect-changes.outputs.services, 'catalog-service')
run: |
dotnet ef database update \
--project services/catalog-service-net/src/CatalogService.Infrastructure/CatalogService.Infrastructure.csproj \
--startup-project services/catalog-service-net/src/CatalogService.API/CatalogService.API.csproj
env:
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_CATALOG_DATABASE_URL_STAGING }}
- name: Run Storage migrations
if: contains(needs.detect-changes.outputs.services, 'storage-service')
run: |
dotnet ef database update \
--project services/storage-service-net/src/StorageService.Infrastructure/StorageService.Infrastructure.csproj \
--startup-project services/storage-service-net/src/StorageService.API/StorageService.API.csproj
env:
ConnectionStrings__DefaultConnection: ${{ secrets.NEON_STORAGE_DATABASE_URL_STAGING }}
# =========================================================================
# Deploy to Kubernetes
# =========================================================================
deploy:
needs: [detect-changes, build-and-push, migrations]
if: needs.detect-changes.outputs.services != '[]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v3
- name: Configure kubectl
run: |
echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 -d > kubeconfig
export KUBECONFIG=./kubeconfig
- name: Deploy IAM Service
echo "KUBECONFIG=$(pwd)/kubeconfig" >> $GITHUB_ENV
- name: Apply namespace and config
run: |
kubectl apply -f deployments/staging/kubernetes/namespace.yaml
kubectl apply -f deployments/staging/kubernetes/configmap.yaml
- name: Deploy Redis
run: |
kubectl apply -f deployments/staging/kubernetes/redis.yaml
- name: Deploy services
run: |
SERVICES='${{ needs.detect-changes.outputs.services }}'
declare -A DEPLOY_MAP=(
["iam-service"]="iam-service.yaml"
["merchant-service"]="merchant-service.yaml"
["order-service"]="order-service.yaml"
["fnb-engine"]="fnb-engine.yaml"
["inventory-service"]="inventory-service.yaml"
["wallet-service"]="wallet-service.yaml"
["catalog-service"]="catalog-service.yaml"
["storage-service"]="storage-service.yaml"
["pos-web"]="pos-web.yaml"
)
for svc in "${!DEPLOY_MAP[@]}"; do
if echo "$SERVICES" | grep -q "\"${svc}\""; then
echo "Deploying ${svc}..."
kubectl apply -f "deployments/staging/kubernetes/${DEPLOY_MAP[$svc]}"
# EN: Force rollout to pick up new image
# VI: Buoc rollout de cap nhat image moi
kubectl set image "deployment/${svc}" \
"${svc}=$(echo ${DEPLOY_MAP[$svc]} | sed 's/.yaml//')" \
-n staging 2>/dev/null || true
kubectl rollout restart "deployment/${svc}" -n staging
fi
done
- name: Apply ingress
run: |
export KUBECONFIG=./kubeconfig
kubectl apply -f deployments/staging/kubernetes/iam-service.yaml
kubectl apply -f deployments/staging/kubernetes/iam-service-configmap.yaml
kubectl apply -f deployments/staging/kubernetes/ingress.yaml
kubectl rollout status deployment/iam-service -n staging
- name: Deploy Web App
- name: Wait for rollouts
run: |
export KUBECONFIG=./kubeconfig
kubectl apply -f deployments/staging/kubernetes/web-app.yaml || echo "Web app deployment not configured"
kubectl rollout status deployment/web-app -n staging || echo "Web app deployment not configured"
SERVICES='${{ needs.detect-changes.outputs.services }}'
declare -A DEPLOY_NAMES=(
["iam-service"]="iam-service"
["merchant-service"]="merchant-service"
["order-service"]="order-service"
["fnb-engine"]="fnb-engine"
["inventory-service"]="inventory-service"
["wallet-service"]="wallet-service"
["catalog-service"]="catalog-service"
["storage-service"]="storage-service"
["pos-web"]="pos-web"
)
FAILED=0
for svc in "${!DEPLOY_NAMES[@]}"; do
if echo "$SERVICES" | grep -q "\"${svc}\""; then
echo "Waiting for ${svc}..."
if ! kubectl rollout status "deployment/${DEPLOY_NAMES[$svc]}" -n staging --timeout=180s; then
echo "WARNING: ${svc} rollout did not complete in time"
FAILED=$((FAILED + 1))
fi
fi
done
if [ $FAILED -gt 0 ]; then
echo "WARNING: ${FAILED} service(s) did not complete rollout"
kubectl get pods -n staging
exit 1
fi
- name: Verify deployment
run: |
echo "=== Pods ==="
kubectl get pods -n staging -o wide
echo ""
echo "=== Services ==="
kubectl get svc -n staging
echo ""
echo "=== Ingress ==="
kubectl get ingress -n staging

View File

@@ -1,3 +1,5 @@
# EN: Build and push Docker images for all MVP services
# VI: Build va push Docker images cho tat ca MVP services
name: Docker Build
on:
@@ -7,56 +9,104 @@ on:
- develop
paths:
- 'services/iam-service-net/**'
- 'apps/web-client/**'
- 'services/merchant-service-net/**'
- 'services/order-service-net/**'
- 'services/fnb-engine-net/**'
- 'services/inventory-service-net/**'
- 'services/wallet-service-net/**'
- 'services/catalog-service-net/**'
- 'services/storage-service-net/**'
- 'apps/web-client-tpos-net/**'
workflow_dispatch:
inputs:
service:
description: 'Service to build (leave empty for changed only)'
required: false
default: ''
jobs:
build-iam-service:
detect-changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push IAM Service
uses: docker/build-push-action@v5
with:
context: ./services/iam-service-net
push: true
tags: |
goodgo/iam-service-net:latest
goodgo/iam-service-net:${{ github.sha }}
cache-from: type=registry,ref=goodgo/iam-service-net:buildcache
cache-to: type=registry,ref=goodgo/iam-service-net:buildcache,mode=max
fetch-depth: 2
build-web-client:
- name: Detect changed services
id: set-matrix
run: |
if [ -n "${{ github.event.inputs.service }}" ]; then
echo 'matrix={"include":[{"service":"${{ github.event.inputs.service }}"}]}' >> $GITHUB_OUTPUT
exit 0
fi
CHANGED=$(git diff --name-only HEAD~1 HEAD)
INCLUDES=()
declare -A SERVICES=(
["services/iam-service-net"]='{"service":"iam-service-net","context":"./services/iam-service-net","image":"goodgo/iam-service-net"}'
["services/merchant-service-net"]='{"service":"merchant-service-net","context":"./services/merchant-service-net","image":"goodgo/merchant-service-net"}'
["services/order-service-net"]='{"service":"order-service-net","context":"./services/order-service-net","image":"goodgo/order-service-net"}'
["services/fnb-engine-net"]='{"service":"fnb-engine-net","context":"./services/fnb-engine-net","image":"goodgo/fnb-engine-net"}'
["services/inventory-service-net"]='{"service":"inventory-service-net","context":"./services/inventory-service-net","image":"goodgo/inventory-service-net"}'
["services/wallet-service-net"]='{"service":"wallet-service-net","context":"./services/wallet-service-net","image":"goodgo/wallet-service-net"}'
["services/catalog-service-net"]='{"service":"catalog-service-net","context":"./services/catalog-service-net","image":"goodgo/catalog-service-net"}'
["services/storage-service-net"]='{"service":"storage-service-net","context":"./services/storage-service-net","image":"goodgo/storage-service-net"}'
["apps/web-client-tpos-net"]='{"service":"web-client-tpos-net","context":"./apps/web-client-tpos-net","image":"goodgo/web-client-tpos-net"}'
)
for path in "${!SERVICES[@]}"; do
if echo "$CHANGED" | grep -q "^${path}/"; then
INCLUDES+=("${SERVICES[$path]}")
fi
done
if [ ${#INCLUDES[@]} -eq 0 ]; then
echo 'matrix={"include":[]}' >> $GITHUB_OUTPUT
else
JOINED=$(IFS=,; echo "${INCLUDES[*]}")
echo "matrix={\"include\":[${JOINED}]}" >> $GITHUB_OUTPUT
fi
build:
needs: detect-changes
if: needs.detect-changes.outputs.matrix != '{"include":[]}'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.detect-changes.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Web Client
- name: Set tags
id: tags
run: |
IMAGE="${{ matrix.image }}"
SHA="${{ github.sha }}"
BRANCH="${{ github.ref_name }}"
if [ "$BRANCH" = "main" ]; then
echo "tags=${IMAGE}:latest,${IMAGE}:${SHA}" >> $GITHUB_OUTPUT
else
echo "tags=${IMAGE}:staging,${IMAGE}:${SHA}" >> $GITHUB_OUTPUT
fi
- name: Build and push ${{ matrix.service }}
uses: docker/build-push-action@v5
with:
context: ./apps/web-client
context: ${{ matrix.context }}
push: true
tags: |
goodgo/web-client:latest
goodgo/web-client:${{ github.sha }}
cache-from: type=registry,ref=goodgo/web-client:buildcache
cache-to: type=registry,ref=goodgo/web-client:buildcache,mode=max
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=registry,ref=${{ matrix.image }}:buildcache
cache-to: type=registry,ref=${{ matrix.image }}:buildcache,mode=max

View File

@@ -134,7 +134,7 @@
| Task | Agent | Status | Depends On |
|------|-------|:------:|:----------:|
| VN Pay payment gateway integration | Senior Backend #1 | `DONE` | wallet-service |
| Momo payment gateway integration | Senior Backend #2 | `TODO` | wallet-service |
| Momo payment gateway integration | Senior Backend #2 | `DEFERRED` | wallet-service (VNPay sufficient for MVP) |
| SignalR hub for real-time updates | Senior Backend #3 | `DONE` | — |
| KDS push notifications via SignalR | Senior Backend #3 | `DONE` | SignalR hub |
| Payment UI — connect to real gateway | Senior Frontend | `DONE` | Payment backends |

View File

@@ -0,0 +1,123 @@
# EN: Catalog Service - Polymorphic Product & Category Management
# VI: Catalog Service - Quan ly San pham & Danh muc da hinh
apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-service
namespace: staging
labels:
app: catalog-service
environment: staging
platform: goodgo
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: catalog-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: catalog-service
environment: staging
spec:
containers:
- name: catalog-service
image: goodgo/catalog-service-net:staging
ports:
- containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: goodgo-config
- secretRef:
name: goodgo-secrets
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: CATALOG_DATABASE_URL
- name: IamService__ServiceName
value: "catalog-service"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: catalog-service
namespace: staging
labels:
app: catalog-service
environment: staging
spec:
selector:
app: catalog-service
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: catalog-service-hpa
namespace: staging
labels:
app: catalog-service
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: catalog-service
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -0,0 +1,57 @@
# EN: Shared configuration for all GoodGo staging services
# VI: Cau hinh chung cho tat ca cac service staging cua GoodGo
apiVersion: v1
kind: ConfigMap
metadata:
name: goodgo-config
namespace: staging
labels:
environment: staging
platform: goodgo
data:
# EN: ASP.NET Core Configuration
# VI: Cau hinh ASP.NET Core
ASPNETCORE_ENVIRONMENT: "Staging"
ASPNETCORE_URLS: "http://+:8080"
# EN: JWT Configuration (shared across all services)
# VI: Cau hinh JWT (dung chung cho tat ca services)
Jwt__Authority: "http://iam-service:8080"
Jwt__Audience: "goodgo-api"
Jwt__RequireHttpsMetadata: "false"
# EN: Service Discovery URLs (K8s DNS: {service-name}.staging.svc.cluster.local)
# VI: URL tim kiem service (K8s DNS: {service-name}.staging.svc.cluster.local)
IamService__BaseUrl: "http://iam-service:8080"
MerchantService__BaseUrl: "http://merchant-service:8080"
CatalogService__BaseUrl: "http://catalog-service:8080"
OrderService__BaseUrl: "http://order-service:8080"
InventoryService__BaseUrl: "http://inventory-service:8080"
WalletService__BaseUrl: "http://wallet-service:8080"
StorageService__BaseUrl: "http://storage-service:8080"
FnbEngine__BaseUrl: "http://fnb-engine:8080"
# EN: Redis Configuration
# VI: Cau hinh Redis
Redis__Host: "redis"
Redis__Port: "6379"
Redis__Database: "0"
# EN: CORS Configuration
# VI: Cau hinh CORS
Cors__AllowedOrigins: "https://pos.staging.goodgo.vn,https://staging.goodgo.vn,https://admin.staging.goodgo.vn"
# EN: Logging
# VI: Ghi log
Serilog__MinimumLevel__Default: "Information"
Serilog__MinimumLevel__Override__Microsoft: "Warning"
Serilog__MinimumLevel__Override__System: "Warning"
# EN: Feature Flags
# VI: Tinh nang bat/tat
Features__SwaggerEnabled: "true"
Features__DetailedErrors: "false"
# EN: API Version
# VI: Phien ban API
API_VERSION: "v1"

View File

@@ -0,0 +1,130 @@
# EN: FnB Engine - Table, Session, Kitchen & Reservation Management (SignalR for Kitchen Display)
# VI: FnB Engine - Quan ly Ban, Phien, Bep & Dat ban (SignalR cho Man hinh bep)
apiVersion: apps/v1
kind: Deployment
metadata:
name: fnb-engine
namespace: staging
labels:
app: fnb-engine
environment: staging
platform: goodgo
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: fnb-engine
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: fnb-engine
environment: staging
spec:
containers:
- name: fnb-engine
image: goodgo/fnb-engine-net:staging
ports:
- containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: goodgo-config
- secretRef:
name: goodgo-secrets
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: FNB_DATABASE_URL
- name: IamService__ServiceName
value: "fnb-engine"
# EN: Redis for SignalR backplane (Kitchen Display)
# VI: Redis cho SignalR backplane (Man hinh bep)
- name: ConnectionStrings__Redis
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: ConnectionStrings__Redis
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: fnb-engine
namespace: staging
labels:
app: fnb-engine
environment: staging
spec:
selector:
app: fnb-engine
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: fnb-engine-hpa
namespace: staging
labels:
app: fnb-engine
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: fnb-engine
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -1,14 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: iam-service-config
namespace: staging
data:
NODE_ENV: "staging"
PORT: "5001"
API_VERSION: "v1"
CORS_ORIGIN: "https://staging.goodgo.vn"
LOG_LEVEL: "info"
SERVICE_NAME: "iam-service"
# Note: DATABASE_URL is stored in secrets (iam-service-secrets)
# DATABASE_URL should point to Neon staging branch

View File

@@ -1,38 +1,57 @@
# EN: IAM Service - Identity & Access Management (Duende IdentityServer, JWT, RBAC, MFA)
# VI: IAM Service - Quan ly Danh tinh & Truy cap (Duende IdentityServer, JWT, RBAC, MFA)
apiVersion: apps/v1
kind: Deployment
metadata:
name: iam-service
namespace: staging
labels:
app: iam-service
environment: staging
platform: goodgo
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: iam-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: iam-service
environment: staging
spec:
containers:
- name: iam-service
image: goodgo/iam-service:latest
image: goodgo/iam-service-net:staging
ports:
- containerPort: 5001
- containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: goodgo-config
- secretRef:
name: goodgo-secrets
env:
- name: NODE_ENV
value: "staging"
- name: DATABASE_URL
# EN: Override service-specific database URL
# VI: Override URL database rieng cho service
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: iam-service-secrets
key: database-url
- name: JWT_SECRET
name: goodgo-secrets
key: IAM_DATABASE_URL
- name: IamService__ServiceName
value: "iam-service"
- name: IdentityServer__IssuerUri
valueFrom:
secretKeyRef:
name: iam-service-secrets
key: jwt-secret
- name: REDIS_HOST
value: "redis-service"
name: goodgo-secrets
key: IdentityServer__IssuerUri
resources:
requests:
memory: "256Mi"
@@ -43,27 +62,69 @@ spec:
livenessProbe:
httpGet:
path: /health/live
port: 5001
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 5001
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: iam-service
namespace: staging
labels:
app: iam-service
environment: staging
spec:
selector:
app: iam-service
ports:
- protocol: TCP
port: 5001
targetPort: 5001
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: iam-service-hpa
namespace: staging
labels:
app: iam-service
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: iam-service
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -1,69 +1,303 @@
# EN: Traefik Ingress for GoodGo Staging - API Gateway routing
# VI: Traefik Ingress cho GoodGo Staging - Dinh tuyen API Gateway
#
# Routes match infra/traefik/dynamic/routes.yml for consistency
# Host: api.staging.goodgo.vn (API), pos.staging.goodgo.vn (POS Frontend)
# =============================================================================
# API Ingress - Backend services
# =============================================================================
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: staging
labels:
environment: staging
platform: goodgo
annotations:
traefik.ingress.kubernetes.io/rule-type: PathPrefix
# EN: Traefik Ingress class
# VI: Ingress class cua Traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
# EN: Rate limiting middleware
# VI: Middleware gioi han toc do
traefik.ingress.kubernetes.io/router.middlewares: staging-cors@kubernetescrd,staging-secure-headers@kubernetescrd
# EN: cert-manager TLS
# VI: TLS bang cert-manager
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
ingressClassName: traefik
tls:
- hosts:
- api.staging.goodgo.vn
secretName: api-staging-tls
rules:
- host: api.staging.goodgo.vn
http:
paths:
# ===== IAM Service =====
- path: /api/v1/auth
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 5001
number: 8080
- path: /api/v1/users
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 5001
number: 8080
- path: /api/v1/identity
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 5001
number: 8080
- path: /api/v1/access
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 5001
number: 8080
- path: /api/v1/governance
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 5001
number: 8080
- path: /api/v1/rbac
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 5001
number: 8080
- path: /api/v1/mfa
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 5001
number: 8080
- path: /api/v1/sessions
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 5001
number: 8080
# EN: IdentityServer OIDC endpoints
# VI: IdentityServer OIDC endpoints
- path: /connect
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 8080
- path: /.well-known
pathType: Prefix
backend:
service:
name: iam-service
port:
number: 8080
# ===== Merchant Service =====
- path: /api/v1/merchants
pathType: Prefix
backend:
service:
name: merchant-service
port:
number: 8080
- path: /api/v1/shops
pathType: Prefix
backend:
service:
name: merchant-service
port:
number: 8080
- path: /api/v1/subscriptions
pathType: Prefix
backend:
service:
name: merchant-service
port:
number: 8080
# ===== Order Service =====
- path: /api/v1/orders
pathType: Prefix
backend:
service:
name: order-service
port:
number: 8080
# EN: POS/KDS SignalR Hub (WebSocket)
# VI: POS/KDS SignalR Hub (WebSocket)
- path: /hubs/pos
pathType: Prefix
backend:
service:
name: order-service
port:
number: 8080
# ===== FnB Engine =====
- path: /api/v1/kitchen
pathType: Prefix
backend:
service:
name: fnb-engine
port:
number: 8080
- path: /api/v1/fnb
pathType: Prefix
backend:
service:
name: fnb-engine
port:
number: 8080
- path: /api/v1/tables
pathType: Prefix
backend:
service:
name: fnb-engine
port:
number: 8080
- path: /api/v1/sessions
pathType: Prefix
backend:
service:
name: fnb-engine
port:
number: 8080
# EN: Kitchen Display SignalR Hub
# VI: SignalR Hub Man hinh bep
- path: /hubs/kitchen
pathType: Prefix
backend:
service:
name: fnb-engine
port:
number: 8080
# ===== Inventory Service =====
- path: /api/v1/inventory
pathType: Prefix
backend:
service:
name: inventory-service
port:
number: 8080
- path: /api/v1/stock
pathType: Prefix
backend:
service:
name: inventory-service
port:
number: 8080
# ===== Wallet Service =====
- path: /api/v1/wallets
pathType: Prefix
backend:
service:
name: wallet-service
port:
number: 8080
- path: /api/v1/points
pathType: Prefix
backend:
service:
name: wallet-service
port:
number: 8080
- path: /api/v1/payments
pathType: Prefix
backend:
service:
name: wallet-service
port:
number: 8080
# ===== Catalog Service =====
- path: /api/v1/products
pathType: Prefix
backend:
service:
name: catalog-service
port:
number: 8080
- path: /api/v1/categories
pathType: Prefix
backend:
service:
name: catalog-service
port:
number: 8080
# ===== Storage Service =====
- path: /api/v1/files
pathType: Prefix
backend:
service:
name: storage-service
port:
number: 8080
- path: /api/v1/quota
pathType: Prefix
backend:
service:
name: storage-service
port:
number: 8080
- path: /api/v1/uploads
pathType: Prefix
backend:
service:
name: storage-service
port:
number: 8080
---
# =============================================================================
# POS Frontend Ingress
# =============================================================================
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pos-web-ingress
namespace: staging
labels:
environment: staging
platform: goodgo
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
ingressClassName: traefik
tls:
- hosts:
- pos.staging.goodgo.vn
secretName: pos-staging-tls
rules:
- host: pos.staging.goodgo.vn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: pos-web
port:
number: 8080

View File

@@ -0,0 +1,123 @@
# EN: Inventory Service - Stock Management & Deduction (Retail + FnB)
# VI: Inventory Service - Quan ly Ton kho & Tru kho (Retail + FnB)
apiVersion: apps/v1
kind: Deployment
metadata:
name: inventory-service
namespace: staging
labels:
app: inventory-service
environment: staging
platform: goodgo
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: inventory-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: inventory-service
environment: staging
spec:
containers:
- name: inventory-service
image: goodgo/inventory-service-net:staging
ports:
- containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: goodgo-config
- secretRef:
name: goodgo-secrets
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: INVENTORY_DATABASE_URL
- name: IamService__ServiceName
value: "inventory-service"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: inventory-service
namespace: staging
labels:
app: inventory-service
environment: staging
spec:
selector:
app: inventory-service
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: inventory-service-hpa
namespace: staging
labels:
app: inventory-service
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: inventory-service
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -0,0 +1,123 @@
# EN: Merchant Service - Merchant & Shop Management
# VI: Merchant Service - Quan ly Merchant & Shop
apiVersion: apps/v1
kind: Deployment
metadata:
name: merchant-service
namespace: staging
labels:
app: merchant-service
environment: staging
platform: goodgo
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: merchant-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: merchant-service
environment: staging
spec:
containers:
- name: merchant-service
image: goodgo/merchant-service-net:staging
ports:
- containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: goodgo-config
- secretRef:
name: goodgo-secrets
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: MERCHANT_DATABASE_URL
- name: IamService__ServiceName
value: "merchant-service"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: merchant-service
namespace: staging
labels:
app: merchant-service
environment: staging
spec:
selector:
app: merchant-service
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: merchant-service-hpa
namespace: staging
labels:
app: merchant-service
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: merchant-service
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -0,0 +1,10 @@
# EN: Staging namespace for GoodGo Platform
# VI: Namespace staging cho GoodGo Platform
apiVersion: v1
kind: Namespace
metadata:
name: staging
labels:
environment: staging
platform: goodgo
managed-by: kubectl

View File

@@ -0,0 +1,131 @@
# EN: Order Service - Order Processing & POS API (SignalR WebSocket for POS/KDS)
# VI: Order Service - Xu ly Order & POS API (SignalR WebSocket cho POS/KDS)
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: staging
labels:
app: order-service
environment: staging
platform: goodgo
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: order-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: order-service
environment: staging
spec:
containers:
- name: order-service
image: goodgo/order-service-net:staging
ports:
- containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: goodgo-config
- secretRef:
name: goodgo-secrets
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: ORDER_DATABASE_URL
- name: IamService__ServiceName
value: "order-service"
# EN: Inter-service communication
# VI: Giao tiep giua cac service
- name: CatalogService__BaseUrl
value: "http://catalog-service:8080"
- name: InventoryService__BaseUrl
value: "http://inventory-service:8080"
- name: WalletService__BaseUrl
value: "http://wallet-service:8080"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: order-service
namespace: staging
labels:
app: order-service
environment: staging
spec:
selector:
app: order-service
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
namespace: staging
labels:
app: order-service
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -0,0 +1,143 @@
# EN: POS Web Client - Blazor WebAssembly Hosted (TPOS multi-vertical)
# VI: POS Web Client - Blazor WebAssembly Hosted (TPOS da nganh doc)
apiVersion: apps/v1
kind: Deployment
metadata:
name: pos-web
namespace: staging
labels:
app: pos-web
environment: staging
platform: goodgo
tier: frontend
spec:
replicas: 2
selector:
matchLabels:
app: pos-web
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: pos-web
environment: staging
spec:
containers:
- name: pos-web
image: goodgo/web-client-tpos-net:staging
ports:
- containerPort: 8080
protocol: TCP
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Staging"
- name: ASPNETCORE_URLS
value: "http://+:8080"
# EN: API Gateway URL for backend communication
# VI: URL API Gateway de giao tiep voi backend
- name: ApiSettings__GatewayUrl
value: "https://api.staging.goodgo.vn"
# EN: IAM Service for auth
# VI: IAM Service cho xac thuc
- name: IamService__BaseUrl
value: "http://iam-service:8080"
# EN: YARP Reverse Proxy cluster addresses (K8s internal DNS)
# VI: Dia chi cluster YARP Reverse Proxy (K8s internal DNS)
- name: ReverseProxy__Clusters__iam-cluster__Destinations__destination1__Address
value: "http://iam-service:8080"
- name: ReverseProxy__Clusters__merchant-cluster__Destinations__destination1__Address
value: "http://merchant-service:8080"
- name: ReverseProxy__Clusters__catalog-cluster__Destinations__destination1__Address
value: "http://catalog-service:8080"
- name: ReverseProxy__Clusters__order-cluster__Destinations__destination1__Address
value: "http://order-service:8080"
# EN: BFF HTTP Proxy - Forward requests to microservice APIs
# VI: BFF HTTP Proxy - Chuyen tiep request sang microservice APIs
- name: MerchantService__BaseUrl
value: "http://merchant-service:8080"
- name: CatalogService__BaseUrl
value: "http://catalog-service:8080"
- name: OrderService__BaseUrl
value: "http://order-service:8080"
- name: InventoryService__BaseUrl
value: "http://inventory-service:8080"
- name: WalletService__BaseUrl
value: "http://wallet-service:8080"
- name: FnbEngine__BaseUrl
value: "http://fnb-engine:8080"
- name: StorageService__BaseUrl
value: "http://storage-service:8080"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: pos-web
namespace: staging
labels:
app: pos-web
environment: staging
spec:
selector:
app: pos-web
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: pos-web-hpa
namespace: staging
labels:
app: pos-web
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: pos-web
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75

View File

@@ -0,0 +1,114 @@
# EN: Redis - Cache & SignalR Backplane for staging
# VI: Redis - Cache & SignalR Backplane cho staging
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: staging
labels:
app: redis
environment: staging
platform: goodgo
tier: infrastructure
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
environment: staging
spec:
containers:
- name: redis
image: redis:7-alpine
command:
- redis-server
- "--requirepass"
- "$(REDIS_PASSWORD)"
- "--maxmemory"
- "256mb"
- "--maxmemory-policy"
- "allkeys-lru"
- "--appendonly"
- "yes"
ports:
- containerPort: 6379
protocol: TCP
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: Redis__Password
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "250m"
livenessProbe:
exec:
command:
- redis-cli
- -a
- "$(REDIS_PASSWORD)"
- ping
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- redis-cli
- -a
- "$(REDIS_PASSWORD)"
- ping
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-pvc
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: staging
labels:
app: redis
environment: staging
spec:
selector:
app: redis
ports:
- name: redis
protocol: TCP
port: 6379
targetPort: 6379
type: ClusterIP
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-pvc
namespace: staging
labels:
app: redis
environment: staging
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

View File

@@ -0,0 +1,77 @@
# EN: Shared secrets for all GoodGo staging services
# VI: Secrets dung chung cho tat ca cac service staging cua GoodGo
#
# IMPORTANT: This file contains PLACEHOLDER values only.
# DO NOT commit real credentials to Git.
#
# Create secrets using kubectl:
# kubectl create secret generic goodgo-secrets \
# --from-literal=ConnectionStrings__DefaultConnection='postgresql://...' \
# --from-literal=Jwt__Secret='...' \
# -n staging
#
# Or use sealed-secrets / external-secrets operator in production.
#
# GitHub Secrets used in CI/CD:
# - NEON_IAM_DATABASE_URL_STAGING
# - NEON_MERCHANT_DATABASE_URL_STAGING
# - NEON_ORDER_DATABASE_URL_STAGING
# - NEON_FNB_DATABASE_URL_STAGING
# - NEON_INVENTORY_DATABASE_URL_STAGING
# - NEON_WALLET_DATABASE_URL_STAGING
# - NEON_CATALOG_DATABASE_URL_STAGING
# - NEON_STORAGE_DATABASE_URL_STAGING
# - JWT_SECRET_STAGING
# - JWT_REFRESH_SECRET_STAGING
# - REDIS_PASSWORD_STAGING
# - MINIO_ACCESS_KEY_STAGING
# - MINIO_SECRET_KEY_STAGING
# - RABBITMQ_PASSWORD_STAGING
apiVersion: v1
kind: Secret
metadata:
name: goodgo-secrets
namespace: staging
labels:
environment: staging
platform: goodgo
type: Opaque
stringData:
# EN: JWT Secrets (use strong random strings, min 32 characters)
# VI: JWT Secrets (su dung chuoi ngau nhien manh, toi thieu 32 ky tu)
Jwt__Secret: "PLACEHOLDER-staging-jwt-secret-min-32-chars"
Jwt__RefreshSecret: "PLACEHOLDER-staging-refresh-secret-min-32-chars"
# EN: IdentityServer Issuer
# VI: IdentityServer Issuer
IdentityServer__IssuerUri: "https://api.staging.goodgo.vn"
# EN: Neon PostgreSQL Connection Strings (per-service databases)
# VI: Chuoi ket noi Neon PostgreSQL (database rieng cho tung service)
# Format: postgresql://user:password@ep-xxx.region.neon.tech/dbname?sslmode=require
IAM_DATABASE_URL: "PLACEHOLDER-postgresql://user:pass@ep-xxx.region.neon.tech/iam_staging?sslmode=require"
MERCHANT_DATABASE_URL: "PLACEHOLDER-postgresql://user:pass@ep-xxx.region.neon.tech/merchant_staging?sslmode=require"
ORDER_DATABASE_URL: "PLACEHOLDER-postgresql://user:pass@ep-xxx.region.neon.tech/order_staging?sslmode=require"
FNB_DATABASE_URL: "PLACEHOLDER-postgresql://user:pass@ep-xxx.region.neon.tech/fnb_staging?sslmode=require"
INVENTORY_DATABASE_URL: "PLACEHOLDER-postgresql://user:pass@ep-xxx.region.neon.tech/inventory_staging?sslmode=require"
WALLET_DATABASE_URL: "PLACEHOLDER-postgresql://user:pass@ep-xxx.region.neon.tech/wallet_staging?sslmode=require"
CATALOG_DATABASE_URL: "PLACEHOLDER-postgresql://user:pass@ep-xxx.region.neon.tech/catalog_staging?sslmode=require"
STORAGE_DATABASE_URL: "PLACEHOLDER-postgresql://user:pass@ep-xxx.region.neon.tech/storage_staging?sslmode=require"
# EN: Redis Password
# VI: Mat khau Redis
Redis__Password: "PLACEHOLDER-redis-password"
ConnectionStrings__Redis: "redis:6379,password=PLACEHOLDER-redis-password"
# EN: MinIO / S3 Storage Credentials
# VI: Thong tin xac thuc MinIO / S3
Storage__MinIO__AccessKey: "PLACEHOLDER-minio-access-key"
Storage__MinIO__SecretKey: "PLACEHOLDER-minio-secret-key"
Storage__MinIO__Endpoint: "minio.staging.goodgo.vn"
# EN: RabbitMQ Credentials
# VI: Thong tin xac thuc RabbitMQ
RabbitMQ__Host: "rabbitmq"
RabbitMQ__Username: "goodgo"
RabbitMQ__Password: "PLACEHOLDER-rabbitmq-password"

View File

@@ -1,34 +1,56 @@
# Kubernetes Secrets Template for Staging
# DO NOT commit actual secrets to Git
# Use this as a template to create secrets
# Create secret using kubectl:
# kubectl create secret generic iam-service-secrets \
# --from-literal=database-url='postgresql://user:pass@ep-xxx.region.neon.tech/dbname?sslmode=require&pgbouncer=true' \
# --from-literal=jwt-secret='your-staging-jwt-secret-min-32-chars' \
# --from-literal=jwt-refresh-secret='your-staging-refresh-secret-min-32-chars' \
# --from-literal=redis-password='' \
# EN: Kubernetes Secrets Template for GoodGo Staging
# VI: Template Secrets Kubernetes cho GoodGo Staging
#
# DO NOT commit actual secrets to Git.
# Use this as a template to create secrets via kubectl.
#
# =============================================================================
# Option 1: Create secrets using kubectl (recommended for staging)
# =============================================================================
#
# kubectl create secret generic goodgo-secrets \
# --from-literal=Jwt__Secret='your-staging-jwt-secret-min-32-chars' \
# --from-literal=Jwt__RefreshSecret='your-staging-refresh-secret-min-32-chars' \
# --from-literal=IdentityServer__IssuerUri='https://api.staging.goodgo.vn' \
# --from-literal=IAM_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/iam_staging?sslmode=require' \
# --from-literal=MERCHANT_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/merchant_staging?sslmode=require' \
# --from-literal=ORDER_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/order_staging?sslmode=require' \
# --from-literal=FNB_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/fnb_staging?sslmode=require' \
# --from-literal=INVENTORY_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/inventory_staging?sslmode=require' \
# --from-literal=WALLET_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/wallet_staging?sslmode=require' \
# --from-literal=CATALOG_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/catalog_staging?sslmode=require' \
# --from-literal=STORAGE_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/storage_staging?sslmode=require' \
# --from-literal=Redis__Password='your-redis-password' \
# --from-literal=ConnectionStrings__Redis='redis:6379,password=your-redis-password' \
# --from-literal=Storage__MinIO__Endpoint='minio.staging.goodgo.vn' \
# --from-literal=Storage__MinIO__AccessKey='your-minio-access-key' \
# --from-literal=Storage__MinIO__SecretKey='your-minio-secret-key' \
# --from-literal=RabbitMQ__Host='rabbitmq' \
# --from-literal=RabbitMQ__Username='goodgo' \
# --from-literal=RabbitMQ__Password='your-rabbitmq-password' \
# -n staging
# Or use GitHub Secrets in CI/CD:
# - NEON_DATABASE_URL_STAGING
# - JWT_SECRET_STAGING
# - JWT_REFRESH_SECRET_STAGING
apiVersion: v1
kind: Secret
metadata:
name: iam-service-secrets
namespace: staging
type: Opaque
stringData:
# Neon Database URL (Staging branch)
# Format: postgresql://user:password@ep-xxx.region.neon.tech/dbname?sslmode=require&pgbouncer=true
database-url: "postgresql://user:password@ep-xxx.region.neon.tech/dbname?sslmode=require&pgbouncer=true"
# JWT Secrets (use strong random strings, min 32 characters)
jwt-secret: "your-staging-jwt-secret-min-32-chars"
jwt-refresh-secret: "your-staging-refresh-secret-min-32-chars"
# Redis (if password protected)
redis-password: ""
#
# =============================================================================
# Option 2: Use GitHub Secrets in CI/CD (for automated deployments)
# =============================================================================
#
# Required GitHub Secrets:
# - KUBECONFIG_STAGING (base64 encoded kubeconfig)
# - DOCKER_USERNAME / DOCKER_PASSWORD
# - NEON_IAM_DATABASE_URL_STAGING
# - NEON_MERCHANT_DATABASE_URL_STAGING
# - NEON_ORDER_DATABASE_URL_STAGING
# - NEON_FNB_DATABASE_URL_STAGING
# - NEON_INVENTORY_DATABASE_URL_STAGING
# - NEON_WALLET_DATABASE_URL_STAGING
# - NEON_CATALOG_DATABASE_URL_STAGING
# - NEON_STORAGE_DATABASE_URL_STAGING
# - JWT_SECRET_STAGING
# - JWT_REFRESH_SECRET_STAGING
# - REDIS_PASSWORD_STAGING
# - MINIO_ACCESS_KEY_STAGING
# - MINIO_SECRET_KEY_STAGING
#
# =============================================================================
# Option 3: Use sealed-secrets or external-secrets operator (recommended for production)
# =============================================================================

View File

@@ -0,0 +1,144 @@
# EN: Storage Service - File Storage Management (MinIO S3-compatible)
# VI: Storage Service - Quan ly Luu tru File (MinIO tuong thich S3)
apiVersion: apps/v1
kind: Deployment
metadata:
name: storage-service
namespace: staging
labels:
app: storage-service
environment: staging
platform: goodgo
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: storage-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: storage-service
environment: staging
spec:
containers:
- name: storage-service
image: goodgo/storage-service-net:staging
ports:
- containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: goodgo-config
- secretRef:
name: goodgo-secrets
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: STORAGE_DATABASE_URL
- name: IamService__ServiceName
value: "storage-service"
- name: Storage__Provider
value: "minio"
- name: Storage__DefaultBucket
value: "goodgo-staging"
- name: Storage__MinIO__UseSSL
value: "true"
- name: Storage__MinIO__Endpoint
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: Storage__MinIO__Endpoint
- name: Storage__MinIO__AccessKey
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: Storage__MinIO__AccessKey
- name: Storage__MinIO__SecretKey
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: Storage__MinIO__SecretKey
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: storage-service
namespace: staging
labels:
app: storage-service
environment: staging
spec:
selector:
app: storage-service
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: storage-service-hpa
namespace: staging
labels:
app: storage-service
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: storage-service
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -0,0 +1,123 @@
# EN: Wallet Service - Wallet & Payment Management
# VI: Wallet Service - Quan ly Vi & Thanh toan
apiVersion: apps/v1
kind: Deployment
metadata:
name: wallet-service
namespace: staging
labels:
app: wallet-service
environment: staging
platform: goodgo
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: wallet-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: wallet-service
environment: staging
spec:
containers:
- name: wallet-service
image: goodgo/wallet-service-net:staging
ports:
- containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: goodgo-config
- secretRef:
name: goodgo-secrets
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: goodgo-secrets
key: WALLET_DATABASE_URL
- name: IamService__ServiceName
value: "wallet-service"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
name: wallet-service
namespace: staging
labels:
app: wallet-service
environment: staging
spec:
selector:
app: wallet-service
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: wallet-service-hpa
namespace: staging
labels:
app: wallet-service
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: wallet-service
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 75
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -1,21 +1,155 @@
#!/bin/bash
# EN: Deploy GoodGo Platform MVP services to Kubernetes staging environment
# VI: Trien khai cac service MVP cua GoodGo Platform len moi truong staging Kubernetes
#
# Prerequisites:
# - kubectl configured with staging cluster access (KUBECONFIG env var)
# - Docker images pushed to Docker Hub (goodgo/*:staging)
# - Secrets created via kubectl (see secrets.yaml for template)
#
# Usage:
# export KUBECONFIG=/path/to/kubeconfig
# ./scripts/deploy/deploy-staging.sh
# ./scripts/deploy/deploy-staging.sh --service iam-service # Deploy single service
# ./scripts/deploy/deploy-staging.sh --dry-run # Dry run mode
set -e
set -euo pipefail
echo "🚀 Deploying to staging..."
# EN: Color output helpers
# VI: Ham ho tro mau output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# EN: Verify KUBECONFIG environment variable is set
# VI: Xác minh biến môi trường KUBECONFIG đã được thiết lập
if [ -z "$KUBECONFIG" ]; then
echo "❌ KUBECONFIG environment variable not set"
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/deployments/staging/kubernetes"
NAMESPACE="staging"
DRY_RUN=""
SINGLE_SERVICE=""
# EN: Parse arguments
# VI: Phan tich tham so
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN="--dry-run=client"
echo -e "${YELLOW}[DRY RUN] No changes will be applied${NC}"
shift
;;
--service)
SINGLE_SERVICE="$2"
shift 2
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
done
# EN: Verify KUBECONFIG
# VI: Xac minh KUBECONFIG
if [ -z "${KUBECONFIG:-}" ]; then
echo -e "${RED}KUBECONFIG environment variable not set${NC}"
echo "Usage: export KUBECONFIG=/path/to/kubeconfig && $0"
exit 1
fi
# EN: Apply Kubernetes configurations and wait for rollout
# VI: Áp dụng cấu hình Kubernetes và đợi quá trình rollout hoàn tất
kubectl apply -f deployments/staging/kubernetes/
# EN: Verify kubectl connectivity
# VI: Xac minh ket noi kubectl
echo -e "${BLUE}Verifying kubectl connectivity...${NC}"
if ! kubectl cluster-info &>/dev/null; then
echo -e "${RED}Cannot connect to Kubernetes cluster. Check KUBECONFIG.${NC}"
exit 1
fi
echo "⏳ Waiting for rollout..."
kubectl rollout status deployment -n staging --timeout=90s || echo "⚠️ Some deployments might still be updating"
echo -e "${GREEN}=== GoodGo Platform - Staging Deployment ===${NC}"
echo -e "${BLUE}Namespace: ${NAMESPACE}${NC}"
echo -e "${BLUE}Manifests: ${DEPLOY_DIR}${NC}"
echo ""
echo "✅ Deployment completed!"
# EN: Step 1 - Apply namespace
# VI: Buoc 1 - Tao namespace
echo -e "${BLUE}[1/5] Applying namespace...${NC}"
kubectl apply -f "${DEPLOY_DIR}/namespace.yaml" ${DRY_RUN}
# EN: Step 2 - Apply shared configuration and secrets
# VI: Buoc 2 - Ap dung cau hinh chung va secrets
echo -e "${BLUE}[2/5] Applying configuration and secrets...${NC}"
kubectl apply -f "${DEPLOY_DIR}/configmap.yaml" ${DRY_RUN}
# EN: Only apply secrets.yaml if it doesn't contain PLACEHOLDER values
# VI: Chi ap dung secrets.yaml neu khong chua gia tri PLACEHOLDER
if grep -q "PLACEHOLDER" "${DEPLOY_DIR}/secrets.yaml"; then
echo -e "${YELLOW} WARNING: secrets.yaml contains PLACEHOLDER values.${NC}"
echo -e "${YELLOW} Skipping secrets apply. Use kubectl create secret or sealed-secrets.${NC}"
echo -e "${YELLOW} See secrets.yaml.example for reference.${NC}"
else
kubectl apply -f "${DEPLOY_DIR}/secrets.yaml" ${DRY_RUN}
fi
# EN: Step 3 - Deploy infrastructure (Redis)
# VI: Buoc 3 - Trien khai ha tang (Redis)
echo -e "${BLUE}[3/5] Deploying infrastructure...${NC}"
if [ -z "$SINGLE_SERVICE" ] || [ "$SINGLE_SERVICE" = "redis" ]; then
kubectl apply -f "${DEPLOY_DIR}/redis.yaml" ${DRY_RUN}
echo -e "${GREEN} redis deployed${NC}"
fi
# EN: Step 4 - Deploy backend services
# VI: Buoc 4 - Trien khai cac service backend
echo -e "${BLUE}[4/5] Deploying services...${NC}"
MVP_SERVICES=(
"iam-service"
"merchant-service"
"catalog-service"
"order-service"
"fnb-engine"
"inventory-service"
"wallet-service"
"storage-service"
"pos-web"
)
for svc in "${MVP_SERVICES[@]}"; do
if [ -z "$SINGLE_SERVICE" ] || [ "$SINGLE_SERVICE" = "$svc" ]; then
if [ -f "${DEPLOY_DIR}/${svc}.yaml" ]; then
kubectl apply -f "${DEPLOY_DIR}/${svc}.yaml" ${DRY_RUN}
echo -e "${GREEN} ${svc} deployed${NC}"
else
echo -e "${YELLOW} ${svc}.yaml not found, skipping${NC}"
fi
fi
done
# EN: Step 5 - Apply ingress routing
# VI: Buoc 5 - Ap dung dinh tuyen ingress
echo -e "${BLUE}[5/5] Applying ingress routing...${NC}"
if [ -z "$SINGLE_SERVICE" ]; then
kubectl apply -f "${DEPLOY_DIR}/ingress.yaml" ${DRY_RUN}
fi
echo ""
echo -e "${BLUE}Waiting for rollouts to complete...${NC}"
if [ -z "$DRY_RUN" ]; then
for svc in "${MVP_SERVICES[@]}"; do
if [ -z "$SINGLE_SERVICE" ] || [ "$SINGLE_SERVICE" = "$svc" ]; then
echo -n " ${svc}: "
if kubectl rollout status "deployment/${svc}" -n "${NAMESPACE}" --timeout=120s 2>/dev/null; then
echo -e "${GREEN}ready${NC}"
else
echo -e "${YELLOW}still updating (check: kubectl get pods -n ${NAMESPACE} -l app=${svc})${NC}"
fi
fi
done
fi
echo ""
echo -e "${GREEN}=== Staging deployment completed ===${NC}"
echo -e "${BLUE}API: https://api.staging.goodgo.vn${NC}"
echo -e "${BLUE}POS: https://pos.staging.goodgo.vn${NC}"
echo ""
echo -e "Verify: kubectl get pods -n ${NAMESPACE}"
echo -e "Logs: kubectl logs -n ${NAMESPACE} -l app=<service-name> -f"

View File

@@ -9,6 +9,7 @@ namespace OrderService.FunctionalTests.Controllers;
/// EN: Functional tests for health check endpoints.
/// VI: Functional tests cho các endpoint kiểm tra sức khỏe.
/// </summary>
[Collection("OrderService")]
public class HealthChecksControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;

View File

@@ -0,0 +1,776 @@
// EN: Functional tests for OrdersController covering the full order lifecycle.
// VI: Functional tests cho OrdersController bao phủ toàn bộ vòng đời order.
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Infrastructure;
using Xunit;
namespace OrderService.FunctionalTests.Controllers;
/// <summary>
/// EN: Functional tests for the Orders API endpoints.
/// Tests the full order lifecycle: create -> pay -> complete/cancel.
/// Uses InMemory database and mocked external services.
/// VI: Functional tests cho Orders API endpoints.
/// Test toàn bộ vòng đời order: tạo -> thanh toán -> hoàn thành/hủy.
/// Sử dụng InMemory database và mock external services.
/// </summary>
[Collection("OrderService")]
public class OrdersControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly CustomWebApplicationFactory _factory;
/// <summary>
/// EN: Default shop ID used in tests (matches TestAuthHandler.TestShopId).
/// VI: Shop ID mặc định sử dụng trong tests (khớp với TestAuthHandler.TestShopId).
/// </summary>
private static readonly Guid TestShopId = TestAuthHandler.TestShopId;
/// <summary>
/// EN: JSON serializer options matching the API's camelCase convention.
/// VI: Tùy chọn JSON serializer khớp với quy ước camelCase của API.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public OrdersControllerTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
}
// ============================================================
// EN: Helper methods
// VI: Các phương thức helper
// ============================================================
/// <summary>
/// EN: Create a valid order request payload.
/// VI: Tạo payload request order hợp lệ.
/// </summary>
private static object CreateValidOrderRequest(Guid? shopId = null, decimal unitPrice = 50_000m)
{
return new
{
shopId = shopId ?? TestShopId,
items = new[]
{
new
{
productId = Guid.NewGuid(),
productName = "Test Product",
productType = "Physical",
quantity = 2,
unitPrice = unitPrice,
trackInventory = true
}
}
};
}
/// <summary>
/// EN: Create an order via API and return the order ID.
/// VI: Tạo order qua API và trả về order ID.
/// </summary>
private async Task<Guid> CreateOrderAndGetIdAsync(Guid? shopId = null, decimal unitPrice = 50_000m)
{
var request = CreateValidOrderRequest(shopId, unitPrice);
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
return body.GetProperty("orderId").GetGuid();
}
/// <summary>
/// EN: Pay for an order via API (cash payment).
/// VI: Thanh toán order qua API (tiền mặt).
/// </summary>
private async Task PayOrderCashAsync(Guid orderId, decimal amountTendered = 200_000m)
{
var payRequest = new { paymentMethod = "cash", amountTendered = amountTendered };
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
response.EnsureSuccessStatusCode();
}
// ============================================================
// EN: CREATE ORDER tests
// VI: Tests TẠO ORDER
// ============================================================
[Fact]
public async Task CreateOrder_WithValidData_Returns201()
{
// Arrange
var request = CreateValidOrderRequest();
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("orderId").GetGuid().Should().NotBeEmpty();
body.GetProperty("totalAmount").GetDecimal().Should().Be(100_000m);
body.GetProperty("status").GetString().Should().Be("Validated");
}
[Fact]
public async Task CreateOrder_WithMultipleItems_Returns201WithCorrectTotal()
{
// Arrange
var request = new
{
shopId = TestShopId,
items = new[]
{
new { productId = Guid.NewGuid(), productName = "Item A", productType = "Physical", quantity = 1, unitPrice = 30_000m, trackInventory = true },
new { productId = Guid.NewGuid(), productName = "Item B", productType = "Service", quantity = 3, unitPrice = 15_000m, trackInventory = false },
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
// 30_000 * 1 + 15_000 * 3 = 75_000
body.GetProperty("totalAmount").GetDecimal().Should().Be(75_000m);
}
[Fact]
public async Task CreateOrder_WithEmptyShopId_ReturnsError()
{
// Arrange
var request = new
{
shopId = Guid.Empty,
items = new[]
{
new { productId = Guid.NewGuid(), productName = "Test", productType = "Physical", quantity = 1, unitPrice = 10_000m, trackInventory = true }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert — FluentValidation throws ValidationException via MediatR pipeline;
// Hellang ProblemDetails maps this to 500 (no explicit mapping for FluentValidation.ValidationException).
// 400 would be preferred, but requires ProblemDetails.Map<ValidationException> config.
response.IsSuccessStatusCode.Should().BeFalse();
response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.InternalServerError);
}
[Fact]
public async Task CreateOrder_WithNoItems_ReturnsError()
{
// Arrange
var request = new
{
shopId = TestShopId,
items = Array.Empty<object>()
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.InternalServerError);
}
[Fact]
public async Task CreateOrder_WithInvalidProductType_ReturnsError()
{
// Arrange
var request = new
{
shopId = TestShopId,
items = new[]
{
new { productId = Guid.NewGuid(), productName = "Test", productType = "InvalidType", quantity = 1, unitPrice = 10_000m, trackInventory = true }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.InternalServerError);
}
[Fact]
public async Task CreateOrder_WithZeroQuantity_ReturnsError()
{
// Arrange
var request = new
{
shopId = TestShopId,
items = new[]
{
new { productId = Guid.NewGuid(), productName = "Test", productType = "Physical", quantity = 0, unitPrice = 10_000m, trackInventory = true }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.InternalServerError);
}
[Fact]
public async Task CreateOrder_WithDiscount_Returns201WithDiscountedTotal()
{
// Arrange
var request = new
{
shopId = TestShopId,
items = new[]
{
new { productId = Guid.NewGuid(), productName = "Test Product", productType = "Physical", quantity = 2, unitPrice = 50_000m, trackInventory = true }
},
discountAmount = 20_000m,
discountType = "promotion",
discountReference = "PROMO-001"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
// 50_000 * 2 - 20_000 = 80_000
body.GetProperty("totalAmount").GetDecimal().Should().Be(80_000m);
}
// ============================================================
// EN: PAY ORDER tests
// VI: Tests THANH TOAN ORDER
// ============================================================
[Fact]
public async Task PayOrder_CashPayment_Returns200WithChange()
{
// Arrange
var orderId = await CreateOrderAndGetIdAsync(unitPrice: 50_000m);
var payRequest = new { paymentMethod = "cash", amountTendered = 200_000m };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
body.GetProperty("data").GetProperty("changeAmount").GetDecimal().Should().Be(100_000m);
body.GetProperty("data").GetProperty("transactionId").GetString().Should().StartWith("CASH-");
}
[Fact]
public async Task PayOrder_CardPayment_Returns200()
{
// Arrange
var orderId = await CreateOrderAndGetIdAsync();
var payRequest = new { paymentMethod = "card" };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
body.GetProperty("data").GetProperty("transactionId").GetString().Should().StartWith("CARD-");
}
[Fact]
public async Task PayOrder_OnlinePayment_Returns200WithPaymentUrl()
{
// Arrange
var orderId = await CreateOrderAndGetIdAsync();
var payRequest = new
{
paymentMethod = "vnpay",
returnUrl = "https://myshop.test/return"
};
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
body.GetProperty("data").GetProperty("paymentUrl").GetString().Should().StartWith("https://mock-gateway.test/pay");
body.GetProperty("data").GetProperty("status").GetString().Should().Be("PaymentPending");
}
[Fact]
public async Task PayOrder_CashWithInsufficientAmount_Returns400()
{
// Arrange
var orderId = await CreateOrderAndGetIdAsync(unitPrice: 50_000m);
// 2 items * 50_000 = 100_000, but tendering only 50_000
var payRequest = new { paymentMethod = "cash", amountTendered = 50_000m };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeFalse();
}
[Fact]
public async Task PayOrder_AlreadyPaid_Returns500()
{
// Arrange — create and pay an order
var orderId = await CreateOrderAndGetIdAsync();
await PayOrderCashAsync(orderId);
// Act — try to pay again
var payRequest = new { paymentMethod = "cash", amountTendered = 200_000m };
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
// Assert — should fail (order is already Processing, not Validated)
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.InternalServerError);
}
// ============================================================
// EN: COMPLETE ORDER tests
// VI: Tests HOAN THANH ORDER
// ============================================================
[Fact]
public async Task CompleteOrder_AfterPayment_Returns200()
{
// Arrange — create, pay (transitions to Processing)
var orderId = await CreateOrderAndGetIdAsync();
await PayOrderCashAsync(orderId);
// Act — complete the order
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/complete?shopId={TestShopId}",
new { });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
body.GetProperty("status").GetString().Should().Be("Completed");
}
[Fact]
public async Task CompleteOrder_WithoutPayment_Returns500()
{
// Arrange — create order but don't pay (status = Validated)
var orderId = await CreateOrderAndGetIdAsync();
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/complete?shopId={TestShopId}",
new { });
// Assert — should fail because order is not in Processing state
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.InternalServerError);
}
// ============================================================
// EN: CANCEL ORDER tests
// VI: Tests HUY ORDER
// ============================================================
[Fact]
public async Task CancelOrder_PendingOrder_Returns200()
{
// Arrange — create an order (status = Validated)
var orderId = await CreateOrderAndGetIdAsync();
// Act
var cancelRequest = new { reason = "Customer changed their mind" };
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/cancel?shopId={TestShopId}", cancelRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
body.GetProperty("status").GetString().Should().Be("Cancelled");
}
[Fact]
public async Task CancelOrder_AlreadyCompleted_Returns500()
{
// Arrange — create, pay, and complete the order
var orderId = await CreateOrderAndGetIdAsync();
await PayOrderCashAsync(orderId);
var completeResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/complete?shopId={TestShopId}",
new { });
completeResponse.EnsureSuccessStatusCode();
// Act — try to cancel a completed order
var cancelRequest = new { reason = "Too late" };
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/cancel?shopId={TestShopId}", cancelRequest);
// Assert — should fail (cannot cancel completed order)
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.InternalServerError);
}
[Fact]
public async Task CancelOrder_AlreadyCancelled_Returns500()
{
// Arrange — create and cancel an order
var orderId = await CreateOrderAndGetIdAsync();
var cancelRequest = new { reason = "First cancellation" };
var firstResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/cancel?shopId={TestShopId}", cancelRequest);
firstResponse.EnsureSuccessStatusCode();
// Act — try to cancel again
var secondCancelRequest = new { reason = "Second cancellation attempt" };
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/cancel?shopId={TestShopId}", secondCancelRequest);
// Assert — should fail (already cancelled)
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.InternalServerError);
}
[Fact]
public async Task CancelOrder_WithEmptyReason_ReturnsError()
{
// Arrange
var orderId = await CreateOrderAndGetIdAsync();
// Act
var cancelRequest = new { reason = "" };
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/cancel?shopId={TestShopId}", cancelRequest);
// Assert — FluentValidation rejects empty reason;
// mapped as 500 via ProblemDetails (no explicit ValidationException mapping).
response.IsSuccessStatusCode.Should().BeFalse();
response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.InternalServerError);
}
// ============================================================
// EN: PAYMENT CALLBACK tests
// VI: Tests CALLBACK THANH TOAN
// ============================================================
[Fact]
public async Task PaymentCallback_SuccessfulPayment_Returns200()
{
// Arrange — create order and initiate online payment (status -> PaymentPending)
var orderId = await CreateOrderAndGetIdAsync();
var payRequest = new
{
paymentMethod = "vnpay",
returnUrl = "https://myshop.test/return"
};
var payResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
payResponse.EnsureSuccessStatusCode();
// Act — simulate successful gateway callback
var callbackRequest = new
{
gatewayTransactionId = "VNP-TXN-12345",
isSuccess = true,
gatewayResponseCode = "00"
};
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/payment-callback", callbackRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
body.GetProperty("data").GetProperty("status").GetString().Should().Be("Processing");
}
[Fact]
public async Task PaymentCallback_FailedPayment_CancelsOrder()
{
// Arrange — create order and initiate online payment
var orderId = await CreateOrderAndGetIdAsync();
var payRequest = new
{
paymentMethod = "momo",
returnUrl = "https://myshop.test/return"
};
var payResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
payResponse.EnsureSuccessStatusCode();
// Act — simulate failed gateway callback
var callbackRequest = new
{
gatewayTransactionId = "MOMO-TXN-FAIL",
isSuccess = false,
gatewayResponseCode = "99"
};
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/payment-callback", callbackRequest);
// Assert — payment failed but callback endpoint succeeds, order is cancelled
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeFalse();
}
// ============================================================
// EN: FULL LIFECYCLE tests
// VI: Tests VONG DOI DAY DU
// ============================================================
[Fact]
public async Task FullLifecycle_CashPayment_CreatePayComplete()
{
// Step 1: Create order
var request = new
{
shopId = TestShopId,
items = new[]
{
new { productId = Guid.NewGuid(), productName = "Bia Saigon", productType = "Physical", quantity = 5, unitPrice = 15_000m, trackInventory = true },
new { productId = Guid.NewGuid(), productName = "Pho", productType = "PreparedFood", quantity = 2, unitPrice = 45_000m, trackInventory = false }
}
};
var createResponse = await _client.PostAsJsonAsync("/api/v1/orders", request);
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var createBody = await createResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
var orderId = createBody.GetProperty("orderId").GetGuid();
// 15_000 * 5 + 45_000 * 2 = 165_000
createBody.GetProperty("totalAmount").GetDecimal().Should().Be(165_000m);
createBody.GetProperty("status").GetString().Should().Be("Validated");
// Step 2: Pay with cash
var payRequest = new { paymentMethod = "cash", amountTendered = 200_000m };
var payResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
payResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var payBody = await payResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
payBody.GetProperty("success").GetBoolean().Should().BeTrue();
payBody.GetProperty("data").GetProperty("changeAmount").GetDecimal().Should().Be(35_000m);
// Step 3: Complete order
var completeResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/complete?shopId={TestShopId}", new { });
completeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var completeBody = await completeResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
completeBody.GetProperty("success").GetBoolean().Should().BeTrue();
completeBody.GetProperty("status").GetString().Should().Be("Completed");
}
[Fact]
public async Task FullLifecycle_OnlinePayment_CreatePayCallbackComplete()
{
// Step 1: Create order
var orderId = await CreateOrderAndGetIdAsync(unitPrice: 100_000m);
// Step 2: Initiate online payment
var payRequest = new
{
paymentMethod = "vnpay",
returnUrl = "https://myshop.test/return"
};
var payResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
payResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var payBody = await payResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
payBody.GetProperty("success").GetBoolean().Should().BeTrue();
payBody.GetProperty("data").GetProperty("paymentUrl").GetString().Should().Contain("mock-gateway");
payBody.GetProperty("data").GetProperty("status").GetString().Should().Be("PaymentPending");
// Step 3: Gateway callback (success)
var callbackRequest = new
{
gatewayTransactionId = "VNP-SUCCESS-001",
isSuccess = true,
gatewayResponseCode = "00"
};
var callbackResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/payment-callback", callbackRequest);
callbackResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var callbackBody = await callbackResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
callbackBody.GetProperty("success").GetBoolean().Should().BeTrue();
callbackBody.GetProperty("data").GetProperty("status").GetString().Should().Be("Processing");
// Step 4: Complete order
var completeResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/complete?shopId={TestShopId}", new { });
completeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var completeBody = await completeResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
completeBody.GetProperty("status").GetString().Should().Be("Completed");
}
// ============================================================
// EN: HEALTH CHECK tests
// VI: Tests KIEM TRA SUC KHOE
// ============================================================
[Fact]
public async Task HealthCheck_Live_Returns200()
{
// Act
var response = await _client.GetAsync("/health/live");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task HealthCheck_Root_Returns200()
{
// Act
var response = await _client.GetAsync("/health");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
// ============================================================
// EN: EDGE CASE tests
// VI: Tests TRUONG HOP CANH
// ============================================================
[Fact]
public async Task PayOrder_QrPayment_TreatedAsCard()
{
// Arrange
var orderId = await CreateOrderAndGetIdAsync();
var payRequest = new { paymentMethod = "qr" };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
body.GetProperty("data").GetProperty("transactionId").GetString().Should().StartWith("CARD-");
}
[Fact]
public async Task PayOrder_TransferPayment_TreatedAsCard()
{
// Arrange
var orderId = await CreateOrderAndGetIdAsync();
var payRequest = new { paymentMethod = "transfer" };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task CreateOrder_WithTableId_Returns201()
{
// Arrange
var tableId = Guid.NewGuid();
var request = new
{
shopId = TestShopId,
customerId = Guid.NewGuid(),
tableId = tableId,
items = new[]
{
new { productId = Guid.NewGuid(), productName = "Pho Bo", productType = "PreparedFood", quantity = 1, unitPrice = 55_000m, trackInventory = false }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("orderId").GetGuid().Should().NotBeEmpty();
}
[Fact]
public async Task PayOrder_CashExactAmount_ZeroChange()
{
// Arrange — order total = 2 * 50_000 = 100_000
var orderId = await CreateOrderAndGetIdAsync(unitPrice: 50_000m);
var payRequest = new { paymentMethod = "cash", amountTendered = 100_000m };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/orders/{orderId}/pay?shopId={TestShopId}", payRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
body.GetProperty("success").GetBoolean().Should().BeTrue();
body.GetProperty("data").GetProperty("changeAmount").GetDecimal().Should().Be(0m);
}
}

View File

@@ -1,56 +1,411 @@
// EN: Custom WebApplicationFactory for functional tests with full dependency mocking.
// VI: WebApplicationFactory tùy chỉnh cho functional tests với mock đầy đủ dependencies.
using System.Data;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OrderService.API.Hubs;
using OrderService.API.Infrastructure.Tenant;
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Domain.Strategies;
using OrderService.Infrastructure;
using OrderService.Infrastructure.ExternalServices;
using Serilog;
namespace OrderService.FunctionalTests;
/// <summary>
/// EN: Custom WebApplicationFactory for functional tests.
/// Replaces PostgreSQL with InMemory database, mocks external services,
/// and provides test authentication.
/// VI: WebApplicationFactory tùy chỉnh cho functional tests.
/// Thay thế PostgreSQL bằng InMemory database, mock external services,
/// và cung cấp test authentication.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
/// <summary>
/// EN: Unique database name per factory instance to isolate test data.
/// VI: Tên database duy nhất cho mỗi factory instance để cách ly dữ liệu test.
/// </summary>
private readonly string _databaseName = $"TestDatabase_{Guid.NewGuid()}";
public CustomWebApplicationFactory()
{
// EN: Reset Serilog static logger before host creation to prevent "logger is already frozen".
// Program.cs calls CreateBootstrapLogger() which creates a ReloadableLogger.
// UseSerilog() then freezes it. On subsequent factory instances in the same
// test process, Log.Logger is already frozen. Reset it to a fresh bootstrap logger.
// VI: Reset Serilog static logger trước khi tạo host để ngăn "logger is already frozen".
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console()
.CreateBootstrapLogger();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// EN: Remove the existing DbContext registration
// VI: Xóa đăng ký DbContext hiện tại
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<OrderContext>));
// ============================================================
// EN: Replace DbContext with InMemory database.
// Must remove ALL EF Core / Npgsql service registrations
// to avoid "multiple database providers" error.
// VI: Thay thế DbContext bằng InMemory database.
// Phải xóa TẤT CẢ đăng ký EF Core / Npgsql services
// để tránh lỗi "multiple database providers".
// ============================================================
var descriptorsToRemove = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<OrderContext>) ||
d.ServiceType == typeof(OrderContext) ||
d.ServiceType.FullName?.Contains("Npgsql") == true ||
d.ServiceType.FullName?.Contains("RelationalConnection") == true ||
(d.ServiceType.IsGenericType &&
d.ServiceType.GetGenericTypeDefinition().FullName?.Contains("DbContextOptions") == true))
.ToList();
if (descriptor != null)
foreach (var descriptor in descriptorsToRemove)
{
services.Remove(descriptor);
}
// EN: Remove DbContext service
// VI: Xóa DbContext service
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(OrderContext));
// EN: Also remove the generic DbContextOptions registration
// VI: Cũng xóa đăng ký DbContextOptions generic
services.RemoveAll<DbContextOptions<OrderContext>>();
if (dbContextDescriptor != null)
{
services.Remove(dbContextDescriptor);
}
// EN: Add in-memory database for testing
// VI: Thêm in-memory database để test
services.AddDbContext<OrderContext>(options =>
{
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
options.UseInMemoryDatabase(_databaseName);
});
// EN: Ensure database is created with seed data
// VI: Đảm bảo database được to với seed data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OrderContext>();
db.Database.EnsureCreated();
// ============================================================
// EN: Replace IDbConnection (Dapper) with a no-op to avoid PostgreSQL dependency.
// Queries that use Dapper with raw SQL won't work with InMemory,
// so we provide a mock connection that returns empty results.
// VI: Thay thế IDbConnection (Dapper) để tránh phụ thuộc PostgreSQL.
// Queries dùng Dapper với raw SQL không hoạt động với InMemory,
// nên cung cấp mock connection trả về kết quả rỗng.
// ============================================================
services.RemoveAll<IDbConnection>();
services.AddTransient<IDbConnection>(_ => new MockDbConnection());
// ============================================================
// EN: Mock external services
// VI: Mock external services
// ============================================================
// EN: Mock IWalletServiceClient — return success for all payment requests
// VI: Mock IWalletServiceClient — trả về thành công cho tất cả yêu cầu thanh toán
services.RemoveAll<IWalletServiceClient>();
services.AddSingleton<IWalletServiceClient, MockWalletServiceClient>();
// EN: Mock IPosNotificationService — no-op (skip SignalR)
// VI: Mock IPosNotificationService — no-op (bỏ qua SignalR)
services.RemoveAll<IPosNotificationService>();
services.AddSingleton<IPosNotificationService, MockPosNotificationService>();
// EN: Mock ILineItemStrategy — always return valid for all product types
// VI: Mock ILineItemStrategy — luôn trả về hợp lệ cho tất cả loại sản phẩm
services.RemoveAll<ILineItemStrategy>();
services.AddTransient<ILineItemStrategy>(_ => new MockLineItemStrategy("Physical"));
services.AddTransient<ILineItemStrategy>(_ => new MockLineItemStrategy("Service"));
services.AddTransient<ILineItemStrategy>(_ => new MockLineItemStrategy("PreparedFood"));
// EN: Mock IOrderTenantProvider — bypass tenant filtering in tests
// VI: Mock IOrderTenantProvider — bỏ qua tenant filtering trong tests
services.RemoveAll<IOrderTenantProvider>();
services.AddSingleton<IOrderTenantProvider, MockTenantProvider>();
services.RemoveAll<ITenantProvider>();
services.AddSingleton<ITenantProvider, MockApiTenantProvider>();
// EN: Remove TransactionBehavior — InMemory database does not support transactions.
// The ExecutionStrategy from InMemory throws NotSupportedException.
// VI: Xóa TransactionBehavior — InMemory database không hỗ trợ transactions.
var transactionBehaviors = services
.Where(d => d.ServiceType.IsGenericType &&
d.ServiceType.GetGenericTypeDefinition() == typeof(MediatR.IPipelineBehavior<,>) &&
d.ImplementationType?.Name?.Contains("TransactionBehavior") == true)
.ToList();
foreach (var descriptor in transactionBehaviors)
{
services.Remove(descriptor);
}
// EN: Note: IUserIdProvider is registered by SignalR and needed at runtime.
// ClaimsUserIdProvider is already registered in Program.cs, so no action needed.
// VI: IUserIdProvider đã được đăng ký bởi SignalR và cần tại runtime.
// ClaimsUserIdProvider đã đăng ký trong Program.cs, không cần thao tác.
// ============================================================
// EN: Replace authentication with test scheme
// VI: Thay thế authentication bằng test scheme
// ============================================================
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName, _ => { });
});
}
/// <summary>
/// EN: Seed database after host is created but before tests run.
/// VI: Seed database sau khi host được tạo nhưng trước khi tests chạy.
/// </summary>
protected override IHost CreateHost(IHostBuilder builder)
{
var host = base.CreateHost(builder);
// EN: Seed the InMemory database with OrderStatus enum data
// VI: Seed InMemory database với OrderStatus enum data
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OrderContext>();
db.Database.EnsureCreated();
return host;
}
}
// ============================================================
// EN: Mock implementations
// VI: Mock implementations
// ============================================================
/// <summary>
/// EN: Mock wallet service client that always succeeds.
/// VI: Mock wallet service client luôn thành công.
/// </summary>
internal class MockWalletServiceClient : IWalletServiceClient
{
public Task<CreatePaymentResponse?> CreatePaymentAsync(
Guid orderId, decimal amount, string gateway,
string returnUrl, string ipAddress,
CancellationToken cancellationToken = default)
{
return Task.FromResult<CreatePaymentResponse?>(
new CreatePaymentResponse(
TransactionId: $"MOCK-{Guid.NewGuid():N}",
PaymentUrl: $"https://mock-gateway.test/pay?txn=MOCK-{orderId}",
Status: "Pending"));
}
}
/// <summary>
/// EN: Mock POS notification service — no-op.
/// VI: Mock POS notification service — no-op.
/// </summary>
internal class MockPosNotificationService : IPosNotificationService
{
public Task NotifyOrderCreatedAsync(Guid shopId, OrderNotificationDto order, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task NotifyOrderUpdatedAsync(Guid shopId, OrderNotificationDto order, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task NotifyOrderStatusChangedAsync(Guid shopId, Guid orderId, string oldStatus, string newStatus, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task NotifyKitchenTicketCreatedAsync(Guid shopId, KitchenTicketNotificationDto ticket, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task NotifyKitchenTicketUpdatedAsync(Guid shopId, KitchenTicketNotificationDto ticket, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task NotifyPaymentCompletedAsync(Guid shopId, PaymentNotificationDto payment, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task NotifyTableStatusChangedAsync(Guid shopId, TableStatusNotificationDto tableStatus, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
/// <summary>
/// EN: Mock line item strategy — always validates and executes successfully.
/// VI: Mock line item strategy — luôn validate và execute thành công.
/// </summary>
internal class MockLineItemStrategy : ILineItemStrategy
{
public string SupportedType { get; }
public MockLineItemStrategy(string supportedType)
{
SupportedType = supportedType;
}
public Task<bool> ValidateAsync(OrderItem item, Guid shopId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public Task ExecuteAsync(OrderItem item, Guid shopId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
/// <summary>
/// EN: Mock tenant provider for Infrastructure layer — bypasses tenant filtering.
/// VI: Mock tenant provider cho lớp Infrastructure — bỏ qua tenant filtering.
/// </summary>
internal class MockTenantProvider : IOrderTenantProvider
{
public Guid? GetCurrentShopId() => null;
public bool ShouldBypassTenantFilter() => true;
}
/// <summary>
/// EN: Mock API tenant provider — returns test tenant IDs.
/// VI: Mock API tenant provider — trả về test tenant IDs.
/// </summary>
internal class MockApiTenantProvider : ITenantProvider
{
public Guid? GetCurrentUserId() => TestAuthHandler.TestUserId;
public Guid? GetCurrentMerchantId() => TestAuthHandler.TestMerchantId;
public Guid? GetCurrentShopId() => TestAuthHandler.TestShopId;
public bool IsServiceCall() => false;
public bool IsAdmin() => true;
}
#pragma warning disable CS8767 // Nullability of reference types in interface implementations (mock objects)
/// <summary>
/// EN: Mock IDbConnection for Dapper queries — returns empty results.
/// Since Dapper queries use raw SQL with PostgreSQL-specific joins,
/// they cannot work with InMemory database. This mock prevents
/// connection errors while allowing command-side tests to pass.
/// VI: Mock IDbConnection cho Dapper queries — trả về kết quả rỗng.
/// Vì Dapper queries dùng raw SQL với PostgreSQL-specific joins,
/// chúng không hoạt động với InMemory database. Mock này ngăn
/// connection errors trong khi cho phép command-side tests pass.
/// </summary>
internal class MockDbConnection : IDbConnection
{
public string ConnectionString { get; set; } = string.Empty;
public int ConnectionTimeout => 30;
public string Database => "test";
public ConnectionState State => ConnectionState.Open;
public IDbTransaction BeginTransaction() => new MockDbTransaction(this);
public IDbTransaction BeginTransaction(IsolationLevel il) => new MockDbTransaction(this);
public void ChangeDatabase(string databaseName) { }
public void Close() { }
public IDbCommand CreateCommand() => new MockDbCommand();
public void Dispose() { }
public void Open() { }
}
internal class MockDbTransaction : IDbTransaction
{
public IDbConnection Connection { get; }
public IsolationLevel IsolationLevel => IsolationLevel.ReadCommitted;
public MockDbTransaction(IDbConnection connection)
{
Connection = connection;
}
public void Commit() { }
public void Rollback() { }
public void Dispose() { }
}
internal class MockDbCommand : IDbCommand
{
public string CommandText { get; set; } = string.Empty;
public int CommandTimeout { get; set; } = 30;
public CommandType CommandType { get; set; } = CommandType.Text;
public IDbConnection? Connection { get; set; }
public IDataParameterCollection Parameters => new MockParameterCollection();
public IDbTransaction? Transaction { get; set; }
public UpdateRowSource UpdatedRowSource { get; set; } = UpdateRowSource.None;
public void Cancel() { }
public IDbDataParameter CreateParameter() => new MockDbDataParameter();
public void Dispose() { }
public int ExecuteNonQuery() => 0;
public IDataReader ExecuteReader() => new MockDataReader();
public IDataReader ExecuteReader(CommandBehavior behavior) => new MockDataReader();
public object? ExecuteScalar() => 0;
public void Prepare() { }
}
internal class MockDbDataParameter : IDbDataParameter
{
public DbType DbType { get; set; }
public ParameterDirection Direction { get; set; }
public bool IsNullable => true;
public string ParameterName { get; set; } = string.Empty;
public string SourceColumn { get; set; } = string.Empty;
public DataRowVersion SourceVersion { get; set; }
public object? Value { get; set; }
public byte Precision { get; set; }
public byte Scale { get; set; }
public int Size { get; set; }
}
internal class MockParameterCollection : IDataParameterCollection
{
private readonly List<object> _list = new();
public object this[string parameterName] { get => null!; set { } }
public object this[int index] { get => _list[index]; set => _list[index] = value; }
public bool IsFixedSize => false;
public bool IsReadOnly => false;
public bool IsSynchronized => false;
public int Count => _list.Count;
public object SyncRoot => _list;
public int Add(object value) { _list.Add(value); return _list.Count - 1; }
public void Clear() => _list.Clear();
public bool Contains(string parameterName) => false;
public bool Contains(object value) => _list.Contains(value);
public void CopyTo(Array array, int index) { }
public System.Collections.IEnumerator GetEnumerator() => _list.GetEnumerator();
public int IndexOf(string parameterName) => -1;
public int IndexOf(object value) => _list.IndexOf(value);
public void Insert(int index, object value) => _list.Insert(index, value);
public void Remove(object value) => _list.Remove(value);
public void RemoveAt(string parameterName) { }
public void RemoveAt(int index) => _list.RemoveAt(index);
}
internal class MockDataReader : IDataReader
{
public int Depth => 0;
public bool IsClosed => false;
public int RecordsAffected => 0;
public int FieldCount => 0;
public object this[string name] => throw new IndexOutOfRangeException();
public object this[int i] => throw new IndexOutOfRangeException();
public void Close() { }
public void Dispose() { }
public bool GetBoolean(int i) => false;
public byte GetByte(int i) => 0;
public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) => 0;
public char GetChar(int i) => '\0';
public long GetChars(int i, long fieldOffset, char[]? buffer, int bufferoffset, int length) => 0;
public IDataReader GetData(int i) => this;
public string GetDataTypeName(int i) => string.Empty;
public DateTime GetDateTime(int i) => DateTime.MinValue;
public decimal GetDecimal(int i) => 0;
public double GetDouble(int i) => 0;
public Type GetFieldType(int i) => typeof(object);
public float GetFloat(int i) => 0;
public Guid GetGuid(int i) => Guid.Empty;
public short GetInt16(int i) => 0;
public int GetInt32(int i) => 0;
public long GetInt64(int i) => 0;
public string GetName(int i) => string.Empty;
public int GetOrdinal(string name) => -1;
public DataTable GetSchemaTable() => new DataTable();
public string GetString(int i) => string.Empty;
public object GetValue(int i) => DBNull.Value;
public int GetValues(object[] values) => 0;
public bool IsDBNull(int i) => true;
public bool NextResult() => false;
public bool Read() => false;
}

View File

@@ -0,0 +1,68 @@
// EN: Custom authentication handler for functional tests.
// VI: Authentication handler tùy chỉnh cho functional tests.
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace OrderService.FunctionalTests;
/// <summary>
/// EN: Test authentication handler that creates a fake authenticated user with configurable claims.
/// VI: Authentication handler test tạo user authenticated giả với claims có thể cấu hình.
/// </summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
/// <summary>
/// EN: Authentication scheme name used in tests.
/// VI: Tên scheme xác thực sử dụng trong tests.
/// </summary>
public const string SchemeName = "TestScheme";
/// <summary>
/// EN: Default test user ID.
/// VI: User ID test mặc định.
/// </summary>
public static readonly Guid TestUserId = Guid.Parse("11111111-1111-1111-1111-111111111111");
/// <summary>
/// EN: Default test merchant ID.
/// VI: Merchant ID test mặc định.
/// </summary>
public static readonly Guid TestMerchantId = Guid.Parse("22222222-2222-2222-2222-222222222222");
/// <summary>
/// EN: Default test shop ID.
/// VI: Shop ID test mặc định.
/// </summary>
public static readonly Guid TestShopId = Guid.Parse("33333333-3333-3333-3333-333333333333");
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, TestUserId.ToString()),
new Claim("sub", TestUserId.ToString()),
new Claim("merchant_id", TestMerchantId.ToString()),
new Claim("shop_id", TestShopId.ToString()),
new Claim(ClaimTypes.Role, "admin"),
new Claim("role", "admin"),
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}