This commit is contained in:
Ho Ngoc Hai
2026-05-23 18:37:02 +07:00
parent f15d91ee29
commit 76d75c753b
3993 changed files with 403 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
# GoodGo Platform — Pact Contract Tests
EN: Consumer-driven contract tests for microservice boundaries using Pact.io v3.
VI: Contract tests consumer-driven cho ranh giới microservice sử dụng Pact.io v3.
## Covered Boundaries
| Consumer | Provider | Test File |
|----------|----------|-----------|
| OrderService | CatalogService | `order-catalog.consumer.test.ts` |
| OrderService | WalletService | `order-wallet.consumer.test.ts` |
| OrderService | InventoryService | `order-inventory.consumer.test.ts` |
| MerchantService | IamService | `merchant-iam.consumer.test.ts` |
| PromotionService | OrderService | `promotion-order.consumer.test.ts` |
## Running Tests
```bash
cd tests/contract
pnpm install
pnpm test
```
EN: Pact files are generated in `tests/contract/pacts/`.
VI: Pact files được tạo trong `tests/contract/pacts/`.
## Provider Verification (CI)
EN: Each provider service must verify the Pact it receives. Add to the provider's CI workflow:
VI: Mỗi provider service phải verify Pact nhận được. Thêm vào CI workflow của provider:
```csharp
// EN: In provider test project — PactNet provider verification
// VI: Trong provider test project — PactNet provider verification
[Fact]
public async Task EnsureProviderApiHonoursPactWithOrderService()
{
var config = new PactVerifierConfig
{
Outputters = new List<IOutput> { new XUnitOutput(_output) },
};
await new PactVerifier(config)
.ServiceProvider("CatalogService", new Uri("http://localhost:5020"))
.WithPactFile(new FileInfo("../../../../tests/contract/pacts/OrderService-CatalogService.json"))
.WithProviderStateUrl(new Uri("http://localhost:5020/provider-states"))
.Verify();
}
```
## Publishing Pacts (Optional — Pact Broker)
```bash
pnpm pact:publish
```
EN: Requires `PACT_BROKER_URL` and `PACT_BROKER_TOKEN` environment variables.
VI: Cần các biến môi trường `PACT_BROKER_URL``PACT_BROKER_TOKEN`.

View File

@@ -0,0 +1,20 @@
{
"name": "@goodgo/contract-tests",
"version": "1.0.0",
"description": "EN: Pact contract tests for GoodGo microservice boundaries. VI: Pact contract tests cho ranh giới microservice GoodGo.",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"pact:publish": "node scripts/publish-pacts.mjs"
},
"devDependencies": {
"@pact-foundation/pact": "^13.1.3",
"@types/node": "^25.0.3",
"@vitest/coverage-v8": "^2.1.0",
"typescript": "^5.9.3",
"vitest": "^2.1.0"
}
}

View File

