diff --git a/.env.coolify.example b/.env.coolify.example index e679ab18..142225eb 100644 --- a/.env.coolify.example +++ b/.env.coolify.example @@ -34,7 +34,7 @@ APP_BASE_URL=https://api.nobat724.com # ddev exec php docker/gen-cors-env.php (یا روی سرور: php docker/gen-cors-env.php) # و خروجی ALLOWED_FRONTEND_HOSTS را این‌جا/در Coolify جایگزین کن. # ⚠️ دیگر CORS_ALLOW_ORIGIN را ست نکن (استفاده نمی‌شود). -ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir +ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,clinic-pro.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir # ── Secrets (REQUIRED — set before the first deploy) ── APP_SECRET= # php -r "echo bin2hex(random_bytes(32));" diff --git a/.env.liara.example b/.env.liara.example index 1367b621..473c58b3 100644 --- a/.env.liara.example +++ b/.env.liara.example @@ -42,7 +42,7 @@ TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1 # (CorsRegexEnvProcessor → nelmio). CORS_ALLOW_ORIGIN حذف شده — دیگر ست نکن. # افزودن/حذف دامنه: docker/frontend-domains.json را ویرایش کن، سپس گرفتن خروجی جدید: # ddev exec php docker/gen-cors-env.php (یا روی سرور: php docker/gen-cors-env.php) -ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir +ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,clinic-pro.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir # ── api.ir identity inquiry (Shahkar + IbanMatch) ── # Empty token => fail-closed (representative verification is rejected). diff --git a/composer.json b/composer.json index 5fff70fc..eb620ef4 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "symfony/asset": "7.4.*", "symfony/cache": "7.4.*", "symfony/console": "7.4.*", + "symfony/doctrine-messenger": "7.4.*", "symfony/dotenv": "7.4.*", "symfony/flex": "^2", "symfony/framework-bundle": "7.4.*", diff --git a/composer.lock b/composer.lock index 454b9894..005f00e5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "af02756595d9f158a8adcfe3a7d22a68", + "content-hash": "a224d59e9e6ce93b3f3f78ee13b06bf5", "packages": [ { "name": "altcha-org/altcha", @@ -2917,6 +2917,82 @@ ], "time": "2026-06-11T07:31:44+00:00" }, + { + "name": "symfony/doctrine-messenger", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-messenger.git", + "reference": "c29901ea81dff6557ae3c78c0bad1d559500c2d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/c29901ea81dff6557ae3c78c0bad1d559500c2d8", + "reference": "c29901ea81dff6557ae3c78c0bad1d559500c2d8", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.6|^4", + "php": ">=8.2", + "symfony/messenger": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" + }, + "type": "symfony-messenger-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Doctrine Messenger Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-11T07:31:44+00:00" + }, { "name": "symfony/dotenv", "version": "v7.4.14", diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index f581a94c..9dcce1a8 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -6,3 +6,9 @@ framework: altcha.pool: adapter: cache.adapter.redis default_lifetime: 600 + # Pool اختصاصی probe سلامت redis؛ در HealthController با NullLogger اجرا می‌شود + # تا افتادن موقت redis (DNS flap) لاگ warning تکراری در app_log تولید نکند — + # وضعیت redis همان‌جا در پاسخ /health و کد 503 گزارش می‌شود. + health.pool: + adapter: cache.adapter.redis + default_lifetime: 10 diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 65b0eace..70427707 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -20,7 +20,7 @@ framework: max_retries: 0 routing: - 'App\Shared\Message\SendSmsMessage': async + 'App\Sms\Message\SendSmsMessage': async 'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default 'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default diff --git a/config/services.yaml b/config/services.yaml index ed696005..faa5b9da 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -61,6 +61,10 @@ services: $expireSeconds: '%env(int:ALTCHA_EXPIRE_SECONDS)%' $altchaPool: '@altcha.pool' + App\Shared\Controller\HealthController: + arguments: + $healthPool: '@health.pool' + # Persist warning+ logs to the app_log table while keeping stderr output. App\Shared\Logging\DbLogger: decorates: 'logger' diff --git a/docker/frontend-domains.json b/docker/frontend-domains.json index 4df65340..0fed8884 100644 --- a/docker/frontend-domains.json +++ b/docker/frontend-domains.json @@ -2,6 +2,7 @@ "_comment": "Frontend domains served by this backend. Add an entry per city, then run: ddev exec php docker/gen-cors-env.php — paste the output into Coolify env (CORS_ALLOW_ORIGIN + ALLOWED_FRONTEND_HOSTS), add the same domain to the app service in Coolify UI, and redeploy.", "domains": [ { "domain": "nobat724.com", "label": "اصلی" }, + { "domain": "clinic-pro.ir", "label": "پنل ادمین/بک‌اند" }, { "domain": "ahvaz-nobat.ir", "label": "اهواز" }, { "domain": "arak-nobat.ir", "label": "اراک" }, { "domain": "ardabil-nobat.ir", "label": "اردبیل" }, diff --git a/docs/api/sms.md b/docs/api/sms.md index 707db336..397d45a9 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -17,7 +17,8 @@ - شماره فرستنده تنظیم نمی‌شود؛ کاوه‌نگار از خط پیش‌فرض حساب استفاده می‌کند. - endpoint `GET /api/v1/admin/settings` یک فیلد read-only به نام `sms_api_key_configured` (boolean) برمی‌گرداند. - `PATCH /api/v1/admin/settings` کلیدهای `sms_provider`، `kavenegar_api_key`، `kavenegar_sender`، `rangineh_api_key`، `rangineh_sender` را نمی‌پذیرد (از `ALLOWED_KEYS` حذف شده‌اند). -- **ترابری (transport) کاوه‌نگار:** همه‌ی فراخوانی‌های `KavehNegarProvider` به کاوه‌نگار به‌صورت **GET با query string** ارسال می‌شوند (مطابق مستند رسمی) — هیچ درخواست `POST`/`body` ساخته نمی‌شود. GET بدنه ندارد پس curl هدر `Expect: 100-continue` نمی‌فرستد؛ این جلوی خطاهای `431 Request Header Fields Too Large` و `Idle timeout` را که در prod دیده می‌شد می‌گیرد. هر درخواست `timeout=15s`، `max_duration=30s`، بایپس proxy محیطی (`proxy=null`) و تا **۳ بار retry** با backoff برای خطاهای گذرای شبکه دارد. +- **ترابری (transport) کاوه‌نگار:** همه‌ی فراخوانی‌های `KavehNegarProvider` به کاوه‌نگار به‌صورت **GET با query string** ارسال می‌شوند (مطابق مستند رسمی) — هیچ درخواست `POST`/`body` ساخته نمی‌شود. GET بدنه ندارد پس curl هدر `Expect: 100-continue` نمی‌فرستد؛ این جلوی `Idle timeout` را می‌گیرد. هر درخواست `timeout=15s`، `max_duration=30s`، بایپس proxy محیطی (`proxy=null`) و تا **۳ بار retry** با backoff فقط برای خطاهای **گذرای شبکه** (`TransportException`) دارد؛ خطاهای HTTP کاوه‌نگار (`4xx`/`5xx`، مثل `431`) دائم‌اند و retry نمی‌شوند. +- **سقف طول توکن (`431` guard):** مقدار طولانی در توکن‌ها (مثل نام کلینیک فارسی که هر کاراکترش ۹ بایت URL-encode می‌شود) query string را بزرگ می‌کند و کاوه‌نگار `431 Request Header Fields Too Large` برمی‌گرداند. provider هر مقدار توکن را به `MAX_TOKEN_LEN = 60` کاراکتر cap می‌کند. اگر با این حال کاوه‌نگار خطای HTTP بدهد، به‌جای `error` پرسروصدا فقط یک `warning` کوتاه با کد وضعیت لاگ می‌شود (چون خطای دائم است، نه transient). - **قیمت هر پیامک** از کلید تنظیمات `sms_price_rials` خوانده می‌شود (قابل ویرایش در `/admin/settings` → بخش پیامک، و از طریق `PATCH /api/v1/admin/settings`). اگر تنظیم نشده باشد، مقدار پیش‌فرض `SmsWalletController::SMS_PRICE_RIALS = 500` ریال به‌عنوان fallback استفاده می‌شود. `GET /api/v1/sms/wallet/balance` این مقدار را در `sms_price_rials` و تعداد تخمینی پیامک را در `estimated_sms_count` برمی‌گرداند. --- @@ -484,7 +485,7 @@ Updated template with `status: "rejected"`. **همه‌ی پیامک‌های سیستمی از طریق Kavenegar VerifyLookup (`verify/lookup.json`) ارسال می‌شوند** (نه متن‌آزاد `sms/send`). هر تمپلت دو فیلد اضافه دارد: - `kavenegar_template`: نام تمپلت مصوب در پنل کاوه‌نگار. **متن واقعیِ ارسالی از همین تمپلت پنل می‌آید، نه از `body` دیتابیس**؛ `body` فقط برای پیش‌نمایش ادمین و رندرِ رکورد `SmsLog` استفاده می‌شود و باید دستی با تمپلت پنل هم‌راستا نگه داشته شود. -- `token_map`: نگاشت متغیر منطقی → جایگاه کاوه‌نگار. **قانون کاوه‌نگار:** هر الگو **حتماً باید `%token` داشته باشد** (token1 اجباری است)؛ پس token_map هر تگ یک مقدار به `token` می‌دهد. قانون فاصله: `token`/`token2`/`token3` **فاصله نمی‌پذیرند** — provider (`KavehNegarProvider`) فاصله‌های مقدارِ این جایگاه‌ها را خودکار با نیم‌فاصله (ZWNJ) جایگزین می‌کند؛ `token10`/`token20` فاصله را بدون تغییر می‌پذیرند. پس ترجیحاً مقادیر بدون‌فاصله (کد/تاریخ/ساعت/username/لینک) در `token`/`token2`/`token3` و مقادیر دارای فاصله (نام دکتر/بیمار/کلینیک) در `token10`/`token20`. +- `token_map`: نگاشت متغیر منطقی → جایگاه کاوه‌نگار. **قانون کاوه‌نگار:** هر الگو **حتماً باید `%token` داشته باشد** (token1 اجباری است)؛ پس token_map هر تگ یک مقدار به `token` می‌دهد. قانون فاصله: `token`/`token2`/`token3` **فاصله نمی‌پذیرند** — provider (`KavehNegarProvider`) فاصله‌های مقدارِ این جایگاه‌ها را خودکار با نیم‌فاصله (ZWNJ) جایگزین می‌کند؛ `token10`/`token20` فاصله را بدون تغییر می‌پذیرند. پس ترجیحاً مقادیر بدون‌فاصله (کد/تاریخ/ساعت/username/لینک) در `token`/`token2`/`token3` و مقادیر دارای فاصله (نام دکتر/بیمار/کلینیک) در `token10`/`token20`. مقدار هر جایگاه به `MAX_TOKEN_LEN = 60` کاراکتر cap می‌شود تا از خطای `431` جلوگیری شود. `SmsService::dispatchTemplate(tag, mobile, vars)` نقطه‌ی واحدِ ارسال است: `kavenegar_template` و `token_map` را از رکورد DB (یا `SmsMessageTemplate::DEFAULTS`) می‌خواند، `vars` را به جایگاه‌ها نگاشت می‌کند و با VerifyLookup می‌فرستد. اگر `kavenegar_template` تعریف نشده باشد → fallback به ارسال متن‌آزاد با `body` رندرشده. diff --git a/src/Shared/Controller/HealthController.php b/src/Shared/Controller/HealthController.php index 631fa931..fb8f2103 100644 --- a/src/Shared/Controller/HealthController.php +++ b/src/Shared/Controller/HealthController.php @@ -3,16 +3,23 @@ namespace App\Shared\Controller; use Doctrine\ORM\EntityManagerInterface; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\NullLogger; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Contracts\Cache\CacheInterface; class HealthController { public function __construct( private readonly EntityManagerInterface $em, - private readonly CacheInterface $cache, - ) {} + private readonly CacheItemPoolInterface $healthPool, + ) { + // probe سلامت نباید هنگام افتادن redis لاگ warning تولید کند؛ logger این pool را خنثی می‌کنیم. + if ($this->healthPool instanceof LoggerAwareInterface) { + $this->healthPool->setLogger(new NullLogger()); + } + } #[Route('/health', methods: ['GET'])] public function __invoke(): JsonResponse @@ -29,7 +36,7 @@ class HealthController } try { - $item = $this->cache->getItem('health_check'); + $this->healthPool->getItem('health_check'); $checks['redis'] = 'ok'; } catch (\Throwable) { $checks['redis'] = 'error'; diff --git a/src/Shared/Message/SendSmsMessage.php b/src/Shared/Message/SendSmsMessage.php deleted file mode 100644 index 57070d90..00000000 --- a/src/Shared/Message/SendSmsMessage.php +++ /dev/null @@ -1,12 +0,0 @@ - $this->sender(), ]); return ($data['return']['status'] ?? 0) === 200; + } catch (HttpExceptionInterface $e) { + $this->logger->warning(sprintf('SMS send rejected (kavenegar): HTTP %d', $e->getResponse()->getStatusCode()), ['mobile' => $mobile]); + return false; } catch (\Throwable $e) { $this->logger->error(sprintf('SMS send failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]); return false; @@ -66,12 +70,20 @@ class KavehNegarProvider implements SmsProviderInterface // token/token2/token3 مقدارِ دارای فاصله را رد می‌کنند؛ فاصله را با نیم‌فاصله (ZWNJ) جایگزین می‌کنیم. private const NO_SPACE_SLOTS = ['token', 'token2', 'token3']; + // کاوه‌نگار روی طول توکن سقف دارد؛ مقدار بلند (مثلاً نام کلینیک فارسی که هر کاراکترش + // ۹ بایت URL-encode می‌شود) درخواست را بزرگ می‌کند و پاسخ 431 می‌گیریم. مقدار را cap می‌کنیم. + private const MAX_TOKEN_LEN = 60; + private function forSlot(string $slot, string $value): string { - if (!in_array($slot, self::NO_SPACE_SLOTS, true)) { - return $value; + $value = trim($value); + if (in_array($slot, self::NO_SPACE_SLOTS, true)) { + $value = preg_replace('/\s+/u', "\u{200C}", $value) ?? $value; } - return preg_replace('/\s+/u', "\u{200C}", $value) ?? $value; + if (mb_strlen($value, 'UTF-8') > self::MAX_TOKEN_LEN) { + $value = mb_substr($value, 0, self::MAX_TOKEN_LEN, 'UTF-8'); + } + return $value; } public function sendTemplate(string $mobile, string $templateCode, array $vars): bool @@ -95,6 +107,11 @@ class KavehNegarProvider implements SmsProviderInterface } $data = $this->get('/verify/lookup.json', $params); return ($data['return']['status'] ?? 0) === 200; + } catch (HttpExceptionInterface $e) { + // 4xx/5xx از کاوه‌نگار (مثل 431): خطای دائم درخواست است نه transient؛ + // warning کوتاه با کد وضعیت، بدون dump کامل exception. + $this->logger->warning(sprintf('SMS sendTemplate rejected (kavenegar): HTTP %d', $e->getResponse()->getStatusCode()), ['mobile' => $mobile, 'template' => $templateCode]); + return false; } catch (\Throwable $e) { $this->logger->error(sprintf('SMS sendTemplate failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]); return false; diff --git a/tests/Shared/HealthControllerTest.php b/tests/Shared/HealthControllerTest.php new file mode 100644 index 00000000..f7d7207b --- /dev/null +++ b/tests/Shared/HealthControllerTest.php @@ -0,0 +1,60 @@ +createMock(Connection::class); + $conn->method('executeQuery'); // SELECT 1 → موفق + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getConnection')->willReturn($conn); + + return $em; + } + + public function testSilencesLoggerOnPool(): void + { + $pool = new class implements CacheItemPoolInterface, LoggerAwareInterface { + public ?LoggerInterface $logger = null; + public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; } + public function getItem($key): CacheItemInterface { throw new \RuntimeException('redis down'); } + public function getItems(array $keys = []): iterable { return []; } + public function hasItem($key): bool { return false; } + public function clear(): bool { return true; } + public function deleteItem($key): bool { return true; } + public function deleteItems(array $keys): bool { return true; } + public function save(CacheItemInterface $item): bool { return true; } + public function saveDeferred(CacheItemInterface $item): bool { return true; } + public function commit(): bool { return true; } + }; + + $controller = new HealthController($this->em(), $pool); + + // logger فوراً در constructor خنثی می‌شود + $this->assertInstanceOf(NullLogger::class, $pool->logger); + + // redis افتاده → degraded + 503، بدون throw + $response = $controller(); + $this->assertSame(503, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + $this->assertSame('degraded', $body['status']); + $this->assertSame('error', $body['checks']['redis']); + $this->assertSame('ok', $body['checks']['database']); + } +} diff --git a/tests/Sms/KavehNegarProviderTest.php b/tests/Sms/KavehNegarProviderTest.php index 46b5be54..11d4acb9 100644 --- a/tests/Sms/KavehNegarProviderTest.php +++ b/tests/Sms/KavehNegarProviderTest.php @@ -46,4 +46,25 @@ class KavehNegarProviderTest extends TestCase $this->assertSame('GET', $this->captured['method']); $this->assertStringContainsString('/TESTKEY/sms/send.json', $this->captured['url']); } + + /** توکن بلند (نام فارسی) باید cap شود تا URL بزرگ نشود و کاوه‌نگار 431 ندهد. */ + public function testLongTokenIsCapped(): void + { + $long = str_repeat('ک', 200); // 200 کاراکتر فارسی + $ok = $this->provider()->sendTemplate('09120671756', 'clinicpro-welcome', ['token' => $long]); + + $this->assertTrue($ok); + parse_str(parse_url($this->captured['url'], PHP_URL_QUERY), $q); + $this->assertLessThanOrEqual(60, mb_strlen($q['token'], 'UTF-8')); + } + + /** خطای 4xx کاوه‌نگار (مثل 431) باید false برگرداند و throw نکند. */ + public function testHttpErrorReturnsFalseWithoutThrowing(): void + { + $client = new MockHttpClient(fn (): MockResponse => new MockResponse('too large', ['http_code' => 431])); + $provider = new KavehNegarProvider($client, new NullLogger(), 'TESTKEY', '10004346'); + + $this->assertFalse($provider->sendTemplate('09120671756', 'clinicpro-welcome', ['token' => 'x'])); + $this->assertFalse($provider->send('09120671756', 'hello')); + } }