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(); // 65 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(65); $createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema); expect($createIndexes)->toBe(123); // v8.26: +2 supplier_projects_platform_key_subject_unique, idx_psl_* $createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema); expect($createPolicies)->toBe(40); });