2b401298d8
Клиент отдаёт нам записи СВОИХ холодных разговоров. Это голоса живых людей, которые об этом не знают и согласия нам не давали. До этой правки принять их было негде и нечем: положить так, чтобы клиент А не открыл запись клиента Б, было некуда; срока жизни у записей не было; обращение к чужому голосу не оставляло следа. Что заведено: - таблица obzvon_materialy_klienta — канон в db/schema_modules.sql раздел 24, журнал схемы v9.70. Изоляция клиентов защитой по строкам: ENABLE + FORCE, политика с USING и WITH CHECK. DELETE не выдан никому — строка остаётся носителем надписи «удалены по сроку хранения»; - ДВА срока, а не один — решение владельца Р82: звук 30 дней, расшифровка шесть месяцев. Обе даты истечения NOT NULL: строка без даты означает голос, которого не найдёт ни одна чистка. Сами сроки — в настройках портала, не в коде; - два следа удаления врозь: команда чистки из З-2.2 обязана уметь стереть звук, не тронув текст. Два замка не дают строке врать «стёрто», пока файл жив; - закрытое хранилище obzvon_materialy: без ключа url и с serve=false, корень под storage/app/private. Публичной ссылки на чужой голос не появится даже по ошибке; - служба MaterialyKlientaService: приём с отклонением чужого формата и битого файла, открытие с явной сверкой клиента поверх защиты по строкам, счёт записей без нижней границы — три берём, двадцать берём, ноль пускаем вторым путём; - отказ MaterialNePrinyat отделяет наш сочинённый текст от дословного показания системы видимой границей. Настоящая причина не выбрасывается; - след в журнале ПДн pd_processing_log готовым сервисом PdAuditLogger — на приём и на каждое открытие. Стирает НЕ эта правка, а З-2.2, и она не построена. До её постройки материалы не исчезнут — это известно и так задумано. При накатке на боевой ОБЯЗАТЕЛЕН перезапуск db/03_service_bypass_policies.sql: новой RLS-таблице мало политики и прав, иначе служебная роль молча правит НОЛЬ строк. Тронут один чужой сторож: SchemaDeltaTest, счётчик таблиц канона модулей 42 в 43. Так же его правили соседи на З-1.1 и З-1.3 — счётчик обязан расти вместе с каноном, иначе прогон красный у всех. Осталось открытым: от какой даты считать полгода — считаем от загрузки, столбец recorded_at заведён под будущий ответ владельца. 24 сторожа, все 24 показаны красными двенадцатью ножами. Полный прогон 4954 проверки, 4950 зелёных, 4 пропущено, красных 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
9.5 KiB
PHP
140 lines
9.5 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, 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('канон модулей на месте и описывает 43 таблицы, которых нет в теле 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.
|
||
expect($createTables)->toBe(43);
|
||
// Тело 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');
|
||
});
|