toBe('pgsql_supplier'); }); test('SyncSupplierProjectJob declares DB_CONNECTION = pgsql_supplier (queue worker has no tenant GUC)', function (): void { // Дублирует RouteSupplierLeadJob: создание/правка проекта тоже запускается из очереди, // где SetTenantContext-прослойка не отработала. Под обычной ролью crm_app_user // SELECT по projects падает 42704 (unrecognized configuration parameter // "app.current_tenant_id"). Все DB-операции джоба обязаны идти через pgsql_supplier (BYPASSRLS). expect(SyncSupplierProjectJob::DB_CONNECTION)->toBe('pgsql_supplier'); }); test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier (BLOCKER #6)', function (): void { // Под обычной ролью policy tenant_isolation USING (tenant_id = current_setting('app.current_tenant_id')::bigint) // отвергает NULL (NULL :: bigint = NULL, NULL = '0'::bigint → NULL → false). // Под pgsql_supplier (BYPASSRLS на prod / postgres superuser на dev) INSERT проходит. DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([ 'tenant_id' => null, 'webhook_log_id' => null, 'raw_payload' => json_encode(['supplier_lead_id' => 42, 'project' => 'B1_test.ru']), 'exception' => 'simulated failure for BLOCKER #6 regression test', 'retry_count' => 3, 'failed_at' => now(), ]); $exists = DB::connection('pgsql_supplier') ->table('failed_webhook_jobs') ->whereNull('tenant_id') ->where('exception', 'simulated failure for BLOCKER #6 regression test') ->exists(); expect($exists)->toBeTrue(); }); test("LeadRouter видит проекты всех tenant'ов под pgsql_supplier без SET LOCAL (WARN #2)", function (): void { // 6 tenant × 1 проект = 6 проектов, все привязаны к одному supplier_project. // БЕЗ SET LOCAL app.current_tenant_id (он уже '0' из beforeEach) — под обычной // ролью RLS отбросил бы всё; под pgsql_supplier (BYPASSRLS) видны все 6. // NB: LeadRouter возвращает DISTINCT ON (tenant_id) — один проект на тенанта, // поэтому используем 6 тенантов × 1 проект чтобы expectation «6» оставалась. $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'plan3-task3-warn2.example.com', ]); $tenants = Tenant::factory()->count(6)->create(['balance_leads' => 100, 'balance_rub' => 500]); foreach ($tenants as $tenant) { $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'plan3-task3-warn2.example.com', 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, ]); DB::table('project_supplier_links')->insert([ 'project_id' => $project->id, 'supplier_project_id' => $supplier->id, 'platform' => $supplier->platform, 'subject_code' => $supplier->subject_code, ]); createRoutingSnapshotFromProject($project, null, 'site', 'plan3-task3-warn2.example.com', 10); } $router = app(LeadRouter::class); $eligible = $router->matchEligibleProjects($supplier); expect($eligible)->toHaveCount(6); }); test("ResetDeliveredTodayCommand сбрасывает delivered_today по всем tenant'ам (WARN #3)", function (): void { // Создаём 3 tenant'а с проектами, у каждого delivered_today=5. // Команда должна сбросить все 3 → 0 (под pgsql_supplier BYPASSRLS — без SET LOCAL). $tenants = Tenant::factory()->count(3)->create(); $projectIds = []; foreach ($tenants as $tenant) { $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'delivered_today' => 5, ]); $projectIds[] = $project->id; } // @phpstan-ignore-next-line method.notFound (Pest TestCall->artisan() mixin) $this->artisan('projects:reset-delivered-today')->assertExitCode(0); $remaining = DB::connection('pgsql_supplier') ->table('projects') ->whereIn('id', $projectIds) ->where('delivered_today', '>', 0) ->count(); expect($remaining)->toBe(0); });