# Xử lý lỗi & testing

[English](../en/04-errors-and-testing.md) · [Mục lục](./README.md)

---

## Cây lỗi

```
GpmPayError                       .code, GpmPayError.isGpmPayError()
├── GpmPayConfigError             lỗi cục bộ, CHƯA gọi mạng
│     .code: missing_api_token | invalid_api_token
│          | invalid_api_token_format | invalid_base_url | invalid_argument
├── GpmPayConnectionError         DNS / TCP / TLS   .syscallCode, .cause
├── GpmPayTimeoutError            .timeoutMs
├── GpmPayWebhookSignatureError   .reason
└── GpmPayAPIError                .status, .requestId, .rawBody, .rawMessage
    ├── GpmPayBadRequestError       400   .validationMessages: string[]
    ├── GpmPayAuthenticationError   401   .reason
    ├── GpmPayPermissionError       403   .missingScope, .reason
    ├── GpmPayNotFoundError         404   .resource
    ├── GpmPayRateLimitError        429   .retryAfterSeconds
    └── GpmPayServerError           5xx
```

> Không có lớp riêng cho **409**. Mọi route SDK phơi ra đều là đọc, hoặc ghi
> không có ràng buộc duy nhất nào — không chỗ nào sinh ra conflict. Nếu bạn gặp
> 409 qua `client.request()`, nó về dưới dạng `GpmPayAPIError` với
> `.status === 409`.

## Bắt theo lớp, không bắt theo chuỗi

```ts
import {
  GpmPayPermissionError,
  GpmPayAuthenticationError,
  GpmPayBadRequestError,
  GpmPayServerError,
  GpmPayError,
} from '@gpmpay/sdk';

try {
  await client.webhookSettings.create({ url, driver: 'HTTP' });
} catch (error) {
  if (error instanceof GpmPayPermissionError) {
    // .reason: 'scope' | 'endpoint' | 'ownership'
    if (error.reason === 'endpoint') {
      throw new Error('Endpoint này chỉ dùng được từ dashboard — không scope nào mở được.');
    }
    logger.error(`Token thiếu scope: ${error.missingScope}`);
    throw new Error('Sai cấu hình thanh toán');
  }
  if (error instanceof GpmPayAuthenticationError) {
    // token_expired | token_inactive | invalid_token | user_inactive | ...
    alertOps(`Token GPM Pay hỏng: ${error.reason}`);
    throw error;
  }
  if (error instanceof GpmPayBadRequestError) {
    logger.warn({ messages: error.validationMessages }, 'payload không hợp lệ');
  }
  if (GpmPayError.isGpmPayError(error)) {
    logger.error({ code: error.code, requestId: (error as any).requestId });
  }
  throw error;
}
```

> Nếu dự án có thể tồn tại song song bản CJS và ESM của SDK, `instanceof` sẽ sai. Dùng `GpmPayError.isGpmPayError(error)` cho nhánh chung.

## Luôn log `requestId`

Mọi `GpmPayAPIError` mang `.requestId` (SDK gửi kèm header `X-GPMPay-Request-Id`). Dán vào ticket hỗ trợ để tra đúng request trên log server.

```ts
logger.error({
  requestId: error.requestId,
  status: error.status,
  code: error.code,
}, 'GPM Pay API lỗi');
```

## `.reason` của lỗi 401

| `.reason` | Nghĩa | Xử lý |
|---|---|---|
| `token_expired` | quá `expiresAt` của token | tạo token mới |
| `token_inactive` | chủ tài khoản tạm khoá token | mở lại trong dashboard |
| `invalid_token` | không tồn tại / secret sai | tạo token mới |
| `invalid_format` | không đúng dạng `gpm_…` | sai biến môi trường |
| `user_inactive` | tài khoản GPM Pay bị vô hiệu | liên hệ hỗ trợ |
| `missing_bearer` | thiếu header Authorization | lỗi nội bộ SDK, báo bug |

