a39694e11b
Этап 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>
173 lines
6.2 KiB
PHP
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);
|
|
});
|