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 { // Двумя отдельными шагами, а НЕ одним migrate:fresh. Замер 31.07.2026: // migrate:fresh спотыкался на «повторяющийся ключ pg_type» — таблицу // снесли, а её тип-призрак остался, и следующее CREATE TABLE падало, // оставляя схему неполной. Связка «очистить вместе с типами → собрать» // отработала на тех же состояниях без единого отказа. // // Artisan::call исключения НЕ бросает — он возвращает код возврата. // Поэтому проверяем и код тоже: иначе неудачная сборка прошла бы молча // и весь прогон поехал бы по пустой схеме — ровно та болезнь, ради // которой этот код и написан. $code = Artisan::call('db:wipe', [ '--drop-views' => true, '--drop-types' => true, '--force' => true, ]); if ($code === 0) { $code = Artisan::call('migrate', ['--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')}')" ); } } } }