Files
portal/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php
T
Дмитрий 2b401298d8 feat: приём записей клиента для обучения робота обзвона — З-2.3
Клиент отдаёт нам записи СВОИХ холодных разговоров. Это голоса живых людей,
которые об этом не знают и согласия нам не давали. До этой правки принять их
было негде и нечем: положить так, чтобы клиент А не открыл запись клиента Б,
было некуда; срока жизни у записей не было; обращение к чужому голосу не
оставляло следа.

Что заведено:

- таблица 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>
2026-08-05 20:35:21 +03:00

140 lines
9.5 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('канон модулей на месте и описывает 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');
});