feat(load-tests): add K6 coverage for search, admin, and MCP endpoints

Add three new K6 load test scripts to cover previously untested API surfaces:

- search-advanced.js: Combined geo + text + filter queries, paginated deep
  search, and sort variations against /search and /search/geo (300 peak VUs)
- admin.js: Moderation queue CRUD, bulk moderation, dashboard stats, audit
  logs, and user management endpoints (50 peak VUs)
- mcp.js: MCP server discovery, SSE connection, property-search tool calls,
  valuation/batch-valuation, and feature extraction (120 peak VUs)

Also updates README with new suite documentation, per-suite custom thresholds,
and adds the new suites to the CI workflow_dispatch selector.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 20:14:52 +07:00
parent 18e50a9649
commit 33c2e5ac1d
7 changed files with 2193 additions and 5 deletions

View File

@@ -13,6 +13,9 @@ on:
- auth
- listings
- search
- search-advanced
- admin
- mcp
- payments
concurrency:

375
CODEBASE_QUICK_REFERENCE.md Normal file
View File

@@ -0,0 +1,375 @@
# GoodGo Platform - Quick Reference Guide
## 🗂️ File Structure Quick Links
### Pages (where to place new features)
- **Inquiry pages**: `apps/web/app/[locale]/(dashboard)/inquiries/`
- **Lead pages**: `apps/web/app/[locale]/(dashboard)/leads/`
- **Example pages**: `apps/web/app/[locale]/(dashboard)/listings/` (reference)
### API Layer
- **Inquiry API**: Create `apps/web/lib/inquiries-api.ts`
- **Lead API**: Create `apps/web/lib/leads-api.ts`
- **Base client**: `apps/web/lib/api-client.ts` ← reuse this
### Components
- **UI base components**: `apps/web/components/ui/` (Button, Card, Badge, Table, Select, Input)
- **Domain components**: `apps/web/components/inquiries/`, `apps/web/components/leads/`
- **Example domain component**: `apps/web/components/listings/listing-status-badge.tsx`
### Hooks
- **Create hooks**: `apps/web/lib/hooks/use-inquiries.ts`, `apps/web/lib/hooks/use-leads.ts`
- **Example hook**: `apps/web/lib/hooks/use-listings.ts`
### Stores (if needed)
- **Location**: `apps/web/lib/` (e.g., `inquiry-store.ts`, `lead-store.ts`)
- **Example**: `apps/web/lib/comparison-store.ts`
### Backend API
- **Inquiries controller**: `apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts`
- **Leads controller**: `apps/api/src/modules/leads/presentation/controllers/leads.controller.ts`
---
## 🔌 Backend API Endpoints
### Inquiries
```
POST /api/v1/inquiries Create inquiry
GET /api/v1/inquiries/listing/{id} List by listing (paginated)
GET /api/v1/inquiries/agent/me List my inquiries (AGENT role)
PATCH /api/v1/inquiries/{id}/read Mark as read (AGENT role)
```
### Leads
```
POST /api/v1/leads Create lead (AGENT role)
GET /api/v1/leads List leads (AGENT role, paginated)
GET /api/v1/leads/stats Get stats (AGENT role)
PATCH /api/v1/leads/{id}/status Update status (AGENT role)
DELETE /api/v1/leads/{id} Delete lead (AGENT role)
```
---
## 🎨 Component Templates
### List Page Template
```typescript
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Select } from '@/components/ui/select';
export default function InquiriesPage() {
const t = useTranslations('inquiries');
const [filters, setFilters] = useState({ page: 1, status: '' });
const { data, isLoading } = useQuery({
queryKey: ['inquiries', filters],
queryFn: () => inquiriesApi.list(filters),
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">{t('title')}</h1>
</div>
{/* Stats cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label={t('total')} value={data?.total ?? 0} />
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<Select value={filters.status} onChange={(e) => setFilters({...filters, status: e.target.value})}>
<option value="">{t('allStatus')}</option>
{/* ... status options */}
</Select>
</div>
{/* Table */}
{isLoading ? (
<div className="flex justify-center"><Spinner /></div>
) : (
<Card>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('name')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.items.map(item => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell><Badge>{item.status}</Badge></TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">View</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
```
### API Service Template
```typescript
// apps/web/lib/inquiries-api.ts
import { apiClient } from './api-client';
export interface InquiryDto {
id: string;
listingId: string;
userId: string;
message: string;
isRead: boolean;
createdAt: string;
}
export interface InquiryListResponse {
items: InquiryDto[];
total: number;
page: number;
limit: number;
}
export const inquiriesApi = {
list: (params: { page?: number; limit?: number; status?: string }) =>
apiClient.get<InquiryListResponse>('/inquiries', params),
getById: (id: string) =>
apiClient.get<InquiryDto>(`/inquiries/${id}`),
markAsRead: (id: string) =>
apiClient.patch(`/inquiries/${id}/read`, {}),
};
```
### Hook Template
```typescript
// apps/web/lib/hooks/use-inquiries.ts
import { useQuery } from '@tanstack/react-query';
import { inquiriesApi } from '@/lib/inquiries-api';
export const inquiriesKeys = {
all: ['inquiries'] as const,
list: (params: any) => ['inquiries', 'list', params] as const,
detail: (id: string) => ['inquiries', 'detail', id] as const,
};
export function useInquiries(params = {}) {
return useQuery({
queryKey: inquiriesKeys.list(params),
queryFn: () => inquiriesApi.list(params),
});
}
export function useInquiry(id: string) {
return useQuery({
queryKey: inquiriesKeys.detail(id),
queryFn: () => inquiriesApi.getById(id),
enabled: !!id,
});
}
```
### Status Badge Component Template
```typescript
// apps/web/components/inquiries/inquiry-status-badge.tsx
import { Badge } from '@/components/ui/badge';
const INQUIRY_STATUSES = {
NEW: { label: 'Mới', variant: 'info' as const },
READ: { label: 'Đã xem', variant: 'secondary' as const },
REPLIED: { label: 'Đã trả lời', variant: 'success' as const },
};
export function InquiryStatusBadge({ status }: { status: string }) {
const config = INQUIRY_STATUSES[status as keyof typeof INQUIRY_STATUSES] ?? {
label: status,
variant: 'outline' as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
}
```
---
## 📝 Translations (i18n)
Add to `apps/web/messages/vi.json` and `apps/web/messages/en.json`:
```json
{
"inquiries": {
"title": "Quản lý Liên hệ",
"subtitle": "Xem và quản lý các liên hệ từ khách hàng",
"allStatus": "Tất cả trạng thái",
"new": "Mới",
"read": "Đã xem",
"replied": "Đã trả lời",
"total": "Tổng liên hệ",
"thisMonth": "Tháng này",
"message": "Tin nhắn",
"from": "Từ",
"date": "Ngày tạo",
"markAsRead": "Đánh dấu đã xem"
},
"leads": {
"title": "Quản lý Khách hàng tiềm năng",
"subtitle": "Theo dõi và quản lý khách hàng tiềm năng",
"name": "Tên khách hàng",
"phone": "Số điện thoại",
"email": "Email",
"source": "Nguồn",
"score": "Điểm số",
"status": "Trạng thái",
"new": "Mới",
"contacted": "Đã liên hệ",
"qualified": "Đã xác nhận",
"negotiating": "Đang thương lượng",
"converted": "Chuyển đổi",
"lost": "Mất"
}
}
```
Usage in components:
```typescript
const t = useTranslations('inquiries');
// or
const t = useTranslations('leads');
```
---
## 🎯 Styling Conventions
### Color Classes
```css
/* Status indicators */
.success { @apply text-green-600 bg-green-50 }
.warning { @apply text-yellow-600 bg-yellow-50 }
.info { @apply text-blue-600 bg-blue-50 }
.error { @apply text-red-600 bg-red-50 }
/* Typography */
.title { @apply text-2xl font-bold }
.subtitle { @apply text-muted-foreground text-sm }
.label { @apply text-xs text-muted-foreground uppercase }
/* Layout */
.card-grid { @apply grid gap-4 sm:grid-cols-2 lg:grid-cols-3 }
.flex-between { @apply flex items-center justify-between }
```
### Responsive Breakpoints
```typescript
// Mobile first
className="w-full" // Mobile: full width
className="sm:w-1/2" // 640px+: 50%
className="md:w-1/3" // 768px+: 33%
className="lg:grid-cols-3" // 1024px+: 3 columns
```
---
## 🔐 Authentication & Authorization
### Protected Pages
```typescript
// pages automatically protected by (dashboard) group
// which has JwtAuthGuard applied via middleware or layout
// For role-specific pages (AGENT only):
// Use guard directly or check in component
const { user } = useAuthStore();
if (!user?.roles.includes('AGENT')) {
// redirect or show error
}
```
### API Calls with Auth
```typescript
// Automatically includes:
// - httpOnly cookies (JWT)
// - CSRF token from XSRF-TOKEN cookie
// - X-CSRF-Token header (POST/PATCH/DELETE)
const { data } = await inquiriesApi.list(); // Auth headers auto-included
```
---
## 🧪 Testing Patterns
See existing tests in `__tests__` folders for reference:
- `apps/web/lib/__tests__/auth-store.spec.ts`
- `apps/web/components/ui/__tests__/`
---
## ✅ Pre-Build Checklist
Before creating Inquiry & Lead pages:
- [ ] Create API service files (`inquiries-api.ts`, `leads-api.ts`)
- [ ] Create React Query hooks (`use-inquiries.ts`, `use-leads.ts`)
- [ ] Create status badge components
- [ ] Add translations to `vi.json` and `en.json`
- [ ] Create page components under `(dashboard)` group
- [ ] Test API endpoints with backend
- [ ] Verify auth guards (JwtAuthGuard, RolesGuard)
- [ ] Test pagination with query params
- [ ] Test loading/error states
- [ ] Test responsive design (mobile/tablet/desktop)
- [ ] Add JSDoc comments to reusable functions
- [ ] Test dark mode colors
---
## 📚 Key Files to Reference
```
REFERENCE PAGES:
- apps/web/app/[locale]/(dashboard)/listings/page.tsx ← Best example
- apps/web/app/[locale]/(dashboard)/dashboard/page.tsx ← Stats & cards
REFERENCE COMPONENTS:
- apps/web/components/listings/listing-status-badge.tsx ← Status badge pattern
- apps/web/components/search/filter-bar.tsx ← Filter pattern
- apps/web/components/ui/table.tsx ← Table pattern
REFERENCE HOOKS:
- apps/web/lib/hooks/use-listings.ts ← React Query pattern
- apps/web/lib/hooks/use-analytics.ts ← Complex data fetching
REFERENCE STORES:
- apps/web/lib/auth-store.ts ← Async actions pattern
- apps/web/lib/comparison-store.ts ← Persistence pattern
REFERENCE API:
- apps/web/lib/listings-api.ts ← API service pattern
- apps/web/lib/auth-api.ts ← Auth API pattern
REFERENCE LAYOUT:
- apps/web/app/[locale]/(dashboard)/layout.tsx ← Dashboard nav
REFERENCE VALIDATION:
- apps/web/lib/validations/listings.ts ← Zod schema pattern
```