@@ -0,0 +1,156 @@
/**
* EN: Pact consumer test — Merchant Service consuming IAM Service.
* VI: Pact consumer test — Merchant Service sử dụng IAM Service.
*
* Boundary: merchant -> iam
* Contracts:
* 1. GET /api/v1/users/{userId} — resolve user/owner info during merchant onboarding
* 2. POST /api/v1/rbac/check — permission check for merchant operations
*/
import { describe, it, expect } from 'vitest';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { fileURLToPath } from 'url';
const { like, string, uuid, boolean, eachLike } = MatchersV3;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PACT_DIR = path.resolve(__dirname, '../../pacts');
const provider = new PactV3({
consumer: 'MerchantService',
provider: 'IamService',
dir: PACT_DIR,
logLevel: 'warn',
});
const USER_ID = '550e8400-e29b-41d4-a716-446655440020';
const BEARER_TOKEN = 'Bearer test-jwt-token';
// ---------------------------------------------------------------------------
// Consumer-side client
// ---------------------------------------------------------------------------
async function getUserById(baseUrl: string, userId: string, token: string) {
const res = await fetch(`${baseUrl}/api/v1/users/${userId}`, {
headers: { Authorization: token },
});
if (!res.ok) return null;
return res.json();
}
async function checkPermission(
baseUrl: string,
userId: string,
resource: string,
action: string,
token: string
) {
const res = await fetch(`${baseUrl}/api/v1/rbac/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token },
body: JSON.stringify({ userId, resource, action }),
});
if (!res.ok) return { allowed: false };
return res.json();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('MerchantService → IamService contract', () => {
describe('GET /api/v1/users/{userId}', () => {
it('returns user info for existing active user', async () => {
await provider
.addInteraction({
states: [{ description: `user ${USER_ID} is active` }],
uponReceiving: 'a request to get user by ID with valid token',
withRequest: {
method: 'GET',
path: `/api/v1/users/${USER_ID}`,
headers: { Authorization: BEARER_TOKEN },
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
success: boolean(true),
data: like({
id: uuid(USER_ID),
email: string('owner@goodgo.vn'),
role: string('ADMIN'),
isActive: boolean(true),
}),
},
},
})
.executeTest(async (mockServer) => {
const result = await getUserById(mockServer.url, USER_ID, BEARER_TOKEN);
expect(result).not.toBeNull();
expect(result.success).toBe(true);
expect(result.data.id).toBe(USER_ID);
expect(result.data.isActive).toBe(true);
});
});
it('returns 401 when token is missing', async () => {
await provider
.addInteraction({
states: [{ description: 'authentication is required' }],
uponReceiving: 'a request to get user without authorization token',
withRequest: {
method: 'GET',
path: `/api/v1/users/${USER_ID}`,
},
willRespondWith: {
status: 401,
headers: { 'Content-Type': 'application/json' },
body: {
success: boolean(false),
error: like({ code: string('UNAUTHORIZED') }),
},
},
})
.executeTest(async (mockServer) => {
const result = await getUserById(mockServer.url, USER_ID, '');
expect(result).toBeNull();
});
});
});
describe('POST /api/v1/rbac/check', () => {
it('returns allowed=true for authorized merchant operation', async () => {
await provider
.addInteraction({
states: [{ description: `user ${USER_ID} has merchants:write permission` }],
uponReceiving: 'a permission check for merchants:write',
withRequest: {
method: 'POST',
path: '/api/v1/rbac/check',
headers: { 'Content-Type': 'application/json', Authorization: BEARER_TOKEN },
body: {
userId: uuid(USER_ID),
resource: string('merchants'),
action: string('write'),
},
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
allowed: boolean(true),
},
},
})
.executeTest(async (mockServer) => {
const result = await checkPermission(
mockServer.url,
USER_ID,
'merchants',
'write',
BEARER_TOKEN
);
expect(result.allowed).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,101 @@
/**
* EN: Pact consumer test — Order Service consuming Catalog Service.
* VI: Pact consumer test — Order Service sử dụng Catalog Service.
*
* Boundary: order -> catalog
* Contract: Order Service calls GET /api/v1/products/{productId} to validate product.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { fileURLToPath } from 'url';
const { like, eachLike, string, uuid, decimal, boolean } = MatchersV3;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PACT_DIR = path.resolve(__dirname, '../../pacts');
// ---------------------------------------------------------------------------
// Pact provider definition
// ---------------------------------------------------------------------------
const provider = new PactV3({
consumer: 'OrderService',
provider: 'CatalogService',
dir: PACT_DIR,
logLevel: 'warn',
});
// ---------------------------------------------------------------------------
// Consumer-side HTTP client (mirrors OrderService.API CatalogServiceClient)
// ---------------------------------------------------------------------------
async function getProductById(baseUrl: string, productId: string) {
const res = await fetch(`${baseUrl}/api/v1/products/${productId}`);
if (!res.ok) return null;
return res.json();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('OrderService → CatalogService contract', () => {
const PRODUCT_ID = '550e8400-e29b-41d4-a716-446655440001';
describe('GET /api/v1/products/{productId}', () => {
it('returns product details when product exists', async () => {
await provider
.addInteraction({
states: [{ description: 'product 550e8400-e29b-41d4-a716-446655440001 exists' }],
uponReceiving: 'a request for product by ID',
withRequest: {
method: 'GET',
path: `/api/v1/products/${PRODUCT_ID}`,
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
// EN: Match the response shape — not exact values
// VI: Khớp hình dạng response — không phải giá trị chính xác
id: uuid(PRODUCT_ID),
name: string('Espresso'),
type: string('beverage'),
price: decimal(35000),
isActive: boolean(true),
},
},
})
.executeTest(async (mockServer) => {
const product = await getProductById(mockServer.url, PRODUCT_ID);
expect(product).not.toBeNull();
expect(product.id).toBe(PRODUCT_ID);
expect(product.isActive).toBe(true);
expect(typeof product.price).toBe('number');
});
});
it('returns null (404) when product does not exist', async () => {
const MISSING_ID = '550e8400-e29b-41d4-a716-000000000000';
await provider
.addInteraction({
states: [{ description: `product ${MISSING_ID} does not exist` }],
uponReceiving: 'a request for a non-existent product',
withRequest: {
method: 'GET',
path: `/api/v1/products/${MISSING_ID}`,
},
willRespondWith: {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: {
success: boolean(false),
error: like({ code: string('PRODUCT_NOT_FOUND') }),
},
},
})
.executeTest(async (mockServer) => {
const product = await getProductById(mockServer.url, MISSING_ID);
expect(product).toBeNull();
});
});
});
});

View File

@@ -0,0 +1,138 @@
/**
* EN: Pact consumer test — Order Service consuming Inventory Service.
* VI: Pact consumer test — Order Service sử dụng Inventory Service.
*
* Boundary: order -> inventory
* Contracts:
* 1. GET /api/v1/inventory/check — check stock availability
* 2. POST /api/v1/inventory/deduct — deduct stock on order confirmation
*/
import { describe, it, expect } from 'vitest';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { fileURLToPath } from 'url';
const { like, string, uuid, integer, boolean } = MatchersV3;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PACT_DIR = path.resolve(__dirname, '../../pacts');
const provider = new PactV3({
consumer: 'OrderService',
provider: 'InventoryService',
dir: PACT_DIR,
logLevel: 'warn',
});
const PRODUCT_ID = '550e8400-e29b-41d4-a716-446655440002';
const SHOP_ID = '550e8400-e29b-41d4-a716-446655440003';
// ---------------------------------------------------------------------------
// Consumer-side clients (mirrors InventoryServiceClient)
// ---------------------------------------------------------------------------
async function checkStock(baseUrl: string, productId: string, shopId: string, quantity: number) {
const res = await fetch(
`${baseUrl}/api/v1/inventory/check?productId=${productId}&shopId=${shopId}&quantity=${quantity}`
);
if (!res.ok) return { isAvailable: false };
return res.json();
}
async function deductStock(baseUrl: string, productId: string, shopId: string, quantity: number) {
const res = await fetch(`${baseUrl}/api/v1/inventory/deduct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, shopId, quantity }),
});
return res.ok;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('OrderService → InventoryService contract', () => {
describe('GET /api/v1/inventory/check', () => {
it('returns available=true when sufficient stock exists', async () => {
await provider
.addInteraction({
states: [{ description: `product ${PRODUCT_ID} has 10 units in shop ${SHOP_ID}` }],
uponReceiving: 'a stock availability check for quantity 3',
withRequest: {
method: 'GET',
path: '/api/v1/inventory/check',
query: { productId: PRODUCT_ID, shopId: SHOP_ID, quantity: '3' },
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
isAvailable: boolean(true),
availableQuantity: integer(10),
},
},
})
.executeTest(async (mockServer) => {
const result = await checkStock(mockServer.url, PRODUCT_ID, SHOP_ID, 3);
expect(result.isAvailable).toBe(true);
expect(result.availableQuantity).toBeGreaterThanOrEqual(3);
});
});
it('returns available=false when insufficient stock', async () => {
await provider
.addInteraction({
states: [{ description: `product ${PRODUCT_ID} has only 1 unit in shop ${SHOP_ID}` }],
uponReceiving: 'a stock availability check for quantity 10 when only 1 available',
withRequest: {
method: 'GET',
path: '/api/v1/inventory/check',
query: { productId: PRODUCT_ID, shopId: SHOP_ID, quantity: '10' },
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
isAvailable: boolean(false),
availableQuantity: integer(1),
},
},
})
.executeTest(async (mockServer) => {
const result = await checkStock(mockServer.url, PRODUCT_ID, SHOP_ID, 10);
expect(result.isAvailable).toBe(false);
});
});
});
describe('POST /api/v1/inventory/deduct', () => {
it('deducts stock and returns 200 on success', async () => {
await provider
.addInteraction({
states: [{ description: `product ${PRODUCT_ID} has sufficient stock in shop ${SHOP_ID}` }],
uponReceiving: 'a request to deduct 2 units from inventory',
withRequest: {
method: 'POST',
path: '/api/v1/inventory/deduct',
headers: { 'Content-Type': 'application/json' },
body: {
productId: uuid(PRODUCT_ID),
shopId: uuid(SHOP_ID),
quantity: integer(2),
},
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
success: boolean(true),
data: like({ remainingQuantity: integer(8) }),
},
},
})
.executeTest(async (mockServer) => {
const success = await deductStock(mockServer.url, PRODUCT_ID, SHOP_ID, 2);
expect(success).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,140 @@
/**
* EN: Pact consumer test — Order Service consuming Wallet Service.
* VI: Pact consumer test — Order Service sử dụng Wallet Service.
*
* Boundary: order -> wallet
* Contract: Order Service calls POST /api/v1/payments/create to initiate payment.
*/
import { describe, it, expect } from 'vitest';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { fileURLToPath } from 'url';
const { like, string, uuid, decimal, boolean } = MatchersV3;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PACT_DIR = path.resolve(__dirname, '../../pacts');
const provider = new PactV3({
consumer: 'OrderService',
provider: 'WalletService',
dir: PACT_DIR,
logLevel: 'warn',
});
// ---------------------------------------------------------------------------
// Consumer-side client (mirrors WalletServiceClient in OrderService.Infrastructure)
// ---------------------------------------------------------------------------
interface CreatePaymentRequest {
orderId: string;
amount: number;
gateway: string;
returnUrl: string;
ipAddress: string;
description: string;
}
async function createPayment(baseUrl: string, request: CreatePaymentRequest) {
const res = await fetch(`${baseUrl}/api/v1/payments/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!res.ok) return null;
return res.json();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('OrderService → WalletService contract', () => {
const ORDER_ID = '550e8400-e29b-41d4-a716-446655440010';
describe('POST /api/v1/payments/create', () => {
it('returns payment URL when order is valid', async () => {
await provider
.addInteraction({
states: [{ description: `order ${ORDER_ID} exists and is payable` }],
uponReceiving: 'a request to create payment for an order',
withRequest: {
method: 'POST',
path: '/api/v1/payments/create',
headers: { 'Content-Type': 'application/json' },
body: {
orderId: uuid(ORDER_ID),
amount: decimal(175000),
gateway: string('vnpay'),
returnUrl: string('https://app.goodgo.vn/payment/callback'),
ipAddress: string('127.0.0.1'),
description: string(`Payment for order ${ORDER_ID}`),
},
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
success: boolean(true),
data: like({
paymentId: string('pay_abc123'),
paymentUrl: string('https://sandbox.vnpayment.vn/pay?vnp_TxnRef=abc'),
expiredAt: string('2026-03-23T03:43:20Z'),
}),
},
},
})
.executeTest(async (mockServer) => {
const result = await createPayment(mockServer.url, {
orderId: ORDER_ID,
amount: 175000,
gateway: 'vnpay',
returnUrl: 'https://app.goodgo.vn/payment/callback',
ipAddress: '127.0.0.1',
description: `Payment for order ${ORDER_ID}`,
});
expect(result).not.toBeNull();
expect(result.success).toBe(true);
expect(result.data.paymentUrl).toBeTruthy();
});
});
it('returns error when amount is invalid', async () => {
await provider
.addInteraction({
states: [{ description: 'payment validation is enforced' }],
uponReceiving: 'a request to create payment with zero amount',
withRequest: {
method: 'POST',
path: '/api/v1/payments/create',
headers: { 'Content-Type': 'application/json' },
body: {
orderId: uuid(ORDER_ID),
amount: decimal(0),
gateway: string('vnpay'),
returnUrl: string('https://app.goodgo.vn/payment/callback'),
ipAddress: string('127.0.0.1'),
description: string('invalid payment'),
},
},
willRespondWith: {
status: 400,
headers: { 'Content-Type': 'application/json' },
body: {
success: boolean(false),
error: like({ code: string('INVALID_AMOUNT') }),
},
},
})
.executeTest(async (mockServer) => {
const result = await createPayment(mockServer.url, {
orderId: ORDER_ID,
amount: 0,
gateway: 'vnpay',
returnUrl: 'https://app.goodgo.vn/payment/callback',
ipAddress: '127.0.0.1',
description: 'invalid payment',
});
expect(result).toBeNull();
});
});
});
});

View File

@@ -0,0 +1,146 @@
/**
* EN: Pact consumer test — Promotion Service consuming Order Service.
* VI: Pact consumer test — Promotion Service sử dụng Order Service.
*
* Boundary: promotion -> order
* Contracts:
* 1. GET /api/v1/orders/{orderId} — fetch order to validate promotion eligibility
* 2. GET /api/v1/orders?customerId={id}&shopId={id} — fetch order history for loyalty check
*/
import { describe, it, expect } from 'vitest';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { fileURLToPath } from 'url';
const { like, string, uuid, decimal, integer, boolean, eachLike } = MatchersV3;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PACT_DIR = path.resolve(__dirname, '../../pacts');
const provider = new PactV3({
consumer: 'PromotionService',
provider: 'OrderService',
dir: PACT_DIR,
logLevel: 'warn',
});
const ORDER_ID = '550e8400-e29b-41d4-a716-446655440030';
const CUSTOMER_ID = '550e8400-e29b-41d4-a716-446655440031';
const SHOP_ID = '550e8400-e29b-41d4-a716-446655440032';
const BEARER_TOKEN = 'Bearer service-to-service-token';
// ---------------------------------------------------------------------------
// Consumer-side clients
// ---------------------------------------------------------------------------
async function getOrderById(baseUrl: string, orderId: string, token: string) {
const res = await fetch(`${baseUrl}/api/v1/orders/${orderId}`, {
headers: { Authorization: token },
});
if (!res.ok) return null;
return res.json();
}
async function getCustomerOrderHistory(
baseUrl: string,
customerId: string,
shopId: string,
token: string
) {
const res = await fetch(
`${baseUrl}/api/v1/orders?customerId=${customerId}&shopId=${shopId}&status=completed`,
{ headers: { Authorization: token } }
);
if (!res.ok) return [];
const body = await res.json();
return body.data?.items ?? body.data ?? [];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('PromotionService → OrderService contract', () => {
describe('GET /api/v1/orders/{orderId}', () => {
it('returns order with total for promotion validation', async () => {
await provider
.addInteraction({
states: [{ description: `order ${ORDER_ID} is completed` }],
uponReceiving: 'a request to get order details for promotion eligibility',
withRequest: {
method: 'GET',
path: `/api/v1/orders/${ORDER_ID}`,
headers: { Authorization: BEARER_TOKEN },
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
success: boolean(true),
data: like({
id: uuid(ORDER_ID),
status: string('completed'),
totalAmount: decimal(175000),
customerId: uuid(CUSTOMER_ID),
shopId: uuid(SHOP_ID),
createdAt: string('2026-03-23T02:00:00Z'),
}),
},
},
})
.executeTest(async (mockServer) => {
const result = await getOrderById(mockServer.url, ORDER_ID, BEARER_TOKEN);
expect(result).not.toBeNull();
expect(result.success).toBe(true);
expect(result.data.status).toBe('completed');
expect(typeof result.data.totalAmount).toBe('number');
});
});
});
describe('GET /api/v1/orders (customer history)', () => {
it('returns order history for loyalty tier calculation', async () => {
await provider
.addInteraction({
states: [
{
description: `customer ${CUSTOMER_ID} has 5 completed orders in shop ${SHOP_ID}`,
},
],
uponReceiving: 'a request to get completed orders for a customer',
withRequest: {
method: 'GET',
path: '/api/v1/orders',
query: { customerId: CUSTOMER_ID, shopId: SHOP_ID, status: 'completed' },
headers: { Authorization: BEARER_TOKEN },
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
success: boolean(true),
data: like({
items: eachLike({
id: uuid(),
totalAmount: decimal(100000),
status: string('completed'),
}),
totalCount: integer(5),
}),
},
},
})
.executeTest(async (mockServer) => {
const orders = await getCustomerOrderHistory(
mockServer.url,
CUSTOMER_ID,
SHOP_ID,
BEARER_TOKEN
);
expect(Array.isArray(orders)).toBe(true);
expect(orders.length).toBeGreaterThanOrEqual(1);
orders.forEach((o: any) => {
expect(o.status).toBe('completed');
});
});
});
});
});

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
// EN: Contract tests can take longer due to Pact mock server startup.
// VI: Contract tests có thể chậm hơn do Pact mock server khởi động.
testTimeout: 30000,
hookTimeout: 15000,
// EN: Run serially to avoid port conflicts between mock servers.
// VI: Chạy tuần tự để tránh xung đột port giữa các mock server.
pool: 'forks',
poolOptions: { forks: { singleFork: true } },
},
});

View File

@@ -0,0 +1,72 @@
# GoodGo Platform — k6 Load Tests
EN: Load and performance tests for critical API paths.
VI: Load và performance tests cho các API path quan trọng.
## Prerequisites
```bash
brew install k6 # macOS
# or
choco install k6 # Windows
# or
sudo apt install k6 # Ubuntu/Debian
```
## Test Scripts
| Script | Target | Thresholds |
|--------|--------|------------|
| `order-creation.js` | POST /api/v1/orders | p95 < 500ms, errors < 1% |
| `catalog-listing.js` | GET /api/v1/products | p95 < 200ms, errors < 0.5% |
| `ads-tracking.js` | POST /api/v1/tracking/events | p95 < 100ms, errors < 0.1% |
| `signalr-connections.js` | WS /hubs/pos + negotiate | p95 < 300ms, errors < 1% |
## Running Tests
```bash
# EN: Run against local environment / VI: Chạy với môi trường local
k6 run tests/load/k6/order-creation.js
# EN: Run against staging / VI: Chạy với staging
k6 run \
--env BASE_URL=http://api.staging.goodgo.vn \
--env JWT_TOKEN=<your-token> \
--env SHOP_ID=<your-shop-id> \
tests/load/k6/order-creation.js
# EN: Run with output to InfluxDB for Grafana / VI: Xuất kết quả ra InfluxDB cho Grafana
k6 run \
--out influxdb=http://localhost:8086/k6 \
tests/load/k6/order-creation.js
# EN: Run with HTML report / VI: Chạy với báo cáo HTML
k6 run \
--out json=results/order-creation.json \
tests/load/k6/order-creation.js
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `BASE_URL` | `http://localhost:5010` | API base URL |
| `WS_URL` | `ws://localhost:5010` | WebSocket base URL (SignalR) |
| `JWT_TOKEN` | `test-bearer-token` | Bearer token for auth |
| `SHOP_ID` | `00000000-...01` | Shop UUID for tenant isolation |
| `PRODUCT_ID` | `00000000-...02` | Sample product UUID |
| `TRACKING_API_KEY` | `test-tracking-key` | Ads tracking API key |
## CI Integration
Add to `.github/workflows/ci-performance.yml`:
```yaml
- name: Run k6 load tests (smoke only in CI)
run: |
k6 run \
--vus 5 --duration 30s \
--env BASE_URL=${{ secrets.STAGING_API_URL }} \
--env JWT_TOKEN=${{ secrets.STAGING_JWT_TOKEN }} \
tests/load/k6/order-creation.js
```

View File

@@ -0,0 +1,121 @@
/**
* EN: k6 load test — Ads tracking event ingestion (POST /api/v1/tracking/events).
* VI: k6 load test — nhập event tracking quảng cáo (POST /api/v1/tracking/events).
*
* EN: Ads tracking is a high-throughput, fire-and-forget endpoint.
* VI: Tracking là endpoint thông lượng cao, fire-and-forget.
*
* Usage:
* k6 run tests/load/k6/ads-tracking.js
* k6 run --env BASE_URL=http://api.staging.goodgo.vn tests/load/k6/ads-tracking.js
*
* Thresholds:
* - 95th-percentile response time < 100ms
* - Error rate < 0.1%
* - Throughput > 500 req/s
*/
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
// ---------------------------------------------------------------------------
// Custom metrics
// ---------------------------------------------------------------------------
const trackingErrors = new Rate('tracking_errors');
const trackingDuration = new Trend('tracking_duration', true);
const eventsIngested = new Counter('events_ingested');
// ---------------------------------------------------------------------------
// Test options — simulate high-frequency event stream
// ---------------------------------------------------------------------------
export const options = {
stages: [
{ duration: '20s', target: 100 },
{ duration: '1m', target: 300 },
{ duration: '30s', target: 500 }, // EN: Peak throughput / VI: Đỉnh thông lượng
{ duration: '20s', target: 0 },
],
thresholds: {
// EN: Tracking must be very fast — p95 < 100ms / VI: Tracking phải rất nhanh
http_req_duration: ['p(95)<100', 'p(99)<200'],
tracking_errors: ['rate<0.001'],
tracking_duration: ['p(95)<100'],
},
};
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5080';
const API_KEY = __ENV.TRACKING_API_KEY || 'test-tracking-key';
const headers = {
'Content-Type': 'application/json',
'X-Api-Key': API_KEY,
};
// ---------------------------------------------------------------------------
// Event types
// ---------------------------------------------------------------------------
const EVENT_TYPES = ['impression', 'click', 'view', 'conversion', 'engagement'];
const AD_IDS = [
'00000000-0000-0000-0000-000000000011',
'00000000-0000-0000-0000-000000000012',
'00000000-0000-0000-0000-000000000013',
];
const CAMPAIGN_IDS = [
'00000000-0000-0000-0000-000000000021',
'00000000-0000-0000-0000-000000000022',
];
// ---------------------------------------------------------------------------
// Helper — build a tracking event payload
// ---------------------------------------------------------------------------
function buildTrackingEvent() {
const eventType = EVENT_TYPES[Math.floor(Math.random() * EVENT_TYPES.length)];
const adId = AD_IDS[Math.floor(Math.random() * AD_IDS.length)];
const campaignId = CAMPAIGN_IDS[Math.floor(Math.random() * CAMPAIGN_IDS.length)];
return JSON.stringify({
eventType,
adId,
campaignId,
sessionId: `sess-${Math.random().toString(36).substring(2, 10)}`,
userId: Math.random() > 0.3 ? `user-${Math.floor(Math.random() * 10000)}` : null,
deviceType: ['mobile', 'desktop', 'tablet'][Math.floor(Math.random() * 3)],
platform: ['ios', 'android', 'web'][Math.floor(Math.random() * 3)],
timestamp: new Date().toISOString(),
metadata: {
pageUrl: '/pos/menu',
referrer: 'organic',
duration: eventType === 'view' ? Math.floor(Math.random() * 10000) : null,
},
});
}
// ---------------------------------------------------------------------------
// Default function
// ---------------------------------------------------------------------------
export default function () {
const payload = buildTrackingEvent();
const res = http.post(`${BASE_URL}/api/v1/tracking/events`, payload, { headers });
trackingDuration.add(res.timings.duration);
const ok = check(res, {
// EN: 202 Accepted or 200 OK / VI: 202 Accepted hoặc 200 OK
'status 200 or 202': (r) => r.status === 200 || r.status === 202,
'response < 100ms': (r) => r.timings.duration < 100,
});
if (ok) {
eventsIngested.add(1);
trackingErrors.add(0);
} else {
trackingErrors.add(1);
}
// EN: No sleep — fire-and-forget high throughput / VI: Không sleep — thông lượng cao
sleep(0.01);
}

View File

@@ -0,0 +1,128 @@
/**
* EN: k6 load test — Catalog listing endpoint (GET /api/v1/products).
* VI: k6 load test — endpoint danh sách catalog (GET /api/v1/products).
*
* Usage:
* k6 run tests/load/k6/catalog-listing.js
* k6 run --env BASE_URL=http://api.staging.goodgo.vn tests/load/k6/catalog-listing.js
*
* Thresholds:
* - 95th-percentile response time < 200ms (read-heavy endpoint must be fast)
* - Error rate < 0.5%
*/
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
// ---------------------------------------------------------------------------
// Custom metrics
// ---------------------------------------------------------------------------
const catalogErrors = new Rate('catalog_errors');
const catalogListDuration = new Trend('catalog_list_duration', true);
const catalogDetailDuration = new Trend('catalog_detail_duration', true);
const catalogRequests = new Counter('catalog_total_requests');
// ---------------------------------------------------------------------------
// Test options — higher concurrency for read endpoint
// ---------------------------------------------------------------------------
export const options = {
stages: [
{ duration: '30s', target: 50 },
{ duration: '2m', target: 100 },
{ duration: '30s', target: 200 }, // EN: Stress test / VI: Stress test
{ duration: '30s', target: 0 },
],
thresholds: {
// EN: Listing must be fast — p95 < 200ms / VI: Listing phải nhanh — p95 < 200ms
http_req_duration: ['p(95)<200', 'p(99)<500'],
catalog_errors: ['rate<0.005'],
catalog_list_duration: ['p(95)<200'],
catalog_detail_duration: ['p(95)<150'],
},
};
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5020';
const JWT_TOKEN = __ENV.JWT_TOKEN || 'test-bearer-token';
const SHOP_ID = __ENV.SHOP_ID || '00000000-0000-0000-0000-000000000001';
const SAMPLE_PRODUCT_ID = __ENV.PRODUCT_ID || '00000000-0000-0000-0000-000000000002';
const headers = {
Authorization: `Bearer ${JWT_TOKEN}`,
'X-Shop-Id': SHOP_ID,
};
// ---------------------------------------------------------------------------
// Scenarios
// ---------------------------------------------------------------------------
export default function () {
// EN: Scenario 1 — List all products with pagination / VI: Tình huống 1 — danh sách sản phẩm có phân trang
group('list products', () => {
const page = Math.floor(Math.random() * 5) + 1;
const limit = [10, 20, 50][Math.floor(Math.random() * 3)];
const res = http.get(
`${BASE_URL}/api/v1/products?page=${page}&limit=${limit}&shopId=${SHOP_ID}`,
{ headers }
);
catalogListDuration.add(res.timings.duration);
catalogRequests.add(1);
const ok = check(res, {
'list status 200': (r) => r.status === 200,
'list body has items': (r) => {
try {
const body = JSON.parse(r.body);
return body.success === true && Array.isArray(body.data?.items ?? body.data);
} catch {
return false;
}
},
'list response < 200ms': (r) => r.timings.duration < 200,
});
if (!ok) catalogErrors.add(1);
else catalogErrors.add(0);
});
// EN: Scenario 2 — Get product by ID / VI: Tình huống 2 — lấy sản phẩm theo ID
group('get product detail', () => {
const res = http.get(
`${BASE_URL}/api/v1/products/${SAMPLE_PRODUCT_ID}`,
{ headers }
);
catalogDetailDuration.add(res.timings.duration);
catalogRequests.add(1);
const ok = check(res, {
'detail status 200 or 404': (r) => r.status === 200 || r.status === 404,
'detail response < 150ms': (r) => r.timings.duration < 150,
});
if (!ok) catalogErrors.add(1);
else catalogErrors.add(0);
});
// EN: Scenario 3 — Search products / VI: Tình huống 3 — tìm kiếm sản phẩm
group('search products', () => {
const terms = ['cafe', 'tra sua', 'banh', 'pho', 'com'];
const keyword = terms[Math.floor(Math.random() * terms.length)];
const res = http.get(
`${BASE_URL}/api/v1/products?q=${encodeURIComponent(keyword)}&shopId=${SHOP_ID}`,
{ headers }
);
catalogRequests.add(1);
const ok = check(res, {
'search status 200': (r) => r.status === 200,
});
if (!ok) catalogErrors.add(1);
else catalogErrors.add(0);
});
sleep(0.2 + Math.random() * 0.3);
}

View File

@@ -0,0 +1,119 @@
/**
* EN: k6 load test — Order creation endpoint (POST /api/v1/orders).
* VI: k6 load test — endpoint tạo đơn hàng (POST /api/v1/orders).
*
* Usage:
* k6 run tests/load/k6/order-creation.js
* k6 run --env BASE_URL=http://api.staging.goodgo.vn tests/load/k6/order-creation.js
*
* Thresholds:
* - 95th-percentile response time < 500ms
* - Error rate < 1%
*/
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// ---------------------------------------------------------------------------
// Custom metrics
// ---------------------------------------------------------------------------
const orderCreationErrors = new Rate('order_creation_errors');
const orderCreationDuration = new Trend('order_creation_duration', true);
// ---------------------------------------------------------------------------
// Test options
// ---------------------------------------------------------------------------
export const options = {
stages: [
// EN: Ramp up to 20 VUs over 30s / VI: Tăng lên 20 VUs trong 30s
{ duration: '30s', target: 20 },
// EN: Hold at 20 VUs for 1 minute / VI: Duy trì 20 VUs trong 1 phút
{ duration: '1m', target: 20 },
// EN: Spike to 50 VUs for 30s / VI: Tăng đột biến lên 50 VUs trong 30s
{ duration: '30s', target: 50 },
// EN: Ramp down / VI: Giảm dần
{ duration: '30s', target: 0 },
],
thresholds: {
// EN: 95% of requests must complete below 500ms / VI: 95% requests phải hoàn thành dưới 500ms
http_req_duration: ['p(95)<500'],
// EN: Error rate must stay below 1% / VI: Tỷ lệ lỗi phải dưới 1%
order_creation_errors: ['rate<0.01'],
// EN: Custom trend threshold / VI: Ngưỡng trend tùy chỉnh
order_creation_duration: ['p(99)<1000'],
},
};
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5010';
const JWT_TOKEN = __ENV.JWT_TOKEN || 'test-bearer-token';
const SHOP_ID = __ENV.SHOP_ID || '00000000-0000-0000-0000-000000000001';
const PRODUCT_ID = __ENV.PRODUCT_ID || '00000000-0000-0000-0000-000000000002';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${JWT_TOKEN}`,
'X-Shop-Id': SHOP_ID,
};
// ---------------------------------------------------------------------------
// Helper — generate a random order payload
// ---------------------------------------------------------------------------
function buildOrderPayload() {
const tableNumber = Math.floor(Math.random() * 20) + 1;
const qty = Math.floor(Math.random() * 3) + 1;
return JSON.stringify({
shopId: SHOP_ID,
tableNumber: `T${tableNumber}`,
orderType: 'dine_in',
items: [
{
productId: PRODUCT_ID,
quantity: qty,
unitPrice: 35000,
note: '',
},
],
note: `k6-load-test-${Date.now()}`,
});
}
// ---------------------------------------------------------------------------
// Default function — executed per VU iteration
// ---------------------------------------------------------------------------
export default function () {
const payload = buildOrderPayload();
const startTime = Date.now();
const res = http.post(`${BASE_URL}/api/v1/orders`, payload, { headers });
const duration = Date.now() - startTime;
orderCreationDuration.add(duration);
const success = check(res, {
// EN: Status is 201 Created / VI: Status là 201 Created
'status is 201': (r) => r.status === 201,
// EN: Response body has success=true / VI: Body phản hồi có success=true
'body has success:true': (r) => {
try {
return JSON.parse(r.body).success === true;
} catch {
return false;
}
},
// EN: Response time below 500ms / VI: Thời gian phản hồi dưới 500ms
'response time < 500ms': (r) => r.timings.duration < 500,
});
if (!success) {
orderCreationErrors.add(1);
} else {
orderCreationErrors.add(0);
}
// EN: Think time between requests (0.51.5s) / VI: Thời gian nghỉ giữa các request
sleep(0.5 + Math.random());
}

View File

@@ -0,0 +1,146 @@
/**
* EN: k6 load test — SignalR connection simulation via HTTP negotiation endpoint.
* VI: k6 load test — mô phỏng kết nối SignalR qua endpoint negotiation HTTP.
*
* EN: SignalR long-polling / SSE fallback test. WebSocket tests require k6 ws module.
* VI: Test long-polling / SSE fallback. Test WebSocket cần module k6 ws.
*
* EN: Tests the /hubs/pos/negotiate endpoint which is the SignalR handshake.
* VI: Test endpoint /hubs/pos/negotiate là bắt tay SignalR.
*
* Usage:
* k6 run tests/load/k6/signalr-connections.js
* k6 run --env BASE_URL=http://api.staging.goodgo.vn tests/load/k6/signalr-connections.js
*
* Thresholds:
* - 95th-percentile negotiation < 300ms
* - Concurrent connections > 200 without errors
*/
import http from 'k6/http';
import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter, Gauge } from 'k6/metrics';
// ---------------------------------------------------------------------------
// Custom metrics
// ---------------------------------------------------------------------------
const negotiationErrors = new Rate('signalr_negotiation_errors');
const negotiationDuration = new Trend('signalr_negotiation_duration', true);
const activeConnections = new Gauge('signalr_active_connections');
const messagesReceived = new Counter('signalr_messages_received');
// ---------------------------------------------------------------------------
// Test options
// ---------------------------------------------------------------------------
export const options = {
scenarios: {
// EN: Scenario 1 — HTTP negotiation endpoint load / VI: Tình huống 1 — load endpoint negotiation
negotiation_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 50 },
{ duration: '1m', target: 100 },
{ duration: '30s', target: 200 },
{ duration: '30s', target: 0 },
],
exec: 'testNegotiation',
},
// EN: Scenario 2 — Concurrent WebSocket connections / VI: Tình huống 2 — kết nối WebSocket đồng thời
websocket_connections: {
executor: 'constant-vus',
vus: 50,
duration: '2m',
startTime: '30s',
exec: 'testWebSocketConnection',
},
},
thresholds: {
http_req_duration: ['p(95)<300'],
signalr_negotiation_errors: ['rate<0.01'],
signalr_negotiation_duration: ['p(95)<300'],
},
};
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5010';
const WS_URL = __ENV.WS_URL || 'ws://localhost:5010';
const JWT_TOKEN = __ENV.JWT_TOKEN || 'test-bearer-token';
const SHOP_ID = __ENV.SHOP_ID || '00000000-0000-0000-0000-000000000001';
const headers = {
Authorization: `Bearer ${JWT_TOKEN}`,
'X-Shop-Id': SHOP_ID,
};
// ---------------------------------------------------------------------------
// Scenario 1: HTTP negotiation endpoint
// ---------------------------------------------------------------------------
export function testNegotiation() {
const res = http.post(
`${BASE_URL}/hubs/pos/negotiate?negotiateVersion=1`,
null,
{ headers }
);
negotiationDuration.add(res.timings.duration);
const ok = check(res, {
'negotiate status 200': (r) => r.status === 200,
'negotiate returns connectionToken': (r) => {
try {
const body = JSON.parse(r.body);
return body.connectionToken !== undefined || body.connectionId !== undefined;
} catch {
// EN: May return 401 in load env without valid token — still a valid response
// VI: Có thể trả 401 trong load env không có token hợp lệ
return r.status < 500;
}
},
'negotiate < 300ms': (r) => r.timings.duration < 300,
});
negotiationErrors.add(ok ? 0 : 1);
sleep(1 + Math.random() * 2);
}
// ---------------------------------------------------------------------------
// Scenario 2: WebSocket connection lifecycle
// ---------------------------------------------------------------------------
export function testWebSocketConnection() {
const wsUrl = `${WS_URL}/hubs/pos?access_token=${JWT_TOKEN}&shopId=${SHOP_ID}`;
const res = ws.connect(wsUrl, {}, (socket) => {
activeConnections.add(1);
socket.on('open', () => {
// EN: Send SignalR handshake message / VI: Gửi tin handshake SignalR
socket.send(JSON.stringify({ protocol: 'json', version: 1 }) + '\x1e');
});
socket.on('message', (msg) => {
messagesReceived.add(1);
// EN: Keep connection alive for 5s then close / VI: Giữ kết nối 5s rồi đóng
});
socket.on('error', () => {
negotiationErrors.add(1);
});
// EN: Hold connection for 5 seconds to simulate active POS session
// VI: Giữ kết nối 5 giây để mô phỏng phiên POS đang hoạt động
socket.setTimeout(() => {
socket.close();
}, 5000);
});
activeConnections.add(-1);
check(res, {
'ws connected or 401': () => res.status === 101 || res.status === 401,
});
sleep(1);
}