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:
341
.github/workflows/deploy-staging.yml
vendored
341
.github/workflows/deploy-staging.yml
vendored
@@ -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
|
||||
|
||||
114
.github/workflows/docker-build.yml
vendored
114
.github/workflows/docker-build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
123
deployments/staging/kubernetes/catalog-service.yaml
Normal file
123
deployments/staging/kubernetes/catalog-service.yaml
Normal 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
|
||||
57
deployments/staging/kubernetes/configmap.yaml
Normal file
57
deployments/staging/kubernetes/configmap.yaml
Normal 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"
|
||||
130
deployments/staging/kubernetes/fnb-engine.yaml
Normal file
130
deployments/staging/kubernetes/fnb-engine.yaml
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
123
deployments/staging/kubernetes/inventory-service.yaml
Normal file
123
deployments/staging/kubernetes/inventory-service.yaml
Normal 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
|
||||
123
deployments/staging/kubernetes/merchant-service.yaml
Normal file
123
deployments/staging/kubernetes/merchant-service.yaml
Normal 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
|
||||
10
deployments/staging/kubernetes/namespace.yaml
Normal file
10
deployments/staging/kubernetes/namespace.yaml
Normal 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
|
||||
131
deployments/staging/kubernetes/order-service.yaml
Normal file
131
deployments/staging/kubernetes/order-service.yaml
Normal 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
|
||||
143
deployments/staging/kubernetes/pos-web.yaml
Normal file
143
deployments/staging/kubernetes/pos-web.yaml
Normal 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
|
||||
114
deployments/staging/kubernetes/redis.yaml
Normal file
114
deployments/staging/kubernetes/redis.yaml
Normal 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
|
||||
77
deployments/staging/kubernetes/secrets.yaml
Normal file
77
deployments/staging/kubernetes/secrets.yaml
Normal 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"
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
144
deployments/staging/kubernetes/storage-service.yaml
Normal file
144
deployments/staging/kubernetes/storage-service.yaml
Normal 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
|
||||
123
deployments/staging/kubernetes/wallet-service.yaml
Normal file
123
deployments/staging/kubernetes/wallet-service.yaml
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 tạo 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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user