Files
portal/app/tests/Feature/PartitionsCreateMonthsTest.php
T
Дмитрий 7feab849a6
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
fix(tests): партиция за прошлый месяц + возврат аудит-тестов, отставших от ADR-021
Разбор хвостов полного прогона: было 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>
2026-07-12 20:02:21 +03:00

157 lines
7.8 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
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();
});