51b9c20d0b
Две находки обязательного проверяющего доступа (приёмка Этапа 2, журнал В-80). Обе — про молчаливые поломки: ошибок нет, тесты зелёные, защиты нет. 1. Индекс ключа заказа мог тихо не создаться. Миграция делает два шага — колонку и уникальный индекс, — а защита от повторного запуска стояла общим выходом в начале: «колонка есть, значит всё сделано». На бою SQL подаётся руками; прервалась подача между шагами — колонка легла, индекс нет, повторный накат прошёл мимо. Дальше два одновременных запроса с одним ключом создали бы две рассылки и списали деньги дважды. Проверка стала пошаговой. Доказано вырезанием: до правки новый тест краснеет («защита от двойного заказа потеряна молча»), после — зелёный. Живьём на локальной dev-базе: индекс уронен руками, повторный накат его вернул. 2. Сторож прав спрашивал базу только про рабочую роль. А гардов с именами служебных ролей в миграциях семь, и опечатка внутри такого гарда ошибки НЕ даёт — права просто молча не выдаются. Ровно тот блокер выката, ради которого сторож и заводился (В-36). Теперь спрашиваем и crm_admin_user, и crm_supplier_worker — по матрице, сверенной со всеми GRANT'ами миграций, — и счётчики для всех трёх ролей. Доказано вырезанием: опечатка в имени роли внутри гарда → сторож краснеет с именем роли, таблицы и права. Прогоны: СМС 171/171 (было 169, два новых теста), приём лидов 17/17, pint чисто. Структура таблиц не менялась — запись в журнале схемы v9.11.
98 lines
5.1 KiB
PHP
98 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Миграции модуля должны выдерживать ПОВТОРНЫЙ запуск (журнал В-73).
|
|
*
|
|
* Зачем это вообще нужно. Обычно повторный запуск невозможен: Laravel помнит,
|
|
* какие файлы уже применены. Но на боевом кластере миграции накатываются иначе —
|
|
* SQL вынимается командой `migrate --pretend` и подаётся в базу руками одной
|
|
* транзакцией. Там памяти «этот файл уже применён» нет: повторный накат того же
|
|
* куска — после сорвавшегося выката, после отката на середине — упирается в
|
|
* «таблица уже существует» и валит всю транзакцию целиком.
|
|
*
|
|
* Тест прогоняет `up()` каждой миграции модуля ВТОРОЙ раз — сразу после того, как
|
|
* её уже применил RefreshDatabase. Ничего не должно сломаться и ничего не должно
|
|
* измениться. Проверяется вырезанием: убери защиту в любой миграции — тест краснеет
|
|
* с именем этой миграции.
|
|
*/
|
|
uses(RefreshDatabase::class);
|
|
|
|
/** Миграции клиентского СМС-модуля — по маске имени, чтобы новые попадали сюда сами. */
|
|
function clientSmsMigrationFiles(): array
|
|
{
|
|
$all = glob(database_path('migrations/*.php')) ?: [];
|
|
|
|
$mine = array_values(array_filter(
|
|
$all,
|
|
fn (string $path) => str_contains(basename($path), 'client_sms')
|
|
|| str_contains(basename($path), 'sms_global_optouts'),
|
|
));
|
|
|
|
// Модуль продаж (`sales_sms`) — чужая работа, сюда не берём.
|
|
return array_values(array_filter(
|
|
$mine,
|
|
fn (string $path) => ! str_contains(basename($path), 'sales_sms'),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Отдельный случай, найденный проверяющим при приёмке Этапа 2 (журнал В-80, З-5).
|
|
*
|
|
* Повторный запуск — это не только «не сломаться», но и «доделать недоделанное».
|
|
* Миграция ключа заказа состоит из ДВУХ шагов: колонка и уникальный индекс. Если
|
|
* ручная подача SQL на бою прервалась между ними, колонка есть, а индекса нет —
|
|
* и повторный накат, выходящий по наличию колонки, молча пройдёт мимо. Исчезнет
|
|
* последняя линия обороны от двойного заказа: два одновременных запроса с одним
|
|
* ключом создадут две рассылки и спишут деньги дважды.
|
|
*
|
|
* Класс поломки прежний — «тихо стало хуже»: ошибок нет, тесты зелёные, защиты нет.
|
|
*/
|
|
it('повторный запуск восстанавливает индекс ключа заказа, если его нет', function () {
|
|
$indexExists = fn () => DB::selectOne(<<<'SQL'
|
|
SELECT 1 AS ok FROM pg_indexes
|
|
WHERE schemaname = 'public' AND indexname = 'client_sms_campaigns_idem_uniq'
|
|
SQL) !== null;
|
|
|
|
expect($indexExists())->toBeTrue('Индекса нет ещё до проверки — миграция не применилась');
|
|
|
|
// Так выглядит база после прерванной ручной подачи SQL: колонка легла, индекс — нет.
|
|
DB::statement('DROP INDEX client_sms_campaigns_idem_uniq');
|
|
expect($indexExists())->toBeFalse();
|
|
|
|
$path = collect(clientSmsMigrationFiles())
|
|
->first(fn (string $p) => str_contains(basename($p), 'idempotency_key'));
|
|
|
|
expect($path)->not->toBeNull('Миграция ключа заказа не найдена по имени файла');
|
|
|
|
(require $path)->up();
|
|
|
|
expect($indexExists())->toBeTrue(
|
|
'Повторный запуск не восстановил уникальный индекс — защита от двойного заказа потеряна молча',
|
|
);
|
|
});
|
|
|
|
it('все миграции модуля переживают повторный запуск', function () {
|
|
$files = clientSmsMigrationFiles();
|
|
|
|
expect($files)->not->toBeEmpty();
|
|
|
|
foreach ($files as $path) {
|
|
$migration = require $path;
|
|
|
|
try {
|
|
$migration->up();
|
|
} catch (Throwable $e) {
|
|
$this->fail(sprintf(
|
|
"Миграция %s не переживает повторный запуск:\n%s",
|
|
basename($path),
|
|
$e->getMessage(),
|
|
));
|
|
}
|
|
}
|
|
});
|