Files
portal/app/tests/Feature/Import/MonthlyPartitionManagerTest.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

127 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function partitionExists(string $name): bool
{
return DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
[$name],
) !== null;
}
// ---------------------------------------------------------------------------
// Existing tests (deals — business table, received_at key)
// ---------------------------------------------------------------------------
test('ensureRange создаёт месячные партиции deals под диапазон', function (): void {
$manager = app(MonthlyPartitionManager::class);
$created = $manager->ensureRange(
'deals',
Carbon::parse('2024-02-15'),
Carbon::parse('2024-04-03'),
);
expect($created)->toBeGreaterThanOrEqual(3)
->and(partitionExists('deals_y2024_m02'))->toBeTrue()
->and(partitionExists('deals_y2024_m03'))->toBeTrue()
->and(partitionExists('deals_y2024_m04'))->toBeTrue();
});
test('ensureRange идемпотентна — повторный вызов не падает', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20'));
$secondRun = $manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20'));
expect($secondRun)->toBe(0); // всё уже существует
});
test('ensureRange отвергает неизвестную таблицу', function (): void {
app(MonthlyPartitionManager::class)->ensureRange('orders', now(), now());
})->throws(InvalidArgumentException::class);
// ---------------------------------------------------------------------------
// Hole #2 tests: audit tables (created_at key)
// ---------------------------------------------------------------------------
test('ensureMonth создаёт партицию auth_log (created_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$month = Carbon::parse('2024-03-01');
$manager->ensureMonth('auth_log', $month);
expect(partitionExists('auth_log_y2024_m03'))->toBeTrue();
});
test('ensureMonth создаёт партицию activity_log (created_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('activity_log', Carbon::parse('2024-03-01'));
expect(partitionExists('activity_log_y2024_m03'))->toBeTrue();
});
test('ensureMonth создаёт партицию tenant_operations_log (created_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('tenant_operations_log', Carbon::parse('2024-03-01'));
expect(partitionExists('tenant_operations_log_y2024_m03'))->toBeTrue();
});
test('ensureMonth создаёт партицию webhook_log (received_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('webhook_log', Carbon::parse('2024-03-01'));
expect(partitionExists('webhook_log_y2024_m03'))->toBeTrue();
});
test('ensureMonth создаёт партицию balance_transactions (created_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('balance_transactions', Carbon::parse('2024-03-01'));
expect(partitionExists('balance_transactions_y2024_m03'))->toBeTrue();
});
test('ensureMonth создаёт партицию pd_processing_log (created_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('pd_processing_log', Carbon::parse('2024-03-01'));
expect(partitionExists('pd_processing_log_y2024_m03'))->toBeTrue();
});
test('ensureMonth создаёт партицию saas_admin_audit_log (created_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('saas_admin_audit_log', Carbon::parse('2024-03-01'));
expect(partitionExists('saas_admin_audit_log_y2024_m03'))->toBeTrue();
});
test('partitionName возвращает правильный формат', function (): void {
$manager = app(MonthlyPartitionManager::class);
expect($manager->partitionName('auth_log', Carbon::parse('2026-05-15')))
->toBe('auth_log_y2026_m05');
expect($manager->partitionName('deals', Carbon::parse('2024-01-01')))
->toBe('deals_y2024_m01');
});
test('listPartitions возвращает созданные партиции', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('auth_log', Carbon::parse('2024-04-01'));
$manager->ensureMonth('auth_log', Carbon::parse('2024-05-01'));
$partitions = $manager->listPartitions('auth_log');
expect($partitions)->toContain('auth_log_y2024_m04')
->toContain('auth_log_y2024_m05');
});