Files
portal/app/tests/Feature/ClientSms/UnsubscribeLinkTest.php
T
Дмитрий 7aa3083399 feat(смс-клиент): страница отказа по ссылке из СМС — без входа, номер маской, с лимитом
Человек, получивший СМС, теперь может отказаться сам: короткая ссылка
liderra.ru/s/<токен> открывает простую страницу с одной кнопкой. Нажал — номер
в стоп-листе именно той компании, от которой пришло сообщение.

- таблица client_sms_unsubscribe_links (RLS + tenant_isolation), одна ссылка
  на пару «клиент + номер» навсегда — при каждой рассылке новая не плодится
- сервис токенов: 12 символов, без 0/O/o и 1/l/I (ссылку диктуют вслух)
- публичная страница: blade без Vue-сборки, номер только маской +7 *** *** ** 67
- ограничение частоты 20/мин с IP — перебор ссылок иначе = утечка номеров;
  защита проверена вырезанием, тест на 429 краснеет
- неизвестная ссылка отвечает 404 без подсказок
- служебное соединение только в этом контроллере (тенант-контекста у страницы
  нет) + GRANT для crm_supplier_worker на client_sms_optouts
- CHANGELOG схемы v9.02, с напоминанием ПЕРЕзапустить 03_service_bypass_policies.sql

Строки приёмочного листа 1.6, 1.7, 1.8, 1.9 закрыты тестами; живой прогон —
после выката, страницу надо открыть телефоном.

Ветка feat/client-sms-broadcast, песочница, в main не влито.

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

60 lines
2.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientSmsUnsubscribeLink;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Строка листа 1.9: ссылка на один и тот же номер у одного клиента всегда одна
* и та же. Иначе при каждой рассылке одному человеку уезжала бы новая ссылка,
* а старые копились бы вечно.
*
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->otherTenant = Tenant::factory()->create();
$this->service = app(ClientSmsUnsubscribeLinkService::class);
});
it('токен на пару «клиент + номер» постоянный', function () {
$first = $this->service->tokenFor($this->tenant->id, '79990000001');
$second = $this->service->tokenFor($this->tenant->id, '79990000001');
expect($second)->toBe($first)
->and(strlen($first))->toBe(12)
->and(ClientSmsUnsubscribeLink::count())->toBe(1);
});
it('у разных клиентов на один номер разные ссылки', function () {
$mine = $this->service->tokenFor($this->tenant->id, '79990000001');
$theirs = $this->service->tokenFor($this->otherTenant->id, '79990000001');
expect($theirs)->not->toBe($mine)
->and(ClientSmsUnsubscribeLink::count())->toBe(2);
});
it('по токену находится тот самый клиент и тот самый номер', function () {
$token = $this->service->tokenFor($this->tenant->id, '79990000002');
$link = ClientSmsUnsubscribeLink::findOrFail($token);
expect($link->tenant_id)->toBe($this->tenant->id)
->and($link->phone)->toBe('79990000002');
});
it('в токене нет букв и цифр, которые путаются при чтении вслух', function () {
$tokens = [];
foreach (range(1, 20) as $i) {
$tokens[] = $this->service->tokenFor($this->tenant->id, '7999000'.str_pad((string) $i, 4, '0', STR_PAD_LEFT));
}
// 0/O/o, 1/l/I — человек диктует ссылку по телефону, путаница = чужая страница
expect(implode('', $tokens))->not->toMatch('/[0OolI1]/');
});