Files
portal/app/tests/Feature/PartitionsCreateMonthsTest.php
T
Дмитрий 6e1f5355b8 refactor(webhook): Phase 4 — DROP migration + schema v8.35 + test/factory cleanup
Task 4.1 Steps 1–7: legacy direct webhook channel DDL removal.

Migration 2026_05_24_140000_drop_legacy_webhook_artefacts:
- DROP TABLE webhook_log CASCADE (partitioned RANGE по received_at)
- DROP TABLE rejected_deals_log CASCADE
- ALTER TABLE tenants DROP COLUMN webhook_token, webhook_token_rotated_at
- DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'
NB: webhook_dedup_keys ОСТАВЛЕНА — используется CSV-каналом (HistoricalImportService).

Services fixed (не покрыты Phase 3):
- MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
- PdErasureService::eraseSubject() — убрана секция 4 (SELECT/UPDATE webhook_log)

Factory + tests cleanup (webhook_token column gone):
- TenantFactory: убрано webhook_token из definition()
- 7 test files: убраны вставки webhook_token в DB::table('tenants')->insert(...)
- storage/_demo_split_tenants.php: убрана строка webhook_token

Schema v8.35:
- −2 таблицы (webhook_log partitioned + rejected_deals_log)
- −5 индексов (idx_webhook_log_*, idx_rejected_*, idx_tenants_webhook_token)
- −2 RLS-политики
- db/CHANGELOG_schema.md: запись v8.35

Tests updated:
- SchemaDeltaTest: 66 base tables / 120 indexes / 40 RLS policies
- PartitionsCreateMonthsTest: webhook_log убрана из regex / 48 skipped вместо 54

Smoke: 36/36 passed (RlsSmoke, AdminBilling, AdminPdSubject, PartitionsCreateMonths, SchemaDelta).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:17 +03:00

134 lines
6.2 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 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» по всем 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();
});