Files
portal/app/tests/TestCase.php
T
Дмитрий ceb88ab102 fix тесты: тестовая база перестала рваться посреди прогона
Корень оказался в Laravel: в конце каждого теста он проверяет, осталось ли
соединение в своей черновой транзакции, и если тест её закрыл сам - сбрасывает
признак "база собрана". Следующий тест пересобирает базу целиком прямо посреди
прогона, снося её под всеми остальными. В проекте полно кода со своими
транзакциями, поэтому срабатывало примерно раз на восемь прогонов.

Что сделано:
- пересборка базы теперь один раз и в самом начале прогона, а не лениво в
  середине по первому файлу, который её закажет;
- признак "собрано" держится взведённым - пересборка посреди прогона стала
  невозможна;
- отказ заливки схемы стал громким: раньше PDO мог вернуть отказ без ошибки,
  и прогон ехал дальше по неполной схеме;
- добавлена сверка полноты сборки: сколько шагов записано против того,
  сколько их лежит.

Приёмка вырезанием: новый тест-сторож зелёный с защитой и падает без неё,
прогон без защиты дольше на 12 секунд - это и есть лишняя пересборка.

Замер до и после, полный набор портала:
  без правки           3175 прошло, 837 упало
  с правкой, прогон 1  3971 прошло, 19 упало
  с правкой, прогон 2  3971 прошло, 19 упало, списки совпали дословно

Оставшиеся 19 - давние и не от базы: 11 падают на неверном токене Яндекса,
одна на типе исключения, семь счётных от накопления данных между тестами.

Плюс план перевода чтения вердикта и пересдачи на опрос - отдельным файлом,
код по нему ещё не писался.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:40:30 +03:00

