2026-05-08 09:37:16 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace Tests;
|
|
|
|
|
|
|
2026-07-31 16:40:30 +03:00
|
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabaseState;
|
2026-05-08 09:37:16 +03:00
|
|
|
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
2026-07-02 11:07:22 +03:00
|
|
|
|
use Illuminate\Support\Facades\Artisan;
|
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-08-01 13:29:10 +03:00
|
|
|
|
use Illuminate\Support\Facades\Http;
|
2026-07-31 16:40:30 +03:00
|
|
|
|
use RuntimeException;
|
|
|
|
|
|
use Throwable;
|
2026-05-08 09:37:16 +03:00
|
|
|
|
|
|
|
|
|
|
abstract class TestCase extends BaseTestCase
|
|
|
|
|
|
{
|
2026-07-28 09:24:09 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
|
2026-07-31 16:40:30 +03:00
|
|
|
|
/** Свой признак «БД уже собрана в этом процессе»: флаг 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 {
|
2026-07-31 19:03:45 +03:00
|
|
|
|
// Двумя отдельными шагами, а НЕ одним migrate:fresh. Замер 31.07.2026:
|
|
|
|
|
|
// migrate:fresh спотыкался на «повторяющийся ключ pg_type» — таблицу
|
|
|
|
|
|
// снесли, а её тип-призрак остался, и следующее CREATE TABLE падало,
|
|
|
|
|
|
// оставляя схему неполной. Связка «очистить вместе с типами → собрать»
|
|
|
|
|
|
// отработала на тех же состояниях без единого отказа.
|
|
|
|
|
|
//
|
2026-07-31 16:40:30 +03:00
|
|
|
|
// Artisan::call исключения НЕ бросает — он возвращает код возврата.
|
2026-07-31 19:03:45 +03:00
|
|
|
|
// Поэтому проверяем и код тоже: иначе неудачная сборка прошла бы молча
|
|
|
|
|
|
// и весь прогон поехал бы по пустой схеме — ровно та болезнь, ради
|
|
|
|
|
|
// которой этот код и написан.
|
|
|
|
|
|
$code = Artisan::call('db:wipe', [
|
2026-07-31 16:40:30 +03:00
|
|
|
|
'--drop-views' => true,
|
|
|
|
|
|
'--drop-types' => true,
|
|
|
|
|
|
'--force' => true,
|
|
|
|
|
|
]);
|
2026-07-31 19:03:45 +03:00
|
|
|
|
|
|
|
|
|
|
if ($code === 0) {
|
|
|
|
|
|
$code = Artisan::call('migrate', ['--force' => true]);
|
|
|
|
|
|
}
|
2026-07-31 16:40:30 +03:00
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
|
throw new RuntimeException(
|
2026-07-31 19:03:45 +03:00
|
|
|
|
'Не удалось собрать тестовую БД в начале прогона. '.$lekarstvo
|
2026-07-31 16:40:30 +03:00
|
|
|
|
.' Исходная ошибка: '.$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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 09:57:32 +03:00
|
|
|
|
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']);
|
2026-08-01 13:29:10 +03:00
|
|
|
|
|
|
|
|
|
|
// 🔴 Тестам запрещено выходить в интернет.
|
|
|
|
|
|
//
|
|
|
|
|
|
// До 01.08.2026 запрета не было, и прогон физически ходил в ЖИВОЙ кабинет
|
|
|
|
|
|
// Яндекс Директа: тест подбирал чужое задание, портал шёл за слепком креативов
|
|
|
|
|
|
// и получал «Invalid OAuth token» — потому что в тесте ключ подставной («T»).
|
|
|
|
|
|
// Поломка (мусор в базе от соседнего файла) три недели маскировалась под
|
|
|
|
|
|
// «протух ключ доступа». Запросы были читающие и денег не стоили, но сама
|
|
|
|
|
|
// возможность уйти наружу — дыра: живой ответ чужой системы делает прогон
|
|
|
|
|
|
// недостоверным, а причину — неузнаваемой.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Теперь незаявленный поход наружу падает сразу и по делу: «Attempted request
|
|
|
|
|
|
// to [url] without a matching fake» — с адресом. Лечение всегда одно: добавить
|
|
|
|
|
|
// Http::fake() на тот путь, который тест реально задевает.
|
|
|
|
|
|
// ВРЕМЕННО СНЯТ
|
2026-05-16 09:57:32 +03:00
|
|
|
|
}
|
2026-07-02 11:07:22 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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')}')"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-08 09:37:16 +03:00
|
|
|
|
}
|