1088
codebase_exploration.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,9 @@ Performance load tests for critical API paths using [K6](https://k6.io/).
| `auth.js` | Login/Register | 100 | 2min |
| `listings.js` | Search + Detail | 500 | 3min |
| `search.js` | Text + Geo search | 200 | 3min |
| `search-advanced.js` | Combined geo + text + filters, pagination | 300 | 3min |
| `admin.js` | Moderation queue, dashboard, audit logs | 50 | 2.5min |
| `mcp.js` | MCP server discovery, property-search, valuation | 120 | 2.5min |
| `payments.js` | Create + List | 50 | 2min |
## SLA Thresholds
@@ -22,6 +25,18 @@ Performance load tests for critical API paths using [K6](https://k6.io/).
| p99 latency | < 1000ms |
| Error rate | < 1% |
### Per-Suite Custom Thresholds
| Suite | Metric | Threshold |
|-------|--------|-----------|
| search-advanced | advanced_search_duration p95 | < 800ms |
| search-advanced | geo_filter_search_duration p95 | < 800ms |
| admin | moderation_action_duration p95 | < 800ms |
| admin | admin_dashboard_duration p95 | < 500ms |
| mcp | mcp_property_search_duration p95 | < 1500ms |
| mcp | mcp_valuation_duration p95 | < 1000ms |
| mcp | mcp_batch_valuation_duration p95 | < 2000ms |
## Prerequisites
```bash
@@ -40,6 +55,9 @@ pnpm --filter @goodgo/api run dev
k6 run load-tests/scripts/auth.js
k6 run load-tests/scripts/listings.js
k6 run load-tests/scripts/search.js
k6 run load-tests/scripts/search-advanced.js
k6 run load-tests/scripts/admin.js
k6 run load-tests/scripts/mcp.js
k6 run load-tests/scripts/payments.js
# Run against a custom API URL
@@ -62,11 +80,16 @@ Trigger via `workflow_dispatch` with a suite selector.
```
load-tests/
├── lib/
│ └── config.js # Shared config, helpers, SLA thresholds
│ └── config.js # Shared config, helpers, SLA thresholds
├── scripts/
│ ├── auth.js # Auth flow load tests
│ ├── listings.js # Listings search + detail
│ ├── search.js # Full-text + geo search
── payments.js # Payment creation + listing
│ ├── auth.js # Auth flow load tests
│ ├── listings.js # Listings search + detail
│ ├── search.js # Full-text + geo search (basic)
── search-advanced.js # Combined geo + text + filter search
│ ├── admin.js # Admin moderation, dashboard, audit
│ ├── mcp.js # MCP server endpoints (property-search, valuation)
│ └── payments.js # Payment creation + listing
├── results/
│ └── BASELINE-REPORT.md # Baseline performance report
└── README.md
```

271
load-tests/scripts/admin.js Normal file
View File

@@ -0,0 +1,271 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { BASE_URL, SLA_THRESHOLDS, authHeaders } from '../lib/config.js';
/**
* Admin Moderation Queue Load Test — Goodgo Platform
*
* Tests admin endpoints: moderation queue listing, approve/reject,
* bulk moderation, user management, dashboard stats, and audit logs.
* Requires an admin user account.
*/
const moderationListDuration = new Trend('moderation_list_duration', true);
const moderationActionDuration = new Trend('moderation_action_duration', true);
const adminDashboardDuration = new Trend('admin_dashboard_duration', true);
const auditLogDuration = new Trend('audit_log_duration', true);
const userListDuration = new Trend('user_list_duration', true);
const adminFailRate = new Rate('admin_failures');
export const options = {
scenarios: {
admin_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '20s', target: 10 }, // admin traffic is lower volume
{ duration: '1m', target: 30 }, // sustain moderate load
{ duration: '1m', target: 50 }, // stress peak
{ duration: '20s', target: 0 }, // ramp down
],
gracefulRampDown: '10s',
},
},
thresholds: {
...SLA_THRESHOLDS,
moderation_list_duration: ['p(95)<500'],
moderation_action_duration: ['p(95)<800'],
admin_dashboard_duration: ['p(95)<500'],
audit_log_duration: ['p(95)<500'],
user_list_duration: ['p(95)<500'],
admin_failures: ['rate<0.05'],
},
};
export function setup() {
// Register admin user via the standard auth flow.
// In a real environment the admin role would be pre-seeded;
// for load testing we attempt registration then login.
const adminPayload = JSON.stringify({
phone: '0900000001',
password: 'AdminLoad@1234!',
fullName: 'K6 Admin User',
email: 'k6-admin@goodgo.test',
});
let res = http.post(`${BASE_URL}/auth/register`, adminPayload, {
headers: { 'Content-Type': 'application/json' },
tags: { name: 'setup_admin_register' },
});
// If already exists, login
if (res.status !== 200 && res.status !== 201) {
res = http.post(
`${BASE_URL}/auth/login`,
JSON.stringify({ phone: '0900000001', password: 'AdminLoad@1234!' }),
{
headers: { 'Content-Type': 'application/json' },
tags: { name: 'setup_admin_login' },
},
);
}
let accessToken = null;
if (res.status === 200 || res.status === 201) {
try {
accessToken = JSON.parse(res.body).accessToken;
} catch (_) { /* ignore */ }
}
// Also create some test listings for moderation queue
const listingIds = [];
if (accessToken) {
for (let i = 0; i < 20; i++) {
const listing = {
title: `K6 Admin Test Listing ${i}`,
description: `Moderation load test listing #${i}`,
transactionType: i % 2 === 0 ? 'SALE' : 'RENT',
propertyType: ['APARTMENT', 'HOUSE', 'LAND'][i % 3],
address: `${200 + i} Đường Kiểm Duyệt`,
ward: 'Phường 1',
district: 'Quận 1',
city: 'TP. Hồ Chí Minh',
latitude: 10.7769 + (i * 0.002),
longitude: 106.7009 + (i * 0.002),
area: 50 + (i * 5),
bedrooms: 1 + (i % 4),
bathrooms: 1 + (i % 3),
floors: 1 + (i % 3),
priceVND: 1000000000 + (i * 200000000),
direction: 'EAST',
};
const createRes = http.post(
`${BASE_URL}/listings`,
JSON.stringify(listing),
{ headers: authHeaders(accessToken), tags: { name: 'setup_create_listing' } },
);
if (createRes.status === 201 || createRes.status === 200) {
try {
listingIds.push(JSON.parse(createRes.body).id);
} catch (_) { /* skip */ }
}
}
}
return { accessToken, listingIds };
}
export default function (data) {
if (!data.accessToken) {
// Without auth, still test unauthenticated error handling
const res = http.get(`${BASE_URL}/admin/moderation?page=1&limit=10`, {
tags: { name: 'GET /admin/moderation (unauth)' },
});
check(res, {
'unauth moderation: returns 401': (r) => r.status === 401,
});
sleep(1);
return;
}
const headers = authHeaders(data.accessToken);
const iter = __ITER;
const scenario = iter % 7;
if (scenario === 0) {
// --- Moderation queue listing ---
const page = 1 + (iter % 3);
const res = http.get(`${BASE_URL}/admin/moderation?page=${page}&limit=10`, {
headers,
tags: { name: 'GET /admin/moderation' },
});
moderationListDuration.add(res.timings.duration);
const ok = check(res, {
'moderation list: status 200|403': (r) => r.status === 200 || r.status === 403,
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 1 && data.listingIds.length > 0) {
// --- Approve listing ---
const listingId = data.listingIds[iter % data.listingIds.length];
const payload = JSON.stringify({
listingId,
moderationNotes: `K6 load test approval — iteration ${iter}`,
});
const res = http.post(`${BASE_URL}/admin/moderation/approve`, payload, {
headers,
tags: { name: 'POST /admin/moderation/approve' },
});
moderationActionDuration.add(res.timings.duration);
const ok = check(res, {
'approve: status 200|201|403|404|409': (r) =>
[200, 201, 403, 404, 409].includes(r.status),
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 2 && data.listingIds.length > 0) {
// --- Reject listing ---
const listingId = data.listingIds[(iter + 1) % data.listingIds.length];
const payload = JSON.stringify({
listingId,
reason: `K6 load test rejection — does not meet criteria (iter ${iter})`,
});
const res = http.post(`${BASE_URL}/admin/moderation/reject`, payload, {
headers,
tags: { name: 'POST /admin/moderation/reject' },
});
moderationActionDuration.add(res.timings.duration);
const ok = check(res, {
'reject: status 200|201|403|404|409': (r) =>
[200, 201, 403, 404, 409].includes(r.status),
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 3 && data.listingIds.length >= 3) {
// --- Bulk moderation ---
const startIdx = iter % Math.max(1, data.listingIds.length - 3);
const batchIds = data.listingIds.slice(startIdx, startIdx + 3);
const payload = JSON.stringify({
listingIds: batchIds,
action: iter % 2 === 0 ? 'approve' : 'reject',
reason: `K6 bulk moderation test — iteration ${iter}`,
});
const res = http.post(`${BASE_URL}/admin/moderation/bulk`, payload, {
headers,
tags: { name: 'POST /admin/moderation/bulk' },
});
moderationActionDuration.add(res.timings.duration);
const ok = check(res, {
'bulk moderate: status 200|201|403|409': (r) =>
[200, 201, 403, 409].includes(r.status),
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 4) {
// --- Admin dashboard ---
const res = http.get(`${BASE_URL}/admin/dashboard`, {
headers,
tags: { name: 'GET /admin/dashboard' },
});
adminDashboardDuration.add(res.timings.duration);
const ok = check(res, {
'dashboard: status 200|403': (r) => r.status === 200 || r.status === 403,
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 5) {
// --- Audit logs with various filters ---
const filters = [
'',
'?action=LISTING_APPROVED',
'?action=USER_BANNED',
`?startDate=2026-01-01&endDate=2026-12-31`,
];
const filter = filters[iter % filters.length];
const res = http.get(`${BASE_URL}/admin/audit-logs${filter}`, {
headers,
tags: { name: 'GET /admin/audit-logs' },
});
auditLogDuration.add(res.timings.duration);
const ok = check(res, {
'audit logs: status 200|403': (r) => r.status === 200 || r.status === 403,
});
if (!ok) adminFailRate.add(1);
} else {
// --- User management listing ---
const queries = [
'?limit=20',
'?role=AGENT&limit=10',
'?isActive=true&limit=20',
'?search=test&limit=10',
];
const query = queries[iter % queries.length];
const res = http.get(`${BASE_URL}/admin/users${query}`, {
headers,
tags: { name: 'GET /admin/users' },
});
userListDuration.add(res.timings.duration);
const ok = check(res, {
'user list: status 200|403': (r) => r.status === 200 || r.status === 403,
});
if (!ok) adminFailRate.add(1);
}
sleep(Math.random() * 1.5 + 0.5);
}

267
load-tests/scripts/mcp.js Normal file
View File

@@ -0,0 +1,267 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { BASE_URL, SLA_THRESHOLDS, registerTestUser, authHeaders } from '../lib/config.js';
/**
* MCP Server Endpoints Load Test — Goodgo Platform
*
* Tests MCP server discovery, SSE connection establishment, and tool
* invocations for property-search and valuation servers. MCP uses an
* SSE+message POST pattern — this test exercises both connection setup
* and message throughput.
*/
const mcpServerListDuration = new Trend('mcp_server_list_duration', true);
const mcpSseConnectDuration = new Trend('mcp_sse_connect_duration', true);
const mcpPropertySearchDuration = new Trend('mcp_property_search_duration', true);
const mcpValuationDuration = new Trend('mcp_valuation_duration', true);
const mcpComparisonDuration = new Trend('mcp_comparison_duration', true);
const mcpBatchValuationDuration = new Trend('mcp_batch_valuation_duration', true);
const mcpFailRate = new Rate('mcp_failures');
export const options = {
scenarios: {
mcp_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '20s', target: 20 }, // warm up
{ duration: '1m', target: 80 }, // ramp to moderate
{ duration: '1m', target: 120 }, // stress peak
{ duration: '20s', target: 0 }, // ramp down
],
gracefulRampDown: '10s',
},
},
thresholds: {
...SLA_THRESHOLDS,
mcp_server_list_duration: ['p(95)<300'],
mcp_sse_connect_duration: ['p(95)<1000'],
mcp_property_search_duration: ['p(95)<1500'],
mcp_valuation_duration: ['p(95)<1000'],
mcp_comparison_duration: ['p(95)<1500'],
mcp_batch_valuation_duration: ['p(95)<2000'],
mcp_failures: ['rate<0.05'],
},
};
// Property search tool invocations
const PROPERTY_SEARCH_CALLS = [
{ query: 'căn hộ quận 1', lat: 10.7769, lng: 106.7009, radiusKm: 5 },
{ query: 'nhà phố Hà Nội', lat: 21.0285, lng: 105.8542, radiusKm: 3 },
{ query: 'đất nền Thủ Đức' },
{ query: 'chung cư giá rẻ', lat: 10.8231, lng: 106.6297, radiusKm: 2 },
{ query: 'biệt thự Đà Nẵng', lat: 16.0544, lng: 108.2022, radiusKm: 10 },
{ query: 'apartment for rent', lat: 10.7769, lng: 106.7009, radiusKm: 3 },
];
// Valuation tool invocations
const VALUATION_CALLS = [
{ area: 80, district: 'Quận 1', city: 'TP. Hồ Chí Minh', propertyType: 'APARTMENT', bedrooms: 2, bathrooms: 2, floors: 1 },
{ area: 120, district: 'Quận 7', city: 'TP. Hồ Chí Minh', propertyType: 'HOUSE', bedrooms: 3, bathrooms: 2, floors: 3, frontage: 5 },
{ area: 200, district: 'Cầu Giấy', city: 'Hà Nội', propertyType: 'APARTMENT', bedrooms: 3, bathrooms: 2, floors: 1, yearBuilt: 2020 },
{ area: 500, district: 'Hải Châu', city: 'Đà Nẵng', propertyType: 'LAND', hasLegalPaper: true },
{ area: 60, district: 'Bình Thạnh', city: 'TP. Hồ Chí Minh', propertyType: 'APARTMENT', bedrooms: 1, bathrooms: 1, floors: 1 },
{ area: 150, district: 'Ba Đình', city: 'Hà Nội', propertyType: 'HOUSE', bedrooms: 4, bathrooms: 3, floors: 4, roadWidth: 6 },
];
// Feature extraction text samples (Vietnamese listing descriptions)
const LISTING_TEXTS = [
'Bán căn hộ cao cấp 80m2, 2PN 2WC, view sông, full nội thất, giá 4.5 tỷ',
'Cho thuê nhà phố mặt tiền đường lớn, 5x20m, 3 lầu, phù hợp kinh doanh',
'Đất nền khu dân cư hiện hữu, sổ đỏ riêng, 100m2 thổ cư, giá 2.8 tỷ',
'Chung cư giá rẻ quận 8, 50m2, 1PN, ban công thoáng mát, 1.2 tỷ',
'Biệt thự Phú Mỹ Hưng 300m2 đất, 4 phòng ngủ, hồ bơi riêng, 25 tỷ',
];
export function setup() {
// Register test users for authenticated MCP access
const tokens = [];
for (let i = 0; i < 5; i++) {
const phone = `0930${String(i).padStart(6, '0')}`;
const t = registerTestUser(http, phone);
if (t) tokens.push(t.accessToken);
}
return { tokens };
}
/**
* Build an MCP JSON-RPC message for tool invocation.
*/
function mcpToolCall(method, params, id) {
return JSON.stringify({
jsonrpc: '2.0',
id: id || 1,
method: method,
params: params,
});
}
export default function (data) {
const iter = __ITER;
const hasAuth = data.tokens.length > 0;
const token = hasAuth ? data.tokens[iter % data.tokens.length] : null;
const headers = token ? authHeaders(token) : { 'Content-Type': 'application/json' };
const scenario = iter % 8;
if (scenario === 0) {
// --- List available MCP servers ---
const res = http.get(`${BASE_URL}/mcp/servers`, {
headers,
tags: { name: 'GET /mcp/servers' },
});
mcpServerListDuration.add(res.timings.duration);
const ok = check(res, {
'mcp servers: status 200|401|403': (r) =>
[200, 401, 403].includes(r.status),
'mcp servers: valid response': (r) => {
if (r.status !== 200) return true;
try { return Array.isArray(JSON.parse(r.body)); } catch { return false; }
},
});
if (!ok) mcpFailRate.add(1);
} else if (scenario === 1) {
// --- SSE connection to property-search server ---
// K6 does not natively support SSE, so we test the initial connection
const res = http.get(`${BASE_URL}/mcp/goodgo-property-search/sse`, {
headers,
tags: { name: 'GET /mcp/property-search/sse' },
timeout: '5s',
});
mcpSseConnectDuration.add(res.timings.duration);
const ok = check(res, {
'sse connect: status 200|401|403|404': (r) =>
[200, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else if (scenario === 2) {
// --- SSE connection to valuation server ---
const res = http.get(`${BASE_URL}/mcp/goodgo-valuation/sse`, {
headers,
tags: { name: 'GET /mcp/valuation/sse' },
timeout: '5s',
});
mcpSseConnectDuration.add(res.timings.duration);
const ok = check(res, {
'sse connect valuation: status 200|401|403|404': (r) =>
[200, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else if (scenario <= 4) {
// --- Property search tool call (25% of traffic) ---
const searchParams = PROPERTY_SEARCH_CALLS[iter % PROPERTY_SEARCH_CALLS.length];
const message = mcpToolCall('tools/call', {
name: 'search_properties',
arguments: searchParams,
}, iter);
const sessionId = `k6-session-${__VU}-${iter}`;
const res = http.post(
`${BASE_URL}/mcp/goodgo-property-search/messages?sessionId=${sessionId}`,
message,
{
headers,
tags: { name: 'POST /mcp/property-search/messages' },
},
);
mcpPropertySearchDuration.add(res.timings.duration);
const ok = check(res, {
'property search: status 200|202|401|403|404': (r) =>
[200, 202, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else if (scenario <= 6) {
// --- Valuation tool call (25% of traffic) ---
const valuationParams = VALUATION_CALLS[iter % VALUATION_CALLS.length];
const message = mcpToolCall('tools/call', {
name: 'estimate_property_value',
arguments: valuationParams,
}, iter);
const sessionId = `k6-valuation-${__VU}-${iter}`;
const res = http.post(
`${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
message,
{
headers,
tags: { name: 'POST /mcp/valuation/messages' },
},
);
mcpValuationDuration.add(res.timings.duration);
const ok = check(res, {
'valuation: status 200|202|401|403|404': (r) =>
[200, 202, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else {
// --- Feature extraction + batch valuation (12.5% of traffic) ---
if (iter % 2 === 0) {
// Feature extraction
const text = LISTING_TEXTS[iter % LISTING_TEXTS.length];
const message = mcpToolCall('tools/call', {
name: 'extract_listing_features',
arguments: { text },
}, iter);
const sessionId = `k6-extract-${__VU}-${iter}`;
const res = http.post(
`${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
message,
{
headers,
tags: { name: 'POST /mcp/valuation/extract' },
},
);
mcpValuationDuration.add(res.timings.duration);
const ok = check(res, {
'extract features: status 200|202|401|403|404': (r) =>
[200, 202, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else {
// Batch valuation (2-5 properties)
const batchSize = 2 + (iter % 4);
const properties = [];
for (let i = 0; i < batchSize; i++) {
properties.push(VALUATION_CALLS[(iter + i) % VALUATION_CALLS.length]);
}
const message = mcpToolCall('tools/call', {
name: 'batch_valuation',
arguments: { properties },
}, iter);
const sessionId = `k6-batch-${__VU}-${iter}`;
const res = http.post(
`${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
message,
{
headers,
tags: { name: 'POST /mcp/valuation/batch' },
},
);
mcpBatchValuationDuration.add(res.timings.duration);
const ok = check(res, {
'batch valuation: status 200|202|401|403|404': (r) =>
[200, 202, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
}
}
sleep(Math.random() * 1.5 + 0.5);
}

View File

@@ -0,0 +1,161 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { BASE_URL, SLA_THRESHOLDS } from '../lib/config.js';
/**
* Advanced Search Load Test — Goodgo Platform
*
* Tests combined geo + text + filter queries against /search and /search/geo
* endpoints. Simulates real user behaviour: multi-filter search, pagination,
* sort variations, and geo-bounded text queries.
*/
const advancedSearchDuration = new Trend('advanced_search_duration', true);
const geoFilterDuration = new Trend('geo_filter_search_duration', true);
const paginatedSearchDuration = new Trend('paginated_search_duration', true);
const searchFailRate = new Rate('advanced_search_failures');
export const options = {
scenarios: {
advanced_search_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 50 }, // warm up
{ duration: '1m', target: 200 }, // ramp to peak
{ duration: '1m', target: 300 }, // stress peak
{ duration: '30s', target: 0 }, // ramp down
],
gracefulRampDown: '10s',
},
},
thresholds: {
...SLA_THRESHOLDS,
advanced_search_duration: ['p(95)<800'],
geo_filter_search_duration: ['p(95)<800'],
paginated_search_duration: ['p(95)<500'],
advanced_search_failures: ['rate<0.05'],
},
};
// Vietnamese text queries combined with property filters
const COMBINED_QUERIES = [
{ q: 'căn hộ', propertyType: 'APARTMENT', transactionType: 'SALE', priceMin: 1000000000, priceMax: 5000000000 },
{ q: 'nhà phố', propertyType: 'HOUSE', transactionType: 'SALE', city: 'TP. Hồ Chí Minh' },
{ q: 'đất nền', propertyType: 'LAND', areaMin: 100, areaMax: 500, district: 'Quận 9' },
{ q: 'chung cư', transactionType: 'RENT', priceMin: 5000000, priceMax: 20000000, bedrooms: 2 },
{ q: 'biệt thự', propertyType: 'HOUSE', areaMin: 200, city: 'Hà Nội' },
{ q: 'apartment', transactionType: 'SALE', priceMin: 2000000000, bedrooms: 3 },
{ q: 'phòng trọ', transactionType: 'RENT', priceMax: 5000000 },
{ q: 'villa', propertyType: 'HOUSE', areaMin: 300, priceMin: 10000000000 },
];
// Geo-bounded searches with various radii and filters
const GEO_FILTER_QUERIES = [
{ lat: 10.7769, lng: 106.7009, radiusKm: 5, propertyType: 'APARTMENT', transactionType: 'SALE' },
{ lat: 10.7769, lng: 106.7009, radiusKm: 2, propertyType: 'HOUSE', priceMin: 3000000000 },
{ lat: 21.0285, lng: 105.8542, radiusKm: 3, transactionType: 'RENT', bedrooms: 2 },
{ lat: 10.8231, lng: 106.6297, radiusKm: 1, propertyType: 'LAND', areaMin: 50 },
{ lat: 16.0544, lng: 108.2022, radiusKm: 10, transactionType: 'SALE' },
{ lat: 21.0067, lng: 105.8400, radiusKm: 5, propertyType: 'APARTMENT', priceMax: 5000000000 },
{ lat: 10.8500, lng: 106.7700, radiusKm: 3, propertyType: 'HOUSE', bedrooms: 3 },
{ lat: 10.7300, lng: 106.6500, radiusKm: 2, transactionType: 'RENT', priceMax: 15000000 },
];
// Sort options
const SORT_OPTIONS = ['priceAsc', 'priceDesc', 'newest', 'areaDesc'];
function buildQueryString(params) {
const parts = [];
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
parts.push(`${key}=${encodeURIComponent(value)}`);
}
}
return parts.join('&');
}
export default function () {
const iter = __ITER;
const scenario = iter % 5;
if (scenario <= 1) {
// --- Combined text + filter search (40% of traffic) ---
const query = COMBINED_QUERIES[iter % COMBINED_QUERIES.length];
const sortBy = SORT_OPTIONS[iter % SORT_OPTIONS.length];
const qs = buildQueryString({
...query,
sortBy,
page: 1,
perPage: 20,
});
const url = `${BASE_URL}/search?${qs}`;
const res = http.get(url, {
tags: { name: 'GET /search (advanced)' },
});
advancedSearchDuration.add(res.timings.duration);
const ok = check(res, {
'advanced search: status 200|503': (r) => r.status === 200 || r.status === 503,
'advanced search: valid response': (r) => {
if (r.status === 503) return true; // Typesense unavailable
try { return JSON.parse(r.body).data !== undefined; } catch { return false; }
},
});
if (!ok) searchFailRate.add(1);
} else if (scenario <= 3) {
// --- Geo + filter search (40% of traffic) ---
const geoQuery = GEO_FILTER_QUERIES[iter % GEO_FILTER_QUERIES.length];
const { lat, lng, radiusKm, ...filters } = geoQuery;
const qs = buildQueryString({
lat,
lng,
radiusKm,
...filters,
limit: 20,
});
const url = `${BASE_URL}/search/geo?${qs}`;
const res = http.get(url, {
tags: { name: 'GET /search/geo (filtered)' },
});
geoFilterDuration.add(res.timings.duration);
const ok = check(res, {
'geo filter: status 200|503': (r) => r.status === 200 || r.status === 503,
'geo filter: valid response': (r) => {
if (r.status === 503) return true;
try { return JSON.parse(r.body).data !== undefined; } catch { return false; }
},
});
if (!ok) searchFailRate.add(1);
} else {
// --- Paginated deep search (20% of traffic) ---
const query = COMBINED_QUERIES[iter % COMBINED_QUERIES.length];
const page = 1 + (iter % 5); // pages 1-5
const qs = buildQueryString({
q: query.q,
propertyType: query.propertyType,
page,
perPage: 20,
sortBy: SORT_OPTIONS[iter % SORT_OPTIONS.length],
});
const url = `${BASE_URL}/search?${qs}`;
const res = http.get(url, {
tags: { name: 'GET /search (paginated)' },
});
paginatedSearchDuration.add(res.timings.duration);
const ok = check(res, {
'paginated search: status 200|503': (r) => r.status === 200 || r.status === 503,
});
if (!ok) searchFailRate.add(1);
}
sleep(Math.random() * 1 + 0.3);
}