Files
portal/app/tests/Feature/ClientSms/AutoSmsTest.php
T
Дмитрий 593030271b feat(смс): имя отправителя выбирается по оператору получателя, а не одно на всех
Имя согласовывает ОПЕРАТОР, и у каждого оно своё. Клиентская рассылка несла одно
имя всем каналам, а умолчанием ему служило `services.sms.mts.naming` — то есть имя,
согласованное с МТС, подставлялось Теле2 и подставилось бы Мегафону и Билайну.
Живой отказ Теле2 `invalid_source_address` 04.08.2026 — ровно этот механизм. На бою
у клиентов заведено НОЛЬ своих имён, значит это касалось каждой отправки.

Появился ClientSmsSenderResolver: спрашивает имя у той сети, чей номер. Пустая
строка на выходе — не «имени нет», а «своего имени для этой сети нет, канал
подпишется собственным согласованным именем из настроек».

Переведены на него оба места отправки: рассылка (SendClientSmsCampaignJob) и
авто-СМС по сделке (SendAutoSmsForDealJob).

Универсальный СМС-центр передавал имя КАК ЕСТЬ — теперь тоже падает на своё
(SMSC_NAMING). Раньше пустое имя туда не приходило никогда, с этой правкой стало
бы приходить: ушли бы СМС без отправителя.

Заплатка внутри канала Теле2 (приведение написания к своему регистру) остаётся, но
больше не несущая: она чинила следствие в одном канале, а причина была общая.

Экранное имя (ResolvesClientSmsSenderName) НЕ трогали — это запись намерения, а не
то, чем подпишется сеть; в его шапке теперь написано почему, чтобы не «починили»
обратно.

🪤 Урок по дороге: авто-СМС ловит любую ошибку и молча выходит. Справочник имён был
передан не в ту функцию — вместо падения получилась ТИШИНА, не уходило ничего.
Поймали три чужих теста, до того зелёных.

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

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