178 lines
12 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
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabaseState;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
abstract class TestCase extends BaseTestCase
{
/**
* RefreshDatabase's migrate:fresh drops tables but NOT Postgres types by
* default. Our initial schema is loaded raw from db/schema.sql, whose
* `CREATE TABLE`s implicitly define composite row-types; when a table drop
* is blocked by a lingering dependency, its row-type survives db:wipe and
* the next schema.sql reload aborts on a duplicate-type error
* («legal_entities … уже существует»), leaving a PARTIAL schema — the
* long-standing intermittent «migrate:fresh иногда прерывается» flake
* (site_events/client_tg_tariffs randomly missing). Dropping types on every
* refresh clears those leftovers and makes migrate:fresh deterministic.
* Testing-only (APP_ENV=testing) — no production impact.
*/
protected $dropTypes = true;
/** Свой признак «БД уже собрана в этом процессе»: флаг Laravel для этого не годится — он сбрасывается сам. */
private static bool $bazaSobrana = false;
/**
* Пересборка тестовой БД — РОВНО ОДИН РАЗ и в САМОМ НАЧАЛЕ прогона.
*
* Было (корень «тестовая база рвётся через раз», замер 31.07.2026):
* в tests/Pest.php `->use(RefreshDatabase::class)` закомментирован с 08.05.2026,
* поэтому пересборку заказывают лишь 81 файл из 570. Laravel запускает её лениво —
* в setUp ПЕРВОГО такого файла, то есть в СЕРЕДИНЕ прогона. Доказано опытом:
* метка, поставленная в БД до прогона, после него исчезает. Следствия:
* — 489 файлов до этой точки бегут по остаткам прошлого прогона (и чужих веток);
* — сама пересборка сносит БД под уже отработавшими тестами;
* — если она падает, RefreshDatabaseState::$migrated остаётся false и КАЖДЫЙ
* следующий тест пробует пересобрать заново — каскад из сотен невнятных
* «таблица X не существует» вместо одной внятной ошибки. Замер: одна и та же
* пара файлов дала 6/6 красных, затем 10/10 зелёных.
*
* Стало: пересобираем здесь — до setUpTraits(), значит до того, как RefreshDatabase
* успеет сделать это лениво, — и поднимаем флаг $migrated. RefreshDatabase-файлы
* свою транзакцию по-прежнему открывают и откатывают (изоляция не тронута), но
* пересборку в середине прогона уже не запускают.
*
* Флаг поднимаем ДО пересборки намеренно: если она упадёт, прогон обязан умереть
* сразу и громко, а не выродиться в каскад повторных попыток.
*/
protected function setUpTraits()
{
if (! self::$bazaSobrana) {
self::$bazaSobrana = true;
$lekarstvo = 'Чинить так: php artisan db:wipe --drop-types --force, затем migrate --force.';
try {
// Artisan::call исключения НЕ бросает — он возвращает код возврата.
// Поэтому проверяем и код тоже: иначе неудачная пересборка прошла бы
// молча и весь прогон поехал бы по пустой схеме — ровно та болезнь,
// ради которой этот код и написан.
$code = Artisan::call('migrate:fresh', [
'--drop-views' => true,
'--drop-types' => true,
'--force' => true,
]);
} catch (Throwable $e) {
throw new RuntimeException(
'Не удалось пересобрать тестовую БД в начале прогона. '.$lekarstvo
.' Исходная ошибка: '.$e->getMessage(),
0,
$e
);
}
if ($code !== 0) {
throw new RuntimeException(
'Пересборка тестовой БД вернула код '.$code.' — схема неполная. '.$lekarstvo
);
}
// Кода возврата МАЛО. Заливка схемы идёт одной большой строкой через
// PDO::exec, который умеет вернуть отказ без исключения; бывало, что
// migrate:fresh рапортовал успех, а часть шагов не применилась. Тогда прогон
// сыпался невнятными «таблица X не существует» за сотни шагов от причины.
// Сверяем по факту: сколько шагов записано против того, сколько их лежит.
$ozhidaem = count(glob(database_path('migrations').DIRECTORY_SEPARATOR.'*.php'));
$primeneno = (int) DB::table('migrations')->count();
if ($primeneno !== $ozhidaem) {
throw new RuntimeException(
"Тестовая БД собрана НЕ ПОЛНОСТЬЮ: применено {$primeneno} шагов из {$ozhidaem}. "
.'Прогон остановлен здесь намеренно — иначе он развалился бы позже и непонятно где. '
.$lekarstvo
);
}
$this->afterRefreshingDatabase();
}
// 🔴 Взводим флаг Laravel НА КАЖДОМ тесте, а не один раз.
//
// Laravel в конце каждого теста проверяет, осталось ли соединение внутри своей
// «черновой» транзакции, и если нет — сбрасывает флаг в false
// (vendor/laravel/framework/.../Testing/RefreshDatabase.php, строки 158-160),
// тем самым заказывая ПОЛНУЮ пересборку БД перед следующим тестом. А выйти из
// транзакции тест может запросто: в проекте полно кода со своими DB::transaction
// и SET LOCAL. Итог — пересборка срабатывает ПОСРЕДИ прогона и сносит базу под
// всеми остальными тестами; отсюда и «через раз», и «таблица X не существует»
// за сотни шагов от причины (замер 31.07.2026: 1 отказ на ~8 прогонов).
//
// Мы собрали базу в начале прогона сами. Повторная пересборка в середине не нужна
// никогда и полезной быть не может — только ломает. Других мест, где вызывается
// migrate:fresh, в проекте нет (проверено поиском по app/ и tests/).
RefreshDatabaseState::$migrated = true;
return parent::setUpTraits();
}
protected function setUp(): void
{
parent::setUp();
// Quirk 72: supplier code (SupplierPortalClient::loadSession,
// RefreshSupplierSessionJob, CsvReconcileJob, RouteSupplierLeadJob)
// hardcodes Cache::store('redis'), bypassing phpunit.xml's
// CACHE_STORE=array. The Redis store is a shared external service —
// under `pest --parallel` every worker collides on the global
// `supplier:session` key, so one worker's afterEach forget()/flush()
// races another worker's mid-test loadSession(). Repoint the `redis`
// cache store at the in-process `array` driver: each worker then gets
// a hermetic, worker-local cache. Production keeps the real `redis`
// driver — this override only ever runs under APP_ENV=testing.
config(['cache.stores.redis.driver' => 'array']);
}
/**
* Laravel вызывает этот хук из трейта RefreshDatabase сразу после migrate:fresh.
*
* migrate:fresh пересоздаёт только родительские партиционированные таблицы;
* месячные партиции — отдельная команда (только вперёд). Тесты с датами
* прошлых месяцев иначе падают на отсутствии партиции. Диапазон: 2 назад +
* текущий + 3 вперёд. Идемпотентно.
*
* DDL идёт через pgsql_supplier (как в MonthlyPartitionManager::DDL_CONNECTION),
* иначе на проде crm_app_user не имеет прав создавать партиции.
*/
protected function afterRefreshingDatabase()
{
Artisan::call('partitions:create-months', ['--ahead' => 3]);
// Окно партиций считаем от РЕАЛЬНОГО системного времени (new DateTimeImmutable),
// а не от now()/Carbon: отдельные тесты замораживают Carbon::setTestNow на
// прошлые/будущие даты, и тогда единственный migrate:fresh пересоздавал бы
// месяцы вокруг замороженной даты, не покрывая реальный прошлый месяц (тесты
// с датами вроде now()->subDays(10) падали на отсутствии партиции). Диапазон
// -3..+5 месяцев. Идемпотентно. DDL через pgsql_supplier (DDL_CONNECTION) —
// у crm_app_user нет прав на создание партиций.
$base = new \DateTimeImmutable('first day of this month 00:00:00');
$parents = DB::select("select c.relname from pg_class c join pg_partitioned_table p on p.partrelid = c.oid where c.relkind = 'p'");
for ($m = -3; $m <= 5; $m++) {
$start = $base->modify(($m >= 0 ? '+' : '-').abs($m).' months');
$end = $start->modify('+1 month');
$suffix = '_y'.$start->format('Y').'_m'.$start->format('m');
foreach ($parents as $p) {
$t = $p->relname;
DB::connection('pgsql_supplier')->statement(
"CREATE TABLE IF NOT EXISTS \"{$t}{$suffix}\" PARTITION OF \"{$t}\" FOR VALUES FROM ('{$start->format('Y-m-d')}') TO ('{$end->format('Y-m-d')}')"
);
}
}
}
}