Files
portal/app/tests/Feature/ClientSms/SendJobTest.php
T
Дмитрий 3512479e32
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(смс): своё имя есть, но у этой сети не согласовано — номер не уходит вовсе
Решение владельца 05.08.2026. Прежний вариант (подписать нашим «Liderra.ru»)
подменял бренд, за который клиент заплатил, причём незаметно: журнал сообщений
имя отправителя не хранит вовсе. Так уже работает отдел продаж
(SmsRecipientSelector → SKIP_NO_SENDER), и надпись на клиентском экране
(«нет имени отправителя для оператора») давно лежала готовая — её просто никогда
не показывали.

Два случая разведены НАМЕРЕННО и закреплены отдельными тестами:
  • своего имени нет вовсе (или приостановлено) — шлём от имени Лидерры, как
    и раньше. Смешай их — и рассылка встала бы у ВСЕХ разом: на бою своих имён
    заведено НОЛЬ;
  • своё имя есть, но у этой сети не согласовано — номер не уходит.

Отсев в ОТБОРЩИКЕ, а не при отправке: деньги не замораживаются, и причина видна
клиенту в предпросмотре — до оплаты.

🪤 Полный прогон поймал мою ошибку, которую выборочные тесты не видели: имя
спрашивалось ПО КАЖДОМУ номеру — 20 000 запросов в базу, отбор 20 000 получателей
вырос с 20 до 29 секунд и упёрся в сторожа скорости. Добавлена памятка на тенанта
(та же защита, что у AllowedSmsOperators, который прямо об этом предупреждает).
После лечения — 1,47 с.

Два теста часовой давности переписаны: они закрепляли ПРЕЖНЕЕ решение и после
смены решения стали неправдой. Теперь проверяют и «в канал не ушло ничего», и
«деньги целы».

Узкая щель записана в коде: если админ сузит список операторов у имени ПОСЛЕ
создания рассылки, такой номер уже лежит в снимке и уйдёт подписанным нашим именем.
Случай редкий — пересогласование переводит имя в «на рассмотрении», а это снимает
блокировку.

Проверено: 4621 тест, упал один чужой (ExampleTest — в рабочей папке не собран
фронтенд). Статанализ 0. Стиль чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:21:01 +03:00

