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:
3
.github/workflows/load-test.yml
vendored
3
.github/workflows/load-test.yml
vendored
@@ -13,6 +13,9 @@ on:
|
||||
- auth
|
||||
- listings
|
||||
- search
|
||||
- search-advanced
|
||||
- admin
|
||||
- mcp
|
||||
- payments
|
||||
|
||||
concurrency:
|
||||
|
||||
375
CODEBASE_QUICK_REFERENCE.md
Normal file
375
CODEBASE_QUICK_REFERENCE.md
Normal 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
1088
codebase_exploration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
271
load-tests/scripts/admin.js
Normal 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
267
load-tests/scripts/mcp.js
Normal 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);
|
||||
}
|
||||
161
load-tests/scripts/search-advanced.js
Normal file
161
load-tests/scripts/search-advanced.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user