# K6 Load Testing — Hướng Dẫn Bắt Đầu Nhanh

Bắt đầu với K6 load tests cho GoodGo Platform API trong vài phút.

---

## 1️⃣ Cài Đặt

### macOS
```bash
brew install k6
```

### Linux
```bash
apt-get install k6
```

### Docker
```bash
docker pull grafana/k6:latest
```

### Xác Minh Cài Đặt
```bash
k6 version
```

---

## 2️⃣ Thiết Lập Môi Trường Test

### Khởi Động API & Database
```bash
# Terminal 1: Start all services
pnpm dev

# Terminal 2: Seed test database (if needed)
pnpm db:seed
```

### Xác Minh API Đang Chạy
```bash
curl http://localhost:3001/api/v1/docs
# Should return Swagger UI
```

---

## 3️⃣ Tạo Test K6 Đầu Tiên

### Search Load Test Đơn Giản

Tạo 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️⃣ Chạy Test Đầu Tiên

### Phát Triển Cục Bộ
```bash
k6 run load-tests/search.k6.js
```

### Với Base URL Tùy Chỉnh
```bash
BASE_URL=http://localhost:3001/api/v1 k6 run load-tests/search.k6.js
```

### Với 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️⃣ Test Authentication

Tạo 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);
  }
}
```

### Chạy Auth Test
```bash
k6 run load-tests/auth.k6.js
```

---

## 6️⃣ Test Tạo Listing (Đã Xác Thực)

Tạo 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,
        });
      });
    }
  });
}
```

### Chạy Listing Test
```bash
k6 run load-tests/listings.k6.js
```

---

## 7️⃣ Test Payment

Tạo 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,
        });
      });
    }
  });
}
```

### Chạy Payment Test
```bash
k6 run load-tests/payments.k6.js
```

---

## 8️⃣ Chạy Tất Cả Tests Với Kết Quả

Tạo 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);
    });
  }
}
```

### Chạy Với Tóm Tắt
```bash
k6 run load-tests/all-scenarios.k6.js \
  --vus=50 \
  --duration=5m \
  --summary-export=summary.json
```

---

## 9️⃣ Tạo Báo Cáo

### Báo Cáo JSON
```bash
k6 run load-tests/search.k6.js --summary-export=report.json
```

### Tải Lên Grafana Cloud
```bash
k6 run load-tests/search.k6.js \
  --out cloud
  # Requires: k6 login or K6_CLOUD_TOKEN env var
```

### Tạo Báo Cáo HTML (với extension)
```bash
k6 run load-tests/search.k6.js \
  --out csv=results.csv
```

---

## 🔟 Tích Hợp CI

Tạo: `.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
```

---

## 📊 Các Check K6 Phổ Biến

```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',
});
```

---

## 🛠️ Debug

### Output Chi Tiết
```bash
k6 run -v load-tests/search.k6.js
```

### Hiển Thị Request/Response
```bash
k6 run --http-debug=full load-tests/search.k6.js
```

### Giới Hạn Một VU
```bash
k6 run --vus=1 --iterations=1 load-tests/search.k6.js
```

---

## 📚 Bước Tiếp Theo

1. **Đọc Hướng Dẫn Đầy Đủ**: `K6_LOAD_TESTING_GUIDE.md`
2. **Kiểm Tra Endpoint**: `K6_ENDPOINTS_SUMMARY.md`
3. **Khám Phá Tài Liệu K6**: https://k6.io/docs
4. **Script Cộng Đồng**: https://github.com/grafana/k6-templates

---

## ✅ Xử Lý Sự Cố

### "connection refused"
- Đảm bảo API đang chạy: `pnpm dev`
- Kiểm tra port: 3001
- Xác minh BASE_URL: `http://localhost:3001/api/v1`

### "rate limit exceeded"
- Endpoint auth: giới hạn 5/giờ
- Phân tán request hoặc dùng dữ liệu test khác
- Kiểm tra database test có dữ liệu seed

### "insufficient credits" (payments)
- Payment yêu cầu người dùng đã xác thực
- Tạo phiên người dùng trước trong test
- Sử dụng thông tin xác thực provider test

### "timeout"
- Tăng K6 timeout trong options
- Kiểm tra log API để tìm lỗi
- Giảm số lượng VU ban đầu

---

