Files
portal/app/tests/Feature/ClientSms/RecipientSelectorTest.php
T
Дмитрий a39694e11b feat(смс-клиент): общий стоп-лист портала — номер закрыт у всех клиентов сразу
Этап 1 «Нельзя обжечься», задачи 1–3 приёмочного листа
(docs/superpowers/2026-07-27-PRIEMKA-client-sms-fixes.md, строки 1.4, 1.4а, 1.5).

- новая таблица sms_global_optouts (SaaS-уровень, RLS намеренно нет: номер
  закрывается у всех тенантов сразу — защита договора с МТС при жалобе);
- ClientSmsRecipientSelector отсеивает такой номер ПЕРВЫМ, раньше тенантского
  стоп-листа: наше обязательство перед оператором сильнее настроек клиента;
- клиенту причина видна словами — «номер закрыт администрацией», а не молчаливое
  «не отправлено» (решение владельца, вопрос В-2 листа);
- админ-адреса /api/admin/sms/global-optouts: внести (номер в любом виде),
  список, убрать; непонятый номер отклоняется внятно;
- запись v9.00 в db/CHANGELOG_schema.md.

Проверено: ClientSms 122/122, приём лидов 17/17, phpstan по своим файлам 0,
gitleaks чисто. Защита доказана вырезанием — без отсева падают ровно 3 теста.

Живого прогона строк 1.4/1.5 ещё нет: админ-экран идёт задачей 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:17:20 +03:00

173 lines
6.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsOptout;
use App\Models\SmsGlobalOptout;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Отбор получателей клиентской СМС-рассылки (Task 5). Получатели уже несут
* оператора (его определил ClientSmsAudienceBuilder) — здесь только стоп-лист,
* дубли внутри рассылки и маршрут. Никакого отсева по каналу отправителя —
* Фаза 1 всегда шлёт от имени liderra.ru.
*
* Роутер собран руками (не app()-синглтон): в песочнице singleton — заглушка,
* которая маршрутизирует всё подряд, и тест ничего бы не проверил.
*/
uses(RefreshDatabase::class);
function client_sms_selector(): ClientSmsRecipientSelector
{
$router = new SmsRouter([
new MtsSmsProvider(token: 'test', serves: ['mts'], priceKopecks: ['*' => 0]),
]);
return new ClientSmsRecipientSelector($router, new OperatorNormalizer);
}
it('стоп-лист побеждает даже подходящий маршрут', function () {
$tenant = Tenant::factory()->create();
ClientSmsOptout::create([
'tenant_id' => $tenant->id,
'phone' => '79990000001',
]);
$plan = client_sms_selector()->build(
[['phone' => '79990000001', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000001' => ClientSmsMessage::SKIP_OPTOUT]);
});
it('номер МТС уходит в отправку через МТС-провайдера', function () {
$tenant = Tenant::factory()->create();
$plan = client_sms_selector()->build(
[['phone' => '79990000002', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->skipped)->toBeEmpty()
->and($plan->sendable)->toHaveCount(1)
->and($plan->sendable[0]['phone'])->toBe('79990000002')
->and($plan->sendable[0]['operator'])->toBe('mts')
->and($plan->sendable[0]['provider'])->not->toBeNull()
->and($plan->sendable[0]['provider']->key())->toBe('mts');
});
it('оператор без маршрута помечается skipped_no_route', function () {
$tenant = Tenant::factory()->create();
$plan = client_sms_selector()->build(
[['phone' => '79990000003', 'operator' => 'beeline']],
$tenant->id,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000003' => ClientSmsMessage::SKIP_NO_ROUTE]);
});
it('неизвестный/пустой оператор тоже помечается skipped_no_route', function () {
$tenant = Tenant::factory()->create();
$planNull = client_sms_selector()->build(
[['phone' => '79990000005', 'operator' => null]],
$tenant->id,
);
$planGarbage = client_sms_selector()->build(
[['phone' => '79990000006', 'operator' => 'garbage']],
$tenant->id,
);
expect($planNull->skipped)->toBe(['79990000005' => ClientSmsMessage::SKIP_NO_ROUTE])
->and($planGarbage->skipped)->toBe(['79990000006' => ClientSmsMessage::SKIP_NO_ROUTE]);
});
it('дубль внутри одной рассылки считается один раз', function () {
$tenant = Tenant::factory()->create();
$plan = client_sms_selector()->build(
[
['phone' => '79990000004', 'operator' => 'mts'],
['phone' => '79990000004', 'operator' => 'mts'],
],
$tenant->id,
);
expect($plan->sendable)->toHaveCount(1)
->and($plan->skipped)->toBe(['79990000004' => ClientSmsMessage::SKIP_DUPLICATE]);
});
it('номер из общего стоп-листа портала не получает рассылку', function () {
$tenant = Tenant::factory()->create();
SmsGlobalOptout::create(['phone' => '79990000010', 'reason' => 'жалоба оператору']);
$plan = client_sms_selector()->build(
[['phone' => '79990000010', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000010' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
});
it('общий стоп-лист закрывает номер у ВСЕХ тенантов сразу', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
SmsGlobalOptout::create(['phone' => '79990000011']);
foreach ([$tenantA->id, $tenantB->id] as $tenantId) {
$plan = client_sms_selector()->build(
[['phone' => '79990000011', 'operator' => 'mts']],
$tenantId,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000011' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
}
});
it('общий стоп-лист проверяется РАНЬШЕ тенантского — причина видна именно общая', function () {
$tenant = Tenant::factory()->create();
SmsGlobalOptout::create(['phone' => '79990000012']);
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000012']);
$plan = client_sms_selector()->build(
[['phone' => '79990000012', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->skipped)->toBe(['79990000012' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
});
it('стоп-лист чужого тенанта не глушит тот же номер у своего', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
ClientSmsOptout::create([
'tenant_id' => $tenantB->id,
'phone' => '79990000007',
]);
$plan = client_sms_selector()->build(
[['phone' => '79990000007', 'operator' => 'mts']],
$tenantA->id,
);
expect($plan->skipped)->toBeEmpty()
->and($plan->sendable)->toHaveCount(1);
});