# Эталонные векторы SDK

Векторы закрепляют контракт поведения из [sdk/README.md](../README.md): оба SDK (PHP и TypeScript) проходят их без
изменений и без поправок под язык. Любое изменение транспорта, повторов, токенов, ошибок, кодирования или проверки
вебхуков начинается с вектора; изменение ожидаемого значения существующего вектора — изменение поведения, которое
требует записи в CHANGELOG SDK.

- `http/NN_<имя>.json` — обмен SDK с API: какие запросы клиент отправляет на вызовы операций, что получает в ответ и
  что возвращает вызывающему.
- `webhooks.json` — проверка подписей вебхуков (Standard Webhooks).

Кто читает векторы:

| Где | Что проверяет |
|---|---|
| `tests/Unit/Sdk/ConformanceVectorsTest.php` | векторы согласованы со спецификациями: операции и аргументы существуют, тела запросов и готовые ответы проходят `OpenApiContract`, коды ошибок — из каталога `x-problem-codes`, заголовки соответствуют метаданным операций |
| исполнитель PHP SDK (`sdk/php/tests`) | PHP SDK ведёт себя ровно так, как записано |
| исполнитель TS SDK (`sdk/typescript/test`) | TypeScript SDK ведёт себя ровно так, как записано |
| `app-modules/webhooks/tests/Unit` | `Signer` платформы подписывает, а `docs/examples/webhook-receiver.php` проверяет так же |

## HTTP-векторы

Файл — один клиент и последовательность вызовов его операций:

```json
{
  "name": "что проверяет вектор",
  "description": "подробности (необязательно)",
  "client": { ... },
  "calls": [ ... ],
  "exchanges": [ ... ],
  "sleeps": [500, 1000]
}
```

### `client`

| Поле | Значение |
|---|---|
| `api` | `runtime` — `RuntimeClient`, `management` — `ManagementClient` |
| `credential` | ровно одно из: `{"terminalKey": "lk_…"}` (только `runtime`), `{"merchantKey": "lm_…"}`, `{"clientCredentials": {"clientId": "lc_…", "clientSecret": "lcs_…", "scopes"?: ["…"]}}` (только `management`) |
| `baseUrl` | базовый адрес как его передаёт интегратор (может оканчиваться на `/`) |
| `maxRetries` | необязательно, число повторов клиента; по умолчанию 2. По нему же повторяется запрос токена партнёра — всегда, даже если у вызова свой `maxRetries` |
| `merchant` | необязательно, только с `clientCredentials`: клиент, выбравший мерчанта (`forMerchant(merchant)`) |

