Files
portal/app/tests/Feature/Supplier/SupplierConnectionTest.php
T
Дмитрий b29bfe2ac6 fix(supplier): SyncSupplierProjectJob → pgsql_supplier (BYPASSRLS) — иначе queue-воркер падает 42704
Джоб создания/правки проекта запускается из очереди, где SetTenantContext не
отрабатывает (нет app.current_tenant_id GUC). Под боевой ролью crm_app_user первый
же Project::find() падал SQLSTATE 42704 (unrecognized configuration parameter
app.current_tenant_id) за ~2мс — до контакта с поставщиком: проект у поставщика не
создавался, в UI вечный «Sync pending». На dev не всплывало (postgres superuser
обходит RLS). Единственный supplier-flow джоб, который был на дефолтном подключении.

Фикс: const DB_CONNECTION = 'pgsql_supplier' + все DB-операции через ::on()/
DB::connection() — как у SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob.

Тесты: SupplierConnectionTest +constant-assert; SyncSupplierProjectJobTest
+поведенческий connection-assert (DB::listen → projects-запросы на pgsql_supplier);
Plan5/SyncSupplierProjectJobTest +SharesSupplierPdo (джоб теперь пишет через
pgsql_supplier → нужен shared PDO под DatabaseTransactions).

Проверено вживую на тест-сервере: проекты 14/15 синхронизированы, 6 доноров у
crm.bp-gr.ru (12742042-44 / 12766120-22), aggregateSyncStatus=ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:49:59 +03:00

142 lines
7.0 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);
/**
* Plan 3 Task 3 — supplier-flow на pgsql_supplier (BYPASSRLS) connection.
*
* 4 regression-теста, закрывающих 3 backlog-айтема Plan 2.6:
* 1. RouteSupplierLeadJob $connection = 'pgsql_supplier' (sanity-check, что свойство выставлено).
* 2. BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier
* (политика tenant_isolation отвергает NULL под обычной ролью; BYPASSRLS обходит).
* 3. WARN #2: LeadRouter::matchEligibleProjects видит проекты ВСЕХ tenant'ов
* без SET LOCAL app.current_tenant_id (sharing-model).
* 4. WARN #3: ResetDeliveredTodayCommand сбрасывает delivered_today по всем tenant'ам.
*
* Spec: docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
*
* NB: На dev DB_USERNAME=postgres (superuser, BYPASSRLS implicit) — fallback в
* config/database.php pgsql_supplier берёт DB_USERNAME, тесты работают без отдельной
* роли. На production env-keys DB_SUPPLIER_USERNAME/PASSWORD указывают на crm_supplier_worker.
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
// Симулируем условия supplier-flow на queue worker'е: tenant ещё не определён.
// set_config('app.current_tenant_id', '0', true) — session-scoped + откатывается транзакцией.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
test('RouteSupplierLeadJob declares DB_CONNECTION = pgsql_supplier (Plan 3 Task 3)', function (): void {
// Job's $connection из Bus\Queueable управляет очередью (sync/database/redis), не БД.
// Для DB-операций используется константа DB_CONNECTION — failed() callback пишет
// в failed_webhook_jobs через DB::connection(RouteSupplierLeadJob::DB_CONNECTION).
// Закрывает BLOCKER #6: INSERT с tenant_id=NULL проходит под BYPASSRLS-роль.
expect(RouteSupplierLeadJob::DB_CONNECTION)->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 {
// 3 tenant × 2 проекта = 6 проектов, все привязаны к одному supplier_project.
// БЕЗ SET LOCAL app.current_tenant_id (он уже '0' из beforeEach) — под обычной
// ролью RLS отбросил бы всё; под pgsql_supplier (BYPASSRLS) видны все 6.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'plan3-task3-warn2.example.com',
]);
$tenants = Tenant::factory()->count(3)->create(['balance_leads' => 100]);
foreach ($tenants as $tenant) {
for ($i = 0; $i < 2; $i++) {
$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,
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
'subject_code' => $supplier->subject_code,
]);
}
}
$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);
});