merge: имя отправителя выбирается по оператору получателя
Ветка feat/sms-imena-otpravitelya-po-kanalam, одна запись. Закрыта общая причина отказа Теле2 invalid_source_address: имя согласовывает оператор, и у каждого оно своё, а клиентская рассылка несла одно имя всем с умолчанием от МТС. Проверено перед сведением: 4614 тестов (упал один чужой — в рабочей папке не собран фронтенд), статанализ 0, удалений чужих строк нет. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,9 @@ SMS_SANDBOX=true
|
||||
SMS_FAILOVER_TO_UNIVERSAL=false
|
||||
SMS_SMSC_ENABLED=false
|
||||
SMS_SMSC_PRICE_KOP=0
|
||||
# Имя отправителя универсального канала — им подписываемся, когда своего имени
|
||||
# у клиента для сети получателя нет. Пусто = уйдёт без имени.
|
||||
SMSC_NAMING=
|
||||
SMS_MTS_ENABLED=false
|
||||
SMS_MTS_TOKEN=
|
||||
SMS_MTS_PRICE_KOP=0
|
||||
|
||||
@@ -19,6 +19,15 @@ trait ResolvesClientSmsSenderName
|
||||
/**
|
||||
* Эффективное имя отправителя: активное согласованное имя тенанта, иначе
|
||||
* согласованное имя МТС из конфига или liderra.ru по умолчанию.
|
||||
*
|
||||
* 🪤 Это ЭКРАННОЕ имя и запись намерения (`campaign.sender_name`), а НЕ то,
|
||||
* чем подпишется каждая сеть. Чем подписаться в сети конкретного оператора,
|
||||
* решает `App\Services\ClientSms\ClientSmsSenderResolver` в момент отправки:
|
||||
* имя согласовывает оператор, и у каждого оно своё.
|
||||
*
|
||||
* Подставлять отсюда имя прямо в канал — та самая ошибка, что дала живой
|
||||
* отказ Теле2 `invalid_source_address` 04.08.2026: умолчание здесь взято
|
||||
* от МТС и для других сетей неверно.
|
||||
*/
|
||||
protected function effectiveSenderName(int $tenantId): string
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Deal;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientSms\ClientSmsPricing;
|
||||
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
||||
use App\Services\ClientSms\ClientSmsSenderResolver;
|
||||
use App\Services\ClientSms\SmsQuietHours;
|
||||
use App\Services\DaData\DaDataBudgetGuard;
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
@@ -77,13 +78,18 @@ class SendAutoSmsForDealJob implements ShouldQueue
|
||||
SmsQuietHours $quiet,
|
||||
DaDataPhoneClient $daData,
|
||||
DaDataBudgetGuard $budget,
|
||||
?ClientSmsSenderResolver $senders = null,
|
||||
): void {
|
||||
// Необязательный довод: вызовы с шестью аргументами не ломаются, а в бою
|
||||
// контейнер отдаёт тот же самый объект.
|
||||
$senders ??= app(ClientSmsSenderResolver::class);
|
||||
|
||||
// Авто-СМС — best-effort: ЛЮБОЙ сбой логируем и выходим, НИКОГДА не роняем.
|
||||
// Под sync-очередью (и в тестах) джоб может исполниться прямо в запросе
|
||||
// создания лида — исключение отсюда сломало бы приём лида. Идемпотентность
|
||||
// (deal_id + external_key) делает потерянный прогон безопасным для повтора.
|
||||
try {
|
||||
$this->run($selector, $pricing, $wallet, $quiet, $daData, $budget);
|
||||
$this->run($selector, $pricing, $wallet, $quiet, $daData, $budget, $senders);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('client_sms.auto_job_failed', [
|
||||
'deal_id' => $this->dealId,
|
||||
@@ -100,6 +106,7 @@ class SendAutoSmsForDealJob implements ShouldQueue
|
||||
SmsQuietHours $quiet,
|
||||
DaDataPhoneClient $daData,
|
||||
DaDataBudgetGuard $budget,
|
||||
ClientSmsSenderResolver $senders,
|
||||
): void {
|
||||
$sandbox = (bool) config('services.sms.sandbox');
|
||||
|
||||
@@ -224,10 +231,13 @@ class SendAutoSmsForDealJob implements ShouldQueue
|
||||
$provider = $row['provider'];
|
||||
|
||||
try {
|
||||
// Имя отправителя — ПО ОПЕРАТОРУ получателя (см. ClientSmsSenderResolver):
|
||||
// согласовывает его оператор, и у каждого оно своё. Пустая строка означает
|
||||
// «своего имени для этой сети нет» — канал подпишется собственным.
|
||||
$result = $provider->send(new SmsOutgoing(
|
||||
phone: $row['phone'],
|
||||
body: $rule->body,
|
||||
senderName: $rule->sender_name,
|
||||
senderName: $senders->forOperator($this->tenantId, (string) $row['operator']),
|
||||
operator: $row['operator'],
|
||||
segments: $segments,
|
||||
));
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\ClientSmsCampaign;
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientSms\ClientSmsPricing;
|
||||
use App\Services\ClientSms\ClientSmsSenderResolver;
|
||||
use App\Services\ClientSms\ClientSmsSnapshotReader;
|
||||
use App\Services\Sms\SmsOutgoing;
|
||||
use App\Services\Sms\SmsSendException;
|
||||
@@ -67,7 +68,12 @@ class SendClientSmsCampaignJob implements ShouldQueue
|
||||
ClientSmsSnapshotReader $snapshot,
|
||||
ClientSmsPricing $pricing,
|
||||
AdWalletService $wallet,
|
||||
?ClientSmsSenderResolver $senders = null,
|
||||
): void {
|
||||
// Необязательный довод: вызовы с тремя аргументами (тесты, старые места)
|
||||
// не ломаются, а в бою контейнер отдаёт тот же самый объект.
|
||||
$senders ??= app(ClientSmsSenderResolver::class);
|
||||
|
||||
$sandbox = (bool) config('services.sms.sandbox');
|
||||
|
||||
// Фаза A — под tenant-контекстом: статус и уже отправленное. Аудиторию
|
||||
@@ -182,10 +188,16 @@ class SendClientSmsCampaignJob implements ShouldQueue
|
||||
$provider = $row['provider'];
|
||||
|
||||
try {
|
||||
// Имя отправителя — ПО ОПЕРАТОРУ получателя, а не одно на всех:
|
||||
// согласовывает имя оператор, и у каждого оно своё. Пустая строка
|
||||
// здесь означает «своего имени для этой сети нет» — канал подпишется
|
||||
// собственным согласованным именем из настроек. Раньше сюда шло
|
||||
// `campaign.sender_name`, умолчанием которого было имя МТС: Теле2
|
||||
// отвечал на него `invalid_source_address` (живой отказ 04.08.2026).
|
||||
$result = $provider->send(new SmsOutgoing(
|
||||
phone: $row['phone'],
|
||||
body: $campaign->body,
|
||||
senderName: $campaign->sender_name,
|
||||
senderName: $senders->forOperator($this->tenantId, (string) $row['operator']),
|
||||
operator: $row['operator'],
|
||||
segments: $segments,
|
||||
));
|
||||
|
||||
@@ -151,6 +151,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
(string) config('services.smsc.password'),
|
||||
$serves,
|
||||
$price,
|
||||
// Чем подписаться, если своего имени у клиента для этой сети нет.
|
||||
(string) config('services.smsc.naming', ''),
|
||||
)
|
||||
: null,
|
||||
'mts' => ($token = (string) config('services.sms.mts.token')) !== ''
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ClientSms;
|
||||
|
||||
use App\Models\ClientSmsSender;
|
||||
|
||||
/**
|
||||
* Каким именем подписать СМС клиента в сети КОНКРЕТНОГО оператора.
|
||||
*
|
||||
* Имя отправителя согласовывает оператор, а не канал: через универсальный
|
||||
* СМС-центр можно слать в любую сеть, и спрашивать надо у той сети, чей номер.
|
||||
*
|
||||
* 🪤 До 05.08.2026 клиентская рассылка несла ОДНО имя всем каналам, а
|
||||
* умолчанием служило `services.sms.mts.naming` — имя, согласованное с МТС,
|
||||
* подставлялось Теле2 и подставилось бы любому будущему каналу. Живой отказ
|
||||
* Теле2 `invalid_source_address` 04.08.2026 — ровно этот механизм.
|
||||
*
|
||||
* Пустая строка на выходе — это НЕ «имени нет». Это «своего имени у клиента для
|
||||
* этой сети нет, подпишись собственным именем канала»: каждый канал знает своё
|
||||
* согласованное написание из настроек и сам подставляет его, когда имя пустое.
|
||||
*/
|
||||
final class ClientSmsSenderResolver
|
||||
{
|
||||
public function forOperator(int $tenantId, string $operator): string
|
||||
{
|
||||
$sender = ClientSmsSender::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', ClientSmsSender::STATUS_ACTIVE)
|
||||
->first();
|
||||
|
||||
if ($sender === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @var array<int, string>|null $operators */
|
||||
$operators = $sender->operators;
|
||||
|
||||
// Список не заполнен — запись заведена до появления разбора по
|
||||
// операторам. Отнять у неё имя молча значило бы поменять поведение
|
||||
// задним числом, поэтому считаем «годится всем».
|
||||
if ($operators === null || $operators === []) {
|
||||
return (string) $sender->name;
|
||||
}
|
||||
|
||||
return in_array($operator, $operators, true) ? (string) $sender->name : '';
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ final class SmscSmsProvider implements SmsProvider
|
||||
private readonly string $password,
|
||||
private readonly array $serves,
|
||||
private readonly array $priceKopecks,
|
||||
/**
|
||||
* Своё согласованное имя канала. Подставляется, когда в сообщении имя
|
||||
* пустое — то есть «своего имени у клиента для сети получателя нет».
|
||||
* До 05.08.2026 универсал слал имя КАК ЕСТЬ и ушёл бы с пустым
|
||||
* отправителем, как только имена стали выбираться по оператору.
|
||||
*/
|
||||
private readonly string $naming = '',
|
||||
) {}
|
||||
|
||||
public function key(): string
|
||||
@@ -53,7 +60,7 @@ final class SmscSmsProvider implements SmsProvider
|
||||
'psw' => $this->password,
|
||||
'phones' => $message->phone,
|
||||
'mes' => $message->body,
|
||||
'sender' => $message->senderName,
|
||||
'sender' => $message->senderName !== '' ? $message->senderName : $this->naming,
|
||||
'charset' => 'utf-8',
|
||||
'fmt' => 3,
|
||||
]);
|
||||
|
||||
@@ -141,6 +141,11 @@ return [
|
||||
'red_floor_rub' => (int) env('SMSC_RED_FLOOR_RUB', 300),
|
||||
'amber_floor_rub' => (int) env('SMSC_AMBER_FLOOR_RUB', 1000),
|
||||
'topup_url' => env('SMSC_TOPUP_URL', 'https://smsc.ru/oplata/'),
|
||||
// Своё согласованное имя отправителя универсального канала. Подставляется,
|
||||
// когда своего имени у клиента для сети получателя нет: имя согласовывает
|
||||
// ОПЕРАТОР, а универсал ходит во все сети сразу (см. ClientSmsSenderResolver).
|
||||
// Пусто — уйдёт без имени, как было до 05.08.2026.
|
||||
'naming' => env('SMSC_NAMING', ''),
|
||||
],
|
||||
|
||||
// AITUNNEL (OpenAI-совместимый) для автоподбора: эмбеддинги (похожесть конкурента на
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\AdWallet;
|
||||
use App\Models\ClientSmsAutoRule;
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Models\ClientSmsOptout;
|
||||
use App\Models\ClientSmsSender;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -19,7 +20,10 @@ use App\Services\DaData\DaDataBudgetGuard;
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
use App\Services\Sms\OperatorNormalizer;
|
||||
use App\Services\Sms\Providers\MtsSmsProvider;
|
||||
use App\Services\Sms\SmsOutgoing;
|
||||
use App\Services\Sms\SmsProvider;
|
||||
use App\Services\Sms\SmsRouter;
|
||||
use App\Services\Sms\SmsSendResult;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -341,3 +345,105 @@ it('3.8 — авто-СМС считается по ступени с учёто
|
||||
->and($m->segments)->toBe(1) // журнал помнит объём (В-108)
|
||||
->and((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('992.00');
|
||||
});
|
||||
|
||||
/**
|
||||
* Канал, запоминающий, каким именем его просили подписать. Имя своё, не как в
|
||||
* SendJobTest: помощники Pest — ГЛОБАЛЬНЫЕ, одноимённые уронили бы прогон.
|
||||
*/
|
||||
// Без объявленного типа возврата НАМЕРЕННО: с ним анализатор видит только
|
||||
// интерфейс и теряет поле $seenSenderNames, ради которого канал и заведён.
|
||||
function autoRecordingProvider()
|
||||
{
|
||||
return new class implements SmsProvider
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $seenSenderNames = [];
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'mts';
|
||||
}
|
||||
|
||||
public function servesOperators(): array
|
||||
{
|
||||
return ['mts'];
|
||||
}
|
||||
|
||||
public function priceKopecks(string $operator): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function send(SmsOutgoing $message): SmsSendResult
|
||||
{
|
||||
$this->seenSenderNames[] = $message->senderName;
|
||||
|
||||
return new SmsSendResult('MSG-AUTO-SENDER-1', $message->segments, 0, CarbonImmutable::now());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('авто-СМС: имя согласовано у ДРУГОГО оператора — каналу отдаём пустое', function () {
|
||||
config(['services.sms.sandbox' => false]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
|
||||
makeAutoRule((int) $tenant->id, true);
|
||||
|
||||
// Своё имя согласовано только у Теле2, а лид — из сети МТС.
|
||||
ClientSmsSender::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'mybrand.ru',
|
||||
'name_type' => ClientSmsSender::TYPE_WEBSITE,
|
||||
'status' => ClientSmsSender::STATUS_ACTIVE,
|
||||
'operators' => ['tele2'],
|
||||
'monthly_fee_rub' => '0.00',
|
||||
'consent_at' => now(),
|
||||
]);
|
||||
|
||||
$deal = makeFreshDeal((int) $tenant->id, '79990000021');
|
||||
|
||||
$provider = autoRecordingProvider();
|
||||
$selector = new ClientSmsRecipientSelector(
|
||||
new SmsRouter([$provider]),
|
||||
new OperatorNormalizer,
|
||||
new AllowedSmsOperators(new OperatorNormalizer),
|
||||
);
|
||||
|
||||
runAutoJob((int) $deal->id, (int) $tenant->id, $selector);
|
||||
|
||||
// Сначала — что сравниваем не пустоту с пустотой: сообщение реально ушло.
|
||||
expect($provider->seenSenderNames)->toHaveCount(1);
|
||||
expect($provider->seenSenderNames[0])->toBe('');
|
||||
});
|
||||
|
||||
it('авто-СМС: имя согласовано у ЭТОГО оператора — им и подписываем', function () {
|
||||
config(['services.sms.sandbox' => false]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
|
||||
makeAutoRule((int) $tenant->id, true);
|
||||
|
||||
ClientSmsSender::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'mybrand.ru',
|
||||
'name_type' => ClientSmsSender::TYPE_WEBSITE,
|
||||
'status' => ClientSmsSender::STATUS_ACTIVE,
|
||||
'operators' => ['mts'],
|
||||
'monthly_fee_rub' => '0.00',
|
||||
'consent_at' => now(),
|
||||
]);
|
||||
|
||||
$deal = makeFreshDeal((int) $tenant->id, '79990000022');
|
||||
|
||||
$provider = autoRecordingProvider();
|
||||
$selector = new ClientSmsRecipientSelector(
|
||||
new SmsRouter([$provider]),
|
||||
new OperatorNormalizer,
|
||||
new AllowedSmsOperators(new OperatorNormalizer),
|
||||
);
|
||||
|
||||
runAutoJob((int) $deal->id, (int) $tenant->id, $selector);
|
||||
|
||||
expect($provider->seenSenderNames)->toBe(['mybrand.ru']);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\ClientSmsCampaign;
|
||||
use App\Models\ClientSmsContact;
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Models\ClientSmsOptout;
|
||||
use App\Models\ClientSmsSender;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientSms\AllowedSmsOperators;
|
||||
@@ -405,3 +406,107 @@ it('В-125 — снимок читается только под пометко
|
||||
|
||||
expect(array_filter($reads, fn ($underContext) => $underContext === false))->toBe([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Имя отправителя выбирается ПО ОПЕРАТОРУ получателя, а не одно на всех.
|
||||
*
|
||||
* До 05.08.2026 джоб слал `campaign.sender_name` каждому каналу, а умолчанием
|
||||
* этого поля служило `services.sms.mts.naming` — имя, согласованное с МТС.
|
||||
* Теле2 отвечал на него `invalid_source_address` (живой отказ 04.08.2026).
|
||||
*/
|
||||
// Без объявленного типа возврата НАМЕРЕННО: с ним анализатор видит только
|
||||
// интерфейс и теряет поле $seenSenderNames, ради которого канал и заведён.
|
||||
function recordingSenderProvider(string $key, array $serves)
|
||||
{
|
||||
return new class($key, $serves) implements SmsProvider
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $seenSenderNames = [];
|
||||
|
||||
public function __construct(private string $k, private array $serves) {}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->k;
|
||||
}
|
||||
|
||||
public function servesOperators(): array
|
||||
{
|
||||
return $this->serves;
|
||||
}
|
||||
|
||||
public function priceKopecks(string $operator): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function send(SmsOutgoing $message): SmsSendResult
|
||||
{
|
||||
$this->seenSenderNames[] = $message->senderName;
|
||||
|
||||
return new SmsSendResult('MSG-SENDER-1', $message->segments, 0, CarbonImmutable::now());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('имя клиента согласовано у ДРУГОГО оператора — каналу отдаём пустое, чтобы он подписался своим', function () {
|
||||
config(['services.sms.sandbox' => false]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
|
||||
|
||||
// Своё имя согласовано только у Теле2, а шлём в сеть МТС.
|
||||
ClientSmsSender::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'mybrand.ru',
|
||||
'name_type' => ClientSmsSender::TYPE_WEBSITE,
|
||||
'status' => ClientSmsSender::STATUS_ACTIVE,
|
||||
'operators' => ['tele2'],
|
||||
'monthly_fee_rub' => '0.00',
|
||||
'consent_at' => now(),
|
||||
]);
|
||||
|
||||
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
||||
|
||||
$provider = recordingSenderProvider('mts', ['mts']);
|
||||
bindSendJobRoute($provider);
|
||||
|
||||
$campaign = makeSendJobCampaign($tenant->id, 1);
|
||||
makeSendJobSnapshot($campaign);
|
||||
|
||||
runSendJob($campaign->id, $tenant->id);
|
||||
|
||||
// Сначала убеждаемся, что сравниваем не пустоту с пустотой: номер реально ушёл.
|
||||
expect($provider->seenSenderNames)->toHaveCount(1);
|
||||
// И имя МТС-кампании клиенту не подставили — канал подпишется своим.
|
||||
expect($provider->seenSenderNames[0])->toBe('');
|
||||
});
|
||||
|
||||
it('имя клиента согласовано у ЭТОГО оператора — им и подписываем', function () {
|
||||
config(['services.sms.sandbox' => false]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
|
||||
|
||||
ClientSmsSender::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'mybrand.ru',
|
||||
'name_type' => ClientSmsSender::TYPE_WEBSITE,
|
||||
'status' => ClientSmsSender::STATUS_ACTIVE,
|
||||
'operators' => ['mts'],
|
||||
'monthly_fee_rub' => '0.00',
|
||||
'consent_at' => now(),
|
||||
]);
|
||||
|
||||
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
||||
|
||||
$provider = recordingSenderProvider('mts', ['mts']);
|
||||
bindSendJobRoute($provider);
|
||||
|
||||
$campaign = makeSendJobCampaign($tenant->id, 1);
|
||||
makeSendJobSnapshot($campaign);
|
||||
|
||||
runSendJob($campaign->id, $tenant->id);
|
||||
|
||||
expect($provider->seenSenderNames)->toBe(['mybrand.ru']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientSmsSender;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ClientSms\ClientSmsSenderResolver;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Имя отправителя согласовывает ОПЕРАТОР, а не канал: через универсальный
|
||||
* СМС-центр можно слать в любую сеть, и спрашивать надо у той сети, чей номер.
|
||||
*
|
||||
* До 05.08.2026 клиентская рассылка несла ОДНО имя всем, а умолчанием служило
|
||||
* `services.sms.mts.naming` — то есть имя, согласованное с МТС, подставлялось
|
||||
* Теле2 и любому будущему каналу. Живой отказ `invalid_source_address` от Теле2
|
||||
* 04.08 — ровно этот механизм.
|
||||
*
|
||||
* Пустая строка на выходе — НЕ «нет имени», а «своего имени нет, подпишись
|
||||
* собственным именем канала»: каждый канал знает своё согласованное написание
|
||||
* из настроек (MtsSmsProvider: `senderName !== '' ? senderName : $this->naming`).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function makeSenderFor(?array $operators, string $status = ClientSmsSender::STATUS_ACTIVE, string $name = 'mybrand.ru'): Tenant
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
ClientSmsSender::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $name,
|
||||
'name_type' => ClientSmsSender::TYPE_WEBSITE,
|
||||
'status' => $status,
|
||||
'operators' => $operators,
|
||||
'monthly_fee_rub' => '0.00',
|
||||
'consent_at' => now(),
|
||||
]);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
it('своего имени нет вовсе — отдаёт пустую строку, чтобы канал подписался своим', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('');
|
||||
});
|
||||
|
||||
it('имя согласовано у этого оператора — отдаёт его', function () {
|
||||
$tenant = makeSenderFor(['mts', 'tele2']);
|
||||
|
||||
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('mybrand.ru');
|
||||
});
|
||||
|
||||
it('имя согласовано у ДРУГОГО оператора — своим именем не подписывает', function () {
|
||||
$tenant = makeSenderFor(['mts']);
|
||||
|
||||
// Ровно тот случай, что дал живой отказ Теле2 04.08.2026.
|
||||
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('');
|
||||
});
|
||||
|
||||
it('список операторов не заполнен — считаем, что имя годится всем', function () {
|
||||
// Старые записи заведены до появления разбора по операторам: отнять у них
|
||||
// имя молча — значит поменять поведение задним числом.
|
||||
$tenant = makeSenderFor(null);
|
||||
|
||||
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'megafon'))->toBe('mybrand.ru');
|
||||
});
|
||||
|
||||
it('имя ещё на согласовании — не подписывает им', function () {
|
||||
$tenant = makeSenderFor(['tele2'], ClientSmsSender::STATUS_PENDING);
|
||||
|
||||
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('');
|
||||
});
|
||||
|
||||
it('имя приостановлено за долг — не подписывает им', function () {
|
||||
$tenant = makeSenderFor(['tele2'], ClientSmsSender::STATUS_SUSPENDED);
|
||||
|
||||
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('');
|
||||
});
|
||||
|
||||
it('чужое имя соседа не подставляет', function () {
|
||||
makeSenderFor(['tele2'], ClientSmsSender::STATUS_ACTIVE, 'sosed.ru');
|
||||
$mine = Tenant::factory()->create();
|
||||
|
||||
expect(app(ClientSmsSenderResolver::class)->forOperator($mine->id, 'tele2'))->toBe('');
|
||||
});
|
||||
@@ -28,6 +28,43 @@ it('обслуживает всех как универсал', function () {
|
||||
->and(smsc_provider()->key())->toBe('smsc');
|
||||
});
|
||||
|
||||
/**
|
||||
* С 05.08.2026 имя отправителя выбирается ПО ОПЕРАТОРУ получателя, и пустая
|
||||
* строка означает «своего имени у клиента для этой сети нет — подпишись своим».
|
||||
* Универсал же передавал имя КАК ЕСТЬ и ушёл бы с пустым отправителем.
|
||||
*/
|
||||
it('пустое имя заменяет своим согласованным', function () {
|
||||
Http::fake(['smsc.ru/*' => Http::response(['id' => 777, 'cnt' => 1], 200)]);
|
||||
|
||||
$provider = new SmscSmsProvider('login', 'secret', ['*'], ['*' => 300], 'liderra.ru');
|
||||
|
||||
$provider->send(new SmsOutgoing(
|
||||
phone: '79990000001',
|
||||
body: 'Здравствуйте!',
|
||||
senderName: '',
|
||||
operator: 'МегаФон',
|
||||
segments: 1,
|
||||
));
|
||||
|
||||
Http::assertSent(fn ($request) => $request['sender'] === 'liderra.ru');
|
||||
});
|
||||
|
||||
it('заданное имя клиента своим не подменяет', function () {
|
||||
Http::fake(['smsc.ru/*' => Http::response(['id' => 778, 'cnt' => 1], 200)]);
|
||||
|
||||
$provider = new SmscSmsProvider('login', 'secret', ['*'], ['*' => 300], 'liderra.ru');
|
||||
|
||||
$provider->send(new SmsOutgoing(
|
||||
phone: '79990000001',
|
||||
body: 'Здравствуйте!',
|
||||
senderName: 'mybrand.ru',
|
||||
operator: 'МегаФон',
|
||||
segments: 1,
|
||||
));
|
||||
|
||||
Http::assertSent(fn ($request) => $request['sender'] === 'mybrand.ru');
|
||||
});
|
||||
|
||||
it('успешная отправка возвращает id сообщения и цену по сегментам', function () {
|
||||
Http::fake(['smsc.ru/*' => Http::response(['id' => 555, 'cnt' => 1], 200)]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user