7feab849a6
Разбор хвостов полного прогона: было 2407/2418, стало 2417/2418 — единственное
оставшееся падение ExampleTest воспроизводится только там, где не собран фронт
Vite manifest not found, к коду отношения не имеет.
1. partitions:create-months — новый флаг --behind, по умолчанию 1.
Нарезалось только «текущий месяц и вперёд», поэтому на свежей базе партиции за
прошлый месяц не было НИКОГДА. Между тем строки с датой в прошлом месяце — штатное
явление: тридцатидневное окно списаний метрика runway и вставки на стыке месяцев.
На боевом не всплывало — там партиции копятся с самого начала, проверено: нарезаны
до января 2027, крон жив. А на свежей базе и в CI INSERT падал «no partition found»
в первой половине КАЖДОГО месяца. Ловилось двумя тестами AdminTenantShowTest.runway
и роутингом лида.
2. Возвращены три аудит-теста, потерянных при сведении main 09.07.
ADR-021 сделал цепочки журналов общими и AuditChainConfig в main это уже отражает,
а тесты остались на старом per-tenant поведении ADR-018 и падали. Правильные версии
лежали на ветке fix/audit-chain-global-scope, но в main не доехали — тот же класс
потери, что и CsvReconcileJobTest.
Тесты: полный прогон 2417/2418 зелёный, Pint чисто.
Известное и НЕ тронутое, вынесено в отдельный список:
- Larastan на main даёт 30 замечаний уровня типов, ни одного живого бага:
мёртвые ветки в AuditRebuildChain, оставшиеся от ADR-021; ресурсы автоподбора без
аннотации модели; ProjectController читает виртуальные пометки gate_payload и
launch_deferred, которых нет в таблице.
- Тест лимита входов флакует только в длинном прогоне, в одиночку зелёный.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
7.8 KiB
PHP
157 lines
7.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Services\MonthlyPartitionManager;
|
||
use Illuminate\Support\Facades\Artisan;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Тесты Artisan-команды `partitions:create-months` (замена pg_partman).
|
||
*
|
||
* NB: команда создаёт партиции через DDL (CREATE TABLE) — это глобальное
|
||
* действие, не Rolled Back trait'ом DatabaseTransactions. Поэтому каждый
|
||
* тест чистит за собой созданные партиции вручную (DROP TABLE IF EXISTS).
|
||
*/
|
||
beforeEach(function () {
|
||
// Снимок партиций ДО теста для cleanup.
|
||
$this->partitionsBefore = collect(DB::select("
|
||
SELECT relname FROM pg_class
|
||
WHERE relkind = 'r'
|
||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||
"))->pluck('relname')->all();
|
||
});
|
||
|
||
afterEach(function () {
|
||
$partitionsAfter = collect(DB::select("
|
||
SELECT relname FROM pg_class
|
||
WHERE relkind = 'r'
|
||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||
"))->pluck('relname')->all();
|
||
|
||
// DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от
|
||
// webhook_dedup_keys → deals (parent partitioned table), и
|
||
// последующий ON DELETE CASCADE тест валится.
|
||
// Восстанавливаем имя parent-таблицы из имени партиции `<parent>_yYYYY_mMM`
|
||
foreach (array_diff($partitionsAfter, $this->partitionsBefore) as $partition) {
|
||
$parent = preg_replace('/_y?\d{4}_m?\d{2}$/', '', $partition);
|
||
DB::statement("ALTER TABLE {$parent} DETACH PARTITION {$partition}");
|
||
DB::statement("DROP TABLE IF EXISTS {$partition}");
|
||
}
|
||
});
|
||
|
||
test('создаёт партиции на N месяцев вперёд для deals и supplier_lead_costs', function () {
|
||
$exitCode = Artisan::call('partitions:create-months', ['--ahead' => 8]);
|
||
|
||
expect($exitCode)->toBe(0);
|
||
|
||
// Должны быть партиции до текущий+8 месяцев включительно.
|
||
$futureMonth = now()->startOfMonth()->addMonths(8);
|
||
$expectedDealName = 'deals_'.'y'.$futureMonth->format('Y').'_m'.$futureMonth->format('m');
|
||
$expectedCostName = 'supplier_lead_costs_'.'y'.$futureMonth->format('Y').'_m'.$futureMonth->format('m');
|
||
|
||
$row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$expectedDealName]);
|
||
expect($row)->not->toBeNull();
|
||
|
||
$row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$expectedCostName]);
|
||
expect($row)->not->toBeNull();
|
||
});
|
||
|
||
test('идемпотентность: повторный запуск не падает и не создаёт дубли', function () {
|
||
Artisan::call('partitions:create-months', ['--ahead' => 5]);
|
||
$afterFirst = collect(DB::select("
|
||
SELECT relname FROM pg_class
|
||
WHERE relkind = 'r'
|
||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||
"))->count();
|
||
|
||
// Повторный запуск — должен только skip'ать.
|
||
$exitCode = Artisan::call('partitions:create-months', ['--ahead' => 5]);
|
||
expect($exitCode)->toBe(0);
|
||
|
||
$afterSecond = collect(DB::select("
|
||
SELECT relname FROM pg_class
|
||
WHERE relkind = 'r'
|
||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||
"))->count();
|
||
|
||
expect($afterSecond)->toBe($afterFirst);
|
||
|
||
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 7 месяцев
|
||
// (behind=1 + текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается
|
||
// при добавлении новых.
|
||
$expectedSkipped = count(MonthlyPartitionManager::PARTITIONED_TABLES) * 7;
|
||
$output = Artisan::output();
|
||
expect($output)->toContain("0 created, {$expectedSkipped} skipped");
|
||
});
|
||
|
||
/*
|
||
* Прошлый месяц (12.07.2026). Нарезчик делал только «текущий + вперёд», поэтому на свежей
|
||
* базе партиции за прошлый месяц не было НИКОГДА. А система штатно пишет строки с датой в
|
||
* прошлом месяце: тридцатидневное окно списаний (метрика runway) и вставки на стыке месяцев
|
||
* (джоб стартовал 31-го в 23:59, вставил после полуночи с ранее взятым временем). На проде
|
||
* это не всплывало — там партиции копятся с самого начала; падало в тестах в первой половине
|
||
* каждого месяца и упало бы в CI.
|
||
*/
|
||
test('создаёт партицию за ПРОШЛЫЙ месяц — окно последних 30 дней всегда в него попадает', function () {
|
||
$exitCode = Artisan::call('partitions:create-months');
|
||
|
||
expect($exitCode)->toBe(0);
|
||
|
||
$prevMonth = now()->startOfMonth()->subMonth();
|
||
$name = 'balance_transactions_y'.$prevMonth->format('Y').'_m'.$prevMonth->format('m');
|
||
|
||
$row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$name]);
|
||
expect($row)->not->toBeNull();
|
||
});
|
||
|
||
test('--ahead=0 --behind=0 создаёт только текущий месяц', function () {
|
||
Artisan::call('partitions:create-months', ['--ahead' => 0, '--behind' => 0]);
|
||
|
||
$currentMonth = now()->startOfMonth();
|
||
$name = 'deals_y'.$currentMonth->format('Y').'_m'.$currentMonth->format('m');
|
||
|
||
$row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$name]);
|
||
expect($row)->not->toBeNull();
|
||
});
|
||
|
||
test('партиция корректно принимает INSERT в окно своего месяца', function () {
|
||
Artisan::call('partitions:create-months', ['--ahead' => 8]);
|
||
|
||
// Проверяем, что INSERT в deals с received_at в новой партиции работает.
|
||
$futureMonth = now()->startOfMonth()->addMonths(8);
|
||
$tenantId = DB::table('tenants')->insertGetId([
|
||
'subdomain' => 'partition-test-'.uniqid(),
|
||
'organization_name' => 'PartitionTest',
|
||
'contact_email' => 'pt@test.local',
|
||
'api_key_limit' => 5,
|
||
]);
|
||
$projectId = DB::table('projects')->insertGetId([
|
||
'tenant_id' => $tenantId,
|
||
'name' => 'PartitionProject',
|
||
'type' => 'webhook',
|
||
'is_active' => true,
|
||
'daily_limit_target' => 10,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
'delivery_days_mask' => 127,
|
||
'assignment_strategy' => 'manual',
|
||
'ttfr_target_minutes' => 15,
|
||
]);
|
||
|
||
$dealId = DB::table('deals')->insertGetId([
|
||
'tenant_id' => $tenantId,
|
||
'project_id' => $projectId,
|
||
'phone' => '79001234567',
|
||
'status' => 'new',
|
||
'received_at' => $futureMonth->copy()->addDays(5),
|
||
]);
|
||
|
||
expect($dealId)->toBeInt();
|
||
|
||
// Cleanup (за пределами trait DatabaseTransactions, т.к. INSERT через DB::table в новой партиции).
|
||
DB::table('deals')->where('id', $dealId)->delete();
|
||
DB::table('projects')->where('id', $projectId)->delete();
|
||
DB::table('tenants')->where('id', $tenantId)->delete();
|
||
});
|