Files
portal/app/tests/Feature/ClientSms/MigrationGrantsTest.php
T
Дмитрий 97a93cb763 feat(смс-клиент): кнопка «Продолжить рассылку» — доводим до конца тех, на кого не хватило денег
Строка листа 4.13, решение владельца В-49 «надо обязательно». Рассылка встала
из-за денег → клиент пополнил кошелёк → нажал «Продолжить», и она идёт дальше
с того места, где остановилась.

🔴 Главная мина (В-151, подтверждена чтением кода): джоб отправки считает
обработанным ЛЮБОЙ номер, у которого есть запись в журнале этой рассылки, без
разбора статуса. А номер, на котором кончились деньги, записан как
«не хватило денег». Значит простой повторный запуск молча пропустил бы его:
человек заплатил бы за продолжение, а получатель не получил бы ничего.

🔴 И вторая находка, которой в плане не было (В-184): «оставить строку и просто
не считать номер обработанным» НЕВОЗМОЖНО физически — на журнале сообщений
уникальный ключ по (клиент, рассылка, номер), вторая запись не вставится.
Поэтому продолжение эти строки УДАЛЯЕТ, и по смыслу это верно: «не отправили
из-за денег» фиксирует НЕслучившееся и после пополнения перестаёт быть правдой.
Заодно замерен масштаб: при остановке пишется РОВНО ОДНА такая строка, у
остальных номеров записей нет вовсе.

Что сделано:
· POST /api/sms/campaigns/{id}/resume — убирает строки «не хватило денег»,
  ставит рассылку в очередь, гасит причину остановки (иначе экран продолжал бы
  объяснять человеку прошлую остановку у работающей рассылки);
· продолжаем ТОЛЬКО остановленную ИЗ-ЗА ДЕНЕГ (В-187). Остановленную человеком
  продолжать — значит отменить его решение; сорванную сторожем — наступить на ту
  же поломку. У каждой причины свой человеческий отказ, и текст под тестом;
· правило живёт в ОДНОМ месте на сервере (как enableDecision в Task 4): список
  рассылок отдаёт готовый признак can_resume и число «сколько ещё уйдёт». Счёт
  остатка — в читателе снимка, где уже живёт «кто ждёт» (одно место, В-96);
· кнопка «Продолжить» в таблице рассылок + подтверждение: сколько осталось
  отправить, что повторно никому не пойдёт, что цена прежняя;
· цену НЕ пересчитываем, снимок НЕ пересобираем, заново НЕ замораживаем
  (В-39, В-188): списание идёт поштучно с проверкой перед каждым номером, в
  минус рассылка не уйдёт — кончатся деньги, встанет снова с той же причиной.

🔴 Мой же тест поймал расхождение с моим же решением (В-189): проверку денег я
впихнул в правило «можно ли продолжить» — и кнопка исчезла бы именно у того,
кому она нужна. Разделил: состояние — признак для экрана, деньги — проверка по
нажатию с числами («свободно 0.00 ₽, одно сообщение 8.50 ₽»).

Право DELETE на журнал сообщений рабочей роли — миграция 2026_08_01_101000,
схема v9.22 (В-186, вторая половина мины В-152): без него продолжение на бою
падало бы «нет доступа». Сторож MigrationGrantsTest расширен.

Проверено: ClientSms 320/320 (13 пачек, все с первой попытки), приём лидов
17/17, фронт 1700 + 3 пропущенных (одна чужая давняя ошибка, В-57), phpstan 2
чужие давние, vue-tsc 5 чужих давних, pint чисто. Шесть вырезов, каждый
покраснел ровно там, где вырезан.

🔴 Живой прогон ДЕНЕЖНЫЙ и парный под боевой ролью crm_app_user: рассылка на 5
номеров при деньгах на 2 встала (ушло 2, списано 17.00 ₽, одна строка «не
хватило денег»); продолжение без пополнения — отказ с числами; БЕЗ пометки
клиента рассылка не видна вовсе (изоляция держит); после пополнения с пометкой —
ушло 5 из 5, списано 42.50 ₽, в журнале ровно 5 записей по одной на номер, «не
хватило денег» не осталось. В браузере: кнопка видна глазами, после нажатия и
прогона очереди рассылка «завершена, 5 из 5», кнопка исчезла, у нетронутой
рассылки осталась. Заодно поправлена речь: было «отправим ещё 3 сообщений».

