Files
portal/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.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

91 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Tenant;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
// NOTE: \Tests\TestCase auto-binds via tests/Pest.php (->in('Feature')); explicit
// uses(\Tests\TestCase::class) conflicts ("already uses the test case").
// DatabaseTransactions — изоляция: каждый тест выполняется в транзакции, rollback после.
// Project convention: LeadChargeTest / PricingTierTest используют тот же паттерн.
uses(DatabaseTransactions::class);
it('tenants table has delivered_in_month column with CHECK >= 0', function () {
expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue();
DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op
expect(fn () => DB::statement(
'INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) '.
"VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)"
))->toThrow(QueryException::class);
});
it('lead_charges table has charge_source column with CHECK on prepaid=zero-price', function () {
expect(Schema::hasColumn('lead_charges', 'charge_source'))->toBeTrue();
$tenant = Tenant::factory()->create();
$deal = Deal::factory()->create(['tenant_id' => $tenant->id]);
expect(fn () => DB::table('lead_charges')->insert([
'tenant_id' => $tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'price_per_lead_kopecks' => 50000,
'charge_source' => 'prepaid',
'charged_at' => now(),
'created_at' => now(),
]))->toThrow(QueryException::class);
});
it('supplier_leads table has recovered_from_csv_at column', function () {
expect(Schema::hasColumn('supplier_leads', 'recovered_from_csv_at'))->toBeTrue();
});
it('supplier_csv_reconcile_log table exists with required columns and status CHECK', function () {
expect(Schema::hasTable('supplier_csv_reconcile_log'))->toBeTrue();
expect(Schema::hasColumns('supplier_csv_reconcile_log', [
'id', 'started_at', 'finished_at', 'window_start', 'window_end',
'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio',
'status', 'error_message', 'alert_email_sent_at', 'created_at',
]))->toBeTrue();
expect(fn () => DB::table('supplier_csv_reconcile_log')->insert([
'started_at' => now(),
'window_start' => now()->subDay(),
'window_end' => now(),
'status' => 'unknown_status',
]))->toThrow(QueryException::class);
});
it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.26.
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
// v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса.
// v8.26 (project-migration-redesign Plans 1-3): +1 таблица project_supplier_links (M:N pivot)
// + 2 индекса (supplier_projects_platform_key_subject_unique, idx_psl_*).
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
$schema = file_get_contents($schemaPath);
expect($schema)->not->toBeFalse();
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
//
// 67 base tables = все CREATE TABLE минус PARTITION OF.
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
$baseTables = $createTables - $partitionOf;
expect($baseTables)->toBe(67);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах
});