Files
portal/app/tests/TestCase.php
T
Дмитрий e724311e38 feat телеграм-робот: чтение вердикта и пересдача переведены на опрос
Теперь на опрос переведены все три работы робота, а не только запуск кампании.
Портал кладёт задание в таблицу, робот сам приходит за ним и отчитывается.
Это нужно потому, что робот живёт не на машине очереди - МТС не пускает адреса
дата-центров.

Что сделано по плану docs/superpowers/plans/2026-07-31-tg-poll-read-status-resubmit.md:

- два новых режима задания: чтение вердикта модерации и пересдача;
- применение вердикта вынуто из джоба в TelegramModerationVerdictApplier,
  применение итога пересдачи - в TelegramResubmitResultApplier; оба переноса
  построчные, денежная логика не менялась ни в одном символе;
- у обоих джобов появилась развилка по каналу: на опросе задание ставится,
  робот не запускается;
- приёмщик отчёта различает режимы - иначе отчёт о чтении вердикта применился
  бы как отчёт о запуске и сдвинул кампанию не туда;
- номера телефонов больше не выдаются режимам, которым они не нужны: чтению
  вердикта и пересдаче аудитория не требуется, она у кампании уже есть;
- у робота развилка по режиму вынесена в отдельный src/poll-plan.js, чтобы
  её можно было проверять без браузера и без сети;
- сторож прав на бою: роль портала обязана иметь право ставить задания.

Новых миграций и новых прав НЕ понадобилось: все три места ставят задание под
подключением по умолчанию, то есть под ролью портала, у которой права уже есть.
Схема БД не менялась, запись в CHANGELOG не требуется.

Приёмка вырезанием: убираем ограничение по режиму в выдаче номеров - два теста
падают, возвращаем - зелёные.

Проверено:
  телеграм на портале  244 из 244 (было 219, старые тесты в том числе)
  робот                124 из 124 (было 120)
  статанализ           0 настоящих замечаний
  полный прогон        4039 тестов, 3995 прошло, 20 упало

Из 20 падений 19 - те же давние, что были до работы. Двадцатое -
ExternalServiceDownAlertTest, в одиночку проходит 3 из 3 и вместе с телеграм-
тестами тоже; падает только в полном прогоне от накопленных данных. Это
известная слабость: у большинства файлов нет изоляции между тестами.

Заодно сборка тестовой БД переведена с migrate:fresh на связку
db:wipe --drop-types + migrate: первая спотыкалась на призрачном типе
legal_entities, вторая на тех же состояниях отрабатывала без отказов.

На бой не выкачено, переключатель TG_ROBOT_TRANSPORT остаётся в process.

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

188 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 {
// Двумя отдельными шагами, а НЕ одним 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')}')"
);
}
}
}
}