Учётные данные — песочницы (`lk_test_…`, `lc_test_…` и т. д.) и проходят проверку формата SDK. Хранилище токенов у
каждого вектора своё, пустое и исправное: сбои хранилища (в [sdk/README.md](../README.md#токены-партнёра) — «Сбой
хранилища не ломает вызов») векторами не описываются.

### `calls`

Вызовы выполняются по порядку, каждый — отдельный вызов метода SDK.

| Поле | Значение |
|---|---|
| `operation` | `operationId` из спецификации или `<operationId>All` для перебора страниц |
| `args` | имена параметров пути и запроса из спецификации и `body` — ровно как именованные аргументы PHP-метода и поля `args` TS-метода; `null` — значение не задано |
| `options` | необязательно: `idempotencyKey` (строка), `maxRetries` (число повторов попыток операции этого вызова; на запрос токена не действует) |
| `expect` | результат или ошибка вызова (ниже) |

Исполнитель PHP передаёт `args` именованными аргументами (`$client->{$operation}(...$args, options: $options)`),
раскодировав JSON в ассоциативные массивы: пустой объект `{}` в `body` становится пустым массивом, и SDK обязан
отправить его как `{}`. Вложенных пустых объектов в телах запросов векторов нет: PHP-массив не отличает их от
пустых списков (интегратор передаёт их как `new \stdClass`). Исполнитель TS передаёт `args` объектом как есть.

`expect` — ровно одно из:

- `{"result": …}` — значение, которое вернул вызов: раскодированный JSON ответа; `null` у операций без содержимого
  (204); у `<operationId>All` — список всех элементов всех страниц по порядку. Сравнивается как JSON-значение
  (в PHP — с ассоциативными массивами).
- `{"error": {…}}` — вызов завершился ошибкой SDK. Поля, кроме `class`, необязательны; отсутствующее поле не
  проверяется, `null` — значение должно быть пустым:

| Поле | Что сверяется |
|---|---|
| `class` | логический класс ошибки (таблица в [sdk/README.md](../README.md#ошибки)): `bad_request`, `authentication`, `permission_denied`, `not_found`, `conflict`, `validation`, `rate_limit`, `server`, `api`, `oauth`, `transport`, `unexpected_response`, `configuration` |
| `status` | HTTP-статус ответа, на котором вызов завершился |
| `code` | код problem+json (`code`) |
| `requestId` | `X-Request-Id` ответа, а без него — `request_id` тела problem+json |
| `oauthError` | поле `error` ответа RFC 6749 |
| `idempotencyKey` | ключ идемпотентности вызова, который несёт ошибка (строка, `"{{idempotency-key}}"` или `null`) |
| `mayHaveBeenProcessed` | у `transport`: могла ли быть обработана хотя бы одна попытка операции за весь вызов — не только последняя ([sdk/README.md](../README.md#мог-ли-вызов-быть-обработан)) |
| `retryAfter` | у `rate_limit`: `Retry-After` в секундах |
| `problem` | тело problem+json целиком, со всеми полями расширений |

Рядом с `result` или `error` может стоять `"meta": {…}` — метаданные последнего HTTP-ответа, полученного за время
вызова (`onResponse`); сверяются только перечисленные поля: `operationId`, `method`, `path` (шаблон пути), `status`,
`requestId`, `idempotentReplayed`, `rateLimitRemaining`, `environment`.

### `exchanges`

Ожидаемые HTTP-обмены всех вызовов вектора в одном списке. Подменный транспорт исполнителя отдаёт их строго по
порядку: каждый запрос SDK сверяется со следующим обменом, после последнего вызова не должно остаться
неиспользованных обменов, а запрос сверх списка — провал.

```json
{
  "request": {
    "method": "POST",
    "url": "https://api.example.com/api/v1/receipts",
    "headers": {"authorization": "Bearer lk_test_…", "accept": "application/json", "content-type": "application/json",
                "idempotency-key": "{{idempotency-key}}", "loyal-merchant": null},
    "json": {"number": "1043", "…": "…"}
  },
  "response": {"status": 201, "headers": {"Content-Type": "application/json", "X-Request-Id": "…"}, "json": {"…": "…"}}
}
```

**Запрос:**

- `method` — точно; `url` — абсолютный адрес точно, символ в символ (кодирование и порядок параметров запроса —
  часть контракта).
- `headers` — имена в нижнем регистре; значение — точная строка, `null` — заголовка нет, `"{{idempotency-key}}"` —
  ключ, который SDK создал для текущего вызова, `"{{uuid}}"` — любой UUIDv4. В векторах перечислены все пять
  управляемых заголовков: `authorization`, `accept`, `content-type`, `idempotency-key`, `loyal-merchant`.
  `user-agent` различается по языкам и в векторах не записан: исполнитель проверяет, что он начинается с
  `loyal-sdk-php/<VERSION>` или `loyal-sdk-js/<VERSION>`. Других заголовков, кроме этих шести (и заголовков
  вызывающего из `options`, которых в векторах нет), SDK не отправляет.
- Тело — ровно одно из полей или ни одного: `json` — JSON-тело, сравнивается как JSON-значение (объекты — по набору
  ключей независимо от порядка, `{}` и `[]` различаются, числа — с типом); `form` — тело
  `application/x-www-form-urlencoded`, сравнивается как набор раскодированных полей. Нет ни `json`, ни `form` —
  запрос без тела.

**Ответ** — ровно одно из:

- `"response": {"status", "headers", "json"? | "text"?}` — готовый ответ: заголовки (имена в любом регистре, SDK
  сравнивает их без учёта регистра), тело — JSON-значение, которое исполнитель кодирует в текст, или `text` — тело
  как есть (не JSON-ответы прокси, обрезанный JSON). Нет ни `json`, ни `text` — ответ без тела.
- `"failure": "before_send" | "after_send"` — сбой транспорта вместо ответа: `before_send` — соединение не
  установлено, запрос не ушёл (DNS, отказ в соединении, таймаут соединения, TLS); `after_send` — запрос мог уйти
  (таймаут ответа, обрыв). Исполнитель выбрасывает из подменного транспорта сбой соответствующего вида так, как его
  сообщает настоящий транспорт SDK. Сбой или ответ 5xx у обмена запроса токена — не попытка операции: он не делает
  вызов «возможно обработанным».

Вызов, который прошёл проверку аргументов и получение токена, есть среди обменов; вызов партнёра может закончиться на
запросе токена (`oauth`, а если токен не получен — `transport`, `rate_limit`, `server`, `unexpected_response`), и
тогда его операции среди обменов нет.

**Плейсхолдер `{{idempotency-key}}`.** При первом появлении в вызове исполнитель запоминает значение заголовка и
проверяет, что это UUIDv4 в нижнем регистре
(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`); дальше в том же вызове (повторы,
повтор после обновления токена, `error.idempotencyKey`) значение должно совпадать. Ключи разных вызовов вектора
различаются.

### `sleeps`

Паузы между попытками всех вызовов вектора по порядку, в миллисекундах. Исполнитель подменяет ожидание (записывает
паузы, не ждёт) и источник случайности: `random(max)` — целое от 0 до `max` включительно — всегда возвращает `max`,
поэтому пауза без `Retry-After` детерминирована: `min(8000, 500 × 2^n)`, где `n` — номер повтора в запросе, с 0. У
попыток операции вызова и у каждого запроса токена счётчики свои; повтор после обновления токена — не повтор и `n`
не увеличивает. Пустой список — пауз не было.

### Исполнитель

```text
для каждого файла http/*.json:
  клиент из client: подменный транспорт (обмены по порядку), подменные ожидание и случайность,
                    пустое хранилище токенов, запись onResponse
  для каждого вызова из calls:
    вызвать operation с args и options
    сверить result или error (и meta, если есть)
  все обмены использованы; записанные паузы == sleeps
```

Транспорт сверяет каждый запрос с `request` следующего обмена и отдаёт `response` или выбрасывает `failure`. Часы
настоящие: токены в векторах живут час, ни один вектор не зависит от текущего времени.

## Вебхуки — `webhooks.json`

```json
{
  "toleranceSeconds": 300,
  "vectors": [
    {
      "name": "что проверяет вектор",
      "secrets": ["whsec_…"],
      "signedWith": ["whsec_…"],
      "headers": {"webhook-id": "…", "webhook-timestamp": "1759320000", "webhook-signature": "v1,…"},
      "body": "{\"type\":\"receipt.confirmed\",…}",
      "now": 1759320000,
      "expect": {"event": {"id": "…", "type": "…", "version": 1, "timestamp": "…", "data": {…}}}
    }
  ]
}
```

| Поле | Значение |
|---|---|
| `toleranceSeconds` | допуск времени для всех векторов |
| `secrets` | секреты, с которыми создан проверяющий (`new WebhookVerifier(secrets)`) |
| `signedWith` | необязательно: секреты, которыми платформа подписала запрос; если поле есть, `webhook-signature` — ровно подписи `Signer::sign` этими секретами через пробел, в этом порядке. Нет поля — заголовок составлен вручную (подделка, лишняя подпись) |
| `headers` | заголовки запроса; имена в любом регистре, отсутствующий заголовок не записан |
| `body` | сырое тело запроса (строка, байты UTF-8) |
| `now` | текущее время проверки, Unix-секунды |
| `expect` | `{"event": {…}}` — проверка прошла, событие: `id` из `webhook-id` и `type`, `version`, `timestamp`, `data` тела; или `{"failure": "<причина>"}`: `missing_headers`, `stale_timestamp`, `signature_mismatch`, `malformed_body` |

Пример `docs/examples/webhook-receiver.php` проверяет только подлинность (`true`/`false`) и читает настоящие часы:
его тест считает подлинными векторы с событием и с `malformed_body` (подпись верна), сдвигает допуск на возраст
`now` и пропускает векторы со временем отправки позже `now`.

## Как добавить вектор

1. Следующий номер, имя файла — что проверяется (`17_<что>.json`).
2. Реальные `operationId`, имена параметров и тела из спецификаций; ответы — такие, какие даёт API (их проверяет
   `ConformanceVectorsTest`). Ответы не от API (прокси, страницы авторизации) — через `text` или статусы 3xx/5xx,
   которых нет в спецификации.
3. Ожидаемые значения, паузы и порядок обменов выводятся вручную из контракта в [sdk/README.md](../README.md), а не
   копируются из вывода SDK.
4. `vendor/bin/pest tests/Unit/Sdk`, затем исполнители обоих SDK.
