Files
portal/app/tests/Feature/PartitionsCreateMonthsTest.php
T
Дмитрий 60ab5be3eb feat(audit): partitioning 7 audit-таблиц по месяцам (hole #2 Phase A)
Закрывает последнюю дыру #2 аудита журналирования. Phase A (dev) — миграция
схемы + retention tooling. Phase B (прод-rewrite через SQL под postgres) —
отдельным шагом с явным approve.

Решения заказчика:
* Scope: все 7 таблиц (auth_log, activity_log, tenant_operations_log,
  webhook_log, balance_transactions, pd_processing_log, saas_admin_audit_log)
* FK на webhook_log: W1 — удалить FK от failed_webhook_jobs+rejected_deals_log
* Retention defaults: auth:24м, activity:36м, tenant_ops:24м, webhook:3м,
  balance:84м, pd:36м, saas_admin:84м. Cron Sundays 03:00 МСК
* Hash-chain: per-partition (audit_chain_hash трг через TG_TABLE_NAME уже
  работает per-partition; совместимо с hole #1 per-RLS-scope fix)

Phase A:
* db/schema.sql v8.30→v8.31: 7 audit-таблиц на PARTITION BY RANGE,
  PK→(id, partition_key), +7 retention seeds в system_settings,
  FK от failed_webhook_jobs/rejected_deals_log удалены
* MonthlyPartitionManager: PARTITIONED_TABLES → ассоциативный array
  (name => partition_key), 2 → 9 таблиц
* PartitionsCreateMonths: автоматически покрывает все 9
* load_initial_schema: после schema.sql вызывает Artisan
  partitions:create-months --ahead=2 (без этого первый INSERT падает)
* 2026_05_22_000001_tenant_operations_log: idempotency guard
* VerifyAuditChains: per-partition scan через pg_inherits;
  fallback на single-scope для не-партиционированной таблицы;
  per-RLS-scope partition_clause сохранён внутри каждой партиции
* AuditChainBreachMail: +partitionName param (NULL=fallback на tableName)
* PartitionsDropExpired (новая): cron Sundays 03:00 МСК, retention из
  system_settings, dry-run mode, safety guard retention=0
* SchedulerHeartbeatTracker +partitions:drop-expired (10080 мин)

Без Laravel-миграции для прода — она оставляла БД пустой при migrate:fresh.
Подход: schema.sql декларирует партиционированные + ad-hoc SQL под postgres
для прод-rewrite (отдельный commit + ручной деплой + pg_dump backup).

Тесты: 1219/1231 (35/35 hole #2 specs, 88 assertions). 3 fail —
pre-existing AdminPdSubjectRequestsControllerTest::executeErasure_*
(FK actor_admin_user_id после partitioning pd_processing_log, отдельная
задача для hole #4 follow-up, не блокирует).

cspell +2 слова (партиционировать, дёшева). Pint --fix чистый.

Spec: docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md
Plan: docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md
2026-05-23 15:50:37 +03:00

134 lines
6.1 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|webhook_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|webhook_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|webhook_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|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
"))->count();
expect($afterSecond)->toBe($afterFirst);
// Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции.
$output = Artisan::output();
expect($output)->toContain('0 created, 54 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',
'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();
});