diff --git a/app/tests/Concerns/SharesSupplierPdo.php b/app/tests/Concerns/SharesSupplierPdo.php index 05624f87..06aeef68 100644 --- a/app/tests/Concerns/SharesSupplierPdo.php +++ b/app/tests/Concerns/SharesSupplierPdo.php @@ -30,7 +30,23 @@ trait SharesSupplierPdo } $defaultConnection = DB::connection('pgsql'); - DB::connection('pgsql_supplier')->setPdo($defaultConnection->getPdo()); - DB::connection('pgsql_supplier')->setReadPdo($defaultConnection->getReadPdo()); + $supplierConnection = DB::connection('pgsql_supplier'); + $supplierConnection->setPdo($defaultConnection->getPdo()); + $supplierConnection->setReadPdo($defaultConnection->getReadPdo()); + + // Шарим не только PDO, но и уровень вложенности транзакции. DatabaseTransactions + // уже открыл транзакцию на pgsql (этот же PDO) к моменту setUp этого трейта. + // Без синхронизации supplier-connection считает transactions=0 и при + // DB::connection('pgsql_supplier')->transaction() зовёт PDO->beginTransaction() + // на уже активной транзакции → PDOException "There is already an active transaction" + // (например AdminIncidentsController::notifyRkn). С синхронизацией вложенный + // transaction() делает SAVEPOINT внутри внешней транзакции (Postgres поддерживает), + // запись видна последующим чтениям и откатывается общим rollback'ом в teardown. + $level = $defaultConnection->transactionLevel(); + if ($level > 0) { + $prop = new \ReflectionProperty(\Illuminate\Database\Connection::class, 'transactions'); + $prop->setAccessible(true); + $prop->setValue($supplierConnection, $level); + } } } diff --git a/app/tests/Feature/AdminIncidentRknNotifyTest.php b/app/tests/Feature/AdminIncidentRknNotifyTest.php index 6aa66d9b..caea00f5 100644 --- a/app/tests/Feature/AdminIncidentRknNotifyTest.php +++ b/app/tests/Feature/AdminIncidentRknNotifyTest.php @@ -4,8 +4,13 @@ declare(strict_types=1); use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; uses(DatabaseTransactions::class); +// AdminIncidentsController читает incidents_log через DB::connection('pgsql_supplier'); +// под DatabaseTransactions записи дефолтного pgsql невидимы отдельному PDO supplier'а +// → notifyRkn() abort(404). Трейт шарит один PDO между коннектами (откат сохраняется). +uses(SharesSupplierPdo::class); beforeEach(function () { DB::table('incidents_log')->delete(); diff --git a/app/tests/Feature/AdminIncidentShowTest.php b/app/tests/Feature/AdminIncidentShowTest.php index 75c47e96..c271c450 100644 --- a/app/tests/Feature/AdminIncidentShowTest.php +++ b/app/tests/Feature/AdminIncidentShowTest.php @@ -4,8 +4,13 @@ declare(strict_types=1); use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; uses(DatabaseTransactions::class); +// AdminIncidentsController читает incidents_log через DB::connection('pgsql_supplier'); +// под DatabaseTransactions записи дефолтного pgsql невидимы отдельному PDO supplier'а +// → show()/notifyRkn() abort(404). Трейт шарит один PDO между коннектами (откат сохраняется). +uses(SharesSupplierPdo::class); beforeEach(function () { DB::table('incidents_log')->delete(); diff --git a/app/tests/Feature/Api/ProjectBulkActionsTest.php b/app/tests/Feature/Api/ProjectBulkActionsTest.php index e79e503e..b42b58cb 100644 --- a/app/tests/Feature/Api/ProjectBulkActionsTest.php +++ b/app/tests/Feature/Api/ProjectBulkActionsTest.php @@ -6,6 +6,8 @@ use App\Models\Project; use App\Models\Tenant; use App\Models\User; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + it('accepts update_regions action with subject-code arrays', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->for($tenant)->create(); diff --git a/app/tests/Feature/Audit/AuditChainRaceConditionTest.php b/app/tests/Feature/Audit/AuditChainRaceConditionTest.php index f3362d92..a676e152 100644 --- a/app/tests/Feature/Audit/AuditChainRaceConditionTest.php +++ b/app/tests/Feature/Audit/AuditChainRaceConditionTest.php @@ -42,6 +42,7 @@ it( DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); DB::table('activity_log')->insert([ 'tenant_id' => $tenant->id, + 'deal_id' => 1, // activity_log.deal_id NOT NULL (per-deal audit chain); без FK 'event' => 'deal.created', 'context' => json_encode(['worker' => $i]), 'created_at' => now(), @@ -97,6 +98,7 @@ it('audit_chain_hash holds pg_advisory_xact_lock on the partition OID during INS DB::transaction(function () use ($tenant, $lockKey, &$lockHeld): void { DB::table('activity_log')->insert([ 'tenant_id' => $tenant->id, + 'deal_id' => 1, // activity_log.deal_id NOT NULL (per-deal audit chain); без FK 'event' => 'deal.created', 'context' => json_encode(['test' => 'advisory_lock_check']), 'created_at' => now(), diff --git a/app/tests/Feature/Auth/AuthFlowIntegrationTest.php b/app/tests/Feature/Auth/AuthFlowIntegrationTest.php index 6ffe0160..c7006137 100644 --- a/app/tests/Feature/Auth/AuthFlowIntegrationTest.php +++ b/app/tests/Feature/Auth/AuthFlowIntegrationTest.php @@ -45,6 +45,7 @@ it('full auth-flow writes all expected auth_log events', function () { 'password' => 'secure-pass-1234', 'accept_offer' => true, 'accept_pdn' => true, + 'captcha_token' => 'tok-123', // RegisterRequest требует captcha_token (CAPTCHA_FAKE_PASSES в phpunit.xml пропускает) ])->assertStatus(201); // logs: register_success diff --git a/app/tests/Feature/Auth/AuthLogCoverageTest.php b/app/tests/Feature/Auth/AuthLogCoverageTest.php index 2a45d9f2..43abe622 100644 --- a/app/tests/Feature/Auth/AuthLogCoverageTest.php +++ b/app/tests/Feature/Auth/AuthLogCoverageTest.php @@ -47,6 +47,7 @@ it('register writes auth_log event=register_success', function () { 'password' => 'fresh-pass-123', 'accept_offer' => true, 'accept_pdn' => true, + 'captcha_token' => 'tok-123', // RegisterRequest требует captcha_token (CAPTCHA_FAKE_PASSES в phpunit.xml пропускает) ]); $response->assertStatus(201); diff --git a/app/tests/Feature/Auth/ConfirmSetsEmailVerifiedAtTest.php b/app/tests/Feature/Auth/ConfirmSetsEmailVerifiedAtTest.php index 653e57c4..4c41e2ae 100644 --- a/app/tests/Feature/Auth/ConfirmSetsEmailVerifiedAtTest.php +++ b/app/tests/Feature/Auth/ConfirmSetsEmailVerifiedAtTest.php @@ -10,6 +10,9 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); +uses(\Tests\Concerns\SharesSupplierPdo::class); + // FN-3 (приёмка 22.06.2026): онбординг-confirm активировал владельца, но НЕ ставил // users.email_verified_at — поле оставалось NULL, хотя email фактически подтверждён // кодом из письма. Контракт: успешный confirm проставляет email_verified_at. diff --git a/app/tests/Feature/Billing/BalanceFreezeMailTest.php b/app/tests/Feature/Billing/BalanceFreezeMailTest.php index e6151fd7..2435f18d 100644 --- a/app/tests/Feature/Billing/BalanceFreezeMailTest.php +++ b/app/tests/Feature/Billing/BalanceFreezeMailTest.php @@ -7,6 +7,8 @@ use App\Mail\BalanceUnfrozenMail; use App\Models\Tenant; use App\Services\Billing\PreflightResult; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + it('renders frozen mail with deficit', function () { $tenant = Tenant::factory()->create(['organization_name' => 'ООО Альфа']); $result = new PreflightResult(false, 30, 20, 10); diff --git a/app/tests/Feature/Console/IncidentsWatchFailuresTest.php b/app/tests/Feature/Console/IncidentsWatchFailuresTest.php index 715b431c..aa453efb 100644 --- a/app/tests/Feature/Console/IncidentsWatchFailuresTest.php +++ b/app/tests/Feature/Console/IncidentsWatchFailuresTest.php @@ -24,7 +24,12 @@ function makeFailedWebhookJob(string $exception, ?DateTimeInterface $at = null): // Helper: ensure at least one saas_admin_users row exists for FK function ensureSystemAdmin(): int { - $id = DB::table('saas_admin_users')->value('id'); + // ВАЖНО: фильтруем по контракту команды (IncidentsWatchFailures ищет + // is_active=true AND deleted_at IS NULL). Без фильтра протёкшие неактивные + // admin'ы от других тестов (PdErasureServiceTest вставляет is_active=false + // committed через pgsql_supplier) удовлетворяли бы value('id') → ранний + // return → активный admin не создавался → команда warn-only → 0 инцидентов. + $id = DB::table('saas_admin_users')->where('is_active', true)->whereNull('deleted_at')->value('id'); if ($id !== null) { return (int) $id; } diff --git a/app/tests/Feature/Console/SnapshotBackfillCommandTest.php b/app/tests/Feature/Console/SnapshotBackfillCommandTest.php index 0346600b..ef3bd3ee 100644 --- a/app/tests/Feature/Console/SnapshotBackfillCommandTest.php +++ b/app/tests/Feature/Console/SnapshotBackfillCommandTest.php @@ -5,6 +5,14 @@ declare(strict_types=1); use App\Models\Project; use App\Models\Tenant; use Carbon\Carbon; +use Illuminate\Foundation\Testing\DatabaseTransactions; +use Tests\Concerns\SharesSupplierPdo; + +// Без изоляции тест само-загрязнялся (проект теста 1 оставался для теста 2 → count=2) +// и протекал committed-проектами в глобальный скан snapshot:backfill для других тестов. +// SharesSupplierPdo — снапшот-вставка идёт через pgsql_supplier, проект создан через pgsql. +uses(DatabaseTransactions::class); +uses(SharesSupplierPdo::class); it('creates snapshot for given date from current live state', function () { Carbon::setTestNow('2026-05-27 14:00:00', 'Europe/Moscow'); diff --git a/app/tests/Feature/ImpersonationTest.php b/app/tests/Feature/ImpersonationTest.php index 87279041..dab091f7 100644 --- a/app/tests/Feature/ImpersonationTest.php +++ b/app/tests/Feature/ImpersonationTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Models\ImpersonationToken; use App\Models\Tenant; +use App\Models\User; use Carbon\Carbon; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; @@ -19,6 +20,9 @@ beforeEach(function () { $this->tenant = Tenant::factory()->create([ 'contact_email' => 'tenant-admin@example.ru', ]); + // verify() делает session-takeover активного юзера тенанта (G7-B 19.06.2026): + // без активного юзера контроллер отдаёт 422 «нет активного пользователя». + User::factory()->create(['tenant_id' => $this->tenant->id, 'is_active' => true]); // Минимальный saas_admin_user через DB::table — factory нет. $this->adminId = DB::table('saas_admin_users')->insertGetId([ 'email' => 'admin-saas@liderra.ru', diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php index 838c7578..b2831595 100644 --- a/app/tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php @@ -12,6 +12,7 @@ use App\Services\LeadDistributor; use App\Services\LeadRouter; use App\Services\NotificationService; use App\Services\RegionTagResolver; +use App\Services\MonthlyPartitionManager; use App\Services\SupplierProjects\SupplierProjectResolver; use Carbon\Carbon; use Database\Seeders\PricingTierSeeder; @@ -25,6 +26,13 @@ uses(SharesSupplierPdo::class); beforeEach(function (): void { $this->seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + // Тесты фиксируют Carbon на 2026-05-28; доставка пишет в несколько + // партиционированных таблиц (deals/balance_transactions/activity_log/ + // pd_processing_log/tenant_operations_log) → гарантируем партиции мая для всех. + $mpm = app(MonthlyPartitionManager::class); + foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) { + $mpm->ensureRange($table, Carbon::parse('2026-05-01'), Carbon::parse('2026-05-31')); + } }); function runSnapshotRouteJob(int $supplierLeadId): void diff --git a/app/tests/Feature/LeadRouter/SnapshotRoutingTest.php b/app/tests/Feature/LeadRouter/SnapshotRoutingTest.php index 94d0a82b..006e261d 100644 --- a/app/tests/Feature/LeadRouter/SnapshotRoutingTest.php +++ b/app/tests/Feature/LeadRouter/SnapshotRoutingTest.php @@ -8,6 +8,9 @@ use App\Models\Tenant; use App\Services\LeadRouter; use Carbon\Carbon; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); +uses(\Tests\Concerns\SharesSupplierPdo::class); + it('uses snapshot before 21:00 MSK, snapshot_date = today', function () { Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow'); diff --git a/app/tests/Feature/Pd/ImpersonationAuditTest.php b/app/tests/Feature/Pd/ImpersonationAuditTest.php index 114c8ae6..3218971c 100644 --- a/app/tests/Feature/Pd/ImpersonationAuditTest.php +++ b/app/tests/Feature/Pd/ImpersonationAuditTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Models\ImpersonationToken; use App\Models\Tenant; +use App\Models\User; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -13,6 +14,8 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class); beforeEach(function () { $this->tenant = Tenant::factory()->create(['contact_email' => 'tenant-admin@example.ru']); + // verify() делает session-takeover активного юзера тенанта (G7-B) → без него 422. + User::factory()->create(['tenant_id' => $this->tenant->id, 'is_active' => true]); $this->adminId = DB::table('saas_admin_users')->insertGetId([ 'email' => 'admin-saas-'.uniqid().'@liderra.ru', 'full_name' => 'SaaS Admin', diff --git a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php index ddfdef0f..235d60c3 100644 --- a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php +++ b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php @@ -59,7 +59,7 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE ]))->toThrow(QueryException::class); }); -it('schema.sql v8.35 has correct metrics — 66 base tables, 120 indexes, 40 RLS policies', function () { +it('schema.sql v8.52 has correct metrics — 72 base tables, 127 indexes, 44 RLS policies', function () { // Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные // Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` — // источник истины метрик. @@ -72,6 +72,10 @@ it('schema.sql v8.35 has correct metrics — 66 base tables, 120 indexes, 40 RLS // 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 и др.). + // Текущий статический парс db/schema.sql (канон метрик — заголовок schema.sql v8.52): + // 72 base tables, 127 индексов, 44 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); @@ -81,11 +85,11 @@ it('schema.sql v8.35 has correct metrics — 66 base tables, 120 indexes, 40 RLS $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(66); + expect($baseTables)->toBe(72); $createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema); - expect($createIndexes)->toBe(120); // v8.35: −5 индексов (webhook_log ×2, rejected_deals_log ×2, tenants.webhook_token ×1) + expect($createIndexes)->toBe(127); // v8.52 static parse $createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema); - expect($createPolicies)->toBe(40); // v8.35: −2 политики (webhook_log + rejected_deals_log) + expect($createPolicies)->toBe(44); // v8.52 static parse }); diff --git a/app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php b/app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php index 66030550..98d4c169 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php @@ -9,6 +9,8 @@ use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + beforeEach(fn () => Queue::fake()); it('destroy hard-deletes a project with no deals', function () { diff --git a/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php b/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php index 6955ceac..9e8f8dd8 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php @@ -6,6 +6,8 @@ use App\Models\Project; use App\Models\Tenant; use App\Models\User; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + it('returns paginated list of active projects for current tenant', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); diff --git a/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php b/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php index 84b9bd1b..f157938d 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php @@ -8,6 +8,8 @@ use App\Models\Tenant; use App\Models\User; use Illuminate\Support\Facades\Queue; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + beforeEach(fn () => Queue::fake()); it('creates a site project with valid payload', function () { @@ -56,17 +58,36 @@ it('creates a call project with valid 11-digit phone', function () { $response->assertCreated(); }); -it('rejects call signal_identifier not starting with 7', function () { +it('normalizes call phone starting with 8 to 7 (accepts)', function () { + // StoreProjectRequest::prepareForValidation() нормализует call-телефон + // через PhoneNormalizer (8XXXXXXXXXX → 7XXXXXXXXXX). UX-подсказка формы + // обещает «можно вводить с 8 — приведём сами», поэтому 8-префикс валиден. $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '89991234567', + 'name' => 'Колл 8-префикс', 'signal_type' => 'call', 'signal_identifier' => '89991234567', + 'daily_limit_target' => 30, 'regions' => [], + 'delivery_days_mask' => 127, + ]); + + $response->assertCreated(); + expect(Project::where('name', 'Колл 8-префикс')->value('signal_identifier'))->toBe('79991234567'); +}); + +it('rejects call signal_identifier that is not a valid RU phone', function () { + // Слишком короткий номер не нормализуется (PhoneNormalizer → null) → regex ^7\d{10}$ не проходит. + $tenant = Tenant::factory()->withRequisites()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '12345', 'daily_limit_target' => 30, 'regions' => [], 'delivery_days_mask' => 127, ]); $response->assertStatus(422); + $response->assertJsonValidationErrors(['signal_identifier']); }); it('creates sms project with senders + keyword', function () { diff --git a/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php b/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php index d583d565..ff916047 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php @@ -8,6 +8,8 @@ use App\Models\Tenant; use App\Models\User; use Illuminate\Support\Facades\Queue; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + beforeEach(fn () => Queue::fake()); it('updates name without resync (name is local-only)', function () { diff --git a/app/tests/Feature/Project/ProjectCreateDedupTest.php b/app/tests/Feature/Project/ProjectCreateDedupTest.php index 93a0e8d6..d7ceb141 100644 --- a/app/tests/Feature/Project/ProjectCreateDedupTest.php +++ b/app/tests/Feature/Project/ProjectCreateDedupTest.php @@ -8,6 +8,8 @@ use App\Services\Project\ProjectService; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Support\Facades\Queue; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + beforeEach(function () { Queue::fake(); $this->tenant = Tenant::factory()->create(['balance_leads' => 100]); diff --git a/app/tests/Feature/Project/ProjectDeleteTest.php b/app/tests/Feature/Project/ProjectDeleteTest.php index 4982c413..6327ec9b 100644 --- a/app/tests/Feature/Project/ProjectDeleteTest.php +++ b/app/tests/Feature/Project/ProjectDeleteTest.php @@ -9,6 +9,8 @@ use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + beforeEach(fn () => Queue::fake()); it('hard-deletes an empty project', function () { diff --git a/app/tests/Feature/Project/ProjectUpdateDedupTest.php b/app/tests/Feature/Project/ProjectUpdateDedupTest.php index 8599e8f1..22314146 100644 --- a/app/tests/Feature/Project/ProjectUpdateDedupTest.php +++ b/app/tests/Feature/Project/ProjectUpdateDedupTest.php @@ -64,6 +64,13 @@ it('changing source detaches old supplier_projects and dispatches their cleanup } $p->save(); + // SupplierSnapshotGuard блокирует смену источника на активном+связанном проекте + // (защита от убытка, пока поставщик может слать лиды по старому слепку — 26.05.2026). + // Чтобы сменить источник, проект надо снять с публикации и выждать grace; ставим + // paused_at в прошлое (вне grace), чтобы isProtected()=false и сработал detach-путь. + DB::table('projects')->where('id', $p->id)->update(['is_active' => false, 'paused_at' => now()->subDays(3)]); + $p->refresh(); + // Change source — should detach old sps, dispatch their cleanup, and dispatch the new-source sync. $svc->update($p, ['signal_identifier' => '79993330000']); diff --git a/app/tests/Feature/Requisites/ProjectGateTest.php b/app/tests/Feature/Requisites/ProjectGateTest.php index d57f1077..910048c8 100644 --- a/app/tests/Feature/Requisites/ProjectGateTest.php +++ b/app/tests/Feature/Requisites/ProjectGateTest.php @@ -6,6 +6,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Requisites\RequisitesService; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + /** @return array */ function validProjectPayload(): array { diff --git a/app/tests/Feature/Requisites/RequisitesHttpTest.php b/app/tests/Feature/Requisites/RequisitesHttpTest.php index 78461b2d..15662d29 100644 --- a/app/tests/Feature/Requisites/RequisitesHttpTest.php +++ b/app/tests/Feature/Requisites/RequisitesHttpTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); use App\Models\Tenant; use App\Models\User; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + it('GET requisites returns null when none', function () { $tenant = Tenant::factory()->create(); $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); diff --git a/app/tests/Feature/Requisites/RequisitesTest.php b/app/tests/Feature/Requisites/RequisitesTest.php index be06deb3..b5111ca8 100644 --- a/app/tests/Feature/Requisites/RequisitesTest.php +++ b/app/tests/Feature/Requisites/RequisitesTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); use App\Models\Tenant; use App\Services\Requisites\RequisitesService; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + it('upsert normalizes phone and is idempotent on tenant', function () { $tenant = Tenant::factory()->create(); $svc = new RequisitesService; diff --git a/app/tests/Feature/Requisites/TenantRequisitesLookupTest.php b/app/tests/Feature/Requisites/TenantRequisitesLookupTest.php index 55f582d3..ae400456 100644 --- a/app/tests/Feature/Requisites/TenantRequisitesLookupTest.php +++ b/app/tests/Feature/Requisites/TenantRequisitesLookupTest.php @@ -7,6 +7,8 @@ use App\Models\User; use App\Services\DaData\Dto\PartyLookupResult; use App\Services\DaData\PartyLookup; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + it('lookup-inn returns found:false when party lookup is null (default Null driver)', function () { $tenant = Tenant::factory()->create(); $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); diff --git a/app/tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php b/app/tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php index c602fbd9..f3c3f2e3 100644 --- a/app/tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php +++ b/app/tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php @@ -5,12 +5,25 @@ declare(strict_types=1); use App\Models\Project; use App\Models\SupplierProject; use App\Models\Tenant; +use App\Services\MonthlyPartitionManager; use App\Services\Project\ProjectService; use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class); +// ProjectService::update() пишет в tenant_operations_log (partition by created_at) +// через pgsql_supplier. Тесты фиксируют Carbon на 2026-05 → партиция мая нужна; +// SharesSupplierPdo держит DDL-коннект в той же откатываемой транзакции. +uses(DatabaseTransactions::class, SharesSupplierPdo::class); + +beforeEach(function () { + app(MonthlyPartitionManager::class)->ensureRange( + 'tenant_operations_log', + Carbon::parse('2026-05-01'), + Carbon::parse('2026-05-31'), + ); +}); it('returns applies_from when changing daily_limit_target before 18:00 MSK', function (): void { Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow'); diff --git a/app/tests/Feature/Supplier/AutoPauseFlowTest.php b/app/tests/Feature/Supplier/AutoPauseFlowTest.php index c0d2f31e..981023d8 100644 --- a/app/tests/Feature/Supplier/AutoPauseFlowTest.php +++ b/app/tests/Feature/Supplier/AutoPauseFlowTest.php @@ -49,6 +49,9 @@ function makeFlowWithBalance(array $balance): array 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($project, $supplierProject); + // LeadRouter::matchEligibleProjects() выбирает кандидатов ТОЛЬКО из project_routing_snapshots + // (slepok-инвариант). Без снапшота на сегодня кандидатов 0 → джоб не доходит до auto-pause. + createRoutingSnapshotFromProject($project, signalType: 'site', signalIdentifier: 'example.com'); $lead = SupplierLead::factory()->create([ 'vid' => random_int(100_000_000, 999_999_999), 'phone' => '79991234567', @@ -155,6 +158,7 @@ it('sharing-flow isolation: tenant A on zero paused, tenant B with balance recei 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($projectA, $supplierProject); + createRoutingSnapshotFromProject($projectA, signalType: 'site', signalIdentifier: 'example.com'); $projectB = Project::factory()->create([ 'tenant_id' => $tenantB->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com', 'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true, @@ -162,6 +166,7 @@ it('sharing-flow isolation: tenant A on zero paused, tenant B with balance recei 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($projectB, $supplierProject); + createRoutingSnapshotFromProject($projectB, signalType: 'site', signalIdentifier: 'example.com'); $lead = SupplierLead::factory()->create([ 'vid' => random_int(100_000_000, 999_999_999), 'phone' => '79991234567', diff --git a/app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php b/app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php index 187267ac..647c98f7 100644 --- a/app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php +++ b/app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php @@ -8,6 +8,9 @@ use App\Models\SupplierProject; use App\Models\Tenant; use App\Services\Supplier\SupplierPortalClient; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); +uses(\Tests\Concerns\SharesSupplierPdo::class); + it('does not mark inactive supplier_project that has pivot link to active project', function () { $tenant = Tenant::factory()->create(); $project = Project::factory()->for($tenant)->create([ diff --git a/app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php b/app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php index d07b72f8..7b090e4d 100644 --- a/app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php +++ b/app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php @@ -84,6 +84,9 @@ it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 cha ]); linkProjectToSupplier($pLow, $sp); linkProjectToSupplier($pHigh, $sp); + // LeadRouter выбирает кандидатов только из project_routing_snapshots (slepok-инвариант). + createRoutingSnapshotFromProject($pLow, signalType: 'site', signalIdentifier: 'twoproj.ru'); + createRoutingSnapshotFromProject($pHigh, signalType: 'site', signalIdentifier: 'twoproj.ru'); $vid = 600001; $lead = SupplierLead::factory()->create([ @@ -117,6 +120,7 @@ it('lock: re-running same delivery to same tenant does not double-charge (Spec B 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($p, $sp); + createRoutingSnapshotFromProject($p, signalType: 'site', signalIdentifier: 'lock.ru'); $vid = 610001; $lead = SupplierLead::factory()->create([ @@ -156,6 +160,7 @@ it('same phone, two different deliveries to one tenant → both charged (no phon 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($p, $sp); + createRoutingSnapshotFromProject($p, signalType: 'site', signalIdentifier: 'twohit.ru'); foreach ([700001, 700002] as $vid) { $lead = SupplierLead::factory()->create([ @@ -189,6 +194,7 @@ it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged (Spec B)' 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($p, $sp); + createRoutingSnapshotFromProject($p, signalType: 'site', signalIdentifier: 'cap3.ru'); } $vid = 710001; diff --git a/app/tests/Feature/Support/SupportRequestControllerTest.php b/app/tests/Feature/Support/SupportRequestControllerTest.php index 01db4397..1200a112 100644 --- a/app/tests/Feature/Support/SupportRequestControllerTest.php +++ b/app/tests/Feature/Support/SupportRequestControllerTest.php @@ -8,6 +8,8 @@ use App\Models\Tenant; use App\Models\User; use Illuminate\Support\Facades\Mail; +uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); + use function Pest\Laravel\actingAs; use function Pest\Laravel\postJson;