Стенд возвращён и сверен; намеренное изменение одно — миграция накатана на
локальную dev-базу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:24:35 +03:00

173 lines
9.8 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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Сторож прав рабочей роли на таблицы модуля.
*
* Зачем он появился (Task 8). В миграциях модуля права выдавались строкой
* `GRANT … TO crm_app_user` без проверки, есть ли такая роль: на чистой базе это
* валит `migrate` целиком. Проверку я добавил — но у неё есть своя опасность:
* опечатка в имени роли внутри проверки НЕ даёт ошибки, права просто молча не
* выдаются. А это ровно тот блокер выката, что стоил нам дня 27.07 (журнал В-36):
* на бою первая же запись падает с «нет доступа», а тесты и локальная база этого
* не видят — там суперпользователь.
*
* Поэтому здесь проверяется не текст миграции, а результат: реально ли у роли есть
* право писать в каждую таблицу модуля и пользоваться её счётчиком.
*
* ⚠️ Честное ограничение: тест доказывает это только там, где роли заведены
* (`db/00_create_roles.sql`). Где ролей нет — он пропускается и не доказывает ничего.
*/
uses(RefreshDatabase::class);
/** Таблица → права, которые модуль обещает рабочей роли. */
const CLIENT_SMS_TABLE_GRANTS = [
'client_sms_campaigns' => ['SELECT', 'INSERT', 'UPDATE'],
// UPDATE у снимка и у базы контактов добавлен при приёмке Этапа 3 (журнал В-126,
// В-127). Этап 3 научил джобы обогащения ПРАВИТЬ эти таблицы — проставлять
// найденный регион, пояс и оператора, — а права остались от старого пути, где
// строки только вставляли и удаляли. Локально не видно: dev ходит
// суперпользователем. На бою правка падала бы «нет доступа», и номер без пояса
// не ушёл бы НИКОГДА (решение владельца В-85).
'client_sms_campaign_phones' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
// DELETE добавлен в Этапе 4 (строка листа 4.13, журналы В-184, В-186). Кнопка
// «Продолжить рассылку» убирает строки «не хватило денег»: джоб считает обработанным
// любой номер с записью этой рассылки, и без удаления такой номер молча выпал бы.
// Оставить строку рядом с будущим «отправлено» не даёт уникальный ключ по
// (клиент, рассылка, номер). Миграция `2026_08_01_101000`.
'client_sms_messages' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_optouts' => ['SELECT', 'INSERT', 'DELETE'],
'client_sms_contacts' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_templates' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_tariffs' => ['SELECT'],
'client_sms_settings' => ['SELECT'],
'client_sms_senders' => ['SELECT', 'INSERT', 'UPDATE'],
'client_sms_auto_rule' => ['SELECT', 'INSERT', 'UPDATE'],
'sms_global_optouts' => ['SELECT'],
];
/**
* Служебные роли: что модуль обещает АДМИН-зоне и служебному работнику.
*
* Появилось при приёмке Этапа 2 (журнал В-80, замечание проверяющего З-2): сторож
* выше спрашивал базу только про рабочую роль, а гардов с именами двух других ролей
* в миграциях семь. Опечатка внутри такого гарда ошибки НЕ даёт — права молча не
* выдаются, и это ровно тот блокер выката, ради которого сторож и заводился.
*
* Список сверен с миграциями (все `GRANT … TO …` семейства), а не переписан на глаз.
*/
const CLIENT_SMS_SERVICE_GRANTS = [
'crm_admin_user' => [
'client_sms_tariffs' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_settings' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_senders' => ['SELECT', 'UPDATE'],
'sms_global_optouts' => ['SELECT', 'INSERT', 'DELETE'],
],
'crm_supplier_worker' => [
'client_sms_senders' => ['SELECT'],
'client_sms_auto_rule' => ['SELECT'],
'sms_global_optouts' => ['SELECT'],
// Добавлено при приёмке Этапа 3 (журнал В-122). Команда добора
// `client-sms:resume-waiting` ходит под этой ролью (`pgsql_supplier`) —
// она кросс-тенантная и tenant-контекст не ставит. Читает рассылки,
// строки снимка и журнал, а строкам снимка ещё и проставляет отправку.
// Прав на эти три таблицы роли выдано НЕ было: локально не видно
// (суперпользователь), а на бою — «нет доступа» каждые 15 минут.
'client_sms_campaigns' => ['SELECT'],
// DELETE добавлен в Этапе 4 (строка листа 4.12, журналы В-152, В-181). Снимок —
// копия персональных данных, и старше 90 дней его уносит команда
// `client-sms:purge-snapshots` под этой же ролью (миграция `2026_08_01_100900`).
// Проверено живым прогоном: без этого права команда каждую ночь ПАДАЕТ с «нет
// доступа к таблице», и персональные данные остаются лежать.
'client_sms_campaign_phones' => ['SELECT', 'UPDATE', 'DELETE'],
'client_sms_messages' => ['SELECT'],
],
];
function clientSmsRoleExists(string $role): bool
{
return DB::selectOne('SELECT 1 AS ok FROM pg_roles WHERE rolname = ?', [$role]) !== null;
}
it('у рабочей роли есть обещанные права на таблицы модуля', function () {
if (! clientSmsRoleExists('crm_app_user')) {
$this->markTestSkipped('Роли crm_app_user на этой базе нет — проверять нечего.');
}
foreach (CLIENT_SMS_TABLE_GRANTS as $table => $privileges) {
foreach ($privileges as $privilege) {
$granted = DB::selectOne(
'SELECT has_table_privilege(?, ?, ?) AS ok',
['crm_app_user', $table, $privilege],
)->ok;
expect($granted)->toBeTrue("Роли crm_app_user не выдано право {$privilege} на {$table}");
}
}
});
it('у служебных ролей есть обещанные права на таблицы модуля', function () {
$checked = 0;
foreach (CLIENT_SMS_SERVICE_GRANTS as $role => $tables) {
if (! clientSmsRoleExists($role)) {
continue;
}
foreach ($tables as $table => $privileges) {
foreach ($privileges as $privilege) {
$granted = DB::selectOne(
'SELECT has_table_privilege(?, ?, ?) AS ok',
[$role, $table, $privilege],
)->ok;
expect($granted)->toBeTrue("Роли {$role} не выдано право {$privilege} на {$table}");
$checked++;
}
}
}
if ($checked === 0) {
$this->markTestSkipped('Служебных ролей на этой базе нет — проверять нечего.');
}
});
/**
* Счётчики (sequence) — отдельная история: право на таблицу их НЕ покрывает, и
* именно этого не хватало всем 14 миграциям модуля до фикса В-36.
*
* Спрашиваем про все три роли: миграция прав (`100600`) выдаёт счётчики каждой из них.
*/
it('у ролей есть право пользоваться счётчиками таблиц модуля', function () {
$roles = array_filter(
['crm_app_user', 'crm_admin_user', 'crm_supplier_worker'],
fn (string $role) => clientSmsRoleExists($role),
);
if ($roles === []) {
$this->markTestSkipped('Ролей модуля на этой базе нет — проверять нечего.');
}
$sequences = DB::select(<<<'SQL'
SELECT sequencename FROM pg_sequences
WHERE schemaname = 'public' AND sequencename LIKE 'client\_sms\_%'
SQL);
expect($sequences)->not->toBeEmpty();
foreach ($roles as $role) {
foreach ($sequences as $row) {
$granted = DB::selectOne(
'SELECT has_sequence_privilege(?, ?, ?) AS ok',
[$role, $row->sequencename, 'USAGE'],
)->ok;
expect($granted)->toBeTrue("Роли {$role} не выдано право на счётчик {$row->sequencename}");
}
}
});