518 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\SendClientSmsCampaignJob;
use App\Models\AdWallet;
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;
use App\Services\ClientSms\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsSnapshotReader;
use App\Services\ClientSms\ClientSmsSnapshotWriter;
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\Facades\DB;
use Illuminate\Support\Facades\Http;
/**
* Джоб отправки клиентской СМС-рассылки (Task 6) — денежно-критичный.
*
* Очередь в бою бежит под ролью НЕ-BYPASSRLS: джоб сам ставит
* SET LOCAL app.current_tenant_id перед каждой денежной/читающей операцией.
* Деньги — только AdWalletService (bcmath, рубли), сеть — только под Http::fake.
* Синтетические номера 7999… — реальные НИКОГДА (в реальном режиме это боевая
* отправка).
*/
uses(RefreshDatabase::class);
// Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем — иначе
// тест зависел бы от часа, в который его запустили (вечером все номера ждали бы утра).
// 09:00 UTC = полдень в Москве, окно открыто.
beforeEach(function () {
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
});
/** Кампания в статусе queued со снимком цены/сегментов (как её создаёт контроллер). */
function makeSendJobCampaign(int $tenantId, int $totalSms): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Рассылка',
'body' => 'Здравствуйте!',
'sender_name' => 'liderra.ru',
'source' => ClientSmsCampaign::SOURCE_BASE,
'audience_days' => null,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'segments' => 1,
'planned_count' => $totalSms,
'sent_count' => 0,
'total_sms' => $totalSms,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '0.00',
'actual_cost_rub' => null,
'created_by' => null,
]);
}
/** Валидный успешный ответ МТС (омни-адаптер api.mts.ru), см. MtsSmsProvider::send. */
function mtsSuccessResponse(): array
{
return [
'code' => 0,
'data' => [
'submitResults' => [
['msid' => '79990000001', 'messageID' => 'MSG-OK-1', 'code' => 'OK'],
],
],
];
}
/**
* Ставит канал, которым будут идти сообщения, и отдаёт его.
*
* 🪤 Канал теперь выбирается ДВАЖДЫ: при создании рассылки — отборщиком, при
* отправке — читателем снимка. Читатель берёт роутер из контейнера, поэтому
* подменять надо именно роутер, а не один отборщик: иначе снимок соберётся,
* а отправится ноль, и денежный тест окажется пустым, оставаясь зелёным.
*/
function bindSendJobRoute(?SmsProvider $provider = null): SmsRouter
{
$router = new SmsRouter([$provider ?? new MtsSmsProvider('x', ['mts'], ['*' => 0])]);
app()->instance(SmsRouter::class, $router);
return $router;
}
/**
* Снимок получателей — то, что в бою делает контроллер при создании рассылки
* (строки листа 2.1–2.2). Джоб аудиторию больше не собирает: без снимка
* рассылке просто некому уходить.
*/
function makeSendJobSnapshot(ClientSmsCampaign $campaign): void
{
$selector = new ClientSmsRecipientSelector(app(SmsRouter::class), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer));
$recipients = app(ClientSmsAudienceBuilder::class)->build($campaign);
app(ClientSmsSnapshotWriter::class)->write(
(int) $campaign->tenant_id,
(int) $campaign->id,
$selector->build($recipients, (int) $campaign->tenant_id),
);
}
/** Запуск джоба в его нынешнем виде: читатель снимка + цены + кошелёк. */
function runSendJob(int $campaignId, int $tenantId, ?AdWalletService $wallet = null): void
{
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
app(ClientSmsSnapshotReader::class),
app(ClientSmsPricing::class),
$wallet ?? app(AdWalletService::class),
);
}
it('sandbox: отправляет через заглушку, стоп-лист пропускает, деньги НЕ трогает', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000009', 'name' => 'В', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000009']);
$campaign = makeSendJobCampaign($tenant->id, 2);
makeSendJobSnapshot($campaign);
runSendJob($campaign->id, $tenant->id);
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and($campaign->sent_count)->toBe(2)
->and((string) $campaign->actual_cost_rub)->toBe('0.00');
$messages = ClientSmsMessage::where('campaign_id', $campaign->id)->get()->keyBy('phone');
expect($messages->get('79990000001')->status)->toBe(ClientSmsMessage::STATUS_FAKE_SENT)
->and($messages->get('79990000002')->status)->toBe(ClientSmsMessage::STATUS_FAKE_SENT)
->and($messages->get('79990000009')->status)->toBe(ClientSmsMessage::SKIP_OPTOUT)
->and((string) $messages->get('79990000001')->cost_rub)->toBe('0.00')
// Журнал помнит, СКОЛЬКО СМС ушло этим сообщением: длинное письмо — это два,
// и накопленный за месяц объём считается в СМС, а не в строках (В-108).
->and($messages->get('79990000001')->segments)->toBe((int) $campaign->segments);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('1000.00')
->and((string) $wallet->frozen_rub)->toBe('0.00');
});
it('реальный режим: списывает по факту, снимает заморозку, номера уходят через МТС', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(mtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000003', 'name' => 'В', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
bindSendJobRoute();
$campaign = makeSendJobCampaign($tenant->id, 3);
makeSendJobSnapshot($campaign);
// Заморозка на этапе создания рассылки (смета = цена × получатели × сегменты).
$estimate = '25.50'; // 8.50 × 3 × 1
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, $estimate);
$walletBefore = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $walletBefore->frozen_rub)->toBe('25.50');
runSendJob($campaign->id, $tenant->id, $wallet);
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and($campaign->sent_count)->toBe(3)
->and((string) $campaign->actual_cost_rub)->toBe('25.50');
$messages = ClientSmsMessage::where('campaign_id', $campaign->id)->get();
expect($messages)->toHaveCount(3);
foreach ($messages as $m) {
expect($m->status)->toBe(ClientSmsMessage::STATUS_SENT)
->and($m->provider_key)->toBe('mts')
->and($m->provider_message_id)->toBe('MSG-OK-1')
->and((string) $m->cost_rub)->toBe('8.50');
}
$walletAfter = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $walletAfter->balance_rub)->toBe('974.50') // 1000 25.50
->and((string) $walletAfter->frozen_rub)->toBe('0.00'); // заморозка снята
});
it('идемпотентность: повторный запуск не плодит сообщения и не трогает баланс (sandbox)', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
$campaign = makeSendJobCampaign($tenant->id, 2);
makeSendJobSnapshot($campaign);
$run = fn () => runSendJob($campaign->id, $tenant->id);
$run();
$countAfterFirst = ClientSmsMessage::where('campaign_id', $campaign->id)->count();
$run();
$countAfterSecond = ClientSmsMessage::where('campaign_id', $campaign->id)->count();
expect($countAfterFirst)->toBe(2)
->and($countAfterSecond)->toBe(2);
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and($campaign->sent_count)->toBe(2); // не удвоилось
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('1000.00');
});
it('идемпотентность реального режима: второй запуск не списывает повторно (external_key)', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(mtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
bindSendJobRoute();
$campaign = makeSendJobCampaign($tenant->id, 2);
makeSendJobSnapshot($campaign);
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '17.00'); // 8.50 × 2
$run = fn () => runSendJob($campaign->id, $tenant->id, $wallet);
$run();
$balanceAfterFirst = (string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
$run();
$balanceAfterSecond = (string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
expect($balanceAfterFirst)->toBe('983.00') // 1000 17.00
->and($balanceAfterSecond)->toBe('983.00') // без повторного списания
->and(ClientSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
});
it('факт списывается ПОЛНОСТЬЮ при падении посреди рассылки и повторе — бизнес не теряет деньги', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(mtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
bindSendJobRoute();
$campaign = makeSendJobCampaign($tenant->id, 2);
makeSendJobSnapshot($campaign);
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '17.00'); // 8.50 × 2
// Заход 1: провайдер отправляет ПЕРВЫЙ номер, затем падает нежданной ошибкой
// (НЕ SmsSendException — джоб не ловит) → прерывается ДО финализации.
// Первый номер уже ушёл и должен быть оплачен, иначе при повторе он не спишется.
$flaky = new class implements SmsProvider
{
public int $calls = 0;
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->calls++;
if ($this->calls >= 2) {
throw new RuntimeException('обрыв связи с МТС посреди рассылки');
}
return new SmsSendResult('MSG-FLAKY-1', $message->segments, 0, CarbonImmutable::now());
}
};
bindSendJobRoute($flaky);
try {
runSendJob($campaign->id, $tenant->id, $wallet);
} catch (RuntimeException) {
// ожидаемо: заход упал посреди рассылки
}
// Заход 2 (ретрай): рабочий провайдер — дошлёт остаток.
bindSendJobRoute();
runSendJob($campaign->id, $tenant->id, $wallet);
// Оба номера отправлены — и ОБА оплачены (2 × 8.50 = 17.00), а не только один.
$sent = ClientSmsMessage::where('campaign_id', $campaign->id)
->whereIn('status', [ClientSmsMessage::STATUS_SENT, ClientSmsMessage::STATUS_FAKE_SENT])
->count();
expect($sent)->toBe(2);
$walletAfter = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $walletAfter->balance_rub)->toBe('983.00') // 1000 17.00, НЕ 991.50
->and((string) $walletAfter->frozen_rub)->toBe('0.00');
$campaign->refresh();
expect($campaign->sent_count)->toBe(2)
->and((string) $campaign->actual_cost_rub)->toBe('17.00');
});
it('нет кампании: джоб тихо выходит без исключения', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
runSendJob(999999, $tenant->id);
expect(ClientSmsMessage::count())->toBe(0);
});
/**
* В-125 — блокер выката: снимок читался ВНЕ пометки клиента.
*
* Почему это нельзя проверить «в лоб». На бою очередь ходит в базу ролью, которая
* подчиняется изоляции по клиенту, и без пометки база отдаёт ноль строк — молча, без
* ошибки. Локально и в тестах мы ходим суперпользователем, для которого изоляции не
* существует: замер показал 1 строку у суперпользователя и 0 у боевой роли на ОДНОЙ И
* ТОЙ ЖЕ строке снимка. Значит поймать дыру «по количеству отправленных» тест не может
* физически — он всегда зелёный.
*
* Поэтому проверяем не результат, а ПОРЯДОК: каждый запрос к снимку обязан идти внутри
* той же открытой транзакции, в которой уже выставлена пометка клиента. Уровень
* вложенности транзакции — то, что отличает «пометка стоит здесь и сейчас» от «пометка
* стояла раньше, в другой, уже закрытой транзакции»: SET LOCAL живёт только до её конца.
*/
it('В-125 — снимок читается только под пометкой клиента, иначе на бою тихий ноль', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
$campaign = makeSendJobCampaign($tenant->id, 1);
makeSendJobSnapshot($campaign);
$reads = [];
$ctxLevel = null;
DB::listen(function ($query) use (&$reads, &$ctxLevel) {
$level = DB::transactionLevel();
// Транзакция, в которой ставили пометку, закрылась — пометки больше нет.
if ($ctxLevel !== null && $level < $ctxLevel) {
$ctxLevel = null;
}
if (str_contains($query->sql, 'app.current_tenant_id')) {
$ctxLevel = $level;
}
if (str_contains($query->sql, 'client_sms_campaign_phones')
&& str_starts_with(ltrim(strtolower($query->sql)), 'select')) {
$reads[] = $ctxLevel !== null;
}
});
runSendJob($campaign->id, $tenant->id);
// Сначала убеждаемся, что сравниваем не ноль с нулём: снимок реально читали и
// номер реально ушёл. Без этого тест был бы зелёным на пустом месте (урок В-46).
expect($reads)->not->toBeEmpty()
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::STATUS_FAKE_SENT)->count())->toBe(1);
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());
}
};
}
/**
* Решение владельца 05.08.2026: своё имя есть, но у этой сети не согласовано —
* номер не уходит ВОВСЕ. Подписать чужую рассылку нашим именем нельзя.
*/
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)->toBeEmpty();
// И деньги целы: за неотправленное не берём.
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('1000.00');
});
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']);
});