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-таблицы из имени партиции `_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» по всем 8 таблицам × 6 месяцев = 48 партиций. // (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts) $output = Artisan::output(); expect($output)->toContain('0 created, 48 skipped'); }); test('--ahead=0 создаёт только текущий месяц', function () { Artisan::call('partitions:create-months', ['--ahead' => 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(); });