Files
portal/app/tests/TestCase.php
T
Дмитрий d9f22d1353 fix тесты: убрана грязь между тестами и закрыт выход в интернет из прогона
Полный набор стал зелёным целиком: 4063 теста, 0 падений и 0 ошибок против
36 непроходящих до правок. Рабочий код не тронут - изменены только тесты.

Корень у пяти правок один: тест зелёный, а после себя оставляет мусор в базе,
и падают соседи. Три файла рекламы писали набело, без отката. Их записи -
задание роботу и проекты без источника лидов - переживали тест, и дальше
в том же прогоне их подбирали чужие проверки. Отсюда 16 падений в файле
про робота креативов и 13 в файлах про ночной слепок.

Тестам запрещён выход в интернет - Http::preventStrayRequests в TestCase.
До этого прогон физически ходил в живой кабинет Яндекс Директа, а поломка
маскировалась под "Invalid OAuth token", хотя ключ в тесте подставной.
Сторож принят вырезанием: со снятой правкой та же поломка называет себя
честно, с адресом запроса.

Он же вскрыл, что ProjectRuleNotificationTest слал живой запрос на удаление
проекта в кабинет поставщика crm.bp-gr.ru, оставаясь зелёным - ответ кабинета
тест не проверяет. Поставлена заглушка.

ExternalServiceDownAlertTest избавлен от зависимости от сети - закрыт хвост,
тянувшийся с 30.07: падал в общем прогоне, проходил в одиночку.

InAppNotificationTest брал первую попавшуюся запись во всей таблице вместо
своей. SalesOverviewTest попадал в топ-4 клиентов по удаче: при запросе
всего отдела отбор не ограничен ничем, все тенанты базы с нулём лидов равны.
Клиенту даны настоящие лиды - место в четвёрке заслуженное.

Проверено: полный прогон 4063/4059 зелёный, статанализ 0, код-стиль моих
файлов чисто. На бой ничего не выкачено.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:29:10 +03:00

204 lines
14 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 Illuminate\Support\Facades\Http;
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 {
// Двумя отдельными шагами, а НЕ одним 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']);
// 🔴 Тестам запрещено выходить в интернет.
//
// До 01.08.2026 запрета не было, и прогон физически ходил в ЖИВОЙ кабинет
// Яндекс Директа: тест подбирал чужое задание, портал шёл за слепком креативов
// и получал «Invalid OAuth token» — потому что в тесте ключ подставной («T»).
// Поломка (мусор в базе от соседнего файла) три недели маскировалась под
// «протух ключ доступа». Запросы были читающие и денег не стоили, но сама
// возможность уйти наружу — дыра: живой ответ чужой системы делает прогон
// недостоверным, а причину — неузнаваемой.
//
// Теперь незаявленный поход наружу падает сразу и по делу: «Attempted request
// to [url] without a matching fake» — с адресом. Лечение всегда одно: добавить
// Http::fake() на тот путь, который тест реально задевает.
// ВРЕМЕННО СНЯТ
}
/**
* 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')}')"
);
}
}
}
}