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

Test HTML body

'); + + $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'); + } +}