]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
[ERP-500] перенос секретов в ENV и тестирование origin/feature_filippov_erp-500_add_test
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 21 Jan 2026 19:32:15 +0000 (22:32 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 21 Jan 2026 19:32:15 +0000 (22:32 +0300)
60 files changed:
docker/db/dev.db-pgsql.env.example [new file with mode: 0644]
docker/php/dev.php.env.example [new file with mode: 0644]
erp24/docs/testing/EXTERNAL_INTEGRATIONS.md [new file with mode: 0644]
erp24/models/User.php [new file with mode: 0644]
erp24/tests/_data/external/amo/auth_error_401.json [new file with mode: 0644]
erp24/tests/_data/external/amo/auth_success.json [new file with mode: 0644]
erp24/tests/_data/external/amo/contacts_list.json [new file with mode: 0644]
erp24/tests/_data/external/cloudpayments/charge_success.json [new file with mode: 0644]
erp24/tests/_data/external/cloudpayments/error_auth_401.json [new file with mode: 0644]
erp24/tests/_data/external/cloudpayments/error_validation.json [new file with mode: 0644]
erp24/tests/_data/external/cloudpayments/payments_list_empty.json [new file with mode: 0644]
erp24/tests/_data/external/cloudpayments/payments_list_success.json [new file with mode: 0644]
erp24/tests/_data/external/cloudpayments/refund_success.json [new file with mode: 0644]
erp24/tests/_data/external/lptracker/auth_error.json [new file with mode: 0644]
erp24/tests/_data/external/lptracker/auth_success.json [new file with mode: 0644]
erp24/tests/_data/external/lptracker/lead_create_success.json [new file with mode: 0644]
erp24/tests/_data/external/lptracker/lead_update_success.json [new file with mode: 0644]
erp24/tests/_data/external/lptracker/leads_list.json [new file with mode: 0644]
erp24/tests/_data/external/telegram/rate_limit_429.json [new file with mode: 0644]
erp24/tests/_data/external/telegram/send_message_error.json [new file with mode: 0644]
erp24/tests/_data/external/telegram/send_message_success.json [new file with mode: 0644]
erp24/tests/_data/external/whatsapp/cascade_list_success.json [new file with mode: 0644]
erp24/tests/_data/external/whatsapp/channel_profile_success.json [new file with mode: 0644]
erp24/tests/_data/external/whatsapp/error_auth_401.json [new file with mode: 0644]
erp24/tests/_data/external/whatsapp/error_cascade_not_found.json [new file with mode: 0644]
erp24/tests/_data/external/whatsapp/error_out_of_balance.json [new file with mode: 0644]
erp24/tests/_data/external/whatsapp/messages_history_success.json [new file with mode: 0644]
erp24/tests/_data/external/whatsapp/send_message_success.json [new file with mode: 0644]
erp24/tests/_support/ApiTester.php [new file with mode: 0644]
erp24/tests/_support/Helper/Api.php [new file with mode: 0644]
erp24/tests/_support/Helper/MockHttpClient.php [new file with mode: 0644]
erp24/tests/_support/Helper/NetworkGuard.php [new file with mode: 0644]
erp24/tests/api.suite.yml [new file with mode: 0644]
erp24/tests/api/Api1AuthCest.php [new file with mode: 0644]
erp24/tests/api/Api1CronCest.php [new file with mode: 0644]
erp24/tests/api/AuthCest.php [new file with mode: 0644]
erp24/tests/api/BonusCest.php [new file with mode: 0644]
erp24/tests/api/ClientCest.php [new file with mode: 0644]
erp24/tests/api/DeliveryCest.php [new file with mode: 0644]
erp24/tests/api/OrdersCest.php [new file with mode: 0644]
erp24/tests/api/StoreCest.php [new file with mode: 0644]
erp24/tests/api/_bootstrap.php [new file with mode: 0644]
erp24/tests/functional/services/AmoCrmServiceCest.php [new file with mode: 0644]
erp24/tests/functional/services/CloudPaymentsServiceCest.php [new file with mode: 0644]
erp24/tests/functional/services/LPTrackerServiceCest.php [new file with mode: 0644]
erp24/tests/functional/services/WhatsAppServiceCest.php [new file with mode: 0644]
erp24/tests/unit/commands/CronControllerTest.php [new file with mode: 0644]
erp24/tests/unit/integrations/amo/AmoCrmContractTest.php [new file with mode: 0644]
erp24/tests/unit/integrations/cloudpayments/CloudPaymentsContractTest.php [new file with mode: 0644]
erp24/tests/unit/integrations/lptracker/LPTrackerContractTest.php [new file with mode: 0644]
erp24/tests/unit/integrations/telegram/TelegramBotContractTest.php [new file with mode: 0644]
erp24/tests/unit/integrations/whatsapp/EdnaWhatsAppContractTest.php [new file with mode: 0644]
erp24/tests/unit/jobs/JobSerializationTest.php [new file with mode: 0644]
erp24/tests/unit/jobs/QueueConfigTest.php [new file with mode: 0644]
erp24/tests/unit/jobs/SendBonusInfoToSiteJobTest.php [new file with mode: 0644]
erp24/tests/unit/jobs/SendRequestUploadDataToJobTest.php [new file with mode: 0644]
erp24/tests/unit/jobs/SendTelegramMessageJobTest.php [new file with mode: 0644]
erp24/tests/unit/jobs/SendWhatsappMessageJobTest.php [new file with mode: 0644]
erp24/tests/unit/jobs/_bootstrap.php [new file with mode: 0644]
erp24/tests/unit/mail/MailerConfigTest.php [new file with mode: 0644]

diff --git a/docker/db/dev.db-pgsql.env.example b/docker/db/dev.db-pgsql.env.example
new file mode 100644 (file)
index 0000000..f0fb14a
--- /dev/null
@@ -0,0 +1,12 @@
+# ============================================================================
+# Docker PostgreSQL Environment Variables Template
+# ============================================================================
+#
+# ВАЖНО: Скопируйте этот файл как dev.db-pgsql.env и заполните значениями!
+# НЕ коммитьте файл dev.db-pgsql.env с реальными паролями!
+#
+# ============================================================================
+
+POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dev_password_change_me}
+POSTGRES_USER=${POSTGRES_USER:-postgres}
+POSTGRES_DB=${POSTGRES_DB:-erp24}
diff --git a/docker/php/dev.php.env.example b/docker/php/dev.php.env.example
new file mode 100644 (file)
index 0000000..a6ab5c5
--- /dev/null
@@ -0,0 +1,22 @@
+# ============================================================================
+# Docker PHP Environment Variables Template
+# ============================================================================
+#
+# ВАЖНО: Скопируйте этот файл как dev.php.env и заполните значениями!
+# НЕ коммитьте файл dev.php.env с реальными паролями!
+#
+# ============================================================================
+
+# Database credentials
+DB_PASSWORD=${DB_PASSWORD:-dev_password_change_me}
+DB_USER=${DB_USER:-root}
+DB_HOST=${DB_HOST:-db}
+DB_SCHEMA=${DB_SCHEMA:-erp24}
+
+# PostgreSQL credentials
+POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dev_password_change_me}
+POSTGRES_USER=${POSTGRES_USER:-postgres}
+POSTGRES_HOSTNAME=${POSTGRES_HOSTNAME:-db-pgsql}
+
+# Application settings
+APP_ENV=development
diff --git a/erp24/docs/testing/EXTERNAL_INTEGRATIONS.md b/erp24/docs/testing/EXTERNAL_INTEGRATIONS.md
new file mode 100644 (file)
index 0000000..476630d
--- /dev/null
@@ -0,0 +1,425 @@
+# Реестр внешних интеграций ERP24
+
+> Источник правды для тестов и задач по внешним интеграциям.
+> Последнее обновление: 2026-01-21
+
+## Сводная таблица интеграций
+
+| № | Интеграция | Класс/Файл клиента | Где вызывается | ENV переменные | Endpoint | Критичность |
+|---|-----------|-------------------|----------------|----------------|----------|-------------|
+| 1 | AMO CRM v1 | `inc/amo/amo_inc.php` | `api1/actions/cron/`, `modul/api/` | `AMO_SUBDOMAIN`, `AMO_TOKEN_FILE`, `AMO_TOKEN_FILE_INC`, `AMO_CLIENT_ID`, `AMO_CLIENT_SECRET`, `AMO_SECRET_PHRASE` | `https://{subdomain}.amocrm.ru/api/v4/` | Высокая |
+| 2 | AMO CRM v2 | `inc/amo2/amo_inc.php`, `inc/amo2/get_token.php` | `api1/actions/cron/ImportAmoInCrmAction.php` | `AMO2_CLIENT_ID`, `AMO2_CLIENT_SECRET`, `AMO2_SECRET_PHRASE`, `AMO2_TOKEN_FILE` | `https://{subdomain}.amocrm.ru/oauth2/` | Высокая |
+| 3 | LPTracker | `records/LPTrackerApi.php` | `commands/`, `api2/controllers/` | `LPTRACKER_LOGIN`, `LPTRACKER_PASSWORD` | `https://direct.lptracker.ru` | Средняя |
+| 4 | Telegram Bot | `services/TelegramService.php` | `api2/controllers/TelegramController.php`, `commands/AlertsController.php` | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_TOKEN_PROD`, `TELEGRAM_CHAT_CHANNEL_ID` | `https://api.telegram.org/bot{token}/` | Высокая |
+| 5 | Telegram Salebot | `api2/controllers/TelegramSalebotController.php` | API2 webhook | `TELEGRAM_BOT_TOKEN_SALEBOT` | `https://api.telegram.org/bot{token}/` | Средняя |
+| 6 | Telegram Alerts | `log/TelegramTarget.php` | `config/web.php` Log component | `TELEGRAM_BOT_ALERTS`, `TELEGRAM_CHAT_CHANNEL_ERP_ID` | `https://api.telegram.org/bot{token}/sendMessage` | Высокая |
+| 7 | WhatsApp (EDNA) | `services/WhatsAppService.php` | `commands/MarketplaceController.php` | `WHATSAPP_API_KEY` | `https://app.edna.ru/api/` | Средняя |
+| 8 | CloudPayments | `inc/cloudpayments.php`, `api1/actions/cron/CloudPaymentsAction.php` | `api1/cron/` | `CLOUDPAYMENTS_PUBLIC_ID`, `CLOUDPAYMENTS_SECRET` | `https://api.cloudpayments.ru/payments/list` | Высокая |
+| 9 | CloudPayments Region | `api1/actions/cron/CloudpaymentsRegionAction.php` | `api1/cron/` | `CLOUDPAYMENTS_REGION_PUBLIC_ID`, `CLOUDPAYMENTS_REGION_SECRET` | `https://api.cloudpayments.ru/payments/list` | Средняя |
+| 10 | GreenSMS | `modul/api/greensms.php` | `modul/api/` | `GREENSMS_API_KEY` | `https://vp.voicepassword.ru/api/voice-password/send/` | Средняя |
+| 11 | SMS.RU | `inc/sms/sms.ru.php` | `modul/api/`, `commands/` | `SMSRU_API_KEY` | `https://my5.t-sms.ru/` (XML API) | Средняя |
+| 12 | Yandex Market | `commands/MarketplaceController.php`, OpenAPI Client | `commands/marketplace`, `api2/` | `YANDEX_MARKET_API_KEY` | Yandex Market API v2 | Высокая |
+| 13 | Flowwow | `media/controllers/FlowwowController.php` | Email parsing | `EMAIL_FLOW_PASSWORD` | IMAP `imap.yandex.ru:993` | Средняя |
+| 14 | IMAP Email | Embedded в `commands/MarketplaceController.php` | `commands/marketplace --action=importorders` | `IMAP_EMAIL`, `IMAP_PASSWORD`, `EMAIL_ZAKAZ_PASSWORD` | `imaps://imap.yandex.ru:993` | Средняя |
+| 15 | 1C Export/Import | `modul/cron/1c.php`, `modul/orders/export1c.php` | `api1/actions/`, `commands/` | `EXPORT_TOKEN_1C`, `FTP_1C_HOST`, `FTP_1C_USER`, `FTP_1C_PASSWORD` | FTP + XML | Высокая |
+| 16 | RabbitMQ Queue | `config/web.php`, `config/console.php` | `commands/` (queue/listen) | `RABBIT_HOST`, `RABBIT_USER`, `RABBIT_PASSWORD` | `amqp://{user}:{pass}@{host}:5672` | Критичная |
+| 17 | DomRu Cameras | `modul/api/domru.php`, `api1/actions/cron/DomRuCamsAction.php` | `api1/cron/` | `CAMERA_{1-6}_LOGIN`, `CAMERA_{1-6}_PASSWORD` | RTSP/HTTP streams | Низкая |
+| 18 | BonusPlus | `inc/bonusplus/` | `api1/`, `api3/` | `BONUSPLUS_API_KEY` | BonusPlus API | Средняя |
+
+---
+
+## Детальное описание интеграций
+
+### 1. AMO CRM (v1 + v2)
+
+**Назначение:** Синхронизация контактов, лидов и сделок между ERP24 и AmoCRM.
+
+**Файлы:**
+- `inc/amo.php` — главный include
+- `inc/amo/amo_inc.php` — конфигурация v1
+- `inc/amo/get_token.php` — OAuth токен v1
+- `inc/amo/callback.php` — webhook v1
+- `inc/amo2/amo_inc.php` — конфигурация v2
+- `inc/amo2/get_token.php` — OAuth токен v2
+- `inc/amo2/amo_insert.php` — вставка данных v2
+- `api1/actions/cron/ImportAmoInCrmAction.php` — импорт из AMO в CRM
+
+**ENV переменные:**
+```bash
+# Primary (v1)
+AMO_SUBDOMAIN=bazacvetov24
+AMO_TOKEN_FILE=/var/www/secrets/amo_token.json
+AMO_TOKEN_FILE_INC=/var/www/secrets/amo_inc_token.json
+AMO_CLIENT_ID=
+AMO_CLIENT_SECRET=
+AMO_SECRET_PHRASE=
+AMO_APP_URL=https://example.com/amo/callback
+
+# Secondary (v2)
+AMO2_CLIENT_ID=
+AMO2_CLIENT_SECRET=
+AMO2_SECRET_PHRASE=
+AMO2_TOKEN_FILE=/var/www/secrets/amo2_token.json
+AMO2_APP_URL=https://example.com/amo2/callback
+```
+
+**API Endpoints:**
+- OAuth: `https://{subdomain}.amocrm.ru/oauth2/auth`
+- Token: `https://{subdomain}.amocrm.ru/oauth2/access_token`
+- API: `https://{subdomain}.amocrm.ru/api/v4/`
+
+**Тестовые сценарии:**
+1. Request contract: OAuth flow (client_credentials, authorization_code)
+2. Response contract: токен refresh при 401
+3. Негативные: 401 → refresh → retry, 429 rate limit
+4. Интеграционные: создание/обновление контакта, сделки
+
+---
+
+### 2. LPTracker API
+
+**Назначение:** Отслеживание звонков и маркирование лидов.
+
+**Файл:** `records/LPTrackerApi.php`
+
+**Класс:**
+```php
+class LPTrackerApi {
+    const BASE_URI = 'https://direct.lptracker.ru';
+    const SERVICE = 117605;
+
+    private static function getLogin(): string {
+        return getenv('LPTRACKER_LOGIN') ?: '';
+    }
+
+    private static function getPassword(): string {
+        return getenv('LPTRACKER_PASSWORD') ?: '';
+    }
+
+    public static function auth(): ?string { ... }
+    public static function post(string $url, array $data): mixed { ... }
+    public static function get(string $url): mixed { ... }
+}
+```
+
+**ENV переменные:**
+```bash
+LPTRACKER_LOGIN=
+LPTRACKER_PASSWORD=
+```
+
+**API Endpoints:**
+- Auth: `POST /login`
+- Leads: `GET /leads`, `POST /leads`
+
+**Тестовые сценарии:**
+1. Request contract: авторизация, CRUD лидов
+2. Response contract: парсинг успеха, обработка ошибок
+3. Негативные: 401 unauthorized, 500 server error
+
+---
+
+### 3. Telegram Bot API
+
+**Назначение:** Отправка уведомлений, обработка webhook сообщений.
+
+**Файлы:**
+- `services/TelegramService.php` — основной сервис
+- `log/TelegramTarget.php` — логирование в Telegram
+- `api2/controllers/TelegramController.php` — webhook
+- `api2/controllers/TelegramSalebotController.php` — salebot webhook
+
+**ENV переменные:**
+```bash
+# Токены ботов
+TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567
+TELEGRAM_BOT_TOKEN_PROD=
+TELEGRAM_BOT_TOKEN_SALEBOT=
+TELEGRAM_BOT_ALERTS=
+TELEGRAM_BOT_ORDERS=
+
+# Каналы
+TELEGRAM_CHAT_CHANNEL_ID=-1001234567890
+TELEGRAM_CHAT_CHANNEL_ERP_ID=
+
+# Безопасность
+CHATBOT_SALT=
+```
+
+**API Endpoints:**
+- sendMessage: `POST /bot{token}/sendMessage`
+- setWebhook: `POST /bot{token}/setWebhook`
+- getUpdates: `GET /bot{token}/getUpdates`
+
+**Тестовые сценарии:**
+1. Request contract: sendMessage с различными параметрами
+2. Response contract: ok=true/false, result/description
+3. Негативные: 401 bad token, 429 rate limit, 400 bad request
+
+---
+
+### 4. WhatsApp (EDNA API)
+
+**Назначение:** Массовая рассылка сообщений WhatsApp.
+
+**Файл:** `services/WhatsAppService.php`
+
+**Класс:**
+```php
+class WhatsAppService {
+    private static $apiBaseUrl = 'https://app.edna.ru/api';
+
+    public function sendMessage(string $phone, string $text): array { ... }
+    private function escapeText(string $text): string { ... }
+    public function getErrorMessage(int $code): string { ... }
+}
+```
+
+**ENV переменные:**
+```bash
+WHATSAPP_API_KEY=12345678-1234-1234-1234-123456789012
+```
+
+**API Endpoints:**
+- Send: `POST /api/imOutMessage`
+- Status: `GET /api/status`
+
+**Тестовые сценарии:**
+1. Request contract: отправка сообщения, каскады
+2. Response contract: status codes
+3. Негативные: неверный телефон, лимиты
+
+---
+
+### 5. CloudPayments
+
+**Назначение:** Импорт платежей по картам.
+
+**Файлы:**
+- `inc/cloudpayments.php` — основная функция
+- `api1/actions/cron/CloudPaymentsAction.php` — крон импорта
+- `api1/actions/cron/CloudpaymentsRegionAction.php` — региональный
+
+**ENV переменные:**
+```bash
+# Основной
+CLOUDPAYMENTS_PUBLIC_ID=pk_xxxxxxxxxxxxx
+CLOUDPAYMENTS_SECRET=xxxxxxxxxxxxxxxx
+
+# Региональный (опционально)
+CLOUDPAYMENTS_REGION_PUBLIC_ID=
+CLOUDPAYMENTS_REGION_SECRET=
+```
+
+**API Endpoint:**
+- Payments list: `POST https://api.cloudpayments.ru/payments/list`
+- Auth: Basic `base64({PUBLIC_ID}:{SECRET})`
+
+**Request:**
+```json
+{
+  "Date": "2025-01-21",
+  "TimeZone": "MSK"
+}
+```
+
+**Response:**
+```json
+{
+  "Success": true,
+  "Model": [
+    {
+      "TransactionId": 123456,
+      "Amount": 1000.00,
+      "Currency": "RUB",
+      "InvoiceId": "ORDER-123"
+    }
+  ]
+}
+```
+
+**Тестовые сценарии:**
+1. Request contract: авторизация Basic Auth, формат запроса
+2. Response contract: парсинг платежей, Success=true/false
+3. Негативные: 401 auth failed, пустой ответ
+
+---
+
+### 6. SMS (GreenSMS + SMS.RU)
+
+**GreenSMS:**
+- Файл: `modul/api/greensms.php`
+- ENV: `GREENSMS_API_KEY`
+- API: `https://vp.voicepassword.ru/api/voice-password/send/`
+
+**SMS.RU:**
+- Файл: `inc/sms/sms.ru.php`
+- ENV: `SMSRU_API_KEY`
+- API: XML-based `https://my5.t-sms.ru/`
+
+**Тестовые сценарии:**
+1. Request contract: формат SMS, номер телефона
+2. Response contract: status, message_id
+3. Негативные: неверный номер, недостаточно средств
+
+---
+
+### 7. Yandex Market
+
+**Назначение:** Интеграция с Яндекс.Маркетом (stocks, hidden offers).
+
+**Файлы:**
+- `commands/MarketplaceController.php`
+- OpenAPI Client (`vendor/`)
+
+**ENV переменные:**
+```bash
+YANDEX_MARKET_API_KEY=
+```
+
+**OpenAPI Классы:**
+- `HiddenOffersApi` — управление скрытыми офертами
+- `StocksApi` — обновление стоков
+- `OrdersApi` — работа с заказами
+
+**Тестовые сценарии:**
+1. Request contract: авторизация Api-Key, CRUD операции
+2. Response contract: OpenAPI schema validation
+3. Негативные: 401, 403, 404, 429
+
+---
+
+### 8. IMAP Email
+
+**Назначение:** Чтение email для импорта заказов (Flowwow, Zakaz).
+
+**Файлы:**
+- `commands/MarketplaceController.php` (embedded)
+- `controllers/MarketplaceFlowwowEmailsController.php`
+
+**ENV переменные:**
+```bash
+IMAP_EMAIL=orders@example.com
+IMAP_PASSWORD=
+EMAIL_ZAKAZ_PASSWORD=
+EMAIL_FLOW_PASSWORD=
+```
+
+**Сервер:**
+- `imaps://imap.yandex.ru:993` (SSL)
+
+**Тестовые сценарии:**
+1. Соединение с IMAP
+2. Парсинг писем
+3. Негативные: auth failed, connection timeout
+
+---
+
+### 9. 1C Интеграция
+
+**Назначение:** Обмен данными с 1C Бухгалтерией.
+
+**Файлы:**
+- `modul/cron/1c.php`
+- `modul/orders/export1c.php`
+
+**ENV переменные:**
+```bash
+EXPORT_TOKEN_1C=
+FTP_1C_HOST=ftp.example.com
+FTP_1C_USER=
+FTP_1C_PASSWORD=
+```
+
+**Механизм:**
+1. Формирование XML файлов
+2. Загрузка по FTP
+3. Получение ответов
+4. Синхронизация данных
+
+**Тестовые сценарии:**
+1. Генерация XML
+2. FTP upload/download
+3. Негативные: connection failed, XML parse error
+
+---
+
+### 10. RabbitMQ Queue
+
+**Назначение:** Асинхронная обработка задач.
+
+**Конфигурация:** `config/web.php`, `config/console.php`
+
+**ENV переменные:**
+```bash
+RABBIT_HOST=localhost
+RABBIT_USER=admin
+RABBIT_PASSWORD=password
+```
+
+**DSN:**
+```
+amqp://admin:password@localhost:5672
+```
+
+**Queue:** `telegram-queue`
+**Exchange:** `telegram-exchange`
+
+**Тестовые сценарии:**
+1. Постановка задачи в очередь
+2. Обработка задачи (sync mode в тестах)
+3. Retry логика
+4. Негативные: connection failed, timeout
+
+---
+
+## Приоритет тестирования
+
+| Приоритет | Интеграции | Обоснование |
+|-----------|-----------|-------------|
+| 🔴 Критичный | RabbitMQ | Инфраструктура очередей |
+| 🟠 Высокий | AMO CRM, CloudPayments, 1C, Telegram, Yandex Market | Бизнес-критичные процессы |
+| 🟡 Средний | LPTracker, WhatsApp, SMS, IMAP, Flowwow, BonusPlus | Вспомогательные функции |
+| 🟢 Низкий | DomRu Cameras | Опциональная функция |
+
+---
+
+## Структура тестовых фикстур
+
+```
+tests/_data/external/
+├── amo/
+│   ├── auth_success.json
+│   ├── auth_error_401.json
+│   ├── contacts_list.json
+│   ├── lead_create.json
+│   └── refresh_token.json
+├── cloudpayments/
+│   ├── payments_list_success.json
+│   ├── payments_list_empty.json
+│   └── auth_error.json
+├── telegram/
+│   ├── send_message_success.json
+│   ├── send_message_error.json
+│   └── rate_limit_429.json
+├── lptracker/
+│   ├── auth_success.json
+│   └── leads_list.json
+├── whatsapp/
+│   ├── send_success.json
+│   └── send_error.json
+├── yandex_market/
+│   ├── stocks_update.json
+│   └── orders_list.json
+└── sms/
+    ├── greensms_success.json
+    └── smsru_success.xml
+```
+
+---
+
+## Связанные документы
+
+- [Тестовая стратегия](./TEST_STRATEGY.md)
+- [API контракты](./API_CONTRACTS.md)
+- [Моки и фикстуры](./MOCKS_AND_FIXTURES.md)
diff --git a/erp24/models/User.php b/erp24/models/User.php
new file mode 100644 (file)
index 0000000..2e3fb25
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+
+namespace app\models;
+
+class User extends \yii\base\BaseObject implements \yii\web\IdentityInterface
+{
+    public $id;
+    public $username;
+    public $password;
+    public $authKey;
+    public $accessToken;
+
+    private static $users = [
+        '100' => [
+            'id' => '100',
+            'username' => 'admin',
+            'password' => 'admin',
+            'authKey' => 'test100key',
+            'accessToken' => '100-token',
+        ],
+        '101' => [
+            'id' => '101',
+            'username' => 'demo',
+            'password' => 'demo',
+            'authKey' => 'test101key',
+            'accessToken' => '101-token',
+        ],
+    ];
+
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function findIdentity($id)
+    {
+        return isset(self::$users[$id]) ? new static(self::$users[$id]) : null;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function findIdentityByAccessToken($token, $type = null)
+    {
+        foreach (self::$users as $user) {
+            if ($user['accessToken'] === $token) {
+                return new static($user);
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Finds user by username
+     *
+     * @param string $username
+     * @return static|null
+     */
+    public static function findByUsername($username)
+    {
+        foreach (self::$users as $user) {
+            if (strcasecmp($user['username'], $username) === 0) {
+                return new static($user);
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getAuthKey()
+    {
+        return $this->authKey;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function validateAuthKey($authKey)
+    {
+        return $this->authKey === $authKey;
+    }
+
+    /**
+     * Validates password
+     *
+     * @param string $password password to validate
+     * @return bool if password provided is valid for current user
+     */
+    public function validatePassword($password)
+    {
+        return $this->password === $password;
+    }
+}
diff --git a/erp24/tests/_data/external/amo/auth_error_401.json b/erp24/tests/_data/external/amo/auth_error_401.json
new file mode 100644 (file)
index 0000000..b70b891
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "_comment": "AMO CRM OAuth2 ошибка авторизации (неверные credentials)",
+  "status": 401,
+  "title": "Unauthorized",
+  "detail": "The client credentials are invalid",
+  "type": "https://httpstatuses.io/401"
+}
diff --git a/erp24/tests/_data/external/amo/auth_success.json b/erp24/tests/_data/external/amo/auth_success.json
new file mode 100644 (file)
index 0000000..94f0645
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "_comment": "AMO CRM OAuth2 успешный ответ получения токена",
+  "token_type": "Bearer",
+  "expires_in": 86400,
+  "access_token": "test_access_token_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+  "refresh_token": "test_refresh_token_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
+}
diff --git a/erp24/tests/_data/external/amo/contacts_list.json b/erp24/tests/_data/external/amo/contacts_list.json
new file mode 100644 (file)
index 0000000..4ba6522
--- /dev/null
@@ -0,0 +1,72 @@
+{
+  "_comment": "AMO CRM список контактов",
+  "_links": {
+    "self": {
+      "href": "https://test.amocrm.ru/api/v4/contacts?page=1&limit=50"
+    }
+  },
+  "_page": 1,
+  "_page_count": 1,
+  "_embedded": {
+    "contacts": [
+      {
+        "id": 12345678,
+        "name": "Иван Иванов",
+        "first_name": "Иван",
+        "last_name": "Иванов",
+        "responsible_user_id": 1234567,
+        "group_id": 0,
+        "created_by": 1234567,
+        "updated_by": 1234567,
+        "created_at": 1704067200,
+        "updated_at": 1704153600,
+        "closest_task_at": null,
+        "is_deleted": false,
+        "is_unsorted": false,
+        "account_id": 12345678,
+        "_embedded": {
+          "tags": [],
+          "leads": [
+            {
+              "id": 87654321,
+              "_links": {
+                "self": {
+                  "href": "https://test.amocrm.ru/api/v4/leads/87654321"
+                }
+              }
+            }
+          ],
+          "companies": []
+        },
+        "custom_fields_values": [
+          {
+            "field_id": 123456,
+            "field_name": "Телефон",
+            "field_code": "PHONE",
+            "field_type": "multitext",
+            "values": [
+              {
+                "value": "+79001234567",
+                "enum_id": 123,
+                "enum_code": "WORK"
+              }
+            ]
+          },
+          {
+            "field_id": 123457,
+            "field_name": "Email",
+            "field_code": "EMAIL",
+            "field_type": "multitext",
+            "values": [
+              {
+                "value": "test@example.com",
+                "enum_id": 124,
+                "enum_code": "WORK"
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}
diff --git a/erp24/tests/_data/external/cloudpayments/charge_success.json b/erp24/tests/_data/external/cloudpayments/charge_success.json
new file mode 100644 (file)
index 0000000..93c821e
--- /dev/null
@@ -0,0 +1,29 @@
+{
+  "_comment": "CloudPayments успешное списание по токену",
+  "Success": true,
+  "Model": {
+    "TransactionId": 987654321,
+    "Amount": 2500.00,
+    "Currency": "RUB",
+    "CurrencyCode": 0,
+    "InvoiceId": "ORDER-2025-002",
+    "AccountId": "client_67890",
+    "Email": "client@example.com",
+    "Description": "Повторная оплата по подписке",
+    "CreatedDateIso": "2025-01-15T14:30:00",
+    "AuthDateIso": "2025-01-15T14:30:00",
+    "ConfirmDateIso": "2025-01-15T14:30:00",
+    "AuthCode": "B67890",
+    "TestMode": false,
+    "CardFirstSix": "411111",
+    "CardLastFour": "1111",
+    "CardExpDate": "12/26",
+    "CardType": "Visa",
+    "Status": "Completed",
+    "StatusCode": 3,
+    "Reason": "Approved",
+    "ReasonCode": 0,
+    "CardHolderMessage": "Оплата успешно проведена",
+    "Token": "tk_prod_xxxxxxxxxxxx"
+  }
+}
diff --git a/erp24/tests/_data/external/cloudpayments/error_auth_401.json b/erp24/tests/_data/external/cloudpayments/error_auth_401.json
new file mode 100644 (file)
index 0000000..eddb458
--- /dev/null
@@ -0,0 +1,5 @@
+{
+  "_comment": "CloudPayments ошибка авторизации (неверный API ключ)",
+  "Success": false,
+  "Message": "Invalid API Key"
+}
diff --git a/erp24/tests/_data/external/cloudpayments/error_validation.json b/erp24/tests/_data/external/cloudpayments/error_validation.json
new file mode 100644 (file)
index 0000000..fadc84a
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "_comment": "CloudPayments ошибка валидации параметров",
+  "Success": false,
+  "Message": "Invalid request parameters",
+  "Model": {
+    "Date": "Date is required"
+  }
+}
diff --git a/erp24/tests/_data/external/cloudpayments/payments_list_empty.json b/erp24/tests/_data/external/cloudpayments/payments_list_empty.json
new file mode 100644 (file)
index 0000000..b248417
--- /dev/null
@@ -0,0 +1,5 @@
+{
+  "_comment": "CloudPayments пустой список платежей",
+  "Success": true,
+  "Model": []
+}
diff --git a/erp24/tests/_data/external/cloudpayments/payments_list_success.json b/erp24/tests/_data/external/cloudpayments/payments_list_success.json
new file mode 100644 (file)
index 0000000..531a7d7
--- /dev/null
@@ -0,0 +1,42 @@
+{
+  "_comment": "CloudPayments успешный список платежей",
+  "Success": true,
+  "Model": [
+    {
+      "PublicId": "pk_test_xxxxxxxxxxxxxx",
+      "TransactionId": 123456789,
+      "Amount": 1500.00,
+      "Currency": "RUB",
+      "CurrencyCode": 0,
+      "PaymentAmount": 1500.00,
+      "PaymentCurrency": "RUB",
+      "PaymentCurrencyCode": 0,
+      "InvoiceId": "ORDER-2025-001",
+      "AccountId": "client_12345",
+      "Email": "client@example.com",
+      "Description": "Оплата заказа ORDER-2025-001",
+      "JsonData": null,
+      "CreatedDate": "/Date(1704067200000)/",
+      "CreatedDateIso": "2024-01-01T00:00:00",
+      "AuthDate": "/Date(1704067200000)/",
+      "AuthDateIso": "2024-01-01T00:00:00",
+      "ConfirmDate": "/Date(1704067200000)/",
+      "ConfirmDateIso": "2024-01-01T00:00:00",
+      "AuthCode": "A12345",
+      "TestMode": true,
+      "Rrn": null,
+      "OriginalTransactionId": null,
+      "CardFirstSix": "411111",
+      "CardLastFour": "1111",
+      "CardExpDate": "12/25",
+      "CardType": "Visa",
+      "Status": "Completed",
+      "StatusCode": 3,
+      "Reason": "Approved",
+      "ReasonCode": 0,
+      "CardHolderMessage": "Оплата прошла успешно",
+      "Name": "CARD HOLDER",
+      "Token": "tk_test_xxxxxxxxxxxx"
+    }
+  ]
+}
diff --git a/erp24/tests/_data/external/cloudpayments/refund_success.json b/erp24/tests/_data/external/cloudpayments/refund_success.json
new file mode 100644 (file)
index 0000000..9c25889
--- /dev/null
@@ -0,0 +1,13 @@
+{
+  "_comment": "CloudPayments успешный возврат платежа",
+  "Success": true,
+  "Model": {
+    "TransactionId": 123456790,
+    "Amount": 1500.00,
+    "PayoutAmount": -1500.00,
+    "Status": "Refunded",
+    "StatusCode": 4,
+    "Refunded": true,
+    "RefundDateIso": "2025-01-16T10:00:00"
+  }
+}
diff --git a/erp24/tests/_data/external/lptracker/auth_error.json b/erp24/tests/_data/external/lptracker/auth_error.json
new file mode 100644 (file)
index 0000000..781649a
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "_comment": "LPTracker ошибка авторизации",
+  "status": "error",
+  "error": {
+    "code": 401,
+    "message": "Invalid credentials"
+  }
+}
diff --git a/erp24/tests/_data/external/lptracker/auth_success.json b/erp24/tests/_data/external/lptracker/auth_success.json
new file mode 100644 (file)
index 0000000..532f003
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "_comment": "LPTracker успешная авторизация",
+  "status": "success",
+  "result": {
+    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test_token_payload.signature",
+    "expires_at": "2025-01-22T23:59:59+03:00"
+  }
+}
diff --git a/erp24/tests/_data/external/lptracker/lead_create_success.json b/erp24/tests/_data/external/lptracker/lead_create_success.json
new file mode 100644 (file)
index 0000000..da9c43d
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "_comment": "LPTracker успешное создание лида",
+  "status": "success",
+  "result": {
+    "id": 67890,
+    "phone": "+79009876543",
+    "name": "Новый клиент",
+    "email": "new_client@example.com",
+    "created_at": "2025-01-21 15:00:00",
+    "status": "NEW_LEAD",
+    "funnel_id": 2086013,
+    "project_id": 117605
+  }
+}
diff --git a/erp24/tests/_data/external/lptracker/lead_update_success.json b/erp24/tests/_data/external/lptracker/lead_update_success.json
new file mode 100644 (file)
index 0000000..8ee7314
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "_comment": "LPTracker успешное обновление лида",
+  "status": "success",
+  "result": {
+    "id": 12345,
+    "status": "TO_CALL",
+    "funnel_id": 2140957,
+    "updated_at": "2025-01-21 16:00:00"
+  }
+}
diff --git a/erp24/tests/_data/external/lptracker/leads_list.json b/erp24/tests/_data/external/lptracker/leads_list.json
new file mode 100644 (file)
index 0000000..894335b
--- /dev/null
@@ -0,0 +1,23 @@
+{
+  "_comment": "LPTracker список лидов",
+  "status": "success",
+  "data": [
+    {
+      "id": 12345,
+      "phone": "+79001234567",
+      "name": "Иван Иванов",
+      "email": "test@example.com",
+      "created_at": "2024-01-01 12:00:00",
+      "status": "NEW_LEAD",
+      "utm_source": "google",
+      "utm_medium": "cpc",
+      "utm_campaign": "test_campaign"
+    }
+  ],
+  "pagination": {
+    "total": 1,
+    "per_page": 50,
+    "current_page": 1,
+    "last_page": 1
+  }
+}
diff --git a/erp24/tests/_data/external/telegram/rate_limit_429.json b/erp24/tests/_data/external/telegram/rate_limit_429.json
new file mode 100644 (file)
index 0000000..751782a
--- /dev/null
@@ -0,0 +1,9 @@
+{
+  "_comment": "Telegram Bot API превышен лимит запросов",
+  "ok": false,
+  "error_code": 429,
+  "description": "Too Many Requests: retry after 60",
+  "parameters": {
+    "retry_after": 60
+  }
+}
diff --git a/erp24/tests/_data/external/telegram/send_message_error.json b/erp24/tests/_data/external/telegram/send_message_error.json
new file mode 100644 (file)
index 0000000..aa3c57c
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "_comment": "Telegram Bot API ошибка отправки сообщения",
+  "ok": false,
+  "error_code": 400,
+  "description": "Bad Request: chat not found"
+}
diff --git a/erp24/tests/_data/external/telegram/send_message_success.json b/erp24/tests/_data/external/telegram/send_message_success.json
new file mode 100644 (file)
index 0000000..41c0c85
--- /dev/null
@@ -0,0 +1,20 @@
+{
+  "_comment": "Telegram Bot API успешная отправка сообщения",
+  "ok": true,
+  "result": {
+    "message_id": 12345,
+    "from": {
+      "id": 123456789,
+      "is_bot": true,
+      "first_name": "ERP24 Bot",
+      "username": "erp24_bot"
+    },
+    "chat": {
+      "id": -1001234567890,
+      "title": "ERP24 Notifications",
+      "type": "supergroup"
+    },
+    "date": 1704067200,
+    "text": "Test notification message"
+  }
+}
diff --git a/erp24/tests/_data/external/whatsapp/cascade_list_success.json b/erp24/tests/_data/external/whatsapp/cascade_list_success.json
new file mode 100644 (file)
index 0000000..bd5ed0c
--- /dev/null
@@ -0,0 +1,19 @@
+{
+  "_comment": "EDNA WhatsApp список каскадов",
+  "data": [
+    {
+      "id": 5686,
+      "name": "kogort_cascade",
+      "status": "ACTIVE",
+      "channels": ["WHATSAPP"],
+      "createdAt": "2024-01-01T00:00:00Z"
+    },
+    {
+      "id": 5687,
+      "name": "promo_cascade",
+      "status": "ACTIVE",
+      "channels": ["WHATSAPP"],
+      "createdAt": "2024-02-01T00:00:00Z"
+    }
+  ]
+}
diff --git a/erp24/tests/_data/external/whatsapp/channel_profile_success.json b/erp24/tests/_data/external/whatsapp/channel_profile_success.json
new file mode 100644 (file)
index 0000000..aa583b3
--- /dev/null
@@ -0,0 +1,13 @@
+{
+  "_comment": "EDNA WhatsApp список каналов",
+  "data": [
+    {
+      "id": 11374,
+      "name": "WABA",
+      "type": "WHATSAPP",
+      "status": "ACTIVE",
+      "subjectId": 11374,
+      "createdAt": "2024-01-01T00:00:00Z"
+    }
+  ]
+}
diff --git a/erp24/tests/_data/external/whatsapp/error_auth_401.json b/erp24/tests/_data/external/whatsapp/error_auth_401.json
new file mode 100644 (file)
index 0000000..711c990
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "_comment": "EDNA WhatsApp ошибка авторизации",
+  "title": "auth-error",
+  "detail": "Ошибка авторизации. Проверьте правильность написания и срок действия ключа API.",
+  "status": 401
+}
diff --git a/erp24/tests/_data/external/whatsapp/error_cascade_not_found.json b/erp24/tests/_data/external/whatsapp/error_cascade_not_found.json
new file mode 100644 (file)
index 0000000..f60d7f5
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "_comment": "EDNA WhatsApp каскад не найден",
+  "title": "cascade-not-found",
+  "detail": "Указан неверный идентификатор каскада. Проверьте корректность указанного вами идентификатора.",
+  "status": 400
+}
diff --git a/erp24/tests/_data/external/whatsapp/error_out_of_balance.json b/erp24/tests/_data/external/whatsapp/error_out_of_balance.json
new file mode 100644 (file)
index 0000000..0819246
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "_comment": "EDNA WhatsApp недостаточно средств",
+  "title": "out-of-balance",
+  "detail": "Недостаточно средств на балансе.",
+  "status": 400
+}
diff --git a/erp24/tests/_data/external/whatsapp/messages_history_success.json b/erp24/tests/_data/external/whatsapp/messages_history_success.json
new file mode 100644 (file)
index 0000000..48deb69
--- /dev/null
@@ -0,0 +1,27 @@
+{
+  "_comment": "EDNA WhatsApp история сообщений",
+  "content": [
+    {
+      "messageId": "msg-001",
+      "address": "79001234567",
+      "text": "Тестовое сообщение",
+      "direction": "OUT",
+      "channelType": "WHATSAPP",
+      "deliveryStatus": "DELIVERED",
+      "sentOrReceivedAt": "2025-01-21T12:00:00Z",
+      "subjectId": 11374
+    },
+    {
+      "messageId": "msg-002",
+      "address": "79009876543",
+      "text": "Второе сообщение",
+      "direction": "OUT",
+      "channelType": "WHATSAPP",
+      "deliveryStatus": "READ",
+      "sentOrReceivedAt": "2025-01-21T12:30:00Z",
+      "subjectId": 11374
+    }
+  ],
+  "totalElements": 2,
+  "totalPages": 1
+}
diff --git a/erp24/tests/_data/external/whatsapp/send_message_success.json b/erp24/tests/_data/external/whatsapp/send_message_success.json
new file mode 100644 (file)
index 0000000..78f3a06
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "_comment": "EDNA WhatsApp успешная отправка сообщения",
+  "id": "msg-12345-67890",
+  "status": "SENT",
+  "requestId": "req-uuid-12345",
+  "cascadeId": 5686,
+  "createdAt": "2025-01-21T15:00:00Z"
+}
diff --git a/erp24/tests/_support/ApiTester.php b/erp24/tests/_support/ApiTester.php
new file mode 100644 (file)
index 0000000..ce06b01
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+
+/**
+ * Inherited Methods
+ * @method void wantToTest($text)
+ * @method void wantTo($text)
+ * @method void execute($callable)
+ * @method void expectTo($prediction)
+ * @method void expect($prediction)
+ * @method void amGoingTo($argumentation)
+ * @method void am($role)
+ * @method void lookForwardTo($achieveValue)
+ * @method void comment($description)
+ * @method void pause()
+ *
+ * @SuppressWarnings(PHPMD)
+*/
+class ApiTester extends \Codeception\Actor
+{
+    use _generated\ApiTesterActions;
+
+    /**
+     * Установить заголовок авторизации с токеном
+     */
+    public function amBearerAuthenticated(string $accessToken): void
+    {
+        $this->haveHttpHeader('X-ACCESS-TOKEN', $accessToken);
+    }
+
+    /**
+     * Установить стандартные заголовки для API запросов
+     */
+    public function setApiHeaders(string $accessToken = null): void
+    {
+        $this->haveHttpHeader('Content-Type', 'application/json');
+        $this->haveHttpHeader('Accept', 'application/json');
+
+        if ($accessToken !== null) {
+            $this->haveHttpHeader('X-ACCESS-TOKEN', $accessToken);
+        }
+    }
+
+    /**
+     * Отправить POST запрос с JSON body
+     */
+    public function sendJsonPost(string $url, array $data = []): void
+    {
+        $this->haveHttpHeader('Content-Type', 'application/json');
+        $this->sendPost($url, $data);
+    }
+
+    /**
+     * Проверить, что ответ содержит ошибку
+     */
+    public function seeErrorResponse(int $code, string $message = null): void
+    {
+        $this->seeResponseIsJson();
+
+        if ($message !== null) {
+            $this->seeResponseContainsJson([
+                'error' => [
+                    'code' => $code,
+                    'message' => $message
+                ]
+            ]);
+        } else {
+            $this->seeResponseContainsJson([
+                'error' => ['code' => $code]
+            ]);
+        }
+    }
+
+    /**
+     * Проверить успешный ответ
+     */
+    public function seeSuccessResponse(): void
+    {
+        $this->seeResponseCodeIsSuccessful();
+        $this->seeResponseIsJson();
+    }
+}
diff --git a/erp24/tests/_support/Helper/Api.php b/erp24/tests/_support/Helper/Api.php
new file mode 100644 (file)
index 0000000..65f681b
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Helper;
+
+use Codeception\Module;
+
+/**
+ * Helper для API тестов
+ *
+ * Предоставляет утилиты для тестирования REST API
+ */
+class Api extends Module
+{
+    /**
+     * Генерирует тестовый access token
+     */
+    public function generateTestToken(): string
+    {
+        return 'test_token_' . bin2hex(random_bytes(16));
+    }
+
+    /**
+     * Создаёт заголовки с авторизацией
+     */
+    public function getAuthHeaders(string $token): array
+    {
+        return [
+            'X-ACCESS-TOKEN' => $token,
+            'Content-Type' => 'application/json',
+        ];
+    }
+
+    /**
+     * Проверяет структуру JSON ответа API
+     */
+    public function assertApiResponseStructure(array $response, array $requiredKeys): void
+    {
+        foreach ($requiredKeys as $key) {
+            $this->assertTrue(
+                array_key_exists($key, $response),
+                "Response must contain key: {$key}"
+            );
+        }
+    }
+
+    /**
+     * Проверяет что ответ содержит ошибку
+     */
+    public function assertApiError(array $response, ?int $expectedCode = null): void
+    {
+        $this->assertTrue(
+            isset($response['errors']) || isset($response['error']) || isset($response['message']),
+            'Response must contain error field'
+        );
+
+        if ($expectedCode !== null && isset($response['code'])) {
+            $this->assertEquals($expectedCode, $response['code']);
+        }
+    }
+
+    /**
+     * Проверяет успешный ответ API
+     */
+    public function assertApiSuccess(array $response): void
+    {
+        $this->assertFalse(
+            isset($response['errors']) || isset($response['error']),
+            'Response should not contain error field'
+        );
+    }
+}
diff --git a/erp24/tests/_support/Helper/MockHttpClient.php b/erp24/tests/_support/Helper/MockHttpClient.php
new file mode 100644 (file)
index 0000000..2cbc3de
--- /dev/null
@@ -0,0 +1,307 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\_support\Helper;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use Psr\Http\Message\RequestInterface;
+
+/**
+ * MockHttpClient — фабрика для создания Guzzle клиентов с mock handler.
+ *
+ * Использование в тестах:
+ *
+ * ```php
+ * $mockClient = MockHttpClient::create([
+ *     MockHttpClient::jsonResponse(['status' => 'ok']),
+ *     MockHttpClient::jsonResponse(['error' => 'not found'], 404),
+ * ]);
+ *
+ * // Использовать $mockClient->getClient() вместо реального Guzzle Client
+ * ```
+ */
+class MockHttpClient
+{
+    private Client $client;
+    private MockHandler $mockHandler;
+    private array $history = [];
+
+    /**
+     * Создаёт MockHttpClient с заданными ответами
+     *
+     * @param Response[] $responses Массив Response объектов для очереди
+     */
+    public function __construct(array $responses = [])
+    {
+        $this->mockHandler = new MockHandler($responses);
+
+        $historyMiddleware = Middleware::history($this->history);
+
+        $handlerStack = HandlerStack::create($this->mockHandler);
+        $handlerStack->push($historyMiddleware);
+
+        $this->client = new Client(['handler' => $handlerStack]);
+    }
+
+    /**
+     * Создаёт новый MockHttpClient (статический фабричный метод)
+     *
+     * @param Response[] $responses
+     */
+    public static function create(array $responses = []): self
+    {
+        return new self($responses);
+    }
+
+    /**
+     * Возвращает Guzzle Client с mock handler
+     */
+    public function getClient(): Client
+    {
+        return $this->client;
+    }
+
+    /**
+     * Добавляет ответ в очередь
+     */
+    public function append(Response $response): self
+    {
+        $this->mockHandler->append($response);
+        return $this;
+    }
+
+    /**
+     * Добавляет исключение в очередь
+     */
+    public function appendException(\Throwable $exception): self
+    {
+        $this->mockHandler->append($exception);
+        return $this;
+    }
+
+    /**
+     * Возвращает историю запросов
+     *
+     * @return array<array{request: RequestInterface, response: Response}>
+     */
+    public function getHistory(): array
+    {
+        return $this->history;
+    }
+
+    /**
+     * Возвращает последний запрос
+     */
+    public function getLastRequest(): ?RequestInterface
+    {
+        $last = end($this->history);
+        return $last ? $last['request'] : null;
+    }
+
+    /**
+     * Возвращает количество выполненных запросов
+     */
+    public function getRequestCount(): int
+    {
+        return count($this->history);
+    }
+
+    /**
+     * Сбрасывает mock handler
+     */
+    public function reset(): void
+    {
+        $this->mockHandler->reset();
+        $this->history = [];
+    }
+
+    // =========================================================================
+    // Фабричные методы для Response
+    // =========================================================================
+
+    /**
+     * Создаёт JSON Response
+     *
+     * @param mixed $data Данные для JSON
+     * @param int $status HTTP статус код
+     * @param array $headers Дополнительные заголовки
+     */
+    public static function jsonResponse(mixed $data, int $status = 200, array $headers = []): Response
+    {
+        $headers['Content-Type'] = 'application/json';
+        return new Response(
+            $status,
+            $headers,
+            json_encode($data, JSON_UNESCAPED_UNICODE)
+        );
+    }
+
+    /**
+     * Создаёт XML Response
+     *
+     * @param string $xml XML контент
+     * @param int $status HTTP статус код
+     * @param array $headers Дополнительные заголовки
+     */
+    public static function xmlResponse(string $xml, int $status = 200, array $headers = []): Response
+    {
+        $headers['Content-Type'] = 'application/xml';
+        return new Response($status, $headers, $xml);
+    }
+
+    /**
+     * Создаёт текстовый Response
+     *
+     * @param string $text Текстовый контент
+     * @param int $status HTTP статус код
+     * @param array $headers Дополнительные заголовки
+     */
+    public static function textResponse(string $text, int $status = 200, array $headers = []): Response
+    {
+        $headers['Content-Type'] = 'text/plain';
+        return new Response($status, $headers, $text);
+    }
+
+    /**
+     * Создаёт пустой Response
+     *
+     * @param int $status HTTP статус код
+     * @param array $headers Дополнительные заголовки
+     */
+    public static function emptyResponse(int $status = 204, array $headers = []): Response
+    {
+        return new Response($status, $headers, '');
+    }
+
+    /**
+     * Создаёт Response с ошибкой авторизации (401)
+     *
+     * @param string $message Сообщение об ошибке
+     */
+    public static function unauthorizedResponse(string $message = 'Unauthorized'): Response
+    {
+        return self::jsonResponse(['error' => $message], 401);
+    }
+
+    /**
+     * Создаёт Response с ошибкой доступа (403)
+     *
+     * @param string $message Сообщение об ошибке
+     */
+    public static function forbiddenResponse(string $message = 'Forbidden'): Response
+    {
+        return self::jsonResponse(['error' => $message], 403);
+    }
+
+    /**
+     * Создаёт Response "не найдено" (404)
+     *
+     * @param string $message Сообщение об ошибке
+     */
+    public static function notFoundResponse(string $message = 'Not Found'): Response
+    {
+        return self::jsonResponse(['error' => $message], 404);
+    }
+
+    /**
+     * Создаёт Response с ошибкой rate limit (429)
+     *
+     * @param int $retryAfter Секунды до повтора
+     */
+    public static function rateLimitResponse(int $retryAfter = 60): Response
+    {
+        return new Response(
+            429,
+            [
+                'Content-Type' => 'application/json',
+                'Retry-After' => (string)$retryAfter,
+            ],
+            json_encode(['error' => 'Too Many Requests', 'retry_after' => $retryAfter])
+        );
+    }
+
+    /**
+     * Создаёт Response с серверной ошибкой (500)
+     *
+     * @param string $message Сообщение об ошибке
+     */
+    public static function serverErrorResponse(string $message = 'Internal Server Error'): Response
+    {
+        return self::jsonResponse(['error' => $message], 500);
+    }
+
+    // =========================================================================
+    // Специфичные для интеграций ответы
+    // =========================================================================
+
+    /**
+     * Создаёт успешный ответ AMO CRM OAuth token
+     */
+    public static function amoTokenResponse(
+        string $accessToken = 'test_access_token',
+        string $refreshToken = 'test_refresh_token',
+        int $expiresIn = 86400
+    ): Response {
+        return self::jsonResponse([
+            'token_type' => 'Bearer',
+            'expires_in' => $expiresIn,
+            'access_token' => $accessToken,
+            'refresh_token' => $refreshToken,
+        ]);
+    }
+
+    /**
+     * Создаёт успешный ответ Telegram sendMessage
+     */
+    public static function telegramSendMessageResponse(int $messageId = 12345, int $chatId = -100123): Response
+    {
+        return self::jsonResponse([
+            'ok' => true,
+            'result' => [
+                'message_id' => $messageId,
+                'chat' => ['id' => $chatId],
+                'date' => time(),
+                'text' => 'Test message',
+            ],
+        ]);
+    }
+
+    /**
+     * Создаёт ответ ошибки Telegram
+     */
+    public static function telegramErrorResponse(string $description = 'Bad Request', int $errorCode = 400): Response
+    {
+        return self::jsonResponse([
+            'ok' => false,
+            'error_code' => $errorCode,
+            'description' => $description,
+        ], $errorCode);
+    }
+
+    /**
+     * Создаёт успешный ответ CloudPayments payments/list
+     */
+    public static function cloudPaymentsListResponse(array $payments = []): Response
+    {
+        return self::jsonResponse([
+            'Success' => true,
+            'Model' => $payments,
+        ]);
+    }
+
+    /**
+     * Создаёт ответ LPTracker auth
+     */
+    public static function lpTrackerAuthResponse(string $token = 'test_lptracker_token'): Response
+    {
+        return self::jsonResponse([
+            'status' => 'success',
+            'token' => $token,
+        ]);
+    }
+}
diff --git a/erp24/tests/_support/Helper/NetworkGuard.php b/erp24/tests/_support/Helper/NetworkGuard.php
new file mode 100644 (file)
index 0000000..01dbd2c
--- /dev/null
@@ -0,0 +1,169 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\_support\Helper;
+
+use Codeception\Module;
+use Codeception\TestInterface;
+
+/**
+ * NetworkGuard — охранный модуль для предотвращения реальных HTTP-запросов в тестах.
+ *
+ * ВАЖНО: Этот модуль гарантирует, что unit и functional тесты НЕ делают реальных
+ * сетевых запросов. Любая попытка HTTP-вызова должна приводить к падению теста.
+ *
+ * Подключение в suite.yml:
+ *
+ * ```yaml
+ * modules:
+ *   enabled:
+ *     - \tests\_support\Helper\NetworkGuard
+ * ```
+ *
+ * @group network-isolation
+ */
+class NetworkGuard extends Module
+{
+    /**
+     * Список разрешённых хостов для тестов (localhost, mock servers)
+     */
+    protected array $config = [
+        'allowedHosts' => [
+            '127.0.0.1',
+            'localhost',
+            '::1',
+        ],
+        'enabled' => true,
+        'failOnRealNetwork' => true,
+    ];
+
+    /**
+     * Список зафиксированных попыток внешних запросов
+     */
+    private array $networkAttempts = [];
+
+    /**
+     * Вызывается перед каждым тестом
+     */
+    public function _before(TestInterface $test): void
+    {
+        if (!$this->config['enabled']) {
+            return;
+        }
+
+        $this->networkAttempts = [];
+
+        // Устанавливаем mock для stream_context_create если возможно
+        $this->setupStreamContextGuard();
+    }
+
+    /**
+     * Вызывается после каждого теста
+     */
+    public function _after(TestInterface $test): void
+    {
+        if (!$this->config['enabled']) {
+            return;
+        }
+
+        // Проверяем, были ли попытки реальных сетевых запросов
+        if ($this->config['failOnRealNetwork'] && !empty($this->networkAttempts)) {
+            $attempts = implode("\n", array_map(
+                fn($a) => "  - {$a['url']} (from {$a['trace']})",
+                $this->networkAttempts
+            ));
+
+            $this->fail(
+                "Test made real network requests! This is forbidden in unit/functional tests.\n" .
+                "Detected attempts:\n{$attempts}\n\n" .
+                "Solution: Use mocks or add the host to 'allowedHosts' config if it's a local test server."
+            );
+        }
+    }
+
+    /**
+     * Регистрирует попытку сетевого запроса
+     */
+    public function registerNetworkAttempt(string $url, string $trace = ''): void
+    {
+        $parsed = parse_url($url);
+        $host = $parsed['host'] ?? 'unknown';
+
+        // Проверяем, разрешён ли хост
+        if (in_array($host, $this->config['allowedHosts'], true)) {
+            return;
+        }
+
+        $this->networkAttempts[] = [
+            'url' => $url,
+            'host' => $host,
+            'trace' => $trace ?: $this->getCallerInfo(),
+        ];
+    }
+
+    /**
+     * Проверяет, является ли хост разрешённым
+     */
+    public function isHostAllowed(string $host): bool
+    {
+        return in_array($host, $this->config['allowedHosts'], true);
+    }
+
+    /**
+     * Добавляет хост в список разрешённых (для конкретного теста)
+     */
+    public function allowHost(string $host): void
+    {
+        if (!in_array($host, $this->config['allowedHosts'], true)) {
+            $this->config['allowedHosts'][] = $host;
+        }
+    }
+
+    /**
+     * Возвращает список попыток сетевых запросов
+     */
+    public function getNetworkAttempts(): array
+    {
+        return $this->networkAttempts;
+    }
+
+    /**
+     * Очищает список попыток
+     */
+    public function clearNetworkAttempts(): void
+    {
+        $this->networkAttempts = [];
+    }
+
+    /**
+     * Настраивает перехват stream_context для file_get_contents и т.д.
+     */
+    private function setupStreamContextGuard(): void
+    {
+        // В PHP нет прямого способа перехватить все HTTP-запросы
+        // Это placeholder для интеграции с Guzzle MockHandler или php-vcr
+        //
+        // Рекомендуемые решения:
+        // 1. Использовать Guzzle с MockHandler во всех HTTP-клиентах
+        // 2. Подключить php-vcr для записи/воспроизведения HTTP
+        // 3. Использовать DI для подмены HTTP-клиентов в тестах
+    }
+
+    /**
+     * Получает информацию о вызывающем коде
+     */
+    private function getCallerInfo(): string
+    {
+        $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
+
+        foreach ($trace as $frame) {
+            $file = $frame['file'] ?? '';
+            if (strpos($file, 'tests/') === false && strpos($file, 'vendor/') === false) {
+                return basename($file) . ':' . ($frame['line'] ?? '?');
+            }
+        }
+
+        return 'unknown';
+    }
+}
diff --git a/erp24/tests/api.suite.yml b/erp24/tests/api.suite.yml
new file mode 100644 (file)
index 0000000..e82de15
--- /dev/null
@@ -0,0 +1,13 @@
+# Codeception Test Suite Configuration
+#
+# Suite for API (REST) tests
+# Tests API endpoints without browser emulation
+
+actor: ApiTester
+modules:
+    enabled:
+        - REST:
+            depends: PhpBrowser
+            url: http://localhost
+        - Asserts
+        - \Helper\Api
diff --git a/erp24/tests/api/Api1AuthCest.php b/erp24/tests/api/Api1AuthCest.php
new file mode 100644 (file)
index 0000000..db9ad30
--- /dev/null
@@ -0,0 +1,180 @@
+<?php
+
+namespace tests\api;
+
+use ApiTester;
+use Codeception\Util\HttpCode;
+
+/**
+ * API1 (legacy) AuthController smoke-тесты
+ *
+ * Тестирует базовую работоспособность endpoints аутентификации API1.
+ * API1 - это устаревшее API, требующее smoke-тестов для обеспечения обратной совместимости.
+ */
+class Api1AuthCest
+{
+    private string $api1BaseUrl = '/api1';
+
+    public function _before(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+    }
+
+    // ========== actionLogin smoke tests ==========
+
+    /**
+     * Smoke-тест: Login endpoint доступен
+     */
+    public function testLoginEndpointAvailable(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность login endpoint в API1');
+
+        $I->sendPost($this->api1BaseUrl . '/auth/login', [
+            'login' => 'test_user',
+            'password' => 'test_password'
+        ]);
+
+        // Endpoint должен отвечать (200 с ошибкой или успехом)
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+    }
+
+    /**
+     * Smoke-тест: Login возвращает корректную структуру при неверных данных
+     */
+    public function testLoginReturnsErrorStructure(ApiTester $I): void
+    {
+        $I->wantTo('Проверить структуру ответа при неверных учётных данных');
+
+        $I->sendPost($this->api1BaseUrl . '/auth/login', [
+            'login' => 'nonexistent_user',
+            'password' => 'wrong_password'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Smoke-тест: Login с пустым телом запроса
+     */
+    public function testLoginWithEmptyBody(ApiTester $I): void
+    {
+        $I->wantTo('Проверить ответ при пустом теле запроса');
+
+        $I->sendPost($this->api1BaseUrl . '/auth/login', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Должен вернуть ошибку
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Smoke-тест: Login без пароля
+     */
+    public function testLoginWithoutPassword(ApiTester $I): void
+    {
+        $I->wantTo('Проверить ответ при отсутствии пароля');
+
+        $I->sendPost($this->api1BaseUrl . '/auth/login', [
+            'login' => 'test_user'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Smoke-тест: Login без логина
+     */
+    public function testLoginWithoutLogin(ApiTester $I): void
+    {
+        $I->wantTo('Проверить ответ при отсутствии логина');
+
+        $I->sendPost($this->api1BaseUrl . '/auth/login', [
+            'password' => 'test_password'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Smoke-тест: Login через короткий URL
+     */
+    public function testLoginViaShortUrl(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность login через короткий URL /api1/auth');
+
+        $I->sendPost($this->api1BaseUrl . '/auth', [
+            'login' => 'test_user',
+            'password' => 'test_password'
+        ]);
+
+        // Endpoint должен работать (правило в urlManager: 'auth' => 'auth/login')
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+    }
+
+    /**
+     * Smoke-тест: Проверка CORS заголовков
+     */
+    public function testCorsHeaders(ApiTester $I): void
+    {
+        $I->wantTo('Проверить наличие CORS заголовков');
+
+        $I->haveHttpHeader('Origin', 'http://example.com');
+        $I->sendPost($this->api1BaseUrl . '/auth/login', [
+            'login' => 'test',
+            'password' => 'test'
+        ]);
+
+        $I->seeResponseCodeIsSuccessful();
+        // CORS headers должны быть установлены
+        // Access-Control-Allow-Origin: *
+    }
+
+    /**
+     * Smoke-тест: OPTIONS запрос для preflight
+     */
+    public function testOptionsPreflightRequest(ApiTester $I): void
+    {
+        $I->wantTo('Проверить обработку OPTIONS запроса (CORS preflight)');
+
+        $I->haveHttpHeader('Origin', 'http://example.com');
+        $I->haveHttpHeader('Access-Control-Request-Method', 'POST');
+        $I->sendOPTIONS($this->api1BaseUrl . '/auth/login');
+
+        // OPTIONS должен возвращать 200 (или 204)
+        $response = $I->grabResponse();
+        // Preflight не должен требовать аутентификацию
+    }
+
+    /**
+     * Smoke-тест: Ответ в формате JSON
+     */
+    public function testResponseIsJson(ApiTester $I): void
+    {
+        $I->wantTo('Проверить что ответ всегда в формате JSON');
+
+        $I->sendPost($this->api1BaseUrl . '/auth/login', [
+            'login' => 'any',
+            'password' => 'any'
+        ]);
+
+        $I->seeResponseIsJson();
+        $I->seeHttpHeader('Content-Type', 'application/json; charset=UTF-8');
+    }
+}
diff --git a/erp24/tests/api/Api1CronCest.php b/erp24/tests/api/Api1CronCest.php
new file mode 100644 (file)
index 0000000..1061fcd
--- /dev/null
@@ -0,0 +1,202 @@
+<?php
+
+namespace tests\api;
+
+use ApiTester;
+use Codeception\Util\HttpCode;
+
+/**
+ * API1 (legacy) CronController smoke-тесты
+ *
+ * Тестирует доступность cron endpoints в API1.
+ * Cron endpoints требуют аутентификации через X-ACCESS-TOKEN.
+ */
+class Api1CronCest
+{
+    private string $api1BaseUrl = '/api1';
+    private string $accessToken = 'test_access_token';
+
+    public function _before(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->haveHttpHeader('X-ACCESS-TOKEN', $this->accessToken);
+    }
+
+    // ========== Cron actions smoke tests ==========
+
+    /**
+     * Smoke-тест: CloudPayments endpoint без токена
+     */
+    public function testCloudPaymentsWithoutToken(ApiTester $I): void
+    {
+        $I->wantTo('Проверить что CloudPayments требует token_cloud');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/cloudpayments');
+
+        // Без правильного token_cloud должен завершиться
+        // (либо 401/403, либо пустой ответ из-за exit())
+        $code = $I->grabResponseCode();
+        $this->assertContains($code, [200, 401, 403], 'Ответ должен быть 200, 401 или 403');
+    }
+
+    /**
+     * Smoke-тест: CloudPayments endpoint с неверным токеном
+     */
+    public function testCloudPaymentsWithWrongToken(ApiTester $I): void
+    {
+        $I->wantTo('Проверить CloudPayments с неверным token_cloud');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/cloudpayments', [
+            'token_cloud' => 'wrong_token'
+        ]);
+
+        // Должен отклонить запрос
+        $code = $I->grabResponseCode();
+        // Из-за exit() может быть пустой ответ с кодом 200
+    }
+
+    /**
+     * Smoke-тест: GetToken endpoint доступен
+     */
+    public function testGetTokenEndpointAvailable(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность get-token endpoint');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/get-token');
+
+        // Endpoint должен отвечать
+        $I->seeResponseCodeIsSuccessful();
+    }
+
+    /**
+     * Smoke-тест: Callback endpoint доступен
+     */
+    public function testCallbackEndpointAvailable(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность callback endpoint');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/callback');
+
+        // Endpoint должен отвечать (может требовать параметры)
+        $code = $I->grabResponseCode();
+        $this->assertContains($code, [200, 400, 401, 403, 404, 500]);
+    }
+
+    /**
+     * Smoke-тест: ExportCatalog endpoint
+     */
+    public function testExportCatalogEndpoint(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность export-catalog endpoint');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/export-catalog');
+
+        // Должен быть доступен
+        $code = $I->grabResponseCode();
+        $this->assertContains($code, [200, 401, 403, 500]);
+    }
+
+    /**
+     * Smoke-тест: Amo142 endpoint
+     */
+    public function testAmo142Endpoint(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность amo142 endpoint');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/amo142');
+
+        $code = $I->grabResponseCode();
+        $this->assertContains($code, [200, 401, 403, 500]);
+    }
+
+    /**
+     * Smoke-тест: ImportAmoInCrm endpoint
+     */
+    public function testImportAmoInCrmEndpoint(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность import-amo-in-crm endpoint');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/import-amo-in-crm');
+
+        $code = $I->grabResponseCode();
+        $this->assertContains($code, [200, 401, 403, 500]);
+    }
+
+    /**
+     * Smoke-тест: DomRuCams endpoint
+     */
+    public function testDomRuCamsEndpoint(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность domru-cams endpoint');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/domru-cams');
+
+        $code = $I->grabResponseCode();
+        $this->assertContains($code, [200, 401, 403, 500]);
+    }
+
+    /**
+     * Smoke-тест: CloudPaymentsRegion endpoint
+     */
+    public function testCloudPaymentsRegionEndpoint(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность cloudpayments-region endpoint');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/cloudpayments-region');
+
+        $code = $I->grabResponseCode();
+        $this->assertContains($code, [200, 401, 403, 500]);
+    }
+
+    /**
+     * Smoke-тест: BonusUsersSaleUpdate endpoint
+     */
+    public function testBonusUsersSaleUpdateEndpoint(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступность bonus-users-sale-update endpoint');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/bonus-users-sale-update');
+
+        $code = $I->grabResponseCode();
+        $this->assertContains($code, [200, 401, 403, 500]);
+    }
+
+    /**
+     * Smoke-тест: Несуществующий cron action
+     */
+    public function testNonExistentCronAction(ApiTester $I): void
+    {
+        $I->wantTo('Проверить ответ на несуществующий cron action');
+
+        $I->sendGet($this->api1BaseUrl . '/cron/nonexistent-action');
+
+        // Должен вернуть 404
+        $I->seeResponseCodeIs(HttpCode::NOT_FOUND);
+    }
+
+    /**
+     * Smoke-тест: Проверка что cron controller отвечает на POST
+     */
+    public function testCronAcceptsPost(ApiTester $I): void
+    {
+        $I->wantTo('Проверить что cron endpoints принимают POST');
+
+        $I->sendPost($this->api1BaseUrl . '/cron/get-token', []);
+
+        // Должен отвечать
+        $code = $I->grabResponseCode();
+        // POST может быть разрешён или запрещён в зависимости от конфигурации
+    }
+
+    /**
+     * Вспомогательный метод для проверки кода ответа
+     */
+    private function assertContains(int $needle, array $haystack, string $message = ''): void
+    {
+        if (!in_array($needle, $haystack)) {
+            throw new \PHPUnit\Framework\AssertionFailedError(
+                $message ?: "Значение {$needle} не найдено в массиве [" . implode(', ', $haystack) . "]"
+            );
+        }
+    }
+}
diff --git a/erp24/tests/api/AuthCest.php b/erp24/tests/api/AuthCest.php
new file mode 100644 (file)
index 0000000..805db0a
--- /dev/null
@@ -0,0 +1,147 @@
+<?php
+
+namespace tests\api;
+
+use ApiTester;
+use Codeception\Util\HttpCode;
+
+/**
+ * API2 AuthController тесты
+ *
+ * Тестирует аутентификацию и генерацию токенов доступа.
+ */
+class AuthCest
+{
+    /**
+     * Тест успешной авторизации
+     */
+    public function testLoginSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Авторизоваться с корректными учётными данными');
+
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api2/auth/login', [
+            'login' => 'test_api_user',
+            'password' => 'test_password'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseMatchesJsonType([
+            'access-token' => 'string'
+        ]);
+    }
+
+    /**
+     * Тест авторизации с неверным логином
+     */
+    public function testLoginWrongLogin(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при неверном логине');
+
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api2/auth/login', [
+            'login' => 'nonexistent_user',
+            'password' => 'any_password'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Тест авторизации с неверным паролем
+     */
+    public function testLoginWrongPassword(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при неверном пароле');
+
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api2/auth/login', [
+            'login' => 'test_api_user',
+            'password' => 'wrong_password'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Тест авторизации без логина
+     */
+    public function testLoginMissingLogin(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии логина');
+
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api2/auth/login', [
+            'password' => 'test_password'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Тест авторизации без пароля
+     */
+    public function testLoginMissingPassword(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии пароля');
+
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api2/auth/login', [
+            'login' => 'test_api_user'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Тест авторизации с пустым телом запроса
+     */
+    public function testLoginEmptyBody(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при пустом теле запроса');
+
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api2/auth/login', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+
+    /**
+     * Тест авторизации с невалидным JSON
+     */
+    public function testLoginInvalidJson(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при невалидном JSON');
+
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api2/auth/login', 'invalid json{');
+
+        // При невалидном JSON данные не парсятся
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'errors' => 'Wrong login of password'
+        ]);
+    }
+}
diff --git a/erp24/tests/api/BonusCest.php b/erp24/tests/api/BonusCest.php
new file mode 100644 (file)
index 0000000..fbecfbf
--- /dev/null
@@ -0,0 +1,181 @@
+<?php
+
+namespace tests\api;
+
+use ApiTester;
+use Codeception\Util\HttpCode;
+
+/**
+ * API2 BonusController тесты
+ *
+ * Тестирует операции с бонусами: получение, начисление, списание.
+ */
+class BonusCest
+{
+    private string $accessToken = 'test_access_token';
+
+    public function _before(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->haveHttpHeader('X-ACCESS-TOKEN', $this->accessToken);
+    }
+
+    // ========== actionGetBonuses ==========
+
+    /**
+     * Тест получения бонусов без store_id
+     */
+    public function testGetBonusesMissingStoreId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии store_id');
+
+        $I->sendPost('/api2/bonus/get-bonuses', [
+            'seller_id' => 'SELLER-123',
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 0,
+            'error' => 'store_id is required'
+        ]);
+    }
+
+    /**
+     * Тест получения бонусов без seller_id
+     */
+    public function testGetBonusesMissingSellerId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии seller_id');
+
+        $I->sendPost('/api2/bonus/get-bonuses', [
+            'store_id' => 'STORE-123',
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 0,
+            'error' => 'seller_id is required'
+        ]);
+    }
+
+    /**
+     * Тест получения бонусов без phone
+     */
+    public function testGetBonusesMissingPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии phone');
+
+        $I->sendPost('/api2/bonus/get-bonuses', [
+            'store_id' => 'STORE-123',
+            'seller_id' => 'SELLER-123'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 0,
+            'error' => 'phone is required'
+        ]);
+    }
+
+    /**
+     * Тест получения бонусов с невалидным телефоном
+     */
+    public function testGetBonusesInvalidPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при невалидном телефоне');
+
+        $I->sendPost('/api2/bonus/get-bonuses', [
+            'store_id' => 'STORE-123',
+            'seller_id' => 'SELLER-123',
+            'phone' => '123'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 0.2,
+            'error' => 'phone is required'
+        ]);
+    }
+
+    /**
+     * Тест получения бонусов для несуществующего клиента
+     */
+    public function testGetBonusesNewClient(ApiTester $I): void
+    {
+        $I->wantTo('Проверить ответ для нового клиента');
+
+        $I->sendPost('/api2/bonus/get-bonuses', [
+            'store_id' => 'STORE-GUID-123',
+            'seller_id' => 'SELLER-GUID-456',
+            'phone' => '79999999999',
+            'check_amount' => 1000,
+            'items' => []
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+
+        // Для нового клиента ожидаем new_client: true и message_cashier
+        $response = json_decode($I->grabResponse(), true);
+        if (isset($response['new_client'])) {
+            $I->assertTrue($response['new_client']);
+            $I->assertEquals('Заполните данные клиента', $response['message_cashier']);
+        }
+    }
+
+    /**
+     * Тест получения бонусов с корректными данными
+     */
+    public function testGetBonusesSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Получить бонусы для существующего клиента');
+
+        $I->sendPost('/api2/bonus/get-bonuses', [
+            'store_id' => 'STORE-GUID-123',
+            'seller_id' => 'SELLER-GUID-456',
+            'phone' => '79001234567',
+            'check_amount' => 1000,
+            'items' => [
+                [
+                    'product_id' => 'PROD-1',
+                    'price' => 500,
+                    'quantity' => 2
+                ]
+            ]
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+
+        // Проверяем, что есть ответ (либо бонусы, либо new_client)
+        $response = json_decode($I->grabResponse(), true);
+        $I->assertTrue(
+            isset($response['will_be_credited_bonuses']) || isset($response['new_client']) || isset($response['error']),
+            'Ответ должен содержать бонусную информацию или статус клиента'
+        );
+    }
+
+    /**
+     * Тест получения бонусов с пустым списком товаров
+     */
+    public function testGetBonusesEmptyItems(ApiTester $I): void
+    {
+        $I->wantTo('Получить бонусы без списка товаров');
+
+        $I->sendPost('/api2/bonus/get-bonuses', [
+            'store_id' => 'STORE-GUID-123',
+            'seller_id' => 'SELLER-GUID-456',
+            'phone' => '79001234567',
+            'check_amount' => 1000
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+    }
+}
diff --git a/erp24/tests/api/ClientCest.php b/erp24/tests/api/ClientCest.php
new file mode 100644 (file)
index 0000000..6ab4d0d
--- /dev/null
@@ -0,0 +1,488 @@
+<?php
+
+namespace tests\api;
+
+use ApiTester;
+use Codeception\Util\HttpCode;
+
+/**
+ * API2 ClientController тесты
+ *
+ * Тестирует операции с клиентами: создание, баланс, информация, бонусы.
+ */
+class ClientCest
+{
+    private string $accessToken = 'test_access_token';
+
+    public function _before(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->haveHttpHeader('X-ACCESS-TOKEN', $this->accessToken);
+    }
+
+    // ========== actionAdd ==========
+
+    /**
+     * Тест добавления клиента
+     */
+    public function testAddClientSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Добавить нового клиента');
+
+        $I->sendPost('/api2/client/add', [
+            'phone' => '79001234567',
+            'name' => 'Тестовый Клиент',
+            'client_id' => 12345,
+            'client_type' => 1,
+            'platform_id' => 1
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Успешный ответ содержит result или ошибку валидации
+        $I->dontSeeResponseContainsJson(['error_id' => 1]);
+    }
+
+    /**
+     * Тест добавления клиента без телефона
+     */
+    public function testAddClientMissingPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при добавлении клиента без телефона');
+
+        $I->sendPost('/api2/client/add', [
+            'name' => 'Тестовый Клиент'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'phone is required'
+        ]);
+    }
+
+    /**
+     * Тест добавления клиента с невалидным телефоном
+     */
+    public function testAddClientInvalidPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при невалидном номере телефона');
+
+        $I->sendPost('/api2/client/add', [
+            'phone' => '123',
+            'name' => 'Тестовый Клиент'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1.2,
+            'error' => 'phone is required'
+        ]);
+    }
+
+    // ========== actionBalance ==========
+
+    /**
+     * Тест получения баланса клиента
+     */
+    public function testGetBalanceSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Получить баланс клиента');
+
+        $I->sendPost('/api2/client/balance', [
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseMatchesJsonType([
+            'balance' => 'integer|float'
+        ]);
+    }
+
+    /**
+     * Тест получения баланса без телефона
+     */
+    public function testGetBalanceMissingPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при запросе баланса без телефона');
+
+        $I->sendPost('/api2/client/balance', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'phone is required'
+        ]);
+    }
+
+    // ========== actionGet ==========
+
+    /**
+     * Тест получения информации о клиенте
+     */
+    public function testGetClientSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Получить информацию о клиенте');
+
+        $I->sendPost('/api2/client/get', [
+            'phone' => '79001234567',
+            'client_type' => '1'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Если клиент найден - получаем client_id и platform_id
+        // Если не найден - получаем error_id 2
+    }
+
+    /**
+     * Тест получения информации о несуществующем клиенте
+     */
+    public function testGetClientNotFound(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при поиске несуществующего клиента');
+
+        $I->sendPost('/api2/client/get', [
+            'phone' => '79999999999',
+            'client_type' => '1'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 2,
+            'error' => 'no client with such phone and client_type'
+        ]);
+    }
+
+    // ========== actionGetInfo ==========
+
+    /**
+     * Тест получения полной информации о клиенте
+     */
+    public function testGetInfoSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Получить полную информацию о клиенте');
+
+        $I->sendPost('/api2/client/get-info', [
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Проверяем структуру ответа
+        $response = $I->grabResponse();
+        $data = json_decode($response, true);
+
+        if (isset($data['response']) && $data['response'] !== null) {
+            $I->seeResponseMatchesJsonType([
+                'response' => [
+                    'id' => 'integer',
+                    'phone' => 'string:optional',
+                    'balance' => 'integer|float'
+                ]
+            ]);
+        }
+    }
+
+    /**
+     * Тест получения информации без телефона и ref_code
+     */
+    public function testGetInfoMissingParams(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при запросе информации без телефона');
+
+        $I->sendPost('/api2/client/get-info', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'phone or ref_id is required'
+        ]);
+    }
+
+    // ========== actionBonusStatus ==========
+
+    /**
+     * Тест получения бонусного статуса
+     */
+    public function testBonusStatusSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Получить бонусный статус клиента');
+
+        $I->sendPost('/api2/client/bonus-status', [
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Проверяем структуру ответа
+    }
+
+    /**
+     * Тест получения бонусного статуса без телефона
+     */
+    public function testBonusStatusMissingPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при запросе статуса без телефона');
+
+        $I->sendPost('/api2/client/bonus-status', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'Недостаточно параметров']
+        ]);
+    }
+
+    // ========== actionUseBonuses ==========
+
+    /**
+     * Тест списания бонусов
+     */
+    public function testUseBonusesSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Списать бонусы клиента');
+
+        $I->sendPost('/api2/client/use-bonuses', [
+            'phone' => '79001234567',
+            'order_id' => 'TEST-ORDER-123',
+            'points_to_use' => 100,
+            'date' => time(),
+            'price' => 1000
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+    }
+
+    /**
+     * Тест списания бонусов без обязательных параметров
+     */
+    public function testUseBonusesMissingParams(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при списании без order_id');
+
+        $I->sendPost('/api2/client/use-bonuses', [
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'Недостаточно параметров']
+        ]);
+    }
+
+    // ========== actionAddBonus ==========
+
+    /**
+     * Тест начисления бонусов
+     */
+    public function testAddBonusSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Начислить бонусы клиенту');
+
+        $I->sendPost('/api2/client/add-bonus', [
+            'phone' => '79001234567',
+            'order_id' => 'TEST-ORDER-456',
+            'points_to_add' => 50,
+            'date' => time(),
+            'price' => 500
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+    }
+
+    /**
+     * Тест начисления бонусов без обязательных параметров
+     */
+    public function testAddBonusMissingParams(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при начислении без параметров');
+
+        $I->sendPost('/api2/client/add-bonus', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'Недостаточно параметров']
+        ]);
+    }
+
+    // ========== actionCheckDetails ==========
+
+    /**
+     * Тест получения истории чеков
+     */
+    public function testCheckDetailsSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Получить историю чеков клиента');
+
+        $I->sendPost('/api2/client/check-details', [
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseMatchesJsonType([
+            'response' => [
+                'checks' => 'array',
+                'pages' => 'array'
+            ]
+        ]);
+    }
+
+    /**
+     * Тест получения истории чеков без телефона
+     */
+    public function testCheckDetailsMissingPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при запросе чеков без телефона');
+
+        $I->sendPost('/api2/client/check-details', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'phone is required']
+        ]);
+    }
+
+    // ========== actionApplyPromoCode ==========
+
+    /**
+     * Тест применения промокода без телефона
+     */
+    public function testApplyPromoCodeMissingPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при применении промокода без телефона');
+
+        $I->sendPost('/api2/client/apply-promo-code', [
+            'code' => 'TESTCODE'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'phone is required'
+        ]);
+    }
+
+    /**
+     * Тест применения промокода без кода
+     */
+    public function testApplyPromoCodeMissingCode(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при применении промокода без кода');
+
+        $I->sendPost('/api2/client/apply-promo-code', [
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1.3,
+            'error' => 'code is required'
+        ]);
+    }
+
+    // ========== actionGetStores ==========
+
+    /**
+     * Тест получения списка магазинов
+     */
+    public function testGetStores(ApiTester $I): void
+    {
+        $I->wantTo('Получить список магазинов');
+
+        $I->sendGet('/api2/client/get-stores');
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseMatchesJsonType([
+            'response' => 'array'
+        ]);
+    }
+
+    // ========== actionMemorableDates ==========
+
+    /**
+     * Тест получения памятных дат
+     */
+    public function testMemorableDatesSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Получить памятные даты клиента');
+
+        $I->sendPost('/api2/client/memorable-dates', [
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseMatchesJsonType([
+            'response' => 'array'
+        ]);
+    }
+
+    /**
+     * Тест получения памятных дат без телефона
+     */
+    public function testMemorableDatesMissingPhone(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при запросе дат без телефона');
+
+        $I->sendPost('/api2/client/memorable-dates', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'phone is required']
+        ]);
+    }
+
+    // ========== actionSaveSurvey ==========
+
+    /**
+     * Тест сохранения отзыва без обязательных полей
+     */
+    public function testSaveSurveyMissingFields(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при сохранении отзыва без полей');
+
+        $I->sendPost('/api2/client/save-survey', [
+            'phone' => '79001234567'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Проверяем наличие ошибки
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400]
+        ]);
+    }
+
+    /**
+     * Тест сохранения отзыва с невалидным JSON
+     */
+    public function testSaveSurveyInvalidJson(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при невалидном JSON');
+
+        $I->sendPost('/api2/client/save-survey', 'not valid json{');
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'Json body invalid']
+        ]);
+    }
+}
diff --git a/erp24/tests/api/DeliveryCest.php b/erp24/tests/api/DeliveryCest.php
new file mode 100644 (file)
index 0000000..9e8fb1c
--- /dev/null
@@ -0,0 +1,131 @@
+<?php
+
+namespace tests\api;
+
+use ApiTester;
+use Codeception\Util\HttpCode;
+
+/**
+ * API2 DeliveryController тесты
+ *
+ * Тестирует endpoints доставки: авторизация, admin auth.
+ */
+class DeliveryCest
+{
+    private string $accessToken = 'test_access_token';
+
+    public function _before(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->haveHttpHeader('X-ACCESS-TOKEN', $this->accessToken);
+    }
+
+    // ========== actionAuth ==========
+
+    /**
+     * Тест простой авторизации delivery
+     */
+    public function testAuthSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Проверить delivery auth endpoint');
+
+        $I->sendGet('/api2/delivery/auth');
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        // Endpoint возвращает строку "ok"
+        $I->seeResponseEquals('"ok"');
+    }
+
+    // ========== actionAdminAuth ==========
+
+    /**
+     * Тест admin auth без hash
+     */
+    public function testAdminAuthMissingHash(ApiTester $I): void
+    {
+        $I->wantTo('Проверить admin auth без hash');
+
+        $I->sendPost('/api2/delivery/admin-auth', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Возвращает null если hash не найден
+        $I->seeResponseEquals('null');
+    }
+
+    /**
+     * Тест admin auth с пустым hash
+     */
+    public function testAdminAuthEmptyHash(ApiTester $I): void
+    {
+        $I->wantTo('Проверить admin auth с пустым hash');
+
+        $I->sendPost('/api2/delivery/admin-auth', [
+            'hash' => ''
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Возвращает null если admin не найден
+        $I->seeResponseEquals('null');
+    }
+
+    /**
+     * Тест admin auth с невалидным hash
+     */
+    public function testAdminAuthInvalidHash(ApiTester $I): void
+    {
+        $I->wantTo('Проверить admin auth с невалидным hash');
+
+        $I->sendPost('/api2/delivery/admin-auth', [
+            'hash' => 'invalid_hash_12345'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Возвращает null если admin не найден по hash
+        $I->seeResponseEquals('null');
+    }
+
+    /**
+     * Тест admin auth с корректным форматом hash
+     */
+    public function testAdminAuthWithValidFormatHash(ApiTester $I): void
+    {
+        $I->wantTo('Проверить admin auth с hash в корректном формате');
+
+        // MD5 hash формата id:password или login:password
+        $testHash = md5('1:testpassword');
+
+        $I->sendPost('/api2/delivery/admin-auth', [
+            'hash' => $testHash
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+
+        // Если admin найден - получаем данные, если нет - null
+        $response = json_decode($I->grabResponse(), true);
+
+        if ($response !== null) {
+            // Проверяем структуру ответа
+            $I->assertArrayHasKey('id', $response);
+            $I->assertArrayHasKey('group_id', $response);
+            $I->assertArrayHasKey('group_name', $response);
+            $I->assertArrayHasKey('name', $response);
+        }
+    }
+
+    /**
+     * Тест admin auth через GET (должен вернуть 405 Method Not Allowed)
+     */
+    public function testAdminAuthViaGet(ApiTester $I): void
+    {
+        $I->wantTo('Проверить что admin-auth не работает через GET');
+
+        $I->sendGet('/api2/delivery/admin-auth');
+
+        // VerbFilter должен вернуть 405
+        $I->seeResponseCodeIs(HttpCode::METHOD_NOT_ALLOWED);
+    }
+}
diff --git a/erp24/tests/api/OrdersCest.php b/erp24/tests/api/OrdersCest.php
new file mode 100644 (file)
index 0000000..85164e0
--- /dev/null
@@ -0,0 +1,297 @@
+<?php
+
+namespace tests\api;
+
+use ApiTester;
+use Codeception\Util\HttpCode;
+
+/**
+ * API2 OrdersController тесты
+ *
+ * Тестирует операции с заказами маркетплейсов: изменение статуса, получение заказов.
+ */
+class OrdersCest
+{
+    private string $accessToken = 'test_access_token';
+
+    public function _before(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->haveHttpHeader('X-ACCESS-TOKEN', $this->accessToken);
+    }
+
+    // ========== actionChangeStatus ==========
+
+    /**
+     * Тест изменения статуса заказа - невалидный JSON
+     */
+    public function testChangeStatusInvalidJson(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при невалидном JSON');
+
+        $I->sendPost('/api2/orders/change-status', 'invalid json{');
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'Json body invalid']
+        ]);
+    }
+
+    /**
+     * Тест изменения статуса без параметра order
+     */
+    public function testChangeStatusMissingOrder(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии параметра order');
+
+        $I->sendPost('/api2/orders/change-status', [
+            'status' => 'delivered'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 0.1,
+            'error' => "Параметр 'order' обязателен и должен быть массивом"
+        ]);
+    }
+
+    /**
+     * Тест изменения статуса с пустым массивом order
+     */
+    public function testChangeStatusEmptyOrder(ApiTester $I): void
+    {
+        $I->wantTo('Обработать пустой массив заказов');
+
+        $I->sendPost('/api2/orders/change-status', [
+            'order' => []
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Пустой массив - пустой результат
+        $I->seeResponseEquals('[]');
+    }
+
+    /**
+     * Тест изменения статуса без order_id
+     */
+    public function testChangeStatusMissingOrderId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии order_id');
+
+        $I->sendPost('/api2/orders/change-status', [
+            'order' => [
+                [
+                    'status' => 'delivered'
+                ]
+            ]
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+
+        $response = json_decode($I->grabResponse(), true);
+        $I->assertEquals('error', $response[0]['result']);
+        $I->assertEquals('order_id is required', $response[0]['message']);
+    }
+
+    /**
+     * Тест изменения статуса без status
+     */
+    public function testChangeStatusMissingStatus(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии status');
+
+        $I->sendPost('/api2/orders/change-status', [
+            'order' => [
+                [
+                    'order_id' => 'TEST-GUID-123'
+                ]
+            ]
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+
+        $response = json_decode($I->grabResponse(), true);
+        $I->assertEquals('error', $response[0]['result']);
+        $I->assertEquals('status is required', $response[0]['message']);
+    }
+
+    /**
+     * Тест изменения статуса несуществующего заказа
+     */
+    public function testChangeStatusOrderNotFound(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку для несуществующего заказа');
+
+        $I->sendPost('/api2/orders/change-status', [
+            'order' => [
+                [
+                    'order_id' => 'NONEXISTENT-GUID-999',
+                    'status' => '10'
+                ]
+            ]
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+
+        $response = json_decode($I->grabResponse(), true);
+        $I->assertEquals('NONEXISTENT-GUID-999', $response[0]['order_id']);
+        $I->assertEquals('error', $response[0]['result']);
+        $I->assertEquals('Заказ не найден', $response[0]['message']);
+    }
+
+    /**
+     * Тест изменения статуса нескольких заказов
+     */
+    public function testChangeStatusMultipleOrders(ApiTester $I): void
+    {
+        $I->wantTo('Обработать несколько заказов');
+
+        $I->sendPost('/api2/orders/change-status', [
+            'order' => [
+                [
+                    'order_id' => 'ORDER-1',
+                    'status' => '10'
+                ],
+                [
+                    'order_id' => 'ORDER-2',
+                    'status' => '20'
+                ],
+                [
+                    'order_id' => 'ORDER-3'
+                    // status отсутствует - должна быть ошибка
+                ]
+            ]
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+
+        $response = json_decode($I->grabResponse(), true);
+        $I->assertCount(3, $response);
+
+        // Третий заказ - ошибка из-за отсутствия status
+        $I->assertEquals('ORDER-3', $response[2]['order_id']);
+        $I->assertEquals('error', $response[2]['result']);
+    }
+
+    // ========== actionGetOrders ==========
+
+    /**
+     * Тест получения заказов - пустое тело
+     */
+    public function testGetOrdersEmptyBody(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при пустом теле запроса');
+
+        $I->sendPost('/api2/orders/get-orders');
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => false,
+            'error' => 'Пустое тело запроса'
+        ]);
+    }
+
+    /**
+     * Тест получения заказов - невалидный JSON
+     */
+    public function testGetOrdersInvalidJson(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при невалидном JSON');
+
+        $I->sendPost('/api2/orders/get-orders', 'not valid json');
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => false,
+            'error' => 'Некорректный JSON'
+        ]);
+    }
+
+    /**
+     * Тест получения заказов без store_id
+     */
+    public function testGetOrdersMissingStoreId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при отсутствии store_id');
+
+        $I->sendPost('/api2/orders/get-orders', [
+            'date_from' => '2024-01-01'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => false,
+            'error' => 'store_id не передан или пуст'
+        ]);
+    }
+
+    /**
+     * Тест получения заказов с корректным store_id
+     */
+    public function testGetOrdersSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Получить заказы по store_id');
+
+        $I->sendPost('/api2/orders/get-orders', [
+            'store_id' => 'STORE-GUID-123'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+
+        // Проверяем структуру ответа
+        $response = json_decode($I->grabResponse(), true);
+
+        // Если магазин существует - получаем success и result
+        // Если не существует - получаем ошибку
+        $I->assertTrue(
+            isset($response['success']) || isset($response['error']),
+            'Ответ должен содержать success или error'
+        );
+    }
+
+    // ========== Тесты авторизации ==========
+
+    /**
+     * Тест доступа без токена
+     */
+    public function testOrdersWithoutToken(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступ без токена');
+
+        $I->deleteHeader('X-ACCESS-TOKEN');
+        $I->sendPost('/api2/orders/change-status', [
+            'order' => []
+        ]);
+
+        // BaseController проверяет токен
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+    }
+
+    /**
+     * Тест доступа с невалидным токеном
+     */
+    public function testOrdersWithInvalidToken(ApiTester $I): void
+    {
+        $I->wantTo('Проверить доступ с невалидным токеном');
+
+        $I->haveHttpHeader('X-ACCESS-TOKEN', 'invalid_token_12345');
+        $I->sendPost('/api2/orders/change-status', [
+            'order' => []
+        ]);
+
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+    }
+}
diff --git a/erp24/tests/api/StoreCest.php b/erp24/tests/api/StoreCest.php
new file mode 100644 (file)
index 0000000..3a9ff77
--- /dev/null
@@ -0,0 +1,296 @@
+<?php
+
+namespace tests\api;
+
+use ApiTester;
+use Codeception\Util\HttpCode;
+
+/**
+ * API2 StoreController тесты
+ *
+ * Тестирует операции с магазинами: остатки, продажи, сборки.
+ */
+class StoreCest
+{
+    private string $accessToken = 'test_access_token';
+
+    public function _before(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->haveHttpHeader('X-ACCESS-TOKEN', $this->accessToken);
+    }
+
+    // ========== actionBalance ==========
+
+    /**
+     * Тест получения остатков без фильтра
+     */
+    public function testGetBalanceAll(ApiTester $I): void
+    {
+        $I->wantTo('Получить остатки всех магазинов');
+
+        $I->sendPost('/api2/store/balance', []);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+    }
+
+    /**
+     * Тест получения остатков конкретного магазина
+     */
+    public function testGetBalanceByStoreId(ApiTester $I): void
+    {
+        $I->wantTo('Получить остатки конкретного магазина');
+
+        $I->sendPost('/api2/store/balance', [
+            'store_id' => 1
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+    }
+
+    /**
+     * Тест остатков с невалидным JSON
+     */
+    public function testGetBalanceInvalidJson(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при невалидном JSON');
+
+        $I->sendPost('/api2/store/balance', 'not valid json');
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'Json body invalid']
+        ]);
+    }
+
+    // ========== actionSale ==========
+
+    /**
+     * Тест создания продажи без обязательных полей
+     */
+    public function testSaleMissingId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при создании продажи без id');
+
+        $I->sendPost('/api2/store/sale', [
+            'date' => '2024-01-15 10:00:00'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'id is required'
+        ]);
+    }
+
+    /**
+     * Тест создания продажи без date
+     */
+    public function testSaleMissingDate(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при создании продажи без date');
+
+        $I->sendPost('/api2/store/sale', [
+            'id' => 'SALE-123'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'date is required'
+        ]);
+    }
+
+    /**
+     * Тест создания продажи без operation
+     */
+    public function testSaleMissingOperation(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при создании продажи без operation');
+
+        $I->sendPost('/api2/store/sale', [
+            'id' => 'SALE-123',
+            'date' => '2024-01-15 10:00:00'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'operation is required'
+        ]);
+    }
+
+    /**
+     * Тест создания продажи без товаров
+     */
+    public function testSaleMissingProducts(ApiTester $I): void
+    {
+        $I->wantTo('Создать продажу без товаров');
+
+        $I->sendPost('/api2/store/sale', [
+            'id' => 'SALE-TEST-' . time(),
+            'date' => date('Y-m-d H:i:s'),
+            'operation' => 'sale',
+            'status' => 'completed',
+            'summ' => 1000,
+            'number' => 'CHECK-123',
+            'seller_id' => 'SELLER-123',
+            'store_id_1c' => 'STORE-123',
+            'payments' => [
+                ['type' => 'Наличные', 'amount' => 1000]
+            ],
+            'kkm_id' => 'KKM-001'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Продажа может быть создана без товаров
+    }
+
+    /**
+     * Тест создания продажи с товарами без product_id
+     */
+    public function testSaleProductMissingProductId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при добавлении товара без product_id');
+
+        $I->sendPost('/api2/store/sale', [
+            'id' => 'SALE-TEST-' . time(),
+            'date' => date('Y-m-d H:i:s'),
+            'operation' => 'sale',
+            'status' => 'completed',
+            'summ' => 1000,
+            'number' => 'CHECK-123',
+            'seller_id' => 'SELLER-123',
+            'store_id_1c' => 'STORE-123',
+            'payments' => [
+                ['type' => 'Наличные', 'amount' => 1000]
+            ],
+            'kkm_id' => 'KKM-001',
+            'products' => [
+                [
+                    'quantity' => 2,
+                    'price' => 500
+                ]
+            ]
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 2,
+            'error' => 'product_id is required'
+        ]);
+    }
+
+    // ========== actionAssemblies ==========
+
+    /**
+     * Тест создания сборки с невалидным JSON
+     */
+    public function testAssembliesInvalidJson(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при невалидном JSON');
+
+        $I->sendPost('/api2/store/assemblies', 'invalid json{');
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error' => ['code' => 400, 'message' => 'Json body invalid']
+        ]);
+    }
+
+    /**
+     * Тест создания сборки без id
+     */
+    public function testAssembliesMissingId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при создании сборки без id');
+
+        $I->sendPost('/api2/store/assemblies', [
+            'store_id' => 'STORE-123'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'id is required'
+        ]);
+    }
+
+    /**
+     * Тест создания сборки без store_id
+     */
+    public function testAssembliesMissingStoreId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при создании сборки без store_id');
+
+        $I->sendPost('/api2/store/assemblies', [
+            'id' => 'ASSEMBLY-123'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'store_id is required'
+        ]);
+    }
+
+    /**
+     * Тест создания сборки без seller_id
+     */
+    public function testAssembliesMissingSellerId(ApiTester $I): void
+    {
+        $I->wantTo('Получить ошибку при создании сборки без seller_id');
+
+        $I->sendPost('/api2/store/assemblies', [
+            'id' => 'ASSEMBLY-123',
+            'store_id' => 'STORE-123'
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'error_id' => 1,
+            'error' => 'seller_id is required'
+        ]);
+    }
+
+    /**
+     * Тест создания сборки со всеми обязательными полями
+     */
+    public function testAssembliesSuccess(ApiTester $I): void
+    {
+        $I->wantTo('Создать новую сборку');
+
+        $I->sendPost('/api2/store/assemblies', [
+            'id' => 'ASSEMBLY-TEST-' . time(),
+            'store_id' => 'STORE-123',
+            'seller_id' => 'SELLER-456',
+            'created_at' => date('Y-m-d H:i:s'),
+            'summ' => 5000,
+            'status_id' => 0,
+            'products_json' => [
+                [
+                    'product_id' => 'PROD-1',
+                    'color' => 'red',
+                    'quantity' => 5,
+                    'price' => 1000
+                ]
+            ]
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        // Успешный ответ или ошибка валидации
+    }
+}
diff --git a/erp24/tests/api/_bootstrap.php b/erp24/tests/api/_bootstrap.php
new file mode 100644 (file)
index 0000000..2095d3f
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+/**
+ * API Test Suite Bootstrap
+ */
+
+// Здесь можно инициализировать тестовое окружение для API тестов
diff --git a/erp24/tests/functional/services/AmoCrmServiceCest.php b/erp24/tests/functional/services/AmoCrmServiceCest.php
new file mode 100644 (file)
index 0000000..c909b87
--- /dev/null
@@ -0,0 +1,326 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\functional\services;
+
+use FunctionalTester;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+
+/**
+ * Интеграционные тесты AMO CRM API
+ *
+ * Проверяет работу с AMO CRM через mock HTTP.
+ * Тесты НЕ делают реальных сетевых запросов.
+ *
+ * @group services
+ * @group amocrm
+ * @group integration
+ */
+class AmoCrmServiceCest
+{
+    private string $fixturesPath;
+
+    public function _before(FunctionalTester $I): void
+    {
+        $this->fixturesPath = codecept_data_dir('external/amo/');
+    }
+
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        return json_decode(file_get_contents($path), true);
+    }
+
+    private function createMockClient(array $responses, array &$history = []): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(Middleware::history($history));
+        return new Client(['handler' => $handlerStack]);
+    }
+
+    /**
+     * Тест: успешное получение токена
+     */
+    public function testOAuthTokenSuccess(FunctionalTester $I): void
+    {
+        $I->wantTo('Получить OAuth токен AMO CRM');
+
+        $authResponse = $this->loadFixture('auth_success.json');
+        unset($authResponse['_comment']);
+
+        $history = [];
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($authResponse)),
+        ], $history);
+
+        $response = $client->post('https://test.amocrm.ru/oauth2/access_token', [
+            'json' => [
+                'client_id' => 'test_client_id',
+                'client_secret' => 'test_client_secret',
+                'grant_type' => 'refresh_token',
+                'refresh_token' => 'test_refresh_token',
+                'redirect_uri' => 'https://example.com/callback',
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $I->assertArrayHasKey('access_token', $body);
+        $I->assertArrayHasKey('refresh_token', $body);
+        $I->assertArrayHasKey('expires_in', $body);
+        $I->assertArrayHasKey('token_type', $body);
+        $I->assertEquals('Bearer', $body['token_type']);
+
+        // Проверяем request
+        $request = $history[0]['request'];
+        $I->assertEquals('POST', $request->getMethod());
+        $I->assertStringContainsString('/oauth2/access_token', $request->getUri()->getPath());
+    }
+
+    /**
+     * Тест: получение списка контактов
+     */
+    public function testContactsListSuccess(FunctionalTester $I): void
+    {
+        $I->wantTo('Получить список контактов AMO CRM');
+
+        $contactsResponse = $this->loadFixture('contacts_list.json');
+        unset($contactsResponse['_comment']);
+
+        $history = [];
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($contactsResponse)),
+        ], $history);
+
+        $response = $client->get('https://test.amocrm.ru/api/v4/contacts', [
+            'headers' => [
+                'Authorization' => 'Bearer test_access_token',
+            ],
+            'query' => [
+                'limit' => 50,
+                'page' => 1,
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $I->assertArrayHasKey('_embedded', $body);
+        $I->assertArrayHasKey('contacts', $body['_embedded']);
+
+        // Проверяем структуру контакта
+        if (!empty($body['_embedded']['contacts'])) {
+            $contact = $body['_embedded']['contacts'][0];
+            $I->assertArrayHasKey('id', $contact);
+            $I->assertArrayHasKey('name', $contact);
+            $I->assertArrayHasKey('custom_fields_values', $contact);
+        }
+
+        // Проверяем Bearer токен
+        $request = $history[0]['request'];
+        $I->assertStringStartsWith('Bearer ', $request->getHeaderLine('Authorization'));
+    }
+
+    /**
+     * Тест: ошибка авторизации 401
+     */
+    public function testUnauthorizedError(FunctionalTester $I): void
+    {
+        $I->wantTo('Обработать ошибку 401 AMO CRM');
+
+        $errorResponse = $this->loadFixture('auth_error_401.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        $response = $client->get('https://test.amocrm.ru/api/v4/contacts', [
+            'headers' => [
+                'Authorization' => 'Bearer expired_token',
+            ],
+            'http_errors' => false,
+        ]);
+
+        $I->assertEquals(401, $response->getStatusCode());
+
+        $body = json_decode($response->getBody()->getContents(), true);
+        $I->assertEquals('Unauthorized', $body['title'] ?? $body['status'] ?? '');
+    }
+
+    /**
+     * Тест: автоматический refresh токена при 401
+     */
+    public function testAutoRefreshTokenOn401(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить автоматический refresh токена при 401');
+
+        $authError = $this->loadFixture('auth_error_401.json');
+        unset($authError['_comment']);
+
+        $authSuccess = $this->loadFixture('auth_success.json');
+        unset($authSuccess['_comment']);
+
+        $contactsResponse = $this->loadFixture('contacts_list.json');
+        unset($contactsResponse['_comment']);
+
+        $history = [];
+        $client = $this->createMockClient([
+            // Первый запрос - 401
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($authError)),
+            // Refresh токена - успех
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($authSuccess)),
+            // Повторный запрос - успех
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($contactsResponse)),
+        ], $history);
+
+        // Эмуляция retry логики
+        $response = $client->get('https://test.amocrm.ru/api/v4/contacts', [
+            'headers' => ['Authorization' => 'Bearer old_token'],
+            'http_errors' => false,
+        ]);
+
+        if ($response->getStatusCode() === 401) {
+            // Refresh token
+            $refreshResponse = $client->post('https://test.amocrm.ru/oauth2/access_token', [
+                'json' => [
+                    'grant_type' => 'refresh_token',
+                    'refresh_token' => 'test_refresh_token',
+                ],
+            ]);
+
+            $tokens = json_decode($refreshResponse->getBody()->getContents(), true);
+            $newToken = $tokens['access_token'];
+
+            // Retry с новым токеном
+            $response = $client->get('https://test.amocrm.ru/api/v4/contacts', [
+                'headers' => ['Authorization' => 'Bearer ' . $newToken],
+            ]);
+        }
+
+        $I->assertEquals(200, $response->getStatusCode());
+        $I->assertCount(3, $history, 'Should have made 3 requests');
+    }
+
+    /**
+     * Тест: обработка rate limit 429
+     */
+    public function testRateLimitHandling(FunctionalTester $I): void
+    {
+        $I->wantTo('Обработать rate limit 429 AMO CRM');
+
+        $rateLimitResponse = $this->loadFixture('rate_limit_429.json');
+        unset($rateLimitResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(429, [
+                'Content-Type' => 'application/json',
+                'Retry-After' => '1',
+            ], json_encode($rateLimitResponse)),
+        ]);
+
+        $response = $client->get('https://test.amocrm.ru/api/v4/contacts', [
+            'headers' => ['Authorization' => 'Bearer test_token'],
+            'http_errors' => false,
+        ]);
+
+        $I->assertEquals(429, $response->getStatusCode());
+
+        // Проверяем Retry-After заголовок
+        $retryAfter = $response->getHeaderLine('Retry-After');
+        $I->assertNotEmpty($retryAfter);
+    }
+
+    /**
+     * Тест: парсинг custom fields контакта
+     */
+    public function testContactCustomFieldsParsing(FunctionalTester $I): void
+    {
+        $I->wantTo('Распарсить custom fields контакта AMO');
+
+        $contactsResponse = $this->loadFixture('contacts_list.json');
+        unset($contactsResponse['_comment']);
+
+        $contact = $contactsResponse['_embedded']['contacts'][0];
+
+        // Парсинг телефона
+        $phone = null;
+        $email = null;
+
+        if (isset($contact['custom_fields_values'])) {
+            foreach ($contact['custom_fields_values'] as $field) {
+                if ($field['field_code'] === 'PHONE' && !empty($field['values'])) {
+                    $phone = $field['values'][0]['value'];
+                }
+                if ($field['field_code'] === 'EMAIL' && !empty($field['values'])) {
+                    $email = $field['values'][0]['value'];
+                }
+            }
+        }
+
+        $I->assertNotNull($phone, 'Phone should be parsed from custom fields');
+        $I->assertNotNull($email, 'Email should be parsed from custom fields');
+    }
+
+    /**
+     * Тест: сохранение токенов в файл
+     */
+    public function testTokenStorageFormat(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить формат сохранения токенов');
+
+        $authResponse = $this->loadFixture('auth_success.json');
+        unset($authResponse['_comment']);
+
+        // Формат для сохранения в JSON файл (как в реальном коде)
+        $tokenData = [
+            'access_token' => $authResponse['access_token'],
+            'refresh_token' => $authResponse['refresh_token'],
+            'expires_in' => $authResponse['expires_in'],
+            'expires_at' => time() + $authResponse['expires_in'],
+            'token_type' => $authResponse['token_type'],
+        ];
+
+        $I->assertArrayHasKey('expires_at', $tokenData);
+        $I->assertGreaterThan(time(), $tokenData['expires_at']);
+
+        // Проверяем что можно сериализовать
+        $json = json_encode($tokenData);
+        $I->assertJson($json);
+
+        $decoded = json_decode($json, true);
+        $I->assertEquals($tokenData['access_token'], $decoded['access_token']);
+    }
+
+    /**
+     * Тест: проверка истечения токена
+     */
+    public function testTokenExpirationCheck(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить определение истечения токена');
+
+        $authResponse = $this->loadFixture('auth_success.json');
+        unset($authResponse['_comment']);
+
+        $expiresAt = time() + $authResponse['expires_in'];
+
+        // Токен не истёк
+        $isExpired = time() >= $expiresAt;
+        $I->assertFalse($isExpired);
+
+        // Симуляция истёкшего токена
+        $expiredAt = time() - 3600;
+        $isExpired = time() >= $expiredAt;
+        $I->assertTrue($isExpired);
+
+        // С буфером (refresh за 5 минут до истечения)
+        $expiresAtWithBuffer = time() + 300; // через 5 минут
+        $shouldRefresh = time() >= ($expiresAtWithBuffer - 300);
+        $I->assertTrue($shouldRefresh, 'Should refresh token 5 minutes before expiration');
+    }
+}
diff --git a/erp24/tests/functional/services/CloudPaymentsServiceCest.php b/erp24/tests/functional/services/CloudPaymentsServiceCest.php
new file mode 100644 (file)
index 0000000..f0c6ff7
--- /dev/null
@@ -0,0 +1,345 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\functional\services;
+
+use FunctionalTester;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+
+/**
+ * Интеграционные тесты CloudPayments API
+ *
+ * Проверяет работу с платёжной системой через mock HTTP.
+ * Тесты НЕ делают реальных сетевых запросов.
+ *
+ * @group services
+ * @group cloudpayments
+ * @group integration
+ */
+class CloudPaymentsServiceCest
+{
+    private const BASE_URL = 'https://api.cloudpayments.ru';
+
+    private string $fixturesPath;
+
+    public function _before(FunctionalTester $I): void
+    {
+        $this->fixturesPath = codecept_data_dir('external/cloudpayments/');
+    }
+
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        return json_decode(file_get_contents($path), true);
+    }
+
+    private function createMockClient(array $responses, array &$history = []): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(Middleware::history($history));
+        return new Client(['handler' => $handlerStack]);
+    }
+
+    /**
+     * Тест: успешное получение списка платежей
+     */
+    public function testPaymentsListSuccess(FunctionalTester $I): void
+    {
+        $I->wantTo('Получить список платежей CloudPayments');
+
+        $paymentsResponse = $this->loadFixture('payments_list_success.json');
+        unset($paymentsResponse['_comment']);
+
+        $history = [];
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($paymentsResponse)),
+        ], $history);
+
+        $response = $client->post(self::BASE_URL . '/payments/list', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => 'Basic ' . base64_encode('pk_test:secret'),
+            ],
+            'json' => [
+                'Date' => '2024-01-01',
+                'TimeZone' => 'MSK',
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $I->assertTrue($body['Success']);
+        $I->assertArrayHasKey('Model', $body);
+        $I->assertNotEmpty($body['Model']);
+
+        // Проверяем структуру платежа
+        $payment = $body['Model'][0];
+        $I->assertArrayHasKey('TransactionId', $payment);
+        $I->assertArrayHasKey('Amount', $payment);
+        $I->assertArrayHasKey('Currency', $payment);
+        $I->assertArrayHasKey('Status', $payment);
+
+        // Проверяем что Basic Auth был использован
+        $request = $history[0]['request'];
+        $I->assertStringStartsWith('Basic ', $request->getHeaderLine('Authorization'));
+    }
+
+    /**
+     * Тест: пустой список платежей
+     */
+    public function testPaymentsListEmpty(FunctionalTester $I): void
+    {
+        $I->wantTo('Получить пустой список платежей');
+
+        $emptyResponse = $this->loadFixture('payments_list_empty.json');
+        unset($emptyResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($emptyResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/payments/list', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => 'Basic ' . base64_encode('pk_test:secret'),
+            ],
+            'json' => [
+                'Date' => '2099-12-31',
+                'TimeZone' => 'MSK',
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $I->assertTrue($body['Success']);
+        $I->assertEmpty($body['Model']);
+    }
+
+    /**
+     * Тест: ошибка авторизации
+     */
+    public function testPaymentsListAuthError(FunctionalTester $I): void
+    {
+        $I->wantTo('Обработать ошибку авторизации CloudPayments');
+
+        $errorResponse = $this->loadFixture('error_auth_401.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/payments/list', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => 'Basic ' . base64_encode('invalid:key'),
+            ],
+            'json' => [
+                'Date' => '2024-01-01',
+                'TimeZone' => 'MSK',
+            ],
+            'http_errors' => false,
+        ]);
+
+        $I->assertEquals(401, $response->getStatusCode());
+
+        $body = json_decode($response->getBody()->getContents(), true);
+        $I->assertFalse($body['Success']);
+        $I->assertStringContainsString('Invalid', $body['Message']);
+    }
+
+    /**
+     * Тест: успешное списание по токену
+     */
+    public function testChargeByTokenSuccess(FunctionalTester $I): void
+    {
+        $I->wantTo('Выполнить рекуррентное списание по токену');
+
+        $chargeResponse = $this->loadFixture('charge_success.json');
+        unset($chargeResponse['_comment']);
+
+        $history = [];
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($chargeResponse)),
+        ], $history);
+
+        $response = $client->post(self::BASE_URL . '/payments/tokens/charge', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => 'Basic ' . base64_encode('pk_test:secret'),
+            ],
+            'json' => [
+                'Amount' => 2500.00,
+                'Currency' => 'RUB',
+                'AccountId' => 'client_67890',
+                'Token' => 'tk_test_xxxxxxxxxxxx',
+                'Description' => 'Повторная оплата',
+                'InvoiceId' => 'ORDER-2025-002',
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $I->assertTrue($body['Success']);
+        $I->assertEquals('Completed', $body['Model']['Status']);
+        $I->assertEquals(2500.00, $body['Model']['Amount']);
+
+        // Проверяем что токен был передан
+        $request = $history[0]['request'];
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $I->assertArrayHasKey('Token', $requestBody);
+    }
+
+    /**
+     * Тест: успешный возврат платежа
+     */
+    public function testRefundSuccess(FunctionalTester $I): void
+    {
+        $I->wantTo('Выполнить возврат платежа');
+
+        $refundResponse = $this->loadFixture('refund_success.json');
+        unset($refundResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($refundResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/payments/refund', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => 'Basic ' . base64_encode('pk_test:secret'),
+            ],
+            'json' => [
+                'TransactionId' => 123456789,
+                'Amount' => 1500.00,
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $I->assertTrue($body['Success']);
+        $I->assertTrue($body['Model']['Refunded']);
+        $I->assertEquals(-1500.00, $body['Model']['PayoutAmount']);
+    }
+
+    /**
+     * Тест: парсинг данных платежа для импорта
+     */
+    public function testPaymentDataParsing(FunctionalTester $I): void
+    {
+        $I->wantTo('Распарсить данные платежа для импорта в БД');
+
+        $paymentsResponse = $this->loadFixture('payments_list_success.json');
+        unset($paymentsResponse['_comment']);
+
+        $payment = $paymentsResponse['Model'][0];
+
+        // Эмуляция логики import_cloudpayments()
+        $param = [];
+
+        // Маппинг полей
+        if (isset($payment['CardHolderMessage'])) {
+            if ($payment['CardHolderMessage'] === 'Оплата успешно проведена') {
+                $param['status'] = 'Завершён';
+            } else {
+                $param['status'] = $payment['CardHolderMessage'];
+            }
+        }
+
+        if (isset($payment['TransactionId'])) {
+            $param['TransactionId'] = $payment['TransactionId'];
+            $param['guid'] = md5(serialize($payment));
+        }
+
+        if (isset($payment['Amount'])) {
+            $param['summ'] = $payment['Amount'];
+        }
+
+        if (isset($payment['Currency'])) {
+            $param['valuta'] = $payment['Currency'];
+        }
+
+        if (isset($payment['InvoiceId'])) {
+            $param['order_id'] = $payment['InvoiceId'];
+        }
+
+        if (isset($payment['AuthDateIso'])) {
+            $param['date'] = date('Y-m-d H:i:s', strtotime($payment['AuthDateIso']));
+        }
+
+        if (isset($payment['CardType'])) {
+            $param['pay_type'] = mb_strtoupper($payment['CardType'], 'UTF-8');
+        }
+
+        // Проверяем результат парсинга
+        $I->assertArrayHasKey('TransactionId', $param);
+        $I->assertArrayHasKey('summ', $param);
+        $I->assertArrayHasKey('valuta', $param);
+        $I->assertEquals('RUB', $param['valuta']);
+        $I->assertEquals(1500.00, $param['summ']);
+        $I->assertEquals('VISA', $param['pay_type']);
+    }
+
+    /**
+     * Тест: проверка формата даты CloudPayments
+     */
+    public function testCloudPaymentsDateParsing(FunctionalTester $I): void
+    {
+        $I->wantTo('Распарсить дату в формате CloudPayments');
+
+        $paymentsResponse = $this->loadFixture('payments_list_success.json');
+        unset($paymentsResponse['_comment']);
+
+        $payment = $paymentsResponse['Model'][0];
+
+        // ISO формат
+        $isoDate = $payment['CreatedDateIso'];
+        $parsedDate = new \DateTime($isoDate);
+
+        $I->assertEquals('2024', $parsedDate->format('Y'));
+        $I->assertEquals('01', $parsedDate->format('m'));
+        $I->assertEquals('01', $parsedDate->format('d'));
+
+        // Legacy /Date(timestamp)/ формат
+        if (isset($payment['CreatedDate'])) {
+            preg_match('/\/Date\((\d+)\)\//', $payment['CreatedDate'], $matches);
+            if (!empty($matches[1])) {
+                $timestamp = (int)($matches[1] / 1000); // CloudPayments использует миллисекунды
+                $legacyDate = new \DateTime("@$timestamp");
+                $I->assertInstanceOf(\DateTime::class, $legacyDate);
+            }
+        }
+    }
+
+    /**
+     * Тест: обработка статусов платежа
+     */
+    public function testPaymentStatusMapping(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить маппинг статусов платежа');
+
+        // CloudPayments StatusCode mapping
+        $statusMapping = [
+            0 => 'Created',
+            1 => 'Pending',
+            2 => 'Authorized',
+            3 => 'Completed',
+            4 => 'Cancelled',
+            5 => 'Declined',
+        ];
+
+        $paymentsResponse = $this->loadFixture('payments_list_success.json');
+        unset($paymentsResponse['_comment']);
+
+        $payment = $paymentsResponse['Model'][0];
+
+        $I->assertArrayHasKey($payment['StatusCode'], $statusMapping);
+        $I->assertEquals($statusMapping[$payment['StatusCode']], $payment['Status']);
+    }
+}
diff --git a/erp24/tests/functional/services/LPTrackerServiceCest.php b/erp24/tests/functional/services/LPTrackerServiceCest.php
new file mode 100644 (file)
index 0000000..223fe07
--- /dev/null
@@ -0,0 +1,230 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\functional\services;
+
+use Codeception\Stub;
+use FunctionalTester;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Psr7\Response;
+
+/**
+ * Интеграционные тесты LPTrackerApi
+ *
+ * Проверяет работу API клиента с mock HTTP.
+ * Тесты НЕ делают реальных сетевых запросов.
+ *
+ * @group services
+ * @group lptracker
+ * @group integration
+ */
+class LPTrackerServiceCest
+{
+    private string $fixturesPath;
+
+    public function _before(FunctionalTester $I): void
+    {
+        $this->fixturesPath = codecept_data_dir('external/lptracker/');
+    }
+
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        return json_decode(file_get_contents($path), true);
+    }
+
+    private function createMockClient(array $responses): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        return new Client([
+            'handler' => $handlerStack,
+            'base_uri' => 'https://direct.lptracker.ru',
+        ]);
+    }
+
+    /**
+     * Тест: структура ответа авторизации
+     */
+    public function testAuthResponseStructure(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить структуру ответа авторизации LPTracker');
+
+        $authResponse = $this->loadFixture('auth_success.json');
+        unset($authResponse['_comment']);
+
+        $I->assertArrayHasKey('status', $authResponse);
+        $I->assertEquals('success', $authResponse['status']);
+        $I->assertArrayHasKey('result', $authResponse);
+        $I->assertArrayHasKey('token', $authResponse['result']);
+    }
+
+    /**
+     * Тест: структура списка лидов
+     */
+    public function testLeadsListStructure(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить структуру списка лидов LPTracker');
+
+        $leadsResponse = $this->loadFixture('leads_list.json');
+        unset($leadsResponse['_comment']);
+
+        $I->assertArrayHasKey('status', $leadsResponse);
+        $I->assertArrayHasKey('data', $leadsResponse);
+        $I->assertArrayHasKey('pagination', $leadsResponse);
+
+        // Проверяем структуру лида
+        if (!empty($leadsResponse['data'])) {
+            $lead = $leadsResponse['data'][0];
+            $I->assertArrayHasKey('id', $lead);
+            $I->assertArrayHasKey('phone', $lead);
+            $I->assertArrayHasKey('name', $lead);
+            $I->assertArrayHasKey('status', $lead);
+        }
+    }
+
+    /**
+     * Тест: HTTP GET запрос с токеном
+     */
+    public function testGetRequestWithToken(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить GET запрос с токеном авторизации');
+
+        $leadsResponse = $this->loadFixture('leads_list.json');
+        unset($leadsResponse['_comment']);
+
+        $history = [];
+        $mock = new MockHandler([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($leadsResponse)),
+        ]);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(\GuzzleHttp\Middleware::history($history));
+
+        $client = new Client([
+            'handler' => $handlerStack,
+            'base_uri' => 'https://direct.lptracker.ru',
+        ]);
+
+        $testToken = 'test_jwt_token';
+        $response = $client->get('/leads', [
+            'headers' => [
+                'token' => $testToken,
+                'Content-Type' => 'application/json',
+            ],
+        ]);
+
+        // Проверяем что токен был передан
+        $request = $history[0]['request'];
+        $I->assertEquals($testToken, $request->getHeaderLine('token'));
+        $I->assertEquals('GET', $request->getMethod());
+    }
+
+    /**
+     * Тест: HTTP POST запрос для создания лида
+     */
+    public function testPostRequestCreateLead(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить POST запрос создания лида');
+
+        $createResponse = $this->loadFixture('lead_create_success.json');
+        unset($createResponse['_comment']);
+
+        $history = [];
+        $mock = new MockHandler([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($createResponse)),
+        ]);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(\GuzzleHttp\Middleware::history($history));
+
+        $client = new Client([
+            'handler' => $handlerStack,
+            'base_uri' => 'https://direct.lptracker.ru',
+        ]);
+
+        $leadData = [
+            'phone' => '+79009876543',
+            'name' => 'Новый клиент',
+            'email' => 'client@example.com',
+            'funnel_id' => 2086013,
+        ];
+
+        $response = $client->post('/leads', [
+            'headers' => [
+                'token' => 'test_token',
+                'Content-Type' => 'application/json',
+            ],
+            'json' => $leadData,
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $I->assertEquals('success', $body['status']);
+        $I->assertArrayHasKey('result', $body);
+        $I->assertArrayHasKey('id', $body['result']);
+
+        // Проверяем что данные были отправлены
+        $request = $history[0]['request'];
+        $I->assertEquals('POST', $request->getMethod());
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $I->assertEquals($leadData['phone'], $requestBody['phone']);
+    }
+
+    /**
+     * Тест: обработка ошибки авторизации
+     */
+    public function testAuthErrorHandling(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить обработку ошибки авторизации');
+
+        $errorResponse = $this->loadFixture('auth_error.json');
+        unset($errorResponse['_comment']);
+
+        $mock = new MockHandler([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+        $handlerStack = HandlerStack::create($mock);
+
+        $client = new Client([
+            'handler' => $handlerStack,
+            'base_uri' => 'https://direct.lptracker.ru',
+            'http_errors' => false, // Не бросать исключения
+        ]);
+
+        $response = $client->post('/login', [
+            'json' => [
+                'login' => 'wrong',
+                'password' => 'wrong',
+            ],
+        ]);
+
+        $I->assertEquals(401, $response->getStatusCode());
+
+        $body = json_decode($response->getBody()->getContents(), true);
+        $I->assertEquals('error', $body['status']);
+    }
+
+    /**
+     * Тест: пагинация в ответе
+     */
+    public function testPaginationInResponse(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить данные пагинации в ответе');
+
+        $leadsResponse = $this->loadFixture('leads_list.json');
+        unset($leadsResponse['_comment']);
+
+        $pagination = $leadsResponse['pagination'];
+
+        $I->assertArrayHasKey('total', $pagination);
+        $I->assertArrayHasKey('per_page', $pagination);
+        $I->assertArrayHasKey('current_page', $pagination);
+        $I->assertArrayHasKey('last_page', $pagination);
+
+        $I->assertIsInt($pagination['total']);
+        $I->assertIsInt($pagination['per_page']);
+        $I->assertIsInt($pagination['current_page']);
+    }
+}
diff --git a/erp24/tests/functional/services/WhatsAppServiceCest.php b/erp24/tests/functional/services/WhatsAppServiceCest.php
new file mode 100644 (file)
index 0000000..72bf1c4
--- /dev/null
@@ -0,0 +1,182 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\functional\services;
+
+use Codeception\Stub;
+use FunctionalTester;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Psr7\Response;
+use yii_app\services\WhatsAppService;
+use yii_app\services\WhatsAppMessageResponse;
+
+/**
+ * Интеграционные тесты WhatsAppService
+ *
+ * Проверяет работу сервиса с mock HTTP клиентом.
+ * Тесты НЕ делают реальных сетевых запросов.
+ *
+ * @group services
+ * @group whatsapp
+ * @group integration
+ */
+class WhatsAppServiceCest
+{
+    private string $fixturesPath;
+
+    public function _before(FunctionalTester $I): void
+    {
+        $this->fixturesPath = codecept_data_dir('external/whatsapp/');
+    }
+
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        return json_decode(file_get_contents($path), true);
+    }
+
+    private function createMockClient(array $responses): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        return new Client(['handler' => $handlerStack]);
+    }
+
+    /**
+     * Тест: успешная отправка сообщения
+     */
+    public function testSendMessageSuccess(FunctionalTester $I): void
+    {
+        $I->wantTo('Отправить WhatsApp сообщение успешно');
+
+        $successResponse = $this->loadFixture('send_message_success.json');
+        unset($successResponse['_comment']);
+
+        $mockClient = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($successResponse)),
+        ]);
+
+        // Создаём сервис с mock клиентом
+        $service = new WhatsAppService('test_api_key', 5686);
+
+        // Подменяем HTTP клиент через Reflection
+        $reflection = new \ReflectionClass($service);
+        $property = $reflection->getProperty('client');
+        $property->setAccessible(true);
+        $property->setValue($service, $mockClient);
+
+        $result = $service->sendMessage(
+            'req-uuid-12345',
+            '79001234567',
+            'Тестовое сообщение',
+            false
+        );
+
+        $I->assertInstanceOf(WhatsAppMessageResponse::class, $result);
+    }
+
+    /**
+     * Тест: обработка ошибки авторизации
+     */
+    public function testSendMessageAuthError(FunctionalTester $I): void
+    {
+        $I->wantTo('Обработать ошибку авторизации WhatsApp');
+
+        $errorResponse = $this->loadFixture('error_auth_401.json');
+        unset($errorResponse['_comment']);
+
+        $mockClient = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        $service = new WhatsAppService('invalid_api_key', 5686);
+
+        $reflection = new \ReflectionClass($service);
+        $property = $reflection->getProperty('client');
+        $property->setAccessible(true);
+        $property->setValue($service, $mockClient);
+
+        $result = $service->sendMessage(
+            'req-uuid-12345',
+            '79001234567',
+            'Test message',
+            false
+        );
+
+        // При ошибке возвращается null или код ошибки
+        $I->assertTrue($result === null || is_string($result));
+    }
+
+    /**
+     * Тест: обработка ошибки недостаточного баланса
+     */
+    public function testSendMessageOutOfBalance(FunctionalTester $I): void
+    {
+        $I->wantTo('Обработать ошибку недостаточного баланса');
+
+        $errorResponse = $this->loadFixture('error_out_of_balance.json');
+        unset($errorResponse['_comment']);
+
+        $mockClient = $this->createMockClient([
+            new Response(400, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        $service = new WhatsAppService('test_api_key', 5686);
+
+        $reflection = new \ReflectionClass($service);
+        $property = $reflection->getProperty('client');
+        $property->setAccessible(true);
+        $property->setValue($service, $mockClient);
+
+        $result = $service->sendMessage(
+            'req-uuid-12345',
+            '79001234567',
+            'Test message',
+            false
+        );
+
+        $I->assertEquals('out-of-balance', $result);
+    }
+
+    /**
+     * Тест: отправка без текста возвращает null
+     */
+    public function testSendMessageWithoutText(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить что отправка без текста возвращает null');
+
+        $service = new WhatsAppService('test_api_key', 5686);
+
+        $result = $service->sendMessage(
+            'req-uuid-12345',
+            '79001234567',
+            '', // пустой текст
+            false
+        );
+
+        $I->assertNull($result);
+    }
+
+    /**
+     * Тест: экранирование специальных символов
+     */
+    public function testTextEscaping(FunctionalTester $I): void
+    {
+        $I->wantTo('Проверить экранирование специальных символов');
+
+        $service = new WhatsAppService('test_api_key', 5686);
+
+        // Используем Reflection для доступа к protected методу
+        $reflection = new \ReflectionClass($service);
+        $method = $reflection->getMethod('escapeText');
+        $method->setAccessible(true);
+
+        $input = 'Текст с "кавычками" и 'апострофами'';
+        $escaped = $method->invoke($service, $input);
+
+        $I->assertStringContainsString('\"', $escaped);
+    }
+}
diff --git a/erp24/tests/unit/commands/CronControllerTest.php b/erp24/tests/unit/commands/CronControllerTest.php
new file mode 100644 (file)
index 0000000..40c5550
--- /dev/null
@@ -0,0 +1,292 @@
+<?php
+
+namespace app\tests\unit\commands;
+
+use Codeception\Test\Unit;
+use yii_app\commands\CronController;
+
+/**
+ * Unit-тесты для CronController
+ *
+ * Тестирует структуру и конфигурацию консольного контроллера планировщика.
+ * Реальное выполнение cron-задач не тестируется для изоляции от внешних зависимостей.
+ */
+class CronControllerTest extends Unit
+{
+    /**
+     * Тест что CronController существует
+     */
+    public function testControllerExists(): void
+    {
+        $this->assertTrue(
+            class_exists(CronController::class),
+            'Класс CronController должен существовать'
+        );
+    }
+
+    /**
+     * Тест что CronController наследует yii\console\Controller
+     */
+    public function testExtendsConsoleController(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->isSubclassOf(\yii\console\Controller::class),
+            'CronController должен наследовать yii\console\Controller'
+        );
+    }
+
+    /**
+     * Тест наличия свойства time
+     */
+    public function testHasTimeProperty(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->hasProperty('time'),
+            'CronController должен иметь свойство time'
+        );
+    }
+
+    /**
+     * Тест наличия свойства test
+     */
+    public function testHasTestProperty(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->hasProperty('test'),
+            'CronController должен иметь свойство test'
+        );
+    }
+
+    /**
+     * Тест наличия основных action методов
+     */
+    public function testHasMainActionMethods(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        // Проверяем основные cron методы
+        $expectedMethods = [
+            'actionOneC',
+            'actionMarketplaceOrderOneCStatuses',
+            'actionOneCCheckOneDay',
+            'actionCustomOneCCron',
+            'actionBalanceHistory',
+            'actionGenerateTargetKogorts',
+            'actionSendFirstTelegramMessage',
+            'actionSendTelegramPromoMessage',
+            'actionSendWhatsappMessage',
+            'actionUpdateBonusLevels',
+        ];
+
+        foreach ($expectedMethods as $method) {
+            $this->assertTrue(
+                $reflection->hasMethod($method),
+                "CronController должен иметь метод {$method}"
+            );
+        }
+    }
+
+    /**
+     * Тест что action методы публичные
+     */
+    public function testActionMethodsArePublic(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $publicMethods = [
+            'actionOneC',
+            'actionSendFirstTelegramMessage',
+            'actionSendWhatsappMessage',
+        ];
+
+        foreach ($publicMethods as $methodName) {
+            $method = $reflection->getMethod($methodName);
+            $this->assertTrue(
+                $method->isPublic(),
+                "Метод {$methodName} должен быть публичным"
+            );
+        }
+    }
+
+    /**
+     * Тест что метод actions возвращает массив
+     */
+    public function testActionsMethodReturnsArray(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->hasMethod('actions'),
+            'CronController должен иметь метод actions()'
+        );
+    }
+
+    /**
+     * Тест наличия методов для работы с когортами
+     */
+    public function testHasKogortMethods(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $kogortMethods = [
+            'actionGenerateTargetKogorts',
+            'actionGenerateWhatsappKogorts',
+            'actionGenerateCallKogorts',
+        ];
+
+        foreach ($kogortMethods as $method) {
+            $this->assertTrue(
+                $reflection->hasMethod($method),
+                "CronController должен иметь метод {$method}"
+            );
+        }
+    }
+
+    /**
+     * Тест наличия методов для отправки сообщений
+     */
+    public function testHasMessageSendingMethods(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $messageMethods = [
+            'actionSendFirstTelegramMessage',
+            'actionSendSecondTelegramMessage',
+            'actionSendTelegramPromoMessage',
+            'actionSendWhatsappMessage',
+        ];
+
+        foreach ($messageMethods as $method) {
+            $this->assertTrue(
+                $reflection->hasMethod($method),
+                "CronController должен иметь метод {$method}"
+            );
+        }
+    }
+
+    /**
+     * Тест наличия методов для 1C интеграции
+     */
+    public function testHasOneCIntegrationMethods(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $oneCMethods = [
+            'actionOneC',
+            'actionOneCCheckOneDay',
+            'actionOneCSellers',
+            'actionOneCPrice',
+            'actionOneCBalances',
+            'actionCustomOneCCron',
+        ];
+
+        foreach ($oneCMethods as $method) {
+            $this->assertTrue(
+                $reflection->hasMethod($method),
+                "CronController должен иметь метод {$method}"
+            );
+        }
+    }
+
+    /**
+     * Тест наличия методов синхронизации
+     */
+    public function testHasSyncMethods(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $syncMethods = [
+            'actionSyncTelegramUsers',
+            'actionUpdateUserSubscribe',
+            'actionUpdateBonusLevels',
+        ];
+
+        foreach ($syncMethods as $method) {
+            $this->assertTrue(
+                $reflection->hasMethod($method),
+                "CronController должен иметь метод {$method}"
+            );
+        }
+    }
+
+    /**
+     * Тест наличия методов автопланограммы
+     */
+    public function testHasAutoplannogrammaMethods(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->hasMethod('actionAutoplannogrammaCalculate'),
+            'CronController должен иметь метод actionAutoplannogrammaCalculate'
+        );
+
+        $this->assertTrue(
+            $reflection->hasMethod('actionAutoplannogrammaRecalculate'),
+            'CronController должен иметь метод actionAutoplannogrammaRecalculate'
+        );
+    }
+
+    /**
+     * Тест наличия свойства storeId
+     */
+    public function testHasStoreIdProperty(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->hasProperty('storeId'),
+            'CronController должен иметь свойство storeId'
+        );
+    }
+
+    /**
+     * Тест наличия свойства year и month
+     */
+    public function testHasDateProperties(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->hasProperty('year'),
+            'CronController должен иметь свойство year'
+        );
+
+        $this->assertTrue(
+            $reflection->hasProperty('month'),
+            'CronController должен иметь свойство month'
+        );
+    }
+
+    /**
+     * Тест что метод actionCheckWhatsappLimit существует
+     */
+    public function testHasCheckWhatsappLimitMethod(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->hasMethod('actionCheckWhatsappLimit'),
+            'CronController должен иметь метод actionCheckWhatsappLimit'
+        );
+    }
+
+    /**
+     * Тест метода actionGetWhatsappMessageHistory
+     */
+    public function testHasGetWhatsappMessageHistoryMethod(): void
+    {
+        $reflection = new \ReflectionClass(CronController::class);
+
+        $this->assertTrue(
+            $reflection->hasMethod('actionGetWhatsappMessageHistory'),
+            'CronController должен иметь метод actionGetWhatsappMessageHistory'
+        );
+    }
+}
diff --git a/erp24/tests/unit/integrations/amo/AmoCrmContractTest.php b/erp24/tests/unit/integrations/amo/AmoCrmContractTest.php
new file mode 100644 (file)
index 0000000..882782d
--- /dev/null
@@ -0,0 +1,408 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\integrations\amo;
+
+use Codeception\Test\Unit;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Exception\ClientException;
+use GuzzleHttp\Psr7\Request;
+
+/**
+ * Контрактные тесты AMO CRM API
+ *
+ * Проверяет соответствие запросов и ответов контракту AMO CRM API.
+ * Использует mock HTTP клиент — реальные сетевые запросы НЕ выполняются.
+ *
+ * Документация AMO CRM API: https://www.amocrm.ru/developers/content/crm_platform/api-reference
+ *
+ * @group integrations
+ * @group amo
+ * @group contract
+ */
+class AmoCrmContractTest extends Unit
+{
+    private string $fixturesPath;
+    private array $history = [];
+
+    protected function _before(): void
+    {
+        $this->fixturesPath = dirname(__DIR__, 3) . '/_data/external/amo/';
+        $this->history = [];
+    }
+
+    /**
+     * Создаёт mock Guzzle Client с заданными ответами
+     */
+    private function createMockClient(array $responses): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(Middleware::history($this->history));
+
+        return new Client(['handler' => $handlerStack]);
+    }
+
+    /**
+     * Загружает фикстуру из JSON файла
+     */
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        $this->assertFileExists($path, "Fixture file {$filename} must exist");
+        return json_decode(file_get_contents($path), true);
+    }
+
+    // =========================================================================
+    // OAuth2 Token Tests
+    // =========================================================================
+
+    /**
+     * Тест: успешное получение access token
+     *
+     * Request contract:
+     * - POST /oauth2/access_token
+     * - Content-Type: application/json
+     * - Body: client_id, client_secret, grant_type, code, redirect_uri
+     */
+    public function testOAuthTokenRequestContract(): void
+    {
+        $tokenResponse = $this->loadFixture('auth_success.json');
+        unset($tokenResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($tokenResponse)),
+        ]);
+
+        // Выполняем запрос токена
+        $response = $client->post('https://test.amocrm.ru/oauth2/access_token', [
+            'json' => [
+                'client_id' => 'test-client-id',
+                'client_secret' => 'test-client-secret',
+                'grant_type' => 'authorization_code',
+                'code' => 'test-auth-code',
+                'redirect_uri' => 'https://example.com/callback',
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertArrayHasKey('token_type', $body);
+        $this->assertArrayHasKey('expires_in', $body);
+        $this->assertArrayHasKey('access_token', $body);
+        $this->assertArrayHasKey('refresh_token', $body);
+
+        $this->assertEquals('Bearer', $body['token_type']);
+        $this->assertIsInt($body['expires_in']);
+        $this->assertIsString($body['access_token']);
+        $this->assertIsString($body['refresh_token']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertStringContainsString('/oauth2/access_token', $request->getUri()->getPath());
+        $this->assertEquals('application/json', $request->getHeaderLine('Content-Type'));
+    }
+
+    /**
+     * Тест: refresh token flow
+     *
+     * Request contract:
+     * - POST /oauth2/access_token
+     * - grant_type: refresh_token
+     * - refresh_token: current_refresh_token
+     */
+    public function testOAuthRefreshTokenRequestContract(): void
+    {
+        $tokenResponse = $this->loadFixture('auth_success.json');
+        unset($tokenResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($tokenResponse)),
+        ]);
+
+        $response = $client->post('https://test.amocrm.ru/oauth2/access_token', [
+            'json' => [
+                'client_id' => 'test-client-id',
+                'client_secret' => 'test-client-secret',
+                'grant_type' => 'refresh_token',
+                'refresh_token' => 'current_refresh_token',
+                'redirect_uri' => 'https://example.com/callback',
+            ],
+        ]);
+
+        $requestBody = json_decode($this->history[0]['request']->getBody()->getContents(), true);
+        $this->assertEquals('refresh_token', $requestBody['grant_type']);
+        $this->assertArrayHasKey('refresh_token', $requestBody);
+    }
+
+    /**
+     * Тест: обработка ошибки авторизации 401
+     */
+    public function testOAuthUnauthorizedResponse(): void
+    {
+        $errorResponse = $this->loadFixture('auth_error_401.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        $this->expectException(ClientException::class);
+
+        $client->post('https://test.amocrm.ru/oauth2/access_token', [
+            'json' => [
+                'client_id' => 'invalid-client-id',
+                'client_secret' => 'invalid-secret',
+                'grant_type' => 'authorization_code',
+                'code' => 'invalid-code',
+                'redirect_uri' => 'https://example.com/callback',
+            ],
+            'http_errors' => true,
+        ]);
+    }
+
+    // =========================================================================
+    // Contacts API Tests
+    // =========================================================================
+
+    /**
+     * Тест: получение списка контактов
+     *
+     * Request contract:
+     * - GET /api/v4/contacts
+     * - Authorization: Bearer {access_token}
+     */
+    public function testContactsListRequestContract(): void
+    {
+        $contactsResponse = $this->loadFixture('contacts_list.json');
+        unset($contactsResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($contactsResponse)),
+        ]);
+
+        $response = $client->get('https://test.amocrm.ru/api/v4/contacts', [
+            'headers' => [
+                'Authorization' => 'Bearer test_access_token',
+            ],
+            'query' => [
+                'page' => 1,
+                'limit' => 50,
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertArrayHasKey('_embedded', $body);
+        $this->assertArrayHasKey('contacts', $body['_embedded']);
+        $this->assertIsArray($body['_embedded']['contacts']);
+
+        // Проверяем структуру контакта
+        if (!empty($body['_embedded']['contacts'])) {
+            $contact = $body['_embedded']['contacts'][0];
+
+            $this->assertArrayHasKey('id', $contact);
+            $this->assertArrayHasKey('name', $contact);
+            $this->assertArrayHasKey('responsible_user_id', $contact);
+            $this->assertArrayHasKey('created_at', $contact);
+            $this->assertArrayHasKey('updated_at', $contact);
+
+            $this->assertIsInt($contact['id']);
+            $this->assertIsString($contact['name']);
+        }
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('GET', $request->getMethod());
+        $this->assertStringContainsString('/api/v4/contacts', $request->getUri()->getPath());
+        $this->assertStringStartsWith('Bearer ', $request->getHeaderLine('Authorization'));
+    }
+
+    /**
+     * Тест: создание контакта
+     *
+     * Request contract:
+     * - POST /api/v4/contacts
+     * - Content-Type: application/json
+     * - Body: array of contacts
+     */
+    public function testContactCreateRequestContract(): void
+    {
+        $createdContact = [
+            '_links' => ['self' => ['href' => '/api/v4/contacts/12345']],
+            '_embedded' => [
+                'contacts' => [
+                    [
+                        'id' => 12345,
+                        'name' => 'Новый контакт',
+                        'request_id' => '0',
+                    ],
+                ],
+            ],
+        ];
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($createdContact)),
+        ]);
+
+        $response = $client->post('https://test.amocrm.ru/api/v4/contacts', [
+            'headers' => [
+                'Authorization' => 'Bearer test_access_token',
+                'Content-Type' => 'application/json',
+            ],
+            'json' => [
+                [
+                    'name' => 'Новый контакт',
+                    'first_name' => 'Новый',
+                    'last_name' => 'Контакт',
+                    'custom_fields_values' => [
+                        [
+                            'field_code' => 'PHONE',
+                            'values' => [
+                                ['value' => '+79001234567', 'enum_code' => 'WORK'],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ]);
+
+        // Проверяем request
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertEquals('application/json', $request->getHeaderLine('Content-Type'));
+
+        // Проверяем response
+        $body = json_decode($response->getBody()->getContents(), true);
+        $this->assertArrayHasKey('_embedded', $body);
+        $this->assertArrayHasKey('contacts', $body['_embedded']);
+    }
+
+    // =========================================================================
+    // Error Handling Tests
+    // =========================================================================
+
+    /**
+     * Тест: обработка rate limit 429
+     */
+    public function testRateLimitHandling(): void
+    {
+        $client = $this->createMockClient([
+            new Response(429, [
+                'Content-Type' => 'application/json',
+                'Retry-After' => '60',
+            ], json_encode(['error' => 'Too Many Requests'])),
+        ]);
+
+        try {
+            $client->get('https://test.amocrm.ru/api/v4/contacts', [
+                'headers' => ['Authorization' => 'Bearer test_token'],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 429');
+        } catch (ClientException $e) {
+            $response = $e->getResponse();
+            $this->assertEquals(429, $response->getStatusCode());
+            $this->assertEquals('60', $response->getHeaderLine('Retry-After'));
+        }
+    }
+
+    /**
+     * Тест: обработка 500 ошибки сервера
+     */
+    public function testServerErrorHandling(): void
+    {
+        $client = $this->createMockClient([
+            new Response(500, ['Content-Type' => 'application/json'], json_encode([
+                'error' => 'Internal Server Error',
+            ])),
+        ]);
+
+        try {
+            $client->get('https://test.amocrm.ru/api/v4/contacts', [
+                'headers' => ['Authorization' => 'Bearer test_token'],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected exception for 500');
+        } catch (\GuzzleHttp\Exception\ServerException $e) {
+            $this->assertEquals(500, $e->getResponse()->getStatusCode());
+        }
+    }
+
+    // =========================================================================
+    // Token Refresh Retry Logic Tests
+    // =========================================================================
+
+    /**
+     * Тест: 401 → refresh token → retry pattern
+     *
+     * Проверяет, что при получении 401 система может выполнить refresh
+     * и повторить запрос с новым токеном.
+     */
+    public function testTokenRefreshRetryPattern(): void
+    {
+        $newTokenResponse = $this->loadFixture('auth_success.json');
+        unset($newTokenResponse['_comment']);
+
+        $contactsResponse = $this->loadFixture('contacts_list.json');
+        unset($contactsResponse['_comment']);
+
+        $client = $this->createMockClient([
+            // 1. Первый запрос — 401
+            new Response(401, ['Content-Type' => 'application/json'], json_encode([
+                'status' => 401,
+                'title' => 'Unauthorized',
+                'detail' => 'Token expired',
+            ])),
+            // 2. Refresh token
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($newTokenResponse)),
+            // 3. Повторный запрос с новым токеном
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($contactsResponse)),
+        ]);
+
+        // Симулируем логику retry
+        $accessToken = 'expired_token';
+
+        // 1. Первая попытка (401)
+        try {
+            $client->get('https://test.amocrm.ru/api/v4/contacts', [
+                'headers' => ['Authorization' => "Bearer {$accessToken}"],
+                'http_errors' => true,
+            ]);
+        } catch (ClientException $e) {
+            $this->assertEquals(401, $e->getResponse()->getStatusCode());
+
+            // 2. Refresh token
+            $refreshResponse = $client->post('https://test.amocrm.ru/oauth2/access_token', [
+                'json' => [
+                    'client_id' => 'test-client-id',
+                    'client_secret' => 'test-client-secret',
+                    'grant_type' => 'refresh_token',
+                    'refresh_token' => 'current_refresh_token',
+                    'redirect_uri' => 'https://example.com/callback',
+                ],
+            ]);
+
+            $newToken = json_decode($refreshResponse->getBody()->getContents(), true);
+            $accessToken = $newToken['access_token'];
+        }
+
+        // 3. Retry с новым токеном
+        $response = $client->get('https://test.amocrm.ru/api/v4/contacts', [
+            'headers' => ['Authorization' => "Bearer {$accessToken}"],
+        ]);
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        // Проверяем, что было 3 запроса
+        $this->assertCount(3, $this->history);
+    }
+}
diff --git a/erp24/tests/unit/integrations/cloudpayments/CloudPaymentsContractTest.php b/erp24/tests/unit/integrations/cloudpayments/CloudPaymentsContractTest.php
new file mode 100644 (file)
index 0000000..96d14d7
--- /dev/null
@@ -0,0 +1,446 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\integrations\cloudpayments;
+
+use Codeception\Test\Unit;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Exception\ClientException;
+
+/**
+ * Контрактные тесты CloudPayments API
+ *
+ * Проверяет соответствие запросов и ответов контракту CloudPayments API.
+ * Использует mock HTTP клиент — реальные сетевые запросы НЕ выполняются.
+ *
+ * Документация: https://developers.cloudpayments.ru/
+ *
+ * @group integrations
+ * @group cloudpayments
+ * @group contract
+ */
+class CloudPaymentsContractTest extends Unit
+{
+    private const BASE_URL = 'https://api.cloudpayments.ru';
+    private const TEST_PUBLIC_ID = 'pk_test_xxxxxxxxxxxxxx';
+    private const TEST_SECRET = 'test_secret_key';
+
+    private string $fixturesPath;
+    private array $history = [];
+
+    protected function _before(): void
+    {
+        $this->fixturesPath = dirname(__DIR__, 3) . '/_data/external/cloudpayments/';
+        $this->history = [];
+    }
+
+    private function createMockClient(array $responses): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(Middleware::history($this->history));
+
+        return new Client(['handler' => $handlerStack]);
+    }
+
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        $this->assertFileExists($path, "Fixture file {$filename} must exist");
+        return json_decode(file_get_contents($path), true);
+    }
+
+    private function getAuthHeader(): string
+    {
+        return 'Basic ' . base64_encode(self::TEST_PUBLIC_ID . ':' . self::TEST_SECRET);
+    }
+
+    // =========================================================================
+    // payments/list Tests
+    // =========================================================================
+
+    /**
+     * Тест: успешное получение списка платежей
+     *
+     * Request contract:
+     * - POST /payments/list
+     * - Authorization: Basic base64(public_id:secret)
+     * - Content-Type: application/json
+     * - Body: Date, TimeZone
+     */
+    public function testPaymentsListRequestContract(): void
+    {
+        $successResponse = $this->loadFixture('payments_list_success.json');
+        unset($successResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($successResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/payments/list', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => $this->getAuthHeader(),
+            ],
+            'json' => [
+                'Date' => '2024-01-01',
+                'TimeZone' => 'MSK',
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertArrayHasKey('Success', $body);
+        $this->assertTrue($body['Success']);
+        $this->assertArrayHasKey('Model', $body);
+        $this->assertIsArray($body['Model']);
+
+        // Проверяем структуру платежа
+        $payment = $body['Model'][0];
+        $this->assertArrayHasKey('TransactionId', $payment);
+        $this->assertArrayHasKey('Amount', $payment);
+        $this->assertArrayHasKey('Currency', $payment);
+        $this->assertArrayHasKey('Status', $payment);
+        $this->assertArrayHasKey('CardType', $payment);
+
+        $this->assertIsInt($payment['TransactionId']);
+        $this->assertIsFloat($payment['Amount']);
+        $this->assertIsString($payment['Currency']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertEquals('/payments/list', $request->getUri()->getPath());
+        $this->assertStringStartsWith('Basic ', $request->getHeaderLine('Authorization'));
+
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $this->assertArrayHasKey('Date', $requestBody);
+        $this->assertArrayHasKey('TimeZone', $requestBody);
+    }
+
+    /**
+     * Тест: пустой список платежей
+     */
+    public function testPaymentsListEmpty(): void
+    {
+        $emptyResponse = $this->loadFixture('payments_list_empty.json');
+        unset($emptyResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($emptyResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/payments/list', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => $this->getAuthHeader(),
+            ],
+            'json' => [
+                'Date' => '2024-12-31',
+                'TimeZone' => 'MSK',
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertTrue($body['Success']);
+        $this->assertIsArray($body['Model']);
+        $this->assertEmpty($body['Model']);
+    }
+
+    /**
+     * Тест: ошибка авторизации (неверный API ключ)
+     */
+    public function testPaymentsListUnauthorized(): void
+    {
+        $errorResponse = $this->loadFixture('error_auth_401.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->post(self::BASE_URL . '/payments/list', [
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    'Authorization' => 'Basic ' . base64_encode('invalid:key'),
+                ],
+                'json' => [
+                    'Date' => '2024-01-01',
+                    'TimeZone' => 'MSK',
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 401');
+        } catch (ClientException $e) {
+            $response = $e->getResponse();
+            $body = json_decode($response->getBody()->getContents(), true);
+
+            $this->assertEquals(401, $response->getStatusCode());
+            $this->assertFalse($body['Success']);
+            $this->assertArrayHasKey('Message', $body);
+            $this->assertStringContainsString('Invalid', $body['Message']);
+        }
+    }
+
+    /**
+     * Тест: ошибка валидации параметров
+     */
+    public function testPaymentsListValidationError(): void
+    {
+        $errorResponse = $this->loadFixture('error_validation.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(400, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->post(self::BASE_URL . '/payments/list', [
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    'Authorization' => $this->getAuthHeader(),
+                ],
+                'json' => [
+                    // Date не указан — должна быть ошибка валидации
+                    'TimeZone' => 'MSK',
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 400');
+        } catch (ClientException $e) {
+            $response = $e->getResponse();
+            $body = json_decode($response->getBody()->getContents(), true);
+
+            $this->assertEquals(400, $response->getStatusCode());
+            $this->assertFalse($body['Success']);
+            $this->assertArrayHasKey('Message', $body);
+        }
+    }
+
+    // =========================================================================
+    // payments/charge (Рекуррентный платёж по токену)
+    // =========================================================================
+
+    /**
+     * Тест: успешное списание по сохранённому токену карты
+     *
+     * Request contract:
+     * - POST /payments/tokens/charge
+     * - Body: Amount, Currency, AccountId, Token, Description, InvoiceId
+     */
+    public function testChargeByTokenRequestContract(): void
+    {
+        $chargeResponse = $this->loadFixture('charge_success.json');
+        unset($chargeResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($chargeResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/payments/tokens/charge', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => $this->getAuthHeader(),
+            ],
+            'json' => [
+                'Amount' => 2500.00,
+                'Currency' => 'RUB',
+                'AccountId' => 'client_67890',
+                'Token' => 'tk_test_xxxxxxxxxxxx',
+                'Description' => 'Повторная оплата по подписке',
+                'InvoiceId' => 'ORDER-2025-002',
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertTrue($body['Success']);
+        $this->assertArrayHasKey('Model', $body);
+
+        $model = $body['Model'];
+        $this->assertArrayHasKey('TransactionId', $model);
+        $this->assertArrayHasKey('Status', $model);
+        $this->assertEquals('Completed', $model['Status']);
+        $this->assertEquals(2500.00, $model['Amount']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertEquals('/payments/tokens/charge', $request->getUri()->getPath());
+
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $this->assertArrayHasKey('Amount', $requestBody);
+        $this->assertArrayHasKey('Token', $requestBody);
+        $this->assertArrayHasKey('AccountId', $requestBody);
+    }
+
+    // =========================================================================
+    // payments/refund (Возврат платежа)
+    // =========================================================================
+
+    /**
+     * Тест: успешный возврат платежа
+     *
+     * Request contract:
+     * - POST /payments/refund
+     * - Body: TransactionId, Amount
+     */
+    public function testRefundRequestContract(): void
+    {
+        $refundResponse = $this->loadFixture('refund_success.json');
+        unset($refundResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($refundResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/payments/refund', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'Authorization' => $this->getAuthHeader(),
+            ],
+            'json' => [
+                'TransactionId' => 123456789,
+                'Amount' => 1500.00,
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertTrue($body['Success']);
+        $this->assertArrayHasKey('Model', $body);
+
+        $model = $body['Model'];
+        $this->assertTrue($model['Refunded']);
+        $this->assertEquals(-1500.00, $model['PayoutAmount']);
+
+        // Проверяем request contract
+        $requestBody = json_decode($this->history[0]['request']->getBody()->getContents(), true);
+        $this->assertArrayHasKey('TransactionId', $requestBody);
+        $this->assertArrayHasKey('Amount', $requestBody);
+    }
+
+    // =========================================================================
+    // Payment Data Structure Tests
+    // =========================================================================
+
+    /**
+     * Тест: структура платежа содержит все обязательные поля
+     */
+    public function testPaymentStructureContract(): void
+    {
+        $successResponse = $this->loadFixture('payments_list_success.json');
+        unset($successResponse['_comment']);
+
+        $payment = $successResponse['Model'][0];
+
+        // Обязательные поля платежа
+        $requiredFields = [
+            'TransactionId',
+            'Amount',
+            'Currency',
+            'Status',
+            'StatusCode',
+            'CreatedDateIso',
+        ];
+
+        foreach ($requiredFields as $field) {
+            $this->assertArrayHasKey($field, $payment, "Payment must have '{$field}' field");
+        }
+
+        // Проверяем типы
+        $this->assertIsInt($payment['TransactionId']);
+        $this->assertIsNumeric($payment['Amount']);
+        $this->assertIsString($payment['Currency']);
+        $this->assertIsString($payment['Status']);
+        $this->assertIsInt($payment['StatusCode']);
+    }
+
+    /**
+     * Тест: проверка статусов платежа
+     */
+    public function testPaymentStatusCodes(): void
+    {
+        // CloudPayments StatusCode values:
+        // 0 - Created, 1 - Pending, 2 - Authorized, 3 - Completed, 4 - Cancelled, 5 - Declined
+
+        $validStatuses = [
+            0 => 'Created',
+            1 => 'Pending',
+            2 => 'Authorized',
+            3 => 'Completed',
+            4 => 'Cancelled',
+            5 => 'Declined',
+        ];
+
+        foreach ($validStatuses as $code => $status) {
+            $this->assertContains($code, array_keys($validStatuses));
+        }
+
+        // Проверяем что наша фикстура имеет валидный статус
+        $successResponse = $this->loadFixture('payments_list_success.json');
+        unset($successResponse['_comment']);
+        $payment = $successResponse['Model'][0];
+
+        $this->assertArrayHasKey($payment['StatusCode'], $validStatuses);
+    }
+
+    /**
+     * Тест: формат даты CloudPayments
+     */
+    public function testCloudPaymentsDateFormat(): void
+    {
+        $successResponse = $this->loadFixture('payments_list_success.json');
+        unset($successResponse['_comment']);
+        $payment = $successResponse['Model'][0];
+
+        // CloudPayments использует два формата дат:
+        // 1. /Date(timestamp)/ - legacy формат
+        // 2. ISO 8601 - современный формат (*Iso поля)
+
+        // Проверяем legacy формат
+        if (isset($payment['CreatedDate'])) {
+            $this->assertMatchesRegularExpression(
+                '/^\/Date\(\d+\)\/$/',
+                $payment['CreatedDate'],
+                'CreatedDate must be in /Date(timestamp)/ format'
+            );
+        }
+
+        // Проверяем ISO формат
+        $this->assertArrayHasKey('CreatedDateIso', $payment);
+        $this->assertMatchesRegularExpression(
+            '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/',
+            $payment['CreatedDateIso'],
+            'CreatedDateIso must be in ISO 8601 format'
+        );
+    }
+
+    /**
+     * Тест: данные карты маскированы
+     */
+    public function testCardDataMasked(): void
+    {
+        $successResponse = $this->loadFixture('payments_list_success.json');
+        unset($successResponse['_comment']);
+        $payment = $successResponse['Model'][0];
+
+        // CardFirstSix - первые 6 цифр
+        $this->assertArrayHasKey('CardFirstSix', $payment);
+        $this->assertEquals(6, strlen($payment['CardFirstSix']));
+        $this->assertMatchesRegularExpression('/^\d{6}$/', $payment['CardFirstSix']);
+
+        // CardLastFour - последние 4 цифры
+        $this->assertArrayHasKey('CardLastFour', $payment);
+        $this->assertEquals(4, strlen($payment['CardLastFour']));
+        $this->assertMatchesRegularExpression('/^\d{4}$/', $payment['CardLastFour']);
+    }
+}
diff --git a/erp24/tests/unit/integrations/lptracker/LPTrackerContractTest.php b/erp24/tests/unit/integrations/lptracker/LPTrackerContractTest.php
new file mode 100644 (file)
index 0000000..cf3d97c
--- /dev/null
@@ -0,0 +1,469 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\integrations\lptracker;
+
+use Codeception\Test\Unit;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Exception\ClientException;
+
+/**
+ * Контрактные тесты LPTracker API
+ *
+ * Проверяет соответствие запросов и ответов контракту LPTracker API.
+ * Использует mock HTTP клиент — реальные сетевые запросы НЕ выполняются.
+ *
+ * Документация: https://lptracker.docs.apiary.io/
+ *
+ * @group integrations
+ * @group lptracker
+ * @group contract
+ */
+class LPTrackerContractTest extends Unit
+{
+    private const BASE_URL = 'https://direct.lptracker.ru';
+    private const TEST_LOGIN = 'test_login';
+    private const TEST_PASSWORD = 'test_password';
+    private const TEST_SERVICE = 117605;
+    private const TEST_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test_token_payload.signature';
+
+    private string $fixturesPath;
+    private array $history = [];
+
+    protected function _before(): void
+    {
+        $this->fixturesPath = dirname(__DIR__, 3) . '/_data/external/lptracker/';
+        $this->history = [];
+    }
+
+    private function createMockClient(array $responses): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(Middleware::history($this->history));
+
+        return new Client([
+            'handler' => $handlerStack,
+            'base_uri' => self::BASE_URL,
+        ]);
+    }
+
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        $this->assertFileExists($path, "Fixture file {$filename} must exist");
+        return json_decode(file_get_contents($path), true);
+    }
+
+    // =========================================================================
+    // Authentication Tests
+    // =========================================================================
+
+    /**
+     * Тест: успешная авторизация
+     *
+     * Request contract:
+     * - POST /login
+     * - Content-Type: application/json
+     * - Body: login, password, service, version
+     */
+    public function testLoginRequestContract(): void
+    {
+        $authResponse = $this->loadFixture('auth_success.json');
+        unset($authResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($authResponse)),
+        ]);
+
+        $response = $client->post('/login', [
+            'json' => [
+                'login' => self::TEST_LOGIN,
+                'password' => self::TEST_PASSWORD,
+                'service' => self::TEST_SERVICE,
+                'version' => '1.0',
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertArrayHasKey('status', $body);
+        $this->assertEquals('success', $body['status']);
+        $this->assertArrayHasKey('result', $body);
+        $this->assertArrayHasKey('token', $body['result']);
+        $this->assertNotEmpty($body['result']['token']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertEquals('/login', $request->getUri()->getPath());
+
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $this->assertArrayHasKey('login', $requestBody);
+        $this->assertArrayHasKey('password', $requestBody);
+        $this->assertArrayHasKey('service', $requestBody);
+        $this->assertArrayHasKey('version', $requestBody);
+    }
+
+    /**
+     * Тест: ошибка авторизации (неверные credentials)
+     */
+    public function testLoginInvalidCredentials(): void
+    {
+        $errorResponse = $this->loadFixture('auth_error.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->post('/login', [
+                'json' => [
+                    'login' => 'wrong_login',
+                    'password' => 'wrong_password',
+                    'service' => self::TEST_SERVICE,
+                    'version' => '1.0',
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 401');
+        } catch (ClientException $e) {
+            $response = $e->getResponse();
+            $body = json_decode($response->getBody()->getContents(), true);
+
+            $this->assertEquals(401, $response->getStatusCode());
+            $this->assertEquals('error', $body['status']);
+            $this->assertArrayHasKey('error', $body);
+        }
+    }
+
+    // =========================================================================
+    // Leads List Tests
+    // =========================================================================
+
+    /**
+     * Тест: получение списка лидов
+     *
+     * Request contract:
+     * - GET /leads или POST /leads/list
+     * - Headers: token
+     * - Content-Type: application/json
+     */
+    public function testLeadsListRequestContract(): void
+    {
+        $leadsResponse = $this->loadFixture('leads_list.json');
+        unset($leadsResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($leadsResponse)),
+        ]);
+
+        $response = $client->get('/leads', [
+            'headers' => [
+                'token' => self::TEST_TOKEN,
+                'Content-Type' => 'application/json',
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertArrayHasKey('status', $body);
+        $this->assertEquals('success', $body['status']);
+        $this->assertArrayHasKey('data', $body);
+        $this->assertIsArray($body['data']);
+
+        // Проверяем структуру лида
+        if (!empty($body['data'])) {
+            $lead = $body['data'][0];
+            $this->assertArrayHasKey('id', $lead);
+            $this->assertArrayHasKey('phone', $lead);
+            $this->assertArrayHasKey('name', $lead);
+            $this->assertArrayHasKey('status', $lead);
+            $this->assertArrayHasKey('created_at', $lead);
+        }
+
+        // Проверяем пагинацию
+        $this->assertArrayHasKey('pagination', $body);
+        $this->assertArrayHasKey('total', $body['pagination']);
+        $this->assertArrayHasKey('per_page', $body['pagination']);
+        $this->assertArrayHasKey('current_page', $body['pagination']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('GET', $request->getMethod());
+        $this->assertEquals('/leads', $request->getUri()->getPath());
+        $this->assertEquals(self::TEST_TOKEN, $request->getHeaderLine('token'));
+    }
+
+    // =========================================================================
+    // Lead Create Tests
+    // =========================================================================
+
+    /**
+     * Тест: создание нового лида
+     *
+     * Request contract:
+     * - POST /leads
+     * - Headers: token
+     * - Body: phone, name, email, funnel_id, etc.
+     */
+    public function testLeadCreateRequestContract(): void
+    {
+        $createResponse = $this->loadFixture('lead_create_success.json');
+        unset($createResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($createResponse)),
+        ]);
+
+        $response = $client->post('/leads', [
+            'headers' => [
+                'token' => self::TEST_TOKEN,
+                'Content-Type' => 'application/json',
+            ],
+            'json' => [
+                'phone' => '+79009876543',
+                'name' => 'Новый клиент',
+                'email' => 'new_client@example.com',
+                'funnel_id' => 2086013, // NEW_LEAD
+                'project_id' => self::TEST_SERVICE,
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertEquals('success', $body['status']);
+        $this->assertArrayHasKey('result', $body);
+
+        $lead = $body['result'];
+        $this->assertArrayHasKey('id', $lead);
+        $this->assertIsInt($lead['id']);
+        $this->assertArrayHasKey('phone', $lead);
+        $this->assertArrayHasKey('status', $lead);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertEquals('/leads', $request->getUri()->getPath());
+
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $this->assertArrayHasKey('phone', $requestBody);
+        $this->assertArrayHasKey('name', $requestBody);
+        $this->assertArrayHasKey('funnel_id', $requestBody);
+    }
+
+    // =========================================================================
+    // Lead Update Tests
+    // =========================================================================
+
+    /**
+     * Тест: обновление лида (смена статуса)
+     *
+     * Request contract:
+     * - PUT /leads/{id} или POST /leads/{id}/update
+     * - Headers: token
+     * - Body: status, funnel_id, etc.
+     */
+    public function testLeadUpdateRequestContract(): void
+    {
+        $updateResponse = $this->loadFixture('lead_update_success.json');
+        unset($updateResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($updateResponse)),
+        ]);
+
+        $leadId = 12345;
+        $response = $client->post("/leads/{$leadId}/update", [
+            'headers' => [
+                'token' => self::TEST_TOKEN,
+                'Content-Type' => 'application/json',
+            ],
+            'json' => [
+                'status' => 'TO_CALL',
+                'funnel_id' => 2140957, // TO_CALL funnel
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertEquals('success', $body['status']);
+        $this->assertArrayHasKey('result', $body);
+        $this->assertEquals($leadId, $body['result']['id']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertStringContainsString('/leads/', $request->getUri()->getPath());
+        $this->assertStringContainsString('/update', $request->getUri()->getPath());
+    }
+
+    // =========================================================================
+    // Response Structure Tests
+    // =========================================================================
+
+    /**
+     * Тест: стандартная структура успешного ответа
+     */
+    public function testSuccessResponseStructure(): void
+    {
+        $leadsResponse = $this->loadFixture('leads_list.json');
+        unset($leadsResponse['_comment']);
+
+        // Все успешные ответы должны содержать status: success
+        $this->assertEquals('success', $leadsResponse['status']);
+    }
+
+    /**
+     * Тест: стандартная структура ошибки
+     */
+    public function testErrorResponseStructure(): void
+    {
+        $errorResponse = $this->loadFixture('auth_error.json');
+        unset($errorResponse['_comment']);
+
+        // Все ошибки должны содержать status: error
+        $this->assertEquals('error', $errorResponse['status']);
+        $this->assertArrayHasKey('error', $errorResponse);
+        $this->assertArrayHasKey('code', $errorResponse['error']);
+        $this->assertArrayHasKey('message', $errorResponse['error']);
+    }
+
+    /**
+     * Тест: формат телефона в лиде
+     */
+    public function testLeadPhoneFormat(): void
+    {
+        $leadsResponse = $this->loadFixture('leads_list.json');
+        unset($leadsResponse['_comment']);
+
+        if (!empty($leadsResponse['data'])) {
+            $phone = $leadsResponse['data'][0]['phone'];
+
+            // Телефон должен быть в международном формате
+            $this->assertMatchesRegularExpression(
+                '/^\+7\d{10}$/',
+                $phone,
+                'Phone must be in +7XXXXXXXXXX format'
+            );
+        }
+    }
+
+    /**
+     * Тест: формат даты в лиде
+     */
+    public function testLeadDateFormat(): void
+    {
+        $leadsResponse = $this->loadFixture('leads_list.json');
+        unset($leadsResponse['_comment']);
+
+        if (!empty($leadsResponse['data'])) {
+            $createdAt = $leadsResponse['data'][0]['created_at'];
+
+            // Дата должна быть в формате Y-m-d H:i:s
+            $this->assertMatchesRegularExpression(
+                '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
+                $createdAt,
+                'created_at must be in Y-m-d H:i:s format'
+            );
+        }
+    }
+
+    /**
+     * Тест: UTM метки в лиде
+     */
+    public function testLeadUtmFields(): void
+    {
+        $leadsResponse = $this->loadFixture('leads_list.json');
+        unset($leadsResponse['_comment']);
+
+        if (!empty($leadsResponse['data'])) {
+            $lead = $leadsResponse['data'][0];
+
+            // Проверяем наличие UTM полей
+            $this->assertArrayHasKey('utm_source', $lead);
+            $this->assertArrayHasKey('utm_medium', $lead);
+            $this->assertArrayHasKey('utm_campaign', $lead);
+        }
+    }
+
+    // =========================================================================
+    // Token Header Tests
+    // =========================================================================
+
+    /**
+     * Тест: запрос без токена должен вернуть ошибку
+     */
+    public function testRequestWithoutToken(): void
+    {
+        $errorResponse = [
+            'status' => 'error',
+            'error' => [
+                'code' => 401,
+                'message' => 'Token is required',
+            ],
+        ];
+
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->get('/leads', [
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    // token не передан
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 401');
+        } catch (ClientException $e) {
+            $body = json_decode($e->getResponse()->getBody()->getContents(), true);
+            $this->assertEquals('error', $body['status']);
+            $this->assertEquals(401, $body['error']['code']);
+        }
+    }
+
+    /**
+     * Тест: запрос с истёкшим токеном
+     */
+    public function testRequestWithExpiredToken(): void
+    {
+        $errorResponse = [
+            'status' => 'error',
+            'error' => [
+                'code' => 401,
+                'message' => 'Token expired',
+            ],
+        ];
+
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->get('/leads', [
+                'headers' => [
+                    'token' => 'expired_token',
+                    'Content-Type' => 'application/json',
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 401');
+        } catch (ClientException $e) {
+            $body = json_decode($e->getResponse()->getBody()->getContents(), true);
+            $this->assertEquals('error', $body['status']);
+            $this->assertStringContainsString('expired', strtolower($body['error']['message']));
+        }
+    }
+}
diff --git a/erp24/tests/unit/integrations/telegram/TelegramBotContractTest.php b/erp24/tests/unit/integrations/telegram/TelegramBotContractTest.php
new file mode 100644 (file)
index 0000000..395d810
--- /dev/null
@@ -0,0 +1,383 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\integrations\telegram;
+
+use Codeception\Test\Unit;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Exception\ClientException;
+
+/**
+ * Контрактные тесты Telegram Bot API
+ *
+ * Проверяет соответствие запросов и ответов контракту Telegram Bot API.
+ * Использует mock HTTP клиент — реальные сетевые запросы НЕ выполняются.
+ *
+ * Документация: https://core.telegram.org/bots/api
+ *
+ * @group integrations
+ * @group telegram
+ * @group contract
+ */
+class TelegramBotContractTest extends Unit
+{
+    private const BOT_TOKEN = '123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567';
+    private const BASE_URL = 'https://api.telegram.org';
+
+    private string $fixturesPath;
+    private array $history = [];
+
+    protected function _before(): void
+    {
+        $this->fixturesPath = dirname(__DIR__, 3) . '/_data/external/telegram/';
+        $this->history = [];
+    }
+
+    private function createMockClient(array $responses): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(Middleware::history($this->history));
+
+        return new Client(['handler' => $handlerStack]);
+    }
+
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        $this->assertFileExists($path, "Fixture file {$filename} must exist");
+        return json_decode(file_get_contents($path), true);
+    }
+
+    private function getBotUrl(string $method): string
+    {
+        return self::BASE_URL . '/bot' . self::BOT_TOKEN . '/' . $method;
+    }
+
+    // =========================================================================
+    // sendMessage Tests
+    // =========================================================================
+
+    /**
+     * Тест: успешная отправка сообщения
+     *
+     * Request contract:
+     * - POST /bot{token}/sendMessage
+     * - Content-Type: application/json
+     * - Body: chat_id, text, [parse_mode, disable_notification, ...]
+     */
+    public function testSendMessageRequestContract(): void
+    {
+        $successResponse = $this->loadFixture('send_message_success.json');
+        unset($successResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($successResponse)),
+        ]);
+
+        $response = $client->post($this->getBotUrl('sendMessage'), [
+            'json' => [
+                'chat_id' => -1001234567890,
+                'text' => 'Test notification message',
+                'parse_mode' => 'HTML',
+                'disable_notification' => false,
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertArrayHasKey('ok', $body);
+        $this->assertTrue($body['ok']);
+        $this->assertArrayHasKey('result', $body);
+
+        $result = $body['result'];
+        $this->assertArrayHasKey('message_id', $result);
+        $this->assertArrayHasKey('chat', $result);
+        $this->assertArrayHasKey('date', $result);
+        $this->assertArrayHasKey('text', $result);
+
+        $this->assertIsInt($result['message_id']);
+        $this->assertIsArray($result['chat']);
+        $this->assertArrayHasKey('id', $result['chat']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertStringContainsString('/sendMessage', $request->getUri()->getPath());
+        $this->assertStringContainsString('bot' . self::BOT_TOKEN, $request->getUri()->getPath());
+
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $this->assertArrayHasKey('chat_id', $requestBody);
+        $this->assertArrayHasKey('text', $requestBody);
+    }
+
+    /**
+     * Тест: отправка сообщения с Markdown форматированием
+     */
+    public function testSendMessageWithMarkdown(): void
+    {
+        $successResponse = $this->loadFixture('send_message_success.json');
+        unset($successResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($successResponse)),
+        ]);
+
+        $markdownText = "*Bold* _italic_ `code`\n[Link](https://example.com)";
+
+        $response = $client->post($this->getBotUrl('sendMessage'), [
+            'json' => [
+                'chat_id' => -1001234567890,
+                'text' => $markdownText,
+                'parse_mode' => 'MarkdownV2',
+            ],
+        ]);
+
+        $requestBody = json_decode($this->history[0]['request']->getBody()->getContents(), true);
+        $this->assertEquals('MarkdownV2', $requestBody['parse_mode']);
+        $this->assertEquals($markdownText, $requestBody['text']);
+    }
+
+    /**
+     * Тест: обработка ошибки "chat not found"
+     */
+    public function testSendMessageChatNotFound(): void
+    {
+        $errorResponse = $this->loadFixture('send_message_error.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(400, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->post($this->getBotUrl('sendMessage'), [
+                'json' => [
+                    'chat_id' => 999999999999,
+                    'text' => 'Test',
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 400');
+        } catch (ClientException $e) {
+            $response = $e->getResponse();
+            $body = json_decode($response->getBody()->getContents(), true);
+
+            $this->assertEquals(400, $response->getStatusCode());
+            $this->assertFalse($body['ok']);
+            $this->assertArrayHasKey('error_code', $body);
+            $this->assertArrayHasKey('description', $body);
+            $this->assertEquals(400, $body['error_code']);
+            $this->assertStringContainsString('chat not found', $body['description']);
+        }
+    }
+
+    /**
+     * Тест: обработка rate limit (429)
+     */
+    public function testSendMessageRateLimit(): void
+    {
+        $rateLimitResponse = $this->loadFixture('rate_limit_429.json');
+        unset($rateLimitResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(429, [
+                'Content-Type' => 'application/json',
+                'Retry-After' => '60',
+            ], json_encode($rateLimitResponse)),
+        ]);
+
+        try {
+            $client->post($this->getBotUrl('sendMessage'), [
+                'json' => [
+                    'chat_id' => -1001234567890,
+                    'text' => 'Test',
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 429');
+        } catch (ClientException $e) {
+            $response = $e->getResponse();
+            $body = json_decode($response->getBody()->getContents(), true);
+
+            $this->assertEquals(429, $response->getStatusCode());
+            $this->assertFalse($body['ok']);
+            $this->assertEquals(429, $body['error_code']);
+            $this->assertArrayHasKey('parameters', $body);
+            $this->assertArrayHasKey('retry_after', $body['parameters']);
+            $this->assertIsInt($body['parameters']['retry_after']);
+        }
+    }
+
+    /**
+     * Тест: обработка невалидного токена (401)
+     */
+    public function testSendMessageUnauthorized(): void
+    {
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode([
+                'ok' => false,
+                'error_code' => 401,
+                'description' => 'Unauthorized',
+            ])),
+        ]);
+
+        try {
+            $client->post('https://api.telegram.org/botINVALID_TOKEN/sendMessage', [
+                'json' => [
+                    'chat_id' => -1001234567890,
+                    'text' => 'Test',
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 401');
+        } catch (ClientException $e) {
+            $body = json_decode($e->getResponse()->getBody()->getContents(), true);
+            $this->assertFalse($body['ok']);
+            $this->assertEquals(401, $body['error_code']);
+        }
+    }
+
+    // =========================================================================
+    // setWebhook Tests
+    // =========================================================================
+
+    /**
+     * Тест: установка webhook
+     *
+     * Request contract:
+     * - POST /bot{token}/setWebhook
+     * - Body: url, [secret_token, max_connections, allowed_updates]
+     */
+    public function testSetWebhookRequestContract(): void
+    {
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode([
+                'ok' => true,
+                'result' => true,
+                'description' => 'Webhook was set',
+            ])),
+        ]);
+
+        $response = $client->post($this->getBotUrl('setWebhook'), [
+            'json' => [
+                'url' => 'https://example.com/webhook/telegram',
+                'secret_token' => 'my_secret_token',
+                'max_connections' => 40,
+                'allowed_updates' => ['message', 'callback_query'],
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertTrue($body['ok']);
+        $this->assertTrue($body['result']);
+
+        $requestBody = json_decode($this->history[0]['request']->getBody()->getContents(), true);
+        $this->assertArrayHasKey('url', $requestBody);
+        $this->assertStringStartsWith('https://', $requestBody['url']);
+    }
+
+    // =========================================================================
+    // getMe Tests
+    // =========================================================================
+
+    /**
+     * Тест: получение информации о боте
+     */
+    public function testGetMeRequestContract(): void
+    {
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode([
+                'ok' => true,
+                'result' => [
+                    'id' => 123456789,
+                    'is_bot' => true,
+                    'first_name' => 'ERP24 Bot',
+                    'username' => 'erp24_bot',
+                    'can_join_groups' => true,
+                    'can_read_all_group_messages' => false,
+                    'supports_inline_queries' => false,
+                ],
+            ])),
+        ]);
+
+        $response = $client->get($this->getBotUrl('getMe'));
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertTrue($body['ok']);
+        $this->assertArrayHasKey('result', $body);
+
+        $bot = $body['result'];
+        $this->assertArrayHasKey('id', $bot);
+        $this->assertArrayHasKey('is_bot', $bot);
+        $this->assertArrayHasKey('first_name', $bot);
+        $this->assertTrue($bot['is_bot']);
+    }
+
+    // =========================================================================
+    // Message Format Tests
+    // =========================================================================
+
+    /**
+     * Тест: экранирование специальных символов для MarkdownV2
+     */
+    public function testMarkdownV2Escaping(): void
+    {
+        // Символы, требующие экранирования в MarkdownV2
+        $specialChars = '_*[]()~`>#+-=|{}.!';
+
+        // В MarkdownV2 эти символы должны быть экранированы обратным слэшем
+        $escapedText = preg_replace('/([_*\[\]()~`>#\+\-=|{}.!])/', '\\\\$1', 'Price: $100 (50% off)');
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode([
+                'ok' => true,
+                'result' => ['message_id' => 1, 'chat' => ['id' => 1], 'date' => time(), 'text' => $escapedText],
+            ])),
+        ]);
+
+        $response = $client->post($this->getBotUrl('sendMessage'), [
+            'json' => [
+                'chat_id' => -1001234567890,
+                'text' => $escapedText,
+                'parse_mode' => 'MarkdownV2',
+            ],
+        ]);
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
+    /**
+     * Тест: максимальная длина сообщения (4096 символов)
+     */
+    public function testMessageLengthLimit(): void
+    {
+        $maxLength = 4096;
+        $longText = str_repeat('A', $maxLength);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode([
+                'ok' => true,
+                'result' => ['message_id' => 1, 'chat' => ['id' => 1], 'date' => time(), 'text' => $longText],
+            ])),
+        ]);
+
+        $response = $client->post($this->getBotUrl('sendMessage'), [
+            'json' => [
+                'chat_id' => -1001234567890,
+                'text' => $longText,
+            ],
+        ]);
+
+        $requestBody = json_decode($this->history[0]['request']->getBody()->getContents(), true);
+        $this->assertEquals($maxLength, strlen($requestBody['text']));
+    }
+}
diff --git a/erp24/tests/unit/integrations/whatsapp/EdnaWhatsAppContractTest.php b/erp24/tests/unit/integrations/whatsapp/EdnaWhatsAppContractTest.php
new file mode 100644 (file)
index 0000000..2da20e4
--- /dev/null
@@ -0,0 +1,542 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\integrations\whatsapp;
+
+use Codeception\Test\Unit;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Exception\ClientException;
+
+/**
+ * Контрактные тесты EDNA WhatsApp API
+ *
+ * Проверяет соответствие запросов и ответов контракту EDNA API.
+ * Использует mock HTTP клиент — реальные сетевые запросы НЕ выполняются.
+ *
+ * Документация: https://edna.docs.apiary.io/
+ *
+ * @group integrations
+ * @group whatsapp
+ * @group edna
+ * @group contract
+ */
+class EdnaWhatsAppContractTest extends Unit
+{
+    private const BASE_URL = 'https://app.edna.ru/api';
+    private const TEST_API_KEY = 'test_api_key_xxxxxxxxxxxx';
+    private const TEST_CASCADE_ID = 5686;
+
+    private string $fixturesPath;
+    private array $history = [];
+
+    protected function _before(): void
+    {
+        $this->fixturesPath = dirname(__DIR__, 3) . '/_data/external/whatsapp/';
+        $this->history = [];
+    }
+
+    private function createMockClient(array $responses): Client
+    {
+        $mock = new MockHandler($responses);
+        $handlerStack = HandlerStack::create($mock);
+        $handlerStack->push(Middleware::history($this->history));
+
+        return new Client(['handler' => $handlerStack]);
+    }
+
+    private function loadFixture(string $filename): array
+    {
+        $path = $this->fixturesPath . $filename;
+        $this->assertFileExists($path, "Fixture file {$filename} must exist");
+        return json_decode(file_get_contents($path), true);
+    }
+
+    // =========================================================================
+    // cascade/schedule Tests (Отправка сообщения)
+    // =========================================================================
+
+    /**
+     * Тест: успешная отправка сообщения через каскад
+     *
+     * Request contract:
+     * - POST /cascade/schedule
+     * - Headers: X-API-KEY, Content-Type: application/json
+     * - Body: requestId, cascadeId, subscriberFilter, content
+     */
+    public function testCascadeScheduleRequestContract(): void
+    {
+        $successResponse = $this->loadFixture('send_message_success.json');
+        unset($successResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($successResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/cascade/schedule', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'X-API-KEY' => self::TEST_API_KEY,
+            ],
+            'json' => [
+                'requestId' => 'req-uuid-12345',
+                'cascadeId' => self::TEST_CASCADE_ID,
+                'subscriberFilter' => [
+                    'address' => '79001234567',
+                    'type' => 'PHONE',
+                ],
+                'content' => [
+                    'whatsappContent' => [
+                        'contentType' => 'TEXT',
+                        'text' => 'Тестовое сообщение',
+                        'messageMatcherId' => 121254,
+                    ],
+                ],
+                'errorIfNotMatched' => true,
+                'priority' => 'DEFAULT',
+            ],
+        ]);
+
+        // Проверяем response contract
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertArrayHasKey('id', $body);
+        $this->assertArrayHasKey('status', $body);
+        $this->assertEquals('SENT', $body['status']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertEquals('/api/cascade/schedule', $request->getUri()->getPath());
+        $this->assertEquals(self::TEST_API_KEY, $request->getHeaderLine('X-API-KEY'));
+
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $this->assertArrayHasKey('requestId', $requestBody);
+        $this->assertArrayHasKey('cascadeId', $requestBody);
+        $this->assertArrayHasKey('subscriberFilter', $requestBody);
+        $this->assertArrayHasKey('content', $requestBody);
+        $this->assertArrayHasKey('whatsappContent', $requestBody['content']);
+    }
+
+    /**
+     * Тест: ошибка авторизации (неверный API ключ)
+     */
+    public function testCascadeScheduleUnauthorized(): void
+    {
+        $errorResponse = $this->loadFixture('error_auth_401.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(401, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->post(self::BASE_URL . '/cascade/schedule', [
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    'X-API-KEY' => 'invalid_api_key',
+                ],
+                'json' => [
+                    'requestId' => 'req-uuid-12345',
+                    'cascadeId' => self::TEST_CASCADE_ID,
+                    'subscriberFilter' => [
+                        'address' => '79001234567',
+                        'type' => 'PHONE',
+                    ],
+                    'content' => [
+                        'whatsappContent' => [
+                            'contentType' => 'TEXT',
+                            'text' => 'Test',
+                        ],
+                    ],
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 401');
+        } catch (ClientException $e) {
+            $response = $e->getResponse();
+            $body = json_decode($response->getBody()->getContents(), true);
+
+            $this->assertEquals(401, $response->getStatusCode());
+            $this->assertArrayHasKey('title', $body);
+            $this->assertEquals('auth-error', $body['title']);
+        }
+    }
+
+    /**
+     * Тест: ошибка - каскад не найден
+     */
+    public function testCascadeScheduleCascadeNotFound(): void
+    {
+        $errorResponse = $this->loadFixture('error_cascade_not_found.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(400, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->post(self::BASE_URL . '/cascade/schedule', [
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    'X-API-KEY' => self::TEST_API_KEY,
+                ],
+                'json' => [
+                    'requestId' => 'req-uuid-12345',
+                    'cascadeId' => 999999, // несуществующий каскад
+                    'subscriberFilter' => [
+                        'address' => '79001234567',
+                        'type' => 'PHONE',
+                    ],
+                    'content' => [
+                        'whatsappContent' => [
+                            'contentType' => 'TEXT',
+                            'text' => 'Test',
+                        ],
+                    ],
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 400');
+        } catch (ClientException $e) {
+            $response = $e->getResponse();
+            $body = json_decode($response->getBody()->getContents(), true);
+
+            $this->assertEquals(400, $response->getStatusCode());
+            $this->assertEquals('cascade-not-found', $body['title']);
+        }
+    }
+
+    /**
+     * Тест: ошибка - недостаточно средств
+     */
+    public function testCascadeScheduleOutOfBalance(): void
+    {
+        $errorResponse = $this->loadFixture('error_out_of_balance.json');
+        unset($errorResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(400, ['Content-Type' => 'application/json'], json_encode($errorResponse)),
+        ]);
+
+        try {
+            $client->post(self::BASE_URL . '/cascade/schedule', [
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    'X-API-KEY' => self::TEST_API_KEY,
+                ],
+                'json' => [
+                    'requestId' => 'req-uuid-12345',
+                    'cascadeId' => self::TEST_CASCADE_ID,
+                    'subscriberFilter' => [
+                        'address' => '79001234567',
+                        'type' => 'PHONE',
+                    ],
+                    'content' => [
+                        'whatsappContent' => [
+                            'contentType' => 'TEXT',
+                            'text' => 'Test',
+                        ],
+                    ],
+                ],
+                'http_errors' => true,
+            ]);
+            $this->fail('Expected ClientException for 400');
+        } catch (ClientException $e) {
+            $body = json_decode($e->getResponse()->getBody()->getContents(), true);
+            $this->assertEquals('out-of-balance', $body['title']);
+        }
+    }
+
+    // =========================================================================
+    // cascade/get-all Tests
+    // =========================================================================
+
+    /**
+     * Тест: получение списка каскадов
+     *
+     * Request contract:
+     * - POST /cascade/get-all
+     * - Headers: X-API-KEY
+     */
+    public function testGetAllCascadesRequestContract(): void
+    {
+        $cascadesResponse = $this->loadFixture('cascade_list_success.json');
+        unset($cascadesResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($cascadesResponse['data'])),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/cascade/get-all', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'X-API-KEY' => self::TEST_API_KEY,
+            ],
+            'json' => (object)[],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertIsArray($body);
+        $this->assertNotEmpty($body);
+
+        // Проверяем структуру каскада
+        $cascade = $body[0];
+        $this->assertArrayHasKey('id', $cascade);
+        $this->assertArrayHasKey('name', $cascade);
+        $this->assertArrayHasKey('status', $cascade);
+        $this->assertIsInt($cascade['id']);
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertEquals('/api/cascade/get-all', $request->getUri()->getPath());
+    }
+
+    // =========================================================================
+    // channel-profile Tests
+    // =========================================================================
+
+    /**
+     * Тест: получение профилей каналов
+     *
+     * Request contract:
+     * - GET /channel-profile?types=WHATSAPP
+     * - Headers: X-API-KEY
+     */
+    public function testChannelProfileRequestContract(): void
+    {
+        $channelResponse = $this->loadFixture('channel_profile_success.json');
+        unset($channelResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($channelResponse['data'])),
+        ]);
+
+        $response = $client->get(self::BASE_URL . '/channel-profile', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'X-API-KEY' => self::TEST_API_KEY,
+            ],
+            'query' => [
+                'types' => 'WHATSAPP',
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertIsArray($body);
+
+        if (!empty($body)) {
+            $channel = $body[0];
+            $this->assertArrayHasKey('id', $channel);
+            $this->assertArrayHasKey('name', $channel);
+            $this->assertArrayHasKey('type', $channel);
+            $this->assertEquals('WHATSAPP', $channel['type']);
+        }
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('GET', $request->getMethod());
+        $this->assertStringContainsString('/channel-profile', $request->getUri()->getPath());
+        $this->assertStringContainsString('types=WHATSAPP', $request->getUri()->getQuery());
+    }
+
+    // =========================================================================
+    // messages/history Tests
+    // =========================================================================
+
+    /**
+     * Тест: получение истории сообщений
+     *
+     * Request contract:
+     * - POST /messages/history
+     * - Body: offset, limit, channelTypes, direction, dateFrom, dateTo
+     */
+    public function testMessagesHistoryRequestContract(): void
+    {
+        $historyResponse = $this->loadFixture('messages_history_success.json');
+        unset($historyResponse['_comment']);
+
+        $client = $this->createMockClient([
+            new Response(200, ['Content-Type' => 'application/json'], json_encode($historyResponse)),
+        ]);
+
+        $response = $client->post(self::BASE_URL . '/messages/history', [
+            'headers' => [
+                'Content-Type' => 'application/json',
+                'X-API-KEY' => self::TEST_API_KEY,
+            ],
+            'json' => [
+                'offset' => 0,
+                'limit' => 1000,
+                'channelTypes' => ['WHATSAPP'],
+                'direction' => 'OUT',
+                'dateFrom' => '2025-01-21T00:00:00Z',
+                'dateTo' => '2025-01-21T23:59:59Z',
+                'sort' => [
+                    [
+                        'property' => 'messageId',
+                        'direction' => 'DESC',
+                    ],
+                ],
+                'subjectId' => 11374,
+            ],
+        ]);
+
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        $this->assertArrayHasKey('content', $body);
+        $this->assertIsArray($body['content']);
+        $this->assertArrayHasKey('totalElements', $body);
+
+        // Проверяем структуру сообщения
+        if (!empty($body['content'])) {
+            $message = $body['content'][0];
+            $this->assertArrayHasKey('messageId', $message);
+            $this->assertArrayHasKey('address', $message);
+            $this->assertArrayHasKey('deliveryStatus', $message);
+            $this->assertArrayHasKey('sentOrReceivedAt', $message);
+            $this->assertArrayHasKey('channelType', $message);
+            $this->assertEquals('WHATSAPP', $message['channelType']);
+        }
+
+        // Проверяем request contract
+        $request = $this->history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod());
+        $this->assertEquals('/api/messages/history', $request->getUri()->getPath());
+
+        $requestBody = json_decode($request->getBody()->getContents(), true);
+        $this->assertArrayHasKey('channelTypes', $requestBody);
+        $this->assertContains('WHATSAPP', $requestBody['channelTypes']);
+    }
+
+    // =========================================================================
+    // WhatsApp Content Structure Tests
+    // =========================================================================
+
+    /**
+     * Тест: структура whatsappContent для текстового сообщения
+     */
+    public function testWhatsAppTextContentStructure(): void
+    {
+        $textContent = [
+            'contentType' => 'TEXT',
+            'text' => 'Тестовое сообщение',
+            'messageMatcherId' => 121254,
+        ];
+
+        // Обязательные поля для TEXT
+        $this->assertArrayHasKey('contentType', $textContent);
+        $this->assertEquals('TEXT', $textContent['contentType']);
+        $this->assertArrayHasKey('text', $textContent);
+        $this->assertNotEmpty($textContent['text']);
+    }
+
+    /**
+     * Тест: структура subscriberFilter
+     */
+    public function testSubscriberFilterStructure(): void
+    {
+        $filter = [
+            'address' => '79001234567',
+            'type' => 'PHONE',
+        ];
+
+        $this->assertArrayHasKey('address', $filter);
+        $this->assertArrayHasKey('type', $filter);
+        $this->assertEquals('PHONE', $filter['type']);
+
+        // Телефон должен быть без +
+        $this->assertMatchesRegularExpression('/^7\d{10}$/', $filter['address']);
+    }
+
+    /**
+     * Тест: валидные статусы доставки
+     */
+    public function testDeliveryStatusValues(): void
+    {
+        // Согласно EDNA API документации
+        $validStatuses = [
+            'SENT',
+            'DELIVERED',
+            'READ',
+            'FAILED',
+            'EXPIRED',
+            'REJECTED',
+            'PENDING',
+        ];
+
+        $historyResponse = $this->loadFixture('messages_history_success.json');
+        unset($historyResponse['_comment']);
+
+        foreach ($historyResponse['content'] as $message) {
+            $this->assertContains(
+                $message['deliveryStatus'],
+                $validStatuses,
+                "Invalid delivery status: {$message['deliveryStatus']}"
+            );
+        }
+    }
+
+    /**
+     * Тест: валидные типы контента WhatsApp
+     */
+    public function testWhatsAppContentTypes(): void
+    {
+        // Согласно EDNA API документации
+        $validContentTypes = [
+            'TEXT',
+            'IMAGE',
+            'DOCUMENT',
+            'VIDEO',
+            'AUDIO',
+            'LOCATION',
+            'CONTACT',
+            'TEMPLATE',
+        ];
+
+        // Проверяем что TEXT - валидный тип
+        $this->assertContains('TEXT', $validContentTypes);
+    }
+
+    // =========================================================================
+    // Error Code Mapping Tests
+    // =========================================================================
+
+    /**
+     * Тест: маппинг кодов ошибок EDNA
+     */
+    public function testEdnaErrorCodesMapping(): void
+    {
+        // Из WhatsAppService::getErrorMessage()
+        $errorCodes = [
+            400 => [
+                'requestId-is-not-unique',
+                'content-not-specified',
+                'cascade-not-found',
+                'out-of-balance',
+                'template-parameter-is-not-valid',
+            ],
+            401 => ['auth-error'],
+            404 => ['not-found'],
+            405 => ['method-not-allowed'],
+            500 => ['system-error'],
+        ];
+
+        // Проверяем что наши фикстуры соответствуют документированным кодам
+        $authError = $this->loadFixture('error_auth_401.json');
+        $this->assertContains($authError['title'], $errorCodes[401]);
+
+        $cascadeError = $this->loadFixture('error_cascade_not_found.json');
+        $this->assertContains($cascadeError['title'], $errorCodes[400]);
+
+        $balanceError = $this->loadFixture('error_out_of_balance.json');
+        $this->assertContains($balanceError['title'], $errorCodes[400]);
+    }
+}
diff --git a/erp24/tests/unit/jobs/JobSerializationTest.php b/erp24/tests/unit/jobs/JobSerializationTest.php
new file mode 100644 (file)
index 0000000..44a0200
--- /dev/null
@@ -0,0 +1,266 @@
+<?php
+
+namespace app\tests\unit\jobs;
+
+use app\jobs\SendTelegramMessageJob;
+use app\jobs\SendWhatsappMessageJob;
+use Codeception\Test\Unit;
+use yii_app\jobs\SendBonusInfoToSiteJob;
+use yii_app\jobs\SendRequestUploadDataToJob;
+
+/**
+ * Unit-тесты для сериализации/десериализации Job объектов
+ *
+ * Тестирует корректную сериализацию jobs для передачи через очередь.
+ * Важно для корректной работы с RabbitMQ и yii2-queue.
+ */
+class JobSerializationTest extends Unit
+{
+    /**
+     * Тест сериализации SendTelegramMessageJob
+     */
+    public function testSendTelegramMessageJobSerialization(): void
+    {
+        $messageData = [
+            'chat_id' => '123456789',
+            'phone' => '79001234567',
+            'message' => 'Тестовое сообщение с кириллицей'
+        ];
+
+        $job = new SendTelegramMessageJob([
+            'messageData' => $messageData,
+            'isDev' => true
+        ]);
+
+        $serialized = serialize($job);
+        $unserialized = unserialize($serialized);
+
+        $this->assertInstanceOf(SendTelegramMessageJob::class, $unserialized);
+        $this->assertEquals($messageData, $unserialized->messageData);
+        $this->assertTrue($unserialized->isDev);
+    }
+
+    /**
+     * Тест сериализации SendWhatsappMessageJob
+     */
+    public function testSendWhatsappMessageJobSerialization(): void
+    {
+        $messageData = [
+            'phone' => '79001234567',
+            'message' => 'WhatsApp сообщение',
+            'kogort_date' => '2024-01-15',
+            'target_date' => '2024-01-20',
+            'cascade_id' => 'cascade_123'
+        ];
+
+        $job = new SendWhatsappMessageJob([
+            'messageData' => $messageData,
+            'isTest' => true
+        ]);
+
+        $serialized = serialize($job);
+        $unserialized = unserialize($serialized);
+
+        $this->assertInstanceOf(SendWhatsappMessageJob::class, $unserialized);
+        $this->assertEquals($messageData, $unserialized->messageData);
+        $this->assertTrue($unserialized->isTest);
+    }
+
+    /**
+     * Тест сериализации SendBonusInfoToSiteJob
+     */
+    public function testSendBonusInfoToSiteJobSerialization(): void
+    {
+        $job = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 150,
+            'purchaseDate' => '2024-01-15',
+            'orderId' => 'ORDER-12345'
+        ]);
+
+        $serialized = serialize($job);
+        $unserialized = unserialize($serialized);
+
+        $this->assertInstanceOf(SendBonusInfoToSiteJob::class, $unserialized);
+        $this->assertEquals('79001234567', $unserialized->phone);
+        $this->assertEquals(150, $unserialized->bonusCount);
+        $this->assertEquals('2024-01-15', $unserialized->purchaseDate);
+        $this->assertEquals('ORDER-12345', $unserialized->orderId);
+    }
+
+    /**
+     * Тест сериализации SendRequestUploadDataToJob
+     */
+    public function testSendRequestUploadDataToJobSerialization(): void
+    {
+        $decodingResult = [
+            'request_id' => 'REQ-12345',
+            'data' => ['item1', 'item2', 'item3'],
+            'metadata' => ['source' => 'api', 'timestamp' => time()]
+        ];
+
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => $decodingResult
+        ]);
+
+        $serialized = serialize($job);
+        $unserialized = unserialize($serialized);
+
+        $this->assertInstanceOf(SendRequestUploadDataToJob::class, $unserialized);
+        $this->assertEquals($decodingResult, $unserialized->decodingResult);
+    }
+
+    /**
+     * Тест JSON сериализации данных job
+     */
+    public function testJobDataJsonSerialization(): void
+    {
+        $messageData = [
+            'chat_id' => '123456789',
+            'phone' => '79001234567',
+            'message' => 'Сообщение с emoji 🎉'
+        ];
+
+        $json = json_encode($messageData, JSON_UNESCAPED_UNICODE);
+        $decoded = json_decode($json, true);
+
+        $this->assertEquals($messageData, $decoded);
+        $this->assertStringContainsString('🎉', $decoded['message']);
+    }
+
+    /**
+     * Тест сериализации с Unicode символами
+     */
+    public function testSerializationWithUnicode(): void
+    {
+        $messageData = [
+            'chat_id' => '123456789',
+            'phone' => '79001234567',
+            'message' => 'Привет! Как дела? 👋 Цветы 🌸 готовы!'
+        ];
+
+        $job = new SendTelegramMessageJob([
+            'messageData' => $messageData
+        ]);
+
+        $serialized = serialize($job);
+        $unserialized = unserialize($serialized);
+
+        $this->assertStringContainsString('👋', $unserialized->messageData['message']);
+        $this->assertStringContainsString('🌸', $unserialized->messageData['message']);
+    }
+
+    /**
+     * Тест сериализации с вложенными массивами
+     */
+    public function testSerializationWithNestedArrays(): void
+    {
+        $decodingResult = [
+            'request_id' => 'REQ-NESTED',
+            'items' => [
+                ['id' => 1, 'data' => ['a' => 1, 'b' => 2]],
+                ['id' => 2, 'data' => ['c' => 3, 'd' => 4]]
+            ]
+        ];
+
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => $decodingResult
+        ]);
+
+        $serialized = serialize($job);
+        $unserialized = unserialize($serialized);
+
+        $this->assertCount(2, $unserialized->decodingResult['items']);
+        $this->assertEquals(['a' => 1, 'b' => 2], $unserialized->decodingResult['items'][0]['data']);
+    }
+
+    /**
+     * Тест сериализации с null значениями
+     */
+    public function testSerializationWithNullValues(): void
+    {
+        $messageData = [
+            'phone' => '79001234567',
+            'message' => 'Test',
+            'kogort_date' => null,
+            'target_date' => null,
+            'cascade_id' => 'cascade_1'
+        ];
+
+        $job = new SendWhatsappMessageJob([
+            'messageData' => $messageData,
+            'isTest' => null
+        ]);
+
+        $serialized = serialize($job);
+        $unserialized = unserialize($serialized);
+
+        $this->assertNull($unserialized->messageData['kogort_date']);
+        $this->assertNull($unserialized->messageData['target_date']);
+        $this->assertNull($unserialized->isTest);
+    }
+
+    /**
+     * Тест сериализации с большими числами
+     */
+    public function testSerializationWithLargeNumbers(): void
+    {
+        $job = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 9999999,
+            'orderId' => PHP_INT_MAX
+        ]);
+
+        $serialized = serialize($job);
+        $unserialized = unserialize($serialized);
+
+        $this->assertEquals(9999999, $unserialized->bonusCount);
+        $this->assertEquals(PHP_INT_MAX, $unserialized->orderId);
+    }
+
+    /**
+     * Тест что статические свойства не сериализуются
+     */
+    public function testStaticPropertiesNotSerialized(): void
+    {
+        SendTelegramMessageJob::$messagesSent = 25;
+        SendTelegramMessageJob::$lastResetTime = microtime(true);
+
+        $job = new SendTelegramMessageJob([
+            'messageData' => ['chat_id' => '123', 'phone' => '79001234567', 'message' => 'test']
+        ]);
+
+        $serialized = serialize($job);
+
+        // Сбрасываем статические свойства
+        SendTelegramMessageJob::$messagesSent = 0;
+        SendTelegramMessageJob::$lastResetTime = null;
+
+        $unserialized = unserialize($serialized);
+
+        // Статические свойства должны остаться с новыми значениями
+        $this->assertEquals(0, SendTelegramMessageJob::$messagesSent);
+        $this->assertNull(SendTelegramMessageJob::$lastResetTime);
+    }
+
+    /**
+     * Тест размера сериализованных данных
+     */
+    public function testSerializedDataSize(): void
+    {
+        $messageData = [
+            'chat_id' => '123456789',
+            'phone' => '79001234567',
+            'message' => str_repeat('A', 1000) // 1KB message
+        ];
+
+        $job = new SendTelegramMessageJob([
+            'messageData' => $messageData
+        ]);
+
+        $serialized = serialize($job);
+
+        // Сериализованные данные должны быть разумного размера
+        $this->assertLessThan(10000, strlen($serialized), 'Сериализованный job не должен быть слишком большим');
+    }
+}
diff --git a/erp24/tests/unit/jobs/QueueConfigTest.php b/erp24/tests/unit/jobs/QueueConfigTest.php
new file mode 100644 (file)
index 0000000..e206c6f
--- /dev/null
@@ -0,0 +1,211 @@
+<?php
+
+namespace app\tests\unit\jobs;
+
+use Codeception\Test\Unit;
+
+/**
+ * Unit-тесты для конфигурации очередей (Queue)
+ *
+ * Тестирует структуру и базовые настройки очередей RabbitMQ/AMQP.
+ * Тесты не требуют реального подключения к RabbitMQ.
+ */
+class QueueConfigTest extends Unit
+{
+    /**
+     * Тест существования класса Queue от yii2-queue
+     */
+    public function testQueueClassExists(): void
+    {
+        $this->assertTrue(
+            class_exists(\yii\queue\amqp_interop\Queue::class),
+            'Класс yii\queue\amqp_interop\Queue должен существовать'
+        );
+    }
+
+    /**
+     * Тест существования интерфейса JobInterface
+     */
+    public function testJobInterfaceExists(): void
+    {
+        $this->assertTrue(
+            interface_exists(\yii\queue\JobInterface::class),
+            'Интерфейс yii\queue\JobInterface должен существовать'
+        );
+    }
+
+    /**
+     * Тест существования интерфейса RetryableJobInterface
+     */
+    public function testRetryableJobInterfaceExists(): void
+    {
+        $this->assertTrue(
+            interface_exists(\yii\queue\RetryableJobInterface::class),
+            'Интерфейс yii\queue\RetryableJobInterface должен существовать'
+        );
+    }
+
+    /**
+     * Тест существования LogBehavior для очередей
+     */
+    public function testLogBehaviorExists(): void
+    {
+        $this->assertTrue(
+            class_exists(\yii\queue\LogBehavior::class),
+            'Класс yii\queue\LogBehavior должен существовать'
+        );
+    }
+
+    /**
+     * Тест конфигурации TTR (Time To Reserve)
+     */
+    public function testTtrConfiguration(): void
+    {
+        // Проверяем типичные значения TTR из конфигурации
+        $productionTtr = 600; // 10 минут
+        $developmentTtr = 300; // 5 минут
+
+        $this->assertEquals(600, $productionTtr);
+        $this->assertEquals(300, $developmentTtr);
+        $this->assertGreaterThan(0, $productionTtr);
+    }
+
+    /**
+     * Тест конфигурации количества попыток
+     */
+    public function testAttemptsConfiguration(): void
+    {
+        $attempts = 3; // Из конфигурации
+
+        $this->assertEquals(3, $attempts);
+        $this->assertGreaterThan(0, $attempts);
+    }
+
+    /**
+     * Тест имени очереди
+     */
+    public function testQueueName(): void
+    {
+        $queueName = 'telegram-queue'; // Из конфигурации
+
+        $this->assertEquals('telegram-queue', $queueName);
+        $this->assertNotEmpty($queueName);
+    }
+
+    /**
+     * Тест имени обменника (exchange)
+     */
+    public function testExchangeName(): void
+    {
+        $exchangeName = 'telegram-exchange'; // Из конфигурации
+
+        $this->assertEquals('telegram-exchange', $exchangeName);
+        $this->assertNotEmpty($exchangeName);
+    }
+
+    /**
+     * Тест формата DSN для AMQP
+     */
+    public function testAmqpDsnFormat(): void
+    {
+        // Проверяем формат DSN без реальных данных
+        $user = 'testuser';
+        $password = 'testpass';
+        $host = 'localhost';
+        $port = 5672;
+
+        $dsn = "amqp://{$user}:{$password}@{$host}:{$port}";
+
+        $this->assertStringStartsWith('amqp://', $dsn);
+        $this->assertStringContainsString('@', $dsn);
+        $this->assertStringContainsString(':5672', $dsn);
+    }
+
+    /**
+     * Тест URL-кодирования учётных данных
+     */
+    public function testCredentialsUrlEncoding(): void
+    {
+        // Тестируем корректное кодирование спецсимволов
+        $user = 'user@domain';
+        $password = 'pass/word:test';
+
+        $encodedUser = rawurlencode($user);
+        $encodedPassword = rawurlencode($password);
+
+        $this->assertEquals('user%40domain', $encodedUser);
+        $this->assertEquals('pass%2Fword%3Atest', $encodedPassword);
+    }
+
+    /**
+     * Тест что SendTelegramMessageJob реализует JobInterface
+     */
+    public function testSendTelegramMessageJobImplementsInterface(): void
+    {
+        $job = new \app\jobs\SendTelegramMessageJob();
+
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    /**
+     * Тест что SendWhatsappMessageJob реализует JobInterface
+     */
+    public function testSendWhatsappMessageJobImplementsInterface(): void
+    {
+        $job = new \app\jobs\SendWhatsappMessageJob();
+
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    /**
+     * Тест что SendBonusInfoToSiteJob реализует JobInterface
+     */
+    public function testSendBonusInfoToSiteJobImplementsInterface(): void
+    {
+        $job = new \yii_app\jobs\SendBonusInfoToSiteJob();
+
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    /**
+     * Тест что SendRequestUploadDataToJob реализует RetryableJobInterface
+     */
+    public function testSendRequestUploadDataToJobImplementsRetryableInterface(): void
+    {
+        $job = new \yii_app\jobs\SendRequestUploadDataToJob();
+
+        $this->assertInstanceOf(\yii\queue\RetryableJobInterface::class, $job);
+    }
+
+    /**
+     * Тест типичных значений времени ожидания
+     */
+    public function testTypicalTimeoutValues(): void
+    {
+        // SendRequestUploadDataToJob использует TTR = 420 (7 минут)
+        $uploadJobTtr = 420;
+
+        $this->assertEquals(420, $uploadJobTtr);
+        $this->assertLessThanOrEqual(600, $uploadJobTtr, 'TTR не должен превышать 10 минут');
+    }
+
+    /**
+     * Тест валидации retry логики
+     */
+    public function testRetryLogic(): void
+    {
+        $maxAttempts = 3;
+
+        // Попытка 1 - должна разрешить retry
+        $this->assertTrue(1 < $maxAttempts);
+
+        // Попытка 2 - должна разрешить retry
+        $this->assertTrue(2 < $maxAttempts);
+
+        // Попытка 3 - не должна разрешить retry
+        $this->assertFalse(3 < $maxAttempts);
+
+        // Попытка 4 - не должна разрешить retry
+        $this->assertFalse(4 < $maxAttempts);
+    }
+}
diff --git a/erp24/tests/unit/jobs/SendBonusInfoToSiteJobTest.php b/erp24/tests/unit/jobs/SendBonusInfoToSiteJobTest.php
new file mode 100644 (file)
index 0000000..fd083ca
--- /dev/null
@@ -0,0 +1,200 @@
+<?php
+
+namespace app\tests\unit\jobs;
+
+use Codeception\Test\Unit;
+use yii_app\jobs\SendBonusInfoToSiteJob;
+
+/**
+ * Unit-тесты для SendBonusInfoToSiteJob
+ *
+ * Тестирует инициализацию job и валидацию данных.
+ * Реальная отправка данных на сайт мокается через SiteService.
+ */
+class SendBonusInfoToSiteJobTest extends Unit
+{
+    /**
+     * Тест создания job с полными данными
+     */
+    public function testJobCreationWithFullData(): void
+    {
+        $job = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 100,
+            'purchaseDate' => '2024-01-15',
+            'orderId' => 'ORDER-12345'
+        ]);
+
+        $this->assertInstanceOf(SendBonusInfoToSiteJob::class, $job);
+        $this->assertEquals('79001234567', $job->phone);
+        $this->assertEquals(100, $job->bonusCount);
+        $this->assertEquals('2024-01-15', $job->purchaseDate);
+        $this->assertEquals('ORDER-12345', $job->orderId);
+    }
+
+    /**
+     * Тест создания job с минимальными данными
+     */
+    public function testJobCreationWithMinimalData(): void
+    {
+        $job = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 50
+        ]);
+
+        $this->assertEquals('79001234567', $job->phone);
+        $this->assertEquals(50, $job->bonusCount);
+        $this->assertNull($job->purchaseDate);
+        $this->assertNull($job->orderId);
+    }
+
+    /**
+     * Тест проверки интерфейса JobInterface
+     */
+    public function testImplementsJobInterface(): void
+    {
+        $job = new SendBonusInfoToSiteJob();
+
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    /**
+     * Тест с нулевым количеством бонусов
+     */
+    public function testJobWithZeroBonuses(): void
+    {
+        $job = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 0
+        ]);
+
+        $this->assertEquals(0, $job->bonusCount);
+    }
+
+    /**
+     * Тест с отрицательным количеством бонусов (списание)
+     */
+    public function testJobWithNegativeBonuses(): void
+    {
+        $job = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => -50
+        ]);
+
+        $this->assertEquals(-50, $job->bonusCount);
+    }
+
+    /**
+     * Тест с большим количеством бонусов
+     */
+    public function testJobWithLargeBonusCount(): void
+    {
+        $job = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 999999
+        ]);
+
+        $this->assertEquals(999999, $job->bonusCount);
+    }
+
+    /**
+     * Тест с различными форматами телефона
+     */
+    public function testPhoneFormats(): void
+    {
+        // Формат с 7
+        $job1 = new SendBonusInfoToSiteJob(['phone' => '79001234567']);
+        $this->assertEquals('79001234567', $job1->phone);
+
+        // Формат с 8
+        $job2 = new SendBonusInfoToSiteJob(['phone' => '89001234567']);
+        $this->assertEquals('89001234567', $job2->phone);
+
+        // Формат с +7
+        $job3 = new SendBonusInfoToSiteJob(['phone' => '+79001234567']);
+        $this->assertEquals('+79001234567', $job3->phone);
+    }
+
+    /**
+     * Тест с различными форматами даты
+     */
+    public function testPurchaseDateFormats(): void
+    {
+        // ISO формат
+        $job1 = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 10,
+            'purchaseDate' => '2024-01-15'
+        ]);
+        $this->assertEquals('2024-01-15', $job1->purchaseDate);
+
+        // Timestamp как строка
+        $job2 = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 10,
+            'purchaseDate' => '1705305600'
+        ]);
+        $this->assertEquals('1705305600', $job2->purchaseDate);
+
+        // DateTime формат
+        $job3 = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 10,
+            'purchaseDate' => '2024-01-15 10:30:00'
+        ]);
+        $this->assertEquals('2024-01-15 10:30:00', $job3->purchaseDate);
+    }
+
+    /**
+     * Тест с различными форматами orderId
+     */
+    public function testOrderIdFormats(): void
+    {
+        // Строковый ID
+        $job1 = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 10,
+            'orderId' => 'ORDER-12345'
+        ]);
+        $this->assertEquals('ORDER-12345', $job1->orderId);
+
+        // Числовой ID
+        $job2 = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 10,
+            'orderId' => 12345
+        ]);
+        $this->assertEquals(12345, $job2->orderId);
+
+        // UUID формат
+        $job3 = new SendBonusInfoToSiteJob([
+            'phone' => '79001234567',
+            'bonusCount' => 10,
+            'orderId' => '550e8400-e29b-41d4-a716-446655440000'
+        ]);
+        $this->assertEquals('550e8400-e29b-41d4-a716-446655440000', $job3->orderId);
+    }
+
+    /**
+     * Тест создания пустого job
+     */
+    public function testEmptyJobCreation(): void
+    {
+        $job = new SendBonusInfoToSiteJob();
+
+        $this->assertNull($job->phone);
+        $this->assertNull($job->bonusCount);
+        $this->assertNull($job->purchaseDate);
+        $this->assertNull($job->orderId);
+    }
+
+    /**
+     * Тест что job наследует BaseObject
+     */
+    public function testExtendsBaseObject(): void
+    {
+        $job = new SendBonusInfoToSiteJob();
+
+        $this->assertInstanceOf(\yii\base\BaseObject::class, $job);
+    }
+}
diff --git a/erp24/tests/unit/jobs/SendRequestUploadDataToJobTest.php b/erp24/tests/unit/jobs/SendRequestUploadDataToJobTest.php
new file mode 100644 (file)
index 0000000..953344a
--- /dev/null
@@ -0,0 +1,218 @@
+<?php
+
+namespace app\tests\unit\jobs;
+
+use Codeception\Test\Unit;
+use yii_app\jobs\SendRequestUploadDataToJob;
+
+/**
+ * Unit-тесты для SendRequestUploadDataToJob
+ *
+ * Тестирует инициализацию job, normalizeToArray логику и RetryableJobInterface.
+ * Реальная обработка данных мокается через UploadService.
+ */
+class SendRequestUploadDataToJobTest extends Unit
+{
+    /**
+     * Тест создания job с массивом данных
+     */
+    public function testJobCreationWithArray(): void
+    {
+        $decodingResult = [
+            'request_id' => 'REQ-12345',
+            'data' => ['item1', 'item2'],
+            'timestamp' => time()
+        ];
+
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => $decodingResult
+        ]);
+
+        $this->assertInstanceOf(SendRequestUploadDataToJob::class, $job);
+        $this->assertEquals($decodingResult, $job->decodingResult);
+    }
+
+    /**
+     * Тест проверки интерфейса RetryableJobInterface
+     */
+    public function testImplementsRetryableJobInterface(): void
+    {
+        $job = new SendRequestUploadDataToJob();
+
+        $this->assertInstanceOf(\yii\queue\RetryableJobInterface::class, $job);
+    }
+
+    /**
+     * Тест проверки интерфейса JobInterface
+     */
+    public function testImplementsJobInterface(): void
+    {
+        $job = new SendRequestUploadDataToJob();
+
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    /**
+     * Тест getTtr возвращает корректное значение
+     */
+    public function testGetTtrReturnsCorrectValue(): void
+    {
+        $job = new SendRequestUploadDataToJob();
+
+        // По коду TTR = 420 секунд (7 минут)
+        $this->assertEquals(420, $job->getTtr());
+    }
+
+    /**
+     * Тест canRetry для первой попытки
+     */
+    public function testCanRetryFirstAttempt(): void
+    {
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => ['request_id' => 'test']
+        ]);
+
+        // Первая попытка (attempt = 1), должна разрешить retry
+        $this->assertTrue($job->canRetry(1, new \Exception('Test error')));
+    }
+
+    /**
+     * Тест canRetry для второй попытки
+     */
+    public function testCanRetrySecondAttempt(): void
+    {
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => ['request_id' => 'test']
+        ]);
+
+        // Вторая попытка (attempt = 2), должна разрешить retry
+        $this->assertTrue($job->canRetry(2, new \Exception('Test error')));
+    }
+
+    /**
+     * Тест canRetry для третьей попытки (превышение лимита)
+     */
+    public function testCanRetryThirdAttemptExceedsLimit(): void
+    {
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => ['request_id' => 'test']
+        ]);
+
+        // Третья попытка (attempt = 3), не должна разрешить retry (лимит 3)
+        $this->assertFalse($job->canRetry(3, new \Exception('Test error')));
+    }
+
+    /**
+     * Тест canRetry для четвёртой попытки
+     */
+    public function testCanRetryFourthAttempt(): void
+    {
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => ['request_id' => 'test']
+        ]);
+
+        // Четвёртая попытка, точно не должна разрешить retry
+        $this->assertFalse($job->canRetry(4, new \Exception('Test error')));
+    }
+
+    /**
+     * Тест создания job с объектом stdClass
+     */
+    public function testJobCreationWithStdClass(): void
+    {
+        $obj = new \stdClass();
+        $obj->request_id = 'REQ-OBJECT';
+        $obj->data = 'test data';
+
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => $obj
+        ]);
+
+        $this->assertInstanceOf(\stdClass::class, $job->decodingResult);
+        $this->assertEquals('REQ-OBJECT', $job->decodingResult->request_id);
+    }
+
+    /**
+     * Тест создания job с JSON строкой
+     */
+    public function testJobCreationWithJsonString(): void
+    {
+        $jsonString = '{"request_id": "REQ-JSON", "data": "test"}';
+
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => $jsonString
+        ]);
+
+        $this->assertEquals($jsonString, $job->decodingResult);
+    }
+
+    /**
+     * Тест создания пустого job
+     */
+    public function testEmptyJobCreation(): void
+    {
+        $job = new SendRequestUploadDataToJob();
+
+        $this->assertNull($job->decodingResult);
+    }
+
+    /**
+     * Тест с пустым массивом
+     */
+    public function testJobWithEmptyArray(): void
+    {
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => []
+        ]);
+
+        $this->assertIsArray($job->decodingResult);
+        $this->assertEmpty($job->decodingResult);
+    }
+
+    /**
+     * Тест что job наследует BaseObject
+     */
+    public function testExtendsBaseObject(): void
+    {
+        $job = new SendRequestUploadDataToJob();
+
+        $this->assertInstanceOf(\yii\base\BaseObject::class, $job);
+    }
+
+    /**
+     * Тест с null значением
+     */
+    public function testJobWithNullValue(): void
+    {
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => null
+        ]);
+
+        $this->assertNull($job->decodingResult);
+    }
+
+    /**
+     * Тест с вложенными массивами
+     */
+    public function testJobWithNestedArrays(): void
+    {
+        $nestedData = [
+            'request_id' => 'REQ-NESTED',
+            'items' => [
+                ['id' => 1, 'name' => 'Item 1'],
+                ['id' => 2, 'name' => 'Item 2']
+            ],
+            'metadata' => [
+                'created_at' => time(),
+                'source' => 'api'
+            ]
+        ];
+
+        $job = new SendRequestUploadDataToJob([
+            'decodingResult' => $nestedData
+        ]);
+
+        $this->assertEquals($nestedData, $job->decodingResult);
+        $this->assertCount(2, $job->decodingResult['items']);
+    }
+}
diff --git a/erp24/tests/unit/jobs/SendTelegramMessageJobTest.php b/erp24/tests/unit/jobs/SendTelegramMessageJobTest.php
new file mode 100644 (file)
index 0000000..50e86d1
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+
+namespace app\tests\unit\jobs;
+
+use app\jobs\SendTelegramMessageJob;
+use Codeception\Test\Unit;
+
+/**
+ * Unit-тесты для SendTelegramMessageJob
+ *
+ * Тестирует инициализацию job, rate limiting логику и структуру данных.
+ * Реальная отправка сообщений мокается через TelegramService.
+ */
+class SendTelegramMessageJobTest extends Unit
+{
+    protected function _before(): void
+    {
+        // Сбрасываем статические счётчики перед каждым тестом
+        SendTelegramMessageJob::$messagesSent = 0;
+        SendTelegramMessageJob::$lastResetTime = null;
+    }
+
+    /**
+     * Тест создания job с корректными данными
+     */
+    public function testJobCreation(): void
+    {
+        $messageData = [
+            'chat_id' => '123456789',
+            'phone' => '79001234567',
+            'message' => 'Тестовое сообщение'
+        ];
+
+        $job = new SendTelegramMessageJob([
+            'messageData' => $messageData,
+            'isDev' => true
+        ]);
+
+        $this->assertInstanceOf(SendTelegramMessageJob::class, $job);
+        $this->assertEquals($messageData, $job->messageData);
+        $this->assertTrue($job->isDev);
+    }
+
+    /**
+     * Тест создания job без флага isDev
+     */
+    public function testJobCreationWithoutIsDev(): void
+    {
+        $messageData = [
+            'chat_id' => '123456789',
+            'phone' => '79001234567',
+            'message' => 'Тестовое сообщение'
+        ];
+
+        $job = new SendTelegramMessageJob([
+            'messageData' => $messageData
+        ]);
+
+        $this->assertNull($job->isDev);
+    }
+
+    /**
+     * Тест проверки интерфейса JobInterface
+     */
+    public function testImplementsJobInterface(): void
+    {
+        $job = new SendTelegramMessageJob();
+
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    /**
+     * Тест начального состояния rate limiting
+     */
+    public function testInitialRateLimitingState(): void
+    {
+        $this->assertEquals(0, SendTelegramMessageJob::$messagesSent);
+        $this->assertNull(SendTelegramMessageJob::$lastResetTime);
+    }
+
+    /**
+     * Тест инкремента счётчика сообщений
+     */
+    public function testMessageCounterIncrement(): void
+    {
+        SendTelegramMessageJob::$messagesSent = 5;
+        SendTelegramMessageJob::$messagesSent++;
+
+        $this->assertEquals(6, SendTelegramMessageJob::$messagesSent);
+    }
+
+    /**
+     * Тест сброса счётчика при превышении интервала
+     */
+    public function testRateLimitingResetAfterInterval(): void
+    {
+        // Симулируем устаревшее время сброса (более 1 секунды назад)
+        SendTelegramMessageJob::$lastResetTime = microtime(true) - 2;
+        SendTelegramMessageJob::$messagesSent = 25;
+
+        // Логика сброса должна сбросить счётчик
+        if (!SendTelegramMessageJob::$lastResetTime ||
+            (microtime(true) - SendTelegramMessageJob::$lastResetTime) > 1) {
+            SendTelegramMessageJob::$lastResetTime = microtime(true);
+            SendTelegramMessageJob::$messagesSent = 0;
+        }
+
+        $this->assertEquals(0, SendTelegramMessageJob::$messagesSent);
+    }
+
+    /**
+     * Тест что счётчик не сбрасывается в пределах интервала
+     */
+    public function testRateLimitingNoResetWithinInterval(): void
+    {
+        SendTelegramMessageJob::$lastResetTime = microtime(true);
+        SendTelegramMessageJob::$messagesSent = 15;
+
+        // Логика сброса не должна сбросить счётчик
+        if (!SendTelegramMessageJob::$lastResetTime ||
+            (microtime(true) - SendTelegramMessageJob::$lastResetTime) > 1) {
+            SendTelegramMessageJob::$lastResetTime = microtime(true);
+            SendTelegramMessageJob::$messagesSent = 0;
+        }
+
+        $this->assertEquals(15, SendTelegramMessageJob::$messagesSent);
+    }
+
+    /**
+     * Тест проверки лимита в 30 сообщений
+     */
+    public function testRateLimitThresholdValue(): void
+    {
+        SendTelegramMessageJob::$messagesSent = 30;
+
+        $this->assertGreaterThanOrEqual(30, SendTelegramMessageJob::$messagesSent);
+    }
+
+    /**
+     * Тест структуры messageData
+     */
+    public function testMessageDataStructure(): void
+    {
+        $messageData = [
+            'chat_id' => '123456789',
+            'phone' => '79001234567',
+            'message' => 'Тестовое сообщение'
+        ];
+
+        $job = new SendTelegramMessageJob([
+            'messageData' => $messageData
+        ]);
+
+        $this->assertArrayHasKey('chat_id', $job->messageData);
+        $this->assertArrayHasKey('phone', $job->messageData);
+        $this->assertArrayHasKey('message', $job->messageData);
+    }
+
+    /**
+     * Тест с пустым messageData
+     */
+    public function testEmptyMessageData(): void
+    {
+        $job = new SendTelegramMessageJob([
+            'messageData' => []
+        ]);
+
+        $this->assertIsArray($job->messageData);
+        $this->assertEmpty($job->messageData);
+    }
+}
diff --git a/erp24/tests/unit/jobs/SendWhatsappMessageJobTest.php b/erp24/tests/unit/jobs/SendWhatsappMessageJobTest.php
new file mode 100644 (file)
index 0000000..edd95f4
--- /dev/null
@@ -0,0 +1,222 @@
+<?php
+
+namespace app\tests\unit\jobs;
+
+use app\jobs\SendWhatsappMessageJob;
+use Codeception\Test\Unit;
+
+/**
+ * Unit-тесты для SendWhatsappMessageJob
+ *
+ * Тестирует инициализацию job, rate limiting логику и валидацию данных.
+ * Реальная отправка сообщений WhatsApp мокается через WhatsAppService.
+ */
+class SendWhatsappMessageJobTest extends Unit
+{
+    protected function _before(): void
+    {
+        // Сбрасываем статические счётчики перед каждым тестом
+        SendWhatsappMessageJob::$messagesSent = 0;
+        SendWhatsappMessageJob::$lastResetTime = null;
+    }
+
+    /**
+     * Тест создания job с корректными данными
+     */
+    public function testJobCreation(): void
+    {
+        $messageData = [
+            'phone' => '79001234567',
+            'message' => 'Тестовое WhatsApp сообщение',
+            'kogort_date' => '2024-01-15',
+            'target_date' => '2024-01-20',
+            'cascade_id' => 'test_cascade_123'
+        ];
+
+        $job = new SendWhatsappMessageJob([
+            'messageData' => $messageData,
+            'isTest' => true
+        ]);
+
+        $this->assertInstanceOf(SendWhatsappMessageJob::class, $job);
+        $this->assertEquals($messageData, $job->messageData);
+        $this->assertTrue($job->isTest);
+    }
+
+    /**
+     * Тест создания job без флага isTest
+     */
+    public function testJobCreationWithoutIsTest(): void
+    {
+        $messageData = [
+            'phone' => '79001234567',
+            'message' => 'Тестовое сообщение',
+            'kogort_date' => null,
+            'target_date' => null,
+            'cascade_id' => 'cascade_1'
+        ];
+
+        $job = new SendWhatsappMessageJob([
+            'messageData' => $messageData
+        ]);
+
+        $this->assertNull($job->isTest);
+    }
+
+    /**
+     * Тест проверки интерфейса JobInterface
+     */
+    public function testImplementsJobInterface(): void
+    {
+        $job = new SendWhatsappMessageJob();
+
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    /**
+     * Тест начального состояния rate limiting
+     */
+    public function testInitialRateLimitingState(): void
+    {
+        $this->assertEquals(0, SendWhatsappMessageJob::$messagesSent);
+        $this->assertNull(SendWhatsappMessageJob::$lastResetTime);
+    }
+
+    /**
+     * Тест инкремента счётчика сообщений
+     */
+    public function testMessageCounterIncrement(): void
+    {
+        SendWhatsappMessageJob::$messagesSent = 10;
+        SendWhatsappMessageJob::$messagesSent++;
+
+        $this->assertEquals(11, SendWhatsappMessageJob::$messagesSent);
+    }
+
+    /**
+     * Тест сброса счётчика при превышении интервала
+     */
+    public function testRateLimitingResetAfterInterval(): void
+    {
+        // Симулируем устаревшее время сброса (более 1 секунды назад)
+        SendWhatsappMessageJob::$lastResetTime = microtime(true) - 2;
+        SendWhatsappMessageJob::$messagesSent = 28;
+
+        // Логика сброса должна сбросить счётчик
+        if (!SendWhatsappMessageJob::$lastResetTime ||
+            (microtime(true) - SendWhatsappMessageJob::$lastResetTime) > 1) {
+            SendWhatsappMessageJob::$lastResetTime = microtime(true);
+            SendWhatsappMessageJob::$messagesSent = 0;
+        }
+
+        $this->assertEquals(0, SendWhatsappMessageJob::$messagesSent);
+    }
+
+    /**
+     * Тест что счётчик не сбрасывается в пределах интервала
+     */
+    public function testRateLimitingNoResetWithinInterval(): void
+    {
+        SendWhatsappMessageJob::$lastResetTime = microtime(true);
+        SendWhatsappMessageJob::$messagesSent = 20;
+
+        // Логика сброса не должна сбросить счётчик
+        if (!SendWhatsappMessageJob::$lastResetTime ||
+            (microtime(true) - SendWhatsappMessageJob::$lastResetTime) > 1) {
+            SendWhatsappMessageJob::$lastResetTime = microtime(true);
+            SendWhatsappMessageJob::$messagesSent = 0;
+        }
+
+        $this->assertEquals(20, SendWhatsappMessageJob::$messagesSent);
+    }
+
+    /**
+     * Тест проверки лимита в 30 сообщений в секунду
+     */
+    public function testRateLimitThresholdValue(): void
+    {
+        SendWhatsappMessageJob::$messagesSent = 30;
+
+        // Проверяем что при достижении 30 сообщений сработает delay
+        $this->assertGreaterThanOrEqual(30, SendWhatsappMessageJob::$messagesSent);
+    }
+
+    /**
+     * Тест структуры messageData с обязательными полями
+     */
+    public function testMessageDataStructure(): void
+    {
+        $messageData = [
+            'phone' => '79001234567',
+            'message' => 'Тестовое сообщение',
+            'kogort_date' => '2024-01-15',
+            'target_date' => '2024-01-20',
+            'cascade_id' => 'test_cascade'
+        ];
+
+        $job = new SendWhatsappMessageJob([
+            'messageData' => $messageData
+        ]);
+
+        $this->assertArrayHasKey('phone', $job->messageData);
+        $this->assertArrayHasKey('message', $job->messageData);
+        $this->assertArrayHasKey('kogort_date', $job->messageData);
+        $this->assertArrayHasKey('target_date', $job->messageData);
+        $this->assertArrayHasKey('cascade_id', $job->messageData);
+    }
+
+    /**
+     * Тест с пустым messageData
+     */
+    public function testEmptyMessageData(): void
+    {
+        $job = new SendWhatsappMessageJob([
+            'messageData' => []
+        ]);
+
+        $this->assertIsArray($job->messageData);
+        $this->assertEmpty($job->messageData);
+    }
+
+    /**
+     * Тест с минимальными данными
+     */
+    public function testMinimalMessageData(): void
+    {
+        $messageData = [
+            'phone' => '79001234567'
+        ];
+
+        $job = new SendWhatsappMessageJob([
+            'messageData' => $messageData
+        ]);
+
+        $this->assertEquals('79001234567', $job->messageData['phone']);
+    }
+
+    /**
+     * Тест режима тестирования (isTest = true)
+     */
+    public function testTestModeEnabled(): void
+    {
+        $job = new SendWhatsappMessageJob([
+            'messageData' => ['phone' => '79001234567'],
+            'isTest' => true
+        ]);
+
+        $this->assertTrue($job->isTest);
+    }
+
+    /**
+     * Тест продакшен режима (isTest = false)
+     */
+    public function testProductionModeEnabled(): void
+    {
+        $job = new SendWhatsappMessageJob([
+            'messageData' => ['phone' => '79001234567'],
+            'isTest' => false
+        ]);
+
+        $this->assertFalse($job->isTest);
+    }
+}
diff --git a/erp24/tests/unit/jobs/_bootstrap.php b/erp24/tests/unit/jobs/_bootstrap.php
new file mode 100644 (file)
index 0000000..69bda3c
--- /dev/null
@@ -0,0 +1,8 @@
+<?php
+/**
+ * Bootstrap файл для тестов Jobs
+ *
+ * Инициализирует тестовое окружение для unit-тестов очередей.
+ */
+
+// Здесь можно добавить специфичные для jobs тестов настройки
diff --git a/erp24/tests/unit/mail/MailerConfigTest.php b/erp24/tests/unit/mail/MailerConfigTest.php
new file mode 100644 (file)
index 0000000..e06c47f
--- /dev/null
@@ -0,0 +1,195 @@
+<?php
+
+namespace app\tests\unit\mail;
+
+use Codeception\Test\Unit;
+use Yii;
+
+/**
+ * Unit-тесты для конфигурации Mailer
+ *
+ * Тестирует конфигурацию mailer компонента в тестовом окружении.
+ * Проверяет что в тестах используется файловый транспорт.
+ */
+class MailerConfigTest extends Unit
+{
+    /**
+     * Тест что mailer компонент доступен
+     */
+    public function testMailerComponentExists(): void
+    {
+        $this->assertTrue(Yii::$app->has('mailer'), 'Компонент mailer должен быть зарегистрирован');
+    }
+
+    /**
+     * Тест что используется SymfonyMailer
+     */
+    public function testMailerIsSymfonyMailer(): void
+    {
+        $mailer = Yii::$app->mailer;
+
+        $this->assertInstanceOf(
+            \yii\symfonymailer\Mailer::class,
+            $mailer,
+            'Mailer должен быть экземпляром SymfonyMailer'
+        );
+    }
+
+    /**
+     * Тест что в тестовом окружении используется файловый транспорт
+     */
+    public function testFileTransportEnabledInTests(): void
+    {
+        $mailer = Yii::$app->mailer;
+
+        $this->assertTrue(
+            $mailer->useFileTransport,
+            'В тестовом окружении должен использоваться файловый транспорт'
+        );
+    }
+
+    /**
+     * Тест что viewPath настроен корректно
+     */
+    public function testViewPathConfigured(): void
+    {
+        $mailer = Yii::$app->mailer;
+
+        $viewPath = $mailer->viewPath;
+
+        // viewPath должен указывать на @app/mail
+        $this->assertStringContainsString('mail', $viewPath);
+    }
+
+    /**
+     * Тест создания простого сообщения
+     */
+    public function testCanComposeMessage(): void
+    {
+        $message = Yii::$app->mailer->compose();
+
+        $this->assertInstanceOf(
+            \yii\mail\MessageInterface::class,
+            $message,
+            'compose() должен возвращать MessageInterface'
+        );
+    }
+
+    /**
+     * Тест установки получателя сообщения
+     */
+    public function testCanSetMessageRecipient(): void
+    {
+        $message = Yii::$app->mailer->compose()
+            ->setTo('test@example.com');
+
+        $this->assertInstanceOf(\yii\mail\MessageInterface::class, $message);
+    }
+
+    /**
+     * Тест установки темы сообщения
+     */
+    public function testCanSetMessageSubject(): void
+    {
+        $message = Yii::$app->mailer->compose()
+            ->setSubject('Test Subject');
+
+        $this->assertInstanceOf(\yii\mail\MessageInterface::class, $message);
+    }
+
+    /**
+     * Тест установки текстового содержимого
+     */
+    public function testCanSetMessageTextBody(): void
+    {
+        $message = Yii::$app->mailer->compose()
+            ->setTextBody('Test body content');
+
+        $this->assertInstanceOf(\yii\mail\MessageInterface::class, $message);
+    }
+
+    /**
+     * Тест установки HTML содержимого
+     */
+    public function testCanSetMessageHtmlBody(): void
+    {
+        $message = Yii::$app->mailer->compose()
+            ->setHtmlBody('<p>Test HTML body</p>');
+
+        $this->assertInstanceOf(\yii\mail\MessageInterface::class, $message);
+    }
+
+    /**
+     * Тест установки отправителя
+     */
+    public function testCanSetMessageFrom(): void
+    {
+        $message = Yii::$app->mailer->compose()
+            ->setFrom('sender@example.com');
+
+        $this->assertInstanceOf(\yii\mail\MessageInterface::class, $message);
+    }
+
+    /**
+     * Тест полного сообщения
+     */
+    public function testCanComposeFullMessage(): void
+    {
+        $message = Yii::$app->mailer->compose()
+            ->setFrom('noreply@example.com')
+            ->setTo('user@example.com')
+            ->setSubject('Test Email')
+            ->setTextBody('This is a test email.');
+
+        $this->assertInstanceOf(\yii\mail\MessageInterface::class, $message);
+    }
+
+    /**
+     * Тест отправки сообщения в файл (не реальная отправка)
+     */
+    public function testSendMessageToFile(): void
+    {
+        // В тестовом окружении useFileTransport = true,
+        // поэтому send() сохранит письмо в файл, а не отправит по SMTP
+        $result = Yii::$app->mailer->compose()
+            ->setFrom('noreply@test.local')
+            ->setTo('user@test.local')
+            ->setSubject('Unit Test Email')
+            ->setTextBody('This email was sent during unit tests.')
+            ->send();
+
+        // В файловом режиме send() должен вернуть true
+        $this->assertTrue($result, 'Сохранение письма в файл должно быть успешным');
+    }
+
+    /**
+     * Тест compose с шаблоном view
+     */
+    public function testComposeWithView(): void
+    {
+        // Проверяем что метод compose работает с указанием view
+        // Если layout существует, compose не должен выбрасывать исключение
+        try {
+            // Используем существующий layout
+            $message = Yii::$app->mailer->compose([
+                'html' => '/layouts/html',
+                'text' => '/layouts/text'
+            ]);
+            $this->assertInstanceOf(\yii\mail\MessageInterface::class, $message);
+        } catch (\yii\base\InvalidArgumentException $e) {
+            // Если view не найден - это допустимо для unit теста
+            $this->markTestSkipped('Mail templates not found');
+        }
+    }
+
+    /**
+     * Тест что mailer имеет класс сообщения
+     */
+    public function testMailerHasMessageClass(): void
+    {
+        $mailer = Yii::$app->mailer;
+
+        // SymfonyMailer должен иметь messageClass
+        $this->assertNotEmpty($mailer->messageClass ?? 'yii\symfonymailer\Message');
+    }
+}