Phân biệt được giúp cảnh báo đúng: `token_expired` là việc cần làm ngay, `invalid_token` có thể là deploy nhầm env.

## Retry: SDK đã làm gì sẵn

| Trường hợp | Hành vi |
|---|---|
| `GET`/`HEAD`/`DELETE` gặp 408/425/429/5xx hoặc lỗi mạng | retry, full-jitter exponential backoff, trần 8s |
| `POST`/`PATCH` gặp **429** | retry (429 nghĩa là request chưa hề được xử lý) |
| `POST`/`PATCH` gặp 5xx hoặc lỗi mạng | **không** retry |
| `Retry-After` có trong response | tôn trọng, hơn cả backoff tính toán |

Vì sao POST không retry khi 5xx: response có thể mất *sau khi* server đã ghi xong, retry mù sẽ ghi trùng. Cần an toàn khi retry thì hãy tự làm request lặp lại được (sinh định danh một cách tất định), thay vì retry bừa.

Chỉnh:

```ts
new GpmPay({
  apiToken,
  maxRetries: 3,          // mặc định 2; 0 để tắt
  retryBaseDelayMs: 500,
  timeoutMs: 30_000,
});
```

## Huỷ request

```ts
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);

try {
  await client.transactions.list({ signal: controller.signal });
} catch (error) {
  if (error instanceof AbortError) { /* do bạn huỷ */ }
  if (error instanceof GpmPayTimeoutError) { /* do timeout của SDK */ }
}
```

SDK phân biệt hai loại: timeout thành `GpmPayTimeoutError`, còn `AbortError` chỉ khi *bạn* huỷ.

## Quan sát

```ts
new GpmPay({
  apiToken,
  onRequest: ({ method, url, requestId, attempt }) => {
    logger.debug({ method, url, requestId, attempt }, 'gpmpay →');
  },
  onResponse: ({ requestId, status, durationMs, attempt }) => {
    metrics.timing('gpmpay.request', durationMs, { status });
  },
});
```

Hook **không bao giờ** nhận token. `client.toString()` và `console.log(client)` cũng chỉ hiện prefix công khai.

---

# Testing

## Sandbox end-to-end

```ts
const client = new GpmPay({ apiToken: process.env.GPMPAY_SANDBOX_TOKEN!, sandbox: true });

// Nhánh thuận: đúng mã của bạn, đúng số tiền.
await client.simulator.createTransaction({
  bankAccountId,
  amount: 50_000,
  transferContent: 'DH123',
});
```

Endpoint webhook của bạn sẽ nhận một lần giao thật — với điều kiện endpoint đó bật `fireOnSimulated`. Hãy dựng cả các nhánh nghịch, vì bây giờ handler của bạn chịu trách nhiệm hết:

```ts
// Chuyển thiếu tiền — handler KHÔNG được giao hàng
await client.simulator.createTransaction({
  bankAccountId,
  amount: 49_000,
  transferContent: 'DH123',
});

// Không có mã nào — handler phải bỏ qua
await client.simulator.createTransaction({
  bankAccountId,
  amount: 50_000,
  transferContent: 'chuyen tien',
});

// Ngân hàng chèn tiền tố riêng — regex của bạn vẫn phải dò ra mã
await client.simulator.createTransaction({
  bankAccountId,
  amount: 50_000,
  transferContent: 'CT DEN:0123456789 DH123 NguyenVanA',
});
```

Simulator từ chối chạy trên production (base URL không chứa `localhost`/`sandbox`) trừ khi truyền `{ allowOnProduction: true }`.

## Unit test: inject `fetch`

Không cần msw hay nock — SDK cho inject `fetch`.

