593030271b
Имя согласовывает ОПЕРАТОР, и у каждого оно своё. Клиентская рассылка несла одно имя всем каналам, а умолчанием ему служило `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>
450 lines
19 KiB
PHP
450 lines
19 KiB
PHP
<?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']);
|
||
});
|