feat: update allowed frontend hosts and add clinic-pro.ir domain

This commit is contained in:
hamed
2026-07-21 16:51:42 +03:30
parent 512dbdaba0
commit 7e847b62c4
14 changed files with 207 additions and 25 deletions
+1 -1
View File
@@ -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));"
+1 -1
View File
@@ -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).
+1
View File
@@ -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.*",
Generated
+77 -1
View File
@@ -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",
+6
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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'
+1
View File
@@ -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": "اردبیل" },
+3 -2
View File
@@ -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` رندرشده.
+11 -4
View File
@@ -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';
-12
View File
@@ -1,12 +0,0 @@
<?php
namespace App\Shared\Message;
class SendSmsMessage
{
public function __construct(
public readonly string $mobile,
public readonly string $message,
public readonly ?int $smsLogId = null,
) {}
}
+20 -3
View File
@@ -3,6 +3,7 @@
namespace App\Sms\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -57,6 +58,9 @@ class KavehNegarProvider implements SmsProviderInterface
'sender' => $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;
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Tests\Shared;
use App\Shared\Controller\HealthController;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* probe سلامت redis نباید هنگام افتادن redis لاگ warning در app_log بریزد:
* HealthController باید logger این pool را با NullLogger خنثی کند و صرفاً وضعیت را برگرداند.
*/
class HealthControllerTest extends TestCase
{
private function em(): EntityManagerInterface
{
$conn = $this->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']);
}
}
+21
View File
@@ -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'));
}
}