70f8b210f4
Sprint 4 Task 1 (schema delta §6): - H1: новая таблица import_unknown_statuses (RLS tenant_isolation, UNIQUE(tenant_id,status_ru), FK→tenants/import_log/lead_statuses/users) - H2: +5 колонок import_log (entity_type, source_system, mapping_config, unknown_statuses_count, dry_run) - schema.sql v8.20→v8.21 (64 таблицы / 118 индексов / 40 RLS-политик) - db/CHANGELOG_schema.md v8.21 entry - db/02_grants.sql v8.21 section (crm_app_user/crm_app_admin/crm_readonly) - migrate: hasTable/hasColumn guards (fresh-safe) - tests: 3 Pest-теста (ImportSchemaTest) + SchemaDeltaTest v8.21 metrics - ide-helper: _ide_helper.php + _ide_helper_models.php (были отсутствуют в worktree, phpstan падал молча из-за missing scanFiles entry) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
4.0 KiB
PHP
84 lines
4.0 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.21 has correct metrics — 63 base tables, 118 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.21.
|
|
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 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();
|
|
|
|
// 63 base tables = все CREATE TABLE минус 12 партиций (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(63);
|
|
|
|
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
|
expect($createIndexes)->toBe(118);
|
|
|
|
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
|
expect($createPolicies)->toBe(40);
|
|
});
|