Migrate
This commit is contained in:
58
microservices/tests/contract/README.md
Normal file
58
microservices/tests/contract/README.md
Normal 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` và `PACT_BROKER_TOKEN`.
|
||||
20
microservices/tests/contract/package.json
Normal file
20
microservices/tests/contract/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
16
microservices/tests/contract/vitest.config.ts
Normal file
16
microservices/tests/contract/vitest.config.ts
Normal 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 } },
|
||||
},
|
||||
});
|
||||
72
microservices/tests/load/k6/README.md
Normal file
72
microservices/tests/load/k6/README.md
Normal 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
|
||||
```
|
||||
121
microservices/tests/load/k6/ads-tracking.js
Normal file
121
microservices/tests/load/k6/ads-tracking.js
Normal 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);
|
||||
}
|
||||
128
microservices/tests/load/k6/catalog-listing.js
Normal file
128
microservices/tests/load/k6/catalog-listing.js
Normal 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);
|
||||
}
|
||||
119
microservices/tests/load/k6/order-creation.js
Normal file
119
microservices/tests/load/k6/order-creation.js
Normal 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.5–1.5s) / VI: Thời gian nghỉ giữa các request
|
||||
sleep(0.5 + Math.random());
|
||||
}
|
||||
146
microservices/tests/load/k6/signalr-connections.js
Normal file
146
microservices/tests/load/k6/signalr-connections.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user