2026-05-08 15:46:41 +03:00
< ? php
declare ( strict_types = 1 );
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'
2026-05-24 17:23:49 +03:00
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} $ '
2026-05-08 15:46:41 +03:00
" )) -> pluck ( 'relname' ) -> all ();
});
afterEach ( function () {
$partitionsAfter = collect ( DB :: select ( "
SELECT relname FROM pg_class
WHERE relkind = 'r'
2026-05-24 17:23:49 +03:00
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} $ '
2026-05-08 15:46:41 +03:00
" )) -> pluck ( 'relname' ) -> all ();
2026-05-09 04:03:02 +03:00
// DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от
// webhook_dedup_keys → deals (parent partitioned table), и
// последующий ON DELETE CASCADE тест валится.
2026-05-23 15:50:37 +03:00
// Восстанавливаем имя parent-таблицы из имени партиции `<parent>_yYYYY_mMM`
2026-05-08 15:46:41 +03:00
foreach ( array_diff ( $partitionsAfter , $this -> partitionsBefore ) as $partition ) {
2026-05-23 15:50:37 +03:00
$parent = preg_replace ( '/_y?\d{4}_m?\d{2}$/' , '' , $partition );
2026-05-09 04:03:02 +03:00
DB :: statement ( " ALTER TABLE { $parent } DETACH PARTITION { $partition } " );
DB :: statement ( " DROP TABLE IF EXISTS { $partition } " );
2026-05-08 15:46:41 +03:00
}
});
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 );
2026-05-23 15:50:37 +03:00
$expectedDealName = 'deals_' . 'y' . $futureMonth -> format ( 'Y' ) . '_m' . $futureMonth -> format ( 'm' );
$expectedCostName = 'supplier_lead_costs_' . 'y' . $futureMonth -> format ( 'Y' ) . '_m' . $futureMonth -> format ( 'm' );
2026-05-08 15:46:41 +03:00
$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'
2026-05-24 17:23:49 +03:00
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} $ '
2026-05-08 15:46:41 +03:00
" )) -> 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'
2026-05-24 17:23:49 +03:00
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} $ '
2026-05-08 15:46:41 +03:00
" )) -> count ();
expect ( $afterSecond ) -> toBe ( $afterFirst );
2026-05-24 17:23:49 +03:00
// Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
2026-05-08 15:46:41 +03:00
$output = Artisan :: output ();
2026-05-24 17:23:49 +03:00
expect ( $output ) -> toContain ( '0 created, 48 skipped' );
2026-05-08 15:46:41 +03:00
});
test ( '--ahead=0 создаёт только текущий месяц' , function () {
Artisan :: call ( 'partitions:create-months' , [ '--ahead' => 0 ]);
$currentMonth = now () -> startOfMonth ();
2026-05-23 15:50:37 +03:00
$name = 'deals_y' . $currentMonth -> format ( 'Y' ) . '_m' . $currentMonth -> format ( 'm' );
2026-05-08 15:46:41 +03:00
$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 ();
});