diff --git a/K6_ENDPOINTS_SUMMARY.md b/K6_ENDPOINTS_SUMMARY.md new file mode 100644 index 0000000..6687ad3 --- /dev/null +++ b/K6_ENDPOINTS_SUMMARY.md @@ -0,0 +1,342 @@ +# GoodGo Platform — K6 Load Testing Endpoints Summary + +Quick reference for all testable API endpoints. + +## 📍 Base URL +``` +http://localhost:3001/api/v1 +``` + +--- + +## 🔐 Authentication (Auth Module) + +### Public Endpoints (No Auth Required) + +| Method | Path | Rate Limit | Purpose | +|--------|------|-----------|---------| +| **POST** | `/auth/register` | 5/hour | Register new user with phone/password/name | +| **POST** | `/auth/login` | 5/hour | Login with phone/password (basic auth) | +| **POST** | `/auth/refresh` | 5/hour | Refresh expired access token | +| **POST** | `/auth/logout` | None | Clear auth cookies | +| **POST** | `/auth/exchange-token` | None | Exchange OAuth tokens for httpOnly cookies | + +### Protected Endpoints (JWT Required) + +| Method | Path | Rate Limit | Purpose | +|--------|------|-----------|---------| +| **GET** | `/auth/profile` | None | Get authenticated user profile | +| **GET** | `/auth/profile/agent` | None | Get agent profile (if user is agent) | + +### Admin Only + +| Method | Path | Rate Limit | Auth | Purpose | +|--------|------|-----------|------|---------| +| **PATCH** | `/auth/kyc` | None | JWT+Admin | Verify/update user KYC status | + +--- + +## 🏠 Listings (Listings Module) + +### Public Endpoints + +| Method | Path | Rate Limit | Purpose | +|--------|------|-----------|---------| +| **GET** | `/listings` | None | Search/filter listings (queryable) | +| **GET** | `/listings/:id` | None | Get listing detail by ID | + +### Protected Endpoints (JWT Required) + +| Method | Path | Quota Gate | Purpose | +|--------|------|-----------|---------| +| **POST** | `/listings` | Yes | Create new property listing | +| **PATCH** | `/listings/:id/status` | No | Update listing status (owner only) | +| **POST** | `/listings/:id/media` | No | Upload photo/video for listing (owner only) | + +### Admin Only + +| Method | Path | Purpose | +|--------|------|---------| +| **GET** | `/listings/pending` | Get listings awaiting moderation (paginated) | +| **PATCH** | `/listings/:id/moderate` | Approve/reject listing & score it | + +--- + +## 💳 Payments (Payments Module) + +### Protected Endpoints (JWT Required) + +| Method | Path | Purpose | +|--------|------|---------| +| **POST** | `/payments` | Create payment (initiates payment flow) | +| **GET** | `/payments` | List user's transactions (paginated) | +| **GET** | `/payments/:id` | Get payment status by ID | + +### Admin Only + +| Method | Path | Purpose | +|--------|------|---------| +| **POST** | `/payments/:id/refund` | Initiate refund for payment | + +### Webhook (Unthrottled, No Auth) + +| Method | Path | Rate Limit | Purpose | +|--------|------|-----------|---------| +| **POST** | `/payments/callback/:provider` | 20/min | Handle payment provider callbacks (VNPay, MoMo, ZaloPay) | + +--- + +## 🔍 Search (Search Module) + +### Public Endpoints + +| Method | Path | Purpose | Query Params | +|--------|------|---------|--------------| +| **GET** | `/search` | Full-text search listings | q, propertyType, transactionType, priceMin/Max, areaMin/Max, bedrooms, district, city, sortBy, page, perPage | +| **GET** | `/search/geo` | Geographic radius search | lat, lng, radiusKm, propertyType, transactionType, priceMin/Max, sortBy, page, perPage | + +### Admin Only + +| Method | Path | Purpose | +|--------|------|---------| +| **POST** | `/search/reindex` | Reindex all properties in search engine | + +--- + +## 📊 Key Data Shapes + +### User Registration +```json +POST /auth/register +{ + "phone": "0901234567", + "password": "SecurePass123!", + "fullName": "Nguyen Van A", + "email": "user@example.com" // optional +} +``` + +### User Login +```json +POST /auth/login +{ + "phone": "0901234567", + "password": "SecurePass123!" +} +``` + +### Create Listing (Minimal) +```json +POST /listings +{ + "transactionType": "SALE", + "priceVND": "5500000000", + "propertyType": "APARTMENT", + "title": "Căn hộ 3PN view sông", + "description": "Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ...", + "address": "208 Nguyễn Hữu Cảnh", + "ward": "Phường 22", + "district": "Bình Thạnh", + "city": "Hồ Chí Minh", + "latitude": 10.7942, + "longitude": 106.7219, + "areaM2": 85.5 +} +``` + +### Search Listings +```json +GET /listings?transactionType=SALE&city=Hồ Chi Minh&minPrice=2000000000&maxPrice=10000000000&page=1&limit=20 +``` + +### Full-Text Search +```json +GET /search?q=chung cu quan 7&propertyType=apartment&transactionType=sale&page=1&perPage=20 +``` + +### Geo Search +```json +GET /search/geo?lat=10.7769&lng=106.7009&radiusKm=5&transactionType=sale&page=1&perPage=20 +``` + +### Create Payment +```json +POST /payments +{ + "provider": "VNPAY", + "type": "LISTING_FEE", + "amountVND": 500000, + "description": "Listing fee", + "returnUrl": "https://example.com/return", + "idempotencyKey": "uuid-123" +} +``` + +--- + +## 🎯 K6 Test Scenarios + +### Load Test 1: Search Load (High Volume, Public) +- **Endpoint**: `GET /search` & `GET /search/geo` +- **Load Profile**: Ramp 100 → 1000 users over 10 min +- **Success Criteria**: p(95) < 500ms, p(99) < 1000ms +- **Expected**: Public search should handle high concurrent load + +### Load Test 2: Authentication (Moderate Load, Gated) +- **Endpoint**: `POST /auth/register`, `POST /auth/login` +- **Load Profile**: Ramp 10 → 100 users +- **Rate Limit**: 5/hour per endpoint +- **Success Criteria**: All requests within rate limit, p(95) < 300ms +- **Expected**: Should gracefully reject over-limit requests + +### Load Test 3: Listing Creation (Low Load, Quota Gated) +- **Endpoint**: `POST /listings` +- **Load Profile**: Ramp 5 → 50 users over 5 min +- **Rate Limit**: Quota-gated (per subscription plan) +- **Success Criteria**: 201 for successful creates, 403 for quota exceeded +- **Expected**: Quota guard enforces plan limits + +### Load Test 4: Payment Processing (Medium Load, Unthrottled) +- **Endpoint**: `POST /payments` +- **Load Profile**: Ramp 20 → 200 users +- **Dependencies**: Requires authenticated session (JWT) +- **Success Criteria**: p(95) < 1s (external provider latency) +- **Expected**: System handles payment initiation concurrently + +### Load Test 5: Payment Webhooks (High Volume, Throttled) +- **Endpoint**: `POST /payments/callback/vnpay` +- **Load Profile**: Sustained 20 requests/min (rate limit) +- **Rate Limit**: 20/min enforced +- **Success Criteria**: All requests process within rate limit +- **Expected**: Webhook queue prevents abuse + +--- + +## 🔗 Authentication Flow for K6 + +### Cookie-Based Flow (Recommended) +```javascript +// 1. Register/Login +const loginRes = http.post(`${BASE_URL}/auth/login`, { + phone, password +}); +// Cookies: access_token, refresh_token, goodgo_authenticated + +// 2. Use cookies for authenticated requests +const profileRes = http.get(`${BASE_URL}/auth/profile`); +// Browser automatically sends cookies + +// 3. Refresh when needed +const refreshRes = http.post(`${BASE_URL}/auth/refresh`); +``` + +### Token-Based Flow (Alternative) +```javascript +// 1. Capture tokens from register/login +const loginRes = http.post(`${BASE_URL}/auth/login`, { phone, password }); +const { accessToken, refreshToken } = loginRes.json(); + +// 2. Use Bearer token header +const params = { + headers: { Authorization: `Bearer ${accessToken}` } +}; +const profileRes = http.get(`${BASE_URL}/auth/profile`, params); + +// 3. Refresh access token +const refreshRes = http.post(`${BASE_URL}/auth/refresh`, + { refreshToken }, + params +); +``` + +--- + +## 🏪 Test Data Resources + +### Available Seed Data +- **Users**: Various roles (USER, AGENT, ADMIN) +- **Listings**: Properties across Ho Chi Minh City districts +- **Districts/Wards**: Vietnamese administrative data +- **Property Types**: APARTMENT, HOUSE, LAND, SHOP, etc. +- **Transaction Types**: SALE, RENT + +### Test Fixtures +- See `e2e/fixtures.ts` for test data generators +- Use `createTestUser()` to generate unique test users +- Test database seeded in `global-setup.ts` + +--- + +## ⚡ Quick K6 Script Template + +```javascript +import http from 'k6/http'; +import { check, group, sleep } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1'; + +export const options = { + stages: [ + { duration: '2m', target: 100 }, // Ramp up + { duration: '5m', target: 100 }, // Sustain + { duration: '2m', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + http_req_failed: ['rate<0.1'], + }, +}; + +export default function() { + group('Search - Public, High Volume', () => { + const res = http.get(`${BASE_URL}/search?q=chung cu&page=1&perPage=20`); + check(res, { 'status is 200': (r) => r.status === 200 }); + sleep(1); + }); + + group('Geo Search - Public', () => { + const res = http.get(`${BASE_URL}/search/geo?lat=10.77&lng=106.70&radiusKm=5&perPage=20`); + check(res, { 'status is 200': (r) => r.status === 200 }); + sleep(1); + }); +} +``` + +--- + +## 📌 Important Rate Limits + +| Endpoint | Limit | Window | +|----------|-------|--------| +| `/auth/register` | 5 | per hour | +| `/auth/login` | 5 | per hour | +| `/auth/refresh` | 5 | per hour | +| `/payments/callback/*` | 20 | per minute | +| Others | None | (quota gates apply instead) | + +--- + +## 📁 Files to Reference + +``` +K6_LOAD_TESTING_GUIDE.md # Comprehensive guide (THIS FILE IS SUMMARY OF) +apps/api/src/modules/*/presentation/controllers/ +apps/api/src/modules/*/presentation/dto/ +e2e/fixtures.ts # Test data generators +e2e/api/ # Existing E2E tests (reference) +.env.example # Environment setup +``` + +--- + +## ✅ Checklist Before Running K6 + +- [ ] API running: `pnpm dev` +- [ ] Database seeded: `pnpm db:seed` +- [ ] Test database migrated: `.env.test` configured +- [ ] K6 installed: `brew install k6` or Docker +- [ ] JWT_SECRET set in `.env` +- [ ] Base URL correct: `http://localhost:3001/api/v1` + +--- + diff --git a/K6_QUICK_START.md b/K6_QUICK_START.md new file mode 100644 index 0000000..0c85c86 --- /dev/null +++ b/K6_QUICK_START.md @@ -0,0 +1,659 @@ +# K6 Load Testing — Quick Start Guide + +Get started with K6 load tests for GoodGo Platform API in minutes. + +--- + +## 1️⃣ Installation + +### macOS +```bash +brew install k6 +``` + +### Linux +```bash +apt-get install k6 +``` + +### Docker +```bash +docker pull grafana/k6:latest +``` + +### Verify Installation +```bash +k6 version +``` + +--- + +## 2️⃣ Setup Test Environment + +### Start API & Database +```bash +# Terminal 1: Start all services +pnpm dev + +# Terminal 2: Seed test database (if needed) +pnpm db:seed +``` + +### Verify API is Running +```bash +curl http://localhost:3001/api/v1/docs +# Should return Swagger UI +``` + +--- + +## 3️⃣ Create Your First K6 Test + +### Simple Search Load Test + +Create file: `load-tests/search.k6.js` + +```javascript +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1'; + +export const options = { + stages: [ + { duration: '1m', target: 50 }, // Ramp up to 50 users + { duration: '2m', target: 50 }, // Stay at 50 users + { duration: '1m', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + http_req_failed: ['rate<0.05'], + }, +}; + +export default function() { + // Full-text search + const searchRes = http.get( + `${BASE_URL}/search?q=chung cu&page=1&perPage=20` + ); + + check(searchRes, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'has results': (r) => r.body.includes('items'), + }); + + sleep(2); + + // Geo search + const geoRes = http.get( + `${BASE_URL}/search/geo?lat=10.7769&lng=106.7009&radiusKm=5` + ); + + check(geoRes, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + }); + + sleep(2); +} +``` + +--- + +## 4️⃣ Run Your First Test + +### Local Development +```bash +k6 run load-tests/search.k6.js +``` + +### With Custom Base URL +```bash +BASE_URL=http://localhost:3001/api/v1 k6 run load-tests/search.k6.js +``` + +### With Docker +```bash +docker run -i grafana/k6 run - < load-tests/search.k6.js +``` + +### Output +``` + /\ |‾‾| /‾‾/‾‾ |‾‾| /‾‾/‾‾ + / \ | |/ / | |/ / + / \ | ( | ( + / \ | |\ \ | |\ \ + / \ |__| \_/\_/|__| \_/\_/ + + execution: local + script: load-tests/search.k6.js + output: - + + scenarios: (100.00%) 1 local scenario, 50 max VUs, 4m30s max duration + +✓ checks........................ 100% +✓ http_req_duration............. 96% ✓ 0 ✗ 12 +✓ http_req_failed............... 0% + + data_received........: 512 kB (1.8 kB/s) + data_sent..............: 45 kB (250 B/s) + http_reqs..............: 120 (0.44/s) + iteration_duration.....: 4s + iterations.............: 60 (0.22/s) +``` + +--- + +## 5️⃣ Authentication Test + +Create file: `load-tests/auth.k6.js` + +```javascript +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1'; +const loginCounter = new Counter('login_success'); +const loginDuration = new Trend('login_duration'); + +export const options = { + stages: [ + { duration: '30s', target: 10 }, // 10 users + { duration: '1m', target: 10 }, // Hold for 1 min + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<300', 'p(99)<500'], + }, +}; + +// Generate unique phone for each user +function generatePhone() { + const timestamp = new Date().getTime(); + const random = Math.floor(Math.random() * 1000000); + return `09${String(timestamp + random).slice(-8)}`; +} + +export default function() { + const phone = generatePhone(); + const password = 'TestPass123!'; + const fullName = 'Load Test User'; + + // Register + const registerRes = http.post(`${BASE_URL}/auth/register`, { + phone, + password, + fullName, + }); + + check(registerRes, { + 'register status is 201': (r) => r.status === 201, + 'has tokens': (r) => r.json().accessToken && r.json().refreshToken, + }); + + // Login (same credentials) + const loginRes = http.post(`${BASE_URL}/auth/login`, { + phone, + password, + }); + + loginDuration.add(loginRes.timings.duration); + + check(loginRes, { + 'login status is 201': (r) => r.status === 201, + 'login response time < 300ms': (r) => r.timings.duration < 300, + }); + + if (loginRes.status === 201) { + loginCounter.add(1); + } +} +``` + +### Run Auth Test +```bash +k6 run load-tests/auth.k6.js +``` + +--- + +## 6️⃣ Listing Creation Test (Authenticated) + +Create file: `load-tests/listings.k6.js` + +```javascript +import http from 'k6/http'; +import { check, group } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1'; + +export const options = { + stages: [ + { duration: '30s', target: 5 }, // 5 users (quota limited) + { duration: '1m30s', target: 5 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<1000'], + }, +}; + +function createAuthenticatedSession() { + // Generate unique user + const phone = `09${Math.random().toString().slice(2, 10)}`; + const password = 'TestPass123!'; + const fullName = 'Listing Agent'; + + // Register user + const registerRes = http.post(`${BASE_URL}/auth/register`, { + phone, + password, + fullName, + }); + + if (registerRes.status !== 201) { + console.error('Registration failed:', registerRes.body); + return null; + } + + return registerRes.json(); +} + +export default function() { + const session = createAuthenticatedSession(); + if (!session?.accessToken) return; + + const headers = { + Authorization: `Bearer ${session.accessToken}`, + 'Content-Type': 'application/json', + }; + + group('Create Listing', () => { + const listingData = { + transactionType: 'SALE', + priceVND: '5500000000', + propertyType: 'APARTMENT', + title: 'Căn hộ 3PN view sông Sài Gòn - Load Test', + description: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ, view trực diện sông Sài Gòn.', + address: '208 Nguyễn Hữu Cảnh', + ward: 'Phường 22', + district: 'Bình Thạnh', + city: 'Hồ Chí Minh', + latitude: 10.7942, + longitude: 106.7219, + areaM2: 85.5, + bedrooms: 3, + bathrooms: 2, + floor: 15, + totalFloors: 30, + yearBuilt: 2020, + legalStatus: 'Sổ hồng', + amenities: ['Hồ bơi', 'Gym', 'Sân chơi trẻ em'], + }; + + const res = http.post(`${BASE_URL}/listings`, listingData, { + headers, + }); + + check(res, { + 'status is 201': (r) => r.status === 201, + 'has listing id': (r) => r.json().id !== undefined, + 'response time < 1s': (r) => r.timings.duration < 1000, + }); + + if (res.status === 201) { + const listingId = res.json().id; + + // Get listing details + group('Get Listing Details', () => { + const detailRes = http.get(`${BASE_URL}/listings/${listingId}`); + check(detailRes, { + 'status is 200': (r) => r.status === 200, + 'title matches': (r) => r.json().title === listingData.title, + }); + }); + } + }); +} +``` + +### Run Listing Test +```bash +k6 run load-tests/listings.k6.js +``` + +--- + +## 7️⃣ Payment Test + +Create file: `load-tests/payments.k6.js` + +```javascript +import http from 'k6/http'; +import { check, group } from 'k6'; +import { randomString } from 'https://jslib.k6.io/k6-utils/1.1.0/index.js'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1'; + +export const options = { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<1000'], + }, +}; + +export default function() { + // Create session first + const phone = `09${Math.random().toString().slice(2, 10)}`; + const password = 'TestPass123!'; + + const registerRes = http.post(`${BASE_URL}/auth/register`, { + phone, + password, + fullName: 'Payment Test User', + }); + + if (registerRes.status !== 201) return; + + const { accessToken } = registerRes.json(); + const headers = { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }; + + group('Create Payment', () => { + const paymentRes = http.post(`${BASE_URL}/payments`, { + provider: 'VNPAY', + type: 'LISTING_FEE', + amountVND: 500000, + description: 'Load test payment', + returnUrl: 'http://localhost:3000/payment/return', + idempotencyKey: `load-test-${randomString(16)}`, + }, { + headers, + }); + + check(paymentRes, { + 'status is 201': (r) => r.status === 201, + 'has paymentUrl': (r) => r.json().paymentUrl !== undefined, + 'response time < 1s': (r) => r.timings.duration < 1000, + }); + + if (paymentRes.status === 201) { + const paymentId = paymentRes.json().id; + + // Check payment status + group('Get Payment Status', () => { + const statusRes = http.get(`${BASE_URL}/payments/${paymentId}`, { + headers, + }); + + check(statusRes, { + 'status is 200': (r) => r.status === 200, + 'has status field': (r) => r.json().status !== undefined, + }); + }); + } + }); +} +``` + +### Run Payment Test +```bash +k6 run load-tests/payments.k6.js +``` + +--- + +## 8️⃣ Run All Tests with Results + +Create file: `load-tests/all-scenarios.k6.js` + +```javascript +import http from 'k6/http'; +import { check, group, sleep } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1'; + +export const options = { + stages: [ + { duration: '1m', target: 50 }, // Ramp up + { duration: '3m', target: 50 }, // Sustain + { duration: '1m', target: 0 }, // Ramp down + ], + thresholds: { + 'http_req_duration{scenario:search}': ['p(95)<500'], + 'http_req_duration{scenario:auth}': ['p(95)<300'], + 'http_req_failed': ['rate<0.05'], + }, +}; + +export default function() { + // Scenario 1: Public Search (60% of load) + group('Search Scenario', () => { + const res = http.get(`${BASE_URL}/search?q=chung cu&page=1&perPage=20`, { + tags: { scenario: 'search' }, + }); + check(res, { 'status 200': (r) => r.status === 200 }); + sleep(1); + }); + + // Scenario 2: Auth (20% of load) + if (Math.random() < 0.2) { + group('Auth Scenario', () => { + const res = http.post(`${BASE_URL}/auth/login`, { + phone: '0912345678', + password: 'TestPass123!', + }, { + tags: { scenario: 'auth' }, + }); + check(res, { 'status 201 or 401': (r) => r.status === 201 || r.status === 401 }); + sleep(1); + }); + } + + // Scenario 3: Geo Search (20% of load) + if (Math.random() < 0.2) { + group('Geo Search Scenario', () => { + const res = http.get( + `${BASE_URL}/search/geo?lat=10.77&lng=106.70&radiusKm=5`, + { tags: { scenario: 'geo' } } + ); + check(res, { 'status 200': (r) => r.status === 200 }); + sleep(1); + }); + } +} +``` + +### Run with Summary +```bash +k6 run load-tests/all-scenarios.k6.js \ + --vus=50 \ + --duration=5m \ + --summary-export=summary.json +``` + +--- + +## 9️⃣ Generate Reports + +### JSON Report +```bash +k6 run load-tests/search.k6.js --summary-export=report.json +``` + +### Upload to Grafana Cloud +```bash +k6 run load-tests/search.k6.js \ + --out cloud + # Requires: k6 login or K6_CLOUD_TOKEN env var +``` + +### Generate HTML Report (with extension) +```bash +k6 run load-tests/search.k6.js \ + --out csv=results.csv +``` + +--- + +## 🔟 CI Integration + +Create: `.github/workflows/load-test.yml` + +```yaml +name: Load Tests + +on: + schedule: + - cron: '0 2 * * *' # Daily at 2 AM + workflow_dispatch: + +jobs: + load-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgis/postgis:16 + env: + POSTGRES_DB: goodgo_test + POSTGRES_USER: goodgo + POSTGRES_PASSWORD: test_secret + options: --health-cmd pg_isready --health-interval 10s + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v2 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup database + env: + DATABASE_URL: postgresql://goodgo:test_secret@localhost:5432/goodgo_test + run: | + pnpm db:migrate:deploy + pnpm db:seed + + - name: Start API + env: + DATABASE_URL: postgresql://goodgo:test_secret@localhost:5432/goodgo_test + run: pnpm --filter @goodgo/api run build & + + - name: Install K6 + run: sudo apt-get install -y k6 + + - name: Wait for API + run: | + for i in {1..30}; do + curl http://localhost:3001/api/v1/docs && break + sleep 2 + done + + - name: Run Load Tests + run: k6 run load-tests/search.k6.js --summary-export=results.json + + - name: Upload Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: k6-results + path: results.json +``` + +--- + +## 📊 Common K6 Checks + +```javascript +// HTTP Status +check(res, { 'status 200': (r) => r.status === 200 }); + +// Response Time +check(res, { 'response < 500ms': (r) => r.timings.duration < 500 }); + +// JSON Content +check(res, { 'has id': (r) => r.json().id }); + +// Body Contains +check(res, { 'body contains': (r) => r.body.includes('text') }); + +// Multiple Conditions +check(res, { + 'status is 2xx': (r) => r.status >= 200 && r.status < 300, + 'response time acceptable': (r) => r.timings.duration < 1000, +}); + +// Response Headers +check(res, { + 'content-type is json': (r) => r.headers['Content-Type'] === 'application/json', +}); +``` + +--- + +## 🛠️ Debugging + +### Verbose Output +```bash +k6 run -v load-tests/search.k6.js +``` + +### Show Request/Response +```bash +k6 run --http-debug=full load-tests/search.k6.js +``` + +### Limit to Single VU +```bash +k6 run --vus=1 --iterations=1 load-tests/search.k6.js +``` + +--- + +## 📚 Next Steps + +1. **Read Full Guide**: `K6_LOAD_TESTING_GUIDE.md` +2. **Check Endpoints**: `K6_ENDPOINTS_SUMMARY.md` +3. **Explore K6 Docs**: https://k6.io/docs +4. **Community Scripts**: https://github.com/grafana/k6-templates + +--- + +## ✅ Troubleshooting + +### "connection refused" +- Ensure API is running: `pnpm dev` +- Check port: 3001 +- Verify BASE_URL: `http://localhost:3001/api/v1` + +### "rate limit exceeded" +- Auth endpoints: 5/hour limit +- Spread requests or use different test data +- Check test database has seed data + +### "insufficient credits" (payments) +- Payments require authenticated user +- Create user session first in test +- Use test provider credentials + +### "timeout" +- Increase K6 timeout in options +- Check API logs for errors +- Reduce number of VUs initially + +--- + diff --git a/apps/web/components/auth/oauth-buttons.tsx b/apps/web/components/auth/oauth-buttons.tsx index 4811c0c..c7d2f41 100644 --- a/apps/web/components/auth/oauth-buttons.tsx +++ b/apps/web/components/auth/oauth-buttons.tsx @@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button'; -const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001'; +const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; export function OAuthButtons() { return ( diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index 5235194..8f73270 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -1,4 +1,4 @@ -const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001'; +const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; export class ApiError extends Error { constructor( diff --git a/apps/web/lib/listings-api.ts b/apps/web/lib/listings-api.ts index 20053be..6dff0bc 100644 --- a/apps/web/lib/listings-api.ts +++ b/apps/web/lib/listings-api.ts @@ -131,7 +131,7 @@ export interface SearchListingsParams { // ─── API Functions ─────────────────────────────────────── -const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001'; +const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; export const listingsApi = { create: (data: CreateListingPayload) => diff --git a/playwright.config.ts b/playwright.config.ts index 524c99c..5377299 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ name: 'api', testDir: './e2e/api', use: { - baseURL: process.env.API_BASE_URL ?? 'http://localhost:3001', + baseURL: process.env.API_BASE_URL ?? 'http://localhost:3001/api/v1', }, }, // Web E2E tests — Chromium browser @@ -58,7 +58,7 @@ export default defineConfig({ webServer: [ { command: 'pnpm --filter @goodgo/api run dev', - url: 'http://localhost:3001/api/docs', + url: 'http://localhost:3001/api/v1/docs', reuseExistingServer: !process.env.CI, timeout: 60_000, env: {