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'
|
|
|
|
|
|
AND relname ~ '^(deals|supplier_lead_costs)_[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)_[0-9]{4}_[0-9]{2}$'
|
|
|
|
|
|
"))->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-08 15:46:41 +03:00
|
|
|
|
foreach (array_diff($partitionsAfter, $this->partitionsBefore) as $partition) {
|
2026-05-09 04:03:02 +03:00
|
|
|
|
$parent = str_starts_with($partition, 'deals_') ? 'deals' : 'supplier_lead_costs';
|
|
|
|
|
|
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);
|
|
|
|
|
|
$expectedDealName = 'deals_'.$futureMonth->format('Y_m');
|
|
|
|
|
|
$expectedCostName = 'supplier_lead_costs_'.$futureMonth->format('Y_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)_[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)_[0-9]{4}_[0-9]{2}$'
|
|
|
|
|
|
"))->count();
|
|
|
|
|
|
|
|
|
|
|
|
expect($afterSecond)->toBe($afterFirst);
|
|
|
|
|
|
|
|
|
|
|
|
// Output второго запуска должен говорить «skipped» по всем 12 партициям (6 мес × 2 табл).
|
|
|
|
|
|
$output = Artisan::output();
|
|
|
|
|
|
expect($output)->toContain('0 created, 12 skipped');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('--ahead=0 создаёт только текущий месяц', function () {
|
|
|
|
|
|
Artisan::call('partitions:create-months', ['--ahead' => 0]);
|
|
|
|
|
|
|
|
|
|
|
|
$currentMonth = now()->startOfMonth();
|
|
|
|
|
|
$name = 'deals_'.$currentMonth->format('Y_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',
|
|
|
|
|
|
'webhook_token' => str_repeat('p', 64),
|
|
|
|
|
|
'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();
|
|
|
|
|
|
});
|