Files
portal/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php
T

144 lines
9.8 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 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, delivered_in_month) '.
"VALUES ('t-neg-test', 'X', 'x@x', -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.66 has correct metrics — 82 base tables, 143 indexes, 48 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик.
// 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_*).
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
// v8.35 (legacy webhook removal): 2 таблицы (webhook_log partitioned + rejected_deals_log)
// −5 индексов, −2 RLS-политики, −2 колонки tenants.webhook_token/webhook_token_rotated_at.
// v8.36→v8.52: рост схемы (lead-region phone_ranges/lead_region_resolution_log,
// project_routing_snapshots, tenant_requisites, support_requests и др.).
// v8.54 (Эпик 4 online-defer): +1 таблица supplier_deferred_sync (SaaS-level, PK неявный, +0 явных индексов).
// v8.55 (Эпик 5 отчёт заливки): +1 таблица supplier_sync_runs + 1 индекс idx_supplier_sync_runs_created.
// Статический парс после v8.54/v8.55 был: 74 base tables, 128 индексов, 44 RLS-политики.
// v8.58→v8.62 (Автоподбор конкурентов, ветка worktree-avtopodbor): +4 base таблицы
// (autopodbor_runs/competitors/sources/merge_events) + 9 индексов (вкл. 2 UNIQUE dedup) +
// 4 RLS-политики tenant_isolation. Итог статического парса db/schema.sql (v8.62):
// 78 base tables, 137 индексов, 48 RLS-политик.
// v8.64 (учёт посетителей, spec 2026-07-13): +2 base таблицы (site_visitors, site_events)
// + 4 индекса (2 на гостей: first_seen/channel; 2 на события: visitor/event) + 0 RLS-политик
// (системные таблицы без tenant_id — доступ ролями, как supplier_order_checks).
// Итог статического парса db/schema.sql (v8.64): 80 base tables, 141 индекс, 48 RLS-политик.
// NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф — отдельный canon-sync,
// не предмет этого теста; тест сверяет фактический парс ФАЙЛА.
// v8.64 (ИИ-бот Jivo, 10.07.2026): +2 глобальные таблицы knowledge_chunks (база знаний,
// GIN search_tsv) и bot_dialogs (журнал диалогов) + 2 индекса, без RLS. Итог статического
// парса db/schema.sql (v8.64): 80 base tables, 139 индексов, 48 RLS-политик.
$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();
// 66 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;
// v8.66 (сведение веток): +2 таблицы бота (knowledge_chunks, bot_dialogs) и +2 таблицы
// учёта посетителей (site_visitors, site_events) к v8.62 (78).
expect($baseTables)->toBe(82);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(144); // v8.86: +1 idx_balance_tx_related (миграция 2026_07_23_100200)
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(48); // v8.62: +4 autopodbor RLS-политики к v8.52 (44)
});
// Канон разбит на два файла (v8.86, 02.08.2026): schema.sql ИСПОЛНЯЕТСЯ первой
// миграцией, schema_modules.sql только описывает то, что дописали дельта-миграции.
// Сторож следит, чтобы второй файл не потерялся и не опустел: без него канон
// снова знает лишь две трети базы, а молчаливая пропажа читается как «всё сошлось».
it('канон модулей на месте и описывает 44 таблицы, которых нет в теле schema.sql', function () {
$path = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema_modules.sql';
expect(is_readable($path))->toBeTrue();
$modules = file_get_contents($path);
$createTables = preg_match_all('/^CREATE TABLE\b/m', $modules);
// v9.68 (задача З-1.1, модуль обзвона): 39 → 41. Раздел 23 описывает две новые
// таблицы — obzvon_calls (одна попытка набора) и obzvon_number_results
// (единственный итог по номеру). Миграция 2026_08_05_100000_create_obzvon_calls.
// v9.69 (задача З-1.3, тариф обзвона): 41 → 42. Тот же раздел 23, таблица
// obzvon_tariffs — две цифры тарифа, строка ровно одна.
// Миграция 2026_08_05_110000_create_obzvon_tariffs.
// v9.70 (задача З-2.3, материалы клиента): 42 → 43. Новый раздел 24, таблица
// obzvon_materialy_klienta — записи, которые клиент отдаёт для обучения робота.
// Миграция 2026_08_05_120000_create_obzvon_materialy_klienta.
// v9.76 (задача З-4.1, кампания обзвона): 43 → 44. Тот же раздел 23, таблица
// obzvon_campaigns — сама кампания, которой у обзвона не было вовсе, хотя
// obzvon_calls и obzvon_number_results уже носили campaign_id.
// Миграция 2026_08_08_100000_create_obzvon_campaigns.
expect($createTables)->toBe(44);
// Тело schema.sql этих таблиц знать НЕ должно — иначе сборка с нуля столкнётся
// с дельта-миграциями, которые создают их же без стражей.
expect(file_get_contents(dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql'))
->not->toMatch('/^CREATE TABLE (ad_campaigns|sales_prospects|client_tg_campaigns)\b/m');
});