450 lines
19 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\SendAutoSmsForDealJob;
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;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\SmsQuietHours;
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;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
/**
* Авто-СМС на каждый НОВЫЙ лид (Task 15) — Eloquent observer поверх боевого
* потока создания сделок + денежно-критичный джоб.
*
* ⚠️ Observer dispatch'ит джоб через ->afterCommit(). Под RefreshDatabase внешняя
* тестовая транзакция НИКОГДА не коммитится, поэтому afterCommit-джоб при обычном
* dispatch не исполняется во время теста (Laravel gotcha). Поэтому:
* — факт, что observer СТАВИТ джоб на свежий лид, проверяем через Queue::fake
* (QueueFake пишет job сразу, минуя afterCommit-отсрочку);
* — поведение самого джоба проверяем прямым вызовом handle() (как SendJobTest).
*
* Синтетические номера 7999… — реальные НИКОГДА (в реальном режиме это боевая
* отправка).
*/
uses(RefreshDatabase::class);
// Час фиксируем на весь файл: с Этапа 3 авто-СМС отдаётся только в окно 10–20 по
// местному времени получателя. Без этого прогон, запущенный вечером, красил бы тесты
// про деньги и стоп-лист — и красил бы по совершенно другой причине.
beforeEach(function () {
Carbon::setTestNow(CarbonImmutable::parse('2026-08-04 09:00:00', 'UTC')); // 12:00 в Москве
});
afterEach(function () {
Carbon::setTestNow();
});
/** Правило авто-СМС тенанта (одно на тенанта — UNIQUE tenant_id). */
function makeAutoRule(int $tenantId, bool $enabled, string $body = 'Здравствуйте!'): ClientSmsAutoRule
{
return ClientSmsAutoRule::create([
'tenant_id' => $tenantId,
'enabled' => $enabled,
'body' => $body,
'sender_name' => 'liderra.ru',
'updated_by' => null,
]);
}
/**
* Свежий лид (в пределах суток) БЕЗ срабатывания DealSmsObserver — чтобы кейсы
* поведения джоба гоняли РОВНО один явный вызов (иначе observer поставил бы второй
* джоб, резолвящий селектор из контейнера, и он бы конфликтовал/блокировал по
* идемпотентности). Сам observer тестируется отдельно (см. тест ниже с Queue::fake).
*/
function makeFreshDeal(int $tenantId, string $phone, ?string $operator = 'МТС'): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => $phone,
'phone_operator' => $operator,
// Регион нужен с Этапа 3: номеру, про который мы не знаем, где он живёт, СМС
// не уходит вовсе (В-85). Предмет этих тестов — деньги, стоп-лист и повторы,
// поэтому регион ставим самый обычный. 82 = Москва.
'subject_code' => 82,
'received_at' => now(),
]));
}
/** Селектор с настоящим МТС-роутером — детерминированный маршрут для реального режима. */
function autoRealMtsSelector(): ClientSmsRecipientSelector
{
return new ClientSmsRecipientSelector(
new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]),
new OperatorNormalizer,
new AllowedSmsOperators(new OperatorNormalizer),
);
}
/** Успешный ответ МТС (омни-адаптер api.mts.ru), см. MtsSmsProvider::send. */
function autoMtsSuccessResponse(): array
{
return [
'code' => 0,
'data' => ['submitResults' => [['msid' => '79990000001', 'messageID' => 'MSG-AUTO-1', 'code' => 'OK']]],
];
}
/** Прямой вызов джоба (как SendJobTest) — обходит afterCommit-отсрочку RefreshDatabase. */
function runAutoJob(int $dealId, int $tenantId, ?ClientSmsRecipientSelector $selector = null): void
{
(new SendAutoSmsForDealJob($dealId, $tenantId))->handle(
$selector ?? app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(SmsQuietHours::class),
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
);
}
it('observer ставит авто-СМС джоб на ГЕНУИННО новый лид', function () {
config(['services.sms.sandbox' => true]);
Queue::fake();
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
// Напрямую (НЕ makeFreshDeal) — чтобы DealSmsObserver сработал; Queue::fake ловит dispatch.
$deal = Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '79990000001',
'phone_operator' => 'МТС',
'received_at' => now(),
]);
Queue::assertPushed(
SendAutoSmsForDealJob::class,
fn (SendAutoSmsForDealJob $job) => $job->dealId === (int) $deal->id
&& $job->tenantId === (int) $tenant->id,
);
});
it('джоб шлёт ровно одну авто-СМС на новый лид (sandbox → fake_sent, deal_id, campaign_id null)', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id);
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
$m = $messages->first();
expect($m->deal_id)->toBe((int) $deal->id)
->and($m->campaign_id)->toBeNull()
->and($m->status)->toBe(ClientSmsMessage::STATUS_FAKE_SENT)
->and((string) $m->cost_rub)->toBe('0.00');
});
it('идемпотентность: повторный запуск джоба на тот же лид не плодит второе сообщение', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id);
runAutoJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->where('deal_id', $deal->id)->count())->toBe(1);
});
it('правило выключено → авто-СМС не уходит', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: false);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0);
});
it('исторический лид → observer пропускает, джоб не ставится', function () {
config(['services.sms.sandbox' => true]);
Carbon::setTestNow('2026-07-25 12:00:00');
Queue::fake();
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '79990000009',
'phone_operator' => 'МТС',
'received_at' => now()->subDays(3),
]);
Queue::assertNotPushed(SendAutoSmsForDealJob::class);
Carbon::setTestNow();
});
it('стоп-лист: джоб журналирует skipped_optout и не шлёт', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000001']);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id);
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
$m = $messages->first();
expect($m->status)->toBe(ClientSmsMessage::SKIP_OPTOUT)
->and($m->deal_id)->toBe((int) $deal->id)
->and($m->campaign_id)->toBeNull()
->and((string) $m->cost_rub)->toBe('0.00');
});
it('реальный режим: уходит через МТС, списывает cost по external_key, повтор не списывает дважды', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(autoMtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
makeAutoRule($tenant->id, enabled: true);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id, autoRealMtsSelector());
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
$m = $messages->first();
expect($m->status)->toBe(ClientSmsMessage::STATUS_SENT)
->and($m->provider_key)->toBe('mts')
->and($m->provider_message_id)->toBe('MSG-AUTO-1')
->and($m->deal_id)->toBe((int) $deal->id)
->and($m->campaign_id)->toBeNull()
->and((string) $m->cost_rub)->toBe('9.00'); // объём 1 → ступень min_qty=1 → 9.00
// Деньги списаны: 1000 − 9.00.
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('991.00');
// Повторный запуск — не плодит и не списывает (deal_id-guard + external_key).
runAutoJob((int) $deal->id, (int) $tenant->id, autoRealMtsSelector());
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->where('deal_id', $deal->id)->count())->toBe(1)
->and((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('991.00');
});
it('реальный режим: денег не хватает → skipped_no_funds, ничего не шлёт, баланс не меняется', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(autoMtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5.00', null, 'test'); // < 9.00
makeAutoRule($tenant->id, enabled: true);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id, autoRealMtsSelector());
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
expect($messages->first()->status)->toBe(ClientSmsMessage::SKIP_NO_FUNDS)
->and($messages->first()->deal_id)->toBe((int) $deal->id);
// Ничего не ушло провайдеру, баланс цел.
Http::assertNothingSent();
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('5.00');
});
it('autoRule возвращает цену одного авто-СМС по тарифу (чтобы клиент видел, во что встанет)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
// короткий текст → 1 сегмент → объём 1 → ступень min_qty=1 = 9.00
$this->postJson('/api/sms/auto-rule', ['enabled' => true, 'body' => 'Спасибо за заявку!'])
->assertOk()
->assertJsonPath('estimated_cost_rub', '9.00');
$this->getJson('/api/sms/auto-rule')
->assertOk()
->assertJsonPath('estimated_cost_rub', '9.00');
});
it('эндпоинты autoRule/saveAutoRule: сохранение и чтение, снимок имени отправителя', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$save = $this->postJson('/api/sms/auto-rule', [
'enabled' => true,
'body' => 'Спасибо за заявку!',
]);
$save->assertOk()
->assertJsonPath('enabled', true)
->assertJsonPath('body', 'Спасибо за заявку!')
->assertJsonPath('sender_name', 'liderra.ru'); // снимок эффективного имени
$get = $this->getJson('/api/sms/auto-rule');
$get->assertOk()
->assertJsonPath('enabled', true)
->assertJsonPath('body', 'Спасибо за заявку!')
->assertJsonPath('sender_name', 'liderra.ru');
});
it('3.8 — авто-СМС считается по ступени с учётом отправленного за месяц', function () {
// Прежде сюда уезжало число сегментов одного сообщения: авто-СМС всегда шла по
// самой дорогой ступени, сколько бы клиент ни отправил за месяц. Парный прогон —
// тест выше, где накопленного нет и цена честные 9.00 (урок В-54).
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(autoMtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
makeAutoRule($tenant->id, enabled: true);
// В этом месяце клиент уже отправил 5 000 СМС — он давно на дешёвой ступени.
ClientSmsMessage::create([
'tenant_id' => $tenant->id,
'phone' => '79995559999',
'status' => ClientSmsMessage::STATUS_SENT,
'segments' => 5000,
'cost_rub' => '0.00',
]);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id, autoRealMtsSelector());
$m = ClientSmsMessage::where('tenant_id', $tenant->id)->where('deal_id', $deal->id)->firstOrFail();
expect($m->status)->toBe(ClientSmsMessage::STATUS_SENT)
->and((string) $m->cost_rub)->toBe('8.00') // а не 9.00, как было
->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']);
});