--- /dev/null
+# ============================================================================
+# 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}
--- /dev/null
+# ============================================================================
+# 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
--- /dev/null
+# Реестр внешних интеграций 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)
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+{
+ "_comment": "AMO CRM OAuth2 ошибка авторизации (неверные credentials)",
+ "status": 401,
+ "title": "Unauthorized",
+ "detail": "The client credentials are invalid",
+ "type": "https://httpstatuses.io/401"
+}
--- /dev/null
+{
+ "_comment": "AMO CRM OAuth2 успешный ответ получения токена",
+ "token_type": "Bearer",
+ "expires_in": 86400,
+ "access_token": "test_access_token_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ "refresh_token": "test_refresh_token_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
+}
--- /dev/null
+{
+ "_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"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
--- /dev/null
+{
+ "_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"
+ }
+}
--- /dev/null
+{
+ "_comment": "CloudPayments ошибка авторизации (неверный API ключ)",
+ "Success": false,
+ "Message": "Invalid API Key"
+}
--- /dev/null
+{
+ "_comment": "CloudPayments ошибка валидации параметров",
+ "Success": false,
+ "Message": "Invalid request parameters",
+ "Model": {
+ "Date": "Date is required"
+ }
+}
--- /dev/null
+{
+ "_comment": "CloudPayments пустой список платежей",
+ "Success": true,
+ "Model": []
+}
--- /dev/null
+{
+ "_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"
+ }
+ ]
+}
--- /dev/null
+{
+ "_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"
+ }
+}
--- /dev/null
+{
+ "_comment": "LPTracker ошибка авторизации",
+ "status": "error",
+ "error": {
+ "code": 401,
+ "message": "Invalid credentials"
+ }
+}
--- /dev/null
+{
+ "_comment": "LPTracker успешная авторизация",
+ "status": "success",
+ "result": {
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test_token_payload.signature",
+ "expires_at": "2025-01-22T23:59:59+03:00"
+ }
+}
--- /dev/null
+{
+ "_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
+ }
+}
--- /dev/null
+{
+ "_comment": "LPTracker успешное обновление лида",
+ "status": "success",
+ "result": {
+ "id": 12345,
+ "status": "TO_CALL",
+ "funnel_id": 2140957,
+ "updated_at": "2025-01-21 16:00:00"
+ }
+}
--- /dev/null
+{
+ "_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
+ }
+}
--- /dev/null
+{
+ "_comment": "Telegram Bot API превышен лимит запросов",
+ "ok": false,
+ "error_code": 429,
+ "description": "Too Many Requests: retry after 60",
+ "parameters": {
+ "retry_after": 60
+ }
+}
--- /dev/null
+{
+ "_comment": "Telegram Bot API ошибка отправки сообщения",
+ "ok": false,
+ "error_code": 400,
+ "description": "Bad Request: chat not found"
+}
--- /dev/null
+{
+ "_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"
+ }
+}
--- /dev/null
+{
+ "_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"
+ }
+ ]
+}
--- /dev/null
+{
+ "_comment": "EDNA WhatsApp список каналов",
+ "data": [
+ {
+ "id": 11374,
+ "name": "WABA",
+ "type": "WHATSAPP",
+ "status": "ACTIVE",
+ "subjectId": 11374,
+ "createdAt": "2024-01-01T00:00:00Z"
+ }
+ ]
+}
--- /dev/null
+{
+ "_comment": "EDNA WhatsApp ошибка авторизации",
+ "title": "auth-error",
+ "detail": "Ошибка авторизации. Проверьте правильность написания и срок действия ключа API.",
+ "status": 401
+}
--- /dev/null
+{
+ "_comment": "EDNA WhatsApp каскад не найден",
+ "title": "cascade-not-found",
+ "detail": "Указан неверный идентификатор каскада. Проверьте корректность указанного вами идентификатора.",
+ "status": 400
+}
--- /dev/null
+{
+ "_comment": "EDNA WhatsApp недостаточно средств",
+ "title": "out-of-balance",
+ "detail": "Недостаточно средств на балансе.",
+ "status": 400
+}
--- /dev/null
+{
+ "_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
+}
--- /dev/null
+{
+ "_comment": "EDNA WhatsApp успешная отправка сообщения",
+ "id": "msg-12345-67890",
+ "status": "SENT",
+ "requestId": "req-uuid-12345",
+ "cascadeId": 5686,
+ "createdAt": "2025-01-21T15:00:00Z"
+}
--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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,
+ ]);
+ }
+}
--- /dev/null
+<?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';
+ }
+}
--- /dev/null
+# 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
--- /dev/null
+<?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');
+ }
+}
--- /dev/null
+<?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) . "]"
+ );
+ }
+ }
+}
--- /dev/null
+<?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'
+ ]);
+ }
+}
--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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']
+ ]);
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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();
+ // Успешный ответ или ошибка валидации
+ }
+}
--- /dev/null
+<?php
+
+/**
+ * API Test Suite Bootstrap
+ */
+
+// Здесь можно инициализировать тестовое окружение для API тестов
--- /dev/null
+<?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');
+ }
+}
--- /dev/null
+<?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']);
+ }
+}
--- /dev/null
+<?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']);
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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']);
+ }
+}
--- /dev/null
+<?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']));
+ }
+ }
+}
--- /dev/null
+<?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']));
+ }
+}
--- /dev/null
+<?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]);
+ }
+}
--- /dev/null
+<?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 не должен быть слишком большим');
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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']);
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?php
+/**
+ * Bootstrap файл для тестов Jobs
+ *
+ * Инициализирует тестовое окружение для unit-тестов очередей.
+ */
+
+// Здесь можно добавить специфичные для jobs тестов настройки
--- /dev/null
+<?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');
+ }
+}