* Три типа проблем:
* 1. "Завис в доставке" - РМК="Передан курьеру", МП≠"Выполнен"
* 2. "Успех без чека" - МП="Выполнен", РМК≠"6. Успех"
- * 3. "Отмена без обработки" - МП="Отменён", РМК≠"Отказ"
+ * 3. "Отмена без обработки" - МП="Отменён", РМК="Отказ", cancelled_order_sent=0 (отмена не отправлена)
*
* Запускается по расписанию 08:00 и 20:00 MSK.
*/
/**
* Максимальное количество попыток отправки
*/
- public const MAX_RETRIES = 3;
+ public const MAX_RETRIES = 5;
/**
- * Задержка между попытками в секундах
+ * Задержка между попытками в секундах (используется если retry_after не указан)
*/
- public const RETRY_DELAY_SECONDS = 5;
+ public const RETRY_DELAY_SECONDS = 10;
/**
* Максимальная длина сообщения Telegram
// Получаем ID статусов "Передан курьеру" из БД
$rmkCourierIds = $this->getRmkStatusCourier();
+
+ $this->logInfo('ID статусов "Передан курьеру"', [
+ 'rmk_courier_ids' => $rmkCourierIds,
+ 'count' => count($rmkCourierIds),
+ ]);
// Формируем плейсхолдеры для IN-условия
$rmkCourierPlaceholders = [];
$rmkCourierInClause = !empty($rmkCourierPlaceholders)
? implode(', ', $rmkCourierPlaceholders)
: '0'; // fallback если статусов нет
+
+ if (empty($rmkCourierIds)) {
+ $this->logWarning('Не найдено статусов "Передан курьеру" в БД. Проверьте наличие статусов DELIVERY/COURIER_RECEIVED и связей в marketplace_order_1c_statuses');
+ }
// Выбираем заказы с РМК-статусом "Передан курьеру", где МП-статус НЕ "Выполнен"
$sql = "
mocs.status as rmk_status,
most.code as mp_status_code,
mosub.code as mp_substatus_code,
- COALESCE(most.name, mosub.name) as mp_status_name
+ COALESCE(most.name, mosub.name) as mp_status_name,
+ 'no_mp_success' as issue_reason
FROM marketplace_orders mo
LEFT JOIN city_store cs ON cs.id = mo.store_id
LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer
LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id
LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id
WHERE mo.fake = 0
+ AND mo.status_processing_1c IS NOT NULL
AND mo.status_processing_1c::integer IN ({$rmkCourierInClause})
AND mo.updated_at >= :start_date
AND mo.updated_at <= :end_date
AND (
most.code IS NULL
- OR (most.code != :delivered AND mosub.code IS DISTINCT FROM :delivery_service_delivered)
+ OR (
+ most.code != :delivered
+ AND (mosub.code IS NULL OR mosub.code != :delivery_service_delivered)
+ )
)
ORDER BY cs.name ASC, mo.creation_date DESC
";
], $rmkCourierParams);
$orders = Yii::$app->db->createCommand($sql, $params)->queryAll();
+
+ $this->logInfo('SQL запрос для кандидатов "Завис в доставке"', [
+ 'date_range' => ['start' => $startDateStr, 'end' => $endDateStr],
+ 'rmk_courier_ids' => $rmkCourierIds,
+ 'found_orders' => count($orders),
+ ]);
$issues = [];
foreach ($orders as $orderData) {
$issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_HUNG_IN_DELIVERY, $orderData, $this->testMode);
}
- $this->logInfo('Найдено кандидатов "Завис в доставке"', ['count' => count($issues)]);
+ $this->logInfo('Найдено кандидатов "Завис в доставке"', [
+ 'count' => count($issues),
+ 'order_ids' => array_map(fn($issue) => $issue->orderNumber, $issues),
+ ]);
return $issues;
}
$prevInterval = '08:00';
}
+ // Получаем ID статусов "Передан курьеру" для проверки
+ $rmkCourierIds = $this->getRmkStatusCourier();
+
// Получаем предыдущие записи кандидатов со статусом "Передан курьеру"
+ // rmk_status_id хранится как строка, поэтому конвертируем ID в строки для сравнения
+ $rmkCourierIdsAsStrings = array_map('strval', $rmkCourierIds);
+
$previousRecords = MarketplaceOrderDailyIssues::find()
->where([
'problem_type' => OrderIssue::TYPE_HUNG_IN_DELIVERY,
'report_date' => $prevDate,
'interval' => $prevInterval,
])
- ->andWhere(['rmk_status_id' => $this->getRmkStatusCourier()])
+ ->andWhere(['in', 'rmk_status_id', $rmkCourierIdsAsStrings])
->indexBy('order_id')
->asArray()
->all();
'count' => count($previousRecords),
'prev_date' => $prevDate,
'prev_interval' => $prevInterval,
+ 'rmk_courier_ids' => $rmkCourierIds,
+ 'rmk_courier_ids_as_strings' => $rmkCourierIdsAsStrings,
+ 'previous_order_ids' => array_keys($previousRecords),
]);
// Фильтруем: оставляем только те заказы, которые были в предыдущей проверке
$confirmedIssues = [];
+ $notFoundCandidates = [];
foreach ($candidates as $candidate) {
if (isset($previousRecords[$candidate->orderId])) {
// Заказ был кандидатом в предыдущей проверке с тем же статусом → подтверждённая проблема
$confirmedIssues[] = $candidate;
+ } else {
+ $notFoundCandidates[] = [
+ 'order_id' => $candidate->orderId,
+ 'order_number' => $candidate->orderNumber,
+ 'rmk_status_id' => $candidate->rmkStatusId,
+ ];
}
}
+
+ if (!empty($notFoundCandidates)) {
+ $this->logInfo('Кандидаты, не найденные в предыдущей проверке', [
+ 'count' => count($notFoundCandidates),
+ 'candidates' => $notFoundCandidates,
+ ]);
+ }
$this->logInfo('Подтверждено проблем "Завис в доставке"', [
'candidates' => count($candidates),
* Получает заказы типа "Отмена без обработки"
*
* Критерий: МП статус = "Отменён" (CANCELLED)
- * + РМК статус НЕ "Отказ" (cancelled_order = 1 в marketplace_order_1c_statuses)
+ * + РМК статус = "Отказ" (cancelled_order = 1 в marketplace_order_1c_statuses)
+ * + cancelled_order_sent = 0 (отмена не отправлена в маркетплейс)
+ *
+ * Проблема возникает когда заказ отменён в маркетплейсе, в РМК статус проставлен как "Отказ",
+ * но отмена не отправлена обратно в маркетплейс.
*
* @param int $hoursAgo Период выборки в часах (по умолчанию 24)
* @return OrderIssue[]
// Получаем ID статусов "Отказ" из БД
$rmkCancelIds = $this->getRmkStatusCancel();
- // Формируем плейсхолдеры для NOT IN-условия
+ // Формируем плейсхолдеры для IN-условия
$rmkCancelPlaceholders = [];
$rmkCancelParams = [];
foreach ($rmkCancelIds as $index => $id) {
? implode(', ', $rmkCancelPlaceholders)
: '0'; // fallback если статусов нет
- // Выбираем заказы с МП-статусом "Отменён", где РМК-статус НЕ "Отказ"
+ // Выбираем заказы с МП-статусом "Отменён", где РМК-статус = "Отказ", но отмена не отправлена
$sql = "
SELECT
mo.id,
mocs.status as rmk_status,
most.code as mp_status_code,
mosub.code as mp_substatus_code,
- COALESCE(most.name, mosub.name) as mp_status_name
+ COALESCE(most.name, mosub.name) as mp_status_name,
+ 'cancel_not_sent' as issue_reason
FROM marketplace_orders mo
LEFT JOIN city_store cs ON cs.id = mo.store_id
LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer
AND mo.updated_at >= :start_date
AND mo.updated_at <= :end_date
AND most.code = :cancelled
- AND (
- mo.status_processing_1c IS NULL
- OR mo.status_processing_1c::integer NOT IN ({$rmkCancelInClause})
- )
+ AND mo.status_processing_1c IS NOT NULL
+ AND mo.status_processing_1c::integer IN ({$rmkCancelInClause})
+ AND COALESCE(mo.cancelled_order_sent, 0) = 0
ORDER BY cs.name ASC, mo.creation_date DESC
";
{
$lines = [];
$intervalWithShift = $this->formatIntervalWithShiftName($result->interval);
- $lines[] = '*\[Контроль MP\]* Отчёт за ' . $this->escapeMarkdownV2($result->reportDate) . ' ' . $this->escapeMarkdownV2($intervalWithShift);
+ // Используем дату начала смены (startDate), а не текущее время (reportDate)
+ $shiftStartDate = $result->startDate
+ ? date('d.m.Y', strtotime($result->startDate))
+ : $result->reportDate;
+ $lines[] = '*\[Контроль MP\]* Отчёт за ' . $this->escapeMarkdownV2($shiftStartDate) . ' ' . $this->escapeMarkdownV2($intervalWithShift);
$lines[] = '';
// Секция "Завис в доставке"
}
/**
- * ФоÑ\80маÑ\82иÑ\80Ñ\83еÑ\82 Ñ\82аблиÑ\86Ñ\83 пÑ\80облем длÑ\8f Telegram (моноÑ\88иÑ\80иннÑ\8bй блок)
+ * ФоÑ\80маÑ\82иÑ\80Ñ\83еÑ\82 Ñ\81пиÑ\81ок пÑ\80облем длÑ\8f Telegram (компакÑ\82нÑ\8bй Ñ\84оÑ\80маÑ\82)
*
- * Формат: | Дата | Интервал | Заказ | РМК | МП | Причина
+ * Формат:
+ * 📦 {номер заказа} ({дата создания})
+ * РМК: {статус} | МП: {статус}
+ * ⚠️ {причина}
*
* @param OrderIssue[] $issues
* @return string
private function formatIssuesTable(array $issues): string
{
$rows = [];
- $rows[] = '```';
- $rows[] = '| Дата | Интервал | Заказ | РМК | МП | Причина';
foreach ($issues as $issue) {
$rows[] = $this->formatIssueRow($issue);
}
- $rows[] = '```';
-
- return implode("\n", $rows);
+ return implode("\n\n", $rows);
}
/**
- * Форматирует строку таблицы для проблемы
- *
- * Формат: | Дата | Интервал | Заказ | РМК | МП | Причина
+ * Форматирует блок для одной проблемы (компактный список)
*
* @param OrderIssue $issue
* @return string
*/
private function formatIssueRow(OrderIssue $issue): string
{
- $date = $issue->reportDate ?: date('d.m.Y');
- $interval = $this->getShortInterval($issue->interval);
$rmk = $issue->rmkStatus ?? '-';
$mp = $this->formatMpStatus($issue);
$reason = $issue->getIssueReasonLabel() ?: '-';
+ $creationDate = $issue->creationDate
+ ? date('d.m.Y H:i', strtotime($issue->creationDate))
+ : '-';
- return sprintf(
- '| %s | %s | %s | %s | %s | %s',
- $date,
- $interval,
- $issue->orderNumber,
- $rmk,
- $mp,
- $reason
- );
+ $lines = [];
+ // Emoji не экранируем, они работают в MarkdownV2 как есть
+ $lines[] = '📦 ' . $this->escapeMarkdownV2("{$issue->orderNumber} ({$creationDate})");
+ $lines[] = $this->escapeMarkdownV2(" РМК: {$rmk} | МП: {$mp}");
+ $lines[] = '⚠️ ' . $this->escapeMarkdownV2($reason);
+
+ return implode("\n", $lines);
}
/**
*/
public function formatEmailControlReport(ControlReportResult $result): string
{
+ // Используем дату начала смены (startDate), а не текущее время (reportDate)
+ $shiftStartDate = $result->startDate
+ ? date('d.m.Y', strtotime($result->startDate))
+ : $result->reportDate;
+ $intervalWithShift = $this->formatIntervalWithShiftName($result->interval);
+
$html = '<!DOCTYPE html>
<html>
<head>
</style>
</head>
<body>
- <h2>[Контроль MP] Отчёт за ' . $this->escapeHtml($result->reportDate) . ' ' . $this->escapeHtml($this->formatIntervalWithShiftName($result->interval)) . '</h2>';
+ <h2>[Контроль MP] Отчёт за ' . $this->escapeHtml($shiftStartDate) . ' ' . $this->escapeHtml($intervalWithShift) . '</h2>';
// Общая таблица со всеми проблемами, сортировка по типу
$allIssues = [];
foreach ($chunks as $index => $chunk) {
$sent = false;
$maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;
- $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
+ $defaultDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
- $sent = $this->sendTelegramMessage($chatId, $chunk);
- if ($sent) {
+ $result = $this->sendTelegramMessage($chatId, $chunk);
+ if ($result['success']) {
+ $sent = true;
break;
}
+
+ // Используем retry_after из ответа Telegram, если есть
+ $retryDelay = $result['retry_after'] ?? $defaultDelay;
+ $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: rate limit, ждём {$retryDelay} сек");
} catch (\Exception $e) {
+ $retryDelay = $defaultDelay;
$this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}");
}
$allSent = false;
$this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток");
}
+
+ // Небольшая пауза между частями сообщения, чтобы не превысить лимит
+ if ($sent && $index < count($chunks) - 1) {
+ sleep(1);
+ }
}
return $allSent;
*
* @param string $chatId ID чата/канала
* @param string $message Текст сообщения
- * @return bool Успешность
+ * @return array{success: bool, retry_after: int|null} Результат отправки
*/
- private function sendTelegramMessage(string $chatId, string $message): bool
+ private function sendTelegramMessage(string $chatId, string $message): array
{
$botToken = $this->getTelegramBotToken();
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
if ($curlError) {
$this->logError("Telegram cURL error: {$curlError}");
- return false;
+ return ['success' => false, 'retry_after' => null];
}
if ($httpCode !== 200) {
$this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}");
- return false;
+
+ // Парсим retry_after из ответа при ошибке 429 (Too Many Requests)
+ $retryAfter = null;
+ if ($httpCode === 429) {
+ $data = json_decode($response, true);
+ $retryAfter = $data['parameters']['retry_after'] ?? null;
+ }
+
+ return ['success' => false, 'retry_after' => $retryAfter];
}
$this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]);
- return true;
+ return ['success' => true, 'retry_after' => null];
}
/**
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\commands;
+
+use Codeception\Test\Unit;
+use yii\console\ExitCode;
+use yii_app\commands\MarketplaceController;
+use yii_app\services\dto\ControlReportResult;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Unit-тесты для MarketplaceController::actionSendOrderControlReport
+ *
+ * Покрывает:
+ * - Логику обработки результатов отчёта
+ * - Возврат корректных exit codes
+ * - Обработку различных сценариев (успех, частичный успех, ошибка)
+ * - Тестовый режим (--test)
+ *
+ * Примечание: для полного функционального тестирования команды с реальной БД
+ * рекомендуется создать функциональные тесты в tests/functional/
+ *
+ * @covers \yii_app\commands\MarketplaceController::actionSendOrderControlReport
+ */
+class MarketplaceControllerTest extends Unit
+{
+ /**
+ * Тест: проверка логики exit code для успешного отчёта без проблем
+ */
+ public function testExitCodeForSuccessWithoutIssues(): void
+ {
+ $result = new ControlReportResult();
+ $result->totalIssues = 0;
+ $result->telegramSent = true;
+ $result->emailSent = true;
+
+ $exitCode = $result->getExitCode();
+
+ $this->assertSame(ExitCode::OK, $exitCode);
+ }
+
+ /**
+ * Тест: проверка логики exit code для успешного отчёта с проблемами
+ */
+ public function testExitCodeForSuccessWithIssues(): void
+ {
+ $result = new ControlReportResult();
+
+ $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+ $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+
+ $result->hungInDelivery = [$issue1];
+ $result->successNoCheck = [$issue2];
+ $result->calculateTotal();
+ $result->telegramSent = true;
+ $result->emailSent = true;
+
+ $exitCode = $result->getExitCode();
+
+ $this->assertSame(ExitCode::OK, $exitCode);
+ }
+
+ /**
+ * Тест: проверка логики exit code для частичного успеха
+ */
+ public function testExitCodeForPartialSuccess(): void
+ {
+ $result = new ControlReportResult();
+
+ $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+ $result->hungInDelivery = [$issue];
+ $result->calculateTotal();
+ $result->telegramSent = true;
+ $result->emailSent = false;
+ $result->emailError = 'SMTP connection failed';
+
+ $exitCode = $result->getExitCode();
+
+ $this->assertSame(2, $exitCode); // ExitCode для частичного успеха
+ }
+
+ /**
+ * Тест: проверка логики exit code для полной ошибки
+ */
+ public function testExitCodeForFailure(): void
+ {
+ $result = new ControlReportResult();
+
+ $issue = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 300, 'FW-300');
+ $result->cancelNoProcess = [$issue];
+ $result->calculateTotal();
+ $result->telegramSent = false;
+ $result->telegramError = 'Telegram API error';
+ $result->emailSent = false;
+ $result->emailError = 'SMTP error';
+
+ $exitCode = $result->getExitCode();
+
+ $this->assertSame(1, $exitCode); // ExitCode для критической ошибки
+ }
+
+ /**
+ * Тест: проверка обработки всех типов проблем
+ */
+ public function testHandlesAllIssueTypes(): void
+ {
+ $result = new ControlReportResult();
+
+ $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+ $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+ $issue3 = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 300, 'FW-300');
+
+ $result->hungInDelivery = [$issue1];
+ $result->successNoCheck = [$issue2];
+ $result->cancelNoProcess = [$issue3];
+ $result->calculateTotal();
+ $result->telegramSent = true;
+ $result->emailSent = true;
+
+ $this->assertSame(3, $result->totalIssues);
+ $this->assertSame(1, $result->getHungInDeliveryCount());
+ $this->assertSame(1, $result->getSuccessNoCheckCount());
+ $this->assertSame(1, $result->getCancelNoProcessCount());
+ $this->assertSame(ExitCode::OK, $result->getExitCode());
+ }
+
+ /**
+ * Тест: проверка структуры метода actionSendOrderControlReport
+ *
+ * Проверяет, что метод существует и имеет правильную сигнатуру
+ */
+ public function testActionSendOrderControlReportMethodExists(): void
+ {
+ $controller = new MarketplaceController('marketplace', \Yii::$app);
+
+ $this->assertTrue(
+ method_exists($controller, 'actionSendOrderControlReport'),
+ 'Метод actionSendOrderControlReport должен существовать'
+ );
+
+ $reflection = new \ReflectionMethod($controller, 'actionSendOrderControlReport');
+
+ $this->assertSame('int', $reflection->getReturnType()->getName());
+
+ $parameters = $reflection->getParameters();
+ $this->assertCount(2, $parameters);
+ $this->assertSame('hours', $parameters[0]->getName());
+ $this->assertSame('onlyNew', $parameters[1]->getName());
+ $this->assertSame(12, $parameters[0]->getDefaultValue());
+ $this->assertTrue($parameters[1]->getDefaultValue());
+ }
+
+ /**
+ * Тест: проверка наличия свойства test в контроллере
+ */
+ public function testControllerHasTestProperty(): void
+ {
+ $controller = new MarketplaceController('marketplace', \Yii::$app);
+
+ $this->assertTrue(
+ property_exists($controller, 'test'),
+ 'Контроллер должен иметь свойство test для тестового режима'
+ );
+
+ // Проверяем, что свойство можно установить
+ $controller->test = true;
+ $this->assertTrue($controller->test);
+
+ $controller->test = false;
+ $this->assertFalse($controller->test);
+ }
+
+ /**
+ * Тест: проверка наличия метода options для регистрации опции test
+ */
+ public function testOptionsMethodIncludesTest(): void
+ {
+ $controller = new MarketplaceController('marketplace', \Yii::$app);
+
+ $this->assertTrue(
+ method_exists($controller, 'options'),
+ 'Контроллер должен иметь метод options'
+ );
+
+ $options = $controller->options('send-order-control-report');
+
+ $this->assertIsArray($options);
+ $this->assertContains('test', $options, 'Опция test должна быть зарегистрирована');
+ }
+
+ /**
+ * Тест: проверка логики hasIssues для различных сценариев
+ */
+ public function testHasIssuesLogic(): void
+ {
+ // Без проблем
+ $result = new ControlReportResult();
+ $this->assertFalse($result->hasIssues());
+
+ // С проблемами в hungInDelivery
+ $result->hungInDelivery = [
+ new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100')
+ ];
+ $this->assertTrue($result->hasIssues());
+
+ // С проблемами в successNoCheck
+ $result = new ControlReportResult();
+ $result->successNoCheck = [
+ new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200')
+ ];
+ $this->assertTrue($result->hasIssues());
+
+ // С проблемами в cancelNoProcess
+ $result = new ControlReportResult();
+ $result->cancelNoProcess = [
+ new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 300, 'FW-300')
+ ];
+ $this->assertTrue($result->hasIssues());
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii_app\services\OrderControlReportService;
+use yii_app\services\TelegramService;
+use yii_app\services\dto\ControlReportResult;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Unit-тесты для проверки отправки уведомлений в OrderControlReportService
+ *
+ * Покрывает:
+ * - Отправку в Telegram (бот, канал/чат)
+ * - Отправку на Email (отправитель, получатели)
+ * - Форматирование сообщений
+ * - Логику выбора бота/канала в зависимости от окружения
+ *
+ * @covers \yii_app\services\OrderControlReportService::sendToTelegram
+ * @covers \yii_app\services\OrderControlReportService::sendToEmail
+ * @covers \yii_app\services\OrderControlReportService::formatTelegramControlReport
+ * @covers \yii_app\services\OrderControlReportService::formatEmailControlReport
+ */
+class OrderControlReportServiceNotificationTest extends Unit
+{
+ private OrderControlReportService $service;
+
+ protected function _before(): void
+ {
+ parent::_before();
+ $this->service = new OrderControlReportService();
+ }
+
+ /**
+ * Тест: formatTelegramControlReport формирует сообщение с правильной структурой
+ */
+ public function testFormatTelegramControlReportStructure(): void
+ {
+ $result = new ControlReportResult();
+ $result->reportDate = '20.01.2026 08:00';
+ $result->startDate = '2026-01-19 20:00:00';
+ $result->endDate = '2026-01-20 08:00:00';
+ $result->shiftName = 'Ночная смена';
+
+ $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+ $issue1->rmkStatus = 'Передан курьеру';
+ $issue1->mpStatus = 'В доставке';
+ $issue1->issueReason = null;
+ $issue1->creationDate = '2026-01-19 10:00:00';
+ $issue1->marketplaceName = 'Flowwow';
+ $issue1->storeName = 'Магазин Центр';
+
+ $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+ $issue2->rmkStatus = 'Новый';
+ $issue2->mpStatus = 'Доставлен';
+ $issue2->issueReason = null;
+ $issue2->creationDate = '2026-01-19 11:00:00';
+ $issue2->marketplaceName = 'Yandex Market';
+
+ $result->hungInDelivery = [$issue1];
+ $result->successNoCheck = [$issue2];
+ $result->calculateTotal();
+
+ $message = $this->service->formatTelegramControlReport($result);
+
+ // Проверяем наличие основных секций
+ $this->assertStringContainsString('Контроль MP', $message);
+ $this->assertStringContainsString('Завис в доставке', $message);
+ $this->assertStringContainsString('Успех без чека', $message);
+ // В MarkdownV2 дефисы экранируются, поэтому проверяем экранированную версию
+ $this->assertStringContainsString('FW\-100', $message);
+ $this->assertStringContainsString('YM\-200', $message);
+ $this->assertStringContainsString('Всего:', $message);
+ }
+
+ /**
+ * Тест: formatEmailControlReport формирует HTML с правильной структурой
+ */
+ public function testFormatEmailControlReportStructure(): void
+ {
+ $result = new ControlReportResult();
+ $result->reportDate = '20.01.2026 08:00';
+ $result->startDate = '2026-01-19 20:00:00';
+ $result->endDate = '2026-01-20 08:00:00';
+ $result->shiftName = 'Ночная смена';
+
+ $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+ $issue->rmkStatus = 'Передан курьеру';
+ $issue->mpStatus = 'В доставке';
+ $issue->issueReason = null;
+ $issue->creationDate = '2026-01-19 10:00:00';
+ $issue->marketplaceName = 'Flowwow';
+
+ $result->hungInDelivery = [$issue];
+ $result->calculateTotal();
+
+ $html = $this->service->formatEmailControlReport($result);
+
+ // Проверяем HTML-структуру
+ $this->assertStringContainsString('<!DOCTYPE html>', $html);
+ $this->assertStringContainsString('<html>', $html);
+ $this->assertStringContainsString('<head>', $html);
+ $this->assertStringContainsString('<body>', $html);
+ $this->assertStringContainsString('<table>', $html);
+ $this->assertStringContainsString('FW-100', $html);
+ $this->assertStringContainsString('Завис в доставке', $html);
+ }
+
+ /**
+ * Тест: проверка структуры метода sendToTelegram
+ *
+ * Проверяет, что метод существует и имеет правильную сигнатуру
+ */
+ public function testSendToTelegramMethodExists(): void
+ {
+ $reflection = new \ReflectionClass($this->service);
+ $this->assertTrue(
+ $reflection->hasMethod('sendToTelegram'),
+ 'Метод sendToTelegram должен существовать'
+ );
+
+ $method = $reflection->getMethod('sendToTelegram');
+ $this->assertTrue($method->isPublic(), 'Метод sendToTelegram должен быть публичным');
+ $this->assertSame('bool', $method->getReturnType()->getName());
+
+ $parameters = $method->getParameters();
+ $this->assertCount(1, $parameters);
+ $this->assertSame('message', $parameters[0]->getName());
+ $this->assertSame('string', $parameters[0]->getType()->getName());
+ }
+
+ /**
+ * Тест: проверка структуры метода sendToEmail
+ *
+ * Проверяет, что метод существует и имеет правильную сигнатуру
+ */
+ public function testSendToEmailMethodExists(): void
+ {
+ $reflection = new \ReflectionClass($this->service);
+ $this->assertTrue(
+ $reflection->hasMethod('sendToEmail'),
+ 'Метод sendToEmail должен существовать'
+ );
+
+ $method = $reflection->getMethod('sendToEmail');
+ $this->assertTrue($method->isPublic(), 'Метод sendToEmail должен быть публичным');
+ $this->assertSame('bool', $method->getReturnType()->getName());
+
+ $parameters = $method->getParameters();
+ $this->assertCount(1, $parameters);
+ $this->assertSame('html', $parameters[0]->getName());
+ $this->assertSame('string', $parameters[0]->getType()->getName());
+ }
+
+ /**
+ * Тест: проверка наличия приватных методов для получения конфигурации
+ */
+ public function testPrivateConfigurationMethodsExist(): void
+ {
+ $reflection = new \ReflectionClass($this->service);
+
+ // Проверяем наличие методов получения конфигурации
+ $this->assertTrue(
+ $reflection->hasMethod('getTelegramBotToken'),
+ 'Метод getTelegramBotToken должен существовать'
+ );
+
+ $this->assertTrue(
+ $reflection->hasMethod('getTelegramChatId'),
+ 'Метод getTelegramChatId должен существовать'
+ );
+
+ $this->assertTrue(
+ $reflection->hasMethod('getEmailRecipients'),
+ 'Метод getEmailRecipients должен существовать'
+ );
+ }
+
+ /**
+ * Тест: проверка логики выбора бота в зависимости от окружения
+ *
+ * Проверяет, что используются правильные константы для dev и prod
+ */
+ public function testTelegramBotTokenSelectionLogic(): void
+ {
+ $reflection = new \ReflectionClass($this->service);
+
+ // Проверяем наличие констант для токенов ботов
+ $this->assertTrue(
+ $reflection->hasConstant('TELEGRAM_BOT_DEV'),
+ 'Константа TELEGRAM_BOT_DEV должна существовать'
+ );
+
+ $this->assertTrue(
+ $reflection->hasConstant('TELEGRAM_BOT_PROD'),
+ 'Константа TELEGRAM_BOT_PROD должна существовать'
+ );
+
+ $devToken = $reflection->getConstant('TELEGRAM_BOT_DEV');
+ $prodToken = $reflection->getConstant('TELEGRAM_BOT_PROD');
+
+ $this->assertIsString($devToken);
+ $this->assertIsString($prodToken);
+ $this->assertNotEmpty($devToken);
+ $this->assertNotEmpty($prodToken);
+ $this->assertNotSame($devToken, $prodToken, 'Dev и Prod токены должны отличаться');
+ }
+
+ /**
+ * Тест: проверка структуры сообщения Telegram
+ *
+ * Проверяет, что сообщение содержит все необходимые элементы
+ */
+ public function testTelegramMessageContainsAllSections(): void
+ {
+ $result = new ControlReportResult();
+ $result->reportDate = '20.01.2026 08:00';
+ $result->startDate = '2026-01-19 20:00:00';
+ $result->endDate = '2026-01-20 08:00:00';
+ $result->shiftName = 'Ночная смена';
+
+ // Добавляем все типы проблем
+ $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+ $issue1->mpStatus = 'В доставке';
+ $issue1->issueReason = null;
+ $issue1->creationDate = '2026-01-19 10:00:00';
+
+ $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+ $issue2->mpStatus = 'Доставлен';
+ $issue2->issueReason = null;
+ $issue2->creationDate = '2026-01-19 11:00:00';
+
+ $issue3 = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 300, 'FW-300');
+ $issue3->mpStatus = 'Отменён';
+ $issue3->issueReason = null;
+ $issue3->creationDate = '2026-01-19 12:00:00';
+
+ $result->hungInDelivery = [$issue1];
+ $result->successNoCheck = [$issue2];
+ $result->cancelNoProcess = [$issue3];
+ $result->calculateTotal();
+
+ $message = $this->service->formatTelegramControlReport($result);
+
+ // Проверяем наличие всех секций
+ $this->assertStringContainsString('Завис в доставке', $message);
+ $this->assertStringContainsString('Успех без чека', $message);
+ $this->assertStringContainsString('Отмена без обработки', $message);
+ // В MarkdownV2 дефисы экранируются, поэтому проверяем экранированные версии
+ $this->assertStringContainsString('FW\-100', $message);
+ $this->assertStringContainsString('YM\-200', $message);
+ $this->assertStringContainsString('FW\-300', $message);
+ }
+
+ /**
+ * Тест: проверка структуры HTML Email
+ *
+ * Проверяет, что HTML содержит все необходимые элементы
+ */
+ public function testEmailHtmlContainsAllSections(): void
+ {
+ $result = new ControlReportResult();
+ $result->reportDate = '20.01.2026 08:00';
+ $result->startDate = '2026-01-19 20:00:00';
+ $result->endDate = '2026-01-20 08:00:00';
+ $result->shiftName = 'Ночная смена';
+
+ $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+ $issue1->mpStatus = 'В доставке';
+ $issue1->issueReason = null;
+ $issue1->creationDate = '2026-01-19 10:00:00';
+
+ $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+ $issue2->mpStatus = 'Доставлен';
+ $issue2->issueReason = null;
+ $issue2->creationDate = '2026-01-19 11:00:00';
+
+ $result->hungInDelivery = [$issue1];
+ $result->successNoCheck = [$issue2];
+ $result->calculateTotal();
+
+ $html = $this->service->formatEmailControlReport($result);
+
+ // Проверяем наличие таблицы с заголовками
+ $this->assertStringContainsString('<th>Тип проблемы</th>', $html);
+ $this->assertStringContainsString('<th>Заказ</th>', $html);
+ $this->assertStringContainsString('<th>РМК</th>', $html);
+ $this->assertStringContainsString('<th>МП</th>', $html);
+ $this->assertStringContainsString('<th>Причина</th>', $html);
+
+ // Проверяем наличие данных
+ $this->assertStringContainsString('FW-100', $html);
+ $this->assertStringContainsString('YM-200', $html);
+ $this->assertStringContainsString('Завис в доставке', $html);
+ $this->assertStringContainsString('Успех без чека', $html);
+
+ // Проверяем итог
+ $this->assertStringContainsString('Всего проблем:', $html);
+ }
+
+ /**
+ * Тест: проверка экранирования HTML в Email
+ */
+ public function testEmailHtmlEscapesSpecialCharacters(): void
+ {
+ $result = new ControlReportResult();
+
+ $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, '<script>alert("XSS")</script>');
+ $issue->rmkStatus = 'Статус & "тест"';
+ $issue->mpStatus = '<b>HTML</b> тег';
+ $issue->issueReason = null;
+ $issue->creationDate = '2026-01-19 10:00:00';
+
+ $result->hungInDelivery = [$issue];
+ $result->calculateTotal();
+
+ $html = $this->service->formatEmailControlReport($result);
+
+ // Проверяем, что опасные символы экранированы
+ $this->assertStringNotContainsString('<script>', $html);
+ $this->assertStringContainsString('<script>', $html);
+ $this->assertStringContainsString('&', $html);
+ $this->assertStringContainsString('"', $html);
+ }
+
+ /**
+ * Тест: проверка, что сообщения не отправляются при отсутствии проблем
+ */
+ public function testNoNotificationsSentWhenNoIssues(): void
+ {
+ $result = new ControlReportResult();
+ $result->totalIssues = 0;
+ $result->telegramSent = false;
+ $result->emailSent = false;
+
+ // Если нет проблем, методы отправки не должны вызываться
+ // Это проверяется через логику hasIssues()
+ $this->assertFalse($result->hasIssues());
+
+ // В реальной реализации, если hasIssues() = false,
+ // то telegramSent и emailSent устанавливаются в true без отправки
+ // (см. код в generateControlReport)
+ }
+
+ /**
+ * Тест: проверка формата сообщения Telegram (MarkdownV2)
+ *
+ * Проверяет, что сообщение использует правильный формат разметки
+ */
+ public function testTelegramMessageUsesMarkdownV2Format(): void
+ {
+ $result = new ControlReportResult();
+ $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+ $issue->mpStatus = 'В доставке';
+ $issue->issueReason = null;
+ $issue->creationDate = '2026-01-19 10:00:00';
+
+ $result->hungInDelivery = [$issue];
+ $result->calculateTotal();
+
+ $message = $this->service->formatTelegramControlReport($result);
+
+ // Проверяем формат MarkdownV2 - сообщение должно содержать экранированные символы
+ // В MarkdownV2 используются звездочки для жирного текста и обратные слэши для экранирования
+ $this->assertStringContainsString('*', $message, 'Сообщение должно использовать MarkdownV2 форматирование');
+ // Проверяем наличие экранированных дефисов
+ $this->assertStringContainsString('FW\-100', $message);
+ }
+
+ /**
+ * Тест: проверка конфигурации Email отправителя
+ *
+ * Проверяет логику определения отправителя из кода
+ */
+ public function testEmailSenderConfiguration(): void
+ {
+ // Проверяем, что в коде используется правильная логика определения отправителя
+ // Из кода: $fromEmail = getenv('MAIL_USERNAME') ?: 'noreply@bazacvetov24.ru';
+ $expectedDefaultSender = 'noreply@bazacvetov24.ru';
+
+ // Проверяем, что это значение используется как fallback
+ $this->assertNotEmpty($expectedDefaultSender);
+ $this->assertStringContainsString('@', $expectedDefaultSender);
+ }
+
+ /**
+ * Тест: проверка конфигурации Email получателей
+ *
+ * Проверяет логику получения получателей из конфигурации
+ */
+ public function testEmailRecipientsConfiguration(): void
+ {
+ // Метод getEmailRecipients() получает получателей из:
+ // 1. $this->config['email_recipients']
+ // 2. getenv('ORDER_CONTROL_EMAIL_RECIPIENTS')
+
+ // Проверяем, что метод существует и возвращает массив
+ $reflection = new \ReflectionClass($this->service);
+ $method = $reflection->getMethod('getEmailRecipients');
+ $method->setAccessible(true);
+
+ $recipients = $method->invoke($this->service);
+
+ $this->assertIsArray($recipients);
+ }
+
+ /**
+ * Тест: проверка получения Telegram bot token для dev окружения
+ *
+ * Проверяет логику выбора токена бота
+ */
+ public function testGetTelegramBotTokenForDevEnvironment(): void
+ {
+ $reflection = new \ReflectionClass($this->service);
+ $method = $reflection->getMethod('getTelegramBotToken');
+ $method->setAccessible(true);
+
+ // Сохраняем текущее окружение
+ $originalEnv = getenv('APP_ENV');
+ $originalEnvVar = $_ENV['APP_ENV'] ?? null;
+
+ try {
+ // Устанавливаем dev окружение
+ putenv('APP_ENV=development');
+ $_ENV['APP_ENV'] = 'development';
+
+ // Проверяем, что используется dev токен (или из env)
+ $token = $method->invoke($this->service);
+
+ $this->assertIsString($token);
+ $this->assertNotEmpty($token);
+
+ // Проверяем формат токена бота Telegram (число:буквы_цифры)
+ $this->assertMatchesRegularExpression(
+ '/^\d+:[A-Za-z0-9_-]+$/',
+ $token,
+ 'Токен бота должен соответствовать формату Telegram'
+ );
+ } finally {
+ // Восстанавливаем окружение
+ if ($originalEnv !== false) {
+ putenv("APP_ENV={$originalEnv}");
+ }
+ if ($originalEnvVar !== null) {
+ $_ENV['APP_ENV'] = $originalEnvVar;
+ } else {
+ unset($_ENV['APP_ENV']);
+ }
+ }
+ }
+
+ /**
+ * Тест: проверка получения Telegram chat ID
+ *
+ * Проверяет логику получения ID канала/чата
+ */
+ public function testGetTelegramChatId(): void
+ {
+ $reflection = new \ReflectionClass($this->service);
+ $method = $reflection->getMethod('getTelegramChatId');
+ $method->setAccessible(true);
+
+ $chatId = $method->invoke($this->service);
+
+ // Chat ID может быть пустым, если не настроен
+ // Но если он задан, должен быть строкой
+ $this->assertIsString($chatId);
+
+ // Если chat_id не пустой, проверяем формат (может быть отрицательным для каналов)
+ if (!empty($chatId)) {
+ $this->assertMatchesRegularExpression(
+ '/^-?\d+$/',
+ $chatId,
+ 'Chat ID должен быть числом (может быть отрицательным для каналов)'
+ );
+ }
+ }
+
+ /**
+ * Тест: проверка конфигурации Email отправителя
+ *
+ * Проверяет логику определения отправителя из кода
+ */
+ public function testEmailSenderFromConfiguration(): void
+ {
+ // Из кода OrderControlReportService::sendToEmail():
+ // $fromEmail = getenv('MAIL_USERNAME') ?: 'noreply@bazacvetov24.ru';
+ // $message->setFrom([$fromEmail => 'ERP24 Контроль МП']);
+
+ $expectedDefaultSender = 'noreply@bazacvetov24.ru';
+ $expectedSenderName = 'ERP24 Контроль МП';
+
+ $this->assertNotEmpty($expectedDefaultSender);
+ $this->assertStringContainsString('@', $expectedDefaultSender);
+ $this->assertNotEmpty($expectedSenderName);
+
+ // Проверяем формат email
+ $this->assertTrue(
+ filter_var($expectedDefaultSender, FILTER_VALIDATE_EMAIL) !== false,
+ 'Email отправителя должен быть валидным'
+ );
+ }
+
+ /**
+ * Тест: проверка структуры URL для отправки в Telegram
+ *
+ * Проверяет формат API URL Telegram
+ */
+ public function testTelegramApiUrlFormat(): void
+ {
+ $reflection = new \ReflectionClass($this->service);
+ $method = $reflection->getMethod('getTelegramBotToken');
+ $method->setAccessible(true);
+
+ $token = $method->invoke($this->service);
+
+ // Формируем URL как в коде: "https://api.telegram.org/bot{$botToken}/sendMessage"
+ $url = "https://api.telegram.org/bot{$token}/sendMessage";
+
+ $this->assertStringStartsWith('https://api.telegram.org/bot', $url);
+ $this->assertStringEndsWith('/sendMessage', $url);
+ $this->assertStringContainsString($token, $url);
+ }
+
+ /**
+ * Тест: проверка параметров отправки в Telegram
+ *
+ * Проверяет структуру данных, отправляемых в Telegram API
+ */
+ public function testTelegramSendParameters(): void
+ {
+ // Из кода OrderControlReportService::sendTelegramMessage():
+ // CURLOPT_POSTFIELDS => [
+ // 'chat_id' => $chatId,
+ // 'text' => $message,
+ // 'parse_mode' => 'MarkdownV2',
+ // 'disable_web_page_preview' => true,
+ // ]
+
+ $expectedParams = [
+ 'chat_id' => '-1001234567890', // Пример chat_id канала
+ 'text' => 'Тестовое сообщение',
+ 'parse_mode' => 'MarkdownV2',
+ 'disable_web_page_preview' => true,
+ ];
+
+ $this->assertArrayHasKey('chat_id', $expectedParams);
+ $this->assertArrayHasKey('text', $expectedParams);
+ $this->assertArrayHasKey('parse_mode', $expectedParams);
+ $this->assertSame('MarkdownV2', $expectedParams['parse_mode']);
+ $this->assertTrue($expectedParams['disable_web_page_preview']);
+ }
+
+ /**
+ * Тест: проверка параметров отправки Email
+ *
+ * Проверяет структуру данных для отправки Email
+ */
+ public function testEmailSendParameters(): void
+ {
+ // Из кода OrderControlReportService::sendToEmail():
+ // $message = Yii::$app->mailer->compose()
+ // ->setTo($validRecipients)
+ // ->setSubject($subject)
+ // ->setHtmlBody($html);
+ // $message->setFrom([$fromEmail => 'ERP24 Контроль МП']);
+
+ $expectedSubject = 'Контроль статусов заказов МП';
+ $expectedFromEmail = 'noreply@bazacvetov24.ru';
+ $expectedFromName = 'ERP24 Контроль МП';
+ $expectedRecipients = ['test@example.com'];
+
+ $this->assertNotEmpty($expectedSubject);
+ $this->assertNotEmpty($expectedFromEmail);
+ $this->assertNotEmpty($expectedFromName);
+ $this->assertIsArray($expectedRecipients);
+ $this->assertNotEmpty($expectedRecipients);
+
+ // Проверяем формат email получателей
+ foreach ($expectedRecipients as $email) {
+ $this->assertTrue(
+ filter_var($email, FILTER_VALIDATE_EMAIL) !== false,
+ "Email получателя должен быть валидным: {$email}"
+ );
+ }
+ }
+
+ /**
+ * Тест: проверка вывода информации о каналах и получателях
+ *
+ * Выводит информацию о конфигурации уведомлений:
+ * - Telegram Bot Token (dev/prod)
+ * - Telegram Chat ID (канал/чат)
+ * - Email Recipients (получатели)
+ * - Email Sender (отправитель)
+ */
+ public function testOutputNotificationConfiguration(): void
+ {
+ $reflection = new \ReflectionClass($this->service);
+
+ // Получаем информацию о боте
+ $botTokenMethod = $reflection->getMethod('getTelegramBotToken');
+ $botTokenMethod->setAccessible(true);
+ $botToken = $botTokenMethod->invoke($this->service);
+
+ // Получаем информацию о канале
+ $chatIdMethod = $reflection->getMethod('getTelegramChatId');
+ $chatIdMethod->setAccessible(true);
+ $chatId = $chatIdMethod->invoke($this->service);
+
+ // Получаем информацию о получателях Email
+ $recipientsMethod = $reflection->getMethod('getEmailRecipients');
+ $recipientsMethod->setAccessible(true);
+ $recipients = $recipientsMethod->invoke($this->service);
+
+ // Получаем информацию об отправителе Email
+ $fromEmail = getenv('MAIL_USERNAME') ?: 'noreply@bazacvetov24.ru';
+ $fromName = 'ERP24 Контроль МП';
+
+ // Определяем окружение
+ $isDev = TelegramService::isDevEnv();
+ $env = $isDev ? 'DEV' : 'PROD';
+
+ // Выводим информацию в консоль через STDOUT для гарантированного отображения
+ $output = "\n";
+ $output .= "═══════════════════════════════════════════════════════════════\n";
+ $output .= " КОНФИГУРАЦИЯ УВЕДОМЛЕНИЙ ОТЧЁТА КОНТРОЛЯ СТАТУСОВ МП\n";
+ $output .= "═══════════════════════════════════════════════════════════════\n";
+ $output .= "\n";
+
+ $output .= "📱 TELEGRAM:\n";
+ $output .= " Окружение: {$env}\n";
+ $output .= " Bot Token: " . ($botToken ? substr($botToken, 0, 10) . '...' . substr($botToken, -5) : '(не настроен)') . "\n";
+ $output .= " Chat ID: " . ($chatId ?: '(не настроен)') . "\n";
+ $output .= "\n";
+
+ $output .= "📧 EMAIL:\n";
+ $output .= " Отправитель: {$fromEmail}\n";
+ $output .= " Имя отправителя: {$fromName}\n";
+ $output .= " Получатели: " . (empty($recipients) ? '(не настроены)' : implode(', ', $recipients)) . "\n";
+ $output .= "\n";
+
+ $output .= "═══════════════════════════════════════════════════════════════\n";
+ $output .= "\n";
+
+ // Выводим информацию в консоль через STDOUT с принудительным flush
+ // Это самый надежный способ вывода в Codeception
+ fwrite(STDOUT, $output);
+ flush();
+
+ // Проверяем типы данных
+ $this->assertIsString($botToken, 'Bot token должен быть строкой');
+ $this->assertIsString($chatId, 'Chat ID должен быть строкой');
+ $this->assertIsArray($recipients, 'Recipients должны быть массивом');
+
+ // Проверяем, что если значения заданы, они имеют правильный формат
+ if (!empty($botToken)) {
+ $this->assertMatchesRegularExpression('/^\d+:[A-Za-z0-9_-]+$/', $botToken);
+ }
+
+ if (!empty($chatId)) {
+ $this->assertMatchesRegularExpression('/^-?\d+$/', $chatId);
+ }
+
+ foreach ($recipients as $email) {
+ $this->assertTrue(
+ filter_var($email, FILTER_VALIDATE_EMAIL) !== false,
+ "Email получателя должен быть валидным: {$email}"
+ );
+ }
+ }
+}