```ts
import { GpmPay } from '@gpmpay/sdk';

const TEST_TOKEN = 'gpm_TESTpub1_abcdefghijklmnopqrstuvwx';   // đúng định dạng, không hợp lệ thật

function fakeFetch(body: unknown, status = 200) {
  return async () =>
    new Response(JSON.stringify({ statusCode: status, message: '', data: body }), {
      status,
      headers: { 'content-type': 'application/json' },
    });
}

it('lấy một giao dịch', async () => {
  const client = new GpmPay({
    apiToken: TEST_TOKEN,
    fetch: fakeFetch({ id: 'tx_1', amount: '50000', transferContent: 'DH123' }),
  });

  const txn = await client.transactions.retrieve('tx_1');
  expect(txn.transferContent).toBe('DH123');
});
```

Nhớ mọi response API đều bọc trong `{ statusCode, message, data }`; SDK tự bóc.

Giả lập lỗi:

```ts
const client = new GpmPay({
  apiToken: TEST_TOKEN,
  fetch: async () =>
    new Response(
      JSON.stringify({ statusCode: 403, message: 'Missing scope: webhooks:manage' }),
      { status: 403, headers: { 'content-type': 'application/json' } },
    ),
});

const error = await client.webhookSettings.list().catch((e) => e);
expect(error).toBeInstanceOf(GpmPayPermissionError);
expect(error.missingScope).toBe('webhooks:manage');
```

## Test webhook handler

Tự ký body để khỏi cần server thật:

```ts
import { signWebhookPayload } from '@gpmpay/sdk/webhooks';

const secret = 'whsec_test';
const rawBody = JSON.stringify({
  id: 'tx_1',
  gateway: 'MB',
  transferType: 'in',
  transferAmount: 50_000,
  content: 'CT DEN:0123456789 DH123 NguyenVanA',
  referenceCode: 'FT2508071234',
  source: 'SIMULATED',
});

const response = await fetch('http://localhost:3000/webhooks/gpmpay', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-gpmpay-signature': signWebhookPayload({ rawBody, secret }),
  },
  body: rawBody,
});

expect(response.status).toBe(200);
```

Nhớ test cả nhánh xấu:

```ts
// chữ ký sai → phải 401 và KHÔNG được giao hàng
// gửi cùng payload.id hai lần → chỉ được giao hàng một lần
```

Test tất định với thời gian bằng cách inject `now`:

```ts
import { verifyWebhookSignature } from '@gpmpay/sdk/webhooks';

verifyWebhookSignature({
  rawBody, signature, secret,
  now: () => 1785600000 * 1000,   // đóng băng thời gian, khỏi lệch skew
});
```

## Test job đối soát bù

Vòng quét đối soát chỉ là code thường chạy trên `transactions.listAll()`, nên hãy test bằng `fetch` inject trả về hai trang, rồi assert mỗi giao dịch khớp được xử lý đúng một lần:

```ts
const seen: string[] = [];
for await (const txn of client.transactions.listAll({ type: 'IN' })) {
  const code = /DH(\d+)/.exec(txn.transferContent)?.[0];
  if (code) seen.push(code);
}
expect(seen).toEqual(['DH123', 'DH124']);
```

Nên phủ: giao dịch đã được webhook xử lý rồi (không được giao hàng hai lần), và ranh giới trang rơi vào giữa tập kết quả.

## Kiểm tra token trong CI

```bash
npx gpmpay ping --json
```

```json
{
  "ok": true,
  "baseUrl": "https://api.gpmpay.com/api/v1",
  "tokenPrefix": "gpm_a1b2c3d4",
  "latencyMs": 182,
  "scopes": {
    "granted": ["transactions:read", "bank-accounts:read", "webhooks:manage"],
    "denied": []
  }
}
```

Chèn vào pipeline để bắt token hết hạn trước khi nó làm hỏng production:

```bash
npx gpmpay ping --json > /dev/null || exit 1
```

Exit code: `0` OK · `2` thiếu token/sai cú pháp · `3` token bị từ chối · `4` lỗi mạng.
